@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.
- package/dist/core/constants.d.ts +21 -0
- package/dist/core/constants.js +34 -0
- package/dist/core/embed/majik-embed.d.ts +78 -8
- package/dist/core/embed/majik-embed.js +235 -8
- package/dist/core/envelope.d.ts +49 -2
- package/dist/core/envelope.js +144 -6
- 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 +52 -6
- package/dist/majik-signature.js +54 -2
- package/package.json +1 -1
package/dist/core/constants.d.ts
CHANGED
|
@@ -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";
|
package/dist/core/constants.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
*
|
|
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 {
|
|
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.
|
package/dist/core/envelope.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
178
|
-
|
|
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
|
}
|