@majikah/majik-signature 0.2.4 → 0.2.5

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.
@@ -71,3 +71,12 @@ export declare const MJKSIG_HEADER_LEN: number;
71
71
  */
72
72
  export declare const MJKSIG_MEDIA_TYPE: "application/vnd.majikah.mjksig";
73
73
  export declare const MJKSIG_FILE_EXTENSION: ".mjksig";
74
+ export declare const MJKSMAP_MAGIC: number[];
75
+ export declare const MJKSMAP_MAGIC_LEN: number;
76
+ export declare const MJKSMAP_VERSION = 1;
77
+ export declare const MJKSMAP_SUPPORTED_VERSIONS: readonly [1];
78
+ export declare const MJKSMAP_HEADER_LEN: number;
79
+ export declare const MJKSMAP_MEDIA_TYPE = "application/vnd.majikah.mjksmap";
80
+ export declare const MJKSMAP_FILE_EXTENSION = ".mjksmap";
81
+ /** Conventional name/location when packaged in a signed batch zip. */
82
+ export declare const MJKSMAP_DEFAULT_FILENAME = "signatures.mjksmap";
@@ -77,3 +77,18 @@ export const MJKSIG_HEADER_LEN = MJKSIG_MAGIC_LEN + 1 + 1 + 4; // 12
77
77
  */
78
78
  export const MJKSIG_MEDIA_TYPE = "application/vnd.majikah.mjksig";
79
79
  export const MJKSIG_FILE_EXTENSION = ".mjksig";
80
+ // ─── MJKSMAP binary format ───────────────────────────────────────────────────
81
+ //
82
+ // Layout: [magic(7)][version(1)][reserved(1)][payloadLen(4, BE u32)][payload JSON]
83
+ // A single manifest file mapping every file in a batch/zip to its detached
84
+ // MajikSignatureEnvelope — one file to track instead of N loose .mjksig files.
85
+ // Header layout mirrors MJKSIG deliberately; only the magic differs.
86
+ export const MJKSMAP_MAGIC = [0x4d, 0x4a, 0x4b, 0x53, 0x4d, 0x41, 0x50]; // "MJKSMAP"
87
+ export const MJKSMAP_MAGIC_LEN = MJKSMAP_MAGIC.length;
88
+ export const MJKSMAP_VERSION = 0x01;
89
+ export const MJKSMAP_SUPPORTED_VERSIONS = [MJKSMAP_VERSION];
90
+ export const MJKSMAP_HEADER_LEN = MJKSMAP_MAGIC_LEN + 1 + 1 + 4; // 13
91
+ export const MJKSMAP_MEDIA_TYPE = "application/vnd.majikah.mjksmap";
92
+ export const MJKSMAP_FILE_EXTENSION = ".mjksmap";
93
+ /** Conventional name/location when packaged in a signed batch zip. */
94
+ export const MJKSMAP_DEFAULT_FILENAME = "signatures.mjksmap";
@@ -21,12 +21,19 @@
21
21
  * extract/strip → delegate to the envelope class → re-embed.
22
22
  */
23
23
  import type { MajikKey } from "@majikah/majik-key";
24
- import type { EmbedOptions, EmbedResult, EnvelopeInfo, ExpectedSigner, ExtractOptions, ExtractResult, MajikSignatureEnvelopeJSON, MajikSignatureJSON, MajikSignerPublicKeys, SealInfo, SealVerificationResult, SignatoriesFilter, SignatoriesResult, SignOptions, VerificationResult } from "../../core/types";
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";
25
25
  import { MajikSignatureEnvelope } from "../../core/envelope";
26
26
  import { FormatHandlerRegistry } from "./registry";
27
27
  import { MajikChainAnchor } from "../../anchor/types";
28
+ import { MajikSignatureMap } from "../mjksmap";
28
29
  export interface MajikSignatureAdapter {
29
30
  toJSON(): MajikSignatureJSON;
31
+ /**
32
+ * Optional — attach a TSA timestamp to this signature. Present because
33
+ * MajikSignature implements it; declared optional here so any future
34
+ * adapter that doesn't support TSA still satisfies this interface.
35
+ */
36
+ addTSA?(tsa: MajikTimestamp): void;
30
37
  }
31
38
  export interface MajikSignatureStaticAdapter {
32
39
  sign(content: Uint8Array | string, key: MajikKey, options?: SignOptions & {
@@ -68,20 +75,59 @@ export declare class MajikSignatureEmbed {
68
75
  envelope: MajikSignatureEnvelope;
69
76
  }>;
70
77
  /**
71
- * Sign a file and return the envelope detached.
72
- * Same flow as signAndEmbed(), minus the final embed step.
78
+ * Sign a file and return the envelope detached, along with the specific
79
+ * signature just produced.
80
+ *
81
+ * If options.tsa is provided, it's attached to this signer's signature
82
+ * via addTSA() immediately after signing and before it's upserted into
83
+ * the envelope — addTSA() itself validates that the TSA's digest matches
84
+ * this content's hash and that the TSA's own signature verifies, so no
85
+ * duplicate validation is needed here. If the adapter doesn't support
86
+ * addTSA (i.e. MajikSig.addTSA is undefined), a TSA option is a hard
87
+ * error rather than a silent no-op — attaching a timestamp is something
88
+ * the caller explicitly asked for, so failing to do it must be loud.
89
+ *
90
+ * Returns `signature` — the most recent signature produced by this call
91
+ * (with the TSA attached, if one was provided) — in addition to the full
92
+ * `envelope`, so callers don't have to re-extract it via
93
+ * envelope.findSignature(key.fingerprint) themselves.
73
94
  */
74
- static signDetached(file: Blob, key: MajikKey, MajikSig: MajikSignatureStaticAdapter, options?: EmbedOptions & {
95
+ static signDetached<T extends MajikSignatureAdapter = MajikSignatureAdapter>(file: Blob, key: MajikKey, MajikSig: MajikSignatureStaticAdapter, options?: EmbedOptions & {
75
96
  contentType?: string;
76
97
  timestamp?: string;
77
98
  expectedSigners?: ExpectedSigner[];
78
99
  existingEnvelope?: MajikSignatureEnvelope | MajikSignatureEnvelopeJSON | Uint8Array | Blob;
100
+ tsa?: MajikTimestamp;
79
101
  }, debug?: boolean): Promise<{
80
102
  blob: Blob;
81
103
  envelope: MajikSignatureEnvelope;
104
+ signature: T;
82
105
  handler: string;
83
106
  mimeType: string;
84
107
  }>;
108
+ /**
109
+ * Sign a batch of files (folder or zip contents) as detached envelopes,
110
+ * packaged either as one MajikSignatureMap (default) or as separate
111
+ * .mjksig Blobs per file.
112
+ *
113
+ * Reuses signDetached() per file — no duplicated crypto path. The only
114
+ * new logic here is path-uniqueness validation and result packaging.
115
+ */
116
+ static signBatchDetached(files: BatchFileInput[], key: MajikKey, MajikSig: MajikSignatureStaticAdapter, options?: BatchSignOptions, debug?: boolean): Promise<BatchSignResult>;
117
+ /**
118
+ * Sign one file detached and extract the contentHash this signer produced
119
+ * for it — needed to populate the map entry without re-hashing separately.
120
+ */
121
+ private static _signOneDetached;
122
+ /**
123
+ * Validate the batch before touching any crypto: non-empty, every file has
124
+ * a non-empty path, and no two files share a path. Duplicate paths would
125
+ * otherwise silently overwrite each other's map entry via withEntry()'s
126
+ * replace-on-match semantics — catching it here means the failure is
127
+ * "your batch has a duplicate path" up front, not a mysteriously missing
128
+ * entry discovered later.
129
+ */
130
+ private static _assertValidBatch;
85
131
  /**
86
132
  * Extract the envelope from a file as a MajikSignatureEnvelope instance.
87
133
  * Returns null if no signature is found.
@@ -108,6 +154,30 @@ export declare class MajikSignatureEmbed {
108
154
  static verifyDetachedWithKey(file: Blob, envelopeInput: MajikSignatureEnvelope | MajikSignatureEnvelopeJSON | Uint8Array | Blob, key: MajikKey, MajikSig: MajikSignatureStaticAdapter, options?: ExtractOptions & {
109
155
  expectedSignerId?: string;
110
156
  }, debug?: boolean): Promise<VerificationResult[]>;
157
+ /**
158
+ * Verify a batch of extracted files against a MajikSignatureMap.
159
+ *
160
+ * For each file: resolve against the map (tolerating relocation — a file
161
+ * moved or renamed after signing is still found and verified by content,
162
+ * not just by its original path), then run the normal signature
163
+ * verification via the envelope stored in that entry. Never throws
164
+ * per-file — every outcome (missing, tampered, relocated-but-valid,
165
+ * invalid, verified) is reported in the returned array, so a caller can
166
+ * render a full per-file status table in one pass instead of catching
167
+ * exceptions.
168
+ *
169
+ * Set options.requireAllPresent to escalate a missing file to a thrown
170
+ * error instead — useful when the caller expects a closed, complete set
171
+ * (e.g. "this zip must contain everything the map lists").
172
+ */
173
+ static verifyFilesFromMjksMap(map: MajikSignatureMap, files: BatchVerifyInput[], publicKeys: MajikSignerPublicKeys, MajikSig: MajikSignatureStaticAdapter, options?: BatchVerifyOptions, debug?: boolean): Promise<FileVerifyResult[]>;
174
+ /** Convenience overload — resolves public keys from a MajikKey. */
175
+ static verifyFilesFromMjksMapWithKey(map: MajikSignatureMap, files: BatchVerifyInput[], key: MajikKey, MajikSig: MajikSignatureStaticAdapter, options?: BatchVerifyOptions, debug?: boolean): Promise<FileVerifyResult[]>;
176
+ /**
177
+ * Summarize a batch verification result — one glance at pass/fail counts
178
+ * without the caller re-deriving it from the array each time.
179
+ */
180
+ static summarizeBatchVerification(results: FileVerifyResult[]): BatchVerifySummary;
111
181
  /**
112
182
  * Seal a multi-sig envelope, preventing any further signatures.
113
183
  * Issuer-only / already-sealed checks are enforced by envelope.withSeal().
@@ -35,7 +35,8 @@ import { OfficeHandler } from "./handlers/office";
35
35
  import { TextHandler } from "./handlers/text";
36
36
  import { FallbackHandler } from "./fallback";
37
37
  import { bytesToBase64, hashContent } from "../hash";
38
- import { MajikSignatureError } from "../errors";
38
+ import { MajikSignatureError, MajikSignatureValidationError } from "../errors";
39
+ import { MajikSignatureMap } from "../mjksmap";
39
40
  // ─── Registry ─────────────────────────────────────────────────────────────────
40
41
  const DEFAULT_REGISTRY = new FormatHandlerRegistry()
41
42
  .register(new PdfHandler())
@@ -128,8 +129,22 @@ export class MajikSignatureEmbed {
128
129
  }
129
130
  // ── signDetached ───────────────────────────────────────────────────────────
130
131
  /**
131
- * Sign a file and return the envelope detached.
132
- * Same flow as signAndEmbed(), minus the final embed step.
132
+ * Sign a file and return the envelope detached, along with the specific
133
+ * signature just produced.
134
+ *
135
+ * If options.tsa is provided, it's attached to this signer's signature
136
+ * via addTSA() immediately after signing and before it's upserted into
137
+ * the envelope — addTSA() itself validates that the TSA's digest matches
138
+ * this content's hash and that the TSA's own signature verifies, so no
139
+ * duplicate validation is needed here. If the adapter doesn't support
140
+ * addTSA (i.e. MajikSig.addTSA is undefined), a TSA option is a hard
141
+ * error rather than a silent no-op — attaching a timestamp is something
142
+ * the caller explicitly asked for, so failing to do it must be loud.
143
+ *
144
+ * Returns `signature` — the most recent signature produced by this call
145
+ * (with the TSA attached, if one was provided) — in addition to the full
146
+ * `envelope`, so callers don't have to re-extract it via
147
+ * envelope.findSignature(key.fingerprint) themselves.
133
148
  */
134
149
  static async signDetached(file, key, MajikSig, options, debug = false) {
135
150
  const { bytes, mimeType, handler } = await MajikSignatureEmbed._prepare(file, options);
@@ -149,6 +164,16 @@ export class MajikSignatureEmbed {
149
164
  ? { allowlistHash: allowlistHashValue }
150
165
  : {}),
151
166
  });
167
+ // ── Attach TSA, if provided ──────────────────────────────────────────────
168
+ // Must happen before toJSON()/upsert — the envelope needs the signature
169
+ // WITH its tsa field already set, not a bare signature followed by a
170
+ // separate mutation the caller has to remember to do.
171
+ if (options?.tsa) {
172
+ if (typeof signature.addTSA !== "function") {
173
+ throw new MajikSignatureError("options.tsa was provided, but the given MajikSig adapter does not support TSA attachment (addTSA is not implemented).");
174
+ }
175
+ signature.addTSA(options.tsa);
176
+ }
152
177
  const establishingAllowlist = envelope.isFirstSigner() && !!options?.expectedSigners?.length;
153
178
  const envelopeWithAllowlist = establishingAllowlist
154
179
  ? envelope.withAllowlist(options.expectedSigners, key.fingerprint)
@@ -156,7 +181,102 @@ export class MajikSignatureEmbed {
156
181
  const nextEnvelope = envelopeWithAllowlist.withSignature(signature.toJSON());
157
182
  // ── Return DETACHED (no embedding) ───────────────────────────────────────
158
183
  const blob = bytesToBlob(originalBytes, mimeType);
159
- return { blob, handler: handler.name, mimeType, envelope: nextEnvelope };
184
+ return {
185
+ blob,
186
+ handler: handler.name,
187
+ mimeType,
188
+ envelope: nextEnvelope,
189
+ signature: signature,
190
+ };
191
+ }
192
+ // ── signBatchDetached ────────────────────────────────────────────────────────
193
+ /**
194
+ * Sign a batch of files (folder or zip contents) as detached envelopes,
195
+ * packaged either as one MajikSignatureMap (default) or as separate
196
+ * .mjksig Blobs per file.
197
+ *
198
+ * Reuses signDetached() per file — no duplicated crypto path. The only
199
+ * new logic here is path-uniqueness validation and result packaging.
200
+ */
201
+ static async signBatchDetached(files, key, MajikSig, options, debug = false) {
202
+ MajikSignatureEmbed._assertValidBatch(files);
203
+ const mode = options?.mode ?? "map";
204
+ const continueOnError = options?.continueOnError ?? false;
205
+ const failures = [];
206
+ let map = mode === "map" ? MajikSignatureMap.empty() : undefined;
207
+ const signatures = [];
208
+ for (const file of files) {
209
+ try {
210
+ const { envelope, contentHash } = await MajikSignatureEmbed._signOneDetached(file, key, MajikSig, options, debug);
211
+ if (mode === "map") {
212
+ map = map.withEntry({
213
+ path: file.path,
214
+ contentHash,
215
+ size: file.blob.size,
216
+ mimeType: file.blob.type || undefined,
217
+ envelope: envelope.toJSON(),
218
+ });
219
+ }
220
+ else {
221
+ signatures.push({ path: file.path, blob: envelope.toMJKSIG() });
222
+ }
223
+ }
224
+ catch (err) {
225
+ const message = err instanceof Error ? err.message : String(err);
226
+ if (!continueOnError) {
227
+ throw err instanceof MajikSignatureError
228
+ ? err
229
+ : new MajikSignatureError(`Batch signing failed on "${file.path}": ${message}`, err);
230
+ }
231
+ failures.push({ path: file.path, error: message });
232
+ }
233
+ }
234
+ return mode === "map"
235
+ ? { mode: "map", map: map, mapBlob: map.toMJKSMAP(), failures }
236
+ : { mode: "separate", signatures, failures };
237
+ }
238
+ // ── Private batch helpers ───────────────────────────────────────────────────
239
+ /**
240
+ * Sign one file detached and extract the contentHash this signer produced
241
+ * for it — needed to populate the map entry without re-hashing separately.
242
+ */
243
+ static async _signOneDetached(file, key, MajikSig, options, debug) {
244
+ const { envelope } = await MajikSignatureEmbed.signDetached(file.blob, key, MajikSig, {
245
+ contentType: options?.contentType,
246
+ timestamp: options?.timestamp,
247
+ expectedSigners: options?.expectedSigners,
248
+ }, debug);
249
+ const sig = envelope.findSignature(key.fingerprint);
250
+ if (!sig) {
251
+ // Should be unreachable — signDetached() always upserts this signer's
252
+ // entry — but fail loudly rather than silently omitting the file from
253
+ // the map if signDetached's contract is ever violated.
254
+ throw new MajikSignatureError(`Internal error: no signature found for this signer after signing "${file.path}"`);
255
+ }
256
+ return { envelope, contentHash: sig.contentHash };
257
+ }
258
+ /**
259
+ * Validate the batch before touching any crypto: non-empty, every file has
260
+ * a non-empty path, and no two files share a path. Duplicate paths would
261
+ * otherwise silently overwrite each other's map entry via withEntry()'s
262
+ * replace-on-match semantics — catching it here means the failure is
263
+ * "your batch has a duplicate path" up front, not a mysteriously missing
264
+ * entry discovered later.
265
+ */
266
+ static _assertValidBatch(files) {
267
+ if (!files || files.length === 0) {
268
+ throw new MajikSignatureValidationError("Batch must contain at least one file.", "files");
269
+ }
270
+ const seen = new Set();
271
+ for (const file of files) {
272
+ if (!file.path || !file.path.trim()) {
273
+ throw new MajikSignatureValidationError("Every batch file must have a non-empty path.", "path");
274
+ }
275
+ if (seen.has(file.path)) {
276
+ throw new MajikSignatureValidationError(`Duplicate path in batch: "${file.path}"`, "path");
277
+ }
278
+ seen.add(file.path);
279
+ }
160
280
  }
161
281
  // ── extract ────────────────────────────────────────────────────────────────
162
282
  /**
@@ -245,6 +365,114 @@ export class MajikSignatureEmbed {
245
365
  const publicKeys = MajikSig.publicKeysFromMajikKey(key);
246
366
  return MajikSignatureEmbed.verifyDetached(file, envelopeInput, publicKeys, MajikSig, options, debug);
247
367
  }
368
+ // ── verifyFilesFromMjksMap ───────────────────────────────────────────────────
369
+ /**
370
+ * Verify a batch of extracted files against a MajikSignatureMap.
371
+ *
372
+ * For each file: resolve against the map (tolerating relocation — a file
373
+ * moved or renamed after signing is still found and verified by content,
374
+ * not just by its original path), then run the normal signature
375
+ * verification via the envelope stored in that entry. Never throws
376
+ * per-file — every outcome (missing, tampered, relocated-but-valid,
377
+ * invalid, verified) is reported in the returned array, so a caller can
378
+ * render a full per-file status table in one pass instead of catching
379
+ * exceptions.
380
+ *
381
+ * Set options.requireAllPresent to escalate a missing file to a thrown
382
+ * error instead — useful when the caller expects a closed, complete set
383
+ * (e.g. "this zip must contain everything the map lists").
384
+ */
385
+ static async verifyFilesFromMjksMap(map, files, publicKeys, MajikSig, options, debug = false) {
386
+ const results = [];
387
+ for (const file of files) {
388
+ const resolved = await map.resolveEntry(file.path, file.blob);
389
+ if (resolved.status === "not_found") {
390
+ if (options?.requireAllPresent) {
391
+ throw new MajikSignatureError(`File "${file.path}" was not found in the signature map.`);
392
+ }
393
+ results.push({
394
+ path: file.path,
395
+ status: "not_in_map",
396
+ reason: `No signature entry found for "${file.path}" (checked by path and by content).`,
397
+ });
398
+ continue;
399
+ }
400
+ if (resolved.status === "path_tampered") {
401
+ results.push({
402
+ path: file.path,
403
+ status: "tampered",
404
+ reason: "File content no longer matches what was signed — it may have been modified after signing.",
405
+ });
406
+ continue;
407
+ }
408
+ // At this point status is "path_match" or "relocated" — both have a
409
+ // confirmed content match against resolved.entry, so verification
410
+ // proceeds identically. Only the reported metadata differs.
411
+ const originalBytes = new Uint8Array(await file.blob.arrayBuffer());
412
+ if (debug) {
413
+ console.log(`verifyFilesFromMjksMap — "${file.path}" bytes hash:`, bytesToBase64(hashContent(originalBytes)));
414
+ }
415
+ const envelope = MajikSignatureEnvelope.fromJSON(resolved.entry.envelope);
416
+ const integrity = envelope.verifyAllowlistIntegrity();
417
+ const relocatedFrom = resolved.status === "relocated" ? resolved.originalPath : undefined;
418
+ if (!integrity.valid) {
419
+ results.push({
420
+ path: file.path,
421
+ status: "invalid",
422
+ reason: integrity.reason,
423
+ ...(relocatedFrom ? { relocatedFrom } : {}),
424
+ });
425
+ continue;
426
+ }
427
+ const verifyResults = MajikSignatureEmbed._verifySignatures(envelope, originalBytes, publicKeys, MajikSig, "mjksmap", options?.expectedSignerId);
428
+ const allValid = verifyResults.every((r) => r.valid);
429
+ results.push({
430
+ path: file.path,
431
+ status: allValid ? "verified" : "invalid",
432
+ results: verifyResults,
433
+ ...(relocatedFrom ? { relocatedFrom } : {}),
434
+ ...(relocatedFrom
435
+ ? {
436
+ reason: allValid
437
+ ? `Verified by content match — originally signed as "${relocatedFrom}".`
438
+ : undefined,
439
+ }
440
+ : {}),
441
+ });
442
+ }
443
+ return results;
444
+ }
445
+ /** Convenience overload — resolves public keys from a MajikKey. */
446
+ static async verifyFilesFromMjksMapWithKey(map, files, key, MajikSig, options, debug = false) {
447
+ const publicKeys = MajikSig.publicKeysFromMajikKey(key);
448
+ return MajikSignatureEmbed.verifyFilesFromMjksMap(map, files, publicKeys, MajikSig, options, debug);
449
+ }
450
+ /**
451
+ * Summarize a batch verification result — one glance at pass/fail counts
452
+ * without the caller re-deriving it from the array each time.
453
+ */
454
+ static summarizeBatchVerification(results) {
455
+ const summary = {
456
+ total: results.length,
457
+ verified: 0,
458
+ invalid: 0,
459
+ tampered: 0,
460
+ notInMap: 0,
461
+ allValid: false,
462
+ };
463
+ for (const r of results) {
464
+ if (r.status === "verified")
465
+ summary.verified++;
466
+ else if (r.status === "invalid")
467
+ summary.invalid++;
468
+ else if (r.status === "tampered")
469
+ summary.tampered++;
470
+ else if (r.status === "not_in_map")
471
+ summary.notInMap++;
472
+ }
473
+ summary.allValid = summary.verified === summary.total && summary.total > 0;
474
+ return summary;
475
+ }
248
476
  // ── seal ───────────────────────────────────────────────────────────────────
249
477
  /**
250
478
  * Seal a multi-sig envelope, preventing any further signatures.
@@ -0,0 +1,103 @@
1
+ /**
2
+ * core/mjksmap.ts
3
+ *
4
+ * MajikSignatureMap — a single manifest mapping every file in a batch/zip
5
+ * to its detached MajikSignatureEnvelope.
6
+ *
7
+ * Design constraints (same as MajikSignatureEnvelope):
8
+ * - Pure/structural only. No crypto — verification is composed by callers
9
+ * via MajikSignature.verifyFileDetached() using an entry's envelope.
10
+ * - Immutable. withEntry() returns a new instance.
11
+ * - Keyed by path, not contentHash — duplicate-content files across a
12
+ * batch are legitimate and must not collide. contentHash is retained
13
+ * per-entry as an integrity check and as a secondary lookup index.
14
+ */
15
+ import { MajikSignatureEnvelope } from "./envelope";
16
+ import type { MjksMapEntry, MjksMapFindResult, MjksMapJSON, MjksMapResolveResult } from "./types";
17
+ export declare class MajikSignatureMap {
18
+ #private;
19
+ private readonly _version;
20
+ private readonly _createdAt;
21
+ private readonly _entries;
22
+ private readonly _byPath;
23
+ private readonly _byHash;
24
+ private constructor();
25
+ get version(): 1;
26
+ get createdAt(): string;
27
+ get entries(): readonly MjksMapEntry[];
28
+ get size(): number;
29
+ /**
30
+ * Resolve a file against the map, tolerating relocation.
31
+ *
32
+ * Tries the exact given path first (cheap, no hashing needed for the miss
33
+ * case... well, actually a match still needs the hash check below). If
34
+ * that path isn't in the map, falls back to a content-based search — this
35
+ * is what makes the map resilient to the batch being reorganized, renamed,
36
+ * or moved to a different folder/device after signing, since none of that
37
+ * changes a file's content or its signatures.
38
+ *
39
+ * "relocated" is reported as its own status rather than folded into
40
+ * "path_match" — a caller may reasonably want to flag/re-index a file
41
+ * that moved, even though its signature is still perfectly valid.
42
+ */
43
+ resolveEntry(path: string, file: Blob): Promise<MjksMapResolveResult>;
44
+ /** Raw entry lookup by exact path — no hash verification, no file needed. */
45
+ getEntry(path: string): MjksMapEntry | undefined;
46
+ /**
47
+ * Resolve a specific file's envelope, given its path AND its current bytes.
48
+ * Recomputes the hash and compares against the stored contentHash — this
49
+ * is the integrity check that catches "same name, edited after signing."
50
+ *
51
+ * Returns { found: false } if no entry exists at that path at all.
52
+ * Returns { found: true, hashMatches: false } if the entry exists but the
53
+ * file's current content no longer matches what was signed — the caller
54
+ * decides whether that's fatal (it usually should be, but this method
55
+ * doesn't throw so the caller can present a clear message rather than
56
+ * catching an exception).
57
+ */
58
+ findEntry(path: string, file: Blob): Promise<MjksMapFindResult>;
59
+ /**
60
+ * Find every entry matching a file's content, regardless of path.
61
+ * Returns an array (not a single entry) because duplicate-content files
62
+ * are legitimate — collapsing to one result would silently hide the rest.
63
+ * Use when the file may have been renamed/relocated after extraction.
64
+ */
65
+ findEntriesByHash(file: Blob): Promise<MjksMapEntry[]>;
66
+ /**
67
+ * Resolve a specific entry's envelope as a rich MajikSignatureEnvelope
68
+ * instance (not just the stored JSON) — matches the convention that every
69
+ * "give me a signature-shaped thing" method in this library returns the
70
+ * behavior-rich class, not raw wire JSON.
71
+ * Returns null if no entry exists at that path.
72
+ */
73
+ getEnvelope(path: string): MajikSignatureEnvelope | null;
74
+ /**
75
+ * All envelopes in the map, each paired with its path, as rich
76
+ * MajikSignatureEnvelope instances. Useful for bulk operations —
77
+ * e.g. rendering a signing-status table for an entire extracted batch
78
+ * without looking up each file individually.
79
+ */
80
+ getAllEnvelopes(): {
81
+ path: string;
82
+ envelope: MajikSignatureEnvelope;
83
+ }[];
84
+ hasEntry(path: string): boolean;
85
+ /**
86
+ * Add or replace an entry by path. Path is normalized before storage and
87
+ * before the uniqueness check, so "docs/a.pdf" and "docs\\a.pdf" collide
88
+ * as the same key rather than silently duplicating.
89
+ */
90
+ withEntry(entry: MjksMapEntry): MajikSignatureMap;
91
+ withoutEntry(path: string): MajikSignatureMap;
92
+ toJSON(): MjksMapJSON;
93
+ toMJKSMAPBytes(): Uint8Array;
94
+ toMJKSMAP(): Blob;
95
+ static fromMJKSMAP(input: Blob | Uint8Array): Promise<MajikSignatureMap>;
96
+ static isMJKSMAP(input: Blob | Uint8Array): Promise<boolean>;
97
+ static empty(): MajikSignatureMap;
98
+ static fromJSON(json: MjksMapJSON | string): MajikSignatureMap;
99
+ /** Accepts an instance, its JSON shape, or MJKSMAP bytes/Blob. */
100
+ static from(input: MajikSignatureMap | MjksMapJSON | Uint8Array | Blob): Promise<MajikSignatureMap>;
101
+ validate(): void;
102
+ isValid(): boolean;
103
+ }
@@ -0,0 +1,371 @@
1
+ /**
2
+ * core/mjksmap.ts
3
+ *
4
+ * MajikSignatureMap — a single manifest mapping every file in a batch/zip
5
+ * to its detached MajikSignatureEnvelope.
6
+ *
7
+ * Design constraints (same as MajikSignatureEnvelope):
8
+ * - Pure/structural only. No crypto — verification is composed by callers
9
+ * via MajikSignature.verifyFileDetached() using an entry's envelope.
10
+ * - Immutable. withEntry() returns a new instance.
11
+ * - Keyed by path, not contentHash — duplicate-content files across a
12
+ * batch are legitimate and must not collide. contentHash is retained
13
+ * per-entry as an integrity check and as a secondary lookup index.
14
+ */
15
+ import { MajikSignatureSerializationError, MajikSignatureValidationError, } from "./errors";
16
+ import { hashContent, bytesToBase64 } from "./hash";
17
+ import { MajikSignatureEnvelope } from "./envelope";
18
+ import { MJKSMAP_HEADER_LEN, MJKSMAP_MAGIC, MJKSMAP_MAGIC_LEN, MJKSMAP_MEDIA_TYPE, MJKSMAP_SUPPORTED_VERSIONS, MJKSMAP_VERSION, } from "./constants";
19
+ // ─── Path normalization ────────────────────────────────────────────────────────
20
+ /**
21
+ * Normalize a path for use as a map key: forward slashes, no leading slash,
22
+ * no drive letters. Same normalization lesson already hit with Tauri
23
+ * fs:scope globs (backslashes silently fail) — applied here proactively
24
+ * rather than waiting to hit it again in batch workflows.
25
+ */
26
+ function normalizePath(path) {
27
+ let p = path.replace(/\\/g, "/").trim();
28
+ p = p.replace(/^[a-zA-Z]:/, ""); // strip drive letters (C:, D:, ...)
29
+ p = p.replace(/^\/+/, ""); // strip leading slashes
30
+ return p;
31
+ }
32
+ // ─── MajikSignatureMap ─────────────────────────────────────────────────────────
33
+ export class MajikSignatureMap {
34
+ _version;
35
+ _createdAt;
36
+ _entries;
37
+ // Lazily built, cached lookup indices — never serialized, purely in-memory
38
+ // acceleration derived from _entries. Rebuilt whenever a new instance is
39
+ // constructed via #withEntries(), never mutated in place.
40
+ _byPath;
41
+ _byHash;
42
+ constructor(data) {
43
+ this._version = data.version;
44
+ this._createdAt = data.createdAt;
45
+ this._entries = data.entries.map((e) => ({
46
+ ...e,
47
+ path: normalizePath(e.path),
48
+ }));
49
+ const byPath = new Map();
50
+ const byHash = new Map();
51
+ for (const entry of data.entries) {
52
+ byPath.set(entry.path, entry);
53
+ const existing = byHash.get(entry.contentHash);
54
+ if (existing)
55
+ existing.push(entry);
56
+ else
57
+ byHash.set(entry.contentHash, [entry]);
58
+ }
59
+ this._byPath = byPath;
60
+ this._byHash = byHash;
61
+ }
62
+ // ── Getters ─────────────────────────────────────────────────────────────────
63
+ get version() {
64
+ return this._version;
65
+ }
66
+ get createdAt() {
67
+ return this._createdAt;
68
+ }
69
+ get entries() {
70
+ return this._entries;
71
+ }
72
+ get size() {
73
+ return this._entries.length;
74
+ }
75
+ // ── Lookup ─────────────────────────────────────────────────────────────────
76
+ /**
77
+ * Resolve a file against the map, tolerating relocation.
78
+ *
79
+ * Tries the exact given path first (cheap, no hashing needed for the miss
80
+ * case... well, actually a match still needs the hash check below). If
81
+ * that path isn't in the map, falls back to a content-based search — this
82
+ * is what makes the map resilient to the batch being reorganized, renamed,
83
+ * or moved to a different folder/device after signing, since none of that
84
+ * changes a file's content or its signatures.
85
+ *
86
+ * "relocated" is reported as its own status rather than folded into
87
+ * "path_match" — a caller may reasonably want to flag/re-index a file
88
+ * that moved, even though its signature is still perfectly valid.
89
+ */
90
+ async resolveEntry(path, file) {
91
+ const direct = await this.findEntry(path, file);
92
+ if (direct.found) {
93
+ return direct.hashMatches
94
+ ? { status: "path_match", entry: direct.entry }
95
+ : { status: "path_tampered", entry: direct.entry };
96
+ }
97
+ const byHash = await this.findEntriesByHash(file);
98
+ if (byHash.length > 0) {
99
+ // Multiple matches (duplicate-content files) — first is a reasonable
100
+ // default, but exposing all of them lets the caller disambiguate.
101
+ return {
102
+ status: "relocated",
103
+ entry: byHash[0],
104
+ originalPath: byHash[0].path,
105
+ };
106
+ }
107
+ return { status: "not_found" };
108
+ }
109
+ /** Raw entry lookup by exact path — no hash verification, no file needed. */
110
+ getEntry(path) {
111
+ return this._byPath.get(normalizePath(path));
112
+ }
113
+ /**
114
+ * Resolve a specific file's envelope, given its path AND its current bytes.
115
+ * Recomputes the hash and compares against the stored contentHash — this
116
+ * is the integrity check that catches "same name, edited after signing."
117
+ *
118
+ * Returns { found: false } if no entry exists at that path at all.
119
+ * Returns { found: true, hashMatches: false } if the entry exists but the
120
+ * file's current content no longer matches what was signed — the caller
121
+ * decides whether that's fatal (it usually should be, but this method
122
+ * doesn't throw so the caller can present a clear message rather than
123
+ * catching an exception).
124
+ */
125
+ async findEntry(path, file) {
126
+ const entry = this.getEntry(path);
127
+ if (!entry)
128
+ return { found: false };
129
+ const bytes = new Uint8Array(await file.arrayBuffer());
130
+ const recomputedHash = bytesToBase64(hashContent(bytes));
131
+ return {
132
+ found: true,
133
+ entry,
134
+ hashMatches: recomputedHash === entry.contentHash,
135
+ };
136
+ }
137
+ /**
138
+ * Find every entry matching a file's content, regardless of path.
139
+ * Returns an array (not a single entry) because duplicate-content files
140
+ * are legitimate — collapsing to one result would silently hide the rest.
141
+ * Use when the file may have been renamed/relocated after extraction.
142
+ */
143
+ async findEntriesByHash(file) {
144
+ const bytes = new Uint8Array(await file.arrayBuffer());
145
+ const hash = bytesToBase64(hashContent(bytes));
146
+ return [...(this._byHash.get(hash) ?? [])];
147
+ }
148
+ /**
149
+ * Resolve a specific entry's envelope as a rich MajikSignatureEnvelope
150
+ * instance (not just the stored JSON) — matches the convention that every
151
+ * "give me a signature-shaped thing" method in this library returns the
152
+ * behavior-rich class, not raw wire JSON.
153
+ * Returns null if no entry exists at that path.
154
+ */
155
+ getEnvelope(path) {
156
+ const entry = this.getEntry(path);
157
+ return entry ? MajikSignatureEnvelope.fromJSON(entry.envelope) : null;
158
+ }
159
+ /**
160
+ * All envelopes in the map, each paired with its path, as rich
161
+ * MajikSignatureEnvelope instances. Useful for bulk operations —
162
+ * e.g. rendering a signing-status table for an entire extracted batch
163
+ * without looking up each file individually.
164
+ */
165
+ getAllEnvelopes() {
166
+ return this._entries.map((entry) => ({
167
+ path: entry.path,
168
+ envelope: MajikSignatureEnvelope.fromJSON(entry.envelope),
169
+ }));
170
+ }
171
+ hasEntry(path) {
172
+ return this._byPath.has(normalizePath(path));
173
+ }
174
+ // ── Builders (immutable) ─────────────────────────────────────────────────────
175
+ /**
176
+ * Add or replace an entry by path. Path is normalized before storage and
177
+ * before the uniqueness check, so "docs/a.pdf" and "docs\\a.pdf" collide
178
+ * as the same key rather than silently duplicating.
179
+ */
180
+ withEntry(entry) {
181
+ if (!entry.path || !entry.path.trim()) {
182
+ throw new MajikSignatureValidationError("Entry must have a non-empty path.", "path");
183
+ }
184
+ if (!entry.contentHash || !entry.contentHash.trim()) {
185
+ throw new MajikSignatureValidationError("Entry must have a non-empty contentHash.", "contentHash");
186
+ }
187
+ const normalized = {
188
+ ...entry,
189
+ path: normalizePath(entry.path),
190
+ };
191
+ const filtered = this._entries.filter((e) => e.path !== normalized.path);
192
+ return new MajikSignatureMap({
193
+ version: this._version,
194
+ createdAt: this._createdAt,
195
+ entries: [...filtered, normalized],
196
+ });
197
+ }
198
+ withoutEntry(path) {
199
+ const normalized = normalizePath(path);
200
+ return new MajikSignatureMap({
201
+ version: this._version,
202
+ createdAt: this._createdAt,
203
+ entries: this._entries.filter((e) => e.path !== normalized),
204
+ });
205
+ }
206
+ // ── Serialization ─────────────────────────────────────────────────────────────
207
+ toJSON() {
208
+ return {
209
+ version: this._version,
210
+ createdAt: this._createdAt,
211
+ entries: [...this._entries],
212
+ };
213
+ }
214
+ // ── MJKSMAP binary format ─────────────────────────────────────────────────
215
+ //
216
+ // Same rationale as MJKSIG: length-prefixed JSON behind a versioned,
217
+ // self-identifying header. toMJKSMAP() returns a Blob for direct use in
218
+ // zip packaging / downloads; toMJKSMAPBytes() is the sync Uint8Array
219
+ // escape hatch for non-Blob contexts (Node scripts, direct fs writes).
220
+ toMJKSMAPBytes() {
221
+ const payloadJson = new TextEncoder().encode(JSON.stringify(this.toJSON()));
222
+ const out = new Uint8Array(MJKSMAP_HEADER_LEN + payloadJson.length);
223
+ out.set(MJKSMAP_MAGIC, 0);
224
+ out[MJKSMAP_MAGIC_LEN] = MJKSMAP_VERSION;
225
+ out[MJKSMAP_MAGIC_LEN + 1] = 0x00; // reserved — always 0 in v1
226
+ const lenOffset = MJKSMAP_MAGIC_LEN + 2;
227
+ out[lenOffset] = (payloadJson.length >>> 24) & 0xff;
228
+ out[lenOffset + 1] = (payloadJson.length >>> 16) & 0xff;
229
+ out[lenOffset + 2] = (payloadJson.length >>> 8) & 0xff;
230
+ out[lenOffset + 3] = payloadJson.length & 0xff;
231
+ out.set(payloadJson, MJKSMAP_HEADER_LEN);
232
+ return out;
233
+ }
234
+ toMJKSMAP() {
235
+ const bytes = this.toMJKSMAPBytes();
236
+ return new Blob([bytes], { type: MJKSMAP_MEDIA_TYPE });
237
+ }
238
+ static async fromMJKSMAP(input) {
239
+ const raw = input instanceof Blob ? new Uint8Array(await input.arrayBuffer()) : input;
240
+ return MajikSignatureMap.#parseMJKSMAPBytes(raw);
241
+ }
242
+ static #parseMJKSMAPBytes(raw) {
243
+ if (raw.length < MJKSMAP_HEADER_LEN + 1) {
244
+ throw new MajikSignatureSerializationError("Malformed MJKSMAP: too short to contain a valid header");
245
+ }
246
+ for (let i = 0; i < MJKSMAP_MAGIC_LEN; i++) {
247
+ if (raw[i] !== MJKSMAP_MAGIC[i]) {
248
+ throw new MajikSignatureSerializationError('Malformed MJKSMAP: missing "MJKSMAP" magic bytes');
249
+ }
250
+ }
251
+ const version = raw[MJKSMAP_MAGIC_LEN];
252
+ if (!MJKSMAP_SUPPORTED_VERSIONS.includes(version)) {
253
+ throw new MajikSignatureSerializationError(`Unsupported MJKSMAP version: ${version} (supported: ${MJKSMAP_SUPPORTED_VERSIONS.join(", ")})`);
254
+ }
255
+ const lenOffset = MJKSMAP_MAGIC_LEN + 2;
256
+ const payloadLen = (raw[lenOffset] << 24) |
257
+ (raw[lenOffset + 1] << 16) |
258
+ (raw[lenOffset + 2] << 8) |
259
+ raw[lenOffset + 3];
260
+ if (payloadLen <= 0) {
261
+ throw new MajikSignatureSerializationError("Malformed MJKSMAP: invalid payload length");
262
+ }
263
+ const payloadStart = MJKSMAP_HEADER_LEN;
264
+ const payloadEnd = payloadStart + payloadLen;
265
+ if (payloadEnd > raw.length) {
266
+ throw new MajikSignatureSerializationError("Malformed MJKSMAP: declared payload length exceeds buffer");
267
+ }
268
+ const json = new TextDecoder().decode(raw.slice(payloadStart, payloadEnd));
269
+ return MajikSignatureMap.fromJSON(json);
270
+ }
271
+ static async isMJKSMAP(input) {
272
+ const header = input instanceof Blob
273
+ ? new Uint8Array(await input.slice(0, MJKSMAP_MAGIC_LEN).arrayBuffer())
274
+ : input;
275
+ if (header.length < MJKSMAP_MAGIC_LEN)
276
+ return false;
277
+ for (let i = 0; i < MJKSMAP_MAGIC_LEN; i++) {
278
+ if (header[i] !== MJKSMAP_MAGIC[i])
279
+ return false;
280
+ }
281
+ return true;
282
+ }
283
+ // ── Creation / parsing ───────────────────────────────────────────────────────
284
+ static empty() {
285
+ return new MajikSignatureMap({
286
+ version: MJKSMAP_VERSION,
287
+ createdAt: new Date().toISOString(),
288
+ entries: [],
289
+ });
290
+ }
291
+ static fromJSON(json) {
292
+ let parsed;
293
+ try {
294
+ parsed = typeof json === "string" ? JSON.parse(json) : json;
295
+ }
296
+ catch (err) {
297
+ throw new MajikSignatureSerializationError("MJKSMAP payload is not valid JSON", err);
298
+ }
299
+ if (parsed === null ||
300
+ typeof parsed !== "object" ||
301
+ Array.isArray(parsed)) {
302
+ throw new MajikSignatureSerializationError("MJKSMAP payload must be a JSON object");
303
+ }
304
+ MajikSignatureMap.#validateShape(parsed);
305
+ return new MajikSignatureMap(parsed);
306
+ }
307
+ /** Accepts an instance, its JSON shape, or MJKSMAP bytes/Blob. */
308
+ static async from(input) {
309
+ if (input instanceof MajikSignatureMap)
310
+ return input;
311
+ if (input instanceof Uint8Array || input instanceof Blob) {
312
+ return MajikSignatureMap.fromMJKSMAP(input);
313
+ }
314
+ return MajikSignatureMap.fromJSON(input);
315
+ }
316
+ validate() {
317
+ MajikSignatureMap.#validateShape(this.toJSON());
318
+ }
319
+ isValid() {
320
+ try {
321
+ this.validate();
322
+ return true;
323
+ }
324
+ catch {
325
+ return false;
326
+ }
327
+ }
328
+ // ── Private validation ───────────────────────────────────────────────────────
329
+ static #validateShape(obj) {
330
+ if (obj.version !== MJKSMAP_VERSION) {
331
+ throw new MajikSignatureValidationError(`Unsupported MJKSMAP schema version: ${String(obj.version)}`, "version");
332
+ }
333
+ if (typeof obj.createdAt !== "string" || !obj.createdAt) {
334
+ throw new MajikSignatureValidationError("createdAt must be a non-empty ISO 8601 string", "createdAt");
335
+ }
336
+ if (!Array.isArray(obj.entries)) {
337
+ throw new MajikSignatureValidationError("entries must be an array", "entries");
338
+ }
339
+ const seenPaths = new Set();
340
+ for (const [i, entry] of obj.entries.entries()) {
341
+ MajikSignatureMap.#validateEntry(entry, i);
342
+ const path = entry.path;
343
+ if (seenPaths.has(path)) {
344
+ throw new MajikSignatureValidationError(`Duplicate path in entries: "${path}"`, "path");
345
+ }
346
+ seenPaths.add(path);
347
+ }
348
+ }
349
+ static #validateEntry(entry, index) {
350
+ if (entry === null || typeof entry !== "object") {
351
+ throw new MajikSignatureValidationError(`entries[${index}] must be an object`, "entries");
352
+ }
353
+ const obj = entry;
354
+ if (typeof obj.path !== "string" || !obj.path.trim()) {
355
+ throw new MajikSignatureValidationError(`entries[${index}].path must be a non-empty string`, "path");
356
+ }
357
+ if (typeof obj.contentHash !== "string" || !obj.contentHash.trim()) {
358
+ throw new MajikSignatureValidationError(`entries[${index}].contentHash must be a non-empty string`, "contentHash");
359
+ }
360
+ if (obj.envelope === null || typeof obj.envelope !== "object") {
361
+ throw new MajikSignatureValidationError(`entries[${index}].envelope must be an object`, "envelope");
362
+ }
363
+ // Delegates to MajikSignatureEnvelope's own validation rather than
364
+ // duplicating envelope-shape checks here — single source of truth.
365
+ MajikSignatureEnvelope.fromJSON(obj.envelope);
366
+ }
367
+ }
368
+ // Freeze static methods
369
+ Object.freeze(MajikSignatureMap);
370
+ // Freeze instance methods
371
+ Object.freeze(MajikSignatureMap.prototype);
@@ -2,6 +2,7 @@
2
2
  * types.ts
3
3
  * Public types for the MajikSignature library.
4
4
  */
5
+ import { ISODateString } from "@majikah/majik-key";
5
6
  import { MajikChainAnchor } from "../anchor/types";
6
7
  import type { ContentType } from "./constants";
7
8
  import type { MajikSignatureEnvelope } from "./envelope";
@@ -297,3 +298,116 @@ export interface ExtractResult {
297
298
  envelope: MajikSignatureEnvelope;
298
299
  handler: string;
299
300
  }
301
+ /**
302
+ * A single file's entry in a MajikSignatureMap.
303
+ * Keyed by `path` (unique within a batch) — NOT by contentHash alone,
304
+ * since duplicate-content files (identical boilerplate, empty templates)
305
+ * are a real case and hash-as-primary-key would silently collide them.
306
+ */
307
+ export interface MjksMapEntry {
308
+ /** Relative path within the batch, POSIX-normalized: forward slashes,
309
+ * no leading slash, no drive letters. */
310
+ path: string;
311
+ /** SHA-256 of the file's original (unsigned) content, base64.
312
+ * Used as an integrity check on lookup-by-path, and as the index
313
+ * for lookup-by-hash. */
314
+ contentHash: string;
315
+ /** Optional cheap-to-show metadata, no need to open the file for it */
316
+ size?: number;
317
+ mimeType?: string;
318
+ /** The full detached envelope for this specific file */
319
+ envelope: MajikSignatureEnvelopeJSON;
320
+ }
321
+ export interface MjksMapJSON {
322
+ version: 1;
323
+ createdAt: ISODateString;
324
+ entries: MjksMapEntry[];
325
+ }
326
+ export interface MjksMapFindResult {
327
+ found: boolean;
328
+ entry?: MjksMapEntry;
329
+ /** Only meaningful when found === true. False means the file at this
330
+ * path was modified after signing — same name, different content. */
331
+ hashMatches?: boolean;
332
+ }
333
+ export interface BatchFileInput {
334
+ /** Relative path within the batch — must be unique across the batch. */
335
+ path: string;
336
+ blob: Blob;
337
+ }
338
+ export interface BatchSignOptions {
339
+ contentType?: string;
340
+ timestamp?: string;
341
+ expectedSigners?: ExpectedSigner[];
342
+ /** "map" (default) produces one MajikSignatureMap covering the whole
343
+ * batch. "separate" produces one .mjksig Blob per file. */
344
+ mode?: "map" | "separate";
345
+ /**
346
+ * If false (default), the batch aborts on the first file that fails to
347
+ * sign — signing is security-sensitive, so silent partial failure is
348
+ * worse than a loud abort. Set true to collect failures and continue,
349
+ * useful for large batches where a handful of unreadable files
350
+ * shouldn't block everything else.
351
+ */
352
+ continueOnError?: boolean;
353
+ }
354
+ export interface BatchSignFailure {
355
+ path: string;
356
+ error: string;
357
+ }
358
+ export type BatchSignResult = {
359
+ mode: "map";
360
+ map: import("./mjksmap").MajikSignatureMap;
361
+ mapBlob: Blob;
362
+ failures: BatchSignFailure[];
363
+ } | {
364
+ mode: "separate";
365
+ signatures: {
366
+ path: string;
367
+ blob: Blob;
368
+ }[];
369
+ failures: BatchSignFailure[];
370
+ };
371
+ export interface BatchVerifyInput {
372
+ path: string;
373
+ blob: Blob;
374
+ }
375
+ export type FileVerifyStatus = "verified" | "invalid" | "tampered" | "not_in_map";
376
+ export interface FileVerifyResult {
377
+ path: string;
378
+ status: FileVerifyStatus;
379
+ /** Present for "verified" / "invalid" / "tampered" — absent for "not_in_map" */
380
+ results?: VerificationResult[];
381
+ /** Human-readable summary — always present, safe to show directly in UI */
382
+ reason?: string;
383
+ /** Present only when the file was found by content match at a different
384
+ * path than requested — i.e. it moved after signing but is still valid. */
385
+ relocatedFrom?: string;
386
+ }
387
+ export interface BatchVerifyOptions {
388
+ expectedSignerId?: string;
389
+ /**
390
+ * If true, a file with status "not_in_map" is a hard error for the whole
391
+ * batch call (throws). Default false — missing files are reported per-file
392
+ * instead, since a batch verify is usually a "tell me what's wrong with
393
+ * each file" operation, not an all-or-nothing gate.
394
+ */
395
+ requireAllPresent?: boolean;
396
+ }
397
+ export interface BatchVerifySummary {
398
+ total: number;
399
+ verified: number;
400
+ invalid: number;
401
+ tampered: number;
402
+ notInMap: number;
403
+ /** True only when every file is "verified" — the one-glance pass/fail check */
404
+ allValid: boolean;
405
+ }
406
+ export type MjksMapResolveStatus = "path_match" | "path_tampered" | "relocated" | "not_found";
407
+ export interface MjksMapResolveResult {
408
+ status: MjksMapResolveStatus;
409
+ entry?: MjksMapEntry;
410
+ /** Only set when status === "relocated" — where the file now lives
411
+ * vs. where the map says it was originally signed. */
412
+ originalPath?: string;
413
+ }
package/dist/index.d.ts CHANGED
@@ -8,6 +8,7 @@ export * from "./core/errors";
8
8
  export * from "./core/constants";
9
9
  export * from "./core/embed/majik-embed";
10
10
  export * from "./core/envelope";
11
+ export * from "./core/mjksmap";
11
12
  export type * from "./anchor/types";
12
13
  export { buildSigningPayload } from "./core/payload";
13
14
  export { hashContent, bytesToBase64, base64ToBytes } from "./core/hash";
package/dist/index.js CHANGED
@@ -10,6 +10,7 @@ export * from "./core/errors";
10
10
  export * from "./core/constants";
11
11
  export * from "./core/embed/majik-embed";
12
12
  export * from "./core/envelope";
13
+ export * from "./core/mjksmap";
13
14
  // ── Low-level utilities (opt-in) ──────────────────────────────────────────────
14
15
  // These are exported for consumers who want to build on top of the primitives
15
16
  // without going through MajikSignature (e.g. streaming hash pipelines,
@@ -4,11 +4,12 @@
4
4
  *
5
5
  */
6
6
  import type { MajikKey } from "@majikah/majik-key";
7
- import type { EnvelopeInfo, ExpectedSigner, MajikSignatureEnvelopeJSON, MajikSignatureJSON, MajikSignerPublicKeys, MajikTimestamp, MajikTSARequest, SealInfo, SealVerificationResult, SignatoriesFilter, SignatoriesResult, SignatoryInfo, SignOptions, VerificationResult } from "./core/types";
7
+ import type { BatchFileInput, BatchSignOptions, BatchVerifyInput, BatchVerifyOptions, EnvelopeInfo, ExpectedSigner, FileVerifyResult, MajikSignatureEnvelopeJSON, MajikSignatureJSON, MajikSignerPublicKeys, MajikTimestamp, MajikTSARequest, 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";
11
11
  import { MajikSignatureEnvelope } from "./core/envelope";
12
+ import { MajikSignatureMap } from "./core/mjksmap";
12
13
  /**
13
14
  * Majik Signature
14
15
  * ---
@@ -140,10 +141,16 @@ export declare class MajikSignature {
140
141
  * into the multi-sig structure, but does NOT embed it back.
141
142
  * Useful for external verification workflows where payloads and envelopes travel out-of-band.
142
143
  *
144
+ * Pass options.tsa to attach a Trusted Timestamp to this signature before
145
+ * it's added to the envelope — the digest-match and TSA-signature checks
146
+ * happen automatically inside addTSA().
147
+ *
143
148
  * @example
144
- * const { blob, signature } = await MajikSignature.signFileDetached(file, aliceKey, {
145
- * existingEnvelope: outOfBandEnvelope // Optionally pass state from an external source
149
+ * const { blob, envelope, signature } = await MajikSignature.signFileDetached(file, aliceKey, {
150
+ * existingEnvelope: outOfBandEnvelope, // Optionally pass state from an external source
151
+ * tsa: myTsaTimestamp, // Optionally attach a Trusted Timestamp
146
152
  * });
153
+ * console.log(signature.hasTSA); // true if tsa was provided and accepted
147
154
  */
148
155
  static signFileDetached(file: Blob, key: MajikKey, options?: {
149
156
  contentType?: string;
@@ -151,7 +158,27 @@ export declare class MajikSignature {
151
158
  mimeType?: string;
152
159
  expectedSigners?: ExpectedSigner[];
153
160
  existingEnvelope?: MajikSignatureEnvelope | MajikSignatureEnvelopeJSON | Uint8Array | Blob;
154
- }): ReturnType<typeof MajikSignatureEmbed.signDetached>;
161
+ tsa?: MajikTimestamp;
162
+ }): ReturnType<typeof MajikSignatureEmbed.signDetached<MajikSignature>>;
163
+ /**
164
+ * Sign a batch of files (e.g. a folder or zip's contents) as detached
165
+ * envelopes. Packaged either as one MajikSignatureMap covering the whole
166
+ * batch (default — meant to sit at the root of the zip as one .mjksmap),
167
+ * or as separate .mjksig Blobs per file when options.mode === "separate".
168
+ *
169
+ * @example
170
+ * const result = await MajikSignature.signBatchDetached(
171
+ * [
172
+ * { path: "docs/report.pdf", blob: reportBlob },
173
+ * { path: "docs/appendix.pdf", blob: appendixBlob },
174
+ * ],
175
+ * aliceKey,
176
+ * );
177
+ * if (result.mode === "map") {
178
+ * zip.file("signatures.mjksmap", await result.mapBlob.arrayBuffer());
179
+ * }
180
+ */
181
+ static signBatchDetached(files: BatchFileInput[], key: MajikKey, options?: BatchSignOptions): ReturnType<typeof MajikSignatureEmbed.signBatchDetached>;
155
182
  /**
156
183
  * Verify a file's embedded signatures.
157
184
  * Returns one VerificationResult per signer. Old single-sig files return a single-item array.
@@ -171,6 +198,25 @@ export declare class MajikSignature {
171
198
  expectedSignerId?: string;
172
199
  mimeType?: string;
173
200
  }, debug?: boolean): Promise<VerificationResult[]>;
201
+ /**
202
+ * Verify a batch of extracted files against a MajikSignatureMap (loaded via
203
+ * MajikSignatureMap.fromMJKSMAP()). Reports a per-file status rather than
204
+ * throwing — a missing, tampered, or invalidly-signed file is a normal
205
+ * possible outcome to display, not an exceptional one to catch.
206
+ *
207
+ * @example
208
+ * const map = await MajikSignatureMap.fromMJKSMAP(mjksmapBlob);
209
+ * const results = await MajikSignature.verifyFilesFromMjksMap(
210
+ * map,
211
+ * extractedFiles,
212
+ * publicKeys,
213
+ * );
214
+ * const summary = MajikSignature.summarizeBatchVerification(results);
215
+ * if (!summary.allValid) { ... }
216
+ */
217
+ static verifyFilesFromMjksMap(map: MajikSignatureMap, files: BatchVerifyInput[], publicKeys: MajikSignerPublicKeys, options?: BatchVerifyOptions, debug?: boolean): Promise<FileVerifyResult[]>;
218
+ static verifyFilesFromMjksMapWithKey(map: MajikSignatureMap, files: BatchVerifyInput[], key: MajikKey, options?: BatchVerifyOptions, debug?: boolean): Promise<FileVerifyResult[]>;
219
+ static summarizeBatchVerification(results: FileVerifyResult[]): ReturnType<typeof MajikSignatureEmbed.summarizeBatchVerification>;
174
220
  /**
175
221
  * Embed this MajikSignature instance into a file.
176
222
  * The signature must cover the original file bytes BEFORE embedding.
@@ -411,14 +411,41 @@ export class MajikSignature {
411
411
  * into the multi-sig structure, but does NOT embed it back.
412
412
  * Useful for external verification workflows where payloads and envelopes travel out-of-band.
413
413
  *
414
+ * Pass options.tsa to attach a Trusted Timestamp to this signature before
415
+ * it's added to the envelope — the digest-match and TSA-signature checks
416
+ * happen automatically inside addTSA().
417
+ *
414
418
  * @example
415
- * const { blob, signature } = await MajikSignature.signFileDetached(file, aliceKey, {
416
- * existingEnvelope: outOfBandEnvelope // Optionally pass state from an external source
419
+ * const { blob, envelope, signature } = await MajikSignature.signFileDetached(file, aliceKey, {
420
+ * existingEnvelope: outOfBandEnvelope, // Optionally pass state from an external source
421
+ * tsa: myTsaTimestamp, // Optionally attach a Trusted Timestamp
417
422
  * });
423
+ * console.log(signature.hasTSA); // true if tsa was provided and accepted
418
424
  */
419
425
  static async signFileDetached(file, key, options) {
420
426
  return MajikSignatureEmbed.signDetached(file, key, MajikSignature, options);
421
427
  }
428
+ /**
429
+ * Sign a batch of files (e.g. a folder or zip's contents) as detached
430
+ * envelopes. Packaged either as one MajikSignatureMap covering the whole
431
+ * batch (default — meant to sit at the root of the zip as one .mjksmap),
432
+ * or as separate .mjksig Blobs per file when options.mode === "separate".
433
+ *
434
+ * @example
435
+ * const result = await MajikSignature.signBatchDetached(
436
+ * [
437
+ * { path: "docs/report.pdf", blob: reportBlob },
438
+ * { path: "docs/appendix.pdf", blob: appendixBlob },
439
+ * ],
440
+ * aliceKey,
441
+ * );
442
+ * if (result.mode === "map") {
443
+ * zip.file("signatures.mjksmap", await result.mapBlob.arrayBuffer());
444
+ * }
445
+ */
446
+ static async signBatchDetached(files, key, options) {
447
+ return MajikSignatureEmbed.signBatchDetached(files, key, MajikSignature, options);
448
+ }
422
449
  /**
423
450
  * Verify a file's embedded signatures.
424
451
  * Returns one VerificationResult per signer. Old single-sig files return a single-item array.
@@ -442,6 +469,31 @@ export class MajikSignature {
442
469
  }
443
470
  return MajikSignatureEmbed.verifyDetached(file, envelope, keyOrPublicKeys, MajikSignature, options, debug);
444
471
  }
472
+ /**
473
+ * Verify a batch of extracted files against a MajikSignatureMap (loaded via
474
+ * MajikSignatureMap.fromMJKSMAP()). Reports a per-file status rather than
475
+ * throwing — a missing, tampered, or invalidly-signed file is a normal
476
+ * possible outcome to display, not an exceptional one to catch.
477
+ *
478
+ * @example
479
+ * const map = await MajikSignatureMap.fromMJKSMAP(mjksmapBlob);
480
+ * const results = await MajikSignature.verifyFilesFromMjksMap(
481
+ * map,
482
+ * extractedFiles,
483
+ * publicKeys,
484
+ * );
485
+ * const summary = MajikSignature.summarizeBatchVerification(results);
486
+ * if (!summary.allValid) { ... }
487
+ */
488
+ static async verifyFilesFromMjksMap(map, files, publicKeys, options, debug = false) {
489
+ return MajikSignatureEmbed.verifyFilesFromMjksMap(map, files, publicKeys, MajikSignature, options, debug);
490
+ }
491
+ static async verifyFilesFromMjksMapWithKey(map, files, key, options, debug = false) {
492
+ return MajikSignatureEmbed.verifyFilesFromMjksMapWithKey(map, files, key, MajikSignature, options, debug);
493
+ }
494
+ static summarizeBatchVerification(results) {
495
+ return MajikSignatureEmbed.summarizeBatchVerification(results);
496
+ }
445
497
  /**
446
498
  * Embed this MajikSignature instance into a file.
447
499
  * The signature must cover the original file bytes BEFORE embedding.
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.4",
5
+ "version": "0.2.5",
6
6
  "license": "Apache-2.0",
7
7
  "author": "Zelijah",
8
8
  "main": "./dist/index.js",