Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
}
}
62 changes: 62 additions & 0 deletions src/onlykey-fido2/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(){

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
61 changes: 61 additions & 0 deletions src/onlykey-fido2/onlykey/onlykey-3rd-party.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;


Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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];

Expand Down
95 changes: 95 additions & 0 deletions src/onlykey-fido2/onlykey/xwing.js
Original file line number Diff line number Diff line change
@@ -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,
};
73 changes: 73 additions & 0 deletions test/xwing-split.test.mjs
Original file line number Diff line number Diff line change
@@ -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));
});