Skip to content
Merged
Show file tree
Hide file tree
Changes from 30 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
f9d9b1c
Add the nRF52840 MDK dongle as the primary board
vaulttec-dev Oct 13, 2026
ad4d087
Build the nRF52840 UF2 bootloader baseline in Docker
vaulttec-dev Oct 13, 2026
0b8dee3
Strip the UF2 bootloader fork down to the drive
vaulttec-dev Oct 13, 2026
a8b50cd
Sign every image the UF2 bootloader fork runs or copies
vaulttec-dev Oct 14, 2026
b102168
Make the nRF52840 the key and the C6 a test board
vaulttec-dev Oct 14, 2026
1197753
Test the UF2 bootloader fork on the board as an app
vaulttec-dev Oct 14, 2026
36d3614
Add tools for the UF2 bootloader fork's real signing key
vaulttec-dev Oct 14, 2026
2ba479c
Add the nRF52840 bootloader fork's real signing key
vaulttec-dev Oct 14, 2026
9e51076
Check the nRF52840's MBR and factory bootloader before the install
vaulttec-dev Oct 14, 2026
b5d50b3
Install the UF2 bootloader fork on the nRF52840
vaulttec-dev Oct 14, 2026
7c50caf
Accept the UF2 bootloader fork on the nRF52840
vaulttec-dev Oct 14, 2026
c4862cf
Group /list by vault, add /settings, drop 1Password documents
vaulttec-dev Oct 14, 2026
624fb91
Close the nRF52840 debug port with APPROTECT at boot
vaulttec-dev Oct 14, 2026
b689303
Accept APPROTECT on the nRF52840 and skip its chip key
vaulttec-dev Oct 14, 2026
b446afd
Serve USB during waits and add CTAPHID to the nRF52840
vaulttec-dev Oct 15, 2026
4bd49f3
Add passkeys to the nRF52840: Trussed and fido-authenticator
vaulttec-dev Oct 15, 2026
d6350ca
Move vkey auth to pam_u2f and drop the Ed25519 login
vaulttec-dev Oct 15, 2026
b2d3eeb
Carry the nRF52840's passkeys in the .vkb and add restore --only
vaulttec-dev Oct 15, 2026
ca39ca7
Sign the C6's images again with its key restored onto the nRF52840
vaulttec-dev Oct 15, 2026
9a98dc8
Sign git commits with a tap and switch auth and commits in /settings
vaulttec-dev Oct 15, 2026
ebd4171
Leave the frame to the main loop after a command run outside raw mode
vaulttec-dev Oct 15, 2026
c6fa7ef
Show the auth and commits state in /settings and flip them on a switch
vaulttec-dev Oct 15, 2026
75882d3
Put wipe and setup last in /settings in red, list vaults with all first
vaulttec-dev Oct 15, 2026
404fa26
Move the nRF52840 notes and the roadmap from docs2/ into docs/
vaulttec-dev Oct 15, 2026
7bd76e1
Add vkey commits github and /settings github: gh signed in, key added
vaulttec-dev Oct 15, 2026
03cacba
List and erase the key's passkeys under its FIDO PIN, a passkey tab
vaulttec-dev Oct 15, 2026
b021954
Ask to remove a passkey on its site before erasing it from the key
vaulttec-dev Oct 15, 2026
f4f99c6
Give the nRF52840's passkeys 44 blocks, taking a quarter of the vault
vaulttec-dev Oct 15, 2026
240ae64
Read an empty passkey list right; say a restart only might come
vaulttec-dev Oct 15, 2026
87c14a1
Make a restore take the wipe's hold; refuse sudo from a user's vkey
vaulttec-dev Oct 16, 2026
5ae64b7
Let typos pass COSE, upstream's bootloader names and commit hashes
vaulttec-dev Oct 16, 2026
e5e6f2a
Answer PR #21's review: CTAPHID channels, idle lock, docs that drifted
vaulttec-dev Oct 16, 2026
c934fee
Retry port discovery after a flash; list the nRF's passkeys too
vaulttec-dev Oct 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .githooks/pre-commit
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ ROOT="$(git rev-parse --show-toplevel)"
# shellcheck source=tools/checks.sh
. "$ROOT/tools/checks.sh"

paths=('*.rs' '*.toml' '*.lock' '*.sh' '*.py' '.githooks/*' 'firmware/boards/*/images/*')
paths=('*.rs' '*.toml' '*.lock' '*.sh' '*.py' '*.pem' '.githooks/*' 'firmware/boards/*/images/*')
Comment thread
vaulttec-dev marked this conversation as resolved.

git diff --cached --quiet && fail "nothing staged"

Expand Down
12 changes: 8 additions & 4 deletions .github/workflows/audit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ name: audit

on:
push:
paths: ['**/Cargo.lock', '**/Cargo.toml', '.github/workflows/audit.yml']
paths: ['**/Cargo.lock', '**/Cargo.toml', '**/.cargo/audit.toml', '.github/workflows/audit.yml']
pull_request:
paths: ['**/Cargo.lock', '**/Cargo.toml']
paths: ['**/Cargo.lock', '**/Cargo.toml', '**/.cargo/audit.toml']
schedule:
- cron: '17 6 * * 1'
workflow_dispatch:
Expand All @@ -24,5 +24,9 @@ jobs:
persist-credentials: false
# rust-toolchain.toml pins the version; rustup installs it on the first call.
- run: cargo install cargo-audit --locked
- run: cargo audit --file firmware/Cargo.lock
- run: cargo audit --file cli/Cargo.lock
# From each folder, as tools/checks.sh does: cargo audit reads .cargo/audit.toml
# from the working directory, not from beside the lock file.
- run: cargo audit
working-directory: firmware
- run: cargo audit
working-directory: cli
10 changes: 7 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,13 @@ jobs:
cargo fmt --check --manifest-path cli/Cargo.toml
- name: clippy - firmware core
run: cargo clippy -p vaultkey-core --all-targets --locked -q --manifest-path firmware/Cargo.toml -- -D warnings
- name: clippy - board
working-directory: firmware/boards/esp32c6-zero
run: cargo clippy --locked -q -- -D warnings
- name: clippy - every board folder
Comment thread
vaulttec-dev marked this conversation as resolved.
run: |
for d in firmware/boards/*/; do
[ -f "$d/board.toml" ] || continue
echo "== $d"
(cd "$d" && cargo clippy --locked -q -- -D warnings)
done
- name: clippy - cli
run: cargo clippy --all-targets --locked -q --manifest-path cli/Cargo.toml -- -D warnings
- name: rustdoc
Expand Down
6 changes: 4 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@ __pycache__/
.mypy_cache/

# Keys, should any ever appear in the tree: they do not belong in the repository.
# The one exception is the public half of the firmware signing key
# (tools/signing-key.sh) - it is not a secret.
# The exceptions are the public halves of signing keys - the firmware's
# (tools/signing-key.sh) and a bootloader fork's (bootloader/signing-key.sh in a board
# folder, which the git hooks check holds nothing more). Neither is a secret.
*.key
*.pem
!firmware/secure-boot/public.pem
!firmware/boards/*/bootloader/signing-key.pem
*.vkb

# Editors
Expand Down
222 changes: 173 additions & 49 deletions CLAUDE.md

Large diffs are not rendered by default.

100 changes: 82 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@ single-binary host CLI, and a press of the board's button for every secret that
> batch, no certification. Read [docs/threat-model.md](docs/threat-model.md) before relying on it,
> and keep the recovery codes your services give you.

> **Two boards** (2026-10-13). Development now targets the Makerdiary nRF52840 MDK USB Dongle
> (`firmware/boards/nrf52840-mdk-dongle`), which also holds passkeys (FIDO2, since
> 2026-10-15) and answers `sudo` through them. It runs the same
> core and passes the full lifecycle test. Its bootloader is now our own fork, which runs
> only signed firmware and serves no flash back, and APPROTECT closes its debug port. This
> revision-2 chip can still be glitched open, though, and it has no chip key
> ([docs/threat-model.md](docs/threat-model.md)). So it is **not for real secrets** until the steps
> in [docs/roadmap.md](docs/roadmap.md) are done, and `vkey setup` does not flash it yet.
> Everything below still describes the ESP32-C6-Zero, which is now only a test board.
Comment thread
vaulttec-dev marked this conversation as resolved.
Outdated

## In ten seconds

```console
Expand Down Expand Up @@ -46,17 +56,17 @@ the device under routine use — only an explicit export under a double tap read
- A TOTP secret leaves the key in the clear only under an explicit double tap (`vkey export <name>`).
Routine use returns only codes. `vkey backup` reseals every item under a
backup passphrase without revealing secrets.
- Everything, `list` included, needs the PIN — except a `vkey auth` login, a tap alone; the key
re-locks after two idle minutes. Secrets and
- Everything, `list` included, needs the PIN — except a passkey or a `vkey auth` login on the
nRF52840, a tap alone; the key re-locks after two idle minutes. Secrets and
Comment thread
vaulttec-dev marked this conversation as resolved.
Outdated
PINs are never arguments or environment variables — hidden prompt or stdin.

## What it does not do

Read [docs/threat-model.md](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.
- **Phishing, for TOTP and passwords.** A TOTP code works on a fake site just as on the real one;
that is a property of TOTP. The browser binds a passkey to its site, and only the nRF52840
board has passkeys: 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
Expand Down Expand Up @@ -108,8 +118,9 @@ vkey get myapp > .env # better without a file: env $(vkey get m
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 backup vault.vkb # everything, the nRF's passkeys too, sealed on the key; two taps
vkey restore [file] # restore from 1Password, CSV, or .vkb backup file
vkey restore --only NAME file.vkb # just that item out of a backup, nothing replaced
vkey check --wipe-everything # lifecycle test on a board; ERASES EVERYTHING
sudo vkey auth enable # sudo and the lock screen with a tap (below)
```
Expand All @@ -127,29 +138,80 @@ touches what a CSV brought, and an entry under a name no source offers is left a

## 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](docs/threat-model.md#vkey-auth--sudo-and-the-lock-screen-with-a-tap-no-pin)
With the nRF52840 key plugged in, `sudo` and the lock screen take a tap instead of your
password; without it they ask for the password as always, and with it but no tap, once the
key gives up after 30 s. This is `pam_u2f`, the scheme every FIDO2 key uses: the key makes
a credential for this host, the host keeps only its public half. It needs no PIN, so a
stolen laptop with the key still in it opens with a tap — read
[the trade-off](docs/threat-model.md#vkey-auth--sudo-and-the-lock-screen-with-a-tap-through-pam_u2f)
first.

```bash
sudo vkey auth enable # PIN, then a tap: sudo and the lock screen are set up
sudo apt install libpam-u2f
sudo install -m 0755 "$(command -v vkey)" /usr/local/sbin/vkey # a copy only root can change
sudo vkey auth enable # two taps: 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.
`enable` and `disable` refuse to run as root from `~/.local/bin`: anything running as you
could rewrite that file and would then run as root. The copy in `/usr/local/sbin` is
root's, and `sudo` finds it by name.

`enable` makes a credential on the key (a tap), tries it the way a login will (another
tap), writes it to `/etc/u2f_mappings` (root's), 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. Keep a root shell open in
another terminal until `sudo` and the lock screen have both worked, with the tap and with
the password.

In the shell, `/settings` → `auth` does the same in one step: it installs `libpam-u2f`
with apt first if it is missing, and brings `/usr/local/sbin/vkey` up to date when it
differs from the `vkey` you run.

## Passkeys on the key

```bash
vkey passkey list # site and account of every passkey the key keeps
vkey passkey rm # one erased from the key, picked from the list
vkey passkey pin # the FIDO PIN set, or changed
```

The nRF52840 keeps the passkeys sites make discoverable (webauthn.io's, a Google
account's). They sit behind the key's **FIDO PIN**, the one a browser asks for, which is
Comment thread
vaulttec-dev marked this conversation as resolved.
Outdated
apart from vkey's PIN on purpose: the key stores it as a plain SHA-256, so the same PIN
for both would give a flash dump the vault's PIN too. In the shell the last tab of the
list is `passkey`: it asks the FIDO PIN once, lists them until the lock, and `d` erases
the highlighted one. Erasing it on the key does not remove it from the site. `/settings`
→ `passkey-pin` sets or changes the FIDO PIN. The `vkey auth` login and the commit
signing key are not kept on the key, so they are not in the list.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: These credentials are non-resident, not absent from the key. Say they are excluded from the discoverable-passkey list so users do not misread reset or backup behavior.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At README.md, line 187:

<comment>These credentials are non-resident, not absent from the key. Say they are excluded from the discoverable-passkey list so users do not misread reset or backup behavior.</comment>

<file context>
@@ -127,29 +138,80 @@ touches what a CSV brought, and an entry under a name no source offers is left a
+list is `passkey`: it asks the FIDO PIN once, lists them until the lock, and `d` erases
+the highlighted one. Erasing it on the key does not remove it from the site. `/settings`
+→ `passkey-pin` sets or changes the FIDO PIN. The `vkey auth` login and the commit
+signing key are not kept on the key, so they are not in the list.
+
+## Git commits signed with a tap
</file context>
Suggested change
signing key are not kept on the key, so they are not in the list.
signing key are non-resident credentials on the key, so they are not in the list.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not changing this one. Both credentials are non-resident: the key keeps no record of them, and the private key travels wrapped inside the credential ID the host stores (/etc/u2f_mappings, ~/.config/vkey/commit-signing-key). So "not kept on the key" is accurate, and "excluded from the list" would suggest they are stored there but hidden.


## Git commits signed with a tap

```bash
vkey commits enable # a tap: an SSH key on the key, git signs every commit with it
vkey commits disable # git stops signing; the key stays for the next enable
vkey commits github # the key on GitHub as a signing key: commits show as verified
```

`enable` makes an OpenSSH `ecdsa-sk` key on the nRF52840 (a tap) and keeps its handle in
`~/.config/vkey/commit-signing-key`, which signs nothing without the key (not in
`~/.ssh`, where GNOME Keyring's agent would take it and refuse to sign). It sets git's `gpg.format`,
`user.signingkey`, `gpg.ssh.allowedSignersFile` and `commit.gpgsign` globally, refuses
to overwrite a signing setup that is not its own, and offers to add the public half to
GitHub as a signing key (`gh`), so the commits show as verified there. `github` does that
step alone: it signs `gh` in, or grants it the `admin:ssh_signing_key` permission, in the
browser when it has to, then adds the key. From then on every commit waits for a tap. In
the shell it is `/settings` → `commits`: the switch, and `github` on the line under it. Linux only
for now; macOS and Windows are issues #18 and #19. What it costs is in
[the threat model](docs/threat-model.md#git-commits-signed-with-a-tap).

## Gestures and capacity

| Gesture | LED | What it releases |
|---|---|---|
| Tap | amber | one TOTP code, the secret fields of one item, one `.env`, or one `vkey auth` login |
| Hold 5 s | red | factory wipe: every secret and the PIN |
| Tap | amber | one TOTP code, the secret fields of one item, one `.env`, one passkey, one `vkey auth` login, or one signed git commit |
| Hold 5 s | red | factory wipe: every secret and the PIN; a restore from a `.vkb`, which replaces everything; on the nRF52840, erasing every passkey (within 10 s of plugging in) |
| Double tap | blue | the encrypted backup file, or an export / seed readout |

A tap never wipes and never exports, so a hostile host cannot swap a code request for a
Expand Down Expand Up @@ -231,6 +293,8 @@ that is not in it exists on neither side. The device has no clock — the host s
|---|---|
| [docs/threat-model.md](docs/threat-model.md) | what it protects against and what it does not. **Read first** |
| [docs/hardware.md](docs/hardware.md) | the C6-Zero board: measured pins, traps, bringing up a new one |
| [docs/roadmap.md](docs/roadmap.md) | the nRF52840 board, from bring-up to daily use |
| [docs/makerdiary.md](docs/makerdiary.md) | the nRF52840 board: why this one, its factory state, what was measured |

## Security and licence

Expand Down
12 changes: 12 additions & 0 deletions cli/.cargo/audit.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Advisories cargo audit skips for cli/Cargo.lock. Every entry says why it cannot reach
# vkey and when it goes; docs/threat-model.md lists the same ones.

[advisories]
ignore = [
# RUSTSEC-2026-0344, ruzstd 0.8 <- object 0.39 <- espflash 4.x: a malformed zstd
# section in an ELF. vkey never parses an ELF - it hands espflash finished images
# through Connection, Flasher and Segment - so the decoder is linked but never fed.
# Goes when `cargo tree -i ruzstd` shows 0.9.1 or later, or when espflash leaves
# the CLI (docs/roadmap.md, phase 5).
"RUSTSEC-2026-0344",
]
Loading
Loading