@e-sig/core 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,119 @@
1
+ // pq-embed.ts
2
+ //
3
+ // Embed a post-quantum seal (see pq-seal.ts) into a rendered PDF as an
4
+ // append-only incremental update, and extract it back out.
5
+ //
6
+ // WHY append-only: the seal signs SHA-256 of the document bytes it covers
7
+ // (`coveredBytes` = length of the pre-seal PDF, P0). An incremental update never
8
+ // rewrites prior bytes, so in the final signed file P2, the prefix P2[0:coveredBytes]
9
+ // is byte-identical to P0 — the verifier recovers exactly what the seal signed.
10
+ // This ordering (embed seal → THEN apply the RSA PAdES signature) also lets the
11
+ // classical /ByteRange signature cryptographically cover the seal.
12
+ //
13
+ // The incremental update we append is deliberately minimal and mirrors the exact
14
+ // xref/trailer byte layout @signpdf/placeholder-plain expects (it later parses
15
+ // this file to add its own signature layer): one new indirect object holding the
16
+ // base64 seal, one single-subsection classic xref, a trailer that copies the
17
+ // document's /Root (+ /Info) and points /Prev at the previous xref, and a clean
18
+ // `startxref … %%EOF` tail.
19
+ //
20
+ // Retrieval scans for the object's `/Seal(<base64>)` literal. Base64's alphabet
21
+ // (A–Z a–z 0–9 + / =) contains no PDF-string metacharacters, so no escaping is
22
+ // needed and a simple regex is unambiguous. @signpdf never rewrites these bytes,
23
+ // so the seal survives the subsequent signing pass verbatim.
24
+ /** Marker object type; also the retrieval anchor. */
25
+ const SEAL_OBJ_TYPE = "ESigPQSeal";
26
+ const SEAL_EXTRACT_RE = new RegExp(`/Type\\s*/${SEAL_OBJ_TYPE}/V\\s+1/Seal\\(([A-Za-z0-9+/=]+)\\)`, "g");
27
+ /**
28
+ * Parse the most recent classic trailer to recover the fields our incremental
29
+ * update must carry forward: /Root, optional /Info, /Size (→ next free object
30
+ * number), and the last xref offset (→ our /Prev). Throws with a clear message
31
+ * on xref-stream PDFs, which the whole signing pipeline (@signpdf included)
32
+ * doesn't support anyway.
33
+ */
34
+ function readLastTrailer(pdf) {
35
+ const text = pdf.toString("latin1"); // 1 byte ↔ 1 char, so string index === byte offset
36
+ const sxIdx = text.lastIndexOf("startxref");
37
+ if (sxIdx < 0)
38
+ throw new Error("pq-embed: no startxref found (not a classic PDF)");
39
+ const prevMatch = text.slice(sxIdx + "startxref".length).match(/\d+/);
40
+ if (!prevMatch)
41
+ throw new Error("pq-embed: could not read previous startxref offset");
42
+ const prevStartxref = parseInt(prevMatch[0], 10);
43
+ const trIdx = text.lastIndexOf("trailer");
44
+ if (trIdx < 0 || trIdx > sxIdx) {
45
+ throw new Error("pq-embed: classic trailer not found (xref-stream PDFs are unsupported)");
46
+ }
47
+ const dict = text.slice(trIdx, sxIdx);
48
+ const root = dict.match(/\/Root\s+(\d+\s+\d+\s+R)/);
49
+ const info = dict.match(/\/Info\s+(\d+\s+\d+\s+R)/);
50
+ const size = dict.match(/\/Size\s+(\d+)/);
51
+ if (!root)
52
+ throw new Error("pq-embed: trailer has no /Root");
53
+ if (!size)
54
+ throw new Error("pq-embed: trailer has no /Size");
55
+ return {
56
+ rootRef: root[1].replace(/\s+/g, " "),
57
+ infoRef: info?.[1].replace(/\s+/g, " "),
58
+ size: parseInt(size[1], 10),
59
+ prevStartxref,
60
+ };
61
+ }
62
+ /**
63
+ * Append `seal` to `pdf` as an incremental update. Returns the new PDF; the first
64
+ * `pdf.length` bytes are preserved verbatim, so `seal.coveredBytes` (set by the
65
+ * caller to `pdf.length`) still points at the original document in the final file.
66
+ */
67
+ export function embedPqSeal(pdf, seal) {
68
+ const trailer = readLastTrailer(pdf);
69
+ const sealB64 = Buffer.from(JSON.stringify(seal), "utf8").toString("base64");
70
+ const objNum = trailer.size; // next free object number
71
+ // Separate our update from the prior %%EOF with a newline (harmless if the
72
+ // file already ended in one).
73
+ const head = Buffer.concat([pdf, Buffer.from("\n", "latin1")]);
74
+ const objOffset = head.length;
75
+ const objBuf = Buffer.from(`${objNum} 0 obj\n<</Type/${SEAL_OBJ_TYPE}/V 1/Seal(${sealB64})>>\nendobj\n`, "latin1");
76
+ const withObj = Buffer.concat([head, objBuf]);
77
+ const xrefOffset = withObj.length;
78
+ const paddedOffset = String(objOffset).padStart(10, "0");
79
+ const xref = Buffer.from("xref\n" +
80
+ `${objNum} 1\n` +
81
+ `${paddedOffset} 00000 n \n` + // 20-byte entry: 10 offset + gen 00000 + 'n' + trailing space + LF
82
+ "trailer\n<<\n" +
83
+ `/Size ${objNum + 1}\n` +
84
+ `/Root ${trailer.rootRef}\n` +
85
+ (trailer.infoRef ? `/Info ${trailer.infoRef}\n` : "") +
86
+ `/Prev ${trailer.prevStartxref}\n` +
87
+ ">>\nstartxref\n" +
88
+ `${xrefOffset}\n%%EOF\n`, "latin1");
89
+ return Buffer.concat([withObj, xref]);
90
+ }
91
+ /**
92
+ * Extract the embedded seal from a (possibly later-signed) PDF, or null if none
93
+ * is present / it is not valid JSON. Returns the FIRST seal if several exist.
94
+ *
95
+ * FIRST, deliberately: the genuine seal is embedded BEFORE the RSA PAdES
96
+ * signature, so it sits at the lowest file offset and is covered by the classical
97
+ * /ByteRange. A seal an attacker appends AFTER signing lands at a higher offset
98
+ * and is NOT RSA-covered — taking the first match ignores it, so even a standalone
99
+ * `verifyPqSeal` reports the authentic signer rather than the appended identity.
100
+ * Never throws — a malformed seal is treated as absent (fail-closed at verify).
101
+ */
102
+ export function extractPqSeal(pdf) {
103
+ SEAL_EXTRACT_RE.lastIndex = 0;
104
+ const match = SEAL_EXTRACT_RE.exec(pdf.toString("latin1"));
105
+ if (!match)
106
+ return null;
107
+ try {
108
+ return JSON.parse(Buffer.from(match[1], "base64").toString("utf8"));
109
+ }
110
+ catch {
111
+ return null;
112
+ }
113
+ }
114
+ /** True if the PDF carries an embedded post-quantum seal. */
115
+ export function hasPqSeal(pdf) {
116
+ SEAL_EXTRACT_RE.lastIndex = 0;
117
+ return SEAL_EXTRACT_RE.test(pdf.toString("latin1"));
118
+ }
119
+ //# sourceMappingURL=pq-embed.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pq-embed.js","sourceRoot":"","sources":["../src/pq-embed.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,59 @@
1
+ import { type PqSigningKeys, type PqPublicMaterial } from "./pq-seal.js";
2
+ /** A persisted, wrapped post-quantum key bundle + its public identity material. */
3
+ export interface StoredPqKeys {
4
+ id: string;
5
+ tenantId: string;
6
+ /** `wrapPqKeyBundle` output (AES-256-GCM). Never store the unwrapped bundle. */
7
+ keyBundleEncrypted: Uint8Array;
8
+ /** base64 raw Ed25519 public key. */
9
+ ed25519Public: string;
10
+ /** base64 raw ML-DSA-65 public key. */
11
+ mldsa65Public: string;
12
+ /** SHA-256 hex of the ML-DSA-65 public key — the identity to publish/pin. */
13
+ mldsa65Fpr: string;
14
+ /** 128-bit hex id over both public keys. */
15
+ keyId: string;
16
+ active: boolean;
17
+ rotatedFromId?: string | null;
18
+ createdAt: Date;
19
+ }
20
+ /** Bring-your-own persistence for post-quantum key bundles (mirrors CertStore). */
21
+ export interface PqKeyStore {
22
+ /** The active bundle for a tenant, or null if none exists yet. */
23
+ findActive(tenantId: string): Promise<StoredPqKeys | null>;
24
+ /** Persist a new bundle. Ensure at most one `active=true` per tenant. */
25
+ insert(input: {
26
+ tenantId: string;
27
+ keyBundleEncrypted: Uint8Array;
28
+ public: PqPublicMaterial;
29
+ rotatedFromId?: string | null;
30
+ }): Promise<StoredPqKeys>;
31
+ /** Mark a bundle inactive (used during rotation). */
32
+ deactivate(id: string): Promise<void>;
33
+ }
34
+ export interface EnsurePqKeysResult {
35
+ record: StoredPqKeys;
36
+ /** In-memory signing keys ready for `signPdf({ pqSeal: { keys } })`. */
37
+ keys: PqSigningKeys;
38
+ public: PqPublicMaterial;
39
+ }
40
+ /**
41
+ * Ensure an active post-quantum key bundle for the tenant, generating + wrapping
42
+ * one on first use. `passphrase` (≥24 chars) wraps the bundle at rest.
43
+ */
44
+ export declare function ensureActivePqKeys(opts: {
45
+ store: PqKeyStore;
46
+ tenantId: string;
47
+ passphrase: string;
48
+ }): Promise<EnsurePqKeysResult>;
49
+ /**
50
+ * Force a rotation: deactivate the current bundle (if any) and mint a fresh one
51
+ * with `rotatedFromId` set. Documents already sealed with the old key keep
52
+ * verifying — verification uses the public key embedded in each seal.
53
+ */
54
+ export declare function rotatePqKeys(opts: {
55
+ store: PqKeyStore;
56
+ tenantId: string;
57
+ passphrase: string;
58
+ }): Promise<EnsurePqKeysResult>;
59
+ //# sourceMappingURL=pq-lifecycle.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pq-lifecycle.d.ts","sourceRoot":"","sources":["../src/pq-lifecycle.ts"],"names":[],"mappings":"AAeA,OAAO,EAKL,KAAK,aAAa,EAClB,KAAK,gBAAgB,EACtB,MAAM,cAAc,CAAC;AAEtB,mFAAmF;AACnF,MAAM,WAAW,YAAY;IAC3B,EAAE,EAAE,MAAM,CAAC;IACX,QAAQ,EAAE,MAAM,CAAC;IACjB,gFAAgF;IAChF,kBAAkB,EAAE,UAAU,CAAC;IAC/B,qCAAqC;IACrC,aAAa,EAAE,MAAM,CAAC;IACtB,uCAAuC;IACvC,aAAa,EAAE,MAAM,CAAC;IACtB,6EAA6E;IAC7E,UAAU,EAAE,MAAM,CAAC;IACnB,4CAA4C;IAC5C,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,OAAO,CAAC;IAChB,aAAa,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,SAAS,EAAE,IAAI,CAAC;CACjB;AAED,mFAAmF;AACnF,MAAM,WAAW,UAAU;IACzB,kEAAkE;IAClE,UAAU,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,GAAG,IAAI,CAAC,CAAC;IAC3D,yEAAyE;IACzE,MAAM,CAAC,KAAK,EAAE;QACZ,QAAQ,EAAE,MAAM,CAAC;QACjB,kBAAkB,EAAE,UAAU,CAAC;QAC/B,MAAM,EAAE,gBAAgB,CAAC;QACzB,aAAa,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;KAC/B,GAAG,OAAO,CAAC,YAAY,CAAC,CAAC;IAC1B,qDAAqD;IACrD,UAAU,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACvC;AAED,MAAM,WAAW,kBAAkB;IACjC,MAAM,EAAE,YAAY,CAAC;IACrB,wEAAwE;IACxE,IAAI,EAAE,aAAa,CAAC;IACpB,MAAM,EAAE,gBAAgB,CAAC;CAC1B;AAED;;;GAGG;AACH,wBAAsB,kBAAkB,CAAC,IAAI,EAAE;IAC7C,KAAK,EAAE,UAAU,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;CACpB,GAAG,OAAO,CAAC,kBAAkB,CAAC,CAiB9B;AAED;;;;GAIG;AACH,wBAAsB,YAAY,CAAC,IAAI,EAAE;IACvC,KAAK,EAAE,UAAU,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;CACpB,GAAG,OAAO,CAAC,kBAAkB,CAAC,CAI9B"}
@@ -0,0 +1,55 @@
1
+ // pq-lifecycle.ts
2
+ //
3
+ // Stack-agnostic "ensure an active post-quantum key bundle for this tenant"
4
+ // helper — the ML-DSA-65 / Ed25519 analogue of ensureActiveCert. Depends only on
5
+ // the bring-your-own `PqKeyStore` interface plus the crypto in pq-seal; no DB, no
6
+ // stack assumptions.
7
+ //
8
+ // Unlike signing certificates, hybrid key bundles do not expire on a clock —
9
+ // rotation is an explicit, deployment-driven decision (e.g. suspected key
10
+ // compromise, or a policy roll). `ensureActivePqKeys` therefore generates on
11
+ // first use and reuses thereafter; `rotatePqKeys` mints a fresh bundle and links
12
+ // the predecessor. Consumers that don't want a managed store can skip this module
13
+ // entirely and use generatePqKeyBundle / wrapPqKeyBundle / loadPqSigningKeys
14
+ // directly, persisting the wrapped blob wherever they like.
15
+ import { generatePqKeyBundle, loadPqSigningKeys, wrapPqKeyBundle, unwrapPqKeyBundle, } from "./pq-seal.js";
16
+ /**
17
+ * Ensure an active post-quantum key bundle for the tenant, generating + wrapping
18
+ * one on first use. `passphrase` (≥24 chars) wraps the bundle at rest.
19
+ */
20
+ export async function ensureActivePqKeys(opts) {
21
+ const existing = await opts.store.findActive(opts.tenantId);
22
+ if (existing) {
23
+ const bundle = unwrapPqKeyBundle(existing.keyBundleEncrypted, opts.passphrase);
24
+ const keys = loadPqSigningKeys(bundle);
25
+ return {
26
+ record: existing,
27
+ keys,
28
+ public: {
29
+ ed25519: existing.ed25519Public,
30
+ mldsa65: existing.mldsa65Public,
31
+ mldsa65Fpr: existing.mldsa65Fpr,
32
+ keyId: existing.keyId,
33
+ },
34
+ };
35
+ }
36
+ return mint(opts.store, opts.tenantId, opts.passphrase, null);
37
+ }
38
+ /**
39
+ * Force a rotation: deactivate the current bundle (if any) and mint a fresh one
40
+ * with `rotatedFromId` set. Documents already sealed with the old key keep
41
+ * verifying — verification uses the public key embedded in each seal.
42
+ */
43
+ export async function rotatePqKeys(opts) {
44
+ const existing = await opts.store.findActive(opts.tenantId);
45
+ if (existing)
46
+ await opts.store.deactivate(existing.id);
47
+ return mint(opts.store, opts.tenantId, opts.passphrase, existing?.id ?? null);
48
+ }
49
+ async function mint(store, tenantId, passphrase, rotatedFromId) {
50
+ const { bundle, public: pub } = generatePqKeyBundle();
51
+ const keyBundleEncrypted = wrapPqKeyBundle(bundle, passphrase);
52
+ const record = await store.insert({ tenantId, keyBundleEncrypted, public: pub, rotatedFromId });
53
+ return { record, keys: loadPqSigningKeys(bundle), public: pub };
54
+ }
55
+ //# sourceMappingURL=pq-lifecycle.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pq-lifecycle.js","sourceRoot":"","sources":["../src/pq-lifecycle.ts"],"names":[],"mappings":"AAAA,kBAAkB;AAClB,EAAE;AACF,4EAA4E;AAC5E,iFAAiF;AACjF,kFAAkF;AAClF,qBAAqB;AACrB,EAAE;AACF,6EAA6E;AAC7E,0EAA0E;AAC1E,6EAA6E;AAC7E,iFAAiF;AACjF,kFAAkF;AAClF,6EAA6E;AAC7E,4DAA4D;AAE5D,OAAO,EACL,mBAAmB,EACnB,iBAAiB,EACjB,eAAe,EACf,iBAAiB,GAGlB,MAAM,cAAc,CAAC;AA2CtB;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,kBAAkB,CAAC,IAIxC;IACC,MAAM,QAAQ,GAAG,MAAM,IAAI,CAAC,KAAK,CAAC,UAAU,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;IAC5D,IAAI,QAAQ,EAAE,CAAC;QACb,MAAM,MAAM,GAAG,iBAAiB,CAAC,QAAQ,CAAC,kBAAkB,EAAE,IAAI,CAAC,UAAU,CAAC,CAAC;QAC/E,MAAM,IAAI,GAAG,iBAAiB,CAAC,MAAM,CAAC,CAAC;QACvC,OAAO;YACL,MAAM,EAAE,QAAQ;YAChB,IAAI;YACJ,MAAM,EAAE;gBACN,OAAO,EAAE,QAAQ,CAAC,aAAa;gBAC/B,OAAO,EAAE,QAAQ,CAAC,aAAa;gBAC/B,UAAU,EAAE,QAAQ,CAAC,UAAU;gBAC/B,KAAK,EAAE,QAAQ,CAAC,KAAK;aACtB;SACF,CAAC;IACJ,CAAC;IACD,OAAO,IAAI,CAAC,IAAI,CAAC,KAAK,EAAE,IAAI,CAAC,QAAQ,EAAE,IAAI,CAAC,UAAU,EAAE,IAAI,CAAC,CAAC;AAChE,CAAC;AAED;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,YAAY,CAAC,IAIlC;IACC,MAAM,QAAQ,GAAG,MAAM,IAAI,CAAC,KAAK,CAAC,UAAU,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;IAC5D,IAAI,QAAQ;QAAE,MAAM,IAAI,CAAC,KAAK,CAAC,UAAU,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC;IACvD,OAAO,IAAI,CAAC,IAAI,CAAC,KAAK,EAAE,IAAI,CAAC,QAAQ,EAAE,IAAI,CAAC,UAAU,EAAE,QAAQ,EAAE,EAAE,IAAI,IAAI,CAAC,CAAC;AAChF,CAAC;AAED,KAAK,UAAU,IAAI,CACjB,KAAiB,EACjB,QAAgB,EAChB,UAAkB,EAClB,aAA4B;IAE5B,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,GAAG,mBAAmB,EAAE,CAAC;IACtD,MAAM,kBAAkB,GAAG,eAAe,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC;IAC/D,MAAM,MAAM,GAAG,MAAM,KAAK,CAAC,MAAM,CAAC,EAAE,QAAQ,EAAE,kBAAkB,EAAE,MAAM,EAAE,GAAG,EAAE,aAAa,EAAE,CAAC,CAAC;IAChG,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,iBAAiB,CAAC,MAAM,CAAC,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC;AAClE,CAAC"}
@@ -0,0 +1,133 @@
1
+ import crypto from "node:crypto";
2
+ /** Seal schema version. Bump on any breaking change to the signed payload shape. */
3
+ export declare const PQ_SEAL_VERSION: 1;
4
+ /** Hybrid algorithm identifier embedded in (and bound by) every seal. */
5
+ export declare const PQ_SEAL_ALG: "hybrid-ed25519-ml-dsa-65";
6
+ /** Bundle schema version for the wrapped at-rest key material. */
7
+ declare const PQ_BUNDLE_VERSION: 1;
8
+ /**
9
+ * The wrappable at-rest key bundle. Persist `wrapPqKeyBundle(bundle, passphrase)`;
10
+ * never store the raw bundle. Compact by design: an Ed25519 PKCS#8 key plus a
11
+ * 32-byte ML-DSA seed (the full 4032-byte ML-DSA secret key is re-derived
12
+ * deterministically via `ml_dsa65.keygen(seed)`).
13
+ */
14
+ export interface PqKeyBundle {
15
+ v: typeof PQ_BUNDLE_VERSION;
16
+ /** base64 PKCS#8 DER of the Ed25519 private key. */
17
+ ed25519Pkcs8: string;
18
+ /** base64 32-byte ML-DSA-65 seed. */
19
+ mldsa65Seed: string;
20
+ }
21
+ /** Public key material derived from a bundle — safe to publish/pin as the signer identity. */
22
+ export interface PqPublicMaterial {
23
+ /** base64 raw 32-byte Ed25519 public key. */
24
+ ed25519: string;
25
+ /** base64 raw 1952-byte ML-DSA-65 public key. */
26
+ mldsa65: string;
27
+ /** SHA-256 hex of the raw ML-DSA-65 public key — the post-quantum identity fingerprint. */
28
+ mldsa65Fpr: string;
29
+ /** Stable 128-bit hex id over both public keys (bound into every seal). */
30
+ keyId: string;
31
+ }
32
+ /** In-memory signing keys, ready to produce seals. Never persisted directly. */
33
+ export interface PqSigningKeys {
34
+ ed25519PrivateKey: crypto.KeyObject;
35
+ ed25519PublicRaw: Uint8Array;
36
+ mldsa65SecretKey: Uint8Array;
37
+ mldsa65PublicKey: Uint8Array;
38
+ }
39
+ /**
40
+ * A hybrid post-quantum seal. The signed payload is every field EXCEPT `sig`,
41
+ * serialized canonically (see `canonicalJson`). `digest` is the SHA-256 (hex) of
42
+ * the first `coveredBytes` bytes of the final document — the PDF-verify layer
43
+ * checks that binding; `verifyPqSealSignatures` only checks the two signatures.
44
+ */
45
+ export interface PqSeal {
46
+ v: typeof PQ_SEAL_VERSION;
47
+ alg: typeof PQ_SEAL_ALG;
48
+ /** Digest algorithm over the covered document bytes. */
49
+ over: "sha256";
50
+ /** SHA-256 hex of the covered document bytes (the first `coveredBytes` bytes). */
51
+ digest: string;
52
+ /** Byte length of the document prefix the digest covers (P0 = rendered PDF pre-seal). */
53
+ coveredBytes: number;
54
+ /** ISO 8601 seal creation time. */
55
+ signedAt: string;
56
+ /** 128-bit hex id over both public keys. */
57
+ keyId: string;
58
+ keys: {
59
+ ed25519: string;
60
+ mldsa65: string;
61
+ mldsa65Fpr: string;
62
+ };
63
+ sig: {
64
+ /** base64 Ed25519 signature over canonicalJson(payload). */
65
+ ed25519: string;
66
+ /** base64 ML-DSA-65 signature over canonicalJson(payload). */
67
+ mldsa65: string;
68
+ };
69
+ }
70
+ /** Result of verifying a seal's two signatures (does NOT check document binding). */
71
+ export interface PqSealVerification {
72
+ /** Classical Ed25519 signature valid. */
73
+ ed25519: boolean;
74
+ /** Post-quantum ML-DSA-65 signature valid. */
75
+ mldsa65: boolean;
76
+ /** The embedded `mldsa65Fpr` matches SHA-256(mldsa65 public key). */
77
+ fingerprintOk: boolean;
78
+ /** The embedded `keyId` matches the digest of the two public keys it claims. */
79
+ keyIdOk: boolean;
80
+ /** Hybrid verdict: every check passed (both signatures + fingerprint + keyId). */
81
+ ok: boolean;
82
+ }
83
+ /**
84
+ * Generate a fresh hybrid key bundle plus its derived public material. The bundle
85
+ * is what you wrap + persist; the public material is what you publish/pin as the
86
+ * signer's post-quantum identity.
87
+ */
88
+ export declare function generatePqKeyBundle(): {
89
+ bundle: PqKeyBundle;
90
+ public: PqPublicMaterial;
91
+ };
92
+ /** Rehydrate in-memory signing keys from a bundle (deterministically re-derives the ML-DSA keypair). */
93
+ export declare function loadPqSigningKeys(bundle: PqKeyBundle): PqSigningKeys;
94
+ /** Derive the publishable public material for a set of signing keys. */
95
+ export declare function publicMaterialForKeys(keys: PqSigningKeys): PqPublicMaterial;
96
+ /** AES-256-GCM-wrap a key bundle for persistence. `passphrase` ≥ 24 chars. */
97
+ export declare function wrapPqKeyBundle(bundle: PqKeyBundle, passphrase: string): Uint8Array;
98
+ /** Inverse of `wrapPqKeyBundle`. Throws on wrong passphrase / tampering (auth-tag mismatch). */
99
+ export declare function unwrapPqKeyBundle(blob: Uint8Array, passphrase: string): PqKeyBundle;
100
+ export interface BuildPqSealInput {
101
+ /** SHA-256 hex of the covered document bytes. */
102
+ digestHex: string;
103
+ /** Byte length the digest covers (the document prefix protected by the seal). */
104
+ coveredBytes: number;
105
+ keys: PqSigningKeys;
106
+ /** Seal timestamp. Defaults to now. */
107
+ signedAt?: Date;
108
+ }
109
+ /**
110
+ * Produce a hybrid seal over `digestHex`. Both signatures are computed over the
111
+ * exact same canonical bytes (the seal minus `sig`), binding digest, covered
112
+ * length, public keys, timestamp and keyId under BOTH schemes.
113
+ */
114
+ export declare function buildPqSeal(input: BuildPqSealInput): PqSeal;
115
+ /**
116
+ * Verify a seal's two signatures over its own payload (does NOT bind the seal to a
117
+ * document — the PDF-verify layer checks `digest` against the covered bytes).
118
+ *
119
+ * Fails CLOSED: any malformed field, wrong-length key/signature, or thrown error
120
+ * yields `ok:false` rather than propagating. The hybrid verdict requires BOTH
121
+ * signatures valid AND the ML-DSA fingerprint self-consistent.
122
+ */
123
+ export declare function verifyPqSealSignatures(seal: PqSeal): PqSealVerification;
124
+ /**
125
+ * Deterministic JSON serialization (recursively key-sorted, no insignificant
126
+ * whitespace) used as the signing input. The seal payload contains only strings
127
+ * and safe integers, so this is unambiguous without full RFC 8785 number
128
+ * canonicalization. Both signing and verification serialize the identical field
129
+ * set (payload = seal minus `sig`), so the two sides always agree.
130
+ */
131
+ export declare function canonicalJson(value: unknown): string;
132
+ export {};
133
+ //# sourceMappingURL=pq-seal.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pq-seal.d.ts","sourceRoot":"","sources":["../src/pq-seal.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,261 @@
1
+ // pq-seal.ts
2
+ //
3
+ // Hybrid post-quantum document seal — Ed25519 (classical) + ML-DSA-65 (FIPS 204,
4
+ // module-lattice, quantum-resistant). This is the cryptographic core of e-sig's
5
+ // post-quantum wedge: every sealed document carries TWO independent signatures
6
+ // over the same payload, and a verifier requires BOTH to pass. If either scheme
7
+ // is ever broken (a CRQC breaks Ed25519, or an unforeseen lattice attack dents
8
+ // ML-DSA), the seal still stands on the other — the belt-and-suspenders
9
+ // migration path NIST/CNSA 2.0 recommends over a hard cutover.
10
+ //
11
+ // The seal is a small canonical-JSON object signed over a SHA-256 digest of the
12
+ // document bytes it covers. It does NOT replace the PDF's PKCS#7/PAdES RSA
13
+ // signature (which stays valid in every PDF reader, including Adobe Acrobat —
14
+ // no mainstream reader validates ML-DSA in PAdES yet, 2026). Instead the seal is
15
+ // embedded in the PDF and the RSA /ByteRange signature is applied on top, so the
16
+ // classical signature cryptographically covers the seal (see sign-pdf.ts).
17
+ //
18
+ // Identity model (v1): raw ML-DSA-65 public key + its SHA-256 fingerprint carried
19
+ // in the seal (the `keyId` / `mldsa65Fpr`), verified TOFU / against-published-key.
20
+ // A self-signed ML-DSA-65 X.509 certificate (RFC 9881 OID 2.16.840.1.101.3.4.3.18)
21
+ // is a deliberate fast-follow, not required to ship the seal.
22
+ //
23
+ // Algorithm binding: both signatures are computed over the SAME canonical bytes —
24
+ // the seal object minus its `sig` field — so digest, covered length, public keys,
25
+ // timestamp and keyId are all bound together and cannot be swapped independently.
26
+ import crypto from "node:crypto";
27
+ import { ml_dsa65 } from "@noble/post-quantum/ml-dsa.js";
28
+ import { encryptKeyPem, decryptKeyPem } from "./cert-issuer.js";
29
+ // ---------- Constants ----------
30
+ /** Seal schema version. Bump on any breaking change to the signed payload shape. */
31
+ export const PQ_SEAL_VERSION = 1;
32
+ /** Hybrid algorithm identifier embedded in (and bound by) every seal. */
33
+ export const PQ_SEAL_ALG = "hybrid-ed25519-ml-dsa-65";
34
+ /** Bundle schema version for the wrapped at-rest key material. */
35
+ const PQ_BUNDLE_VERSION = 1;
36
+ /**
37
+ * DER SubjectPublicKeyInfo prefix for an Ed25519 key (RFC 8410 §4). A raw 32-byte
38
+ * Ed25519 public key is exactly this 12-byte prefix followed by the key, so we
39
+ * can round-trip raw keys ↔ node KeyObjects without a heavier ASN.1 dependency.
40
+ */
41
+ const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
42
+ // FIPS 204 ML-DSA-65 sizes (raw, unencoded) — asserted defensively at load time.
43
+ const MLDSA65_PUBLIC_LEN = 1952;
44
+ const MLDSA65_SIGNATURE_LEN = 3309;
45
+ const MLDSA65_SEED_LEN = 32;
46
+ const ED25519_RAW_PUBLIC_LEN = 32;
47
+ const ED25519_SIGNATURE_LEN = 64;
48
+ // ---------- Key generation / loading ----------
49
+ /**
50
+ * Generate a fresh hybrid key bundle plus its derived public material. The bundle
51
+ * is what you wrap + persist; the public material is what you publish/pin as the
52
+ * signer's post-quantum identity.
53
+ */
54
+ export function generatePqKeyBundle() {
55
+ const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
56
+ const ed25519Pkcs8 = privateKey.export({ type: "pkcs8", format: "der" });
57
+ const ed25519PublicRaw = rawEd25519FromKeyObject(publicKey);
58
+ const seed = crypto.randomBytes(MLDSA65_SEED_LEN);
59
+ const { publicKey: mldsa65PublicKey } = ml_dsa65.keygen(seed);
60
+ const bundle = {
61
+ v: PQ_BUNDLE_VERSION,
62
+ ed25519Pkcs8: b64(ed25519Pkcs8),
63
+ mldsa65Seed: b64(seed),
64
+ };
65
+ return { bundle, public: publicMaterial(ed25519PublicRaw, mldsa65PublicKey) };
66
+ }
67
+ /** Rehydrate in-memory signing keys from a bundle (deterministically re-derives the ML-DSA keypair). */
68
+ export function loadPqSigningKeys(bundle) {
69
+ if (bundle.v !== PQ_BUNDLE_VERSION) {
70
+ throw new Error(`loadPqSigningKeys: unknown bundle version ${bundle.v}`);
71
+ }
72
+ const ed25519PrivateKey = crypto.createPrivateKey({
73
+ key: Buffer.from(bundle.ed25519Pkcs8, "base64"),
74
+ format: "der",
75
+ type: "pkcs8",
76
+ });
77
+ const ed25519PublicRaw = rawEd25519FromKeyObject(crypto.createPublicKey(ed25519PrivateKey));
78
+ const seed = Buffer.from(bundle.mldsa65Seed, "base64");
79
+ if (seed.length !== MLDSA65_SEED_LEN) {
80
+ throw new Error(`loadPqSigningKeys: ML-DSA seed must be ${MLDSA65_SEED_LEN} bytes, got ${seed.length}`);
81
+ }
82
+ const { publicKey, secretKey } = ml_dsa65.keygen(seed);
83
+ return {
84
+ ed25519PrivateKey,
85
+ ed25519PublicRaw,
86
+ mldsa65SecretKey: secretKey,
87
+ mldsa65PublicKey: publicKey,
88
+ };
89
+ }
90
+ /** Derive the publishable public material for a set of signing keys. */
91
+ export function publicMaterialForKeys(keys) {
92
+ return publicMaterial(keys.ed25519PublicRaw, keys.mldsa65PublicKey);
93
+ }
94
+ // ---------- At-rest wrapping (reuses cert-issuer AES-256-GCM) ----------
95
+ /** AES-256-GCM-wrap a key bundle for persistence. `passphrase` ≥ 24 chars. */
96
+ export function wrapPqKeyBundle(bundle, passphrase) {
97
+ return encryptKeyPem(JSON.stringify(bundle), passphrase);
98
+ }
99
+ /** Inverse of `wrapPqKeyBundle`. Throws on wrong passphrase / tampering (auth-tag mismatch). */
100
+ export function unwrapPqKeyBundle(blob, passphrase) {
101
+ const bundle = JSON.parse(decryptKeyPem(blob, passphrase));
102
+ if (bundle.v !== PQ_BUNDLE_VERSION) {
103
+ throw new Error(`unwrapPqKeyBundle: unknown bundle version ${bundle.v}`);
104
+ }
105
+ return bundle;
106
+ }
107
+ /**
108
+ * Produce a hybrid seal over `digestHex`. Both signatures are computed over the
109
+ * exact same canonical bytes (the seal minus `sig`), binding digest, covered
110
+ * length, public keys, timestamp and keyId under BOTH schemes.
111
+ */
112
+ export function buildPqSeal(input) {
113
+ if (!/^[0-9a-f]{64}$/.test(input.digestHex)) {
114
+ throw new Error("buildPqSeal: digestHex must be 64 lowercase hex chars (SHA-256)");
115
+ }
116
+ if (!Number.isInteger(input.coveredBytes) || input.coveredBytes <= 0) {
117
+ throw new Error("buildPqSeal: coveredBytes must be a positive integer");
118
+ }
119
+ const pub = publicMaterialForKeys(input.keys);
120
+ const payload = {
121
+ v: PQ_SEAL_VERSION,
122
+ alg: PQ_SEAL_ALG,
123
+ over: "sha256",
124
+ digest: input.digestHex,
125
+ coveredBytes: input.coveredBytes,
126
+ signedAt: (input.signedAt ?? new Date()).toISOString(),
127
+ keyId: pub.keyId,
128
+ keys: { ed25519: pub.ed25519, mldsa65: pub.mldsa65, mldsa65Fpr: pub.mldsa65Fpr },
129
+ };
130
+ const signingInput = Buffer.from(canonicalJson(payload), "utf8");
131
+ const ed25519Sig = crypto.sign(null, signingInput, input.keys.ed25519PrivateKey);
132
+ const mldsa65Sig = ml_dsa65.sign(signingInput, input.keys.mldsa65SecretKey);
133
+ return { ...payload, sig: { ed25519: b64(ed25519Sig), mldsa65: b64(mldsa65Sig) } };
134
+ }
135
+ /**
136
+ * Verify a seal's two signatures over its own payload (does NOT bind the seal to a
137
+ * document — the PDF-verify layer checks `digest` against the covered bytes).
138
+ *
139
+ * Fails CLOSED: any malformed field, wrong-length key/signature, or thrown error
140
+ * yields `ok:false` rather than propagating. The hybrid verdict requires BOTH
141
+ * signatures valid AND the ML-DSA fingerprint self-consistent.
142
+ */
143
+ export function verifyPqSealSignatures(seal) {
144
+ const fail = {
145
+ ed25519: false,
146
+ mldsa65: false,
147
+ fingerprintOk: false,
148
+ keyIdOk: false,
149
+ ok: false,
150
+ };
151
+ try {
152
+ if (seal.v !== PQ_SEAL_VERSION || seal.alg !== PQ_SEAL_ALG || seal.over !== "sha256")
153
+ return fail;
154
+ const ed25519Pub = Buffer.from(seal.keys.ed25519, "base64");
155
+ const mldsa65Pub = Buffer.from(seal.keys.mldsa65, "base64");
156
+ const ed25519Sig = Buffer.from(seal.sig.ed25519, "base64");
157
+ const mldsa65Sig = Buffer.from(seal.sig.mldsa65, "base64");
158
+ if (ed25519Pub.length !== ED25519_RAW_PUBLIC_LEN)
159
+ return fail;
160
+ if (mldsa65Pub.length !== MLDSA65_PUBLIC_LEN)
161
+ return fail;
162
+ if (ed25519Sig.length !== ED25519_SIGNATURE_LEN)
163
+ return fail;
164
+ if (mldsa65Sig.length !== MLDSA65_SIGNATURE_LEN)
165
+ return fail;
166
+ // Reconstruct the exact signed payload: the seal minus `sig`.
167
+ const { sig: _sig, ...payload } = seal;
168
+ void _sig;
169
+ const signingInput = Buffer.from(canonicalJson(payload), "utf8");
170
+ // Self-consistency of the identity fields carried in the (signed) payload:
171
+ // fingerprint over the ML-DSA key, and keyId over BOTH public keys. These
172
+ // don't add trust (the keys are the seal's own), but they make the identity
173
+ // fields the signature commits to actually correspond to the keys present.
174
+ const fingerprintOk = sha256Hex(mldsa65Pub) === seal.keys.mldsa65Fpr;
175
+ const expectedKeyId = crypto
176
+ .createHash("sha256")
177
+ .update(ed25519Pub)
178
+ .update(mldsa65Pub)
179
+ .digest("hex")
180
+ .slice(0, 32);
181
+ const keyIdOk = seal.keyId === expectedKeyId;
182
+ let ed25519 = false;
183
+ try {
184
+ ed25519 = crypto.verify(null, signingInput, ed25519RawToPublicKey(ed25519Pub), ed25519Sig);
185
+ }
186
+ catch {
187
+ ed25519 = false;
188
+ }
189
+ let mldsa65 = false;
190
+ try {
191
+ mldsa65 = ml_dsa65.verify(mldsa65Sig, signingInput, mldsa65Pub);
192
+ }
193
+ catch {
194
+ mldsa65 = false;
195
+ }
196
+ return {
197
+ ed25519,
198
+ mldsa65,
199
+ fingerprintOk,
200
+ keyIdOk,
201
+ ok: ed25519 && mldsa65 && fingerprintOk && keyIdOk,
202
+ };
203
+ }
204
+ catch {
205
+ return fail;
206
+ }
207
+ }
208
+ // ---------- Internals ----------
209
+ function publicMaterial(ed25519PublicRaw, mldsa65PublicKey) {
210
+ const ed25519 = b64(ed25519PublicRaw);
211
+ const mldsa65 = b64(mldsa65PublicKey);
212
+ const mldsa65Fpr = sha256Hex(mldsa65PublicKey);
213
+ const keyId = crypto
214
+ .createHash("sha256")
215
+ .update(ed25519PublicRaw)
216
+ .update(mldsa65PublicKey)
217
+ .digest("hex")
218
+ .slice(0, 32);
219
+ return { ed25519, mldsa65, mldsa65Fpr, keyId };
220
+ }
221
+ /** Extract the raw 32-byte Ed25519 public key from a node KeyObject (SPKI tail). */
222
+ function rawEd25519FromKeyObject(pub) {
223
+ const spki = pub.export({ type: "spki", format: "der" });
224
+ return Uint8Array.prototype.slice.call(spki, spki.length - ED25519_RAW_PUBLIC_LEN);
225
+ }
226
+ /** Rebuild a node public KeyObject from a raw 32-byte Ed25519 key. */
227
+ function ed25519RawToPublicKey(raw) {
228
+ if (raw.length !== ED25519_RAW_PUBLIC_LEN) {
229
+ throw new Error(`ed25519 raw public key must be ${ED25519_RAW_PUBLIC_LEN} bytes`);
230
+ }
231
+ return crypto.createPublicKey({
232
+ key: Buffer.concat([ED25519_SPKI_PREFIX, Buffer.from(raw)]),
233
+ format: "der",
234
+ type: "spki",
235
+ });
236
+ }
237
+ function sha256Hex(bytes) {
238
+ return crypto.createHash("sha256").update(bytes).digest("hex");
239
+ }
240
+ function b64(bytes) {
241
+ return Buffer.from(bytes).toString("base64");
242
+ }
243
+ /**
244
+ * Deterministic JSON serialization (recursively key-sorted, no insignificant
245
+ * whitespace) used as the signing input. The seal payload contains only strings
246
+ * and safe integers, so this is unambiguous without full RFC 8785 number
247
+ * canonicalization. Both signing and verification serialize the identical field
248
+ * set (payload = seal minus `sig`), so the two sides always agree.
249
+ */
250
+ export function canonicalJson(value) {
251
+ if (value === null || typeof value !== "object") {
252
+ return JSON.stringify(value);
253
+ }
254
+ if (Array.isArray(value)) {
255
+ return `[${value.map((v) => canonicalJson(v)).join(",")}]`;
256
+ }
257
+ const obj = value;
258
+ const keys = Object.keys(obj).sort();
259
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(obj[k])}`).join(",")}}`;
260
+ }
261
+ //# sourceMappingURL=pq-seal.js.map