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
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
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,
};
156 changes: 156 additions & 0 deletions src/plugins/age/INTEGRATION.md
Original file line number Diff line number Diff line change
@@ -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 <b64(ct)>` 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`.
Loading