Skip to content

Repository files navigation

vkey — a hardware key for TOTP codes, passwords and .env files

Your project's .env and your TOTP seeds on a $6 board instead of on your disk and in your phone. An open-source USB-C key on a Waveshare ESP32-C6-Zero: bare-metal Rust firmware, a single-binary host CLI, and a press of the board's button for every secret that comes out.

Status: a personal key, not a product (2026-09-07). It runs on real hardware, its TOTP is checked against the RFC 6238 vectors, and the author uses it for his own accounts. No sales, no batch, no certification. Read docs/threat-model.md before relying on it, and keep the recovery codes your services give you.

In ten seconds

$ vkey totp add GitHub:me
stored 'GitHub:me' - codes need a tap on the button

$ vkey get GitHub:me
tap the BOOT button on the board...
734128   (17s left)  copied
$ vkey env add myapp .env
stored 'myapp' - it comes back whole after a tap:  vkey get myapp

$ rm .env                      # the project's secrets are off the disk now

$ env $(vkey get myapp) npm start
tap the BOOT button on the board...

Nothing came back without a press, and the TOTP secret behind that code has no way out of the device at all — there is no command for it.

What it is

  • TOTP secrets, passwords (login, password, note) and whole project .env files, encrypted with AES-256-GCM.
  • The key comes from an 8-digit PIN through Argon2id (128 KiB) and an HMAC key burned into eFuse, so a flash dump without the chip is useless. Eight digits and not fewer: whoever holds the board can erase the attempt counter through download mode and guess through the firmware at ~1.3 s a try, which is four years for eight digits and a fortnight for six. JTAG is disabled; Secure Boot v2 (RSA-3072) is on; Flash Encryption is deliberately not used.
  • Every code, password and .env needs a button gesture. Eight wrong PINs wipe everything.
  • A TOTP secret goes in once and never comes out in the clear — there is no command for it. The one way out is vkey backup, which reseals every item under a backup passphrase.
  • Everything, list included, needs the PIN — except a vkey auth login, a tap alone; the key re-locks after two idle minutes. Secrets and PINs are never arguments or environment variables — hidden prompt or stdin.

What it does not do

Read docs/threat-model.md before you depend on this.

  • Nothing against phishing. A TOTP code works on a fake site just as on the real one; that is a property of TOTP. WebAuthn resists it, and this hardware cannot: the C6's USB is a fixed Serial/JTAG port with no HID.
  • Nothing for the code or password you just received — it crosses the host: terminal, optionally the clipboard. The clipboard is cleared after 30 s, but a clipboard manager has already kept its own copy, and the reveal stays in the terminal's scrollback until the window closes. Details in the threat model.
  • ESP32 is not a secure element. Against power glitching and side channels it does not hold; a public Secure Boot glitch bypass for ESP32-C3/C6 exists (Espressif AR2023-007). Download mode stays open on purpose: anyone holding the board can rewrite the flash.
  • A backup is only the .vkb file you made: with the passphrase it is every secret, without it the file opens nowhere.
  • No NFC, no phone — USB-C to a computer only. Against a state actor, or someone with a soldering iron, buy a certified key with a secure element instead.

Install

One static binary, no Python and no ESP-IDF; the firmware image is embedded in it.

curl -fsSL https://github.com/vaulttec-dev/vaulttec-key/releases/latest/download/install.sh | sh

x86_64 Linux. The script checks the download against the published SHA256SUMS and refuses to install on a mismatch, needs no root, and writes only ~/.local/bin/vkey. Read it first — you should, for any piped installer.

From source, which needs only Rust (rustup):

git clone https://github.com/vaulttec-dev/vaulttec-key.git
cd vaulttec-key/cli && cargo build --release && ./target/release/vkey install

Either way, put ~/.local/bin on your PATH and run sudo usermod -aG dialout $USER, then log back in — without that group the serial port will not open.

Quick start

Plug a bare board in over USB-C and run vkey setup: it flashes the firmware, asks for a PIN and runs the self-test. vkey alone opens the shell, where an entry's name is a command: github + Enter gives a code after a tap; mail + Enter gives the login at once and the password after a tap. Empty Enter opens the entries as a menu with three tabs — TOTP, Passwords, ENV — where a adds, e edits, d deletes and i imports.

vkey totp add GitHub:me               # asks for the otpauth URI or base32; or: echo SECRET | vkey totp add name
vkey pass add mail --login me@example.com
vkey env add myapp .env               # or: wl-paste | vkey env add myapp
vkey get GitHub:me                    # a code; vkey get mail [--copy] for a login and password
vkey get myapp > .env                 # better without a file:  env $(vkey get myapp) npm start
vkey list · rm · info · lock · pin set/change · totp selftest · wipe
vkey op [query]                       # mirror 1Password (passwords, TOTP, notes, developer .env)
vkey import passwords.csv             # Google Password Manager or 1Password CSV export (skips unchanged)
vkey backup vault.vkb                 # everything in one file, sealed on the key; two taps
vkey restore [file]                   # restore from 1Password, CSV, or .vkb backup file
vkey check --wipe-everything          # lifecycle test on a board; ERASES EVERYTHING
sudo vkey auth enable                 # sudo and the lock screen with a tap (below)

A .env is stored as is and comes out whole after one tap; only KEY=value lines are accepted, checked by the CLI. A CSV export is plaintext on disk before and after vkey import, and the CLI does not delete it. vkey op syncs directly with the 1Password CLI (op) without plaintext files on disk, automatically skipping unchanged items.

A sync mirrors its source both ways: an entry the source no longer offers is deleted from the key, and the deletion is printed. It reaches only what that same source owns — which source each entry came from is remembered in ~/.config/vaultkey/sources.json — so a 1Password sync never touches what a CSV brought, and an entry under a name no source offers is left alone. Narrowing the run with --tag, --vault or a single item makes it a selection rather than a mirror, and a selection only adds.

sudo and the lock screen with a tap

With the key plugged in, sudo and the lock screen take a tap instead of your password; without it, or with no tap for ten seconds, they ask for the password as always. The key signs a fresh challenge with a secret that never leaves it; the host keeps only its public key. It needs no PIN, so a stolen laptop with the key still in it opens with a tap — read the trade-off first.

sudo vkey auth enable     # PIN, then a tap: sudo and the lock screen are set up
sudo vkey auth disable    # back to the password alone

enable copies vkey to /usr/local/bin, and adds one line to the PAM files of sudo and of the lock screens this host has — COSMIC, GNOME, KDE, sway, Hyprland — keeping each original as *.before-vkey. A file it does not recognise it leaves alone and prints the line to add by hand.

Gestures and capacity

Gesture LED What it releases
Tap amber one TOTP code, one password, one .env, or one vkey auth login
Hold 5 s red factory wipe: every secret and the PIN
Double tap blue the encrypted backup file

A tap never wipes and never exports, so a hostile host cannot swap a code request for a wipe or an export: the gesture the owner makes for one satisfies neither of the others.

Limit
Entries (TOTP secrets and passwords together) 256 slots
.env blobs 16, up to 8000 bytes each
Entry name 32 bytes; names are one namespace
Login, password, note 255 bytes each, 256 per entry in total
PIN exactly 8 digits, 8 attempts
Backup passphrase 12–128 characters; five or six random words, not an invented one

Build

Upgrading a key whose PIN is shorter than eight digits: change the PIN first. From version 0.9 a PIN is exactly eight digits, checked before anything is sent, so a key holding a six- or seven-digit PIN will not unlock under the new firmware — and a vault that does not unlock is a vault that is gone. With the currently installed vkey, run vkey pin change to an eight-digit PIN, then build and flash. A short PIN costs no attempt and cannot wipe the key; it simply never opens it.

rustup target add riscv32imac-unknown-none-elf && cargo install espflash   # once
./tools/build-vaultkey.sh                     # every board, no-radio check, images into the CLI
./tools/sign-vaultkey.sh                      # Secure Boot: sign the images (PIN and a tap)
(cd cli && cargo build --release) && cli/target/release/vkey install && vkey setup
./tools/build-bootloader.sh                   # only when firmware/boards/*/bootloader/ changes (Docker)

The signing key lives on the key itself, as the .env entry vkey-signing. Acceptance after any firmware change: vkey totp selftest green — one tap, destroys nothing. Before a release and on every new board also vkey check --wipe-everything, which erases the device, so run that one on an empty board and never on a key holding secrets. A new board is a folder firmware/boards/<name>/ with its pins, chip crates and board.toml; the CLI picks it up at build time. Git hooks in .githooks/ run fmt, clippy (pedantic as errors), tests, shellcheck, the no-radio and wording checks, and prove the committed image equals a fresh build.

CI runs the same set on every pull request, plus cargo audit weekly, a spell check, actionlint and zizmor over the workflows themselves. The job worth knowing about is images: it rebuilds the firmware from source, fails if the committed binary differs by a byte, and verifies both signed images against firmware/secure-boot/public.pem — no secret involved, so anyone can repeat it. That is the only honest reason to trust a firmware image published as a binary.

How it works

flowchart LR
    vkey["vkey CLI (host)<br/>subcommands · shell · flashing"] --- w1["wire.rs"]
    w1 <-->|"VTC2 frames over USB Serial/JTAG"| w2["wire.rs"]
    subgraph core["firmware/core — no chip name"]
        w2 --- proto["proto — frames"] --> devi["device — PIN, entries"]
        devi --> vault["vault — Argon2id, AES-256-GCM"] & store["store — flash, A/B image"] & oath["oath — HOTP / TOTP"]
    end
    board["firmware/boards/&lt;board&gt;/ — pins, board.toml"]
    board -->|"four traits: flash · entropy · clock · button+LED"| core
Loading

wire.rs is one file compiled on both ends of the cable: a command, error code or flag that is not in it exists on neither side. The device has no clock — the host sends the time.

Layout

Path What
firmware/core/ the key itself: protocol, PIN, crypto, storage, TOTP — no chip name
firmware/boards/<board>/ one folder per board: pins, chip crates, board.toml, images
cli/ the vkey command: subcommands, shell, flashing (espflash as a library)
tools/ build, signing and check scripts
Document About
docs/threat-model.md what it protects against and what it does not. Read first
docs/hardware.md the C6-Zero board: measured pins, traps, bringing up a new one

Security and licence

Report vulnerabilities privately — see SECURITY.md. Please do not open a public issue for anything in the crypto, PIN handling, storage or host protocol.

Licensed under Apache-2.0.

About

Open-source USB-C hardware key for TOTP codes, passwords and .env files. Bare-metal Rust on ESP32-C6, secrets encrypted on-device, every use needs a button press.

Topics

Resources

Security policy

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages