@majikah/majik-signature 0.2.4 → 0.2.6
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/dist/core/constants.d.ts +9 -0
- package/dist/core/constants.js +15 -0
- package/dist/core/embed/majik-embed.d.ts +74 -4
- package/dist/core/embed/majik-embed.js +232 -4
- package/dist/core/mjksmap.d.ts +103 -0
- package/dist/core/mjksmap.js +371 -0
- package/dist/core/types.d.ts +114 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/majik-signature.d.ts +50 -4
- package/dist/majik-signature.js +54 -2
- package/package.json +2 -3
package/dist/core/constants.d.ts
CHANGED
|
@@ -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";
|
package/dist/core/constants.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
*
|
|
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 {
|
|
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);
|
package/dist/core/types.d.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
package/dist/majik-signature.js
CHANGED
|
@@ -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.
|
|
5
|
+
"version": "0.2.6",
|
|
6
6
|
"license": "Apache-2.0",
|
|
7
7
|
"author": "Zelijah",
|
|
8
8
|
"main": "./dist/index.js",
|
|
@@ -55,8 +55,7 @@
|
|
|
55
55
|
"@noble/post-quantum": "^0.6.1",
|
|
56
56
|
"@stablelib/ed25519": "^2.1.0",
|
|
57
57
|
"@stablelib/sha256": "^2.0.1",
|
|
58
|
-
"fflate": "^0.8.3"
|
|
59
|
-
"pdf-lib": "^1.17.1"
|
|
58
|
+
"fflate": "^0.8.3"
|
|
60
59
|
},
|
|
61
60
|
"devDependencies": {
|
|
62
61
|
"@types/node": "^26.1.2",
|