@majikah/majik-signature 0.2.1 → 0.2.3

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.
@@ -42,58 +42,58 @@ export class Mp4Handler {
42
42
  async embed(bytes, signatureJson) {
43
43
  const clean = await this.strip(bytes);
44
44
  const majkBox = this._buildBox(MP4_BOX_TYPE, textEncode(signatureJson));
45
- const boxes = this._parseBoxes(clean, 0, clean.length);
45
+ const { boxes, trailing: topTrailing } = this._parseBoxes(clean, 0, clean.length);
46
46
  const moovIdx = boxes.findIndex((b) => b.type === "moov");
47
47
  if (moovIdx < 0) {
48
- // No moov box — use trailer fallback
49
48
  const { appendTrailer } = await import("../utils");
50
49
  return appendTrailer(clean, signatureJson);
51
50
  }
52
51
  const moovBox = boxes[moovIdx];
53
- const moovChildren = this._parseBoxes(moovBox.data, 0, moovBox.data.length);
52
+ const { boxes: moovChildren, trailing: moovTrailing } = this._parseBoxes(moovBox.data, 0, moovBox.data.length);
54
53
  const udtaIdx = moovChildren.findIndex((b) => b.type === "udta");
55
- let newUdta;
54
+ const { boxes: existingUdtaChildren, trailing: udtaTrailing } = udtaIdx >= 0
55
+ ? this._parseBoxes(moovChildren[udtaIdx].data, 0, moovChildren[udtaIdx].data.length)
56
+ : { boxes: [], trailing: new Uint8Array(0) };
57
+ const newUdta = this._buildBox("udta", concatBytes(...existingUdtaChildren.map((b) => b.raw), majkBox, // ← box-aligned, right after real children
58
+ udtaTrailing));
59
+ const newMoovChildren = [];
56
60
  if (udtaIdx >= 0) {
57
- const udtaBox = moovChildren[udtaIdx];
58
- // Append majk to udta
59
- const newUdtaData = concatBytes(udtaBox.data, majkBox);
60
- newUdta = this._buildBox("udta", newUdtaData);
61
+ // Retain the exact original index of the udta box to avoid byte shifting
62
+ for (let i = 0; i < moovChildren.length; i++) {
63
+ newMoovChildren.push(i === udtaIdx ? newUdta : moovChildren[i].raw);
64
+ }
61
65
  }
62
66
  else {
63
- newUdta = this._buildBox("udta", majkBox);
64
- }
65
- // Rebuild moov
66
- const newMoovChildren = [];
67
- for (let i = 0; i < moovChildren.length; i++) {
68
- if (i === udtaIdx)
69
- continue; // skip old udta (we'll add new one)
70
- newMoovChildren.push(moovChildren[i].raw);
67
+ // If it didn't exist previously, it's safe to append it to the end
68
+ for (let i = 0; i < moovChildren.length; i++) {
69
+ newMoovChildren.push(moovChildren[i].raw);
70
+ }
71
+ newMoovChildren.push(newUdta);
71
72
  }
72
- newMoovChildren.push(newUdta);
73
- const newMoov = this._buildBox("moov", concatBytes(...newMoovChildren));
74
- // Rebuild file
73
+ const newMoov = this._buildBox("moov", concatBytes(...newMoovChildren, moovTrailing));
75
74
  const parts = [];
76
75
  for (let i = 0; i < boxes.length; i++) {
77
- if (i === moovIdx)
78
- parts.push(newMoov);
79
- else
80
- parts.push(boxes[i].raw);
76
+ parts.push(i === moovIdx ? newMoov : boxes[i].raw);
81
77
  }
82
- return concatBytes(...parts);
78
+ return concatBytes(...parts, topTrailing);
83
79
  }
84
80
  async extract(bytes) {
85
81
  if (!this.canHandle(bytes))
86
82
  return null;
87
83
  try {
88
- const boxes = this._parseBoxes(bytes, 0, bytes.length);
84
+ const { boxes } = this._parseBoxes(bytes, 0, bytes.length);
89
85
  const moov = boxes.find((b) => b.type === "moov");
90
- if (!moov)
91
- return null;
92
- const moovChildren = this._parseBoxes(moov.data, 0, moov.data.length);
86
+ // Fallback: If no moov box exists, check for a Tier-2 trailer
87
+ if (!moov) {
88
+ const { extractTrailer } = await import("../utils");
89
+ const trailer = extractTrailer(bytes);
90
+ return trailer ? trailer.signatureJson : null;
91
+ }
92
+ const { boxes: moovChildren } = this._parseBoxes(moov.data, 0, moov.data.length);
93
93
  const udta = moovChildren.find((b) => b.type === "udta");
94
94
  if (!udta)
95
95
  return null;
96
- const udtaChildren = this._parseBoxes(udta.data, 0, udta.data.length);
96
+ const { boxes: udtaChildren } = this._parseBoxes(udta.data, 0, udta.data.length);
97
97
  const majk = udtaChildren.find((b) => b.type === MP4_BOX_TYPE);
98
98
  if (!majk)
99
99
  return null;
@@ -107,32 +107,36 @@ export class Mp4Handler {
107
107
  if (!this.canHandle(bytes))
108
108
  return bytes;
109
109
  try {
110
- const boxes = this._parseBoxes(bytes, 0, bytes.length);
110
+ const { boxes, trailing: topTrailing } = this._parseBoxes(bytes, 0, bytes.length);
111
111
  const moovIdx = boxes.findIndex((b) => b.type === "moov");
112
- if (moovIdx < 0)
113
- return bytes;
112
+ // Fallback: If no moov box exists, strip the Tier-2 trailer if present
113
+ if (moovIdx < 0) {
114
+ const { extractTrailer } = await import("../utils");
115
+ const trailer = extractTrailer(bytes);
116
+ return trailer ? trailer.original : bytes;
117
+ }
114
118
  const moovBox = boxes[moovIdx];
115
- const moovChildren = this._parseBoxes(moovBox.data, 0, moovBox.data.length);
119
+ const { boxes: moovChildren, trailing: moovTrailing } = this._parseBoxes(moovBox.data, 0, moovBox.data.length);
116
120
  const udtaIdx = moovChildren.findIndex((b) => b.type === "udta");
117
121
  if (udtaIdx < 0)
118
122
  return bytes;
119
123
  const udtaBox = moovChildren[udtaIdx];
120
- const udtaChildren = this._parseBoxes(udtaBox.data, 0, udtaBox.data.length);
124
+ const { boxes: udtaChildren, trailing: udtaTrailing } = this._parseBoxes(udtaBox.data, 0, udtaBox.data.length);
121
125
  const filteredUdta = udtaChildren.filter((b) => b.type !== MP4_BOX_TYPE);
122
126
  let newMoovChildren;
123
- if (filteredUdta.length === 0) {
124
- // Remove udta entirely
127
+ // Only remove the udta box entirely if it is completely barren of data and trailing bytes
128
+ if (filteredUdta.length === 0 && udtaTrailing.length === 0) {
125
129
  newMoovChildren = moovChildren
126
130
  .filter((_, i) => i !== udtaIdx)
127
131
  .map((b) => b.raw);
128
132
  }
129
133
  else {
130
- const newUdta = this._buildBox("udta", concatBytes(...filteredUdta.map((b) => b.raw)));
134
+ const newUdta = this._buildBox("udta", concatBytes(...filteredUdta.map((b) => b.raw), udtaTrailing));
131
135
  newMoovChildren = moovChildren.map((b, i) => i === udtaIdx ? newUdta : b.raw);
132
136
  }
133
- const newMoov = this._buildBox("moov", concatBytes(...newMoovChildren));
137
+ const newMoov = this._buildBox("moov", concatBytes(...newMoovChildren, moovTrailing));
134
138
  const parts = boxes.map((b, i) => (i === moovIdx ? newMoov : b.raw));
135
- return concatBytes(...parts);
139
+ return concatBytes(...parts, topTrailing);
136
140
  }
137
141
  catch {
138
142
  return bytes;
@@ -146,14 +150,11 @@ export class Mp4Handler {
146
150
  let size = readUint32BE(bytes, offset);
147
151
  const type = textDecode(bytes.slice(offset + 4, offset + 8));
148
152
  if (size === 1) {
149
- // 64-bit extended size (large box)
150
- // Read as two 32-bit values
151
153
  const hi = readUint32BE(bytes, offset + 8);
152
154
  const lo = readUint32BE(bytes, offset + 12);
153
155
  size = hi * 0x100000000 + lo;
154
156
  }
155
157
  else if (size === 0) {
156
- // Box extends to end of file
157
158
  size = end - offset;
158
159
  }
159
160
  if (size < 8 || offset + size > end)
@@ -164,7 +165,10 @@ export class Mp4Handler {
164
165
  boxes.push({ type, data, raw });
165
166
  offset += size;
166
167
  }
167
- return boxes;
168
+ // Anything left over (padding, malformed box, unparseable trailing bytes)
169
+ // — preserve it verbatim instead of silently dropping it.
170
+ const trailing = bytes.slice(offset, end);
171
+ return { boxes, trailing };
168
172
  }
169
173
  _buildBox(type, data) {
170
174
  const size = 8 + data.length;
@@ -10,17 +10,19 @@
10
10
  *
11
11
  * majik-signature → majik-embed → majik-signature ✗
12
12
  *
13
- * Instead, operations that need MajikSignature receive it via the
14
- * MajikSignatureStaticAdapter interface — no circular import needed.
13
+ * Operations that need MajikSignature (signing, verifying) receive it via
14
+ * the MajikSignatureStaticAdapter interface — no circular import needed.
15
15
  *
16
- * Multi-sig + allowlist + seal:
17
- * ─────────────────────────────
18
- * Files embed a MultiSigEnvelope (array of per-signer envelopes + optional
19
- * allowlist + optional seal). parseEnvelope() transparently promotes old
20
- * single-sig files — all code here always operates on MultiSigEnvelope.
16
+ * MajikSignatureEnvelope, by contrast, is pure/structural (no crypto), so it
17
+ * IS imported directly here — no adapter required for it. All parsing,
18
+ * validation, allowlist enforcement, seal computation, and signatory/issuer
19
+ * resolution now live on that class (core/envelope.ts). This file is
20
+ * reduced to file-format orchestration: read bytes → resolve handler →
21
+ * extract/strip → delegate to the envelope class → re-embed.
21
22
  */
22
23
  import type { MajikKey } from "@majikah/majik-key";
23
- import type { EmbedOptions, EmbedResult, EnvelopeInfo, ExpectedSigner, ExtractOptions, ExtractResult, MajikSignatureJSON, MajikSignerPublicKeys, SealInfo, SealVerificationResult, SignatoriesFilter, SignatoriesResult, SignOptions, VerificationResult } from "../../core/types";
24
+ import type { EmbedOptions, EmbedResult, EnvelopeInfo, ExpectedSigner, ExtractOptions, ExtractResult, MajikSignatureEnvelopeJSON, MajikSignatureJSON, MajikSignerPublicKeys, SealInfo, SealVerificationResult, SignatoriesFilter, SignatoriesResult, SignOptions, VerificationResult } from "../../core/types";
25
+ import { MajikSignatureEnvelope } from "../../core/envelope";
24
26
  import { FormatHandlerRegistry } from "./registry";
25
27
  import { MajikChainAnchor } from "../../anchor/types";
26
28
  export interface MajikSignatureAdapter {
@@ -38,7 +40,7 @@ export declare class MajikSignatureEmbed {
38
40
  /**
39
41
  * Embed a pre-computed signature into a file Blob.
40
42
  * Reads any existing envelope, upserts the new signature by signerId,
41
- * and writes the updated MultiSigEnvelope back.
43
+ * and writes the updated envelope back.
42
44
  * Does NOT sign — call signAndEmbed() for sign + embed together.
43
45
  */
44
46
  static embed(file: Blob, signature: MajikSignatureAdapter | MajikSignatureJSON, options?: EmbedOptions): Promise<EmbedResult>;
@@ -46,15 +48,15 @@ export declare class MajikSignatureEmbed {
46
48
  * Sign a file and embed the signature in one call.
47
49
  *
48
50
  * Flow:
49
- * 1. Extract existing MultiSigEnvelope (or start fresh)
50
- * 2. Reject if envelope is sealed
51
- * 3. Enforce allowlist — throw MajikSignatureAllowlistError before any crypto
52
- * 4. Strip existing envelope to get clean original bytes
53
- * 5. If first signer and options.expectedSigners provided: compute allowlistHash
54
- * to bind the allowlist into the signing payload
55
- * 6. Sign the clean bytes (allowlistHash included in payload when present)
56
- * 7. Upsert signature into envelope; if establishing allowlist, attach
57
- * allowlist + allowlistSignerId to envelope
51
+ * 1. Read existing envelope (or start fresh)
52
+ * 2. assertCanSign() — rejects sealed envelopes and non-allowlisted signers
53
+ * before any cryptographic operation (issuer always bypasses)
54
+ * 3. Strip existing envelope to get clean original bytes
55
+ * 4. Resolve allowlistHash for this signer (establishing / re-signing / none)
56
+ * 5. Sign the clean bytes
57
+ * 6. If establishing an allowlist, attach it BEFORE upserting the signature
58
+ * (withAllowlist() requires zero existing signatures — see note below)
59
+ * 7. Upsert signature into envelope
58
60
  * 8. Embed updated envelope back into the file
59
61
  */
60
62
  static signAndEmbed<T extends MajikSignatureAdapter>(file: Blob, key: MajikKey, MajikSig: MajikSignatureStaticAdapter, options?: EmbedOptions & {
@@ -63,17 +65,31 @@ export declare class MajikSignatureEmbed {
63
65
  expectedSigners?: ExpectedSigner[];
64
66
  }, debug?: boolean): Promise<EmbedResult & {
65
67
  signature: T;
68
+ envelope: MajikSignatureEnvelope;
66
69
  }>;
67
70
  /**
68
- * Extract the MultiSigEnvelope from a file.
71
+ * Sign a file and return the envelope detached.
72
+ * Same flow as signAndEmbed(), minus the final embed step.
73
+ */
74
+ static signDetached(file: Blob, key: MajikKey, MajikSig: MajikSignatureStaticAdapter, options?: EmbedOptions & {
75
+ contentType?: string;
76
+ timestamp?: string;
77
+ expectedSigners?: ExpectedSigner[];
78
+ existingEnvelope?: MajikSignatureEnvelope | MajikSignatureEnvelopeJSON;
79
+ }, debug?: boolean): Promise<{
80
+ blob: Blob;
81
+ envelope: MajikSignatureEnvelope;
82
+ handler: string;
83
+ mimeType: string;
84
+ }>;
85
+ /**
86
+ * Extract the envelope from a file as a MajikSignatureEnvelope instance.
69
87
  * Returns null if no signature is found.
70
- * Old single-sig files are promoted to MultiSigEnvelope transparently.
71
88
  */
72
89
  static extract(file: Blob, options?: ExtractOptions): Promise<ExtractResult | null>;
73
90
  /**
74
91
  * Verify a file's embedded signatures against public keys.
75
92
  * Returns one VerificationResult per signature in the envelope.
76
- * Old single-sig files return a single-item array.
77
93
  */
78
94
  static verify(file: Blob, publicKeys: MajikSignerPublicKeys, MajikSig: MajikSignatureStaticAdapter, options?: ExtractOptions & {
79
95
  expectedSignerId?: string;
@@ -81,19 +97,20 @@ export declare class MajikSignatureEmbed {
81
97
  static verifyWithKey(file: Blob, key: MajikKey, MajikSig: MajikSignatureStaticAdapter, options?: ExtractOptions & {
82
98
  expectedSignerId?: string;
83
99
  }, debug?: boolean): Promise<VerificationResult[]>;
100
+ /**
101
+ * Verify a file against a provided, detached envelope (instance or JSON).
102
+ * Still strips the file in case it also contains an embedded envelope,
103
+ * ensuring verification runs against the clean original bytes.
104
+ */
105
+ static verifyDetached(file: Blob, envelopeInput: MajikSignatureEnvelope | MajikSignatureEnvelopeJSON, publicKeys: MajikSignerPublicKeys, MajikSig: MajikSignatureStaticAdapter, options?: ExtractOptions & {
106
+ expectedSignerId?: string;
107
+ }, debug?: boolean): Promise<VerificationResult[]>;
108
+ static verifyDetachedWithKey(file: Blob, envelopeInput: MajikSignatureEnvelope | MajikSignatureEnvelopeJSON, key: MajikKey, MajikSig: MajikSignatureStaticAdapter, options?: ExtractOptions & {
109
+ expectedSignerId?: string;
110
+ }, debug?: boolean): Promise<VerificationResult[]>;
84
111
  /**
85
112
  * Seal a multi-sig envelope, preventing any further signatures.
86
- *
87
- * Rules:
88
- * - Only the issuer (allowlistSignerId) may seal
89
- * - The envelope must have an allowlist (must be a restricted multi-sig file)
90
- * - The envelope must not already be sealed
91
- * - The key must be unlocked
92
- *
93
- * The seal hash is SHA3-512 of all current signatories + sealTimestamp,
94
- * prefixed with MAJIK_SEAL_DOMAIN. It is stored in the envelope alongside
95
- * the sealTimestamp and sealedBy fields. No new cryptographic signature
96
- * is produced — the seal is a hash-based integrity lock.
113
+ * Issuer-only / already-sealed checks are enforced by envelope.withSeal().
97
114
  */
98
115
  static seal(file: Blob, key: MajikKey, options?: ExtractOptions & {
99
116
  timestamp?: string;
@@ -103,94 +120,41 @@ export declare class MajikSignatureEmbed {
103
120
  handler: string;
104
121
  mimeType: string;
105
122
  }>;
106
- /**
107
- * Verify the seal hash against the current signatories and sealTimestamp.
108
- * Returns invalid if the envelope is not sealed.
109
- * Does NOT verify the individual cryptographic signatures — call verify() for that.
110
- */
111
123
  static verifySeal(file: Blob, options?: ExtractOptions): Promise<SealVerificationResult>;
112
- /**
113
- * Return seal metadata without verifying.
114
- * Returns null if the envelope is not sealed or has no envelope.
115
- */
116
124
  static getSealInfo(file: Blob, options?: ExtractOptions): Promise<SealInfo | null>;
117
- /**
118
- * Returns true if the file has a sealed envelope (structural check only).
119
- * Does not verify the seal hash.
120
- */
121
125
  static isSealed(file: Blob, options?: ExtractOptions): Promise<boolean>;
122
- /**
123
- * Returns true when the file has a restricted multi-sig envelope
124
- * (allowlist present with more than one expected signer).
125
- * Returns false for unsigned files, open-signing files, or single-signer files.
126
- */
127
126
  static isMultiSig(file: Blob, options?: ExtractOptions): Promise<boolean>;
128
- /**
129
- * Check whether a MajikKey is permitted to add a signature to this file.
130
- *
131
- * Returns false (with a reason) when:
132
- * - The file is sealed
133
- * - The file has an allowlist and the key is not on it (all three fields checked)
134
- *
135
- * Returns true when:
136
- * - The file has no envelope (unsigned — anyone may sign)
137
- * - The file has no allowlist (open signing — anyone may sign)
138
- * - The key is on the allowlist
139
- *
140
- * Always requires a full MajikKey — fingerprint-only checks are not supported
141
- * because they cannot verify the public key fields required by the allowlist.
142
- */
143
127
  static canSign(file: Blob, key: MajikKey, options?: ExtractOptions): Promise<{
144
128
  permitted: boolean;
145
129
  reason?: string;
146
130
  }>;
147
- /**
148
- * Core signatories method. Returns all, signed, and pending arrays.
149
- * Pass filter to narrow the return — the filtered array is still returned
150
- * inside the full SignatoriesResult so callers always have the complete picture.
151
- *
152
- * Returns null if the file has no envelope.
153
- */
154
131
  static getSignatories(file: Blob, options?: ExtractOptions, filter?: SignatoriesFilter): Promise<SignatoriesResult | null>;
155
- /**
156
- * Return the issuer (the signer who established the allowlist and controls sealing).
157
- * Returns null for open-signing files or unsigned files.
158
- */
159
132
  static getIssuer(file: Blob, options?: ExtractOptions): Promise<import("../../core/types").SignatoryInfo | null>;
160
- /**
161
- * Return a full summary of the envelope state in a single file read.
162
- * Useful for rendering UI state (badge, status, signatories list) without
163
- * making multiple separate calls.
164
- * Returns null if the file has no envelope.
165
- */
166
133
  static getEnvelopeInfo(file: Blob, options?: ExtractOptions): Promise<EnvelopeInfo | null>;
167
134
  static strip(file: Blob, options?: ExtractOptions): Promise<Blob>;
168
135
  static hasSignature(file: Blob, options?: ExtractOptions): Promise<boolean>;
169
136
  static getAllowlist(file: Blob, options?: ExtractOptions): Promise<ExpectedSigner[] | null>;
170
137
  static readonly registry: FormatHandlerRegistry;
171
138
  static listHandlers(): string[];
172
- /**
173
- * Check whether a file is eligible for chain anchoring.
174
- * Anchoring requires a sealed envelope — permitted = isSealed(file).
175
- * Chain-agnostic: does not know or care which chain the caller intends to use.
176
- */
177
139
  static canAnchor(file: Blob, options?: ExtractOptions): Promise<{
178
140
  permitted: boolean;
179
141
  reason?: string;
180
142
  }>;
181
143
  /**
182
144
  * Embed an already-confirmed chain anchor into the envelope.
183
- * Does NOT talk to any chain — purely appends a record it's handed.
184
- *
185
- * Defensive checks (beyond the master plan's minimum):
186
- * - File must be sealed
187
- * - anchor.payload.digest.value must match the envelope's current sealHash —
188
- * catches a caller accidentally embedding an anchor computed against a
189
- * stale seal (e.g. file was re-sealed between notarize() and this call)
190
- * - Upserts by anchor.id rather than blind-pushing, so a duplicate call
191
- * with the same anchor (e.g. retried after a network blip) doesn't
192
- * produce two entries for the same on-chain transaction
145
+ * Sealed check, digest match, and upsert-by-id dedup are all enforced by
146
+ * envelope.withChainAnchor().
193
147
  */
194
148
  static registerChainAnchor(file: Blob, anchor: MajikChainAnchor, options?: ExtractOptions): Promise<EmbedResult>;
195
149
  static getChainAnchors(file: Blob, options?: ExtractOptions): Promise<MajikChainAnchor[]>;
150
+ private static _prepare;
151
+ /** Extract + parse, or a fresh empty envelope when none exists. */
152
+ private static _readEnvelope;
153
+ private static _noSignatureResult;
154
+ /**
155
+ * Shared by verify() and verifyDetached(): filter by expectedSignerId,
156
+ * verify each remaining signature, and stamp the handler name onto each
157
+ * result. Previously duplicated near-verbatim in both methods.
158
+ */
159
+ private static _verifySignatures;
196
160
  }