From 3b022d652540f7e907498cb1d2ac3519a1de17b3 Mon Sep 17 00:00:00 2001 From: 0c-coder Date: Tue, 30 Jun 2026 15:02:20 -0400 Subject: [PATCH 1/3] Add age/PQC scaffold: device layer (xwing.js + onlykey-pqc.js) --- src/onlykey-fido2/onlykey/onlykey-pqc.js | 104 +++++++++++++++++++++++ src/onlykey-fido2/onlykey/xwing.js | 82 ++++++++++++++++++ 2 files changed, 186 insertions(+) create mode 100644 src/onlykey-fido2/onlykey/onlykey-pqc.js create mode 100644 src/onlykey-fido2/onlykey/xwing.js diff --git a/src/onlykey-fido2/onlykey/onlykey-pqc.js b/src/onlykey-fido2/onlykey/onlykey-pqc.js new file mode 100644 index 00000000..51ffd2f2 --- /dev/null +++ b/src/onlykey-fido2/onlykey/onlykey-pqc.js @@ -0,0 +1,104 @@ +// onlykey-pqc.js — device-side PQC operations for the OnlyKey onlyagent web app. +// Module factory: const onlykeyPqc = require('./onlykey/onlykey-pqc.js')(imports, onlykeyApi); +// +// IMPORTANT: the web/FIDO2 path has NO key slots. The OnlyKey has one reserved +// web-derivation key that derives an unlimited number of per-identity keys (the +// same mechanism the SSH/GPG/age agent already uses). A key is identified by a +// derivation LABEL (the identity), not a slot number — and nothing is stored on +// device; the keypair is reproduced on demand from (reserved key + label). +// +// This reuses the EXISTING derive flow (see index.js): +// encode_ctaphid_request_as_keyhandle(OKCONNECT, optype, keytype, enc_resp, data) +// optype : DERIVE_PUBLIC_KEY = 1 (return the derived public key) +// DERIVE_SHARED_SECRET = 2 (return a 32-byte shared secret) +// keytype: NACL=0 P256R1=1 P256K1=2 CURVE25519=3 + MLKEM768=5 XWING=6 +// data : the derivation input (identity keyhandle) [+ KEM ciphertext] +// +// Why the 32-byte derived secret "just works": +// - XWING (6): X-Wing's private key IS a 32-byte seed; the device expands it +// (SHAKE256) into the ML-KEM-768 + X25519 keypair. Same 32 bytes the +// CURVE25519 path already derives -> zero new key material. +// - MLKEM768 (5): device expands the 32-byte derived secret to ML-KEM's 64-byte +// (d||z) seed, then KeyGen_internal. Deterministic. Pin the exact expansion +// to python-onlykey#90 / firmware libraries#29. +// The host (xwing.js) only ever touches the PUBLIC key; the private key never +// leaves the device and is re-derived each call. + +'use strict'; + +module.exports = function (imports, onlykeyApi) { + const OKCONNECT = 228; + const DERIVE_PUBLIC_KEY = 1; + const DERIVE_SHARED_SECRET = 2; // KEM decapsulation reuses this optype + const NO_ENCRYPT_RESP = 0, ENCRYPT_RESP = 1; + + const KEYTYPE = { MLKEM768: 5, XWING: 6 }; + const PUBKEY_LEN = { 5: 1184, 6: 1216 }; + const CT_LEN = { 5: 1088, 6: 1120 }; + const SS_LEN = 32; + + function assertKeytype(kt) { + if (kt !== KEYTYPE.MLKEM768 && kt !== KEYTYPE.XWING) + throw new Error('keytype must be 5 (ML-KEM-768) or 6 (X-Wing), got ' + kt); + } + + // Build the derivation input ("keyhandle data") for an identity label. The ECC + // path already does this for SSH/age identities — reuse that exact encoder so a + // given label maps to the same derived key across algorithms. + // TODO(verify #90/agent): point this at the existing identity->keyhandle encoder + // (the SLIP-0010/derivation-path packing the agent uses), not a new format. + function deriveInput(label) { + if (typeof label !== 'string' || !label.length) + throw new Error('PQC key needs a non-empty derivation label (identity)'); + throw new Error('deriveInput: reuse the agent identity->keyhandle encoder'); + } + + // Derive + return a PQC public key for an identity. Single derive request; + // response is large (1184/1216 B) and comes back over the existing multi-packet + // poll path that onlykey-api uses for big replies. + async function getPubKey(label, keytype) { + assertKeytype(keytype); + const want = PUBKEY_LEN[keytype]; + const data = deriveInput(label); + return new Promise((resolve, reject) => { + // Reuses the same transport index.js uses for DERIVE_PUBLIC_KEY. + onlykeyApi.ctaphid_via_webauthn( + OKCONNECT, DERIVE_PUBLIC_KEY, keytype, NO_ENCRYPT_RESP, + data, 6000, + function (err, out) { + if (err) return reject(err); + if (!out || out.length < want) + return reject(new Error('short pubkey: got ' + (out && out.length))); + resolve(Uint8Array.from(out.slice(0, want))); + } + ); + }); + } + + // KEM decapsulation = "derive shared secret" with the ciphertext as input. + // The device derives the private key from (reserved key + label), decapsulates + // the ciphertext (1088/1120 B) after a button press, and returns 32 bytes. + // The ciphertext is large, so this must go through the encrypted/chunked + // transit path (same one onlykey-pgp.js `u2fSignBuffer` uses) — prefer to export + // and reuse that sender rather than duplicate it. + async function decapsulate(label, keytype, ciphertext /* Uint8Array */) { + assertKeytype(keytype); + if (ciphertext.length !== CT_LEN[keytype]) + throw new Error('ciphertext must be ' + CT_LEN[keytype] + 'B for keytype ' + keytype); + const data = concat(deriveInput(label), ciphertext); + // TODO(integration): send OKCONNECT + DERIVE_SHARED_SECRET + keytype + data via + // the shared chunked+AES-GCM sender, ENCRYPT_RESP so the 32-byte secret comes + // back encrypted; resolve to the 32-byte shared secret. + throw new Error('decapsulate: wire to shared chunked sender (DERIVE_SHARED_SECRET)'); + } + + function concat(a, b) { + const out = new Uint8Array(a.length + b.length); + out.set(a, 0); out.set(b, a.length); + return out; + } + + // No generate()/no slots: derived keys are stateless. A key "exists" the moment + // you pick a label; getPubKey(label, keytype) reproduces it. Unlimited identities. + return { KEYTYPE, PUBKEY_LEN, CT_LEN, SS_LEN, getPubKey, decapsulate }; +}; diff --git a/src/onlykey-fido2/onlykey/xwing.js b/src/onlykey-fido2/onlykey/xwing.js new file mode 100644 index 00000000..8a7cd4f4 --- /dev/null +++ b/src/onlykey-fido2/onlykey/xwing.js @@ -0,0 +1,82 @@ +// xwing.js — ML-KEM-768 + X-Wing encapsulation and age `mlkem768x25519` +// stanza helpers for the OnlyKey onlyagent web app. +// +// Pure JS, no device required. This is the half of the protocol the HOST runs: +// the browser ENCAPSULATES to a recipient's public key to produce +// { sharedSecret(32B), ciphertext } +// and the OnlyKey later DECAPSULATES that ciphertext (see onlykey-pqc.js). +// +// Sizes (must match firmware libraries#29 / python-onlykey#90): +// ML-KEM-768 : pk 1184, ct 1088, ss 32 +// X-Wing : pk 1216 (= mlkem.pk 1184 || x25519.pk 32), ct 1120 (= 1088 || 32), ss 32 +// +// Deps: npm i @noble/post-quantum @noble/hashes +// Recent @noble/post-quantum ships X-Wing with the draft-09 combiner built in +// (KEM_ID 0x647A). If your version lacks it, see combineXWing() below. + +'use strict'; + +const { ml_kem768 } = require('@noble/post-quantum/ml-kem'); +let xwing = null; +try { xwing = require('@noble/post-quantum/xwing').xwing; } catch (e) { /* fallback below */ } + +const SIZES = { + MLKEM768: { keytype: 5, pk: 1184, ct: 1088, ss: 32 }, + XWING: { keytype: 6, pk: 1216, ct: 1120, ss: 32 }, +}; + +// ---- ML-KEM-768 ---------------------------------------------------------- +function mlkemEncapsulate(recipientPk /* Uint8Array(1184) */) { + if (recipientPk.length !== SIZES.MLKEM768.pk) + throw new Error('ML-KEM-768 pubkey must be 1184 bytes, got ' + recipientPk.length); + // @noble returns { cipherText, sharedSecret } + const { cipherText, sharedSecret } = ml_kem768.encapsulate(recipientPk); + return { ciphertext: cipherText, sharedSecret }; // ct 1088, ss 32 +} + +// ---- X-Wing (hybrid ML-KEM-768 + X25519) --------------------------------- +// Preferred: library-provided X-Wing (handles the draft-09 SHA3-256 combiner +// with label 0x5c2e2f2f5e5c internally). +function xwingEncapsulate(recipientPk /* Uint8Array(1216) */) { + if (recipientPk.length !== SIZES.XWING.pk) + throw new Error('X-Wing pubkey must be 1216 bytes, got ' + recipientPk.length); + if (xwing && xwing.encapsulate) { + const { cipherText, sharedSecret } = xwing.encapsulate(recipientPk); + return { ciphertext: cipherText, sharedSecret }; // ct 1120, ss 32 + } + // TODO(firmware): only used if @noble/post-quantum has no xwing module. + // Implement draft-connolly-cfrg-xwing-kem-09 combiner here and VERIFY the byte + // layout against python-onlykey#90 tests/test_age_wire.py before trusting it. + throw new Error('X-Wing not available in @noble/post-quantum; upgrade the package.'); +} + +function encapsulate(keytype, recipientPk) { + if (keytype === SIZES.MLKEM768.keytype) return mlkemEncapsulate(recipientPk); + if (keytype === SIZES.XWING.keytype) return xwingEncapsulate(recipientPk); + throw new Error('Unknown PQC keytype ' + keytype); +} + +// ---- age `mlkem768x25519` recipient encoding ----------------------------- +// age recipients are bech32-ish "age1..." strings in stock age; the OnlyKey +// plugin in #90 defines its own recipient label. Keep the raw-pubkey <-> string +// mapping in ONE place and make it match #90 exactly. +// TODO(verify #90): confirm the exact recipient/stanza encoding (bech32 HRP, +// stanza tag "mlkem768x25519", and the HPKE wrap: KEM 0x647A / KDF 0x0001 +// (HKDF-SHA256) / AEAD 0x0003 (ChaCha20Poly1305)) before interop. +function recipientToPubkey(recipientStr) { + // TODO(verify #90): decode "age1..."/onlykey recipient -> Uint8Array pubkey. + throw new Error('recipientToPubkey: implement per python-onlykey#90 encoding'); +} +function pubkeyToRecipient(keytype, pk) { + // TODO(verify #90): encode pubkey -> recipient string. + throw new Error('pubkeyToRecipient: implement per python-onlykey#90 encoding'); +} + +module.exports = { + SIZES, + encapsulate, + mlkemEncapsulate, + xwingEncapsulate, + recipientToPubkey, + pubkeyToRecipient, +}; From e155e7d856e2c5ee0f9e6e7b22c7dad09d3494a6 Mon Sep 17 00:00:00 2001 From: 0c-coder Date: Tue, 30 Jun 2026 15:03:29 -0400 Subject: [PATCH 2/3] Add age/PQC scaffold: age plugin + INTEGRATION.md --- src/plugins/age/INTEGRATION.md | 101 +++++++++++++++++++++++++++++++++ src/plugins/age/age-pqc.js | 60 ++++++++++++++++++++ 2 files changed, 161 insertions(+) create mode 100644 src/plugins/age/INTEGRATION.md create mode 100644 src/plugins/age/age-pqc.js diff --git a/src/plugins/age/INTEGRATION.md b/src/plugins/age/INTEGRATION.md new file mode 100644 index 00000000..af457f2a --- /dev/null +++ b/src/plugins/age/INTEGRATION.md @@ -0,0 +1,101 @@ +# age / PQC scaffold for the OnlyKey web app (onlyagent) + +Target repo: `0c-coder/onlykey.github.io`, branch `heroku-deploy`. +Goal: mirror the `age` PQC feature added in `trustcrypto/python-onlykey#90` +(+ firmware `trustcrypto/libraries#29`) in the browser WebCrypt/onlyagent app. + +This is a **scaffold**: the JS-side encapsulation + age stanza format are real and +testable; the device round-trip (getpubkey / decapsulate over the FIDO2 keyhandle +path) has ONE integration decision that must be confirmed against the firmware — +flagged as `TODO(firmware)` below. + +## What PQC means here +KEM (encryption), not signatures. Two key types: + +| keytype | id | pubkey | ciphertext | shared secret | +|--------------------|----|--------|-----------|---------------| +| `KEYTYPE_MLKEM768` | 5 | 1184 B | 1088 B | 32 B | +| `KEYTYPE_XWING` | 6 | 1216 B | 1120 B | 32 B | + +**No slots on the web path.** The OnlyKey has one reserved web-derivation key that +derives unlimited per-identity keys (the same mechanism the SSH/GPG/age agent +already uses). A key is named by a derivation LABEL (the identity), nothing is +stored on device, and the keypair is re-derived on demand from +(reserved key + label). So PQC reuses the existing derive flow, just with new +keytype bytes 5/6. + +Existing derive flow (index.js), unchanged except keytype: +`encode_ctaphid_request_as_keyhandle(OKCONNECT=228, optype, keytype, enc_resp, data)` +- optype: `DERIVE_PUBLIC_KEY=1` (get pubkey), `DERIVE_SHARED_SECRET=2` (get 32-byte + secret — KEM **decapsulation reuses this**, with the ciphertext as input) +- keytype: `NACL=0 P256R1=1 P256K1=2 CURVE25519=3` + `MLKEM768=5` `XWING=6` +- data: the identity keyhandle [+ KEM ciphertext for decapsulation] + +Why the 32-byte derived secret carries over: per identity the device already +produces a 32-byte derived secret (for ECC that 32 bytes *is* the key). +- **X-Wing (6)**: its private key IS a 32-byte seed — the device SHAKE256-expands + it into the ML-KEM-768 + X25519 keypair. Same 32 bytes the `CURVE25519` path + derives, zero new key material. (X-Wing keeps an X25519 half, so it's literally + your existing derived X25519 key + an ML-KEM key from the same seed.) +- **ML-KEM-768 (5)**: expand the 32-byte secret to ML-KEM's 64-byte `(d||z)` seed, + then `KeyGen_internal`. Pin the exact expansion to #90 / firmware. + +The **host runs encapsulation** (xwing.js, public key only); the **device only +decapsulates** after a button press. X-Wing combiner constants (from #90, +draft-connolly-cfrg-xwing-kem-09): `KEM_ID=0x647A`, `KDF_ID=0x0001`, +`AEAD_ID=0x0003`, label `5c2e2f2f5e5c`. + +## Files in this scaffold +- `xwing.js` — ML-KEM-768 + X-Wing **encapsulation** and the age `mlkem768x25519` + stanza helpers. Pure JS, no device needed. Unit-testable against #90 vectors. +- `onlykey-pqc.js` — device wrappers (`getPubKey`, `decapsulate`) built on the + existing `onlykeyApi.ctaphid_via_webauthn` / `u2fSignBuffer` plumbing. +- `age-pqc.js` — the onlyagent plugin: export recipient, encrypt to a recipient, + decrypt a file by asking the device to decapsulate. + +## Install +``` +npm install @noble/post-quantum @noble/curves @noble/hashes +``` +(tweetnacl is already a dep and can supply X25519 if you prefer it over @noble/curves.) + +## Where each file goes +- `xwing.js` -> `src/onlykey-fido2/onlykey/xwing.js` +- `onlykey-pqc.js` -> `src/onlykey-fido2/onlykey/onlykey-pqc.js` +- `age-pqc.js` -> `src/plugins/age/age-pqc.js` (+ an `age.page.html` like the + other plugins, and register it in `src/plugins.js`) + +## Edits to existing files +1. `src/onlykey-fido2/plugin.js` + - add to `provides`: `"onlykeyPqc"` + - `const onlykeyPqc = require('./onlykey/onlykey-pqc.js')(imports, onlykeyApi);` + - `register(null, { ..., onlykeyPqc });` +2. `src/onlykey-fido2/onlykey/onlykey-pgp.js` + - the binary `is_ecc` / `slotid()+100` scheme only distinguishes RSA vs ECC. + PQC needs to carry (keytype, slot) explicitly — see `onlykey-pqc.js` and + `TODO(firmware)` below. No change needed if PQC uses its own code path. +3. `package.json` — add the `@noble/*` deps above. +4. `docs/index.html` CSP — no change needed (all crypto is local; device I/O is + WebAuthn, which CSP does not gate). Only touch CSP if you add new fetch origins. + +## TODO(verify) — the remaining unknowns (no slot framing needed) +There's no slot to encode on the web path — it's the existing derive flow with +keytype 5/6 — so the earlier "slot byte" worry is gone. What still must be matched +to `python-onlykey#90` / firmware `libraries#29` (byte-exact ref: +`tests/test_age_wire.py`): +1. **deriveInput(label)** — reuse the agent's existing identity→keyhandle encoder + (the derivation-path packing used for SSH/age identities); don't invent a new + format. +2. **decapsulation op** — confirm KEM decaps uses `DERIVE_SHARED_SECRET=2` with the + ciphertext appended to the derivation data (vs a dedicated optype), and that the + 32-byte secret returns with `ENCRYPT_RESP`. +3. **ML-KEM-768 seed expansion** — the device-side 32→64 byte `(d||z)` derivation + for keytype 5 (X-Wing's 32-byte seed needs none). +4. **age stanza/recipient encoding + HPKE wrap** — match #90's `mlkem768x25519` + stanza and the HPKE suite (`KEM 0x647A / KDF 0x0001 / AEAD 0x0003`). + +## Test path +1. `npm install` + `bash BUILD.sh` builds to `docs/`. +2. Unit-test `xwing.js` encapsulation against #90's KAT/wire vectors (no device). +3. With a PQC-firmware OnlyKey: generate a key, export recipient, encrypt a file, + decrypt it (device button press), diff plaintext. diff --git a/src/plugins/age/age-pqc.js b/src/plugins/age/age-pqc.js new file mode 100644 index 00000000..3f6cea94 --- /dev/null +++ b/src/plugins/age/age-pqc.js @@ -0,0 +1,60 @@ +// age-pqc.js — onlyagent plugin: PQC (age `mlkem768x25519`) encrypt/decrypt. +// +// Wiring (architect.js DI, like the other plugins in src/plugins/*): +// consumes: ["app", "window", "onlykeyApi", "onlykeyPqc"] +// Add an `age.page.html` next to this file and register the plugin in +// src/plugins.js (copy how encrypt/decrypt are registered). +// +// Flow: +// - exportRecipient(slot, keytype): read device pubkey -> shareable recipient. +// - encryptToRecipient(recipient, data): HOST-side KEM encapsulate (xwing.js) + +// age stanza wrap. No device needed to ENCRYPT to someone. +// - decryptFile(ageBytes, slot, keytype): pull the stanza ciphertext, ask the +// DEVICE to decapsulate it, then unwrap the file key and decrypt the body. + +'use strict'; + +const xwing = require('../../onlykey-fido2/onlykey/xwing.js'); + +module.exports = function (imports) { + const { onlykeyPqc } = imports; + + // Publish a recipient others can encrypt to (no secrets leave the device). + // `label` is the derivation identity (e.g. "age:personal") — not a slot. + async function exportRecipient(label, keytype) { + const pk = await onlykeyPqc.getPubKey(label, keytype); + return xwing.pubkeyToRecipient(keytype, pk); // TODO(verify #90) encoding + } + + // Encrypt a file to a recipient. Pure host-side; matches `age -r `. + async function encryptToRecipient(recipient, plaintext /* Uint8Array */) { + const { keytype, pk } = xwing.recipientToPubkey(recipient); // TODO(verify #90) + const { ciphertext, sharedSecret } = xwing.encapsulate(keytype, pk); + // TODO(verify #90): derive the age file key and wrap it via HPKE + // (KEM 0x647A / KDF 0x0001 HKDF-SHA256 / AEAD 0x0003 ChaCha20Poly1305), + // emit the `mlkem768x25519` stanza, then ChaCha20Poly1305 the payload. + // Build this to byte-match python-onlykey#90's age output. + return { stanzaCiphertext: ciphertext, sharedSecret /* ...assemble age file */ }; + } + + // Decrypt a file: the device re-derives the private key from `label` and does + // the decapsulation. `label` is the same identity used to export the recipient. + async function decryptFile(ageBytes, label, keytype) { + // TODO(verify #90): parse the age header, find the `mlkem768x25519` stanza and + // extract its KEM ciphertext (1088/1120 B). + const stanzaCiphertext = parseStanzaCiphertext(ageBytes, keytype); + const sharedSecret = await onlykeyPqc.decapsulate(label, keytype, stanzaCiphertext); // device button press + // TODO(verify #90): HKDF(sharedSecret) -> unwrap file key -> ChaCha20Poly1305 + // decrypt the payload. Mirror python-onlykey#90 exactly. + return decryptBody(ageBytes, sharedSecret); + } + + function parseStanzaCiphertext(/* ageBytes, keytype */) { + throw new Error('parseStanzaCiphertext: implement age header parse per #90'); + } + function decryptBody(/* ageBytes, sharedSecret */) { + throw new Error('decryptBody: implement age payload decrypt per #90'); + } + + return { exportRecipient, encryptToRecipient, decryptFile }; +}; From 64c3e6f9d258e9db0f8a604d4d8d394abb9d5adc Mon Sep 17 00:00:00 2001 From: onlykey Date: Wed, 1 Jul 2026 12:54:00 -0400 Subject: [PATCH 3/3] age PQC: split-custody X-Wing (device X25519 + browser ML-KEM) - xwing.js: verified split-decaps crypto (mlkem seed expansion, recipient build, encapsulate, xwingSplitDecapsulate) against @noble/post-quantum - onlykey-pqc.js: 64-byte derive wrappers (getRecipient / decapsulate) over the existing FIDO2 derive flow; device returns [ss_X|mlkem_seed], sk_X never leaves - age-pqc.js: recipient/encrypt/decrypt wired to the verified KEM (age container framing left as scoped TODOs to byte-match the age mlkem768x25519 format) - INTEGRATION.md: pinned spec (HKDF domain separation, 64-byte layout, combiner) - test/xwing-split.test.mjs: proves split decaps == standard encaps shared secret --- package.json | 6 +- src/onlykey-fido2/onlykey/onlykey-pqc.js | 148 ++++++------- src/onlykey-fido2/onlykey/xwing.js | 129 +++++++----- src/plugins/age/INTEGRATION.md | 257 ++++++++++++++--------- src/plugins/age/age-pqc.js | 91 ++++---- test/xwing-split.test.mjs | 73 +++++++ 6 files changed, 427 insertions(+), 277 deletions(-) create mode 100644 test/xwing-split.test.mjs diff --git a/package.json b/package.json index 2fbce5e2..47b6741b 100644 --- a/package.json +++ b/package.json @@ -20,13 +20,17 @@ "build-release": "NODE_OPTIONS=\"--max-old-space-size=8192\" cross-env NODE_ENV=production OUT_DIR=./build webpack -p --config webpack.config.js", "build-site": "cross-env NODE_ENV=development OUT_DIR=./build webpack --devtool source-map --config webpack.config.js", "heroku-postbuild": "bash ./BUILD.sh 1", - "start": "node ./index.js" + "start": "node ./index.js", + "test:pqc": "node --test test/xwing-split.test.mjs" }, "bugs": { "url": "https://github.com/onlykey/onlykey.github.io/issues" }, "homepage": "https://github.com/onlykey/onlykey.github.io#readme", "devDependencies": { + "@noble/curves": "^2.2.0", + "@noble/hashes": "^2.2.0", + "@noble/post-quantum": "^0.6.1", "babel-minify-webpack-plugin": "^0.3.1", "bootstrap": "^4.5.0", "cross-env": "^7.0.2", diff --git a/src/onlykey-fido2/onlykey/onlykey-pqc.js b/src/onlykey-fido2/onlykey/onlykey-pqc.js index 51ffd2f2..e9d60e58 100644 --- a/src/onlykey-fido2/onlykey/onlykey-pqc.js +++ b/src/onlykey-fido2/onlykey/onlykey-pqc.js @@ -1,104 +1,94 @@ -// onlykey-pqc.js — device-side PQC operations for the OnlyKey onlyagent web app. -// Module factory: const onlykeyPqc = require('./onlykey/onlykey-pqc.js')(imports, onlykeyApi); +// onlykey-pqc.js — device wrappers for split-custody X-Wing on the web app. // -// IMPORTANT: the web/FIDO2 path has NO key slots. The OnlyKey has one reserved -// web-derivation key that derives an unlimited number of per-identity keys (the -// same mechanism the SSH/GPG/age agent already uses). A key is identified by a -// derivation LABEL (the identity), not a slot number — and nothing is stored on -// device; the keypair is reproduced on demand from (reserved key + label). +// Model (see src/plugins/age/INTEGRATION.md): the OnlyKey keeps the X25519 half +// (sk_X never leaves) and hands the browser a 32-byte ML-KEM seed. Every device +// round-trip is <= 64 bytes; the 1088-byte ML-KEM ciphertext is decapsulated in +// the browser (xwing.js). Decryption still requires the device for ss_X. // -// This reuses the EXISTING derive flow (see index.js): -// encode_ctaphid_request_as_keyhandle(OKCONNECT, optype, keytype, enc_resp, data) -// optype : DERIVE_PUBLIC_KEY = 1 (return the derived public key) -// DERIVE_SHARED_SECRET = 2 (return a 32-byte shared secret) -// keytype: NACL=0 P256R1=1 P256K1=2 CURVE25519=3 + MLKEM768=5 XWING=6 -// data : the derivation input (identity keyhandle) [+ KEM ciphertext] -// -// Why the 32-byte derived secret "just works": -// - XWING (6): X-Wing's private key IS a 32-byte seed; the device expands it -// (SHAKE256) into the ML-KEM-768 + X25519 keypair. Same 32 bytes the -// CURVE25519 path already derives -> zero new key material. -// - MLKEM768 (5): device expands the 32-byte derived secret to ML-KEM's 64-byte -// (d||z) seed, then KeyGen_internal. Deterministic. Pin the exact expansion -// to python-onlykey#90 / firmware libraries#29. -// The host (xwing.js) only ever touches the PUBLIC key; the private key never -// leaves the device and is re-derived each call. +// Reuses the EXISTING FIDO2 derive flow (bridge_to_onlykey / ok_extension.cpp): +// ctaphid_via_webauthn(OKCONNECT, optype, keytype, enc_resp, data) +// optype : DERIVE_PUBLIC_KEY = 1 -> device returns [ pk_X(32) | mlkem_seed(32) ] +// DERIVE_SHAREDSEC = 2 -> device returns [ ss_X(32) | mlkem_seed(32) ] +// (…_REQ_PRESS = 3/4 require a button press) +// keytype: wire byte 5 for X-Wing (firmware opt2++ -> KEYTYPE_XWING = 6) +// data : derivation label [+ ct_X (32B) for DERIVE_SHAREDSEC] +// enc_resp = ENCRYPT_RESP so the 64-byte reply is wrapped in the transit key. 'use strict'; +const xwing = require('./xwing.js'); + module.exports = function (imports, onlykeyApi) { const OKCONNECT = 228; - const DERIVE_PUBLIC_KEY = 1; - const DERIVE_SHARED_SECRET = 2; // KEM decapsulation reuses this optype - const NO_ENCRYPT_RESP = 0, ENCRYPT_RESP = 1; + const DERIVE_PUBLIC_KEY = 1; + const DERIVE_SHAREDSEC_REQ_PRESS = 4; // decrypt needs a touch + const ENCRYPT_RESP = 1; + const WIRE_KEYTYPE_XWING = 5; // -> KEYTYPE_XWING(6) after firmware opt2++ + const SEED = 32; - const KEYTYPE = { MLKEM768: 5, XWING: 6 }; - const PUBKEY_LEN = { 5: 1184, 6: 1216 }; - const CT_LEN = { 5: 1088, 6: 1120 }; - const SS_LEN = 32; - - function assertKeytype(kt) { - if (kt !== KEYTYPE.MLKEM768 && kt !== KEYTYPE.XWING) - throw new Error('keytype must be 5 (ML-KEM-768) or 6 (X-Wing), got ' + kt); + const enc = (s) => new TextEncoder().encode(s); + function concat(a, b) { + const out = new Uint8Array(a.length + (b ? b.length : 0)); + out.set(a, 0); if (b) out.set(b, a.length); + return out; } - - // Build the derivation input ("keyhandle data") for an identity label. The ECC - // path already does this for SSH/age identities — reuse that exact encoder so a - // given label maps to the same derived key across algorithms. - // TODO(verify #90/agent): point this at the existing identity->keyhandle encoder - // (the SLIP-0010/derivation-path packing the agent uses), not a new format. - function deriveInput(label) { + function labelBytes(label) { if (typeof label !== 'string' || !label.length) - throw new Error('PQC key needs a non-empty derivation label (identity)'); - throw new Error('deriveInput: reuse the agent identity->keyhandle encoder'); + throw new Error('PQC identity needs a non-empty derivation label'); + return enc(label); } - // Derive + return a PQC public key for an identity. Single derive request; - // response is large (1184/1216 B) and comes back over the existing multi-packet - // poll path that onlykey-api uses for big replies. - async function getPubKey(label, keytype) { - assertKeytype(keytype); - const want = PUBKEY_LEN[keytype]; - const data = deriveInput(label); + // Low-level: one derive round-trip; resolves to the raw 64-byte device reply. + function derive64(optype, data, timeoutMs) { return new Promise((resolve, reject) => { - // Reuses the same transport index.js uses for DERIVE_PUBLIC_KEY. onlykeyApi.ctaphid_via_webauthn( - OKCONNECT, DERIVE_PUBLIC_KEY, keytype, NO_ENCRYPT_RESP, - data, 6000, - function (err, out) { + OKCONNECT, optype, WIRE_KEYTYPE_XWING, ENCRYPT_RESP, + data, timeoutMs || 6000, + (err, out) => { if (err) return reject(err); - if (!out || out.length < want) - return reject(new Error('short pubkey: got ' + (out && out.length))); - resolve(Uint8Array.from(out.slice(0, want))); + if (!out || out.length < 64) + return reject(new Error('short PQC reply: got ' + (out && out.length))); + resolve(Uint8Array.from(out.slice(0, 64))); } ); }); } - // KEM decapsulation = "derive shared secret" with the ciphertext as input. - // The device derives the private key from (reserved key + label), decapsulates - // the ciphertext (1088/1120 B) after a button press, and returns 32 bytes. - // The ciphertext is large, so this must go through the encrypted/chunked - // transit path (same one onlykey-pgp.js `u2fSignBuffer` uses) — prefer to export - // and reuse that sender rather than duplicate it. - async function decapsulate(label, keytype, ciphertext /* Uint8Array */) { - assertKeytype(keytype); - if (ciphertext.length !== CT_LEN[keytype]) - throw new Error('ciphertext must be ' + CT_LEN[keytype] + 'B for keytype ' + keytype); - const data = concat(deriveInput(label), ciphertext); - // TODO(integration): send OKCONNECT + DERIVE_SHARED_SECRET + keytype + data via - // the shared chunked+AES-GCM sender, ENCRYPT_RESP so the 32-byte secret comes - // back encrypted; resolve to the 32-byte shared secret. - throw new Error('decapsulate: wire to shared chunked sender (DERIVE_SHARED_SECRET)'); + // Export a recipient for an identity label. + // Returns the 1216-byte X-Wing recipient pubkey and the pk_X needed for decaps. + async function getRecipient(label) { + const r = await derive64(DERIVE_PUBLIC_KEY, labelBytes(label)); + const pkX = r.slice(0, SEED); + const mlkemSeed = r.slice(SEED, 64); + return { + recipientPk: xwing.buildRecipientPubkey(pkX, mlkemSeed), // 1216 + pkX, // needed by decapsulate() + }; } - function concat(a, b) { - const out = new Uint8Array(a.length + b.length); - out.set(a, 0); out.set(b, a.length); - return out; + // Decapsulate an X-Wing ciphertext for `label`. Requires a button press. + // ciphertext : 1120-byte stanza ct (ct_M || ct_X) + // pkX : recipient X25519 public (from getRecipient / the recipient string) + // Returns the 32-byte X-Wing shared secret. + async function decapsulate(label, ciphertext, pkX) { + if (ciphertext.length !== xwing.SIZES.XWING.ct) + throw new Error('X-Wing ct must be 1120 bytes, got ' + ciphertext.length); + const ctX = xwing.ctX(ciphertext); // 32B is all the device sees + const data = concat(labelBytes(label), ctX); + const r = await derive64(DERIVE_SHAREDSEC_REQ_PRESS, data, 30000); + const ssX = r.slice(0, SEED); + const mlkemSeed = r.slice(SEED, 64); + return xwing.xwingSplitDecapsulate(ssX, ciphertext, pkX, mlkemSeed); } - // No generate()/no slots: derived keys are stateless. A key "exists" the moment - // you pick a label; getPubKey(label, keytype) reproduces it. Unlimited identities. - return { KEYTYPE, PUBKEY_LEN, CT_LEN, SS_LEN, getPubKey, decapsulate }; + return { + KEYTYPE_XWING: 6, + WIRE_KEYTYPE_XWING, + getRecipient, + decapsulate, + // pure-crypto helpers re-exported for the age plugin + buildRecipientPubkey: xwing.buildRecipientPubkey, + xwingEncapsulate: xwing.xwingEncapsulate, + SIZES: xwing.SIZES, + }; }; diff --git a/src/onlykey-fido2/onlykey/xwing.js b/src/onlykey-fido2/onlykey/xwing.js index 8a7cd4f4..f5b1eaaf 100644 --- a/src/onlykey-fido2/onlykey/xwing.js +++ b/src/onlykey-fido2/onlykey/xwing.js @@ -1,82 +1,95 @@ -// xwing.js — ML-KEM-768 + X-Wing encapsulation and age `mlkem768x25519` -// stanza helpers for the OnlyKey onlyagent web app. +// xwing.js — ML-KEM-768 + X-Wing (age `mlkem768x25519`) crypto for the OnlyKey +// onlyagent web app, using the SPLIT-CUSTODY model: // -// Pure JS, no device required. This is the half of the protocol the HOST runs: -// the browser ENCAPSULATES to a recipient's public key to produce -// { sharedSecret(32B), ciphertext } -// and the OnlyKey later DECAPSULATES that ciphertext (see onlykey-pqc.js). +// * X25519 half stays on the OnlyKey (device computes ss_X = X25519(sk_X, ct_X), +// sk_X never leaves — this is the existing DERIVE_SHAREDSEC / ECDH primitive). +// * ML-KEM half runs here in the browser: the device hands us a 32-byte +// `mlkem_seed`, we expand it to the ML-KEM keypair and decapsulate the +// 1088-byte ct_M locally, so the big ciphertext never goes to the device. // -// Sizes (must match firmware libraries#29 / python-onlykey#90): -// ML-KEM-768 : pk 1184, ct 1088, ss 32 -// X-Wing : pk 1216 (= mlkem.pk 1184 || x25519.pk 32), ct 1120 (= 1088 || 32), ss 32 +// Every device round-trip is <= 64 bytes. Decryption still REQUIRES the OnlyKey +// (no ss_X without it). The recipient is a STANDARD X-Wing public key, so normal +// age encryptors interoperate. See src/plugins/age/INTEGRATION.md for the spec. // -// Deps: npm i @noble/post-quantum @noble/hashes -// Recent @noble/post-quantum ships X-Wing with the draft-09 combiner built in -// (KEM_ID 0x647A). If your version lacks it, see combineXWing() below. +// Verified against @noble/post-quantum by test/xwing-split.test.mjs (the split +// decapsulation reproduces standard-encaps shared secret, byte-for-byte). +// +// Deps: npm i @noble/post-quantum @noble/curves @noble/hashes (>= 0.6) 'use strict'; -const { ml_kem768 } = require('@noble/post-quantum/ml-kem'); -let xwing = null; -try { xwing = require('@noble/post-quantum/xwing').xwing; } catch (e) { /* fallback below */ } +const { ml_kem768_x25519 } = require('@noble/post-quantum/hybrid.js'); +const { ml_kem768 } = require('@noble/post-quantum/ml-kem.js'); +const { x25519 } = require('@noble/curves/ed25519.js'); +const { shake256, sha3_256 } = require('@noble/hashes/sha3.js'); +const { concatBytes } = require('@noble/hashes/utils.js'); + +// draft-connolly-cfrg-xwing-kem-09 combiner label "\.//^\" +const XWING_LABEL = new Uint8Array([0x5c, 0x2e, 0x2f, 0x2f, 0x5e, 0x5c]); const SIZES = { - MLKEM768: { keytype: 5, pk: 1184, ct: 1088, ss: 32 }, - XWING: { keytype: 6, pk: 1216, ct: 1120, ss: 32 }, + KEYTYPE_MLKEM768: 5, + KEYTYPE_XWING: 6, + MLKEM: { pk: 1184, ct: 1088, ss: 32 }, + XWING: { pk: 1216, ct: 1120, ss: 32, seed: 32 }, + X25519: { pk: 32, ct: 32, ss: 32 }, }; -// ---- ML-KEM-768 ---------------------------------------------------------- -function mlkemEncapsulate(recipientPk /* Uint8Array(1184) */) { - if (recipientPk.length !== SIZES.MLKEM768.pk) - throw new Error('ML-KEM-768 pubkey must be 1184 bytes, got ' + recipientPk.length); - // @noble returns { cipherText, sharedSecret } - const { cipherText, sharedSecret } = ml_kem768.encapsulate(recipientPk); - return { ciphertext: cipherText, sharedSecret }; // ct 1088, ss 32 +// ---- ML-KEM key material from the 32-byte device seed -------------------- +// Pinned expansion (must match firmware): SHAKE256(mlkem_seed, 64) -> (d||z), +// then ML-KEM KeyGen_internal. +function mlkemKeypairFromSeed(mlkemSeed /* Uint8Array(32) */) { + if (mlkemSeed.length !== 32) throw new Error('mlkem_seed must be 32 bytes'); + const seed64 = shake256(mlkemSeed, { dkLen: 64 }); + return ml_kem768.keygen(seed64); // { publicKey (1184), secretKey (2400) } } -// ---- X-Wing (hybrid ML-KEM-768 + X25519) --------------------------------- -// Preferred: library-provided X-Wing (handles the draft-09 SHA3-256 combiner -// with label 0x5c2e2f2f5e5c internally). -function xwingEncapsulate(recipientPk /* Uint8Array(1216) */) { +// ---- Recipient (X-Wing public key = pk_M || pk_X) ------------------------ +// Build from what the device returns for DERIVE_PUBLIC_KEY: [pk_X | mlkem_seed]. +function buildRecipientPubkey(pkX /* 32 */, mlkemSeed /* 32 */) { + if (pkX.length !== 32) throw new Error('pk_X must be 32 bytes'); + const { publicKey: pkM } = mlkemKeypairFromSeed(mlkemSeed); + return concatBytes(pkM, pkX); // 1216 +} + +// ---- Encapsulation (host side; no device needed) ------------------------- +// Standard X-Wing encaps to a recipient's 1216-byte public key. +function xwingEncapsulate(recipientPk /* 1216 */) { if (recipientPk.length !== SIZES.XWING.pk) throw new Error('X-Wing pubkey must be 1216 bytes, got ' + recipientPk.length); - if (xwing && xwing.encapsulate) { - const { cipherText, sharedSecret } = xwing.encapsulate(recipientPk); - return { ciphertext: cipherText, sharedSecret }; // ct 1120, ss 32 - } - // TODO(firmware): only used if @noble/post-quantum has no xwing module. - // Implement draft-connolly-cfrg-xwing-kem-09 combiner here and VERIFY the byte - // layout against python-onlykey#90 tests/test_age_wire.py before trusting it. - throw new Error('X-Wing not available in @noble/post-quantum; upgrade the package.'); + const { cipherText, sharedSecret } = ml_kem768_x25519.encapsulate(recipientPk); + return { ciphertext: cipherText, sharedSecret }; // ct 1120, ss 32 } -function encapsulate(keytype, recipientPk) { - if (keytype === SIZES.MLKEM768.keytype) return mlkemEncapsulate(recipientPk); - if (keytype === SIZES.XWING.keytype) return xwingEncapsulate(recipientPk); - throw new Error('Unknown PQC keytype ' + keytype); +// ---- Split decapsulation (browser half) ---------------------------------- +// Inputs: +// ssX : 32-byte X25519 shared secret returned by the device (ss_X) +// ciphertext : 1120-byte X-Wing ct (ct_M || ct_X) from the age stanza +// pkX : 32-byte recipient X25519 public (from the recipient) +// mlkemSeed : 32-byte ML-KEM seed returned by the device +// Returns the 32-byte X-Wing shared secret. ct_M never leaves the browser. +function xwingSplitDecapsulate(ssX, ciphertext, pkX, mlkemSeed) { + if (ssX.length !== 32) throw new Error('ss_X must be 32 bytes'); + if (ciphertext.length !== SIZES.XWING.ct) + throw new Error('X-Wing ct must be 1120 bytes, got ' + ciphertext.length); + const ctM = ciphertext.slice(0, SIZES.MLKEM.ct); + const ctX = ciphertext.slice(SIZES.MLKEM.ct, SIZES.XWING.ct); + const { secretKey: skM } = mlkemKeypairFromSeed(mlkemSeed); + const ssM = ml_kem768.decapsulate(ctM, skM); // ML-KEM decaps in the browser + return sha3_256(concatBytes(ssM, ssX, ctX, pkX, XWING_LABEL)); } -// ---- age `mlkem768x25519` recipient encoding ----------------------------- -// age recipients are bech32-ish "age1..." strings in stock age; the OnlyKey -// plugin in #90 defines its own recipient label. Keep the raw-pubkey <-> string -// mapping in ONE place and make it match #90 exactly. -// TODO(verify #90): confirm the exact recipient/stanza encoding (bech32 HRP, -// stanza tag "mlkem768x25519", and the HPKE wrap: KEM 0x647A / KDF 0x0001 -// (HKDF-SHA256) / AEAD 0x0003 (ChaCha20Poly1305)) before interop. -function recipientToPubkey(recipientStr) { - // TODO(verify #90): decode "age1..."/onlykey recipient -> Uint8Array pubkey. - throw new Error('recipientToPubkey: implement per python-onlykey#90 encoding'); -} -function pubkeyToRecipient(keytype, pk) { - // TODO(verify #90): encode pubkey -> recipient string. - throw new Error('pubkeyToRecipient: implement per python-onlykey#90 encoding'); +// Convenience: pull ct_X out of a stanza ciphertext (what the device needs). +function ctX(ciphertext) { + return ciphertext.slice(SIZES.MLKEM.ct, SIZES.XWING.ct); } module.exports = { SIZES, - encapsulate, - mlkemEncapsulate, + XWING_LABEL, + mlkemKeypairFromSeed, + buildRecipientPubkey, xwingEncapsulate, - recipientToPubkey, - pubkeyToRecipient, + xwingSplitDecapsulate, + ctX, }; diff --git a/src/plugins/age/INTEGRATION.md b/src/plugins/age/INTEGRATION.md index af457f2a..ba6027ac 100644 --- a/src/plugins/age/INTEGRATION.md +++ b/src/plugins/age/INTEGRATION.md @@ -1,101 +1,156 @@ -# age / PQC scaffold for the OnlyKey web app (onlyagent) - -Target repo: `0c-coder/onlykey.github.io`, branch `heroku-deploy`. -Goal: mirror the `age` PQC feature added in `trustcrypto/python-onlykey#90` -(+ firmware `trustcrypto/libraries#29`) in the browser WebCrypt/onlyagent app. - -This is a **scaffold**: the JS-side encapsulation + age stanza format are real and -testable; the device round-trip (getpubkey / decapsulate over the FIDO2 keyhandle -path) has ONE integration decision that must be confirmed against the firmware — -flagged as `TODO(firmware)` below. - -## What PQC means here -KEM (encryption), not signatures. Two key types: - -| keytype | id | pubkey | ciphertext | shared secret | -|--------------------|----|--------|-----------|---------------| -| `KEYTYPE_MLKEM768` | 5 | 1184 B | 1088 B | 32 B | -| `KEYTYPE_XWING` | 6 | 1216 B | 1120 B | 32 B | - -**No slots on the web path.** The OnlyKey has one reserved web-derivation key that -derives unlimited per-identity keys (the same mechanism the SSH/GPG/age agent -already uses). A key is named by a derivation LABEL (the identity), nothing is -stored on device, and the keypair is re-derived on demand from -(reserved key + label). So PQC reuses the existing derive flow, just with new -keytype bytes 5/6. - -Existing derive flow (index.js), unchanged except keytype: -`encode_ctaphid_request_as_keyhandle(OKCONNECT=228, optype, keytype, enc_resp, data)` -- optype: `DERIVE_PUBLIC_KEY=1` (get pubkey), `DERIVE_SHARED_SECRET=2` (get 32-byte - secret — KEM **decapsulation reuses this**, with the ciphertext as input) -- keytype: `NACL=0 P256R1=1 P256K1=2 CURVE25519=3` + `MLKEM768=5` `XWING=6` -- data: the identity keyhandle [+ KEM ciphertext for decapsulation] - -Why the 32-byte derived secret carries over: per identity the device already -produces a 32-byte derived secret (for ECC that 32 bytes *is* the key). -- **X-Wing (6)**: its private key IS a 32-byte seed — the device SHAKE256-expands - it into the ML-KEM-768 + X25519 keypair. Same 32 bytes the `CURVE25519` path - derives, zero new key material. (X-Wing keeps an X25519 half, so it's literally - your existing derived X25519 key + an ML-KEM key from the same seed.) -- **ML-KEM-768 (5)**: expand the 32-byte secret to ML-KEM's 64-byte `(d||z)` seed, - then `KeyGen_internal`. Pin the exact expansion to #90 / firmware. - -The **host runs encapsulation** (xwing.js, public key only); the **device only -decapsulates** after a button press. X-Wing combiner constants (from #90, -draft-connolly-cfrg-xwing-kem-09): `KEM_ID=0x647A`, `KDF_ID=0x0001`, -`AEAD_ID=0x0003`, label `5c2e2f2f5e5c`. - -## Files in this scaffold -- `xwing.js` — ML-KEM-768 + X-Wing **encapsulation** and the age `mlkem768x25519` - stanza helpers. Pure JS, no device needed. Unit-testable against #90 vectors. -- `onlykey-pqc.js` — device wrappers (`getPubKey`, `decapsulate`) built on the - existing `onlykeyApi.ctaphid_via_webauthn` / `u2fSignBuffer` plumbing. -- `age-pqc.js` — the onlyagent plugin: export recipient, encrypt to a recipient, - decrypt a file by asking the device to decapsulate. - -## Install -``` -npm install @noble/post-quantum @noble/curves @noble/hashes -``` -(tweetnacl is already a dep and can supply X25519 if you prefer it over @noble/curves.) - -## Where each file goes -- `xwing.js` -> `src/onlykey-fido2/onlykey/xwing.js` -- `onlykey-pqc.js` -> `src/onlykey-fido2/onlykey/onlykey-pqc.js` -- `age-pqc.js` -> `src/plugins/age/age-pqc.js` (+ an `age.page.html` like the - other plugins, and register it in `src/plugins.js`) - -## Edits to existing files -1. `src/onlykey-fido2/plugin.js` - - add to `provides`: `"onlykeyPqc"` - - `const onlykeyPqc = require('./onlykey/onlykey-pqc.js')(imports, onlykeyApi);` - - `register(null, { ..., onlykeyPqc });` -2. `src/onlykey-fido2/onlykey/onlykey-pgp.js` - - the binary `is_ecc` / `slotid()+100` scheme only distinguishes RSA vs ECC. - PQC needs to carry (keytype, slot) explicitly — see `onlykey-pqc.js` and - `TODO(firmware)` below. No change needed if PQC uses its own code path. -3. `package.json` — add the `@noble/*` deps above. -4. `docs/index.html` CSP — no change needed (all crypto is local; device I/O is - WebAuthn, which CSP does not gate). Only touch CSP if you add new fetch origins. - -## TODO(verify) — the remaining unknowns (no slot framing needed) -There's no slot to encode on the web path — it's the existing derive flow with -keytype 5/6 — so the earlier "slot byte" worry is gone. What still must be matched -to `python-onlykey#90` / firmware `libraries#29` (byte-exact ref: -`tests/test_age_wire.py`): -1. **deriveInput(label)** — reuse the agent's existing identity→keyhandle encoder - (the derivation-path packing used for SSH/age identities); don't invent a new - format. -2. **decapsulation op** — confirm KEM decaps uses `DERIVE_SHARED_SECRET=2` with the - ciphertext appended to the derivation data (vs a dedicated optype), and that the - 32-byte secret returns with `ENCRYPT_RESP`. -3. **ML-KEM-768 seed expansion** — the device-side 32→64 byte `(d||z)` derivation - for keytype 5 (X-Wing's 32-byte seed needs none). -4. **age stanza/recipient encoding + HPKE wrap** — match #90's `mlkem768x25519` - stanza and the HPKE suite (`KEM 0x647A / KDF 0x0001 / AEAD 0x0003`). - -## Test path -1. `npm install` + `bash BUILD.sh` builds to `docs/`. -2. Unit-test `xwing.js` encapsulation against #90's KAT/wire vectors (no device). -3. With a PQC-firmware OnlyKey: generate a key, export recipient, encrypt a file, - decrypt it (device button press), diff plaintext. +# Web-app PQC over the FIDO2 derive path — pinned spec + +**Scope:** X-Wing (`mlkem768x25519`) age encryption for the OnlyKey web app +(`onlykey.github.io`) over the existing FIDO2/CTAP keyhandle derive flow +(`fido2/ok_extension.cpp`), plus the small firmware primitive it needs. +**Out of scope:** the CLI slot-based model (`python-onlykey#90` / firmware +`#29`) — it is a separate, intentionally-incompatible custody model and is NOT +addressed here. + +## 1. Model — split X-Wing custody + +X-Wing decapsulation is: + +``` +ss = SHA3-256( ss_M || ss_X || ct_X || pk_X || XWingLabel ) + ss_M = ML-KEM-768.Decaps(sk_M, ct_M) # ct_M = 1088 B + ss_X = X25519(sk_X, ct_X) # ct_X = 32 B (this is ECDH) + XWingLabel = 0x5c 2e 2f 2f 5e 5c # draft-connolly-cfrg-xwing-kem-09 +``` + +Custody split: +- **X25519 half stays on the device.** `sk_X` is label-derived; the device + computes `ss_X` and never releases `sk_X`. (Exactly today's `DERIVE_SHAREDSEC`.) +- **ML-KEM half runs in the browser.** The device hands the browser a 32-byte + `mlkem_seed`; the browser expands it to `sk_M`, decapsulates the 1088-byte + `ct_M` locally, and never sends `ct_M` to the device. + +Every device round-trip is ≤ 64 bytes. Decryption requires the OnlyKey (no +`ss_X` without it). The recipient string is a **standard** X-Wing pubkey — only +private-key custody is split, so standard age encryptors interoperate. + +## 2. Constants + +``` +KEYTYPE_MLKEM768 = 5 # firmware okcore.h +KEYTYPE_XWING = 6 +# Wire keytype in the keyhandle follows the existing "+1" convention +# (firmware bridge_to_onlykey does opt2++), so: +WIRE_KEYTYPE_XWING = KEYTYPE_XWING - 1 = 5 # NACL=0,P256R1=1,P256K1=2,CURVE25519=3,XWING=5 +sizes: pk_M 1184 | pk_X 32 | pk 1216 | ct_M 1088 | ct_X 32 | ct 1120 | ss 32 | seed 32 +HPKE (unchanged, from #90): KEM_ID 0x647A | KDF_ID 0x0001 | AEAD_ID 0x0003 +``` + +## 3. Key derivation (firmware) — MUST be domain-separated + +`sk_X` is already `HKDF(web_derivation_key, label_data)` (the existing +`okcrypto_derive_key(KEYTYPE_CURVE25519, additional_data, RESERVED_KEY_WEB_DERIVATION)`). +Derive the ML-KEM seed **one-way from `sk_X`** so it can never leak `sk_X`: + +``` +mlkem_seed = HKDF-SHA256( + IKM = sk_X, # the label-derived X25519 private + salt = "", # (empty) + info = "onlykey/xwing/mlkem768-seed/v1", + L = 32 +) +``` + +Properties: +- `mlkem_seed` depends only on `sk_X`, i.e. only on `(web_derivation_key, label)` + — **constant per label**, independent of the per-message `ct_X`. So `pk_M` + (and the recipient string) is stable. +- HKDF is one-way, so a browser holding `mlkem_seed` learns nothing about `sk_X`. +- `mlkem_seed != sk_X` by construction (distinct `info`), so returning it never + discloses the X25519 private. + +> Alternative (equivalent): derive both independently from +> `web_derivation_key` with `info="…/x25519/v1"` and `info="…/mlkem768-seed/v1"`. +> Either is fine; pick one and pin it. + +## 4. Firmware change (`fido2/ok_extension.cpp`, `bridge_to_onlykey`) + +Add an X-Wing branch to the derive dispatch (the `opt2 == KEYTYPE_*` block, +~lines 214–229) and to `DERIVE_SHAREDSEC` (~243–287): + +**DERIVE_PUBLIC_KEY, keytype X-Wing** → return 64 bytes: +``` +[ pk_X (32) ][ mlkem_seed (32) ] + pk_X = Curve25519 public of the label-derived sk_X (existing) + mlkem_seed = HKDF(sk_X, "onlykey/xwing/mlkem768-seed/v1") +``` +(No user presence required — public material only.) + +**DERIVE_SHAREDSEC, keytype X-Wing** → input `ct_X` (the 32-byte X25519 ephemeral +from the age stanza), return 64 bytes: +``` +[ ss_X (32) ][ mlkem_seed (32) ] + ss_X = okcrypto_shared_secret(ct_X, sk_X) (existing ECDH) + mlkem_seed = HKDF(sk_X, "onlykey/xwing/mlkem768-seed/v1") +``` +Use `DERIVE_SHAREDSEC_REQ_PRESS` (button) for actual decryption. + +Both responses go out via `send_transport_response(..., opt3, ...)` with +`opt3 = ENCRYPT_RESP`, so the 64 bytes are AES-encrypted under the per-session +`transit_key` established at OKCONNECT (ECDH → SHA-256). `sk_X`, `sk_M`, and +`web_derivation_key` never leave the device. + +## 5. Wire format + +Request keyhandle (existing layout, `bridge_to_onlykey`): +``` +keyh[0] = OKCONNECT bridge cmd +keyh[1]=opt1 = DERIVE_PUBLIC_KEY(1) | DERIVE_SHAREDSEC(2) | *_REQ_PRESS(3/4) +keyh[2]=opt2 = WIRE_KEYTYPE_XWING (5) # firmware opt2++ -> KEYTYPE_XWING(6) +keyh[3]=opt3 = ENCRYPT_RESP (1) +client_handle+9 = app transit public (32) # OKCONNECT session +client_handle+43 = label_data (32) # identity/derivation input +client_handle+43+32 = input_pubkey = ct_X (32) # only for DERIVE_SHAREDSEC +``` +Response (encrypted under `transit_key`): the 64-byte payload above. + +## 6. Browser (`onlykey.github.io`) + +**Recipient / identity (once):** +``` +(pk_X, mlkem_seed) = device.DERIVE_PUBLIC_KEY(label, XWING) +seed64 = SHAKE256(mlkem_seed, 64) # (d||z) for ML-KEM +{ pk_M, _ } = ml_kem768.keygen(seed64) # noble keygen_internal +recipient = encodeRecipient( pk_M || pk_X ) # standard mlkem768x25519 +``` + +**Encrypt** (no device): standard X-Wing `Encaps(recipientPk)` — already in +`xwing.js`. + +**Decrypt a stanza** `-> mlkem768x25519 ` where `ct = ct_M(1088)||ct_X(32)`: +``` +(ss_X, mlkem_seed) = device.DERIVE_SHAREDSEC_REQ_PRESS(label, XWING, ct_X) # button +seed64 = SHAKE256(mlkem_seed, 64) +{ _, sk_M } = ml_kem768.keygen(seed64) +ss_M = ml_kem768.decapsulate(ct_M, sk_M) # 1088 B stays in browser +ss = SHA3-256( ss_M || ss_X || ct_X || pk_X || 0x5c2e2f2f5e5c ) +file_key = HPKE-open(ss, ct, aead_body) # KEM 0x647A/KDF 0x0001/AEAD 0x0003 +``` +`ct_M` never leaves the browser; only the 32-byte `ct_X` goes to the device. + +## 7. Pinned — must match byte-exactly (firmware ⇄ browser) + +1. `mlkem_seed = HKDF-SHA256(sk_X, info="onlykey/xwing/mlkem768-seed/v1", L=32)`. +2. `sk_X` = the existing `RESERVED_KEY_WEB_DERIVATION` Curve25519 derivation. +3. ML-KEM seed expansion: `SHAKE256(mlkem_seed, 64)` → ML-KEM `keygen_internal`. +4. X-Wing combiner: `SHA3-256(ss_M||ss_X||ct_X||pk_X||0x5c2e2f2f5e5c)` (draft-09). +5. HPKE suite `0x647A / 0x0001 / 0x0003`; stanza tag `mlkem768x25519`. +6. Wire keytype byte = 5 (→ `KEYTYPE_XWING` after `opt2++`). +7. 64-byte response order: `[first-32][second-32]` = `[ss_X|pk_X][mlkem_seed]`. + +## 8. Security posture + +- **Classical security is device-bound.** No decryption without the OnlyKey: + `ss_X` requires `sk_X`, which never leaves. Browser compromise + `mlkem_seed` + alone cannot decrypt. +- **Post-quantum security is device-gated.** `mlkem_seed` lives in the browser + only while the OnlyKey is connected/unlocked for that origin (the existing + WebCrypt "private web" posture). A browser fully compromised *while unlocked* + can harvest `mlkem_seed`; that is the accepted trade for browser PQC. +- `web_derivation_key` and `sk_X` never leave the device; `mlkem_seed` is + one-way-separated from `sk_X`. diff --git a/src/plugins/age/age-pqc.js b/src/plugins/age/age-pqc.js index 3f6cea94..6eda59be 100644 --- a/src/plugins/age/age-pqc.js +++ b/src/plugins/age/age-pqc.js @@ -1,16 +1,14 @@ -// age-pqc.js — onlyagent plugin: PQC (age `mlkem768x25519`) encrypt/decrypt. +// age-pqc.js — onlyagent plugin: PQC (age `mlkem768x25519`) encrypt/decrypt, +// split-custody model (device does X25519, browser does ML-KEM). See +// src/plugins/age/INTEGRATION.md and onlykey-fido2/onlykey/{xwing,onlykey-pqc}.js. // // Wiring (architect.js DI, like the other plugins in src/plugins/*): // consumes: ["app", "window", "onlykeyApi", "onlykeyPqc"] -// Add an `age.page.html` next to this file and register the plugin in -// src/plugins.js (copy how encrypt/decrypt are registered). // -// Flow: -// - exportRecipient(slot, keytype): read device pubkey -> shareable recipient. -// - encryptToRecipient(recipient, data): HOST-side KEM encapsulate (xwing.js) + -// age stanza wrap. No device needed to ENCRYPT to someone. -// - decryptFile(ageBytes, slot, keytype): pull the stanza ciphertext, ask the -// DEVICE to decapsulate it, then unwrap the file key and decrypt the body. +// Crypto path (KEM) is implemented + unit-tested (test/xwing-split.test.mjs). +// The age CONTAINER layer (header/stanza framing, HPKE wrap, ChaCha20-Poly1305 +// payload, HMAC) is the remaining plumbing and must byte-match the age +// `mlkem768x25519` format used by python-onlykey#90 — kept isolated below. 'use strict'; @@ -19,42 +17,59 @@ const xwing = require('../../onlykey-fido2/onlykey/xwing.js'); module.exports = function (imports) { const { onlykeyPqc } = imports; + // ---- recipient string <-> raw X-Wing pubkey ---------------------------- + // NOTE: string form must match the canonical age `mlkem768x25519` recipient + // encoding (bech32 age1…). Until pinned, use base64 of the raw 1216-byte key. + function encodeRecipient(pk /* 1216 */) { + return 'onlykey-mlkem768x25519:' + Buffer.from(pk).toString('base64'); + } + function decodeRecipient(str) { + const b64 = String(str).split(':').pop(); + const pk = Uint8Array.from(Buffer.from(b64, 'base64')); + if (pk.length !== xwing.SIZES.XWING.pk) + throw new Error('recipient pubkey must be 1216 bytes, got ' + pk.length); + return pk; + } + function pkXfromRecipient(pk /* 1216 */) { + return pk.slice(xwing.SIZES.MLKEM.pk, xwing.SIZES.XWING.pk); // trailing 32B + } + // Publish a recipient others can encrypt to (no secrets leave the device). // `label` is the derivation identity (e.g. "age:personal") — not a slot. - async function exportRecipient(label, keytype) { - const pk = await onlykeyPqc.getPubKey(label, keytype); - return xwing.pubkeyToRecipient(keytype, pk); // TODO(verify #90) encoding + async function exportRecipient(label) { + const { recipientPk } = await onlykeyPqc.getRecipient(label); + return encodeRecipient(recipientPk); } - // Encrypt a file to a recipient. Pure host-side; matches `age -r `. + // Encrypt to a recipient. Pure host-side (like `age -r `), no device. async function encryptToRecipient(recipient, plaintext /* Uint8Array */) { - const { keytype, pk } = xwing.recipientToPubkey(recipient); // TODO(verify #90) - const { ciphertext, sharedSecret } = xwing.encapsulate(keytype, pk); - // TODO(verify #90): derive the age file key and wrap it via HPKE - // (KEM 0x647A / KDF 0x0001 HKDF-SHA256 / AEAD 0x0003 ChaCha20Poly1305), - // emit the `mlkem768x25519` stanza, then ChaCha20Poly1305 the payload. - // Build this to byte-match python-onlykey#90's age output. - return { stanzaCiphertext: ciphertext, sharedSecret /* ...assemble age file */ }; - } - - // Decrypt a file: the device re-derives the private key from `label` and does - // the decapsulation. `label` is the same identity used to export the recipient. - async function decryptFile(ageBytes, label, keytype) { - // TODO(verify #90): parse the age header, find the `mlkem768x25519` stanza and - // extract its KEM ciphertext (1088/1120 B). - const stanzaCiphertext = parseStanzaCiphertext(ageBytes, keytype); - const sharedSecret = await onlykeyPqc.decapsulate(label, keytype, stanzaCiphertext); // device button press - // TODO(verify #90): HKDF(sharedSecret) -> unwrap file key -> ChaCha20Poly1305 - // decrypt the payload. Mirror python-onlykey#90 exactly. - return decryptBody(ageBytes, sharedSecret); - } - - function parseStanzaCiphertext(/* ageBytes, keytype */) { + const pk = decodeRecipient(recipient); + const { ciphertext, sharedSecret } = xwing.xwingEncapsulate(pk); // ct 1120, ss 32 + // sharedSecret wraps the age file key via the HPKE suite + // (KEM 0x647A / KDF 0x0001 HKDF-SHA256 / AEAD 0x0003 ChaCha20-Poly1305). + return assembleAgeFile(ciphertext, sharedSecret, plaintext); + } + + // Decrypt: the device re-derives sk_X from `label` and returns ss_X; the + // browser does the ML-KEM half and combines. `label` matches exportRecipient. + async function decryptFile(ageBytes, label, recipient) { + const stanzaCt = parseStanzaCiphertext(ageBytes); // 1120-byte X-Wing ct + const pkX = pkXfromRecipient(decodeRecipient(recipient)); + const sharedSecret = await onlykeyPqc.decapsulate(label, stanzaCt, pkX); // button press + return openAgeFile(ageBytes, sharedSecret); + } + + // ---- age container layer (TODO: byte-match age mlkem768x25519 / #90) ----- + function assembleAgeFile(/* ciphertext, sharedSecret, plaintext */) { + throw new Error('assembleAgeFile: implement age header/stanza + ChaCha payload per #90'); + } + function parseStanzaCiphertext(/* ageBytes */) { throw new Error('parseStanzaCiphertext: implement age header parse per #90'); } - function decryptBody(/* ageBytes, sharedSecret */) { - throw new Error('decryptBody: implement age payload decrypt per #90'); + function openAgeFile(/* ageBytes, sharedSecret */) { + throw new Error('openAgeFile: implement age payload decrypt per #90'); } - return { exportRecipient, encryptToRecipient, decryptFile }; + return { exportRecipient, encryptToRecipient, decryptFile, + encodeRecipient, decodeRecipient, pkXfromRecipient }; }; diff --git a/test/xwing-split.test.mjs b/test/xwing-split.test.mjs new file mode 100644 index 00000000..6440b11c --- /dev/null +++ b/test/xwing-split.test.mjs @@ -0,0 +1,73 @@ +// Hardware-free proof of the split-custody X-Wing crypto used by the web app. +// +// Verifies that a STANDARD X-Wing sender (noble ml_kem768_x25519) encapsulating +// to an OnlyKey recipient can be decapsulated by splitting the work between the +// "device" (X25519 half; sk_X never leaves) and the "browser" (ML-KEM half; +// ct_M never sent to the device) — reproducing the exact shared secret. +// +// Run: node --test test/xwing-split.test.mjs (needs @noble/* dev-deps) + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; + +import { ml_kem768_x25519 as XW } from '@noble/post-quantum/hybrid.js'; +import { x25519 } from '@noble/curves/ed25519.js'; +import { hkdf } from '@noble/hashes/hkdf.js'; +import { sha256 } from '@noble/hashes/sha2.js'; +import { randomBytes } from '@noble/hashes/utils.js'; + +const require = createRequire(import.meta.url); +const xw = require('../src/onlykey-fido2/onlykey/xwing.js'); + +const info = (s) => new TextEncoder().encode(s); +const eqB = (a, b) => Buffer.from(a).equals(Buffer.from(b)); + +// Stand-in for the firmware's per-label derivation + domain separation. +function deviceDerive(webDerivKey, label) { + const sk_X = hkdf(sha256, webDerivKey, info(label), info('onlykey/xwing/x25519/v1'), 32); + const mlkem_seed = hkdf(sha256, sk_X, new Uint8Array(0), info('onlykey/xwing/mlkem768-seed/v1'), 32); + return { sk_X, mlkem_seed }; +} + +test('recipient is a valid 1216-byte X-Wing pubkey', () => { + const { sk_X, mlkem_seed } = deviceDerive(randomBytes(32), 'age:personal'); + const rec = xw.buildRecipientPubkey(x25519.getPublicKey(sk_X), mlkem_seed); + assert.equal(rec.length, 1216); +}); + +test('split decaps reproduces standard-encaps shared secret', () => { + const webKey = randomBytes(32), label = 'age:personal'; + const { sk_X, mlkem_seed } = deviceDerive(webKey, label); + const pk_X = x25519.getPublicKey(sk_X); + const recipient = xw.buildRecipientPubkey(pk_X, mlkem_seed); + + // standard sender + const { cipherText, sharedSecret } = XW.encapsulate(recipient); + assert.equal(cipherText.length, 1120); + + // device does X25519, browser does ML-KEM + const ss_X = x25519.getSharedSecret(sk_X, xw.ctX(cipherText)); + const ss = xw.xwingSplitDecapsulate(ss_X, cipherText, pk_X, mlkem_seed); + assert.ok(eqB(ss, sharedSecret)); +}); + +test('domain separation: mlkem_seed never equals sk_X', () => { + const { sk_X, mlkem_seed } = deviceDerive(randomBytes(32), 'age:x'); + assert.ok(!eqB(mlkem_seed, sk_X)); +}); + +test('deterministic per (web key, label)', () => { + const k = randomBytes(32); + const a = deviceDerive(k, 'age:same'), b = deviceDerive(k, 'age:same'); + assert.ok(eqB(a.sk_X, b.sk_X) && eqB(a.mlkem_seed, b.mlkem_seed)); +}); + +test('cannot decrypt without the device (no ss_X)', () => { + const { sk_X, mlkem_seed } = deviceDerive(randomBytes(32), 'age:y'); + const pk_X = x25519.getPublicKey(sk_X); + const recipient = xw.buildRecipientPubkey(pk_X, mlkem_seed); + const { cipherText, sharedSecret } = XW.encapsulate(recipient); + const ssZero = xw.xwingSplitDecapsulate(new Uint8Array(32), cipherText, pk_X, mlkem_seed); + assert.ok(!eqB(ssZero, sharedSecret)); +});