@majikah/majik-signature 0.3.0 → 0.4.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.
package/README.md CHANGED
@@ -103,7 +103,7 @@ Verification requires **both** to pass. This means:
103
103
  Both signatures cover a **domain-separated canonical payload**:
104
104
 
105
105
  ```
106
- "majik-signature-v1:" + JSON({ v, id, ts, ct, hash[, alh][, vu] })
106
+ "majik-signature-v1:" + JSON({ v, id, ts, ct, hash[, alh][, vu][, vch] })
107
107
  ```
108
108
 
109
109
  | Field | Description |
@@ -115,6 +115,7 @@ Both signatures cover a **domain-separated canonical payload**:
115
115
  | `hash` | SHA-256 of the original content, base64 |
116
116
  | `alh` | SHA-256 of the canonical allowlist, base64 — **present only** when this signer is establishing an allowlist |
117
117
  | `vu` | Optional ISO 8601 expiry. When present, verify() treats the signature as invalid once the current time is past this value. Absent = never expires (matches all pre-existing signatures — fully backward compatible). Covered by the canonical signing payload when present, so it cannot be stripped or extended post-hoc without breaking both signatures. |
118
+ | `vch` | SHA-256 of the `fileVersions` revision chain, base64 — present only when file versioning is in use; covers the chain state as of this signer's own entry, so a rewritten chain breaks their signature. |
118
119
 
119
120
  `alh` is *omitted entirely* (not set to `null`) on every signature that isn't establishing an allowlist. This is a deliberate backward-compatibility guarantee: every signature produced before multi-sig support existed still verifies today, because its payload bytes are unchanged.
120
121
 
@@ -144,11 +145,17 @@ Files don't hold a single signature — they hold a **`MultiSigEnvelope`** (mode
144
145
  - **Restricted signing**: the first signer may supply an `expectedSigners` allowlist. That allowlist is cryptographically committed to via `allowlistHash` in the issuer's own canonical payload — tampering with the allowlist after the fact breaks the issuer's signature. Non-listed signers are rejected *before* any cryptographic operation runs.
145
146
  - **Sealing**: the issuer (and only the issuer) can seal an envelope, computing a **SHA3-512** hash over every current signatory plus a seal timestamp, domain-separated with `"majik-seal-v1:"`. A sealed envelope rejects all further signing attempts — including from the issuer.
146
147
 
147
- ### 6. Trusted Timestamps (TSA)
148
+ ### 6. File Versioning for Visually-Mutating Multi-Sig Workflows
149
+
150
+ Ordinary multi-sig assumes every signer signs the *same* underlying bytes. That breaks when signing also means visually stamping the document — each signer's stamp changes the file's content, so the next signer is working from different bytes than the one before them.
151
+
152
+ `signFile()`'s `priorSignedFile` option handles this: pass the file as it was received (carrying the prior signer's envelope), and the library verifies that prior envelope is genuinely valid before extending it with a new `FileVersion` revision entry, cryptographically bound via `versionChainHash` — see [File Versioning & Multi-Sig Revisions](#file-versioning--multi-sig-revisions).
153
+
154
+ ### 7. Trusted Timestamps (TSA)
148
155
 
149
156
  A `MajikSignature` can optionally carry a `MajikTimestamp` — a signature from a timestamp authority over a canonical payload (`"majikah-tsa-v-1:"` domain) binding a digest, a server-generated nonce, and a server-authoritative timestamp. The TSA signature is itself a full Majik Signature (Ed25519 + ML-DSA-87), so it inherits the same hybrid guarantees. TSA timestamps are also what power *attested* [signing-order verification](#verifying-signing-order) — see below.
150
157
 
151
- ### 7. Chain Anchoring
158
+ ### 8. Chain Anchoring
152
159
 
153
160
  A sealed envelope's seal hash can be committed to an external blockchain (handled by a companion product, e.g. `majik-notary`) and the resulting confirmed anchor registered back into the envelope via `MajikChainAnchor` records. The SDK does not talk to any chain itself — it only builds the canonical memo to anchor (`buildChainAnchorMemo()`) and embeds/reads already-confirmed anchors. Anchoring requires the envelope to already be sealed.
154
161
 
@@ -463,8 +470,8 @@ if (result.mode === 'map') {
463
470
  }
464
471
 
465
472
  // Later — verify an extracted batch against the manifest
466
- const map = await MajikSignature.getSignatureMapFromMJKSMAP(mapBlob);
467
- // (or: const map = await MajikSignatureMap.fromMJKSMAP(mapBlob);)
473
+ const map = await MajikSignatureMap.fromMJKSMAP(mapBlob);
474
+
468
475
 
469
476
  const results = await MajikSignature.verifyFilesFromMjksMap(
470
477
  map,
@@ -544,6 +551,49 @@ const blob = await MajikSignature.registerChainAnchor(sealedBlob, confirmedAncho
544
551
  const anchors = await MajikSignature.getChainAnchors(blob);
545
552
  ```
546
553
 
554
+ ---
555
+
556
+ ### File Versioning & Multi-Sig Revisions
557
+
558
+ Use this when signing also means visually stamping the file (drawn signatures, watermark overlays) — so each signer's turn changes the bytes, not just adds a detached signature.
559
+
560
+ ```typescript
561
+ import { MajikSignature } from '@majikah/majik-signature';
562
+
563
+ // Issuer signs first — same as a normal first signature.
564
+ const { blob: signedV1 } = await MajikSignature.signFile(originalFile, aliceKey, {
565
+ expectedSigners: [
566
+ MajikSignature.expectedSignerFromKey(aliceKey),
567
+ MajikSignature.expectedSignerFromKey(bobKey),
568
+ ],
569
+ });
570
+
571
+ // Bob visually stamps the file (application-specific), THEN signs — passing
572
+ // priorSignedFile so the new revision correctly chains onto Alice's.
573
+ const stampedByBob = await applyVisualStamp(signedV1, bobKey);
574
+ const { blob: signedV2 } = await MajikSignature.signFile(stampedByBob, bobKey, {
575
+ priorSignedFile: signedV1,
576
+ message: 'Approved pending final review',
577
+ });
578
+
579
+ // Tier 1 — self-contained verification, no archived revisions needed.
580
+ // Proves every signer's commitment is genuine and the chain hasn't been
581
+ // rewritten, using only the final file.
582
+ const chainResult = await MajikSignature.verifyFileChain(signedV2);
583
+ console.log('Chain valid:', chainResult.chainValid);
584
+ console.log('Latest signer valid:', chainResult.latest.valid);
585
+
586
+ // Tier 2 — archive-assisted, full proof against retained revision bytes.
587
+ // Accepts Blob, File, Uint8Array, or ArrayBuffer for each prior revision.
588
+ const revisionResult = await MajikSignature.verifyFileRevisions(
589
+ signedV2,
590
+ [originalFile, signedV1],
591
+ );
592
+ console.log('All revisions valid:', revisionResult.allValid);
593
+ console.log('Complete set supplied:', revisionResult.isCompleteSet);
594
+ ```
595
+
596
+
547
597
  ---
548
598
 
549
599
  ## API Reference
@@ -908,6 +958,48 @@ Embeds an **already-confirmed** `MajikChainAnchor` into the file's envelope. The
908
958
 
909
959
  ---
910
960
 
961
+ ### File Versioning API
962
+
963
+ #### `MajikSignature.signFile(file, key, options?)` — versioning-relevant options
964
+ - `options?.priorSignedFile?: Blob` — the file as received, before this signer's own visual change. Presence of this option is what makes the call a **revision** (2nd signer+) rather than a fresh signature: the prior file's embedded envelope is fully self-verified (commitment check, per §2.4 of the MJKSIG spec) before being extended.
965
+ - `options?.message?: string` — optional human-readable note stored on this revision's `FileVersion` entry.
966
+
967
+ Throws `MajikSignatureError` if `priorSignedFile` has no embedded envelope, or if any existing signature in it fails verification.
968
+
969
+ #### `MajikSignature.verifyFileChain(file, options?)`
970
+ Self-contained (Tier 1) verification — no archived revisions required. Verifies the most recent signer fully against the file's current bytes, and every earlier signer via commitment-only verification (`verifyCommitment()`, recomputed from stored fields) plus a recomputed `versionChainHash` check against the current `fileVersions`.
971
+
972
+ **Returns:** `Promise<FileChainVerification>`
973
+ ```typescript
974
+ {
975
+ latest: VerificationResult;
976
+ history: RevisionCommitmentResult[]; // VerificationResult & { commitmentOnly: true }
977
+ chainValid: boolean;
978
+ }
979
+ ```
980
+
981
+ #### `MajikSignature.verifyFileRevisions(finalFile, revisions, options?)`
982
+ Archive-assisted (Tier 2) verification — full proof against retained revision bytes. `revisions` accepts any mix of `Blob`, `File`, `Uint8Array`, or `ArrayBuffer`; the final file is included automatically, no need to pass it twice.
983
+
984
+ - `options?.resolvePublicKeys?: (signerId: string) => MajikSignerPublicKeys | Promise<MajikSignerPublicKeys>` — defaults to trusting the envelope's embedded keys, same as `verify()`; pass a resolver to cross-check against an external registry instead.
985
+ - `options?.now?: Date`
986
+
987
+ **Returns:** `Promise<RevisionSetVerification>`
988
+ ```typescript
989
+ {
990
+ allValid: boolean; // every fileVersions entry matched a supplied revision AND verified
991
+ isCompleteSet: boolean; // the supplied revisions cover the whole chain, no gaps
992
+ results: RevisionCheckResult[]; // per-version status: "verified" | "unmatched" | "chain_broken" | "signature_invalid"
993
+ }
994
+ ```
995
+
996
+ #### `MajikSignature.verifyCommitment(signature, publicKeys, now?)`
997
+ Verifies a signature's cryptographic commitment (Ed25519 + ML-DSA-87 over the recomputed canonical payload, plus expiry) **without requiring the original content bytes**. Used internally by `verifyFileChain()` for every signer but the latest; exposed directly for callers building their own chain-inspection tooling.
998
+
999
+ **Returns:** `RevisionCommitmentResult` — a `VerificationResult` with `commitmentOnly: true`.
1000
+
1001
+ ---
1002
+
911
1003
  ### Image Stamping (Experimental)
912
1004
 
913
1005
  > ⚠️ **These APIs are explicitly marked experimental in source and are not yet API-stable.** Expect breaking changes across minor versions.
@@ -987,11 +1079,13 @@ A single signer's envelope (`MajikSignatureJSON`):
987
1079
  "edSignature": "base64-ed25519-signature-64-bytes",
988
1080
  "mlDsaSignature": "base64-ml-dsa-87-signature-4595-bytes",
989
1081
  "allowlistHash": "base64-sha256-of-allowlist-44-chars",
1082
+ "validUntil": "2027-01-01T00:00:00.000Z",
1083
+ "versionChainHash": "base64-sha256-of-version-chain-44-chars",
990
1084
  "tsa": { "...": "optional MajikTimestamp, see below" }
991
1085
  }
992
1086
  ```
993
1087
 
994
- `allowlistHash` and `tsa` are only present when applicable — omitted entirely otherwise, never `null`.
1088
+ `allowlistHash`, `validUntil`, `versionChainHash`, and `tsa` are only present when applicable — omitted entirely otherwise, never `null`.
995
1089
 
996
1090
  What's actually embedded into a file (or produced detached) is a **`MultiSigEnvelope`**, wrapping one or more of the above — modeled at runtime by `MajikSignatureEnvelope`:
997
1091
 
@@ -1004,7 +1098,8 @@ What's actually embedded into a file (or produced detached) is a **`MultiSigEnve
1004
1098
  "sealHash": "128-hex-char-sha3-512-hash",
1005
1099
  "sealTimestamp": "2026-01-01T00:00:00.000Z",
1006
1100
  "sealedBy": "fingerprint-of-issuer",
1007
- "chainAnchors": [ /* MajikChainAnchor[], optional */ ]
1101
+ "chainAnchors": [ /* MajikChainAnchor[], optional */ ],
1102
+ "fileVersions": [ /* FileVersion[], optional — see File Versioning API */ ]
1008
1103
  }
1009
1104
  ```
1010
1105
 
@@ -1106,6 +1201,7 @@ Order and batch verification methods follow the same philosophy: `SignatureOrder
1106
1201
  - **Detachment integrity** — detached verification always strips the target file first, so a stray embedded envelope never interferes with verifying against a separately-supplied envelope
1107
1202
  - **Batch relocation resistance** — `MajikSignatureMap` resolves files by content hash when a path lookup misses, so renaming/moving a signed batch after the fact doesn't break verification
1108
1203
  - **Order-check independence** — signing-order verification checks each expected signer against the public keys **you supplied**, not the keys self-asserted inside their own envelope entry, so a forged identity claim can't also forge its way into a valid order result
1204
+ - **Revision chain integrity** — a signer's `versionChainHash` commits to the exact `fileVersions` state as of their turn; rewriting any earlier revision's metadata (even without touching content bytes) is detectable via `verifyFileChain()`, without needing archived copies of earlier revisions.
1109
1205
 
1110
1206
  ### What is Your Responsibility
1111
1207
 
@@ -1115,6 +1211,7 @@ Order and batch verification methods follow the same philosophy: `SignatureOrder
1115
1211
  - **TSA trust** — the library verifies a TSA signature cryptographically, but trusting *which* TSA identity to accept is your application's decision.
1116
1212
  - **Timestamp trust level in order verification** — a self-reported (non-TSA) timestamp is tamper-evident but not independently attested; a signer could set their local clock to anything. Always check `result.usesUnattestedTimestamp` before treating an order result as strong proof rather than a claim.
1117
1213
  - **Chain anchor submission and confirmation** — this SDK never talks to a blockchain itself. Submitting the memo from `buildChainAnchorMemo()` and confirming the transaction is entirely your (or a companion product's) responsibility before calling `registerChainAnchor()`.
1214
+ - **Revision archival, if full proof is needed** — `verifyFileChain()` proves every signer's commitment is genuine and the chain hasn't been rewritten, self-contained from the final file alone. It cannot independently prove an earlier `contentHash` ever corresponded to real bytes — that requires `verifyFileRevisions()` against retained copies of each revision, which your application is responsible for archiving if that stronger guarantee matters to your workflow.
1118
1215
 
1119
1216
  ### What NOT to Do
1120
1217
 
@@ -47,7 +47,7 @@ export const CONTENT_HASH_B64_LEN = 44; // SHA-256 → always 44 chars base64
47
47
  * Allowlist hash is also a SHA-256 output — identical length to CONTENT_HASH_B64_LEN.
48
48
  * Defined separately for clarity at validation call sites.
49
49
  */
50
- export const ALLOWLIST_HASH_B64_LEN = CONTENT_HASH_B64_LEN; // 44 chars
50
+ export const ALLOWLIST_HASH_B64_LEN = 44; // 44 chars
51
51
  /**
52
52
  * Seal hash is SHA3-512 → 64 bytes → 128 hex chars.
53
53
  * Distinct from SHA-256 hashes — longer length makes tampering obvious at a glance.
@@ -21,7 +21,7 @@
21
21
  * extract/strip → delegate to the envelope class → re-embed.
22
22
  */
23
23
  import type { ISODateString, MajikKey } from "@majikah/majik-key";
24
- import type { BatchFileInput, BatchSignOptions, BatchSignResult, BatchVerifyInput, BatchVerifyOptions, BatchVerifySummary, EmbedOptions, EmbedResult, EnvelopeInfo, ExpectedSigner, ExtractOptions, ExtractResult, FileVerifyResult, MajikSignatureEnvelopeJSON, MajikSignatureJSON, MajikSignerPublicKeys, MajikTimestamp, SealInfo, SealVerificationResult, SignatoriesFilter, SignatoriesResult, SignOptions, VerificationResult } from "../../core/types";
24
+ import type { BatchFileInput, BatchSignOptions, BatchSignResult, BatchVerifyInput, BatchVerifyOptions, BatchVerifySummary, EmbedOptions, EmbedResult, EnvelopeInfo, ExpectedSigner, ExtractOptions, ExtractResult, FileChainVerification, FileLike, FileVerifyResult, MajikSignatureEnvelopeJSON, MajikSignatureJSON, MajikSignerPublicKeys, MajikTimestamp, RevisionSetVerification, SealInfo, SealVerificationResult, SignatoriesFilter, SignatoriesResult, SignOptions, VerificationResult } from "../../core/types";
25
25
  import { MajikSignatureEnvelope } from "../../core/envelope";
26
26
  import { FormatHandlerRegistry } from "./registry";
27
27
  import { MajikChainAnchor } from "../../anchor/types";
@@ -39,8 +39,11 @@ export interface MajikSignatureAdapter {
39
39
  export interface MajikSignatureStaticAdapter {
40
40
  sign(content: Uint8Array | string, key: MajikKey, options?: SignOptions & {
41
41
  allowlistHash?: string;
42
+ versionChainHash?: string;
42
43
  }): Promise<MajikSignatureAdapter>;
43
44
  verify(content: Uint8Array | string, signature: MajikSignatureAdapter | MajikSignatureJSON, publicKeys: MajikSignerPublicKeys, now?: Date): VerificationResult;
45
+ /** Commitment-only check — no content bytes required. See MajikSignature.verifyCommitment. */
46
+ verifyCommitment(signature: MajikSignatureAdapter | MajikSignatureJSON, publicKeys: MajikSignerPublicKeys, now?: Date): VerificationResult;
44
47
  publicKeysFromMajikKey(key: MajikKey): MajikSignerPublicKeys;
45
48
  fromJSON(json: MajikSignatureJSON | string): MajikSignatureAdapter;
46
49
  }
@@ -71,12 +74,18 @@ export declare class MajikSignatureEmbed {
71
74
  contentType?: string;
72
75
  timestamp?: ISODateString;
73
76
  expectedSigners?: ExpectedSigner[];
74
- /** ISO 8601 expiry for this signature. Omit for one that never expires. */
75
77
  validUntil?: ISODateString;
78
+ message?: string;
79
+ /** The file as received, BEFORE this signer's own stamp. Presence of
80
+ * this option is what makes this call a revision (2nd signer+) rather
81
+ * than a fresh signature. */
82
+ priorSignedFile?: Blob;
76
83
  }, debug?: boolean): Promise<EmbedResult & {
77
84
  signature: T;
78
85
  envelope: MajikSignatureEnvelope;
79
86
  }>;
87
+ private static _readAndVerifyPriorEnvelope;
88
+ private static _verifyEnvelopeSelfContained;
80
89
  /**
81
90
  * Sign a file and return the envelope detached, along with the specific
82
91
  * signature just produced.
@@ -200,6 +209,13 @@ export declare class MajikSignatureEmbed {
200
209
  * (instance, JSON, MJKSIG bytes, or Blob).
201
210
  */
202
211
  static verifyDetachedOrder(file: Blob, envelopeInput: MajikSignatureEnvelope | MajikSignatureEnvelopeJSON | Uint8Array | Blob, expectedOrder: readonly (MajikKey | ExpectedSigner)[], MajikSig: MajikSignatureStaticAdapter, options?: ExtractOptions & VerifySignatureOrderOptions): Promise<SignatureOrderResult>;
212
+ static verifyFileChain(file: Blob, MajikSig: MajikSignatureStaticAdapter, options?: ExtractOptions & {
213
+ now?: Date;
214
+ }): Promise<FileChainVerification>;
215
+ static verifyFileRevisions(finalFile: Blob, revisions: FileLike[], MajikSig: MajikSignatureStaticAdapter, options?: ExtractOptions & {
216
+ now?: Date;
217
+ resolvePublicKeys?: (signerId: string) => MajikSignerPublicKeys | Promise<MajikSignerPublicKeys>;
218
+ }): Promise<RevisionSetVerification>;
203
219
  /**
204
220
  * Seal a multi-sig envelope, preventing any further signatures.
205
221
  * Issuer-only / already-sealed checks are enforced by envelope.withSeal().
@@ -22,7 +22,7 @@
22
22
  */
23
23
  import { MajikSignatureEnvelope } from "../../core/envelope";
24
24
  import { FormatHandlerRegistry } from "./registry";
25
- import { blobToBytes, bytesToBlob, detectMimeType } from "./utils";
25
+ import { blobToBytes, bytesToBlob, detectMimeType, normalizeToBlob, normalizeToBytes, } from "./utils";
26
26
  import { PdfHandler } from "./handlers/pdf";
27
27
  import { PngHandler } from "./handlers/png";
28
28
  import { JpegHandler } from "./handlers/jpeg";
@@ -34,7 +34,7 @@ import { MkvHandler } from "./handlers/mkv";
34
34
  import { OfficeHandler } from "./handlers/office";
35
35
  import { TextHandler } from "./handlers/text";
36
36
  import { FallbackHandler } from "./fallback";
37
- import { bytesToBase64, hashContent } from "../hash";
37
+ import { base64ToBytes, bytesToBase64, hashContent } from "../hash";
38
38
  import { MajikSignatureError, MajikSignatureValidationError } from "../errors";
39
39
  import { MajikSignatureMap } from "../mjksmap";
40
40
  import { verifySignatureOrder, } from "../order";
@@ -90,35 +90,47 @@ export class MajikSignatureEmbed {
90
90
  */
91
91
  static async signAndEmbed(file, key, MajikSig, options, debug = false) {
92
92
  const { bytes, mimeType, handler } = await MajikSignatureEmbed._prepare(file, options);
93
- const envelope = await MajikSignatureEmbed._readEnvelope(handler, bytes);
94
- // ── Sealed + allowlist gate (throws before any crypto) ──────────────────
93
+ const envelope = options?.priorSignedFile
94
+ ? await MajikSignatureEmbed._readAndVerifyPriorEnvelope(options.priorSignedFile, MajikSig, debug)
95
+ : await MajikSignatureEmbed._readEnvelope(handler, bytes);
95
96
  envelope.assertCanSign(key);
96
- // ── Clean original bytes ─────────────────────────────────────────────────
97
97
  const originalBytes = await handler.strip(bytes);
98
- if (debug) {
99
- console.log("signAndEmbed — original bytes hash:", bytesToBase64(hashContent(originalBytes)));
100
- }
101
- // ── Resolve allowlistHash for this signer's payload ──────────────────────
98
+ if (debug)
99
+ console.log("signAndEmbed — bytes hash:", bytesToBase64(hashContent(originalBytes)));
102
100
  const allowlistHashValue = envelope.resolveAllowlistHashFor(key, options?.expectedSigners);
103
- // ── Sign ──────────────────────────────────────────────────────────────────
101
+ const lastVersion = envelope.lastFileVersion;
102
+ const newVersionEntry = {
103
+ version: lastVersion ? lastVersion.version + 1 : 1,
104
+ timestamp: options?.timestamp ?? new Date().toISOString(),
105
+ contentHash: bytesToBase64(hashContent(originalBytes)),
106
+ ...(lastVersion
107
+ ? {
108
+ previousVersionHash: MajikSignatureEnvelope.hashFileVersionEntry(lastVersion),
109
+ }
110
+ : {}),
111
+ createdBy: key.fingerprint,
112
+ ...(options?.message ? { message: options.message } : {}),
113
+ };
114
+ const versionChainHash = MajikSignatureEnvelope.hashFileVersionChain([
115
+ ...envelope.fileVersions,
116
+ newVersionEntry,
117
+ ]);
104
118
  const signature = await MajikSig.sign(originalBytes, key, {
105
119
  contentType: options?.contentType,
106
- timestamp: options?.timestamp,
120
+ timestamp: newVersionEntry.timestamp,
107
121
  validUntil: options?.validUntil,
122
+ versionChainHash,
108
123
  ...(allowlistHashValue !== undefined
109
124
  ? { allowlistHash: allowlistHashValue }
110
125
  : {}),
111
126
  });
112
- // ── Attach allowlist FIRST (requires zero signatures), then upsert ───────
113
- // NOTE: withAllowlist() enforces "first signer only" by checking the
114
- // envelope's current signature count. It must run before withSignature()
115
- // adds this signer's entry, or the guard trips on the count it just added.
116
127
  const establishingAllowlist = envelope.isFirstSigner() && !!options?.expectedSigners?.length;
117
128
  const envelopeWithAllowlist = establishingAllowlist
118
129
  ? envelope.withAllowlist(options.expectedSigners, key.fingerprint)
119
130
  : envelope;
120
- const nextEnvelope = envelopeWithAllowlist.withSignature(signature.toJSON());
121
- // ── Embed ─────────────────────────────────────────────────────────────────
131
+ const nextEnvelope = envelopeWithAllowlist
132
+ .withFileVersion(newVersionEntry)
133
+ .withSignature(signature.toJSON());
122
134
  const resultBytes = await handler.embed(originalBytes, JSON.stringify(nextEnvelope.toJSON()));
123
135
  const blob = bytesToBlob(resultBytes, mimeType);
124
136
  return {
@@ -129,6 +141,118 @@ export class MajikSignatureEmbed {
129
141
  envelope: nextEnvelope,
130
142
  };
131
143
  }
144
+ // ── Private: read prior file's envelope, verify it fully, bootstrap chain ──
145
+ static async _readAndVerifyPriorEnvelope(priorFile, MajikSig, debug) {
146
+ const { bytes: priorBytes, handler: priorHandler } = await MajikSignatureEmbed._prepare(priorFile);
147
+ const raw = await priorHandler.extract(priorBytes);
148
+ if (!raw) {
149
+ throw new MajikSignatureError("priorSignedFile has no embedded signature envelope.");
150
+ }
151
+ let envelope = MajikSignatureEnvelope.fromJSON(raw);
152
+ const allowlistIntegrity = envelope.verifyAllowlistIntegrity();
153
+ if (!allowlistIntegrity.valid) {
154
+ throw new MajikSignatureError(`priorSignedFile allowlist integrity failed: ${allowlistIntegrity.reason}`);
155
+ }
156
+ const chainIntegrity = envelope.verifyVersionChainIntegrity();
157
+ if (!chainIntegrity.valid) {
158
+ throw new MajikSignatureError(`priorSignedFile version chain integrity failed: ${chainIntegrity.reason}`);
159
+ }
160
+ const priorStrippedBytes = await priorHandler.strip(priorBytes);
161
+ const { ok, latest, history } = MajikSignatureEmbed._verifyEnvelopeSelfContained(envelope, priorStrippedBytes, MajikSig);
162
+ if (!ok) {
163
+ const failed = [latest, ...history].find((r) => !r.valid);
164
+ throw new MajikSignatureError(`Refusing to build on priorSignedFile — signature by "${failed?.signerId}" is invalid: ${failed?.reason}`);
165
+ }
166
+ // Bootstrap fileVersions[0] for files signed before this feature existed.
167
+ if (envelope.fileVersions.length === 0 && envelope.signatures.length > 0) {
168
+ const firstSig = envelope.signatures[0];
169
+ envelope = envelope.withFileVersion({
170
+ version: 1,
171
+ timestamp: firstSig.timestamp,
172
+ contentHash: bytesToBase64(hashContent(priorStrippedBytes)),
173
+ createdBy: firstSig.signerId,
174
+ });
175
+ }
176
+ if (debug)
177
+ console.log("priorSignedFile bytes hash:", bytesToBase64(hashContent(priorStrippedBytes)));
178
+ return envelope;
179
+ }
180
+ static _verifyEnvelopeSelfContained(envelope, currentStrippedBytes, MajikSig, now) {
181
+ const chain = envelope.fileVersions;
182
+ const currentHash = bytesToBase64(hashContent(currentStrippedBytes));
183
+ const fullChainHash = MajikSignatureEnvelope.hashFileVersionChain(chain);
184
+ let latestSig = envelope.signatures.find((s) => s.contentHash === currentHash && s.versionChainHash === fullChainHash);
185
+ if (!latestSig) {
186
+ // Fallback for pre-feature files
187
+ latestSig = envelope.signatures.find((s) => s.contentHash === currentHash);
188
+ }
189
+ const checkChainHash = (sig) => {
190
+ if (sig.versionChainHash === undefined && chain.length === 0)
191
+ return { ok: true }; // pre-feature file
192
+ const idx = chain.findIndex((v, i) => v.contentHash === sig.contentHash &&
193
+ (sig.versionChainHash
194
+ ? MajikSignatureEnvelope.hashFileVersionChain(chain.slice(0, i + 1)) === sig.versionChainHash
195
+ : true));
196
+ if (idx === -1)
197
+ return {
198
+ ok: false,
199
+ reason: "No fileVersions entry matches this signature's state.",
200
+ };
201
+ const expected = MajikSignatureEnvelope.hashFileVersionChain(chain.slice(0, idx + 1));
202
+ return sig.versionChainHash === undefined ||
203
+ sig.versionChainHash === expected
204
+ ? { ok: true }
205
+ : {
206
+ ok: false,
207
+ reason: "versionChainHash does not match the recomputed chain prefix — chain may be tampered.",
208
+ };
209
+ };
210
+ const publicKeysOf = (sig) => ({
211
+ signerId: sig.signerId,
212
+ edPublicKey: base64ToBytes(sig.signerEdPublicKey),
213
+ mlDsaPublicKey: base64ToBytes(sig.signerMlDsaPublicKey),
214
+ });
215
+ let latest;
216
+ if (!latestSig) {
217
+ latest = {
218
+ valid: false,
219
+ reason: "No signature's contentHash matches the current bytes.",
220
+ timestamp: new Date().toISOString(),
221
+ };
222
+ }
223
+ else {
224
+ const chainCheck = checkChainHash(latestSig);
225
+ latest = chainCheck.ok
226
+ ? MajikSig.verify(currentStrippedBytes, latestSig, publicKeysOf(latestSig), now)
227
+ : {
228
+ valid: false,
229
+ signerId: latestSig.signerId,
230
+ contentHash: latestSig.contentHash,
231
+ timestamp: latestSig.timestamp,
232
+ reason: chainCheck.reason,
233
+ };
234
+ }
235
+ const history = envelope.signatures
236
+ .filter((s) => s !== latestSig)
237
+ .map((sig) => {
238
+ const chainCheck = checkChainHash(sig);
239
+ const result = chainCheck.ok
240
+ ? MajikSig.verifyCommitment(sig, publicKeysOf(sig), now)
241
+ : {
242
+ valid: false,
243
+ signerId: sig.signerId,
244
+ contentHash: sig.contentHash,
245
+ timestamp: sig.timestamp,
246
+ reason: chainCheck.reason,
247
+ };
248
+ return { ...result, commitmentOnly: true };
249
+ });
250
+ return {
251
+ latest,
252
+ history,
253
+ ok: latest.valid && history.every((h) => h.valid),
254
+ };
255
+ }
132
256
  // ── signDetached ───────────────────────────────────────────────────────────
133
257
  /**
134
258
  * Sign a file and return the envelope detached, along with the specific
@@ -513,6 +637,106 @@ export class MajikSignatureEmbed {
513
637
  const originalBytes = await handler.strip(bytes);
514
638
  return verifySignatureOrder(envelope, originalBytes, expectedOrder, (content, sig, pk) => MajikSig.verify(content, sig, pk), { strict: options?.strict });
515
639
  }
640
+ static async verifyFileChain(file, MajikSig, options) {
641
+ const { bytes, handler } = await MajikSignatureEmbed._prepare(file, options);
642
+ const raw = await handler.extract(bytes);
643
+ if (!raw)
644
+ throw new MajikSignatureError("Cannot verify chain — no embedded envelope found.");
645
+ const envelope = MajikSignatureEnvelope.fromJSON(raw);
646
+ const originalBytes = await handler.strip(bytes);
647
+ const allowlistIntegrity = envelope.verifyAllowlistIntegrity();
648
+ const chainIntegrity = envelope.verifyVersionChainIntegrity();
649
+ const { latest, history, ok } = MajikSignatureEmbed._verifyEnvelopeSelfContained(envelope, originalBytes, MajikSig, options?.now);
650
+ const chainValid = allowlistIntegrity.valid && chainIntegrity.valid && ok;
651
+ return { latest, history, chainValid };
652
+ }
653
+ static async verifyFileRevisions(finalFile, revisions, MajikSig, options) {
654
+ const { bytes, handler } = await MajikSignatureEmbed._prepare(finalFile, options);
655
+ const raw = await handler.extract(bytes);
656
+ if (!raw)
657
+ throw new MajikSignatureError("Cannot verify revisions — no embedded envelope found.");
658
+ const envelope = MajikSignatureEnvelope.fromJSON(raw);
659
+ const chain = envelope.fileVersions;
660
+ if (chain.length === 0) {
661
+ throw new MajikSignatureError("This file has no fileVersions chain to verify against.");
662
+ }
663
+ // Normalize + strip every supplied revision up front. Include finalFile
664
+ // to ensure the final state is naturally matchable without forcing
665
+ // the caller to pass it twice.
666
+ const normalized = await Promise.all([...revisions, finalFile].map(async (input) => {
667
+ const revBlob = normalizeToBlob(await normalizeToBytes(input));
668
+ const { bytes: rBytes, handler: rHandler } = await MajikSignatureEmbed._prepare(revBlob);
669
+ const stripped = await rHandler.strip(rBytes);
670
+ return { stripped, contentHash: bytesToBase64(hashContent(stripped)) };
671
+ }));
672
+ const results = [];
673
+ for (let i = 0; i < chain.length; i++) {
674
+ const entry = chain[i];
675
+ const match = normalized.find((r) => r.contentHash === entry.contentHash);
676
+ if (!match) {
677
+ results.push({
678
+ version: entry.version,
679
+ status: "unmatched",
680
+ reason: `No supplied file matches fileVersions[${i}].`,
681
+ });
682
+ continue;
683
+ }
684
+ const expectedPrev = i === 0
685
+ ? undefined
686
+ : MajikSignatureEnvelope.hashFileVersionEntry(chain[i - 1]);
687
+ if (entry.previousVersionHash !== expectedPrev) {
688
+ results.push({
689
+ version: entry.version,
690
+ status: "chain_broken",
691
+ reason: `fileVersions[${i}] does not chain onto the prior entry.`,
692
+ });
693
+ continue;
694
+ }
695
+ const expectedChainHash = MajikSignatureEnvelope.hashFileVersionChain(chain.slice(0, i + 1));
696
+ const sig = envelope.signatures.find((s) => s.contentHash === entry.contentHash &&
697
+ (entry.createdBy ? s.signerId === entry.createdBy : true) &&
698
+ (s.versionChainHash
699
+ ? s.versionChainHash === expectedChainHash
700
+ : true));
701
+ if (!sig) {
702
+ results.push({
703
+ version: entry.version,
704
+ status: "unmatched",
705
+ reason: `No signature matches fileVersions[${i}].`,
706
+ });
707
+ continue;
708
+ }
709
+ if (sig.versionChainHash && sig.versionChainHash !== expectedChainHash) {
710
+ results.push({
711
+ version: entry.version,
712
+ status: "chain_broken",
713
+ signerId: sig.signerId,
714
+ reason: "versionChainHash does not match this revision's chain state.",
715
+ });
716
+ continue;
717
+ }
718
+ const publicKeys = options?.resolvePublicKeys
719
+ ? await options.resolvePublicKeys(sig.signerId)
720
+ : {
721
+ signerId: sig.signerId,
722
+ edPublicKey: base64ToBytes(sig.signerEdPublicKey),
723
+ mlDsaPublicKey: base64ToBytes(sig.signerMlDsaPublicKey),
724
+ };
725
+ const verifyResult = MajikSig.verify(match.stripped, sig, publicKeys, options?.now);
726
+ results.push({
727
+ version: entry.version,
728
+ status: verifyResult.valid ? "verified" : "signature_invalid",
729
+ signerId: sig.signerId,
730
+ reason: verifyResult.reason,
731
+ });
732
+ }
733
+ return {
734
+ allValid: results.length === chain.length &&
735
+ results.every((r) => r.status === "verified"),
736
+ isCompleteSet: results.every((r) => r.status !== "unmatched"),
737
+ results,
738
+ };
739
+ }
516
740
  // ── seal ───────────────────────────────────────────────────────────────────
517
741
  /**
518
742
  * Seal a multi-sig envelope, preventing any further signatures.
@@ -1,6 +1,10 @@
1
1
  /**
2
2
  * utils.ts — Byte manipulation, MIME sniffing, and encoding helpers
3
3
  */
4
+ import { FileLike } from "../types";
5
+ /** Normalize any supported input into raw bytes, defensively copied. */
6
+ export declare function normalizeToBytes(input: FileLike): Promise<Uint8Array>;
7
+ export declare function normalizeToBlob(input: FileLike, mimeType?: string): Blob;
4
8
  export declare function textEncode(str: string): Uint8Array;
5
9
  export declare function textDecode(bytes: Uint8Array): string;
6
10
  export declare function concatBytes(...arrays: Uint8Array[]): Uint8Array;
@@ -1,7 +1,27 @@
1
1
  /**
2
2
  * utils.ts — Byte manipulation, MIME sniffing, and encoding helpers
3
3
  */
4
+ import { MajikSignatureValidationError } from "../errors";
4
5
  import { CANONICAL_MTIME, TRAILER_MAGIC, TRAILER_SUFFIX_LENGTH, } from "./constants";
6
+ /** Normalize any supported input into raw bytes, defensively copied. */
7
+ export async function normalizeToBytes(input) {
8
+ if (input instanceof Uint8Array)
9
+ return input.slice();
10
+ if (input instanceof ArrayBuffer)
11
+ return new Uint8Array(input.slice(0));
12
+ if (input instanceof Blob)
13
+ return blobToBytes(input); // File extends Blob
14
+ throw new MajikSignatureValidationError("Unsupported file input — expected Blob, File, Uint8Array, or ArrayBuffer.", "input");
15
+ }
16
+ export function normalizeToBlob(input, mimeType = "application/octet-stream") {
17
+ if (input instanceof Blob)
18
+ return input;
19
+ if (input instanceof Uint8Array)
20
+ return bytesToBlob(input, mimeType);
21
+ if (input instanceof ArrayBuffer)
22
+ return bytesToBlob(new Uint8Array(input), mimeType);
23
+ throw new MajikSignatureValidationError("Unsupported file input — expected Blob, File, Uint8Array, or ArrayBuffer.", "input");
24
+ }
5
25
  // ─── Encoding ─────────────────────────────────────────────────────────────────
6
26
  export function textEncode(str) {
7
27
  return new TextEncoder().encode(str);
@@ -18,7 +18,7 @@
18
18
  * counterpart — same relationship as MajikSignatureJSON / MajikSignature.
19
19
  */
20
20
  import type { MajikKey } from "@majikah/majik-key";
21
- import type { EnvelopeInfo, ExpectedSigner, MajikSignatureEnvelopeJSON, MajikSignatureJSON, SealInfo, SealVerificationResult, SignatoriesFilter, SignatoriesResult, SignatoryInfo } from "./types";
21
+ import type { EnvelopeInfo, ExpectedSigner, FileVersion, MajikSignatureEnvelopeJSON, MajikSignatureJSON, SealInfo, SealVerificationResult, SignatoriesFilter, SignatoriesResult, SignatoryInfo } from "./types";
22
22
  import type { MajikChainAnchor } from "../anchor/types";
23
23
  export type AllowlistCheckResult = {
24
24
  permitted: true;
@@ -45,6 +45,7 @@ export declare class MajikSignatureEnvelope {
45
45
  private readonly _sealTimestamp?;
46
46
  private readonly _sealedBy?;
47
47
  private readonly _chainAnchors?;
48
+ private readonly _fileVersions?;
48
49
  private constructor();
49
50
  get version(): 1;
50
51
  get signatures(): readonly MajikSignatureJSON[];
@@ -54,6 +55,22 @@ export declare class MajikSignatureEnvelope {
54
55
  get sealTimestamp(): string | undefined;
55
56
  get sealedBy(): string | undefined;
56
57
  get chainAnchors(): readonly MajikChainAnchor[];
58
+ get fileVersions(): readonly FileVersion[];
59
+ get lastFileVersion(): FileVersion | undefined;
60
+ static hashFileVersionEntry(entry: FileVersion): string;
61
+ static hashFileVersionChain(chain: readonly FileVersion[]): string;
62
+ /**
63
+ * Append a new revision entry. Immutable. Validates the entry chains
64
+ * correctly onto the current last entry (sequential version number,
65
+ * previousVersionHash matching the prior entry's hash) before accepting
66
+ * it — a broken chain fails loudly here, not silently later.
67
+ */
68
+ withFileVersion(entry: FileVersion): MajikSignatureEnvelope;
69
+ /** Structural chain-linkage check — does not touch any signature. */
70
+ verifyVersionChainIntegrity(): {
71
+ valid: boolean;
72
+ reason?: string;
73
+ };
57
74
  isSealed(): boolean;
58
75
  hasAllowlist(): boolean;
59
76
  isFirstSigner(): boolean;
@@ -32,6 +32,7 @@ export class MajikSignatureEnvelope {
32
32
  _sealTimestamp;
33
33
  _sealedBy;
34
34
  _chainAnchors;
35
+ _fileVersions;
35
36
  constructor(data) {
36
37
  this._version = data.version;
37
38
  this._signatures = data.signatures;
@@ -41,6 +42,7 @@ export class MajikSignatureEnvelope {
41
42
  this._sealTimestamp = data.sealTimestamp;
42
43
  this._sealedBy = data.sealedBy;
43
44
  this._chainAnchors = data.chainAnchors;
45
+ this._fileVersions = data.fileVersions;
44
46
  }
45
47
  // ── Getters ─────────────────────────────────────────────────────────────────
46
48
  get version() {
@@ -67,6 +69,78 @@ export class MajikSignatureEnvelope {
67
69
  get chainAnchors() {
68
70
  return this._chainAnchors ?? [];
69
71
  }
72
+ get fileVersions() {
73
+ return this._fileVersions ?? [];
74
+ }
75
+ get lastFileVersion() {
76
+ return this._fileVersions?.[this._fileVersions.length - 1];
77
+ }
78
+ // ── Version chain hashing ────────────────────────────────────────────────
79
+ static hashFileVersionEntry(entry) {
80
+ return bytesToBase64(hashContent(JSON.stringify(entry)));
81
+ }
82
+ static hashFileVersionChain(chain) {
83
+ return bytesToBase64(hashContent(JSON.stringify(chain)));
84
+ }
85
+ /**
86
+ * Append a new revision entry. Immutable. Validates the entry chains
87
+ * correctly onto the current last entry (sequential version number,
88
+ * previousVersionHash matching the prior entry's hash) before accepting
89
+ * it — a broken chain fails loudly here, not silently later.
90
+ */
91
+ withFileVersion(entry) {
92
+ if (this.isSealed()) {
93
+ throw new MajikSignatureError("Cannot add a file version to a sealed envelope.");
94
+ }
95
+ const chain = this._fileVersions ?? [];
96
+ const last = chain[chain.length - 1];
97
+ const expectedVersion = last ? last.version + 1 : 1;
98
+ if (entry.version !== expectedVersion) {
99
+ throw new MajikSignatureValidationError(`FileVersion.version must be ${expectedVersion}, got ${entry.version}`, "version");
100
+ }
101
+ const expectedPrevHash = last
102
+ ? MajikSignatureEnvelope.hashFileVersionEntry(last)
103
+ : undefined;
104
+ if (entry.previousVersionHash !== expectedPrevHash) {
105
+ throw new MajikSignatureValidationError("FileVersion.previousVersionHash does not chain onto the current last entry.", "previousVersionHash");
106
+ }
107
+ return new MajikSignatureEnvelope({
108
+ ...this.toJSON(),
109
+ fileVersions: [...chain, entry],
110
+ });
111
+ }
112
+ /** Structural chain-linkage check — does not touch any signature. */
113
+ verifyVersionChainIntegrity() {
114
+ const chain = this._fileVersions;
115
+ if (!chain || chain.length === 0)
116
+ return { valid: true };
117
+ for (let i = 0; i < chain.length; i++) {
118
+ const entry = chain[i];
119
+ if (entry.version !== i + 1) {
120
+ return {
121
+ valid: false,
122
+ reason: `fileVersions[${i}].version is ${entry.version}, expected ${i + 1}`,
123
+ };
124
+ }
125
+ if (i === 0) {
126
+ if (entry.previousVersionHash !== undefined) {
127
+ return {
128
+ valid: false,
129
+ reason: "fileVersions[0] must not have a previousVersionHash",
130
+ };
131
+ }
132
+ continue;
133
+ }
134
+ const expectedPrev = MajikSignatureEnvelope.hashFileVersionEntry(chain[i - 1]);
135
+ if (entry.previousVersionHash !== expectedPrev) {
136
+ return {
137
+ valid: false,
138
+ reason: `fileVersions[${i}].previousVersionHash mismatch`,
139
+ };
140
+ }
141
+ }
142
+ return { valid: true };
143
+ }
70
144
  // ── State predicates ─────────────────────────────────────────────────────────
71
145
  isSealed() {
72
146
  return this._sealHash !== undefined;
@@ -462,6 +536,7 @@ export class MajikSignatureEnvelope {
462
536
  ...(this._sealTimestamp ? { sealTimestamp: this._sealTimestamp } : {}),
463
537
  ...(this._sealedBy ? { sealedBy: this._sealedBy } : {}),
464
538
  ...(this._chainAnchors ? { chainAnchors: [...this._chainAnchors] } : {}),
539
+ ...(this._fileVersions ? { fileVersions: [...this._fileVersions] } : {}),
465
540
  };
466
541
  }
467
542
  serialize() {
@@ -727,6 +802,13 @@ export class MajikSignatureEnvelope {
727
802
  if (obj.chainAnchors !== undefined && !Array.isArray(obj.chainAnchors)) {
728
803
  throw new MajikSignatureValidationError("chainAnchors must be an array when present", "chainAnchors");
729
804
  }
805
+ const hasFileVersions = obj.fileVersions !== undefined;
806
+ if (hasFileVersions) {
807
+ const typedFileVersion = obj.fileVersions;
808
+ if (!Array.isArray(typedFileVersion)) {
809
+ throw new MajikSignatureValidationError("fileVersions must be an array when present", "fileVersions");
810
+ }
811
+ }
730
812
  }
731
813
  static #validateSignatureEntry(sig, index) {
732
814
  if (sig === null || typeof sig !== "object") {
@@ -5,34 +5,14 @@
5
5
  *
6
6
  * Both Ed25519 and ML-DSA-87 sign the SAME payload bytes.
7
7
  * The payload encodes: version, signerId, timestamp, contentType, contentHash,
8
- * and — when an allowlist is being established — allowlistHash.
8
+ * and — when an allowlist is being established — allowlistHash, and validUntil
9
9
  *
10
- * Format:
11
- * "majik-signature-v1:" + JSON.stringify({ v, id, ts, ct, hash[, alh] })
12
- *
13
- * where:
14
- * v — envelope version (integer)
15
- * id — signer fingerprint (string)
16
- * ts — ISO 8601 timestamp (string)
17
- * ct — content type or null
18
- * hash — SHA-256 of original content, base64 (string)
19
- * alh — SHA-256 of canonical allowlist JSON, base64 (string) — OMITTED when
20
- * not set. Omitting preserves byte-identical payloads for all signatures
21
- * made before allowlist support was introduced (backward compat).
22
- *
23
- * The domain prefix prevents cross-protocol misuse.
24
- * JSON key order is fixed — same input always produces same bytes.
25
- *
26
- * Backward compatibility rule:
27
- * `alh` is only included in the JSON when allowlistHash is explicitly provided.
28
- * When absent it is NOT defaulted to null — omitting it entirely keeps the
29
- * payload bytes identical to what pre-multi-sig signers produced, so all
30
- * existing signatures continue to verify correctly.
31
10
  */
11
+ import { ISODateString, MajikKeyFingerprint } from "@majikah/majik-key";
32
12
  import { MajikTSAPayload } from "./types";
33
13
  export interface PayloadFields {
34
- signerId: string;
35
- timestamp: string;
14
+ signerId: MajikKeyFingerprint;
15
+ timestamp: ISODateString;
36
16
  contentHash: string;
37
17
  contentType?: string;
38
18
  /**
@@ -41,7 +21,8 @@ export interface PayloadFields {
41
21
  * Must be omitted entirely (not null) for all other signatures.
42
22
  */
43
23
  allowlistHash?: string;
44
- validUntil?: string;
24
+ validUntil?: ISODateString;
25
+ versionChainHash?: string;
45
26
  }
46
27
  /**
47
28
  * Build the canonical byte payload that both algorithms sign and verify.
@@ -5,29 +5,8 @@
5
5
  *
6
6
  * Both Ed25519 and ML-DSA-87 sign the SAME payload bytes.
7
7
  * The payload encodes: version, signerId, timestamp, contentType, contentHash,
8
- * and — when an allowlist is being established — allowlistHash.
8
+ * and — when an allowlist is being established — allowlistHash, and validUntil
9
9
  *
10
- * Format:
11
- * "majik-signature-v1:" + JSON.stringify({ v, id, ts, ct, hash[, alh] })
12
- *
13
- * where:
14
- * v — envelope version (integer)
15
- * id — signer fingerprint (string)
16
- * ts — ISO 8601 timestamp (string)
17
- * ct — content type or null
18
- * hash — SHA-256 of original content, base64 (string)
19
- * alh — SHA-256 of canonical allowlist JSON, base64 (string) — OMITTED when
20
- * not set. Omitting preserves byte-identical payloads for all signatures
21
- * made before allowlist support was introduced (backward compat).
22
- *
23
- * The domain prefix prevents cross-protocol misuse.
24
- * JSON key order is fixed — same input always produces same bytes.
25
- *
26
- * Backward compatibility rule:
27
- * `alh` is only included in the JSON when allowlistHash is explicitly provided.
28
- * When absent it is NOT defaulted to null — omitting it entirely keeps the
29
- * payload bytes identical to what pre-multi-sig signers produced, so all
30
- * existing signatures continue to verify correctly.
31
10
  */
32
11
  import { MAJIK_SIGNATURE_DOMAIN, MAJIK_SIGNATURE_VERSION, MAJIK_TSA_DOMAIN, } from "./constants";
33
12
  /**
@@ -46,14 +25,13 @@ export function buildSigningPayload(fields) {
46
25
  ts: fields.timestamp,
47
26
  ct: fields.contentType ?? null,
48
27
  hash: fields.contentHash,
49
- // Conditionally include alh — omitting it entirely preserves byte-identical
50
- // payloads for all pre-allowlist signatures (backward compat).
51
28
  ...(fields.allowlistHash !== undefined
52
29
  ? { alh: fields.allowlistHash }
53
30
  : {}),
54
- // Conditionally include vu — same reasoning: omit entirely so all
55
- // pre-expiry signatures reproduce byte-identical payloads (backward compat).
56
31
  ...(fields.validUntil !== undefined ? { vu: fields.validUntil } : {}),
32
+ ...(fields.versionChainHash !== undefined
33
+ ? { vch: fields.versionChainHash }
34
+ : {}),
57
35
  });
58
36
  const prefix = new TextEncoder().encode(MAJIK_SIGNATURE_DOMAIN);
59
37
  const body = new TextEncoder().encode(meta);
@@ -49,6 +49,12 @@ export interface MajikSignatureJSON {
49
49
  * stripped or extended post-hoc without breaking both signatures.
50
50
  */
51
51
  validUntil?: ISODateString;
52
+ /**
53
+ * SHA-256 of the fileVersions chain as it stood when this signer signed
54
+ * (i.e. including their own new entry). Covered by the canonical payload,
55
+ * same treatment as allowlistHash/validUntil — absent, never null.
56
+ */
57
+ versionChainHash?: string;
52
58
  tsa?: MajikTimestamp;
53
59
  }
54
60
  export interface MajikTSAPayload {
@@ -83,6 +89,7 @@ export interface MajikSignatureCompactJSON {
83
89
  mlDsaSignature: MLDSA87Signature;
84
90
  allowlistHash?: string;
85
91
  validUntil?: ISODateString;
92
+ versionChainHash?: string;
86
93
  }
87
94
  export interface MajikTSARequest {
88
95
  digest: {
@@ -155,6 +162,8 @@ export interface MultiSigEnvelope {
155
162
  */
156
163
  sealedBy?: string;
157
164
  chainAnchors?: MajikChainAnchor[];
165
+ /** Append-only revision chain. Absent on files predating this feature. */
166
+ fileVersions?: FileVersion[];
158
167
  }
159
168
  /**
160
169
  * Public key material needed to verify a MajikSignature.
@@ -461,3 +470,36 @@ export interface MjksMapResolveResult {
461
470
  * vs. where the map says it was originally signed. */
462
471
  originalPath?: string;
463
472
  }
473
+ export interface FileVersion {
474
+ version: number;
475
+ timestamp: ISODateString;
476
+ /** SHA-256 of this revision's stripped bytes, base64 */
477
+ contentHash: string;
478
+ /** hash of the ENTIRE prior FileVersion entry — absent only on version 1 */
479
+ previousVersionHash?: string;
480
+ createdBy?: MajikKeyFingerprint;
481
+ message?: string;
482
+ }
483
+ export interface RevisionCommitmentResult extends VerificationResult {
484
+ commitmentOnly: true;
485
+ }
486
+ export interface FileChainVerification {
487
+ latest: VerificationResult;
488
+ history: RevisionCommitmentResult[];
489
+ chainValid: boolean;
490
+ }
491
+ export type FileLike = Blob | File | Uint8Array | ArrayBuffer;
492
+ export type RevisionCheckStatus = "verified" | "unmatched" | "chain_broken" | "signature_invalid";
493
+ export interface RevisionCheckResult {
494
+ version: number;
495
+ status: RevisionCheckStatus;
496
+ signerId?: MajikKeyFingerprint;
497
+ reason?: string;
498
+ }
499
+ export interface RevisionSetVerification {
500
+ /** Every fileVersions entry matched a supplied file AND verified. */
501
+ allValid: boolean;
502
+ /** The supplied set exactly covers the current fileVersions chain — no gaps. */
503
+ isCompleteSet: boolean;
504
+ results: RevisionCheckResult[];
505
+ }
@@ -4,7 +4,7 @@
4
4
  *
5
5
  */
6
6
  import type { ISODateString, MajikKey, MajikKeyFingerprint } from "@majikah/majik-key";
7
- import type { BatchFileInput, BatchSignOptions, BatchVerifyInput, BatchVerifyOptions, ED25519Signature, EnvelopeInfo, ExpectedSigner, FileVerifyResult, MajikSignatureCompactJSON, MajikSignatureEnvelopeJSON, MajikSignatureJSON, MajikSignerPublicKeys, MajikTimestamp, MajikTSARequest, MLDSA87Signature, SealInfo, SealVerificationResult, SignatoriesFilter, SignatoriesResult, SignatoryInfo, SignOptions, VerificationResult } from "./core/types";
7
+ import type { BatchFileInput, BatchSignOptions, BatchVerifyInput, BatchVerifyOptions, ED25519Signature, EnvelopeInfo, ExpectedSigner, FileChainVerification, FileLike, FileVerifyResult, MajikSignatureCompactJSON, MajikSignatureEnvelopeJSON, MajikSignatureJSON, MajikSignerPublicKeys, MajikTimestamp, MajikTSARequest, MLDSA87Signature, RevisionCommitmentResult, RevisionSetVerification, SealInfo, SealVerificationResult, SignatoriesFilter, SignatoriesResult, SignatoryInfo, SignOptions, VerificationResult } from "./core/types";
8
8
  import { MajikSignatureEmbed } from "./core/embed/majik-embed";
9
9
  import type { ImageVerificationResult, ImageSignOptions, ImageSignatureStub } from "./core/stamp";
10
10
  import { MajikChainAnchor, MajikChainAnchorMemo } from "./anchor/types";
@@ -70,6 +70,7 @@ export declare class MajikSignature {
70
70
  private readonly _mlDsaSignature;
71
71
  private readonly _allowlistHash?;
72
72
  private readonly _validUntil?;
73
+ private readonly _versionChainHash?;
73
74
  private _tsa?;
74
75
  private constructor();
75
76
  get version(): 1;
@@ -93,6 +94,7 @@ export declare class MajikSignature {
93
94
  * for every signature created before expiry support existed.
94
95
  */
95
96
  get validUntil(): ISODateString | undefined;
97
+ get versionChainHash(): string | undefined;
96
98
  get tsa(): MajikTimestamp | undefined;
97
99
  /**
98
100
  * Structural expiry check against this signature's validUntil — does NOT
@@ -116,6 +118,7 @@ export declare class MajikSignature {
116
118
  */
117
119
  static sign(content: Uint8Array | string, key: MajikKey, options?: SignOptions & {
118
120
  allowlistHash?: string;
121
+ versionChainHash?: string;
119
122
  }, debug?: boolean): Promise<MajikSignature>;
120
123
  /**
121
124
  * Verify a MajikSignature against content and the signer's public keys.
@@ -134,6 +137,7 @@ export declare class MajikSignature {
134
137
  * otherwise.
135
138
  */
136
139
  static verify(content: Uint8Array | string, signature: MajikSignature | MajikSignatureJSON, publicKeys: MajikSignerPublicKeys, now?: Date): VerificationResult;
140
+ static verifyCommitment(signature: MajikSignature | MajikSignatureJSON, publicKeys: MajikSignerPublicKeys, now?: Date): RevisionCommitmentResult;
137
141
  validate(): void;
138
142
  isValid(): boolean;
139
143
  extractPublicKeys(): MajikSignerPublicKeys;
@@ -185,6 +189,8 @@ export declare class MajikSignature {
185
189
  mimeType?: string;
186
190
  expectedSigners?: ExpectedSigner[];
187
191
  validUntil?: string;
192
+ message?: string;
193
+ priorSignedFile?: Blob;
188
194
  }): ReturnType<typeof MajikSignatureEmbed.signAndEmbed<MajikSignature>>;
189
195
  /**
190
196
  * Sign a file and return the signature envelope detached.
@@ -312,6 +318,15 @@ export declare class MajikSignature {
312
318
  mimeType?: string;
313
319
  strict?: boolean;
314
320
  }): Promise<SignatureOrderResult>;
321
+ static verifyFileChain(file: Blob, options?: {
322
+ mimeType?: string;
323
+ now?: Date;
324
+ }): Promise<FileChainVerification>;
325
+ static verifyFileRevisions(finalFile: Blob, revisions: FileLike[], options?: {
326
+ mimeType?: string;
327
+ now?: Date;
328
+ resolvePublicKeys?: (signerId: string) => MajikSignerPublicKeys | Promise<MajikSignerPublicKeys>;
329
+ }): Promise<RevisionSetVerification>;
315
330
  /**
316
331
  * Normalize a mixed array of MajikKey instances / ExpectedSigner objects
317
332
  * into a plain ExpectedSigner[]. Exposed standalone in case you want to
@@ -74,6 +74,7 @@ export class MajikSignature {
74
74
  _mlDsaSignature;
75
75
  _allowlistHash;
76
76
  _validUntil;
77
+ _versionChainHash;
77
78
  _tsa;
78
79
  constructor(data) {
79
80
  this._version = data.version;
@@ -87,6 +88,7 @@ export class MajikSignature {
87
88
  this._mlDsaSignature = data.mlDsaSignature;
88
89
  this._allowlistHash = data.allowlistHash;
89
90
  this._validUntil = data.validUntil;
91
+ this._versionChainHash = data.versionChainHash;
90
92
  this._tsa = data.tsa;
91
93
  }
92
94
  // ── Getters ─────────────────────────────────────────────────────────────────
@@ -133,6 +135,9 @@ export class MajikSignature {
133
135
  get validUntil() {
134
136
  return this._validUntil;
135
137
  }
138
+ get versionChainHash() {
139
+ return this._versionChainHash;
140
+ }
136
141
  get tsa() {
137
142
  return this._tsa;
138
143
  }
@@ -187,6 +192,7 @@ export class MajikSignature {
187
192
  const contentType = options?.contentType;
188
193
  const allowlistHash = options?.allowlistHash;
189
194
  const validUntil = options?.validUntil; // NEW
195
+ const versionChainHash = options?.versionChainHash;
190
196
  const payload = buildSigningPayload({
191
197
  signerId,
192
198
  timestamp,
@@ -194,6 +200,7 @@ export class MajikSignature {
194
200
  contentType,
195
201
  allowlistHash,
196
202
  validUntil,
203
+ versionChainHash,
197
204
  });
198
205
  if (debug)
199
206
  console.log("Signing Payload:", payload);
@@ -215,7 +222,8 @@ export class MajikSignature {
215
222
  edSignature: bytesToBase64(edSigBytes),
216
223
  mlDsaSignature: bytesToBase64(mlDsaSigBytes),
217
224
  ...(allowlistHash !== undefined ? { allowlistHash } : {}),
218
- ...(validUntil !== undefined ? { validUntil } : {}), // NEW
225
+ ...(validUntil !== undefined ? { validUntil } : {}),
226
+ ...(versionChainHash !== undefined ? { versionChainHash } : {}),
219
227
  };
220
228
  return new MajikSignature(envelope);
221
229
  }
@@ -280,6 +288,7 @@ export class MajikSignature {
280
288
  contentType: env.contentType,
281
289
  allowlistHash: env.allowlistHash,
282
290
  validUntil: env.validUntil,
291
+ versionChainHash: env.versionChainHash,
283
292
  });
284
293
  let edOk;
285
294
  try {
@@ -323,6 +332,69 @@ export class MajikSignature {
323
332
  throw new MajikSignatureVerificationError("Verification failed unexpectedly", err);
324
333
  }
325
334
  }
335
+ static verifyCommitment(signature, publicKeys, now = new Date()) {
336
+ try {
337
+ MajikSignatureValidator.validateSignerPublicKeys(publicKeys);
338
+ const env = signature instanceof MajikSignature ? signature.toJSON() : signature;
339
+ MajikSignatureValidator.validateJSON(env);
340
+ const invalid = (reason) => ({
341
+ valid: false,
342
+ signerId: env.signerId,
343
+ contentHash: env.contentHash,
344
+ timestamp: env.timestamp,
345
+ contentType: env.contentType,
346
+ commitmentOnly: true,
347
+ ...(reason ? { reason } : {}),
348
+ });
349
+ const payload = buildSigningPayload({
350
+ signerId: env.signerId,
351
+ timestamp: env.timestamp,
352
+ contentHash: env.contentHash,
353
+ contentType: env.contentType,
354
+ allowlistHash: env.allowlistHash,
355
+ validUntil: env.validUntil,
356
+ versionChainHash: env.versionChainHash,
357
+ });
358
+ let edOk;
359
+ try {
360
+ edOk = ed25519.verify(publicKeys.edPublicKey, payload, base64ToBytes(env.edSignature));
361
+ }
362
+ catch {
363
+ return invalid("Failed to verify Ed25519 signature");
364
+ }
365
+ if (!edOk)
366
+ return invalid("Invalid Ed25519 signature");
367
+ let mlDsaOk;
368
+ try {
369
+ mlDsaOk = ml_dsa87.verify(base64ToBytes(env.mlDsaSignature), payload, publicKeys.mlDsaPublicKey);
370
+ }
371
+ catch {
372
+ return invalid("Failed to verify ML-DSA-87 signature");
373
+ }
374
+ if (!mlDsaOk)
375
+ return invalid("Invalid ML-DSA-87 signature");
376
+ if (env.validUntil !== undefined &&
377
+ now.getTime() > Date.parse(env.validUntil)) {
378
+ return {
379
+ ...invalid(`Signature expired at ${env.validUntil}`),
380
+ expired: true,
381
+ };
382
+ }
383
+ return {
384
+ valid: true,
385
+ signerId: env.signerId,
386
+ contentHash: env.contentHash,
387
+ timestamp: env.timestamp,
388
+ contentType: env.contentType,
389
+ commitmentOnly: true,
390
+ };
391
+ }
392
+ catch (err) {
393
+ if (err instanceof MajikSignatureError)
394
+ throw err;
395
+ throw new MajikSignatureVerificationError("Commitment verification failed unexpectedly", err);
396
+ }
397
+ }
326
398
  // ── SELF-VALIDATE ───────────────────────────────────────────────────────────
327
399
  validate() {
328
400
  MajikSignatureValidator.validateJSON(this.toJSON());
@@ -370,6 +442,9 @@ export class MajikSignature {
370
442
  ? { validUntil: this._validUntil }
371
443
  : {}),
372
444
  ...(this._tsa !== undefined ? { tsa: this._tsa } : {}),
445
+ ...(this._versionChainHash !== undefined
446
+ ? { versionChainHash: this._versionChainHash }
447
+ : {}),
373
448
  };
374
449
  }
375
450
  serialize() {
@@ -475,7 +550,7 @@ export class MajikSignature {
475
550
  * });
476
551
  */
477
552
  static async signFile(file, key, options) {
478
- return await MajikSignatureEmbed.signAndEmbed(file, key, MajikSignature, options);
553
+ return MajikSignatureEmbed.signAndEmbed(file, key, MajikSignature, options);
479
554
  }
480
555
  /**
481
556
  * Sign a file and return the signature envelope detached.
@@ -605,6 +680,12 @@ export class MajikSignature {
605
680
  static async verifyFileDetachedOrder(file, envelope, expectedOrder, options) {
606
681
  return MajikSignatureEmbed.verifyDetachedOrder(file, envelope, expectedOrder, MajikSignature, options);
607
682
  }
683
+ static async verifyFileChain(file, options) {
684
+ return MajikSignatureEmbed.verifyFileChain(file, MajikSignature, options);
685
+ }
686
+ static async verifyFileRevisions(finalFile, revisions, options) {
687
+ return MajikSignatureEmbed.verifyFileRevisions(finalFile, revisions, MajikSignature, options);
688
+ }
608
689
  /**
609
690
  * Normalize a mixed array of MajikKey instances / ExpectedSigner objects
610
691
  * into a plain ExpectedSigner[]. Exposed standalone in case you want to
@@ -924,6 +1005,7 @@ export class MajikSignature {
924
1005
  mlDsaSignature: this._mlDsaSignature,
925
1006
  allowlistHash: this._allowlistHash,
926
1007
  validUntil: this._validUntil,
1008
+ versionChainHash: this._versionChainHash,
927
1009
  };
928
1010
  }
929
1011
  /**
@@ -952,6 +1034,7 @@ export class MajikSignature {
952
1034
  mlDsaSignature: compact.mlDsaSignature,
953
1035
  allowlistHash: compact.allowlistHash,
954
1036
  validUntil: compact.validUntil,
1037
+ versionChainHash: compact.versionChainHash,
955
1038
  };
956
1039
  return MajikSignature.fromJSON(full);
957
1040
  }
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@majikah/majik-signature",
3
3
  "type": "module",
4
4
  "description": "Majik Signature is a hybrid post-quantum content signing and verification library for the Majikah ecosystem. Built on top of Majik Key, it provides tamper-proof, forgery-resistant digital signatures for any content format — using a dual-algorithm architecture that combines classical Ed25519 with post-quantum ML-DSA-87 (FIPS-204).",
5
- "version": "0.3.0",
5
+ "version": "0.4.0",
6
6
  "license": "Apache-2.0",
7
7
  "author": "Zelijah",
8
8
  "main": "./dist/index.js",
@@ -52,14 +52,15 @@
52
52
  },
53
53
  "dependencies": {
54
54
  "@majikah/majik-key": "^0.4.0",
55
- "@noble/post-quantum": "^0.7.0",
55
+ "@noble/hashes": "^2.4.0",
56
+ "@noble/post-quantum": "^0.7.1",
56
57
  "@stablelib/ed25519": "^2.1.0",
57
58
  "@stablelib/sha256": "^2.0.1",
58
59
  "fflate": "^0.8.3"
59
60
  },
60
61
  "devDependencies": {
61
- "@types/node": "^26.2.0",
62
+ "@types/node": "^26.4.1",
62
63
  "typescript": "^7.0.2",
63
- "vitest": "^4.1.9"
64
+ "vitest": "^4.1.11"
64
65
  }
65
66
  }