@majikah/majik-signature 0.2.9 → 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,17 +103,19 @@ 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] })
106
+ "majik-signature-v1:" + JSON({ v, id, ts, ct, hash[, alh][, vu][, vch] })
107
107
  ```
108
108
 
109
- | Field | Description |
110
- | ------ | ---------------------------------------------------------------------------- |
111
- | `v` | Envelope version |
112
- | `id` | Signer fingerprint (MajikKey identity) |
113
- | `ts` | ISO 8601 timestamp |
114
- | `ct` | Content type (advisory, or `null`) |
115
- | `hash` | SHA-256 of the original content, base64 |
116
- | `alh` | SHA-256 of the canonical allowlist, base64 — **present only** when this signer is establishing an allowlist |
109
+ | Field | Description |
110
+ | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
111
+ | `v` | Envelope version |
112
+ | `id` | Signer fingerprint (MajikKey identity) |
113
+ | `ts` | ISO 8601 timestamp |
114
+ | `ct` | Content type (advisory, or `null`) |
115
+ | `hash` | SHA-256 of the original content, base64 |
116
+ | `alh` | SHA-256 of the canonical allowlist, base64 — **present only** when this signer is establishing an allowlist |
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. |
117
119
 
118
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.
119
121
 
@@ -143,11 +145,17 @@ Files don't hold a single signature — they hold a **`MultiSigEnvelope`** (mode
143
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.
144
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.
145
147
 
146
- ### 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)
147
155
 
148
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.
149
157
 
150
- ### 7. Chain Anchoring
158
+ ### 8. Chain Anchoring
151
159
 
152
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.
153
161
 
@@ -462,8 +470,8 @@ if (result.mode === 'map') {
462
470
  }
463
471
 
464
472
  // Later — verify an extracted batch against the manifest
465
- const map = await MajikSignature.getSignatureMapFromMJKSMAP(mapBlob);
466
- // (or: const map = await MajikSignatureMap.fromMJKSMAP(mapBlob);)
473
+ const map = await MajikSignatureMap.fromMJKSMAP(mapBlob);
474
+
467
475
 
468
476
  const results = await MajikSignature.verifyFilesFromMjksMap(
469
477
  map,
@@ -543,6 +551,49 @@ const blob = await MajikSignature.registerChainAnchor(sealedBlob, confirmedAncho
543
551
  const anchors = await MajikSignature.getChainAnchors(blob);
544
552
  ```
545
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
+
546
597
  ---
547
598
 
548
599
  ## API Reference
@@ -907,6 +958,48 @@ Embeds an **already-confirmed** `MajikChainAnchor` into the file's envelope. The
907
958
 
908
959
  ---
909
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
+
910
1003
  ### Image Stamping (Experimental)
911
1004
 
912
1005
  > ⚠️ **These APIs are explicitly marked experimental in source and are not yet API-stable.** Expect breaking changes across minor versions.
@@ -951,18 +1044,18 @@ const restoredFromB64 = MajikSignature.deserialize(b64);
951
1044
 
952
1045
  Handlers are tried in order; the first one whose `canHandle()` matches wins. If nothing matches, the **universal trailer fallback** always applies — meaning *every* file type is signable, even ones with no dedicated handler.
953
1046
 
954
- | Format(s) | Handler | Embedding Mechanism |
955
- | ----------------------------------- | -------------- | ----------------------------------------------------------------- |
956
- | PDF | PDF | Binary trailer appended after the last `%%EOF` marker (PDF 1.7 §7.5.6 compliant) |
957
- | PNG | PNG | `iTXt` metadata chunk |
958
- | WAV | WAV | RIFF `LIST INFO` chunk, custom `ISIG` sub-chunk |
959
- | MP3 | MP3 | ID3v2 `TXXX` frame |
960
- | MP4, MOV, M4A, M4V | MP4/MOV | `moov/udta` box, custom `majk` box type |
961
- | DOCX, XLSX, PPTX, ODT, ODS, ODP | Office | Dedicated `majik-signature.json` entry inside the ZIP container |
962
- | MKV, WebM | MKV | Custom Matroska metadata tag |
963
- | JPEG, FLAC | JPEG / FLAC | Native format metadata (non-destructive; same round-trip guarantees as other handlers) |
964
- | HTML, Markdown, JSON, plain text, source code | Text | Appended, format-appropriate metadata block |
965
- | Anything else | Fallback | Universal binary trailer: `[original][signature JSON][8-byte length][8-byte magic "MAJIKSIG"]` |
1047
+ | Format(s) | Handler | Embedding Mechanism |
1048
+ | --------------------------------------------- | ----------- | ---------------------------------------------------------------------------------------------- |
1049
+ | PDF | PDF | Binary trailer appended after the last `%%EOF` marker (PDF 1.7 §7.5.6 compliant) |
1050
+ | PNG | PNG | `iTXt` metadata chunk |
1051
+ | WAV | WAV | RIFF `LIST INFO` chunk, custom `ISIG` sub-chunk |
1052
+ | MP3 | MP3 | ID3v2 `TXXX` frame |
1053
+ | MP4, MOV, M4A, M4V | MP4/MOV | `moov/udta` box, custom `majk` box type |
1054
+ | DOCX, XLSX, PPTX, ODT, ODS, ODP | Office | Dedicated `majik-signature.json` entry inside the ZIP container |
1055
+ | MKV, WebM | MKV | Custom Matroska metadata tag |
1056
+ | JPEG, FLAC | JPEG / FLAC | Native format metadata (non-destructive; same round-trip guarantees as other handlers) |
1057
+ | HTML, Markdown, JSON, plain text, source code | Text | Appended, format-appropriate metadata block |
1058
+ | Anything else | Fallback | Universal binary trailer: `[original][signature JSON][8-byte length][8-byte magic "MAJIKSIG"]` |
966
1059
 
967
1060
  All handlers guarantee: files remain fully usable after signing (PDFs still open, videos still play, Office files stay editable), signing is idempotent (safe to re-sign), and `strip()` always reproduces exactly the bytes that were originally hashed — including deterministic ZIP re-canonicalization for Office formats (the ZIP is always rebuilt via a full unzip/rezip pass on `strip()`, even when no signature entry exists yet), so that identical content always strips to identical bytes regardless of when it was last re-zipped.
968
1061
 
@@ -986,11 +1079,13 @@ A single signer's envelope (`MajikSignatureJSON`):
986
1079
  "edSignature": "base64-ed25519-signature-64-bytes",
987
1080
  "mlDsaSignature": "base64-ml-dsa-87-signature-4595-bytes",
988
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",
989
1084
  "tsa": { "...": "optional MajikTimestamp, see below" }
990
1085
  }
991
1086
  ```
992
1087
 
993
- `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`.
994
1089
 
995
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`:
996
1091
 
@@ -1003,7 +1098,8 @@ What's actually embedded into a file (or produced detached) is a **`MultiSigEnve
1003
1098
  "sealHash": "128-hex-char-sha3-512-hash",
1004
1099
  "sealTimestamp": "2026-01-01T00:00:00.000Z",
1005
1100
  "sealedBy": "fingerprint-of-issuer",
1006
- "chainAnchors": [ /* MajikChainAnchor[], optional */ ]
1101
+ "chainAnchors": [ /* MajikChainAnchor[], optional */ ],
1102
+ "fileVersions": [ /* FileVersion[], optional — see File Versioning API */ ]
1007
1103
  }
1008
1104
  ```
1009
1105
 
@@ -1030,9 +1126,9 @@ A batch manifest (`MjksMapJSON`, backing `MajikSignatureMap`) is a flat list of
1030
1126
  **Approximate serialized sizes (per signer):**
1031
1127
 
1032
1128
  | Format | Size |
1033
- | ------------------ | ------ |
1034
- | JSON (minified) | ~10 KB |
1035
- | Base64 serialized | ~14 KB |
1129
+ | ----------------- | ------ |
1130
+ | JSON (minified) | ~10 KB |
1131
+ | Base64 serialized | ~14 KB |
1036
1132
 
1037
1133
  The dominant contributor is `mlDsaSignature` (~6 KB base64) and `signerMlDsaPublicKey` (~3.5 KB base64) — the inherent cost of post-quantum signatures, negligible relative to any real content being signed.
1038
1134
 
@@ -1044,10 +1140,10 @@ Two dedicated, versioned, self-identifying binary containers exist for out-of-ba
1044
1140
 
1045
1141
  Both share the same header layout: `[magic bytes][1-byte version][1-byte reserved][4-byte big-endian payload length][payload JSON]`.
1046
1142
 
1047
- | Format | Magic | Magic Length | Header Length | Media Type | Extension |
1048
- | ------ | ----- | ------------ | -------------- | ---------- | --------- |
1049
- | `.mjksig` — a single detached envelope | `MJKSIG` | 6 bytes | 12 bytes | `application/vnd.majikah.mjksig` | `.mjksig` |
1050
- | `.mjksmap` — a batch manifest | `MJKSMAP` | 7 bytes | 13 bytes | `application/vnd.majikah.mjksmap` | `.mjksmap` |
1143
+ | Format | Magic | Magic Length | Header Length | Media Type | Extension |
1144
+ | -------------------------------------- | --------- | ------------ | ------------- | --------------------------------- | ---------- |
1145
+ | `.mjksig` — a single detached envelope | `MJKSIG` | 6 bytes | 12 bytes | `application/vnd.majikah.mjksig` | `.mjksig` |
1146
+ | `.mjksmap` — a batch manifest | `MJKSMAP` | 7 bytes | 13 bytes | `application/vnd.majikah.mjksmap` | `.mjksmap` |
1051
1147
 
1052
1148
  Both formats validate magic bytes, supported version, and declared payload length **before** attempting to parse the JSON payload — a truncated or corrupted buffer fails fast with a clear `MajikSignatureSerializationError` rather than an obscure `JSON.parse` error.
1053
1149
 
@@ -1074,14 +1170,14 @@ const isMjksmap = await MajikSignatureMap.isMJKSMAP(mapBlob);
1074
1170
 
1075
1171
  Majik Signature throws a typed error hierarchy rather than generic `Error` objects, so you can catch precisely what you need:
1076
1172
 
1077
- | Error Class | Thrown when... |
1078
- | ------------------------------------- | ------------------------------------------------------------------------- |
1079
- | `MajikSignatureError` | Base class; also thrown for general/unexpected failures (e.g. signing a sealed envelope, missing envelope on seal/anchor)|
1080
- | `MajikSignatureKeyError` | The key is locked, lacks signing keys, or isn't the required issuer |
1081
- | `MajikSignatureVerificationError` | Verification fails unexpectedly (not the same as `valid: false`), including a failed TSA signature check |
1082
- | `MajikSignatureSerializationError` | JSON/base64/MJKSIG/MJKSMAP parsing or encoding fails, including malformed binary headers |
1083
- | `MajikSignatureAllowlistError` | A non-listed signer attempts to sign a restricted file |
1084
- | `MajikSignatureValidationError` | A structural shape check fails — malformed envelope, malformed batch manifest entry, empty/duplicate batch paths, empty allowlist, mismatched seal fields, invalid `expectedOrder` input, etc. |
1173
+ | Error Class | Thrown when... |
1174
+ | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1175
+ | `MajikSignatureError` | Base class; also thrown for general/unexpected failures (e.g. signing a sealed envelope, missing envelope on seal/anchor) |
1176
+ | `MajikSignatureKeyError` | The key is locked, lacks signing keys, or isn't the required issuer |
1177
+ | `MajikSignatureVerificationError` | Verification fails unexpectedly (not the same as `valid: false`), including a failed TSA signature check |
1178
+ | `MajikSignatureSerializationError` | JSON/base64/MJKSIG/MJKSMAP parsing or encoding fails, including malformed binary headers |
1179
+ | `MajikSignatureAllowlistError` | A non-listed signer attempts to sign a restricted file |
1180
+ | `MajikSignatureValidationError` | A structural shape check fails — malformed envelope, malformed batch manifest entry, empty/duplicate batch paths, empty allowlist, mismatched seal fields, invalid `expectedOrder` input, etc. |
1085
1181
 
1086
1182
  Note the distinction: `verify()`/`verifyFile()` return `{ valid: false, reason }` for a signature that *fails cryptographic verification* — that's an expected, handled outcome, not an exception. Exceptions are reserved for misuse (locked keys, malformed envelopes, disallowed signers, sealed files, malformed batch input, etc.).
1087
1183
 
@@ -1105,6 +1201,7 @@ Order and batch verification methods follow the same philosophy: `SignatureOrder
1105
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
1106
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
1107
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.
1108
1205
 
1109
1206
  ### What is Your Responsibility
1110
1207
 
@@ -1114,6 +1211,7 @@ Order and batch verification methods follow the same philosophy: `SignatureOrder
1114
1211
  - **TSA trust** — the library verifies a TSA signature cryptographically, but trusting *which* TSA identity to accept is your application's decision.
1115
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.
1116
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.
1117
1215
 
1118
1216
  ### What NOT to Do
1119
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.2.9",
5
+ "version": "0.4.0",
6
6
  "license": "Apache-2.0",
7
7
  "author": "Zelijah",
8
8
  "main": "./dist/index.js",
@@ -51,15 +51,16 @@
51
51
  "test:watch": "vitest"
52
52
  },
53
53
  "dependencies": {
54
- "@majikah/majik-key": "^0.3.4",
55
- "@noble/post-quantum": "^0.7.0",
54
+ "@majikah/majik-key": "^0.4.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
  }