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 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/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-pqc.js b/src/plugins/age/age-pqc.js new file mode 100644 index 00000000..6eda59be --- /dev/null +++ b/src/plugins/age/age-pqc.js @@ -0,0 +1,75 @@ +// 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'); + +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); + } + + // 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); + } + + // 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 }; +}; 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)); +});