Skip to content

Derived X-Wing: device-side decapsulation; drop vault and password generator from production - #4

Merged
0c-coder merged 19 commits into
heroku-deployfrom
fix/drop-req-press-opcodes
Sep 16, 2026
Merged

0c-coder merged 19 commits into
heroku-deployfrom
fix/drop-req-press-opcodes

Conversation

@0c-coder

@0c-coder 0c-coder commented Sep 11, 2026

Copy link
Copy Markdown
Owner

Web-app half of the PQC derived-key work in libraries#6.

The device holds both halves now. It used to return a 32-byte ML-KEM seed and let the browser expand it, run ML-KEM decapsulation and combine. The seed is private key material — it yields sk_M — so a request for a public key was answered with a private one, and the ML-KEM half of a hardware key really lived in the page.

  • DERIVE_PUBLIC_KEY returns the whole 1216-byte recipient (pk_M || pk_X) instead of [pk_X(32) | mlkem_seed(32)]. ML-KEM has no short public key — the only 32-byte value reproducing pk_M also reproduces sk_M — so the public key itself has to cross the wire. It no longer fits one WebAuthn assertion, so the head arrives with the OKCONNECT reply and the rest is polled through send_stored_response() as an ML-DSA-65 signature already is.
  • Decapsulation leaves the DERIVE_* extension entirely: a chunked OKDECRYPT to slot 128 carrying [label32 | ct(1120)], the same tunnel composite_decrypt uses, answered with the finished 32-byte shared secret.

So age_pqc.js loses mlkemKeypairFromSeed(), buildRecipient(), splitDecapsulate() and ctXOf() — nothing calls them, and what remains is host/sender-side math on public values. age-derive.js drops the recipient lookup it only needed to feed splitDecapsulate().

Also removed: press_required and the precomputed challenge code. The REQ_PRESS opcodes are gone from the firmware, and whether a confirmation is required now follows from what is being asked for — a public key never, a shared secret always, enforced device-side. The code we precomputed hashed [keytype | label32 | ct_X32], which is not what the device hashes any more.

Separately: the credential vault and password generator are dropped from production builds. The vault is prerelease and has never shipped; the password generator page has never worked. The vault moves to plugins-devel.js; three dead links come out of index-src.html.

Depends on libraries#6. Untested against hardware — the FIDO2 derive path is still under investigation.

Firmware removed opcodes 3 and 4 and burned the numbers, so sending either now
returns CTAP2_ERR_EXTENSION_NOT_SUPPORTED. Always send 1 or 2.

press_required is kept in the public API signature so existing callers still
parse, but it no longer does anything. Presence is decided by the device from
what is being asked for: a public-key derivation never prompts, a shared-secret
derivation always does, with no setting to disable it.

The suffix had also become a second key domain - the firmware set
additional_data[0] = 1 for it, which changes the HKDF salt - so the same label
produced two different keys depending on an opcode named after touches. That is
how vault.js came to fetch its public key with press_required=false and run its
ECDH with press_required=true: its comment explains the choice purely in terms
of when a touch is needed, so whoever wrote it did not know the two calls were
using different keys. It worked only because both sides were deterministic.
One label now means one key.

Derived secrets change as a result. Vault entries and anything else derived
through the REQ_PRESS path will not reproduce. Pre-release, no migration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011sMEydE1HrJTCPkJ25RLRY
src/lib/history.js could never have run. It calls

    ok.derive_public_key("onlykey-gun", function (error, historyPubkey) {...})

with two arguments against a four-argument signature
(additional_d, keytype, press_required, cb), so `keytype` receives the callback
function, `press_required` and `cb` are undefined, and nothing is ever called
back. Its only reference was plugins-devel.js - it is not in plugins.js, so it
never shipped in a production bundle either.

Found while auditing callers of the derive API for the REQ_PRESS opcode
removal: it was the only other caller, and it turned out not to be one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011sMEydE1HrJTCPkJ25RLRY
The credential vault is prerelease and has never shipped; the password
generator page has never worked. Neither belongs in a production bundle.

- plugins.js: remove the vault plugin registration.
- plugins-devel.js: register vault there instead, so development builds
  keep it available while webpack leaves it out of production.
- index-src.html: remove the Vault nav link, the Credential Vault tool
  card, and the Password Generator tool card. The password-generator
  plugin was already devel-only, so that card was a dead link in
  production already.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011sMEydE1HrJTCPkJ25RLRY
@0c-coder

Copy link
Copy Markdown
Owner Author

Two more removals pushed to this branch:

  • 740f1c9 — deleted src/lib/history.js. Dead: its only caller was plugins-devel.js, and that call passed 2 arguments to a 4-argument signature.
  • 024a2a2 — dropped the credential vault from production. plugins.js no longer registers it; plugins-devel.js does instead, so development builds keep it while webpack leaves it out of the production bundle. Also removed three links from index-src.html: the Vault nav link, the Credential Vault tool card, and the Password Generator tool card — password-generator was already devel-only, so that card had been a dead link in production regardless.

The vault is prerelease and has never shipped, and neither the vault nor the password generator page has ever worked.

claude and others added 3 commits September 14, 2026 14:25
Split custody is gone from the derived (label-based) path. The device used to
return a 32-byte ML-KEM seed and let the browser expand it, run ML-KEM
decapsulation and combine the halves. The seed is private key material - it
yields sk_M - so a request for a public key was answered with a private one,
and the ML-KEM half of a "hardware" key really lived in the page.

Two wire changes follow from that:

- DERIVE_PUBLIC_KEY returns the whole 1216-byte recipient (pk_M || pk_X)
  instead of [pk_X(32) | mlkem_seed(32)]. ML-KEM has no short public key - the
  only 32-byte value reproducing pk_M also reproduces sk_M - so the public key
  itself has to cross the wire. It no longer fits one WebAuthn assertion, so
  the head arrives with the OKCONNECT reply and the rest is polled through
  send_stored_response() exactly as an ML-DSA-65 signature is.

- Decapsulation leaves the DERIVE_* extension entirely. It is now a chunked
  OKDECRYPT to slot RESERVED_KEY_WEB_DERIVATION carrying [label32 | ct(1120)] -
  the same tunnel composite_decrypt uses - answered with the finished 32-byte
  shared secret. The whole ciphertext including ct_M reaches the device, which
  is what the single-shot client_handle request could not carry.

So age_pqc.js loses mlkemKeypairFromSeed(), buildRecipient(), splitDecapsulate()
and ctXOf(): nothing calls them, and what is left there is host/sender-side math
on public values. age-derive.js drops the recipient lookup it only needed to
feed splitDecapsulate(), and decrypts in one device call.

Also gone: press_required, and the precomputed challenge code. The REQ_PRESS
opcodes were removed from the firmware, and whether a confirmation is required
now follows from what is being asked for - a public key never, a shared secret
always, enforced device-side with no setting to turn it off. The code we
precomputed hashed [keytype | label32 | ct_X32], which is not what the device
hashes any more; it computes the digits over the reassembled label and
ciphertext and shows them itself. A wrong code displayed confidently is worse
than no code.

Untested against hardware - nothing in this chain has been built yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011sMEydE1HrJTCPkJ25RLRY
Slot 128 has always served two callers - the OnlyKey web app over FIDO2, and
local tools over USB (onlykey-agent, python-onlykey, the age plugin) - but every
name for it said "web". That hid the half that matters most in practice: the
unattended-agent case runs over USB, not through a browser, and someone reading
"web derive mode" would not expect it to govern their SSH agent. It does.

Renamed throughout: RESERVED_KEY_WEB_DERIVATION -> RESERVED_KEY_WEB_AGENT_DERIVATION,
web_derive_mode -> web_agent_derive_mode (and its EEPROM accessors),
web_derive_gate -> web_agent_derive_gate. The old slot constant stays as an alias
so branches still in flight keep compiling.

The header comment now also states the positioning rather than leaving it to be
inferred: this is the ACCESSIBLE tier, not the protected one. Keys here are
reproducible from a label, reachable by the web app and by local agents, and can
be configured to need no confirmation at all. That is the trade - less protected
than a stored slot, in exchange for being usable by software that has nobody
sitting in front of it. Stored keys are the other end of that scale and are not
reachable this way.

Field-policy harness re-run after the rename: 2054 assertions green with and
without OK_ALLOW_NO_PRESS.
…ebuild docs/

Three things that together meant the released site was never the site being
built for release.

1. age-derive and pgp-pqc were registered only in plugins-devel.js, which is
   development-only and throws if it ever reaches a production bundle. So
   BUILD.sh 1 produced a site with no /app/age-derive.html and no
   /app/pgp-pqc.html at all, while the deployed site - a dev build - had
   them. Both plugins now load from plugins.js like any other page.

2. chat and password-generator are gone: registrations removed and the
   plugin directories deleted. Nothing outside their own directories
   referenced them.

3. docs/ is the built output GitHub Pages serves and had not been rebuilt
   since before the derived X-Wing work. The deployed bundle
   (ec54b1a2108ecf924f09) was byte-identical to heroku-deploy's while src/
   carried five commits of protocol change, so onlyagent.app served a web app
   still expecting the old 32-byte-seed derivation against firmware that now
   returns the whole 1216-byte recipient and takes a 1152-byte chunked decaps.
   Rebuilt with BUILD.sh 1 (production).

vault stays development-only - 024a2a2 removed it from production
deliberately and this does not reverse that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159zPxi7DCuucC1ZcqYBB8W
The production build loaded plugins/console/console.js, the 'debug console
omitter', which strips all console output. When the FIDO2 derived-key path
hung on 2026-09-15 the app reported nothing at all - no error, no log, just a
page that never finished - and the only diagnosis available was the device's
serial log, which is lossy.

onlyagent.app is a test deployment and the real production site will be a
different host, so console output stays on here. The omitter is commented
out with a TODO to restore it when cutting the real production build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159zPxi7DCuucC1ZcqYBB8W
The previous commit removed the console omitter outright, which broke the
app at load: 'Could not resolve dependencies / Missing services: console'.
Both plugins PROVIDE the 'console' service that other plugins consume -
console.js registers no-op log/info/warn/error, console_debug.js registers
the real console - so turning output on is a swap, never a deletion.

Caught immediately because console output was on to see it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159zPxi7DCuucC1ZcqYBB8W
Not to be merged. Production (webpack -p) bundles hang at 'Connecting to
OnlyKey' with no completion; the historically deployed site was a dev build
and connected fine. Deploying a dev build of the same source to isolate
whether the fault is the production build itself rather than the source.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159zPxi7DCuucC1ZcqYBB8W
Traces the assertion result, the decrypted tail, the head length and every
OKPING poll, so the hardware run can say where the derive stops rather than
inferring it. Remove with the rest of the TEMP dev deploy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159zPxi7DCuucC1ZcqYBB8W
Everything after the transit pubkey is ONE AES-GCM blob the device
encrypted in a single call over the whole staged response. xwing_derive()
decrypted the first chunk and then appended the polled chunks RAW, splicing
plaintext onto ciphertext. aesgcm_decrypt() runs with tagLength 0, so the
prefix decrypted correctly on its own and the first 446 bytes of the
recipient were right - the remaining 770 were ciphertext, and agePqc
rejected the result with 'ML-KEM.encapsulate: wrong publicKey modulus'.

Now the ciphertext is reassembled first and decrypted once.

Two more corrections fall out of that:

- The recipient is taken as the LAST XWING_PK bytes, not everything after
  the first NUL. The status field is a fixed-width slot (sizeof(UNLOCKED)+1),
  not a tight string, so a short version string leaves padding between the
  NUL and the recipient - which was being prepended to pk_M.

- poll_for_response() gains an untilShort mode that drains the staged
  response without being told its length, stopping at the first chunk
  shorter than MAX_LARGE_RESP_CHUNK. The host cannot compute the total
  because the status field's width is a firmware build constant.

Still carries the XWTRACE logging and the dev plugin set; not for merge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159zPxi7DCuucC1ZcqYBB8W
okcrypto.cpp returns it with send_transport_response(ss, 32, true, true).
That 'true' only bites on this transport: send_transport_response() ignores
the flag on the raw-HID branch and honours it on the WebAuthn branch, where
store_FIDO_response() AES-GCMs the whole 32 bytes under the transit key. The
CLI's derive_decaps() is right to use its bytes raw; this path was not.

Every okpqc composite return passes false instead (okpqc.cpp:251 X25519_SS,
:262 MLKEM_SS), which is why composite_decrypt() can use its poll result
directly - and why copying that shape here yielded a plausible 32 bytes that
were simply ciphertext. age reported 'invalid tag', measured on hardware
after the device had decapsulated correctly and the user had confirmed on
the key.

Decrypting host-side rather than dropping the firmware's encryption keeps
the secret covered in transit and leaves the CLI untouched. Matches
onlykey-pgp.js, which already decrypts its responses this way.

Still carries XWTRACE and the dev plugin set; not for merge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159zPxi7DCuucC1ZcqYBB8W
onlykey and others added 2 commits September 15, 2026 18:15
xwing_derive()'s request IS an OKCONNECT, so the device replaces its
transit_key with one derived from the keypair xwing_derive just generated.
transit_key is a single global on the device - the last OKCONNECT wins -
while onlykeyApi.sharedsec still held the key from the api's own connect at
page load.

Everything composite goes out under onlykeyApi.sharedsec (prime_composite ->
aesgcm_encrypt), so after any derive the two disagreed and the device
decrypted the OKDECRYPT chunks with the wrong key. It does not fail loudly:
the chunk count is right, the request reassembles to 1152 bytes of garbage,
the device decapsulates that garbage and returns a well-formed 32-byte
secret that is simply the wrong one. age reports 'invalid tag' - measured on
hardware after a correct derive, a correct encrypt, and a confirmed press.

Still carries XWTRACE and the dev plugin set; not for merge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159zPxi7DCuucC1ZcqYBB8W
Logs the first bytes of the shared secret produced at encapsulation, and of
the 32 bytes the decap returns both before and after the transit decrypt, so
one hardware run says which of the two the device actually sent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159zPxi7DCuucC1ZcqYBB8W
@0c-coder 0c-coder changed the title Drop the DERIVE_*_REQ_PRESS opcodes; press_required is now ignored Derived X-Wing: device-side decapsulation; drop vault and password generator from production Sep 16, 2026
Replaces the TEMP dev deploy (6712b5c..3830515). docs/ is now a production
build again - no devel plugin set, no XWTRACE logging - and age-derive.html
and pgp-pqc.html are present, which is what moving those plugins into
plugins.js was for.

Removed: the XWTRACE instrumentation and the encaps/decaps probes. They did
their job - the ciphertext and shared-secret comparisons they printed are what
located the firmware's scratch-overlap regression - and nothing needs them now.

Kept, all three confirmed on hardware:

- Reassemble the X-Wing ciphertext before decrypting it. Everything after the
  transit pubkey is ONE AES-GCM blob; decrypting the first chunk and appending
  the polled chunks raw spliced plaintext onto ciphertext.
- Take the recipient as the last XWING_PK bytes, not everything after the first
  NUL. The status field is a fixed-width slot, so a short version string left
  padding that got prepended to pk_M.
- Decrypt the derived shared secret in transit, and adopt the transit key the
  derive establishes. xwing_derive()'s request is itself an OKCONNECT, so the
  device replaces transit_key while onlykeyApi.sharedsec still held the
  page-load one.

console_debug.js stays for now - onlyagent.app is the test site, and the TODO
in plugins.js still marks the swap back to console.js for the real production
release.
`counter` was declared at the top of onlykey.extra.js and never incremented, so
every AES-GCM call in a session ran with the same all-zero IV, in both
directions, under one transit key. Same key and same IV means the same
keystream. The device's status string "UNLOCKEDv<version>" comes back in the
clear from a plain OKCONNECT and encrypted at a known offset from the derive
OKCONNECT that follows; XOR the two and the leading keystream falls out, and
every other message in the session starts at keystream offset zero. A derived
X-Wing shared secret is 32 bytes. `tagLength: 0` meant there was nothing to
detect a flipped bit either.

transit_seal() / transit_open() replace aesgcm_encrypt() / aesgcm_decrypt() and
frame every message as

    [counter big-endian(4)][ciphertext(n)][tag(16)]

with IV = [dir(1)][counter(4)][zero(7)], dir 0 device->host and 1 host->device
so the first request and the first response cannot collide. The counter is sent
rather than tracked: Windows 10 1903 delivers every request twice and a derive
rekeys the device mid-session, so either side counting for itself would drift
and then fail everything after the drift.

transit_select() picks v1 or v2 from the firmware version, which the host reads
out of the plain OKCONNECT response - that one is not encrypted, so it is
legible before any framing is chosen. Below 3.0.5 the old scheme is used
unchanged, so nothing breaks against firmware in the field.

Chunks go from 228 to 224 bytes. A credential id is 255 with a 10-byte header,
so one assertion carries 245 and the frame costs 20 of them. No chunk count
changes: RSA-4096 (512 B) still takes 3, an ML-KEM-768 ciphertext (1088 B) still
takes 5, a derived X-Wing [label | ct] (1152 B) still takes 6. The tag is free
in round trips.

Three things fall out of making authentication real:

- poll_for_response() sent OKPING with an EMPTY keyhandle. The device now
  rejects anything shorter than the 20-byte frame, so every poll would have been
  refused - and a refused poll is indistinguishable from "not ready", so a
  composite decrypt would have quietly spun out its budget. It sends a sealed
  empty payload instead, which costs 20 bytes and yields plaintext length 0.

- derive_public_key() and derive_shared_secret() now adopt the transit key the
  device just established, as the X-Wing path already did. Their request is an
  OKCONNECT, so the device replaces its key while onlykeyApi.sharedsec still
  held the page-load one. That was already a bug; the tag makes it fatal rather
  than silent.

- An OKPING response that fails to authenticate is surfaced as an ERROR, never
  as data. The device sends its error strings in the clear, so a failure there is
  usually one of those, but falling back to the raw bytes would mean anything
  that strips a tag gets its payload treated as a signature or a decryption.

Also: stop logging onlykeyApi.sharedsec. A console line carrying the session key
hands the session to anything that can read the console.

Verified against an independent AES-256-GCM (node crypto) and against the
compiled firmware functions over plaintext lengths 0, 1, 16, 32, 57, 224 and 245
and counters 0, 1, 255, 65535 and 0x01020304: frames match byte for byte in both
directions, tampered ciphertext / a flipped counter / a flipped tag / a stripped
tag are all rejected, the v1 fallback is byte-identical to the old code, and the
version gate answers correctly for 3.0.4, 3.0.5, 3.0.6, 3.1.0, 4.0.0, 2.9.9,
v0.2-beta.8c and an empty string.

docs/ is NOT rebuilt here - this is the source change only. Run the production
build before testing on hardware.
Pairs with the libraries change that stops okpqc.cpp sending its six responses
bare. The X25519 and ML-KEM-768 shared secrets - the two values a composite
decryption exists to produce - were crossing the tunnel in the clear while the
classical half encrypted the same thing.

poll_for_response() is told the FRAMED length rather than the plaintext length:
its chunk-shape check compares against what is actually on the wire, and a
32-byte secret now arrives as 52 and a 3309-byte ML-DSA signature as 3329.
transit_framed() does that arithmetic and is a no-op against v1 firmware.
composite_sign() and composite_decrypt() open the frame once the whole response
is reassembled.

Also fixes the same mistake one commit earlier in the derived X-Wing decap poll,
which asked for XWING_SS (32) when the device had already been sealing that one
- correct at 32 under v1, 20 bytes short under v2.
FIDO2 transit: counter IV and a verified tag, instead of one keystream
@0c-coder
0c-coder merged commit 8ac3b1e into heroku-deploy Sep 16, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants