From 3b022d652540f7e907498cb1d2ac3519a1de17b3 Mon Sep 17 00:00:00 2001 From: 0c-coder Date: Tue, 30 Jun 2026 15:02:20 -0400 Subject: [PATCH 1/6] 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/6] 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/6] 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)); +}); From 457bdd4e37a49fa087b0e726ae279c8646bc5fa9 Mon Sep 17 00:00:00 2001 From: 0c-coder Date: Sat, 4 Jul 2026 12:11:05 -0400 Subject: [PATCH 4/6] age container framing: implement mlkem768x25519 (bech32 + HPKE + age v1), byte-matched to #90 --- src/plugins/age/AGE-FORMAT.md | 55 ++++++++ src/plugins/age/age-format.js | 243 ++++++++++++++++++++++++++++++++++ src/plugins/age/age-pqc.js | 85 ++++-------- 3 files changed, 323 insertions(+), 60 deletions(-) create mode 100644 src/plugins/age/AGE-FORMAT.md create mode 100644 src/plugins/age/age-format.js diff --git a/src/plugins/age/AGE-FORMAT.md b/src/plugins/age/AGE-FORMAT.md new file mode 100644 index 00000000..6970a79c --- /dev/null +++ b/src/plugins/age/AGE-FORMAT.md @@ -0,0 +1,55 @@ +# age `mlkem768x25519` container format (browser) + +`age-format.js` implements the age file container so the web app can produce and +consume `.age` files **without the `age` binary** (which the python plugin relies +on). The HPKE file‑key wrap and the recipient encoding are **byte‑identical to +python‑onlykey#90** (`age_plugin/{cli,protocol,xwing}.py`) — verified by frozen +known‑answer vectors in `test/age-format.test.mjs`, cross‑checked against the #90 +Python during development. + +## Recipient / identity +- Recipient string = `bech32("age1onlykey", pk)` where `pk` is the 1216‑byte + X‑Wing public key (`pk_M(1184) || pk_X(32)`). Charset/checksum match #90's + `cli.py`. (Identity is the OnlyKey derivation label, not a bech32 slot string — + the web app has no slots.) + +## Stanza +``` +-> mlkem768x25519 enc = 1120‑byte X‑Wing ct (ct_M||ct_X) + body = 32‑byte HPKE‑sealed file key +``` +Body wrapping and unpadded base64 follow #90's `Stanza.encode`. + +## File‑key wrap — HPKE base (RFC 9180), X‑Wing KEM +Suite: **KEM 0x647A (X‑Wing) / KDF 0x0001 (HKDF‑SHA256) / AEAD 0x0003 +(ChaCha20‑Poly1305)**. `kem_context = enc`; `info = ""`. The age **file key is 16 +bytes**; sealed body = 16 + 16‑byte tag = 32 bytes. +- `seal = ChaCha20Poly1305(key).encrypt(base_nonce, file_key)` where `(key, + base_nonce)` come from `KeyScheduleBase(ExtractAndExpand(ss, enc))`. +- The X‑Wing shared secret `ss` comes from the split‑custody decap (device X25519 + + browser ML‑KEM); combiner label is **last**: + `SHA3-256(ss_M || ss_X || ct_X || pk_X || 0x5c2e2f2f5e5c)`. + +## age v1 file (header + MAC + payload) +Standard age v1 (the part `age` normally does): +- Header = `age-encryption.org/v1\n` + stanzas + `---`. +- Header MAC = `HMAC-SHA256(HKDF(file_key, salt="", info="header"), header_through_"---")`, + written as `--- `. +- Payload = 16‑byte random `nonce`, then ChaCha20‑Poly1305 **STREAM**: 64 KiB + plaintext chunks, key = `HKDF(file_key, salt=nonce, info="payload")`, 12‑byte + nonce = 11‑byte big‑endian chunk counter `||` last‑chunk flag (`0x01`/`0x00`). + +## Wiring +`age-pqc.js` calls: `encodeRecipient` (export), `xwing.xwingEncapsulate` + +`sealFileKey` + `buildAgeFile` (encrypt), `parseAgeFile` + +`onlykeyPqc.decapsulate` + `openFileKey` + `openAgeFile` (decrypt). + +## Tests (`test/age-format.test.mjs`, `npm run test:age`) +1. HPKE seal KAT == #90. 2. bech32 recipient KAT == #90 (+ round‑trip). +3. HPKE seal/open round‑trip. 4. age build/open round‑trip (multi‑chunk STREAM). +5. header‑MAC tamper rejection. (Full KEM↔container e2e also passes with the +shipped `xwing.js`.) + +## Deps +`@noble/post-quantum @noble/curves @noble/hashes @noble/ciphers` (added to +`package.json`). diff --git a/src/plugins/age/age-format.js b/src/plugins/age/age-format.js new file mode 100644 index 00000000..b8db56e5 --- /dev/null +++ b/src/plugins/age/age-format.js @@ -0,0 +1,243 @@ +// age-format.js -- age `mlkem768x25519` container framing for the OnlyKey web app. +// Byte-matches python-onlykey#90 (age_plugin/{cli,protocol,xwing}.py) + the age v1 +// file format (str4d/age): bech32 recipient, HPKE file-key wrap, header MAC, +// ChaCha20-Poly1305 STREAM payload. Pure host code; the KEM lives in xwing.js. + +'use strict'; + +const { chacha20poly1305 } = require('@noble/ciphers/chacha.js'); +const { hmac } = require('@noble/hashes/hmac.js'); +const { sha256 } = require('@noble/hashes/sha2.js'); +const { concatBytes, utf8ToBytes } = require('@noble/hashes/utils.js'); + +const FILE_KEY_LEN = 16; // age v1 file key +const XWING_CT_LEN = 1120; // stanza arg +const SEALED_BODY_LEN = 32; // 16-byte file key + 16-byte AEAD tag +const RECIPIENT_HRP = 'age1onlykey'; +const STANZA_TAG = 'mlkem768x25519'; +const CHUNK = 65536; + +const te = utf8ToBytes; +function u8(...b) { return Uint8Array.from(b); } +function b64(bytes) { return Buffer.from(bytes).toString('base64').replace(/=+$/, ''); } +function unb64(str) { return Uint8Array.from(Buffer.from(str, 'base64')); } +function eq(a, b) { if (a.length !== b.length) return false; let d = 0; for (let i = 0; i < a.length; i++) d |= a[i] ^ b[i]; return d === 0; } +function indexOfSub(buf, sub, from) { + outer: for (let i = from || 0; i <= buf.length - sub.length; i++) { + for (let j = 0; j < sub.length; j++) if (buf[i + j] !== sub[j]) continue outer; + return i; + } + return -1; +} + +// ---- bech32 (port of cli.py) -------------------------------------------- +const CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'; +const GEN = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]; +function polymod(values) { + let chk = 1; + for (const v of values) { + const b = chk >>> 25; + chk = ((chk & 0x1ffffff) << 5) ^ v; + for (let i = 0; i < 5; i++) chk ^= ((b >>> i) & 1) ? GEN[i] : 0; + } + return chk >>> 0; +} +function hrpExpand(hrp) { + const o = []; + for (const c of hrp) o.push(c.charCodeAt(0) >> 5); + o.push(0); + for (const c of hrp) o.push(c.charCodeAt(0) & 31); + return o; +} +function createChecksum(hrp, data) { + const values = hrpExpand(hrp).concat(data, [0, 0, 0, 0, 0, 0]); + const mod = polymod(values) ^ 1; + const out = []; + for (let i = 0; i < 6; i++) out.push((mod >>> (5 * (5 - i))) & 31); + return out; +} +function verifyChecksum(hrp, data) { return polymod(hrpExpand(hrp).concat(data)) === 1; } +function convertBits(data, from, to, pad) { + let acc = 0, bits = 0; const ret = []; const maxv = (1 << to) - 1; + for (const value of data) { + if (value < 0 || (value >> from)) return null; + acc = (acc << from) | value; bits += from; + while (bits >= to) { bits -= to; ret.push((acc >> bits) & maxv); } + } + if (pad) { if (bits) ret.push((acc << (to - bits)) & maxv); } + else if (bits >= from || ((acc << (to - bits)) & maxv)) return null; + return ret; +} +function bech32Encode(hrp, data) { + const values = convertBits(Array.from(data), 8, 5, true); + const chk = createChecksum(hrp, values); + return hrp + '1' + values.concat(chk).map((d) => CHARSET[d]).join(''); +} +function bech32Decode(bech) { + bech = bech.toLowerCase(); + const pos = bech.lastIndexOf('1'); + if (pos < 1 || pos + 7 > bech.length) return [null, null]; + const hrp = bech.slice(0, pos); + const data = []; + for (const ch of bech.slice(pos + 1)) { const d = CHARSET.indexOf(ch); if (d === -1) return [null, null]; data.push(d); } + if (!verifyChecksum(hrp, data)) return [null, null]; + const decoded = convertBits(data.slice(0, -6), 5, 8, false); + if (decoded === null) return [null, null]; + return [hrp, Uint8Array.from(decoded)]; +} + +function encodeRecipient(pk) { + if (pk.length !== 1216) throw new Error('recipient pubkey must be 1216 bytes, got ' + pk.length); + return bech32Encode(RECIPIENT_HRP, pk); +} +function decodeRecipient(str) { + const [hrp, data] = bech32Decode(String(str)); + if (hrp !== RECIPIENT_HRP || !data) throw new Error('invalid OnlyKey recipient: ' + str); + if (data.length !== 1216) throw new Error('recipient must decode to 1216 bytes, got ' + data.length); + return data; +} + +// ---- HPKE base (X-Wing KEM / HKDF-SHA256 / ChaCha20-Poly1305), port of xwing.py +const SUITE_KEM = concatBytes(te('KEM'), u8(0x64, 0x7a)); +const SUITE_HPKE = concatBytes(te('HPKE'), u8(0x64, 0x7a, 0x00, 0x01, 0x00, 0x03)); +function i2osp(n, len) { const b = new Uint8Array(len); let x = n; for (let i = len - 1; i >= 0; i--) { b[i] = x & 0xff; x = Math.floor(x / 256); } return b; } +function hkdfExtract(salt, ikm) { if (!salt || salt.length === 0) salt = new Uint8Array(32); return hmac(sha256, salt, ikm); } +function hkdfExpand(prk, info, len) { + const n = Math.ceil(len / 32); let t = new Uint8Array(0); const out = []; + for (let i = 1; i <= n; i++) { t = hmac(sha256, prk, concatBytes(t, info, u8(i))); out.push(t); } + return concatBytes(...out).slice(0, len); +} +function labeledExtract(salt, label, ikm, suite) { return hkdfExtract(salt, concatBytes(te('HPKE-v1'), suite, label, ikm)); } +function labeledExpand(prk, label, info, len, suite) { return hkdfExpand(prk, concatBytes(i2osp(len, 2), te('HPKE-v1'), suite, label, info), len); } +function extractAndExpand(ss, kemCtx) { + const prk = labeledExtract(new Uint8Array(0), te('shared_secret'), ss, SUITE_KEM); + return labeledExpand(prk, te('context'), kemCtx, 32, SUITE_KEM); +} +function keyScheduleBase(ss, info) { + const pih = labeledExtract(new Uint8Array(0), te('psk_id_hash'), new Uint8Array(0), SUITE_HPKE); + const ih = labeledExtract(new Uint8Array(0), te('info_hash'), info, SUITE_HPKE); + const ksCtx = concatBytes(u8(0), pih, ih); + const secret = labeledExtract(ss, te('secret'), new Uint8Array(0), SUITE_HPKE); + const key = labeledExpand(secret, te('key'), ksCtx, 32, SUITE_HPKE); + const baseNonce = labeledExpand(secret, te('base_nonce'), ksCtx, 12, SUITE_HPKE); + return { key, baseNonce }; +} +function sealFileKey(ss, enc, fileKey, info) { + const hss = extractAndExpand(ss, enc); + const { key, baseNonce } = keyScheduleBase(hss, info || new Uint8Array(0)); + return chacha20poly1305(key, baseNonce).encrypt(fileKey); // 32 bytes +} +function openFileKey(ss, enc, body, info) { + const hss = extractAndExpand(ss, enc); + const { key, baseNonce } = keyScheduleBase(hss, info || new Uint8Array(0)); + return chacha20poly1305(key, baseNonce).decrypt(body); // 16 bytes +} + +// ---- age stanza --------------------------------------------------------- +function encodeStanza(tag, args, body) { + let line = '-> ' + [tag].concat(args).join(' ') + '\n'; + const e = b64(body); + const lines = []; + for (let i = 0; i < e.length; i += 64) lines.push(e.slice(i, i + 64)); + if (lines.length && lines[lines.length - 1].length === 64) lines.push(''); + return line + lines.join('\n') + '\n'; +} + +// ---- age v1 STREAM payload ---------------------------------------------- +function streamNonce(counter, last) { + const n = new Uint8Array(12); let c = counter; + for (let i = 10; i >= 0; i--) { n[i] = c & 0xff; c = Math.floor(c / 256); } + n[11] = last ? 1 : 0; + return n; +} +function streamEncrypt(key, pt) { + const out = []; const n = Math.max(1, Math.ceil(pt.length / CHUNK)); + for (let i = 0; i < n; i++) { + const chunk = pt.slice(i * CHUNK, (i + 1) * CHUNK); + out.push(chacha20poly1305(key, streamNonce(i, i === n - 1)).encrypt(chunk)); + } + return concatBytes(...out); +} +function streamDecrypt(key, body) { + const EC = CHUNK + 16; const out = []; let off = 0, i = 0; + for (;;) { + const remaining = body.length - off; + const last = remaining <= EC; + const clen = last ? remaining : EC; + out.push(chacha20poly1305(key, streamNonce(i, last)).decrypt(body.slice(off, off + clen))); + off += clen; i++; + if (last) break; + } + return concatBytes(...out); +} + +// ---- age file (header + MAC + payload) ---------------------------------- +function hkdf(fileKey, salt, info) { + const prk = hkdfExtract(salt, fileKey); + return hkdfExpand(prk, info, 32); +} +function randomBytes(n) { + if (typeof globalThis !== 'undefined' && globalThis.crypto && globalThis.crypto.getRandomValues) { + const b = new Uint8Array(n); globalThis.crypto.getRandomValues(b); return b; + } + return require('crypto').randomBytes(n); +} +function buildAgeFile(stanzas, fileKey, plaintext, nonce16) { + let header = 'age-encryption.org/v1\n'; + for (const s of stanzas) header += encodeStanza(s.tag, s.args, s.body); + header += '---'; + const macKey = hkdf(fileKey, new Uint8Array(0), te('header')); + const mac = hmac(sha256, macKey, te(header)); + const headerFull = header + ' ' + b64(mac) + '\n'; + const nonce = nonce16 || randomBytes(16); + const payloadKey = hkdf(fileKey, Uint8Array.from(nonce), te('payload')); + const payload = streamEncrypt(payloadKey, plaintext); + return concatBytes(te(headerFull), Uint8Array.from(nonce), payload); +} +function parseAgeFile(bytes) { + const dashIdx = indexOfSub(bytes, te('\n---'), 0); // "\n---" + if (dashIdx < 0) throw new Error('age: no header MAC line'); + const macInput = bytes.slice(0, dashIdx + 4); // through "---" + const nlIdx = indexOfSub(bytes, te('\n'), dashIdx + 4); + if (nlIdx < 0) throw new Error('age: truncated MAC line'); + const macVal = unb64(Buffer.from(bytes.slice(dashIdx + 5, nlIdx)).toString('latin1')); // skip space at +4 + const headerText = Buffer.from(bytes.slice(0, dashIdx)).toString('latin1'); + const payload = bytes.slice(nlIdx + 1); + const nonce = payload.slice(0, 16); + const ciphertext = payload.slice(16); + // parse stanzas + const lines = headerText.split('\n'); + if (lines[0] !== 'age-encryption.org/v1') throw new Error('age: bad version line'); + const stanzas = []; + for (let i = 1; i < lines.length; i++) { + if (!lines[i].startsWith('-> ')) continue; + const parts = lines[i].slice(3).split(' '); + const tag = parts[0], args = parts.slice(1); + const bodyLines = []; + let j = i + 1; + for (; j < lines.length; j++) { + if (lines[j].length === 64) { bodyLines.push(lines[j]); } + else { if (lines[j].length) bodyLines.push(lines[j]); break; } + } + stanzas.push({ tag, args, body: bodyLines.length ? unb64(bodyLines.join('')) : new Uint8Array(0) }); + i = j; + } + return { stanzas, macInput, macVal, nonce, ciphertext }; +} +function openAgeFile(bytes, fileKey) { + const p = parseAgeFile(bytes); + const macKey = hkdf(fileKey, new Uint8Array(0), te('header')); + const mac = hmac(sha256, macKey, p.macInput); + if (!eq(mac, p.macVal)) throw new Error('age: header MAC verification failed'); + const payloadKey = hkdf(fileKey, p.nonce, te('payload')); + return streamDecrypt(payloadKey, p.ciphertext); +} + +module.exports = { + FILE_KEY_LEN, XWING_CT_LEN, SEALED_BODY_LEN, STANZA_TAG, + encodeRecipient, decodeRecipient, + sealFileKey, openFileKey, + encodeStanza, buildAgeFile, parseAgeFile, openAgeFile, + bech32Encode, bech32Decode, + randomBytes, b64, unb64, +}; diff --git a/src/plugins/age/age-pqc.js b/src/plugins/age/age-pqc.js index 6eda59be..0096bf13 100644 --- a/src/plugins/age/age-pqc.js +++ b/src/plugins/age/age-pqc.js @@ -1,75 +1,40 @@ -// 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"] -// -// 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'; - const xwing = require('../../onlykey-fido2/onlykey/xwing.js'); +const age = require('./age-format.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) { const { recipientPk } = await onlykeyPqc.getRecipient(label); - return encodeRecipient(recipientPk); + return age.encodeRecipient(recipientPk); } - // Encrypt to a recipient. Pure host-side (like `age -r `), no device. async function encryptToRecipient(recipient, plaintext /* Uint8Array */) { - 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); + const pk = age.decodeRecipient(recipient); + const { ciphertext, sharedSecret } = xwing.xwingEncapsulate(pk); // ct 1120, ss 32 + const fileKey = age.randomBytes(age.FILE_KEY_LEN); // 16 + const body = age.sealFileKey(sharedSecret, ciphertext, fileKey); // HPKE seal -> 32 + const stanza = { tag: age.STANZA_TAG, args: [age.b64(ciphertext)], body }; + return age.buildAgeFile([stanza], fileKey, 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 openAgeFile(/* ageBytes, sharedSecret */) { - throw new Error('openAgeFile: implement age payload decrypt per #90'); - } - - return { exportRecipient, encryptToRecipient, decryptFile, - encodeRecipient, decodeRecipient, pkXfromRecipient }; + const { stanzas } = age.parseAgeFile(ageBytes); + const st = stanzas.find((s) => s.tag === age.STANZA_TAG); + if (!st || st.args.length !== 1) throw new Error('no mlkem768x25519 stanza'); + const ciphertext = age.unb64(st.args[0]); + if (ciphertext.length !== xwing.SIZES.XWING.ct) + throw new Error('stanza ct must be 1120 bytes, got ' + ciphertext.length); + if (st.body.length !== age.SEALED_BODY_LEN) + throw new Error('stanza body must be 32 bytes, got ' + st.body.length); + const pkX = recipient + ? age.decodeRecipient(recipient).slice(xwing.SIZES.MLKEM.pk, xwing.SIZES.XWING.pk) + : (await onlykeyPqc.getRecipient(label)).pkX; + const sharedSecret = await onlykeyPqc.decapsulate(label, ciphertext, pkX); // device + button + const fileKey = age.openFileKey(sharedSecret, ciphertext, st.body); // HPKE open -> 16 + return age.openAgeFile(ageBytes, fileKey); + } + + return { exportRecipient, encryptToRecipient, decryptFile }; }; From 92cfded87ff453b2da31bb643caad7c63cdddd37 Mon Sep 17 00:00:00 2001 From: 0c-coder Date: Sat, 4 Jul 2026 12:12:00 -0400 Subject: [PATCH 5/6] test: age-format KATs + round-trips --- test/age-format.test.mjs | 44 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 test/age-format.test.mjs diff --git a/test/age-format.test.mjs b/test/age-format.test.mjs new file mode 100644 index 00000000..dc03c7ad --- /dev/null +++ b/test/age-format.test.mjs @@ -0,0 +1,44 @@ +// Byte-compat + round-trip tests for the age mlkem768x25519 container. +// KATs are frozen from python-onlykey#90 (verified byte-identical), so this runs +// standalone with `node --test test/age-format.test.mjs`. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createRequire } from 'module'; +import { createHash } from 'crypto'; +const require = createRequire(import.meta.url); +const A = require('../src/plugins/age/age-format.js'); + +const pat = (n, f) => Uint8Array.from({ length: n }, (_, i) => f(i)); +const hex = (u) => Buffer.from(u).toString('hex'); + +test('HPKE seal byte-identical to python-onlykey#90 (KAT)', () => { + const ss = pat(32, i => i), enc = pat(1120, i => i % 251), fk = pat(16, i => 0xa0 + i); + assert.equal(hex(A.sealFileKey(ss, enc, fk)), + '70ffb256bc81bb83124ce0de89455ee90722f35fb9cd0d67e2d6bf55c49b5a5b'); +}); +test('bech32 recipient byte-identical to #90 (KAT) + round-trip', () => { + const pk = pat(1216, i => (i * 7 + 3) % 256); + const r = A.encodeRecipient(pk); + assert.ok(r.startsWith('age1onlykey1')); + assert.equal(createHash('sha256').update(r).digest('hex'), + '447d855ca1e3a7d041ae8fcda05003a12908d142d5bda28060cdca6bfedbf989'); + assert.equal(hex(A.decodeRecipient(r)), hex(pk)); +}); +test('HPKE seal/open round-trips', () => { + const ss = pat(32, i => (i * 3) % 256), enc = pat(1120, i => i % 97), fk = pat(16, i => i + 1); + assert.equal(hex(A.openFileKey(ss, enc, A.sealFileKey(ss, enc, fk))), hex(fk)); +}); +test('age file build/open round-trips (multi-chunk STREAM + MAC)', () => { + const fileKey = A.randomBytes(16); + const stanza = { tag: A.STANZA_TAG, args: [A.b64(pat(1120, i => i % 251))], body: pat(32, i => 0x10 + i) }; + const pt = new TextEncoder().encode('mlkem768x25519 ✅ '.repeat(6000)); // > 64 KiB + const file = A.buildAgeFile([stanza], fileKey, pt); + assert.equal(hex(A.openAgeFile(file, fileKey)), hex(pt)); +}); +test('header MAC rejects tampering', () => { + const fileKey = A.randomBytes(16); + const stanza = { tag: A.STANZA_TAG, args: [A.b64(pat(1120, i => i))], body: pat(32, i => i) }; + const file = A.buildAgeFile([stanza], fileKey, new TextEncoder().encode('hi')); + const t = Uint8Array.from(file); t[60] ^= 1; + assert.throws(() => A.openAgeFile(t, fileKey), /MAC/); +}); From 448447fde39bae4669cc043f6bdc85e6a022a074 Mon Sep 17 00:00:00 2001 From: 0c-coder Date: Sat, 4 Jul 2026 12:12:58 -0400 Subject: [PATCH 6/6] deps: add @noble/* + test:age script --- package.json | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 47b6741b..24ef607d 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,8 @@ "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", - "test:pqc": "node --test test/xwing-split.test.mjs" + "test:pqc": "node --test test/xwing-split.test.mjs", + "test:age": "node --test test/age-format.test.mjs" }, "bugs": { "url": "https://github.com/onlykey/onlykey.github.io/issues" @@ -53,6 +54,10 @@ "gun": "^0.2020.520", "node-onlykey": "github:trustcrypto/node-onlykey#4796f8e7a243024c754a6c44457a1e4a04553987", "xterm": "^4.8.1", - "xterm-addon-fit": "^0.4.0" + "xterm-addon-fit": "^0.4.0", + "@noble/post-quantum": "^0.6.1", + "@noble/curves": "^1.9.7", + "@noble/hashes": "^1.8.0", + "@noble/ciphers": "^1.3.0" } }