Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
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
90 changes: 54 additions & 36 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,58 +152,76 @@ the protocol and logs — hence the `VTC2` magic word in frames. Pins and pitfal
the real firmware with the counter erased was costed: a fortnight for six digits, four years
for eight). A backup passphrase is 12–128 bytes, and the CLI asks for five or six random
words, because 128 KiB of Argon2id does not make up for a phrase a person invented.
- The entry kind (`oath::Kind`) decides what may leave the device. `Totp` — codes only,
after a tap. `Password` — the login with no gesture, the password and note after the same
tap (blob `login_len | login | password_len | password | note`, assembled only by
`Entry::password`). `Env` — a project `.env` whole, up to `ENV_MAX` = 8000 bytes,
`KEY=value` lines only (checked by the CLI, not the firmware), after the same tap. The
single `match` on kind lives in `device.rs`; a TOTP secret has no path to `respond`. The
kind is in the AAD with the name: a kind byte rewritten in flash breaks the tag instead of
turning a seed into a password.
- `Env` is never an `Entry` (the constructor returns `None`): the blob lives in 16 slots of
its own region (`env_*` in `store.rs`), encrypted under a random env key kept in the image
header sealed under the DEK — a PIN change reseals 60 bytes in the same atomic write and
leaves blobs untouched. Entry and blob names share one namespace. Everything, `list`
included, requires the PIN; auto-lock is 2 minutes.
- **A field's class decides what may leave the device**, not the item's category
(2026-09-19, maintainer's decision; `docs/items-v2.md`). An item is a name, a category
and up to 32 fields; a field is `class | kind | section | label | value`, the value up
to `VALUE_MAX` = 8056 bytes and the packed item up to `ITEM_MAX` = 8192. `Open` needs
the PIN alone, `Secret` a tap, `Seed` a tap for the code it computes and the double tap
for the seed itself. The host names a reach per request and gets only those fields; an
unknown reach byte is refused, never rounded down. The single `match` on class lives in
`device.rs`. The category is in the AAD with the name: a category byte rewritten in
flash breaks the tag, even with the CRC repaired.
- The answer to `ItemGet` carries the classes the item holds (`HAS_OPEN`/`HAS_SECRET`/
`HAS_SEED`) in front of the fields, so the host can tell a TOTP item from a password one
without spending a gesture to find out. What exists, never what it is.
- A `.env` is an item of category `Env` with one secret field; `KEY=value` lines are
checked by the CLI, not the firmware. There is no env region and no env key any more:
blobs are items, sealed under the DEK and re-sealed with everything else when the PIN
changes. Names are one namespace. Everything, `list` included, requires the PIN;
auto-lock is 2 minutes.
- A seed's value is `algo | digits | period | secret`: the parameters travel with it
because the device has nowhere else to keep them, and a code's shape is not a secret.
The CLI turns an `otpauth://` URI into that on the way in and back into a URI on the way
out (`totp::seed_field`, `totp::seed_uri`).
- The one exception is `Auth` (2026-09-14, maintainer's decision): a 32-byte Ed25519 seed
for `vkey auth` (`sudo` and the lock screen via `pam_exec`), sealed under
`DeviceKey::mac("vaultkey/auth-key/v1")` — the eFuse key alone, never the DEK — so
`Respond` signs `AUTH_SIGNED_PREFIX | challenge` after a tap while locked, spending no
attempt. Refused on an unbound chip. Which key a record is under is decided in one place,
`Device::record_key`; a PIN change skips `Auth` records. A login waits 10 s for the tap
attempt. Refused on an unbound chip. Which key an item is under is decided in one place,
`Device::item_key`; a PIN change carries `Auth` items across untouched. A login waits 10 s for the tap
(`AUTH_TOUCH_TIMEOUT_MS`), then PAM asks for the password. The host keeps only the public
key, `/etc/vkey/auth/<user>`, and writes nothing at login. `sudo vkey auth enable` is the
one place the CLI writes system files: `/usr/local/bin/vkey`, the public key, and one
`sufficient` line in the PAM services of `SERVICES` (`cli/src/auth.rs`), each original
kept as `*.before-vkey`; `disable` undoes it.
- The only way a TOTP secret leaves the device is the backup (`ExportBegin`/`ExportNext`,
after a double tap; 2026-09-09): the device reseals every entry and blob under a key
derived from a passphrase (Argon2id, `Passphrase` 12–128 bytes, without the chip key, so
the file opens on another board) and returns them one at a time; the host sees only
ciphertext and assembles a `.vkb`. `ImportBegin`/`ImportItem`/`ImportEnd` is the reverse,
under the PIN, with no gesture, like `add`: entries are collected in RAM and written as one
image in `ImportEnd`, blobs immediately after the table is written if it is dirty. A backup
- A TOTP seed leaves the device two ways. The backup (`ExportBegin`/`ExportNext`, after a
double tap; 2026-09-09) reseals every item under a key derived from a passphrase
(Argon2id, `Passphrase` 12–128 bytes, without the chip key, so the file opens on another
board) and returns them one at a time; the host sees only ciphertext and assembles a
`.vkb`. `ImportBegin`/`ImportItem`/`ImportEnd` is the reverse, under the PIN with no
gesture: the items are appended to a fresh image as they arrive and become the vault at
`ImportEnd`, so a restore that stops half-way leaves what was there before. A backup
session is killed by any `load()`, by `lock`, and by the first error. An item's AAD is
`BACKUP_AAD` plus its index in the file: a reordered or altered item does not open
(`BadBackup`).
The second way is `vkey export` (2026-09-19): an item written back into 1Password
through `op item create`, under the same double tap, with the seed in the clear. That
ends the claim that a seed never leaves — `docs/threat-model.md` says what it costs.
- Secrets and PINs never come from command arguments or environment variables: the CLI asks
hidden or reads a line from stdin, and holds them in `Zeroizing`.
- Flash: `store.rs` knows *how*, the board says *where* (`Layout` in its `main.rs`; on
C6-Zero 0x110000..0x17B000, raw flash past the `factory` partition, which the stock
partition table does not describe). The PIN header and the entry table (256 slots of
`SECRET_MAX` = 256 bytes) are **one** image, two A/B copies with a serial and a CRC; a PIN
change is atomic. Every `add` rewrites the whole image, and the cost is erasing its sectors
(~45 ms each): 512-byte slots gave 1.7 s per write, hence 256. The ~80 KiB image lives in
RAM in a single instance — `State` in the board's static, filled in place by `Device`.
`Store` remembers only which copy is newer, never the content; `pin_status` answers from
the 12-byte head of each copy without a CRC — it is the shell's status tick once a second.
Never pass or return `State` by value: the stack is 308 KiB and three copies overflowed it.
- The `.env` region is 16 slots, two copies over 2 sectors, each with its own serial and CRC;
a damaged copy reads as empty, not `Corrupt`, because an interrupted write of a new blob
has no older copy to roll back to. One blob in RAM — a static buffer, sealed and opened in
place. A host request is read into a stack buffer of `MAX_PAYLOAD` bytes: a blob travels in
one frame, no chunking. The attempt counter is a separate sector; an attempt is one word
write with no erase, only via `NorFlash::write`.
partition table does not describe). Three regions: one sector of attempt words and two
image copies of `STATE_SECTORS` = 53 each, filling the region exactly.
- The image is the PIN header and every item, **packed** - `name_len | category |
sealed_len | name | pad | sealed | pad` - not fixed slots: a seed is twenty bytes and an
SSH key three kilobytes, and slots would make every write cost what the biggest item
might have been. A write erases only the sectors the image grows into, plus whatever the
image it replaces left beyond it, so an `add` costs the vault's size (~0.8 s for 66
items), not the region's.
- The magic word is written **last**, after the items and the CRC: until it lands the copy
reads as blank, so a torn write is not a copy a reader has to reason about. The CRC is
computed over what was actually read back from the target, not over what was meant to be
written.
- The image never sits in RAM. What RAM holds is an `Index` - name, category, offset and
length per item, ~10 KiB for 256 - in the board's static, and bodies stream through the
working buffer. That is what pays for items bigger than the old 256-byte secret. `Store`
remembers only which copy is newer, never the content; `pin_status` answers from the
20-byte head of each copy without a CRC — it is the shell's status tick once a second.
- The board's buffer is two item-sized halves (`BUF_LEN` = 2 × `ITEM_BUF_LEN`): one to
open or build an item in, one for the items carried across while the image is rewritten.
The attempt counter is a separate sector; an attempt is one word write with no erase,
only via `NorFlash::write`.
- Entropy: `TrngSource::new(RNG, ADC1)` before `Trng::try_new()`; do not drop the handle.
- Acceptance happens on the board, in two tiers, because the full test wipes the device and
a working key cannot be wiped after every change:
Expand Down
24 changes: 15 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ 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.
the device under routine use — only an explicit export under a double tap reads it back.

## What it is

Expand All @@ -43,8 +43,9 @@ the device at all — there is no command for it.
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.
- A TOTP secret leaves the key in the clear only under an explicit double tap (`vkey export` or
`vkey get --seed`). 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
PINs are never arguments or environment variables — hidden prompt or stdin.
Expand Down Expand Up @@ -147,19 +148,24 @@ line to add by hand.

| Gesture | LED | What it releases |
|---|---|---|
| Tap | amber | one TOTP code, one password, one `.env`, or one `vkey auth` login |
| 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 |
| Double tap | blue | the encrypted backup file |
| 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
wipe or an export: the gesture the owner makes for one satisfies neither of the others.
A TOTP seed leaves the key in the clear only under the double tap, and only because
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
`vkey export` writes an item back into 1Password whole — what that costs is written out
in `docs/threat-model.md`.

| | 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 |
| Items (every category together) | 256 |
| Fields per item | 32 |
| One field | 8056 bytes — a `.env`, an SSH key, a note |

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: This limit update leaves conflicting 8128-byte guidance in the CLI, shell, and threat model, so users can prepare an .env that the firmware rejects. Update those remaining descriptions to the enforced 8056-byte VALUE_MAX.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At README.md, line 165:

<comment>This limit update leaves conflicting 8128-byte guidance in the CLI, shell, and threat model, so users can prepare an `.env` that the firmware rejects. Update those remaining descriptions to the enforced 8056-byte `VALUE_MAX`.</comment>

<file context>
@@ -161,7 +162,7 @@ in `docs/threat-model.md`.
 | Items (every category together) | 256 |
 | Fields per item | 32 |
-| One field | 8128 bytes — a `.env`, an SSH key, a note |
+| One field | 8056 bytes — a `.env`, an SSH key, a note |
 | One item | 8192 bytes, packed |
 | The vault | ~210 KB, two copies; a write costs what the vault holds, not what it might |
</file context>

| One item | 8192 bytes, packed |
| The vault | ~210 KB, two copies; a write costs what the vault holds, not what it might |
| Item name | 32 bytes; names are one namespace |
| PIN | exactly 8 digits, 8 attempts |
| Backup passphrase | 12–128 characters; five or six random words, not an invented one |

Expand Down
15 changes: 12 additions & 3 deletions cli/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,10 @@ use ed25519_dalek::{Signature, SigningKey, VerifyingKey};
use zeroize::Zeroizing;

use crate::device::{
AUTH_CHALLENGE_LEN, AUTH_SECRET_LEN, AUTH_SIGNATURE_LEN, AUTH_SIGNED_PREFIX, Device, Error,
Kind,
AUTH_CHALLENGE_LEN, AUTH_SECRET_LEN, AUTH_SIGNATURE_LEN, AUTH_SIGNED_PREFIX, Category, Class,
Device, Error, FieldKind,
};
use crate::item::{Item, OwnedField};
use crate::prompt::with_unlock;

/// What an auth entry says when someone tries to use it like the others.
Expand Down Expand Up @@ -136,7 +137,15 @@ pub fn enable(dev: &mut Device) -> Result<u8, Error> {
// one already on the key under this name could not be matched to a public key.
let seed = Zeroizing::new(random()?);
let key = SigningKey::from_bytes(&seed).verifying_key();
with_unlock(dev, |d| d.add(&name, &*seed, Kind::Auth, true))?;
// One secret field, and the category is what makes the device seal it under the
// chip key rather than the PIN's.
let item = Item::new(Category::Auth).with(OwnedField::new(
Class::Secret,
FieldKind::Concealed,
"seed",
&*seed,
));
with_unlock(dev, |d| d.put(&name, &item, true))?;
drop(seed);

let challenge = random()?;
Expand Down
142 changes: 136 additions & 6 deletions cli/src/backup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,17 +92,147 @@ pub fn source(path: &Path) -> Result<PathBuf, Error> {
Ok(file)
}

/// Every item in `path` onto the device; how many there were. The device decides what
/// each item means: an entry of the same name is replaced, and one of the other kind
/// makes way.
pub fn import(dev: &mut Device, path: &Path, pass: Passphrase<'_>) -> Result<usize, Error> {
use vaultkey_core::item::{Category, Class, FieldKind, Item as CoreItem};
use vaultkey_core::oath::{Name, Params};
use vaultkey_core::vault::{
self, BACKUP_AAD, Block, CryptoRng, KDF_BLOCKS, NONCE_LEN, RngCore, TAG_LEN,
};

use crate::item::{Item, OwnedField};

struct BackupRng;

impl RngCore for BackupRng {
fn next_u32(&mut self) -> u32 {
let mut b = [0u8; 4];
self.fill_bytes(&mut b);
u32::from_le_bytes(b)
}

fn next_u64(&mut self) -> u64 {
let mut b = [0u8; 8];
self.fill_bytes(&mut b);
u64::from_le_bytes(b)
}

fn fill_bytes(&mut self, dest: &mut [u8]) {
getrandom::getrandom(dest).expect("OS entropy is available");
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
}
}

impl CryptoRng for BackupRng {}

fn convert_legacy_item(
key: &[u8; vault::KEY_LEN],
item_idx: u32,
sealed: &[u8],
) -> Option<Vec<u8>> {
let mut aad = [0u8; BACKUP_AAD.len() + 4];
aad[..BACKUP_AAD.len()].copy_from_slice(BACKUP_AAD);
aad[BACKUP_AAD.len()..].copy_from_slice(&item_idx.to_le_bytes());

let mut buf = sealed.to_vec();
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
let n = vault::open_in_place(key, &aad, &mut buf)?;
let plain = &buf[NONCE_LEN..NONCE_LEN + n];

// Already v2?
if let Some((&category, rest)) = plain.split_first()
&& let Some((_, body)) = Name::take(rest)
&& CoreItem::parse(body).is_some()
&& Category::from_wire(category).is_some()
{
return Some(sealed.to_vec());
}

// Legacy format: Kind::WIRE_LEN (4) | name_len u8 | name | secret
let (kind_bytes, rest) = plain.split_first_chunk::<4>()?;
let (name, secret) = Name::take(rest)?;
let item = match *kind_bytes {
[1, algo, digits, period] => {
let params = Params::from_wire([algo, digits, period])?;
let sf = crate::totp::seed_field(params, secret).ok()?;
Item::new(Category::Login).with(sf)
}
[2, 0, 0, 0] => {
let (&login_len, rest) = secret.split_first()?;
let (login, rest) = rest.split_at_checked(usize::from(login_len))?;
let (&pw_len, note) = rest.split_first()?;
let (password, note) = note.split_at_checked(usize::from(pw_len))?;
crate::device::login_item(
std::str::from_utf8(login).ok()?,
std::str::from_utf8(password).ok()?,
std::str::from_utf8(note).ok()?,
)
.ok()?
}
[3, 0, 0, 0] => Item::new(Category::Env).with(OwnedField::new(
Class::Secret,
FieldKind::String,
".env",
secret,
)),
[4, 0, 0, 0] => Item::new(Category::Auth).with(OwnedField::new(
Class::Secret,
FieldKind::Concealed,
"seed",
secret,
)),
_ => return None,
};

let packed = item.pack().ok()?;
let name_bytes = name.as_bytes();
let name_len = u8::try_from(name_bytes.len()).ok()?;
let mut new_plain = Vec::with_capacity(2 + name_bytes.len() + packed.len());
new_plain.push(item.category.wire());
new_plain.push(name_len);
new_plain.extend_from_slice(name_bytes);
new_plain.extend_from_slice(&packed);

let total = new_plain.len();
let mut out_buf = vec![0u8; NONCE_LEN + total + TAG_LEN];
out_buf[NONCE_LEN..NONCE_LEN + total].copy_from_slice(&new_plain);
let sealed_len = vault::seal_in_place(key, &mut BackupRng, &aad, &mut out_buf, total)?;
out_buf.truncate(sealed_len);
Some(out_buf)
}

/// Every item in `path` onto the device; how many there were. The items go back sealed,
/// and the device opens them - except for a backup written before items existed, which
/// this side converts first, because the device no longer speaks the old shape.
pub fn import(dev: &mut Device, path: &Path, pass: Passphrase<'_>) -> Result<Restored, Error> {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
let (head, items) = read(&source(path)?)?;
let mut mem = vec![Block::new(); KDF_BLOCKS];
let key = vault::backup_key(pass, &head.salt, head.cost, &mut mem);
let converted: Vec<Vec<u8>> = match key {
Some(k) => items
.iter()
.enumerate()
.map(|(i, item)| {
let idx = u32::try_from(i).unwrap_or(0);
convert_legacy_item(&k, idx, item).unwrap_or_else(|| item.clone())
})
.collect(),
None => items,
};

dev.import_begin(pass, head)?;
for item in &items {
for item in &converted {
dev.import_item(item)?;
}
dev.import_end()?;
Ok(items.len())
Ok(Restored {
count: converted.len(),
skipped: Vec::new(),
})
}

/// What a restore did: how many items are on the key, and what the file held that this
/// build could not store. Nothing is left behind by a backup this version wrote; the
/// field is what a caller prints when one is.
pub struct Restored {
pub count: usize,
pub skipped: Vec<String>,
}

/// The file's head and items, checked for shape only: what is inside an item is the
Expand Down
Loading
Loading