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
104 changes: 104 additions & 0 deletions src/onlykey-fido2/onlykey/onlykey-pqc.js
Original file line number Diff line number Diff line change
@@ -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 };
};
82 changes: 82 additions & 0 deletions src/onlykey-fido2/onlykey/xwing.js
Original file line number Diff line number Diff line change
@@ -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,
};
101 changes: 101 additions & 0 deletions src/plugins/age/INTEGRATION.md
Original file line number Diff line number Diff line change
@@ -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.
60 changes: 60 additions & 0 deletions src/plugins/age/age-pqc.js
Original file line number Diff line number Diff line change
@@ -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 <recipient>`.
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 };
};