Derived X-Wing: device-side decapsulation; drop vault and password generator from production - #4
Merged
Merged
Conversation
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
Owner
Author
|
Two more removals pushed to this branch:
The vault is prerelease and has never shipped, and neither the vault nor the password generator page has ever worked. |
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
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
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159zPxi7DCuucC1ZcqYBB8W
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_KEYreturns 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 reproducingpk_Malso reproducessk_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 throughsend_stored_response()as an ML-DSA-65 signature already is.DERIVE_*extension entirely: a chunkedOKDECRYPTto slot 128 carrying[label32 | ct(1120)], the same tunnelcomposite_decryptuses, answered with the finished 32-byte shared secret.So
age_pqc.jslosesmlkemKeypairFromSeed(),buildRecipient(),splitDecapsulate()andctXOf()— nothing calls them, and what remains is host/sender-side math on public values.age-derive.jsdrops the recipient lookup it only needed to feedsplitDecapsulate().Also removed:
press_requiredand 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 ofindex-src.html.Depends on
libraries#6. Untested against hardware — the FIDO2 derive path is still under investigation.