// @aere/pq-sign: hybrid signatures, classical + post-quantum, with keys that never leave the caller. // // Why hybrid: a signature made with two independent schemes stays unforgeable while EITHER scheme holds. If // large-scale quantum computers break secp256k1 / Ed25519, the ML-DSA half still binds the signer; if a flaw is // found in the young lattice scheme, the classical half still does. Verification requires BOTH halves to verify // over the SAME digest: a hybrid signature with one broken half is invalid, never "half valid". // // What is signed: toSign = sha256("aere-hybrid-signature-v1" || alg || sha256(message)). Both schemes sign // exactly those 32 bytes, so the post-quantum half can also be verified by the Aere Network chain precompile // (POST https://cloud.aere.network/v1/pq/verify with scheme, publicKey, signature = pqSignature, message = toSign), // which gives an independent, on-chain answer about the ML-DSA half. // // Envelope (JSON, all byte fields 0x-hex): // { v: 1, kind: "aere-hybrid-signature", alg: "secp256k1+ml-dsa-65", hash: "sha256", messageHash, toSign, // classicalPublicKey, classicalSignature, pqPublicKey, pqSignature } // // Pure JavaScript (noble libraries, audited, no native code): Node 18+ and any bundler for the browser. import { ml_dsa44, ml_dsa65, ml_dsa87 } from '@noble/post-quantum/ml-dsa.js'; import { secp256k1 } from '@noble/curves/secp256k1'; import { ed25519 } from '@noble/curves/ed25519'; import { sha256 } from '@noble/hashes/sha256'; import { randomBytes } from '@noble/hashes/utils'; export const VERSION = 1; export const KIND = 'aere-hybrid-signature'; const DOMAIN = new TextEncoder().encode('aere-hybrid-signature-v1'); // ML-DSA parameter sets (FIPS 204). `id` is the AIP-20 algorithm id and the chain precompile scheme name is `chainScheme`. export const PQ_SCHEMES = Object.freeze({ 'ml-dsa-44': Object.freeze({ id: 1, impl: ml_dsa44, publicKeyBytes: 1312, secretKeyBytes: 2560, signatureBytes: 2420, chainScheme: 'ml-dsa-44' }), 'ml-dsa-65': Object.freeze({ id: 2, impl: ml_dsa65, publicKeyBytes: 1952, secretKeyBytes: 4032, signatureBytes: 3309, chainScheme: 'ml-dsa-65' }), 'ml-dsa-87': Object.freeze({ id: 3, impl: ml_dsa87, publicKeyBytes: 2592, secretKeyBytes: 4896, signatureBytes: 4627, chainScheme: 'ml-dsa-87' }), }); // Classical schemes. secp256k1 signs the 32-byte digest (compact 64-byte signature, compressed 33-byte key); // Ed25519 signs the same 32 bytes as a message (64-byte signature, 32-byte key). export const CLASSICAL_SCHEMES = Object.freeze({ secp256k1: Object.freeze({ publicKeyBytes: 33, secretKeyBytes: 32, signatureBytes: 64, generate: () => secp256k1.utils.randomPrivateKey(), publicKey: (sk) => secp256k1.getPublicKey(sk, true), sign: (digest, sk) => secp256k1.sign(digest, sk).toCompactRawBytes(), verify: (sig, digest, pk) => { try { return secp256k1.verify(sig, digest, pk); } catch { return false; } }, }), ed25519: Object.freeze({ publicKeyBytes: 32, secretKeyBytes: 32, signatureBytes: 64, generate: () => ed25519.utils.randomPrivateKey(), publicKey: (sk) => ed25519.getPublicKey(sk), sign: (digest, sk) => ed25519.sign(digest, sk), verify: (sig, digest, pk) => { try { return ed25519.verify(sig, digest, pk); } catch { return false; } }, }), }); // ---------------------------------------------------------------- bytes export const toHex = (u8) => '0x' + Array.from(u8, (b) => b.toString(16).padStart(2, '0')).join(''); export function fromHex(h) { if (typeof h !== 'string') throw new TypeError('expected a 0x-hex string'); const s = h.startsWith('0x') || h.startsWith('0X') ? h.slice(2) : h; if (s.length % 2 || /[^0-9a-fA-F]/.test(s)) throw new TypeError('malformed hex'); const out = new Uint8Array(s.length / 2); for (let i = 0; i < out.length; i++) out[i] = parseInt(s.slice(2 * i, 2 * i + 2), 16); return out; } const asBytes = (m) => (m instanceof Uint8Array ? m : typeof m === 'string' ? new TextEncoder().encode(m) : (() => { throw new TypeError('message must be a Uint8Array or a string'); })()); function concat(...parts) { const n = parts.reduce((a, p) => a + p.length, 0); const out = new Uint8Array(n); let o = 0; for (const p of parts) { out.set(p, o); o += p.length; } return out; } function equal(a, b) { if (a.length !== b.length) return false; let d = 0; for (let i = 0; i < a.length; i++) d |= a[i] ^ b[i]; return d === 0; } // ---------------------------------------------------------------- algorithms export function parseAlg(alg) { const m = /^([a-z0-9]+)\+([a-z0-9-]+)$/.exec(String(alg || '')); if (!m || !CLASSICAL_SCHEMES[m[1]] || !PQ_SCHEMES[m[2]]) { throw new Error(`unknown alg "${alg}"; expected <${Object.keys(CLASSICAL_SCHEMES).join('|')}>+<${Object.keys(PQ_SCHEMES).join('|')}>`); } return { alg: `${m[1]}+${m[2]}`, classical: m[1], pq: m[2], C: CLASSICAL_SCHEMES[m[1]], P: PQ_SCHEMES[m[2]] }; } // The 32 bytes both schemes sign: domain, algorithm label and the message digest, so a signature cannot be // replayed under another algorithm pair or another message. export function messageHash(message) { return sha256(asBytes(message)); } export function toSign(alg, msgHash) { const { alg: a } = parseAlg(alg); return sha256(concat(DOMAIN, new TextEncoder().encode(a), msgHash)); } // ---------------------------------------------------------------- keys // Generates both key pairs locally. `seed` (32 bytes) makes the ML-DSA key deterministic (for tests and vectors); // classical keys are always fresh random unless `classicalSecretKey` is supplied. export function generateKeyPair({ alg = 'secp256k1+ml-dsa-65', seed, classicalSecretKey } = {}) { const A = parseAlg(alg); const csk = classicalSecretKey ? fromHex(classicalSecretKey) : A.C.generate(); if (csk.length !== A.C.secretKeyBytes) throw new Error(`classical secret key must be ${A.C.secretKeyBytes} bytes`); const s = seed ? fromHex(seed) : randomBytes(32); if (s.length !== 32) throw new Error('seed must be 32 bytes'); const k = A.P.impl.keygen(s); return { alg: A.alg, classical: { scheme: A.classical, publicKey: toHex(A.C.publicKey(csk)), secretKey: toHex(csk) }, pq: { scheme: A.pq, publicKey: toHex(k.publicKey), secretKey: toHex(k.secretKey), seed: toHex(s) }, }; } // ---------------------------------------------------------------- sign / verify export function sign(message, keys) { const A = parseAlg(keys.alg); const mh = messageHash(message); const ts = toSign(A.alg, mh); const csk = fromHex(keys.classical.secretKey), psk = fromHex(keys.pq.secretKey); const cpk = fromHex(keys.classical.publicKey), ppk = fromHex(keys.pq.publicKey); if (!equal(A.C.publicKey(csk), cpk)) throw new Error('classical public key does not match the secret key'); const csig = A.C.sign(ts, csk); const psig = A.P.impl.sign(ts, psk); // a fresh signature that does not verify is a library defect, never something to hand out if (!A.C.verify(csig, ts, cpk) || !A.P.impl.verify(psig, ts, ppk)) throw new Error('fresh hybrid signature does not verify'); return { v: VERSION, kind: KIND, alg: A.alg, hash: 'sha256', messageHash: toHex(mh), toSign: toHex(ts), classicalPublicKey: toHex(cpk), classicalSignature: toHex(csig), pqPublicKey: toHex(ppk), pqSignature: toHex(psig), }; } // Verifies BOTH halves over the recomputed digest of `message`. `valid` is true only when every check holds. export function verify(message, envelope) { const out = { valid: false, classical: false, pq: false, messageHashMatches: false, toSignMatches: false, reason: null }; let A; try { if (!envelope || envelope.v !== VERSION || envelope.kind !== KIND || envelope.hash !== 'sha256') { out.reason = 'unknown envelope'; return out; } A = parseAlg(envelope.alg); const mh = messageHash(message), ts = toSign(A.alg, mh); out.messageHashMatches = equal(mh, fromHex(envelope.messageHash)); out.toSignMatches = equal(ts, fromHex(envelope.toSign)); const cpk = fromHex(envelope.classicalPublicKey), ppk = fromHex(envelope.pqPublicKey); const csig = fromHex(envelope.classicalSignature), psig = fromHex(envelope.pqSignature); if (cpk.length !== A.C.publicKeyBytes || csig.length !== A.C.signatureBytes) { out.reason = 'classical key or signature has the wrong size'; return out; } if (ppk.length !== A.P.publicKeyBytes || psig.length !== A.P.signatureBytes) { out.reason = 'post-quantum key or signature has the wrong size'; return out; } out.classical = A.C.verify(csig, ts, cpk); try { out.pq = A.P.impl.verify(psig, ts, ppk); } catch { out.pq = false; } out.valid = out.classical && out.pq && out.messageHashMatches && out.toSignMatches; if (!out.valid) out.reason = !out.messageHashMatches ? 'message differs from the signed one' : !out.classical && !out.pq ? 'neither half verifies' : !out.classical ? 'classical half does not verify' : !out.pq ? 'post-quantum half does not verify' : 'toSign mismatch'; return out; } catch (e) { out.reason = 'malformed envelope: ' + (e.message || e); return out; } } // ---------------------------------------------------------------- envelope text forms export function serialize(envelope) { return JSON.stringify(envelope); } export function parse(text) { const e = JSON.parse(text); if (!e || e.kind !== KIND) throw new Error('not an aere-hybrid-signature envelope'); return e; } // the digest to notarize when you want an on-chain, post-quantum-anchored first-seen time for this signature // (POST /v1/notarize with this hash; GET /v1/proof/{hash} afterwards) export function envelopeHash(envelope) { return toHex(sha256(new TextEncoder().encode(serialize(envelope)))); } // ---------------------------------------------------------------- independent on-chain check of the post-quantum half // `cloud` is an @aere/cloud client (or anything with pqVerify(body)); the chain precompile verifies the ML-DSA // signature over toSign and answers with the block it was computed at. Independent of this library's code. // // FIPS 204 detail, measured on chain 2800 on 2026-09-17: the ML-DSA precompile implements the INTERNAL verify // (Algorithm 8). A standard external signature (this library, Bouncy Castle, AIP-20) is made over // M' = 0x00 || len(ctx) || ctx || M, so the precompile must be handed M' (here: 0x00 0x00 || toSign, empty context). // The gateway does that when asked for the external interface; the raw encoding is exported for other callers. export function precompileMessage(envelope, context = new Uint8Array(0)) { if (context.length > 255) throw new Error('context is at most 255 bytes'); return toHex(concat(new Uint8Array([0x00, context.length]), context, fromHex(envelope.toSign))); } export async function verifyOnChain(envelope, cloud) { const A = parseAlg(envelope.alg); const r = await cloud.pqVerify({ scheme: A.P.chainScheme, interface: 'external', publicKey: envelope.pqPublicKey, signature: envelope.pqSignature, message: envelope.toSign }); return { pqValidOnChain: r && r.valid === true, precompile: r && r.precompile, block: r && r.block, chainId: r && r.chainId }; }