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.
4.5 KiB
@aere/pq-sign
Hybrid signatures for documents, payloads, receipts and API calls: a classical scheme (secp256k1 or Ed25519) and a NIST post-quantum scheme (ML-DSA-44 / 65 / 87, FIPS 204) sign the same digest, and a signature is valid only when both halves verify. Keys are generated and used only on your side; nothing is sent anywhere. The post-quantum half can be re-verified independently by the Aere Network chain precompile, so a third party does not have to trust this library, or you, to check it.
npm install @aere/pq-sign
Pure JavaScript (the audited noble libraries; no native code): Node 18+, and any bundler for the browser.
Why hybrid
A signature made with two independent schemes stays unforgeable while either scheme holds. If large quantum computers break secp256k1 and Ed25519, the ML-DSA half still binds the signer; if a flaw is found in the young lattice scheme, the classical half still does. This is the migration shape recommended by NIST, the German BSI and the French ANSSI for the transition years: not "replace", but "add, and require both".
Use
import { generateKeyPair, sign, verify, serialize, parse, envelopeHash } from '@aere/pq-sign';
const keys = generateKeyPair({ alg: 'secp256k1+ml-dsa-65' }); // or 'ed25519+ml-dsa-65', '...+ml-dsa-44', '...+ml-dsa-87'
const envelope = sign('release notes of myapp 1.4.2', keys); // string or Uint8Array
const r = verify('release notes of myapp 1.4.2', envelope);
// r = { valid: true, classical: true, pq: true, messageHashMatches: true, toSignMatches: true, reason: null }
const text = serialize(envelope); // JSON, all byte fields 0x-hex; parse(text) gives it back
Keep keys.classical.secretKey and keys.pq.secretKey where you keep secrets. Publish keys.classical.publicKey and
keys.pq.publicKey; they travel inside every envelope anyway. generateKeyPair({ seed }) makes the ML-DSA key
deterministic (32-byte seed) for test vectors; classical keys are always fresh unless classicalSecretKey is given.
What exactly is signed
messageHash = sha256(message)
toSign = sha256("aere-hybrid-signature-v1" || alg || messageHash)
Both schemes sign the 32 bytes of toSign: secp256k1 as a digest (RFC 6979, compact 64-byte signature, compressed
33-byte key), Ed25519 as a message (64-byte signature, 32-byte key), ML-DSA in the pure FIPS 204 interface with an
empty context. The domain string and the algorithm label are under the hash, so an envelope cannot be replayed as a
different algorithm pair or for another message. The envelope:
{ "v": 1, "kind": "aere-hybrid-signature", "alg": "secp256k1+ml-dsa-65", "hash": "sha256",
"messageHash": "0x…", "toSign": "0x…",
"classicalPublicKey": "0x…", "classicalSignature": "0x…",
"pqPublicKey": "0x…", "pqSignature": "0x…" }
Sizes: ML-DSA-44 key 1,312 B, signature 2,420 B; ML-DSA-65 1,952 / 3,309 B; ML-DSA-87 2,592 / 4,627 B.
Independent verification on chain
The Aere Network chain (2800) runs the NIST post-quantum verifiers as native precompiles. With an Aere Cloud key, the ML-DSA half of any envelope can be judged by the chain itself:
import { AereCloud } from '@aere/cloud';
import { verifyOnChain } from '@aere/pq-sign';
const r = await verifyOnChain(envelope, new AereCloud({ apiKey }));
// r = { pqValidOnChain: true, precompile: '0x…0ae3', block: 19018096, chainId: 2800 }
That answer does not depend on this library's code. One FIPS 204 detail, measured on chain 2800 on 2026-09-17: the
ML-DSA precompile implements the internal verify (Algorithm 8), and a standard external signature is made over
M' = 0x00 || len(ctx) || ctx || M; verifyOnChain asks the gateway for the external interface (interface: "external"), which builds M' for an empty context. Callers of the precompile directly should pass
precompileMessage(envelope) as the message. And if you want a first-seen time for the signature that cannot be
backdated and is covered by the validators' post-quantum certificate, notarize envelopeHash(envelope) with
POST /v1/notarize and read GET /v1/proof/{hash}: first appearance, covering anchor, finality, verification steps.
Tests
npm test runs every claim with its negative pair (a flipped bit in either half, another message, another key, a
foreign envelope). With AERE_CHEIE (or AERE_CHEIE_FISIER) in the environment it also asks the chain precompile to
confirm a fresh post-quantum signature and to refuse a corrupted one.
License
MIT. Aere Network, 2026.