diff --git a/package.json b/package.json index 2fbce5e2..24ef607d 100644 --- a/package.json +++ b/package.json @@ -20,13 +20,18 @@ "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", + "test:age": "node --test test/age-format.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", @@ -49,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" } } diff --git a/src/onlykey-fido2/onlykey/onlykey-pqc.js b/src/onlykey-fido2/onlykey/onlykey-pqc.js new file mode 100644 index 00000000..e9d60e58 --- /dev/null +++ b/src/onlykey-fido2/onlykey/onlykey-pqc.js @@ -0,0 +1,94 @@ +// onlykey-pqc.js — device wrappers for split-custody X-Wing on the web app. +// +// 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. +// +// 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_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 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; + } + function labelBytes(label) { + if (typeof label !== 'string' || !label.length) + throw new Error('PQC identity needs a non-empty derivation label'); + return enc(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) => { + onlykeyApi.ctaphid_via_webauthn( + OKCONNECT, optype, WIRE_KEYTYPE_XWING, ENCRYPT_RESP, + data, timeoutMs || 6000, + (err, out) => { + if (err) return reject(err); + if (!out || out.length < 64) + return reject(new Error('short PQC reply: got ' + (out && out.length))); + resolve(Uint8Array.from(out.slice(0, 64))); + } + ); + }); + } + + // 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() + }; + } + + // 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); + } + + 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 new file mode 100644 index 00000000..f5b1eaaf --- /dev/null +++ b/src/onlykey-fido2/onlykey/xwing.js @@ -0,0 +1,95 @@ +// xwing.js — ML-KEM-768 + X-Wing (age `mlkem768x25519`) crypto for the OnlyKey +// onlyagent web app, using the SPLIT-CUSTODY model: +// +// * 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. +// +// 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. +// +// 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_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 = { + 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 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) } +} + +// ---- 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); + const { cipherText, sharedSecret } = ml_kem768_x25519.encapsulate(recipientPk); + return { ciphertext: cipherText, sharedSecret }; // ct 1120, ss 32 +} + +// ---- 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)); +} + +// 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, + XWING_LABEL, + mlkemKeypairFromSeed, + buildRecipientPubkey, + xwingEncapsulate, + xwingSplitDecapsulate, + ctX, +}; 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/INTEGRATION.md b/src/plugins/age/INTEGRATION.md new file mode 100644 index 00000000..ba6027ac --- /dev/null +++ b/src/plugins/age/INTEGRATION.md @@ -0,0 +1,156 @@ +# 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-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 new file mode 100644 index 00000000..0096bf13 --- /dev/null +++ b/src/plugins/age/age-pqc.js @@ -0,0 +1,40 @@ +'use strict'; +const xwing = require('../../onlykey-fido2/onlykey/xwing.js'); +const age = require('./age-format.js'); + +module.exports = function (imports) { + const { onlykeyPqc } = imports; + + async function exportRecipient(label) { + const { recipientPk } = await onlykeyPqc.getRecipient(label); + return age.encodeRecipient(recipientPk); + } + + async function encryptToRecipient(recipient, plaintext /* Uint8Array */) { + 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); + } + + async function decryptFile(ageBytes, label, recipient) { + 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 }; +}; 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/); +}); 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)); +});