aere-proof-of-software/sdk-pq-sign/index.mjs
Aere Network 065f84e34a Aere Proof of Software 1.4.0: an attestation of what was built, from what, by whom and when, verifiable without trusting Aere Network
Every artifact's SHA-256 and size, the SBOM's digest, the git commit and tree of the source, a hybrid signature (classical + ML-DSA,
both required) and, when notarized, a first-seen time on Aere Network covered by the validators' post-quantum certificate. verify
recomputes everything from the files; --rebuild-from repeats an npm pack build from a clone the verifier chose; the SBOM is
re-derived from the committed package-lock.json or, new in 1.4.0, from the committed go.mod and go.sum; --signer requires the
signing keys you expect (1.4.0); a developer credential binds the keys to a person, judged against a trust root you choose. The
builder's declared date can only accuse, never acquit (1.4.0). Includes the GitHub Action and the hybrid signature library it uses.

Laid out as in the development repository (tools/proof-of-software/, sdk-pq-sign/, sdk/) so that nothing is rewritten for
publication. Tests and negative controls measured on 2026-09-29 are listed in README.md.
2026-09-29 22:46:57 +03:00

175 lines
11 KiB
JavaScript

// @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 };
}