@majikah/majik-signature 0.2.3 → 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.
@@ -59,3 +59,24 @@ export declare const MAJIK_TIMESTAMP_VERSION: 1;
59
59
  export declare const MAJIK_TSA_DOMAIN: "majikah-tsa-v-1:";
60
60
  export declare const MAJIK_NOTARY_VERSION: 1;
61
61
  export declare const MAJIK_NOTARY_MEMO_DOMAIN: "majik-notary-v-1:";
62
+ export declare const MJKSIG_MAGIC: number[];
63
+ export declare const MJKSIG_MAGIC_LEN: number;
64
+ export declare const MJKSIG_VERSION = 1;
65
+ export declare const MJKSIG_SUPPORTED_VERSIONS: readonly [1];
66
+ export declare const MJKSIG_HEADER_LEN: number;
67
+ /**
68
+ * Proposed IANA media type / conventional file extension for this format.
69
+ * Referenced here so both live in one place ahead of registration —
70
+ * update if/when the registration settles on different values.
71
+ */
72
+ export declare const MJKSIG_MEDIA_TYPE: "application/vnd.majikah.mjksig";
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";
@@ -58,3 +58,37 @@ export const MAJIK_TIMESTAMP_VERSION = 1;
58
58
  export const MAJIK_TSA_DOMAIN = `majikah-tsa-v-${MAJIK_TIMESTAMP_VERSION}:`;
59
59
  export const MAJIK_NOTARY_VERSION = 1;
60
60
  export const MAJIK_NOTARY_MEMO_DOMAIN = `majik-notary-v-${MAJIK_NOTARY_VERSION}:`;
61
+ // ─── MJKSIG binary format ───────────────────────────────────────────────────
62
+ //
63
+ // Layout: [magic(6)][version(1)][reserved(1)][payloadLen(4, BE u32)][payload JSON]
64
+ // Header length is fixed at 12 bytes regardless of version — only the
65
+ // payload shape may change between versions, never the header layout.
66
+ // This lets fromMJKSIG() always locate and validate the header before it
67
+ // needs to know anything about the payload's internal shape.
68
+ export const MJKSIG_MAGIC = [0x4d, 0x4a, 0x4b, 0x53, 0x49, 0x47]; // "MJKSIG"
69
+ export const MJKSIG_MAGIC_LEN = MJKSIG_MAGIC.length;
70
+ export const MJKSIG_VERSION = 0x01;
71
+ export const MJKSIG_SUPPORTED_VERSIONS = [MJKSIG_VERSION];
72
+ export const MJKSIG_HEADER_LEN = MJKSIG_MAGIC_LEN + 1 + 1 + 4; // 12
73
+ /**
74
+ * Proposed IANA media type / conventional file extension for this format.
75
+ * Referenced here so both live in one place ahead of registration —
76
+ * update if/when the registration settles on different values.
77
+ */
78
+ export const MJKSIG_MEDIA_TYPE = "application/vnd.majikah.mjksig";
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
- existingEnvelope?: MajikSignatureEnvelope | MajikSignatureEnvelopeJSON;
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.
@@ -98,16 +144,40 @@ export declare class MajikSignatureEmbed {
98
144
  expectedSignerId?: string;
99
145
  }, debug?: boolean): Promise<VerificationResult[]>;
100
146
  /**
101
- * Verify a file against a provided, detached envelope (instance or JSON).
147
+ * Verify a file against a provided, detached envelope (instance, blob or JSON).
102
148
  * Still strips the file in case it also contains an embedded envelope,
103
149
  * ensuring verification runs against the clean original bytes.
104
150
  */
105
- static verifyDetached(file: Blob, envelopeInput: MajikSignatureEnvelope | MajikSignatureEnvelopeJSON, publicKeys: MajikSignerPublicKeys, MajikSig: MajikSignatureStaticAdapter, options?: ExtractOptions & {
151
+ static verifyDetached(file: Blob, envelopeInput: MajikSignatureEnvelope | MajikSignatureEnvelopeJSON | Uint8Array | Blob, publicKeys: MajikSignerPublicKeys, MajikSig: MajikSignatureStaticAdapter, options?: ExtractOptions & {
106
152
  expectedSignerId?: string;
107
153
  }, debug?: boolean): Promise<VerificationResult[]>;
108
- static verifyDetachedWithKey(file: Blob, envelopeInput: MajikSignatureEnvelope | MajikSignatureEnvelopeJSON, key: MajikKey, MajikSig: MajikSignatureStaticAdapter, options?: ExtractOptions & {
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,14 +129,27 @@ 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);
136
- // ── Resolve the working envelope ─────────────────────────────────────────
137
151
  const envelope = options?.existingEnvelope
138
- ? MajikSignatureEnvelope.from(options.existingEnvelope)
152
+ ? await MajikSignatureEnvelope.from(options.existingEnvelope)
139
153
  : await MajikSignatureEmbed._readEnvelope(handler, bytes);
140
154
  envelope.assertCanSign(key);
141
155
  const originalBytes = await handler.strip(bytes);
@@ -150,6 +164,16 @@ export class MajikSignatureEmbed {
150
164
  ? { allowlistHash: allowlistHashValue }
151
165
  : {}),
152
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
+ }
153
177
  const establishingAllowlist = envelope.isFirstSigner() && !!options?.expectedSigners?.length;
154
178
  const envelopeWithAllowlist = establishingAllowlist
155
179
  ? envelope.withAllowlist(options.expectedSigners, key.fingerprint)
@@ -157,7 +181,102 @@ export class MajikSignatureEmbed {
157
181
  const nextEnvelope = envelopeWithAllowlist.withSignature(signature.toJSON());
158
182
  // ── Return DETACHED (no embedding) ───────────────────────────────────────
159
183
  const blob = bytesToBlob(originalBytes, mimeType);
160
- 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
+ }
161
280
  }
162
281
  // ── extract ────────────────────────────────────────────────────────────────
163
282
  /**
@@ -218,13 +337,13 @@ export class MajikSignatureEmbed {
218
337
  }
219
338
  // ── verifyDetached ─────────────────────────────────────────────────────────
220
339
  /**
221
- * Verify a file against a provided, detached envelope (instance or JSON).
340
+ * Verify a file against a provided, detached envelope (instance, blob or JSON).
222
341
  * Still strips the file in case it also contains an embedded envelope,
223
342
  * ensuring verification runs against the clean original bytes.
224
343
  */
225
344
  static async verifyDetached(file, envelopeInput, publicKeys, MajikSig, options, debug = false) {
226
345
  const { bytes, handler } = await MajikSignatureEmbed._prepare(file, options);
227
- const envelope = MajikSignatureEnvelope.from(envelopeInput);
346
+ const envelope = await MajikSignatureEnvelope.from(envelopeInput);
228
347
  const originalBytes = await handler.strip(bytes);
229
348
  if (debug) {
230
349
  console.log("verifyDetached — original bytes hash:", bytesToBase64(hashContent(originalBytes)));
@@ -246,6 +365,114 @@ export class MajikSignatureEmbed {
246
365
  const publicKeys = MajikSig.publicKeysFromMajikKey(key);
247
366
  return MajikSignatureEmbed.verifyDetached(file, envelopeInput, publicKeys, MajikSig, options, debug);
248
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
+ }
249
476
  // ── seal ───────────────────────────────────────────────────────────────────
250
477
  /**
251
478
  * Seal a multi-sig envelope, preventing any further signatures.
@@ -161,6 +161,43 @@ export declare class MajikSignatureEnvelope {
161
161
  toJSON(): MajikSignatureEnvelopeJSON;
162
162
  serialize(): string;
163
163
  static deserialize(base64: string): MajikSignatureEnvelope;
164
+ /**
165
+ * Raw MJKSIG bytes, no Blob wrapper. Exposed as a public escape hatch for
166
+ * non-browser contexts (Node scripts, tests, direct fs writes) where
167
+ * wrapping in a Blob just to immediately unwrap it again is pure overhead.
168
+ * toMJKSIG() is the primary API for anything Blob-facing.
169
+ */
170
+ toMJKSIGBytes(): Uint8Array;
171
+ /**
172
+ * Encode this envelope as an MJKSIG file Blob.
173
+ * Always writes the current MJKSIG_VERSION — encoding an old payload
174
+ * shape under an old version tag is not supported; old versions only
175
+ * ever appear when *reading* pre-existing MJKSIG binaries.
176
+ */
177
+ toMJKSIG(): Blob;
178
+ /**
179
+ * Decode MJKSIG bytes back into a MajikSignatureEnvelope.
180
+ * Validates magic bytes, version, and declared payload length before
181
+ * attempting to parse — a truncated or corrupted buffer fails fast with
182
+ * a clear reason rather than an obscure JSON.parse error.
183
+ *
184
+ * Accepts either a Blob (as produced by toMJKSIG()) or raw Uint8Array
185
+ * (as produced by toMJKSIGBytes(), or read directly off disk) — mirrors
186
+ * the same "accept either shape" pattern as from(). Reading a Blob
187
+ * requires awaiting its bytes, which is why this method is async.
188
+ */
189
+ static fromMJKSIG(input: Blob | Uint8Array): Promise<MajikSignatureEnvelope>;
190
+ /**
191
+ * Cheap structural sniff — checks magic bytes only, does not parse or
192
+ * validate the payload. For a Blob, slices only the header bytes rather
193
+ * than reading the whole file, so this stays cheap even on large inputs.
194
+ */
195
+ static isMJKSIG(input: Blob | Uint8Array): Promise<boolean>;
196
+ /**
197
+ * Read just the version byte without parsing the payload.
198
+ * Returns null if the input isn't MJKSIG-shaped at all.
199
+ */
200
+ static getMJKSIGVersion(input: Blob | Uint8Array): Promise<number | null>;
164
201
  static empty(): MajikSignatureEnvelope;
165
202
  /**
166
203
  * Parse a raw string or plain object into a MajikSignatureEnvelope.
@@ -174,8 +211,18 @@ export declare class MajikSignatureEnvelope {
174
211
  * This is the only place that knows about the legacy on-disk shape.
175
212
  */
176
213
  static fromJSON(json: MajikSignatureEnvelopeJSON | MajikSignatureJSON | string): MajikSignatureEnvelope;
177
- /** Accepts either an instance or its JSON shape — normalizes to an instance. */
178
- static from(input: MajikSignatureEnvelope | MajikSignatureEnvelopeJSON): MajikSignatureEnvelope;
214
+ /**
215
+ * Accepts an instance, its JSON shape, or MJKSIG bytes/Blob — normalizes to
216
+ * an instance. This is the single entry point that lets every downstream
217
+ * caller (verifyDetached, verifyDetachedWithKey, signDetached's
218
+ * existingEnvelope option) transparently accept a detached envelope
219
+ * regardless of which form it arrived in.
220
+ *
221
+ * Now async: resolving a Blob input requires awaiting its bytes. Every
222
+ * existing call site is already inside an async method, so this only
223
+ * costs an added `await` at each call site — no structural changes.
224
+ */
225
+ static from(input: MajikSignatureEnvelope | MajikSignatureEnvelopeJSON | Uint8Array | Blob): Promise<MajikSignatureEnvelope>;
179
226
  validate(): void;
180
227
  isValid(): boolean;
181
228
  }