Skip to content
Closed
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
13 changes: 11 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
}
}
94 changes: 94 additions & 0 deletions src/onlykey-fido2/onlykey/onlykey-pqc.js
Original file line number Diff line number Diff line change
@@ -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,
};
};
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,
};
55 changes: 55 additions & 0 deletions src/plugins/age/AGE-FORMAT.md
Original file line number Diff line number Diff line change
@@ -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 <base64_nopad(enc)> enc = 1120‑byte X‑Wing ct (ct_M||ct_X)
<base64_nopad(body)> 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 `--- <base64_nopad(mac)>`.
- 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`).
Loading