From 3b022d652540f7e907498cb1d2ac3519a1de17b3 Mon Sep 17 00:00:00 2001 From: 0c-coder Date: Tue, 30 Jun 2026 15:02:20 -0400 Subject: [PATCH 1/2] Add age/PQC scaffold: device layer (xwing.js + onlykey-pqc.js) --- src/onlykey-fido2/onlykey/onlykey-pqc.js | 104 +++++++++++++++++++++++ src/onlykey-fido2/onlykey/xwing.js | 82 ++++++++++++++++++ 2 files changed, 186 insertions(+) create mode 100644 src/onlykey-fido2/onlykey/onlykey-pqc.js create mode 100644 src/onlykey-fido2/onlykey/xwing.js diff --git a/src/onlykey-fido2/onlykey/onlykey-pqc.js b/src/onlykey-fido2/onlykey/onlykey-pqc.js new file mode 100644 index 00000000..51ffd2f2 --- /dev/null +++ b/src/onlykey-fido2/onlykey/onlykey-pqc.js @@ -0,0 +1,104 @@ +// onlykey-pqc.js — device-side PQC operations for the OnlyKey onlyagent web app. +// Module factory: const onlykeyPqc = require('./onlykey/onlykey-pqc.js')(imports, onlykeyApi); +// +// IMPORTANT: the web/FIDO2 path has NO key slots. The OnlyKey has one reserved +// web-derivation key that derives an unlimited number of per-identity keys (the +// same mechanism the SSH/GPG/age agent already uses). A key is identified by a +// derivation LABEL (the identity), not a slot number — and nothing is stored on +// device; the keypair is reproduced on demand from (reserved key + label). +// +// This reuses the EXISTING derive flow (see index.js): +// encode_ctaphid_request_as_keyhandle(OKCONNECT, optype, keytype, enc_resp, data) +// optype : DERIVE_PUBLIC_KEY = 1 (return the derived public key) +// DERIVE_SHARED_SECRET = 2 (return a 32-byte shared secret) +// keytype: NACL=0 P256R1=1 P256K1=2 CURVE25519=3 + MLKEM768=5 XWING=6 +// data : the derivation input (identity keyhandle) [+ KEM ciphertext] +// +// Why the 32-byte derived secret "just works": +// - XWING (6): X-Wing's private key IS a 32-byte seed; the device expands it +// (SHAKE256) into the ML-KEM-768 + X25519 keypair. Same 32 bytes the +// CURVE25519 path already derives -> zero new key material. +// - MLKEM768 (5): device expands the 32-byte derived secret to ML-KEM's 64-byte +// (d||z) seed, then KeyGen_internal. Deterministic. Pin the exact expansion +// to python-onlykey#90 / firmware libraries#29. +// The host (xwing.js) only ever touches the PUBLIC key; the private key never +// leaves the device and is re-derived each call. + +'use strict'; + +module.exports = function (imports, onlykeyApi) { + const OKCONNECT = 228; + const DERIVE_PUBLIC_KEY = 1; + const DERIVE_SHARED_SECRET = 2; // KEM decapsulation reuses this optype + const NO_ENCRYPT_RESP = 0, ENCRYPT_RESP = 1; + + const KEYTYPE = { MLKEM768: 5, XWING: 6 }; + const PUBKEY_LEN = { 5: 1184, 6: 1216 }; + const CT_LEN = { 5: 1088, 6: 1120 }; + const SS_LEN = 32; + + function assertKeytype(kt) { + if (kt !== KEYTYPE.MLKEM768 && kt !== KEYTYPE.XWING) + throw new Error('keytype must be 5 (ML-KEM-768) or 6 (X-Wing), got ' + kt); + } + + // Build the derivation input ("keyhandle data") for an identity label. The ECC + // path already does this for SSH/age identities — reuse that exact encoder so a + // given label maps to the same derived key across algorithms. + // TODO(verify #90/agent): point this at the existing identity->keyhandle encoder + // (the SLIP-0010/derivation-path packing the agent uses), not a new format. + function deriveInput(label) { + if (typeof label !== 'string' || !label.length) + throw new Error('PQC key needs a non-empty derivation label (identity)'); + throw new Error('deriveInput: reuse the agent identity->keyhandle encoder'); + } + + // Derive + return a PQC public key for an identity. Single derive request; + // response is large (1184/1216 B) and comes back over the existing multi-packet + // poll path that onlykey-api uses for big replies. + async function getPubKey(label, keytype) { + assertKeytype(keytype); + const want = PUBKEY_LEN[keytype]; + const data = deriveInput(label); + return new Promise((resolve, reject) => { + // Reuses the same transport index.js uses for DERIVE_PUBLIC_KEY. + onlykeyApi.ctaphid_via_webauthn( + OKCONNECT, DERIVE_PUBLIC_KEY, keytype, NO_ENCRYPT_RESP, + data, 6000, + function (err, out) { + if (err) return reject(err); + if (!out || out.length < want) + return reject(new Error('short pubkey: got ' + (out && out.length))); + resolve(Uint8Array.from(out.slice(0, want))); + } + ); + }); + } + + // KEM decapsulation = "derive shared secret" with the ciphertext as input. + // The device derives the private key from (reserved key + label), decapsulates + // the ciphertext (1088/1120 B) after a button press, and returns 32 bytes. + // The ciphertext is large, so this must go through the encrypted/chunked + // transit path (same one onlykey-pgp.js `u2fSignBuffer` uses) — prefer to export + // and reuse that sender rather than duplicate it. + async function decapsulate(label, keytype, ciphertext /* Uint8Array */) { + assertKeytype(keytype); + if (ciphertext.length !== CT_LEN[keytype]) + throw new Error('ciphertext must be ' + CT_LEN[keytype] + 'B for keytype ' + keytype); + const data = concat(deriveInput(label), ciphertext); + // TODO(integration): send OKCONNECT + DERIVE_SHARED_SECRET + keytype + data via + // the shared chunked+AES-GCM sender, ENCRYPT_RESP so the 32-byte secret comes + // back encrypted; resolve to the 32-byte shared secret. + throw new Error('decapsulate: wire to shared chunked sender (DERIVE_SHARED_SECRET)'); + } + + function concat(a, b) { + const out = new Uint8Array(a.length + b.length); + out.set(a, 0); out.set(b, a.length); + return out; + } + + // No generate()/no slots: derived keys are stateless. A key "exists" the moment + // you pick a label; getPubKey(label, keytype) reproduces it. Unlimited identities. + return { KEYTYPE, PUBKEY_LEN, CT_LEN, SS_LEN, getPubKey, decapsulate }; +}; diff --git a/src/onlykey-fido2/onlykey/xwing.js b/src/onlykey-fido2/onlykey/xwing.js new file mode 100644 index 00000000..8a7cd4f4 --- /dev/null +++ b/src/onlykey-fido2/onlykey/xwing.js @@ -0,0 +1,82 @@ +// xwing.js — ML-KEM-768 + X-Wing encapsulation and age `mlkem768x25519` +// stanza helpers for the OnlyKey onlyagent web app. +// +// Pure JS, no device required. This is the half of the protocol the HOST runs: +// the browser ENCAPSULATES to a recipient's public key to produce +// { sharedSecret(32B), ciphertext } +// and the OnlyKey later DECAPSULATES that ciphertext (see onlykey-pqc.js). +// +// Sizes (must match firmware libraries#29 / python-onlykey#90): +// ML-KEM-768 : pk 1184, ct 1088, ss 32 +// X-Wing : pk 1216 (= mlkem.pk 1184 || x25519.pk 32), ct 1120 (= 1088 || 32), ss 32 +// +// Deps: npm i @noble/post-quantum @noble/hashes +// Recent @noble/post-quantum ships X-Wing with the draft-09 combiner built in +// (KEM_ID 0x647A). If your version lacks it, see combineXWing() below. + +'use strict'; + +const { ml_kem768 } = require('@noble/post-quantum/ml-kem'); +let xwing = null; +try { xwing = require('@noble/post-quantum/xwing').xwing; } catch (e) { /* fallback below */ } + +const SIZES = { + MLKEM768: { keytype: 5, pk: 1184, ct: 1088, ss: 32 }, + XWING: { keytype: 6, pk: 1216, ct: 1120, ss: 32 }, +}; + +// ---- ML-KEM-768 ---------------------------------------------------------- +function mlkemEncapsulate(recipientPk /* Uint8Array(1184) */) { + if (recipientPk.length !== SIZES.MLKEM768.pk) + throw new Error('ML-KEM-768 pubkey must be 1184 bytes, got ' + recipientPk.length); + // @noble returns { cipherText, sharedSecret } + const { cipherText, sharedSecret } = ml_kem768.encapsulate(recipientPk); + return { ciphertext: cipherText, sharedSecret }; // ct 1088, ss 32 +} + +// ---- X-Wing (hybrid ML-KEM-768 + X25519) --------------------------------- +// Preferred: library-provided X-Wing (handles the draft-09 SHA3-256 combiner +// with label 0x5c2e2f2f5e5c internally). +function xwingEncapsulate(recipientPk /* Uint8Array(1216) */) { + if (recipientPk.length !== SIZES.XWING.pk) + throw new Error('X-Wing pubkey must be 1216 bytes, got ' + recipientPk.length); + if (xwing && xwing.encapsulate) { + const { cipherText, sharedSecret } = xwing.encapsulate(recipientPk); + return { ciphertext: cipherText, sharedSecret }; // ct 1120, ss 32 + } + // TODO(firmware): only used if @noble/post-quantum has no xwing module. + // Implement draft-connolly-cfrg-xwing-kem-09 combiner here and VERIFY the byte + // layout against python-onlykey#90 tests/test_age_wire.py before trusting it. + throw new Error('X-Wing not available in @noble/post-quantum; upgrade the package.'); +} + +function encapsulate(keytype, recipientPk) { + if (keytype === SIZES.MLKEM768.keytype) return mlkemEncapsulate(recipientPk); + if (keytype === SIZES.XWING.keytype) return xwingEncapsulate(recipientPk); + throw new Error('Unknown PQC keytype ' + keytype); +} + +// ---- age `mlkem768x25519` recipient encoding ----------------------------- +// age recipients are bech32-ish "age1..." strings in stock age; the OnlyKey +// plugin in #90 defines its own recipient label. Keep the raw-pubkey <-> string +// mapping in ONE place and make it match #90 exactly. +// TODO(verify #90): confirm the exact recipient/stanza encoding (bech32 HRP, +// stanza tag "mlkem768x25519", and the HPKE wrap: KEM 0x647A / KDF 0x0001 +// (HKDF-SHA256) / AEAD 0x0003 (ChaCha20Poly1305)) before interop. +function recipientToPubkey(recipientStr) { + // TODO(verify #90): decode "age1..."/onlykey recipient -> Uint8Array pubkey. + throw new Error('recipientToPubkey: implement per python-onlykey#90 encoding'); +} +function pubkeyToRecipient(keytype, pk) { + // TODO(verify #90): encode pubkey -> recipient string. + throw new Error('pubkeyToRecipient: implement per python-onlykey#90 encoding'); +} + +module.exports = { + SIZES, + encapsulate, + mlkemEncapsulate, + xwingEncapsulate, + recipientToPubkey, + pubkeyToRecipient, +}; From e155e7d856e2c5ee0f9e6e7b22c7dad09d3494a6 Mon Sep 17 00:00:00 2001 From: 0c-coder Date: Tue, 30 Jun 2026 15:03:29 -0400 Subject: [PATCH 2/2] Add age/PQC scaffold: age plugin + INTEGRATION.md --- src/plugins/age/INTEGRATION.md | 101 +++++++++++++++++++++++++++++++++ src/plugins/age/age-pqc.js | 60 ++++++++++++++++++++ 2 files changed, 161 insertions(+) create mode 100644 src/plugins/age/INTEGRATION.md create mode 100644 src/plugins/age/age-pqc.js diff --git a/src/plugins/age/INTEGRATION.md b/src/plugins/age/INTEGRATION.md new file mode 100644 index 00000000..af457f2a --- /dev/null +++ b/src/plugins/age/INTEGRATION.md @@ -0,0 +1,101 @@ +# age / PQC scaffold for the OnlyKey web app (onlyagent) + +Target repo: `0c-coder/onlykey.github.io`, branch `heroku-deploy`. +Goal: mirror the `age` PQC feature added in `trustcrypto/python-onlykey#90` +(+ firmware `trustcrypto/libraries#29`) in the browser WebCrypt/onlyagent app. + +This is a **scaffold**: the JS-side encapsulation + age stanza format are real and +testable; the device round-trip (getpubkey / decapsulate over the FIDO2 keyhandle +path) has ONE integration decision that must be confirmed against the firmware — +flagged as `TODO(firmware)` below. + +## What PQC means here +KEM (encryption), not signatures. Two key types: + +| keytype | id | pubkey | ciphertext | shared secret | +|--------------------|----|--------|-----------|---------------| +| `KEYTYPE_MLKEM768` | 5 | 1184 B | 1088 B | 32 B | +| `KEYTYPE_XWING` | 6 | 1216 B | 1120 B | 32 B | + +**No slots on the web path.** The OnlyKey has one reserved web-derivation key that +derives unlimited per-identity keys (the same mechanism the SSH/GPG/age agent +already uses). A key is named by a derivation LABEL (the identity), nothing is +stored on device, and the keypair is re-derived on demand from +(reserved key + label). So PQC reuses the existing derive flow, just with new +keytype bytes 5/6. + +Existing derive flow (index.js), unchanged except keytype: +`encode_ctaphid_request_as_keyhandle(OKCONNECT=228, optype, keytype, enc_resp, data)` +- optype: `DERIVE_PUBLIC_KEY=1` (get pubkey), `DERIVE_SHARED_SECRET=2` (get 32-byte + secret — KEM **decapsulation reuses this**, with the ciphertext as input) +- keytype: `NACL=0 P256R1=1 P256K1=2 CURVE25519=3` + `MLKEM768=5` `XWING=6` +- data: the identity keyhandle [+ KEM ciphertext for decapsulation] + +Why the 32-byte derived secret carries over: per identity the device already +produces a 32-byte derived secret (for ECC that 32 bytes *is* the key). +- **X-Wing (6)**: its private key IS a 32-byte seed — the device SHAKE256-expands + it into the ML-KEM-768 + X25519 keypair. Same 32 bytes the `CURVE25519` path + derives, zero new key material. (X-Wing keeps an X25519 half, so it's literally + your existing derived X25519 key + an ML-KEM key from the same seed.) +- **ML-KEM-768 (5)**: expand the 32-byte secret to ML-KEM's 64-byte `(d||z)` seed, + then `KeyGen_internal`. Pin the exact expansion to #90 / firmware. + +The **host runs encapsulation** (xwing.js, public key only); the **device only +decapsulates** after a button press. X-Wing combiner constants (from #90, +draft-connolly-cfrg-xwing-kem-09): `KEM_ID=0x647A`, `KDF_ID=0x0001`, +`AEAD_ID=0x0003`, label `5c2e2f2f5e5c`. + +## Files in this scaffold +- `xwing.js` — ML-KEM-768 + X-Wing **encapsulation** and the age `mlkem768x25519` + stanza helpers. Pure JS, no device needed. Unit-testable against #90 vectors. +- `onlykey-pqc.js` — device wrappers (`getPubKey`, `decapsulate`) built on the + existing `onlykeyApi.ctaphid_via_webauthn` / `u2fSignBuffer` plumbing. +- `age-pqc.js` — the onlyagent plugin: export recipient, encrypt to a recipient, + decrypt a file by asking the device to decapsulate. + +## Install +``` +npm install @noble/post-quantum @noble/curves @noble/hashes +``` +(tweetnacl is already a dep and can supply X25519 if you prefer it over @noble/curves.) + +## Where each file goes +- `xwing.js` -> `src/onlykey-fido2/onlykey/xwing.js` +- `onlykey-pqc.js` -> `src/onlykey-fido2/onlykey/onlykey-pqc.js` +- `age-pqc.js` -> `src/plugins/age/age-pqc.js` (+ an `age.page.html` like the + other plugins, and register it in `src/plugins.js`) + +## Edits to existing files +1. `src/onlykey-fido2/plugin.js` + - add to `provides`: `"onlykeyPqc"` + - `const onlykeyPqc = require('./onlykey/onlykey-pqc.js')(imports, onlykeyApi);` + - `register(null, { ..., onlykeyPqc });` +2. `src/onlykey-fido2/onlykey/onlykey-pgp.js` + - the binary `is_ecc` / `slotid()+100` scheme only distinguishes RSA vs ECC. + PQC needs to carry (keytype, slot) explicitly — see `onlykey-pqc.js` and + `TODO(firmware)` below. No change needed if PQC uses its own code path. +3. `package.json` — add the `@noble/*` deps above. +4. `docs/index.html` CSP — no change needed (all crypto is local; device I/O is + WebAuthn, which CSP does not gate). Only touch CSP if you add new fetch origins. + +## TODO(verify) — the remaining unknowns (no slot framing needed) +There's no slot to encode on the web path — it's the existing derive flow with +keytype 5/6 — so the earlier "slot byte" worry is gone. What still must be matched +to `python-onlykey#90` / firmware `libraries#29` (byte-exact ref: +`tests/test_age_wire.py`): +1. **deriveInput(label)** — reuse the agent's existing identity→keyhandle encoder + (the derivation-path packing used for SSH/age identities); don't invent a new + format. +2. **decapsulation op** — confirm KEM decaps uses `DERIVE_SHARED_SECRET=2` with the + ciphertext appended to the derivation data (vs a dedicated optype), and that the + 32-byte secret returns with `ENCRYPT_RESP`. +3. **ML-KEM-768 seed expansion** — the device-side 32→64 byte `(d||z)` derivation + for keytype 5 (X-Wing's 32-byte seed needs none). +4. **age stanza/recipient encoding + HPKE wrap** — match #90's `mlkem768x25519` + stanza and the HPKE suite (`KEM 0x647A / KDF 0x0001 / AEAD 0x0003`). + +## Test path +1. `npm install` + `bash BUILD.sh` builds to `docs/`. +2. Unit-test `xwing.js` encapsulation against #90's KAT/wire vectors (no device). +3. With a PQC-firmware OnlyKey: generate a key, export recipient, encrypt a file, + decrypt it (device button press), diff plaintext. diff --git a/src/plugins/age/age-pqc.js b/src/plugins/age/age-pqc.js new file mode 100644 index 00000000..3f6cea94 --- /dev/null +++ b/src/plugins/age/age-pqc.js @@ -0,0 +1,60 @@ +// age-pqc.js — onlyagent plugin: PQC (age `mlkem768x25519`) encrypt/decrypt. +// +// Wiring (architect.js DI, like the other plugins in src/plugins/*): +// consumes: ["app", "window", "onlykeyApi", "onlykeyPqc"] +// Add an `age.page.html` next to this file and register the plugin in +// src/plugins.js (copy how encrypt/decrypt are registered). +// +// Flow: +// - exportRecipient(slot, keytype): read device pubkey -> shareable recipient. +// - encryptToRecipient(recipient, data): HOST-side KEM encapsulate (xwing.js) + +// age stanza wrap. No device needed to ENCRYPT to someone. +// - decryptFile(ageBytes, slot, keytype): pull the stanza ciphertext, ask the +// DEVICE to decapsulate it, then unwrap the file key and decrypt the body. + +'use strict'; + +const xwing = require('../../onlykey-fido2/onlykey/xwing.js'); + +module.exports = function (imports) { + const { onlykeyPqc } = imports; + + // 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, keytype) { + const pk = await onlykeyPqc.getPubKey(label, keytype); + return xwing.pubkeyToRecipient(keytype, pk); // TODO(verify #90) encoding + } + + // Encrypt a file to a recipient. Pure host-side; matches `age -r `. + async function encryptToRecipient(recipient, plaintext /* Uint8Array */) { + const { keytype, pk } = xwing.recipientToPubkey(recipient); // TODO(verify #90) + const { ciphertext, sharedSecret } = xwing.encapsulate(keytype, pk); + // TODO(verify #90): derive the age file key and wrap it via HPKE + // (KEM 0x647A / KDF 0x0001 HKDF-SHA256 / AEAD 0x0003 ChaCha20Poly1305), + // emit the `mlkem768x25519` stanza, then ChaCha20Poly1305 the payload. + // Build this to byte-match python-onlykey#90's age output. + return { stanzaCiphertext: ciphertext, sharedSecret /* ...assemble age file */ }; + } + + // Decrypt a file: the device re-derives the private key from `label` and does + // the decapsulation. `label` is the same identity used to export the recipient. + async function decryptFile(ageBytes, label, keytype) { + // TODO(verify #90): parse the age header, find the `mlkem768x25519` stanza and + // extract its KEM ciphertext (1088/1120 B). + const stanzaCiphertext = parseStanzaCiphertext(ageBytes, keytype); + const sharedSecret = await onlykeyPqc.decapsulate(label, keytype, stanzaCiphertext); // device button press + // TODO(verify #90): HKDF(sharedSecret) -> unwrap file key -> ChaCha20Poly1305 + // decrypt the payload. Mirror python-onlykey#90 exactly. + return decryptBody(ageBytes, sharedSecret); + } + + function parseStanzaCiphertext(/* ageBytes, keytype */) { + throw new Error('parseStanzaCiphertext: implement age header parse per #90'); + } + function decryptBody(/* ageBytes, sharedSecret */) { + throw new Error('decryptBody: implement age payload decrypt per #90'); + } + + return { exportRecipient, encryptToRecipient, decryptFile }; +};