From ca00609dd83eb80a6f8492450fd5f57c46fb5a13 Mon Sep 17 00:00:00 2001 From: 0c-coder Date: Sat, 4 Jul 2026 13:26:49 -0400 Subject: [PATCH 1/4] 3rd-party API: add X-Wing derive (keytype 6) + xwing.js KEM core --- .../onlykey/onlykey-3rd-party.js | 61 ++++++++++++ src/onlykey-fido2/onlykey/xwing.js | 95 +++++++++++++++++++ 2 files changed, 156 insertions(+) create mode 100644 src/onlykey-fido2/onlykey/xwing.js diff --git a/src/onlykey-fido2/onlykey/onlykey-3rd-party.js b/src/onlykey-fido2/onlykey/onlykey-3rd-party.js index 4adcdc14..2187e472 100644 --- a/src/onlykey-fido2/onlykey/onlykey-3rd-party.js +++ b/src/onlykey-fido2/onlykey/onlykey-3rd-party.js @@ -2,6 +2,7 @@ module.exports = function(imports, onlykeyApi){ /* global TextEncoder */ // var $ = require("jquery"); var nacl = require("./nacl.min.js"); +var xwing = require("./xwing.js"); // split-custody X-Wing KEM core (validated) var EventEmitter = require("events").EventEmitter; @@ -351,6 +352,8 @@ async function ONLYKEY_ECDH_P256_to_EPUB(publicKeyRawBuffer, callback) { //#define KEYTYPE_P256R1 1 //#define KEYTYPE_P256K1 2 //#define KEYTYPE_CURVE25519 3 +//#define KEYTYPE_MLKEM768 5 +//#define KEYTYPE_XWING 6 // enc_resp //#define NO_ENCRYPT_RESP 0 //#define ENCRYPT_RESP 1 @@ -572,6 +575,64 @@ function onlykey(keytype, enc_resp) { }; } + // ---- X-Wing (post-quantum, split custody), keytype 6 ------------------ + // Same derive flow as the ECC keytypes. The MCU can't run ML-KEM, so the + // device returns a 64-byte reply and the browser (xwing.js) does the ML-KEM + // half. Identity is `additional_d` (SHA-256'd, like the ECC paths); no slots. + // Reply layout = the 64 bytes after the 53-byte response header: + // DERIVE_PUBLIC_KEY(1) -> [ pk_X(32) | mlkem_seed(32) ] + // DERIVE_SHARED_SECRET(2) -> [ ss_X(32) | mlkem_seed(32) ] + // NOTE(firmware): confirm this 64-byte layout against libraries#30. The KEM + // math in xwing.js is unit-tested; only this transport framing needs a HW check. + if (keytype == 6) { // KEYTYPE_XWING + + async function xwing_derive64(additional_d, optype, extra_input, timeout) { + var transit_key = nacl.box.keyPair(); + var message = ctaphid_custom_message_header(transit_key.publicKey); + var dataHash = await digestArray(Uint8Array.from(additional_d || new Uint8Array(32))); + Array.prototype.push.apply(message, dataHash); + if (extra_input) Array.prototype.push.apply(message, Array.from(extra_input)); // ct_X for decaps + var out = null; + await ctaphid_via_webauthn(OKCONNECT, optype, keytype, enc_resp, Uint8Array.from(message), timeout).then(async (response) => { + if (!response || response == 1) return; + var okPub = response.slice(21, 53); + var transit_sharedsec = nacl.box.before(Uint8Array.from(okPub), app_transit.secretKey); + var payload = response.slice(53, response.length); + if (enc_resp == 1) payload = await aesgcm_decrypt(transit_sharedsec, payload); + out = Uint8Array.from(payload.slice(0, 64)); // [half(32) | mlkem_seed(32)] + }); + return out; + } + + // Derive the X-Wing recipient public key (1216 bytes) for an identity. + api.derive_xwing_public_key = async function (additional_d, cb) { + htmlLog("Requesting OnlyKey Derive X-Wing Public Key")(); + var reply = await xwing_derive64(additional_d, 1 /*DERIVE_PUBLIC_KEY*/, null, 6000); + if (!reply) { if (typeof cb === 'function') cb(true); return; } + var pkX = reply.slice(0, 32), mlkemSeed = reply.slice(32, 64); + var recipientPk = xwing.buildRecipientPubkey(pkX, mlkemSeed); // pk_M(1184) || pk_X(32) + htmlLog("OnlyKey Derive X-Wing Public Key Complete")(); + if (typeof cb === 'function') cb(null, { recipientPk: recipientPk, pkX: pkX }); + }; + + // Decapsulate an X-Wing ciphertext (ct_M||ct_X, 1120B) for an identity. + // Only ct_X (32B) reaches the device; browser does ML-KEM decaps + combine. + // Requires a button press. `pkX` comes from derive_xwing_public_key. + api.xwing_decapsulate = async function (additional_d, ciphertext, pkX, cb) { + if (ciphertext.length !== xwing.SIZES.XWING.ct) { if (typeof cb === 'function') cb(new Error('X-Wing ct must be 1120 bytes')); return; } + htmlLog("Requesting OnlyKey X-Wing Decapsulate")(); + var reply = await xwing_derive64(additional_d, 2 /*DERIVE_SHARED_SECRET*/, xwing.ctX(ciphertext), 30000); + if (!reply) { if (typeof cb === 'function') cb(true); return; } + var ssX = reply.slice(0, 32), mlkemSeed = reply.slice(32, 64); + var sharedSecret = xwing.xwingSplitDecapsulate(ssX, ciphertext, pkX, mlkemSeed); // 32B + htmlLog("OnlyKey X-Wing Decapsulate Complete")(); + if (typeof cb === 'function') cb(null, sharedSecret); + }; + + // Host-side X-Wing encapsulation (no device) for encrypting TO a recipient. + api.xwing_encapsulate = function (recipientPk) { return xwing.xwingEncapsulate(recipientPk); }; + } + function ctaphid_custom_message_header(publicKey) { var message = [255, 255, 255, 255, OKCONNECT]; 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, +}; From 151c35056a79a6c3662dff5d8e77e8d5a0825e21 Mon Sep 17 00:00:00 2001 From: 0c-coder Date: Sat, 4 Jul 2026 13:27:42 -0400 Subject: [PATCH 2/4] index.js: X-Wing keytype enum + reference example --- src/onlykey-fido2/index.js | 62 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/src/onlykey-fido2/index.js b/src/onlykey-fido2/index.js index 00b18369..82006da0 100644 --- a/src/onlykey-fido2/index.js +++ b/src/onlykey-fido2/index.js @@ -3,6 +3,7 @@ const crypto = require('node-webcrypto-shim'); const atob = require("atob"); const btoa = require("btoa"); +var xwing = require("./onlykey/xwing.js"); // split-custody X-Wing KEM core (validated in test/xwing-split.test.mjs) async function setTime(){ @@ -62,6 +63,8 @@ async function setTime(){ //#define KEYTYPE_P256R1 1 //#define KEYTYPE_P256K1 2 //#define KEYTYPE_CURVE25519 3 + //#define KEYTYPE_MLKEM768 5 + //#define KEYTYPE_XWING 6 // enc_resp //#define NO_ENCRYPT_RESP 0 //#define ENCRYPT_RESP 1 @@ -410,3 +413,62 @@ async function ONLYKEY_ECDH_P256_to_EPUB(publicKeyRawBuffer, callback) { } + + +// --------------------------------------------------------------------------- +// X-Wing (post-quantum, split custody) reference example. +// Mirrors the ECC derive demo above with keytype 6. The MCU can't run ML-KEM, +// so the device returns a 64-byte reply and the browser (xwing.js) does the +// ML-KEM half. Reply layout is the 64 bytes after the 53-byte response header: +// DERIVE_PUBLIC_KEY(1) -> [ pk_X(32) | mlkem_seed(32) ] +// DERIVE_SHARED_SECRET(2) -> [ ss_X(32) | mlkem_seed(32) ] +// Not auto-run (setTime() above is the live demo); call deriveXWingExample() to try it. +// NOTE(firmware): confirm the 64-byte reply layout against libraries#30. +async function xwingDeviceDerive64(optype, additional_d, extra_input, timeout) { + const OKCONNECT = 228, keytype = 6, enc_resp = 0; + var nacl = require("tweetnacl"); + var appKey = nacl.box.keyPair(); + + var message = [255, 255, 255, 255, OKCONNECT]; + var currentEpochTime = Math.round(new Date().getTime() / 1000.0).toString(16); + Array.prototype.push.apply(message, currentEpochTime.match(/.{2}/g).map(hexStrToDec)); + Array.prototype.push.apply(message, appKey.publicKey); + Array.prototype.push.apply(message, ["N".charCodeAt(0), "W".charCodeAt(0)]); + var dataHash = await digestArray(Uint8Array.from(additional_d || new Uint8Array(32))); + Array.prototype.push.apply(message, dataHash); + if (extra_input) Array.prototype.push.apply(message, Array.from(extra_input)); // ct_X for decaps + + var keyhandle = encode_ctaphid_request_as_keyhandle(OKCONNECT, optype, keytype, enc_resp, Uint8Array.from(message)); + var { FIDO2Client } = require("@vincss-public-projects/fido2-client"); + var assertion = await new FIDO2Client().getAssertion({ + publicKey: { challenge: Uint8Array.from(crypto.getRandomValues(new Uint8Array(32))), + allowCredentials: [{ id: keyhandle, type: 'public-key' }], timeout: timeout, userVerification: 'discouraged' } + }, "https://apps.crp.to"); + var signature = new Uint8Array(assertion.response.signature); + var response = signature.slice(1); + // enc_resp = 0 here, so the 64-byte reply is the 64 bytes after the 53-byte header + return Uint8Array.from(response.slice(53, 53 + 64)); +} + +async function deriveXWingExample(additional_d) { + additional_d = additional_d || Array.from(new TextEncoder().encode("xwing:example")); + + // 1. Derive the X-Wing recipient public key (device gives pk_X | mlkem_seed). + var pub = await xwingDeviceDerive64(1 /*DERIVE_PUBLIC_KEY*/, additional_d, null, 6000); + var pkX = pub.slice(0, 32), mlkemSeed = pub.slice(32, 64); + var recipientPk = xwing.buildRecipientPubkey(pkX, mlkemSeed); // 1216 bytes + console.log("X-Wing recipient (1216B):", toHexString(recipientPk.slice(0, 16)) + " ..."); + + // 2. Encapsulate to it (host side, no device). + var { ciphertext, sharedSecret } = xwing.xwingEncapsulate(recipientPk); // ct 1120, ss 32 + console.log("encaps shared secret:", toHexString(sharedSecret)); + + // 3. Decapsulate: only ct_X goes to the device; browser does ML-KEM + combine. + var ctX = xwing.ctX(ciphertext); + var dec = await xwingDeviceDerive64(2 /*DERIVE_SHARED_SECRET*/, additional_d, ctX, 30000); + var ssX = dec.slice(0, 32), seed2 = dec.slice(32, 64); + var recovered = xwing.xwingSplitDecapsulate(ssX, ciphertext, pkX, seed2); + console.log("decaps shared secret:", toHexString(recovered), + toHexString(recovered) === toHexString(sharedSecret) ? "(MATCH)" : "(MISMATCH)"); + return recovered; +} From 35a1760c420f4580e43f48f8a722cece3a44f67a Mon Sep 17 00:00:00 2001 From: 0c-coder Date: Sat, 4 Jul 2026 13:28:35 -0400 Subject: [PATCH 3/4] test: split-custody X-Wing KEM (5/5) --- test/xwing-split.test.mjs | 73 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 test/xwing-split.test.mjs 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 90e01381caaa89680fed878f01f8a5d6f5b3e366 Mon Sep 17 00:00:00 2001 From: 0c-coder Date: Sat, 4 Jul 2026 13:29:25 -0400 Subject: [PATCH 4/4] deps: add @noble/* + test:pqc script --- package.json | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 2fbce5e2..24bd2a33 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ "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" @@ -49,6 +50,9 @@ "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.0", + "@noble/hashes": "^1.8.0" } }