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
31 changes: 31 additions & 0 deletions src/onlykey-fido2/onlykey/OPENPGP-INTEGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,3 +110,34 @@ npm run build -- --config-build-only=dist # -> dist/openpgp.js (window.openpgp
```
(The committed `openpgp.js` here is the non-minified IIFE build; swap in
`dist/openpgp.min.js` for production once you run a full `npm run build`.)


---

## Composite PQC PGP (IETF OpenPGP-PQC, algo 105/107)

This branch upgrades `openpgp.js` to the composite PQC build (OpenPGP.js v6 with
`draft-ietf-openpgp-pqc`) and adds `onlykey-openpgp-pqc.js`, which delegates the
IETF composite keys loaded on the device (firmware `feature/pqc-pgp-slots`,
`KEYTYPE_PQC_PGP = 7`):

- **Sign** `pqc_mldsa_ed25519` (107): the `signer` hook returns
`{ eccSignature: Ed25519(64), mldsaSignature: ML-DSA-65(3309) }`. Device does
each half; `openpgp.js` serializes the composite signature.
- **Decrypt** `pqc_mlkem_x25519` (105): the `ecdh` hook returns the X25519 shared
secret and the `mlkemDecaps` hook returns the ML-KEM key share; `openpgp.js`
does the `KMAC256("OpenPGPCompositeKDFv1")` combine + AES key-unwrap.

Device callbacks map to the firmware wire protocol (okpqc.cpp):

signEcc(digest) -> OKSIGN [0x00]+digest -> 64 B Ed25519 sig
signPqc(digest) -> OKSIGN [0x01]+digest -> 3309 B ML-DSA-65 sig
x25519(point) -> OKDECRYPT 32-B point -> 32 B shared secret
mlkemDecaps(ct) -> OKDECRYPT 1088-B ct -> 32 B ML-KEM key share

Key loading (160-byte composite seed blob into an RSA slot via `OKSETPRIV 0x67`)
is done by the host CLI (`python-onlykey` `feature/pqc-composite`), not the browser.

**Host stack note:** this is the IETF OpenPGP-PQC format (matches Sequoia /
openpgp.js), NOT GnuPG's LibrePGP hybrid — GnuPG will not interoperate with these
keys. UNTESTED end-to-end; validate against a flashed device.
67 changes: 67 additions & 0 deletions src/onlykey-fido2/onlykey/gen-composite-key.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/**
* gen-composite-key.js — generate an IETF OpenPGP-PQC composite key and emit the
* 160-byte OnlyKey seed blob + armored public key for testing.
*
* node gen-composite-key.js ./openpgp.js "Name <email>"
*
* Output: writes composite-pubkey.asc (import into the web app / recipients) and
* prints the 160-byte blob hex to load with: onlykey-cli setpqc RSA1 <hex>
*
* Blob layout (matches onlykey/pqc.py + okpqc.h):
* [0:32] Ed25519 secret (sign, ecc half) <- primary privateParams.eccSecretKey
* [32:64] ML-DSA-65 seed (sign, pqc half) <- primary privateParams.mldsaSeed
* [64:96] X25519 secret (decrypt, ecc half)<- subkey privateParams.eccSecretKey
* [96:160] ML-KEM-768 seed (decrypt, pqc half)<- subkey privateParams.mlkemSeed
*/
const fs = require('fs');
const path = require('path');
// The bundle is a browser IIFE (`var openpgp = (function(){...})()`), not CommonJS.
// Try require() first; fall back to eval-loading the IIFE and capturing `openpgp`.
function loadOpenPGP(p) {
let m;
try { m = require(p); } catch (e) { m = null; }
if (m && typeof m.generateKey === 'function') return m;
const code = fs.readFileSync(p, 'utf8');
return new Function(code + '\n;return openpgp;')();
}
const openpgp = loadOpenPGP(path.resolve(process.argv[2] || './openpgp.js'));

const uid = process.argv[3] || 'OnlyKey PQC Test <pqc@onlykey.io>';
const m = uid.match(/^(.*?)\s*<(.+?)>\s*$/);
const userIDs = [ m ? { name: m[1], email: m[2] } : { name: uid } ];

function hex(u8) { return Buffer.from(u8).toString('hex'); }
function need(u8, n, label) {
if (!u8) throw new Error('missing ' + label);
if (u8.length !== n) throw new Error(label + ' must be ' + n + ' bytes, got ' + u8.length);
return Buffer.from(u8);
}

(async () => {
const { privateKey, publicKey } = await openpgp.generateKey({
type: 'pqc', userIDs, format: 'armored', config: { v6Keys: true }
});

const priv = await openpgp.readKey({ armoredKey: privateKey });
const primary = priv.keyPacket; // pqc_mldsa_ed25519
const subkey = priv.subkeys[0].keyPacket; // pqc_mlkem_x25519
const pp = primary.privateParams, sp = subkey.privateParams;

const ed25519 = need(pp.eccSecretKey, 32, 'primary eccSecretKey (Ed25519)');
const mldsa = need(pp.mldsaSeed, 32, 'primary mldsaSeed');
const x25519 = need(sp.eccSecretKey, 32, 'subkey eccSecretKey (X25519)');
const mlkem = need(sp.mlkemSeed, 64, 'subkey mlkemSeed');

const blob = Buffer.concat([ed25519, mldsa, x25519, mlkem]);
if (blob.length !== 160) throw new Error('blob is ' + blob.length + ' bytes, expected 160');

fs.writeFileSync('composite-pubkey.asc', publicKey);
fs.writeFileSync('composite-privkey.asc', privateKey);
fs.writeFileSync('composite-blob.hex', blob.toString('hex') + '\n');

console.log('fingerprint :', priv.getFingerprint());
console.log('primary algo:', primary.algorithm, ' subkey algo:', subkey.algorithm);
console.log('blob (160B) :', blob.toString('hex'));
console.log('\nwrote composite-pubkey.asc, composite-privkey.asc, composite-blob.hex');
console.log('load with : onlykey-cli setpqc RSA1 composite-blob.hex');
})().catch(e => { console.error('ERROR:', e.message); process.exit(1); });
155 changes: 155 additions & 0 deletions src/onlykey-fido2/onlykey/onlykey-openpgp-pqc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
/**
* onlykey-openpgp-pqc.js — OnlyKey composite PQC PGP delegation for openpgp.js
* ---------------------------------------------------------------------------
* Companion to onlykey-openpgp.js (classical RSA/ECC). This wires the IETF
* OpenPGP-PQC composite keys (draft-ietf-openpgp-pqc) to the loaded key on the
* device (firmware libraries PR #31, KEYTYPE_PQC_PGP = 7):
*
* pqc_mldsa_ed25519 (algo 107) — sign = Ed25519(64) + ML-DSA-65(3309)
* pqc_mlkem_x25519 (algo 105) — decrypt: device does X25519 + ML-KEM decaps;
* openpgp.js does the KMAC256("OpenPGPCompositeKDFv1")
* combine + AES key-unwrap.
*
* Uses the composite openpgp.js build (0c-coder/openpgpjs onlykey-hardware-hooks
* @ pqc) which exposes:
* openpgp.setHardwareHooks({ signer, ecdh, mlkemDecaps })
* openpgp.createHardwarePrivateKey(publicKey)
*
* Device I/O is provided by the caller as thin callbacks over the SAME OnlyKey
* HID transport used elsewhere (OKSIGN=237, OKDECRYPT=240). The wire framing
* matches okpqc.cpp exactly:
* signEcc(digest) -> OKSIGN payload [0x00] + digest -> 64-byte Ed25519 sig
* signPqc(digest) -> OKSIGN payload [0x01] + digest -> 3309-byte ML-DSA sig
* x25519(point) -> OKDECRYPT payload 32-byte point -> 32-byte shared secret
* mlkemDecaps(ct) -> OKDECRYPT payload 1088-byte ct -> 32-byte key share
*
* Single composite hardware key per session (the common case). For mixed
* software+hardware sessions, refine isDeviceKey() with a keyID compare.
*
* CommonJS (require) or browser global (window.onlykeyOpenPGPpqc).
*/
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory();
else root.onlykeyOpenPGPpqc = factory();
}(typeof self !== 'undefined' ? self : this, function () {
'use strict';

function toU8(x) {
if (x instanceof Uint8Array) return x;
if (Array.isArray(x)) return Uint8Array.from(x);
if (x && x.length != null) return Uint8Array.from(x);
throw new Error('cannot coerce device result to bytes');
}

// Promisify a kbpgp-style `fn(bytes, cb)` device wrapper (cb(resultBytes)).
function call(fn, arg) {
return new Promise(function (resolve, reject) {
try {
fn(toU8(arg), function (result) {
if (result == null) return reject(new Error('OnlyKey returned no data'));
resolve(toU8(result));
});
} catch (e) { reject(e); }
});
}

// expected on-device output sizes (validate before handing back to openpgp.js)
var ED25519_SIG = 64, MLDSA_SIG = 3309, SS = 32;

/**
* Register composite PQC hardware hooks.
* @param {object} opts
* @param {object} opts.openpgp the composite openpgp.js namespace
* @param {function} opts.signEcc (digest, cb) => cb(64-byte Ed25519 sig)
* @param {function} opts.signPqc (digest, cb) => cb(3309-byte ML-DSA-65 sig)
* @param {function} opts.x25519 (point32, cb) => cb(32-byte shared secret)
* @param {function} opts.mlkemDecaps (ct1088, cb) => cb(32-byte key share)
* @param {function} [opts.signEccClassical] optional: classical ed25519 for non-composite keys
*/
function registerHooks(opts) {
var openpgp = opts.openpgp;
if (!openpgp || typeof openpgp.setHardwareHooks !== 'function') {
throw new Error('registerHooks: composite openpgp.js with setHardwareHooks required');
}
var P = openpgp.enums.publicKey;

openpgp.setHardwareHooks({
// ---- composite signing (algo 107): return both halves --------------
async signer(algo, hashAlgo, hashed, publicKeyParams) {
hashed = toU8(hashed);
if (algo === P.pqc_mldsa_ed25519) {
if (!opts.signEcc || !opts.signPqc) return null;
var ecc = await call(opts.signEcc, hashed); // OKSIGN [0]+digest
var pqc = await call(opts.signPqc, hashed); // OKSIGN [1]+digest
if (ecc.length !== ED25519_SIG) throw new Error('bad Ed25519 sig len ' + ecc.length);
if (pqc.length !== MLDSA_SIG) throw new Error('bad ML-DSA sig len ' + pqc.length);
// openpgp.js composite serializer expects { eccSignature, mldsaSignature }.
return { eccSignature: ecc, mldsaSignature: pqc };
}
// optional classical ed25519 fallthrough (e.g. a legacy signing subkey)
if ((algo === P.ed25519 || algo === P.eddsaLegacy) && opts.signEccClassical) {
var raw = await call(opts.signEccClassical, hashed);
if (algo === P.ed25519) return { RS: raw };
return { r: raw.subarray(0, 32), s: raw.subarray(32, 64) };
}
return null; // software path
},

// ---- ECC (X25519) half of composite decryption --------------------
// openpgp.js composite decaps calls recomputeSharedSecret(x25519, V, ...)
// which routes here; it does the SHA3-256 ecc key-share itself afterwards.
async ecdh(algo, ephemeralPublicKey, publicKeyParams) {
if (algo !== P.x25519 && algo !== P.ecdh) return null;
if (!opts.x25519) return null;
var ss = await call(opts.x25519, toU8(ephemeralPublicKey)); // OKDECRYPT 32B
if (ss.length !== SS) throw new Error('bad X25519 ss len ' + ss.length);
return ss;
},

// ---- ML-KEM half of composite decryption --------------------------
// returns the ML-KEM key share; openpgp.js does the KMAC256 combine +
// AES key-unwrap. mlkemCipherText is the 1088-byte ML-KEM ciphertext.
async mlkemDecaps(algo, mlkemCipherText, mlkemSecretKey) {
if (algo !== P.pqc_mlkem_x25519) return null;
if (!opts.mlkemDecaps) return null;
var share = await call(opts.mlkemDecaps, toU8(mlkemCipherText)); // OKDECRYPT 1088B
if (share.length !== SS) throw new Error('bad ML-KEM share len ' + share.length);
return share;
}
});
return openpgp;
}

/**
* Build a hardware-backed composite PrivateKey from the device's armored
* composite PUBLIC key (algo 105 subkey + algo 107 primary). Operations route
* to the device via the hooks above.
*/
async function buildHardwareKey(openpgp, armoredPublicKey) {
var pub = await openpgp.readKey({ armoredKey: armoredPublicKey });
return openpgp.createHardwarePrivateKey(pub);
}

// --- convenience wrappers ---------------------------------------------------
async function okSign(openpgp, hwKey, text, o) {
o = o || {};
var message = await openpgp.createMessage({ text: text });
return openpgp.sign({ message, signingKeys: hwKey,
format: o.format || 'armored', detached: !!o.detached });
}

async function okDecrypt(openpgp, hwKey, armoredMessage, o) {
o = o || {};
var message = await openpgp.readMessage({ armoredMessage: armoredMessage });
return openpgp.decrypt({ message, decryptionKeys: hwKey,
verificationKeys: o.verificationKeys, format: o.format || 'utf8' });
}

return {
registerHooks: registerHooks,
clearHooks: function (openpgp) { openpgp.clearHardwareHooks(); },
buildHardwareKey: buildHardwareKey,
okSign: okSign,
okDecrypt: okDecrypt
};
}));
53 changes: 53 additions & 0 deletions src/onlykey-fido2/onlykey/onlykey-pgp.js
Original file line number Diff line number Diff line change
Expand Up @@ -378,7 +378,60 @@ module.exports = function(imports) {
return u2fSignBuffer(OKSIGN, typeof ct === 'string' ? ct.match(/.{2}/g) : ct, cb);
};

// ===== Composite PQC PGP (IETF OpenPGP-PQC) device callbacks ==========
// The composite key is loaded in an RSA slot (1-4) with `onlykey-cli setpqc`.
// Firmware okpqc.cpp (KEYTYPE_PQC_PGP=7) dispatches by the slot's keytype:
// SIGN payload = [selector]+digest (0=Ed25519 ->64B, 1=ML-DSA ->3309B)
// DECRYPT payload = 32-B X25519 point OR 1088-B ML-KEM ct (selected by size)
// openpgp.js does the KMAC256("OpenPGPCompositeKDFv1") combine + AES unwrap.
// NOTE: RSA-slot addressing over the WebAuthn/CTAPHID transport and the PIN
// framing are best-effort here (mirrors auth_sign_ecc) and must be confirmed
// on hardware; set the slot with KB_ONLYKEY.registerPQCHooks(openpgp, slot).
KB_ONLYKEY.pqcMode = false;
KB_ONLYKEY.pqcSlot = 1;
function pqcSetPin(bytes) {
var h = sha256(bytes);
pin = [get_pin(h[0]), get_pin(h[15]), get_pin(h[31])];
}
KB_ONLYKEY.pqc_sign_ecc = function(digest, cb) { // -> 64-byte Ed25519 sig
var payload = [0].concat(Array.from(digest));
pqcSetPin(payload); KB_ONLYKEY.pqcMode = true;
return u2fSignBuffer(OKSIGN, payload, cb);
};
KB_ONLYKEY.pqc_sign_pqc = function(digest, cb) { // -> 3309-byte ML-DSA-65 sig
var payload = [1].concat(Array.from(digest));
pqcSetPin(payload); KB_ONLYKEY.pqcMode = true;
return u2fSignBuffer(OKSIGN, payload, cb);
};
KB_ONLYKEY.pqc_x25519 = function(point, cb) { // 32-B point -> 32-B shared secret
var payload = Array.from(point);
pqcSetPin(payload); KB_ONLYKEY.pqcMode = true;
return u2fSignBuffer(OKDECRYPT, payload, cb);
};
KB_ONLYKEY.pqc_mlkem = function(ct, cb) { // 1088-B ct -> 32-B key share
var payload = Array.from(ct);
pqcSetPin(payload); KB_ONLYKEY.pqcMode = true;
return u2fSignBuffer(OKDECRYPT, payload, cb);
};
// Register the composite openpgp.js hooks against these device callbacks.
// og = composite openpgp.js namespace (window.openpgp); needs window.onlykeyOpenPGPpqc.
KB_ONLYKEY.registerPQCHooks = function(og, pqcSlot) {
og = og || (typeof window !== 'undefined' && window.openpgp);
var glue = (typeof window !== 'undefined' && window.onlykeyOpenPGPpqc) ||
(typeof onlykeyOpenPGPpqc !== 'undefined' && onlykeyOpenPGPpqc);
if (!og || !glue) throw new Error('composite openpgp.js + onlykey-openpgp-pqc.js required');
if (pqcSlot) KB_ONLYKEY.pqcSlot = pqcSlot;
return glue.registerHooks({
openpgp: og,
signEcc: KB_ONLYKEY.pqc_sign_ecc,
signPqc: KB_ONLYKEY.pqc_sign_pqc,
x25519: KB_ONLYKEY.pqc_x25519,
mlkemDecaps: KB_ONLYKEY.pqc_mlkem
});
};

function slotid(slot) {
if (KB_ONLYKEY.pqcMode) return KB_ONLYKEY.pqcSlot; // composite key in RSA slot 1-4
var ret = (slot == OKSIGN ? 2 : 1);

if(KB_ONLYKEY.is_ecc){
Expand Down
Loading