@majikah/majik-signature 0.2.8 → 0.2.9

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/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # Majik Signature
2
2
 
3
3
  [![Developed by Zelijah](https://img.shields.io/badge/Developed%20by-Zelijah-red?logo=github&logoColor=white)](https://thezelijah.world) ![GitHub Sponsors](https://img.shields.io/github/sponsors/jedlsf?style=plastic&label=Sponsors&link=https%3A%2F%2Fgithub.com%2Fsponsors%2Fjedlsf)
4
- ![npm](https://img.shields.io/npm/v/@majikah/majik-signature) ![npm downloads](https://img.shields.io/npm/dm/@majikah/majik-signature) ![npm bundle size](https://img.shields.io/bundlephobia/min/%40majikah%2Fmajik-signature) [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) ![TypeScript](https://img.shields.io/badge/TypeScript-Ready-blue)
4
+ ![npm](https://img.shields.io/npm/v/@majikah/majik-signature) ![npm downloads](https://img.shields.io/npm/dm/@majikah/majik-signature) ![npm bundle size](https://img.shields.io/bundlephobia/min/%40majikah%2Fmajik-signature) [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) ![TypeScript](https://img.shields.io/badge/TypeScript-Ready-blue) [![Static Badge](https://img.shields.io/badge/IANA-vnd.majikah.mjksig-green)](https://www.iana.org/assignments/media-types/application/vnd.majikah.mjksig)
5
5
 
6
6
  **Majik Signature** is a hybrid post-quantum content signing and verification library for the Majikah ecosystem. Built on top of [**Majik Key**](https://www.npmjs.com/package/@majikah/majik-key), it produces tamper-evident, forgery-resistant digital signatures for any content — plaintext, JSON, PDFs, audio, video, Office documents, or raw binary — using a dual-algorithm architecture that combines classical **Ed25519** with post-quantum **ML-DSA-87** (FIPS-204).
7
7
 
@@ -196,6 +196,8 @@ Verification is fully **public** — anyone with the signer's public keys can ve
196
196
 
197
197
  ### Detached Signing & Batch Workflows
198
198
 
199
+ [![Static Badge](https://img.shields.io/badge/IANA-vnd.majikah.mjksig-green)](https://www.iana.org/assignments/media-types/application/vnd.majikah.mjksig)
200
+
199
201
  - **Detached envelopes** — sign a file and receive the envelope separately, for external verification pipelines where payload and signature travel independently
200
202
  - **Self-describing binary containers** — `.mjksig` for a single detached envelope, `.mjksmap` for a manifest covering an entire batch, each with magic bytes, a version header, and a length-prefixed payload
201
203
  - **Batch signing** — sign every file in a folder or zip in one call, packaged as one `.mjksmap` manifest or as separate `.mjksig` files per asset
@@ -20,7 +20,7 @@
20
20
  * reduced to file-format orchestration: read bytes → resolve handler →
21
21
  * extract/strip → delegate to the envelope class → re-embed.
22
22
  */
23
- import type { MajikKey } from "@majikah/majik-key";
23
+ import type { ISODateString, MajikKey } from "@majikah/majik-key";
24
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";
@@ -40,7 +40,7 @@ export interface MajikSignatureStaticAdapter {
40
40
  sign(content: Uint8Array | string, key: MajikKey, options?: SignOptions & {
41
41
  allowlistHash?: string;
42
42
  }): Promise<MajikSignatureAdapter>;
43
- verify(content: Uint8Array | string, signature: MajikSignatureAdapter | MajikSignatureJSON, publicKeys: MajikSignerPublicKeys): VerificationResult;
43
+ verify(content: Uint8Array | string, signature: MajikSignatureAdapter | MajikSignatureJSON, publicKeys: MajikSignerPublicKeys, now?: Date): VerificationResult;
44
44
  publicKeysFromMajikKey(key: MajikKey): MajikSignerPublicKeys;
45
45
  fromJSON(json: MajikSignatureJSON | string): MajikSignatureAdapter;
46
46
  }
@@ -69,8 +69,10 @@ export declare class MajikSignatureEmbed {
69
69
  */
70
70
  static signAndEmbed<T extends MajikSignatureAdapter>(file: Blob, key: MajikKey, MajikSig: MajikSignatureStaticAdapter, options?: EmbedOptions & {
71
71
  contentType?: string;
72
- timestamp?: string;
72
+ timestamp?: ISODateString;
73
73
  expectedSigners?: ExpectedSigner[];
74
+ /** ISO 8601 expiry for this signature. Omit for one that never expires. */
75
+ validUntil?: ISODateString;
74
76
  }, debug?: boolean): Promise<EmbedResult & {
75
77
  signature: T;
76
78
  envelope: MajikSignatureEnvelope;
@@ -95,7 +97,9 @@ export declare class MajikSignatureEmbed {
95
97
  */
96
98
  static signDetached<T extends MajikSignatureAdapter = MajikSignatureAdapter>(file: Blob, key: MajikKey, MajikSig: MajikSignatureStaticAdapter, options?: EmbedOptions & {
97
99
  contentType?: string;
98
- timestamp?: string;
100
+ timestamp?: ISODateString;
101
+ /** ISO 8601 expiry for this signature. Omit for one that never expires. */
102
+ validUntil?: ISODateString;
99
103
  expectedSigners?: ExpectedSigner[];
100
104
  existingEnvelope?: MajikSignatureEnvelope | MajikSignatureEnvelopeJSON | Uint8Array | Blob;
101
105
  tsa?: MajikTimestamp;
@@ -140,9 +144,11 @@ export declare class MajikSignatureEmbed {
140
144
  */
141
145
  static verify(file: Blob, publicKeys: MajikSignerPublicKeys, MajikSig: MajikSignatureStaticAdapter, options?: ExtractOptions & {
142
146
  expectedSignerId?: string;
147
+ now?: Date;
143
148
  }, debug?: boolean): Promise<VerificationResult[]>;
144
149
  static verifyWithKey(file: Blob, key: MajikKey, MajikSig: MajikSignatureStaticAdapter, options?: ExtractOptions & {
145
150
  expectedSignerId?: string;
151
+ now?: Date;
146
152
  }, debug?: boolean): Promise<VerificationResult[]>;
147
153
  /**
148
154
  * Verify a file against a provided, detached envelope (instance, blob or JSON).
@@ -151,9 +157,12 @@ export declare class MajikSignatureEmbed {
151
157
  */
152
158
  static verifyDetached(file: Blob, envelopeInput: MajikSignatureEnvelope | MajikSignatureEnvelopeJSON | Uint8Array | Blob, publicKeys: MajikSignerPublicKeys, MajikSig: MajikSignatureStaticAdapter, options?: ExtractOptions & {
153
159
  expectedSignerId?: string;
154
- }, debug?: boolean): Promise<VerificationResult[]>;
160
+ now?: Date;
161
+ }, // FIX
162
+ debug?: boolean): Promise<VerificationResult[]>;
155
163
  static verifyDetachedWithKey(file: Blob, envelopeInput: MajikSignatureEnvelope | MajikSignatureEnvelopeJSON | Uint8Array | Blob, key: MajikKey, MajikSig: MajikSignatureStaticAdapter, options?: ExtractOptions & {
156
164
  expectedSignerId?: string;
165
+ now?: Date;
157
166
  }, debug?: boolean): Promise<VerificationResult[]>;
158
167
  /**
159
168
  * Verify a batch of extracted files against a MajikSignatureMap.
@@ -37,7 +37,7 @@ import { FallbackHandler } from "./fallback";
37
37
  import { bytesToBase64, hashContent } from "../hash";
38
38
  import { MajikSignatureError, MajikSignatureValidationError } from "../errors";
39
39
  import { MajikSignatureMap } from "../mjksmap";
40
- import { verifySignatureOrder } from "../order";
40
+ import { verifySignatureOrder, } from "../order";
41
41
  // ─── Registry ─────────────────────────────────────────────────────────────────
42
42
  const DEFAULT_REGISTRY = new FormatHandlerRegistry()
43
43
  .register(new PdfHandler())
@@ -104,6 +104,7 @@ export class MajikSignatureEmbed {
104
104
  const signature = await MajikSig.sign(originalBytes, key, {
105
105
  contentType: options?.contentType,
106
106
  timestamp: options?.timestamp,
107
+ validUntil: options?.validUntil,
107
108
  ...(allowlistHashValue !== undefined
108
109
  ? { allowlistHash: allowlistHashValue }
109
110
  : {}),
@@ -161,6 +162,7 @@ export class MajikSignatureEmbed {
161
162
  const signature = await MajikSig.sign(originalBytes, key, {
162
163
  contentType: options?.contentType,
163
164
  timestamp: options?.timestamp,
165
+ validUntil: options?.validUntil,
164
166
  ...(allowlistHashValue !== undefined
165
167
  ? { allowlistHash: allowlistHashValue }
166
168
  : {}),
@@ -246,6 +248,7 @@ export class MajikSignatureEmbed {
246
248
  contentType: options?.contentType,
247
249
  timestamp: options?.timestamp,
248
250
  expectedSigners: options?.expectedSigners,
251
+ validUntil: options?.validUntil,
249
252
  }, debug);
250
253
  const sig = envelope.findSignature(key.fingerprint);
251
254
  if (!sig) {
@@ -329,7 +332,7 @@ export class MajikSignatureEmbed {
329
332
  },
330
333
  ];
331
334
  }
332
- return MajikSignatureEmbed._verifySignatures(envelope, originalBytes, publicKeys, MajikSig, handler.name, options?.expectedSignerId);
335
+ return MajikSignatureEmbed._verifySignatures(envelope, originalBytes, publicKeys, MajikSig, handler.name, options?.expectedSignerId, options?.now);
333
336
  }
334
337
  // ── verifyWithKey ──────────────────────────────────────────────────────────
335
338
  static async verifyWithKey(file, key, MajikSig, options, debug = false) {
@@ -342,7 +345,8 @@ export class MajikSignatureEmbed {
342
345
  * Still strips the file in case it also contains an embedded envelope,
343
346
  * ensuring verification runs against the clean original bytes.
344
347
  */
345
- static async verifyDetached(file, envelopeInput, publicKeys, MajikSig, options, debug = false) {
348
+ static async verifyDetached(file, envelopeInput, publicKeys, MajikSig, options, // FIX
349
+ debug = false) {
346
350
  const { bytes, handler } = await MajikSignatureEmbed._prepare(file, options);
347
351
  const envelope = await MajikSignatureEnvelope.from(envelopeInput);
348
352
  const originalBytes = await handler.strip(bytes);
@@ -359,7 +363,7 @@ export class MajikSignatureEmbed {
359
363
  },
360
364
  ];
361
365
  }
362
- return MajikSignatureEmbed._verifySignatures(envelope, originalBytes, publicKeys, MajikSig, handler.name, options?.expectedSignerId);
366
+ return MajikSignatureEmbed._verifySignatures(envelope, originalBytes, publicKeys, MajikSig, handler.name, options?.expectedSignerId, options?.now);
363
367
  }
364
368
  // ── verifyDetachedWithKey ──────────────────────────────────────────────────
365
369
  static async verifyDetachedWithKey(file, envelopeInput, key, MajikSig, options, debug = false) {
@@ -425,7 +429,7 @@ export class MajikSignatureEmbed {
425
429
  });
426
430
  continue;
427
431
  }
428
- const verifyResults = MajikSignatureEmbed._verifySignatures(envelope, originalBytes, publicKeys, MajikSig, "mjksmap", options?.expectedSignerId);
432
+ const verifyResults = MajikSignatureEmbed._verifySignatures(envelope, originalBytes, publicKeys, MajikSig, "mjksmap", options?.expectedSignerId, options?.now);
429
433
  const allValid = verifyResults.every((r) => r.valid);
430
434
  results.push({
431
435
  path: file.path,
@@ -667,7 +671,7 @@ export class MajikSignatureEmbed {
667
671
  * verify each remaining signature, and stamp the handler name onto each
668
672
  * result. Previously duplicated near-verbatim in both methods.
669
673
  */
670
- static _verifySignatures(envelope, originalBytes, publicKeys, MajikSig, handlerName, expectedSignerId) {
674
+ static _verifySignatures(envelope, originalBytes, publicKeys, MajikSig, handlerName, expectedSignerId, now) {
671
675
  const sigsToVerify = expectedSignerId
672
676
  ? envelope.signatures.filter((s) => s.signerId === expectedSignerId)
673
677
  : envelope.signatures;
@@ -683,7 +687,7 @@ export class MajikSignatureEmbed {
683
687
  ];
684
688
  }
685
689
  return sigsToVerify.map((sig) => ({
686
- ...MajikSig.verify(originalBytes, sig, publicKeys),
690
+ ...MajikSig.verify(originalBytes, sig, publicKeys, now),
687
691
  handler: handlerName,
688
692
  }));
689
693
  }
@@ -41,14 +41,16 @@ export interface PayloadFields {
41
41
  * Must be omitted entirely (not null) for all other signatures.
42
42
  */
43
43
  allowlistHash?: string;
44
+ validUntil?: string;
44
45
  }
45
46
  /**
46
47
  * Build the canonical byte payload that both algorithms sign and verify.
47
48
  * Deterministic: identical inputs always produce identical bytes.
48
49
  *
49
- * `alh` is conditionally spread — present only when allowlistHash is provided.
50
- * This is the load-bearing backward-compat guarantee: old signatures never had
51
- * `alh` in their payload, so we must not add it (even as null) when verifying them.
50
+ * `alh` and `vu` are conditionally spread — present only when allowlistHash /
51
+ * validUntil are provided. This is the load-bearing backward-compat guarantee:
52
+ * old signatures never had these keys in their payload, so we must not add
53
+ * them (even as null) when verifying old signatures.
52
54
  */
53
55
  export declare function buildSigningPayload(fields: PayloadFields): Uint8Array;
54
56
  export declare function buildTSACanonicalBytes(payload: MajikTSAPayload): Uint8Array;
@@ -29,14 +29,15 @@
29
29
  * payload bytes identical to what pre-multi-sig signers produced, so all
30
30
  * existing signatures continue to verify correctly.
31
31
  */
32
- import { MAJIK_SIGNATURE_DOMAIN, MAJIK_SIGNATURE_VERSION, MAJIK_TSA_DOMAIN } from "./constants";
32
+ import { MAJIK_SIGNATURE_DOMAIN, MAJIK_SIGNATURE_VERSION, MAJIK_TSA_DOMAIN, } from "./constants";
33
33
  /**
34
34
  * Build the canonical byte payload that both algorithms sign and verify.
35
35
  * Deterministic: identical inputs always produce identical bytes.
36
36
  *
37
- * `alh` is conditionally spread — present only when allowlistHash is provided.
38
- * This is the load-bearing backward-compat guarantee: old signatures never had
39
- * `alh` in their payload, so we must not add it (even as null) when verifying them.
37
+ * `alh` and `vu` are conditionally spread — present only when allowlistHash /
38
+ * validUntil are provided. This is the load-bearing backward-compat guarantee:
39
+ * old signatures never had these keys in their payload, so we must not add
40
+ * them (even as null) when verifying old signatures.
40
41
  */
41
42
  export function buildSigningPayload(fields) {
42
43
  const meta = JSON.stringify({
@@ -50,6 +51,9 @@ export function buildSigningPayload(fields) {
50
51
  ...(fields.allowlistHash !== undefined
51
52
  ? { alh: fields.allowlistHash }
52
53
  : {}),
54
+ // Conditionally include vu — same reasoning: omit entirely so all
55
+ // pre-expiry signatures reproduce byte-identical payloads (backward compat).
56
+ ...(fields.validUntil !== undefined ? { vu: fields.validUntil } : {}),
53
57
  });
54
58
  const prefix = new TextEncoder().encode(MAJIK_SIGNATURE_DOMAIN);
55
59
  const body = new TextEncoder().encode(meta);
@@ -2,12 +2,14 @@
2
2
  * types.ts
3
3
  * Public types for the MajikSignature library.
4
4
  */
5
- import { ISODateString } from "@majikah/majik-key";
6
- import { MajikChainAnchor } from "../anchor/types";
5
+ import type { ISODateString, MajikKeyFingerprint } from "@majikah/majik-key";
6
+ import type { MajikChainAnchor } from "../anchor/types";
7
7
  import type { ContentType } from "./constants";
8
8
  import type { MajikSignatureEnvelope } from "./envelope";
9
9
  export type { ContentType };
10
10
  export type MajikSignatureEnvelopeJSON = MultiSigEnvelope;
11
+ export type ED25519Signature = string;
12
+ export type MLDSA87Signature = string;
11
13
  /**
12
14
  * The serializable per-signer signature envelope.
13
15
  * Everything a verifier needs — no private keys required.
@@ -16,7 +18,7 @@ export interface MajikSignatureJSON {
16
18
  /** Envelope version — must equal MAJIK_SIGNATURE_VERSION */
17
19
  version: 1;
18
20
  /** MajikKey fingerprint (SHA-256 of X25519 public key, base64) */
19
- signerId: string;
21
+ signerId: MajikKeyFingerprint;
20
22
  /** Ed25519 public key, base64 (32 bytes) */
21
23
  signerEdPublicKey: string;
22
24
  /** ML-DSA-87 public key, base64 (2592 bytes) */
@@ -26,11 +28,11 @@ export interface MajikSignatureJSON {
26
28
  /** Advisory content type — e.g. "audio/wav", "application/pdf" */
27
29
  contentType?: string;
28
30
  /** ISO 8601 timestamp of when the signature was created */
29
- timestamp: string;
31
+ timestamp: ISODateString;
30
32
  /** Ed25519 signature over the canonical payload, base64 (64 bytes) */
31
- edSignature: string;
33
+ edSignature: ED25519Signature;
32
34
  /** ML-DSA-87 signature over the canonical payload, base64 (4595 bytes) */
33
- mlDsaSignature: string;
35
+ mlDsaSignature: MLDSA87Signature;
34
36
  /**
35
37
  * SHA-256 hash of the canonical allowlist JSON, base64 (44 chars).
36
38
  * Present only on the envelope of the signer who established the allowlist.
@@ -39,6 +41,14 @@ export interface MajikSignatureJSON {
39
41
  * Absent on all other signers and on any signature made before multi-sig support.
40
42
  */
41
43
  allowlistHash?: string;
44
+ /**
45
+ * Optional ISO 8601 expiry. When present, verify() treats the signature
46
+ * as invalid once the current time is past this value. Absent = never
47
+ * expires (matches all pre-existing signatures — fully backward compatible).
48
+ * Covered by the canonical signing payload when present, so it cannot be
49
+ * stripped or extended post-hoc without breaking both signatures.
50
+ */
51
+ validUntil?: ISODateString;
42
52
  tsa?: MajikTimestamp;
43
53
  }
44
54
  export interface MajikTSAPayload {
@@ -65,13 +75,14 @@ export interface MajikTSAPayload {
65
75
  */
66
76
  export interface MajikSignatureCompactJSON {
67
77
  v: 1;
68
- signerId: string;
78
+ signerId: MajikKeyFingerprint;
69
79
  contentHash: string;
70
80
  contentType?: string;
71
- timestamp: string;
72
- edSignature: string;
73
- mlDsaSignature: string;
81
+ timestamp: ISODateString;
82
+ edSignature: ED25519Signature;
83
+ mlDsaSignature: MLDSA87Signature;
74
84
  allowlistHash?: string;
85
+ validUntil?: ISODateString;
75
86
  }
76
87
  export interface MajikTSARequest {
77
88
  digest: {
@@ -91,7 +102,7 @@ export interface MajikTimestamp {
91
102
  */
92
103
  export interface ExpectedSigner {
93
104
  /** MajikKey fingerprint (SHA-256 of X25519 public key, base64) */
94
- signerId: string;
105
+ signerId: MajikKeyFingerprint;
95
106
  /** Ed25519 public key, base64 (32 bytes) */
96
107
  edPublicKey: string;
97
108
  /** ML-DSA-87 public key, base64 (2592 bytes) */
@@ -123,7 +134,7 @@ export interface MultiSigEnvelope {
123
134
  * their signature verification.
124
135
  * Absent when allowlist is absent.
125
136
  */
126
- allowlistSignerId?: string;
137
+ allowlistSignerId?: MajikKeyFingerprint;
127
138
  /** All per-signer envelopes. One entry per signer, keyed logically by signerId. */
128
139
  signatures: MajikSignatureJSON[];
129
140
  /**
@@ -137,7 +148,7 @@ export interface MultiSigEnvelope {
137
148
  * ISO 8601 timestamp of when the seal was applied.
138
149
  * Included in the seal hash input — changing this breaks the seal.
139
150
  */
140
- sealTimestamp?: string;
151
+ sealTimestamp?: ISODateString;
141
152
  /**
142
153
  * Fingerprint of the signer who applied the seal.
143
154
  * Must equal allowlistSignerId — only the issuer can seal.
@@ -151,7 +162,7 @@ export interface MultiSigEnvelope {
151
162
  */
152
163
  export interface MajikSignerPublicKeys {
153
164
  /** MajikKey fingerprint */
154
- signerId: string;
165
+ signerId: MajikKeyFingerprint;
155
166
  /** Ed25519 public key bytes (32 bytes) */
156
167
  edPublicKey: Uint8Array;
157
168
  /** ML-DSA-87 public key bytes (2592 bytes) */
@@ -164,7 +175,7 @@ export interface SignOptions {
164
175
  /** Advisory content type label */
165
176
  contentType?: string;
166
177
  /** Override timestamp (useful for deterministic tests) */
167
- timestamp?: string;
178
+ timestamp?: ISODateString;
168
179
  /**
169
180
  * Restrict future signers to these keys only.
170
181
  * Only honoured when this is the first signature on a file (no existing
@@ -173,20 +184,28 @@ export interface SignOptions {
173
184
  * Each entry must include signerId + edPublicKey + mlDsaPublicKey (base64).
174
185
  */
175
186
  expectedSigners?: ExpectedSigner[];
187
+ /**
188
+ * ISO 8601 timestamp. If set, verify() will fail with an "expired" reason
189
+ * once the current time passes this value. Optional — omit for a
190
+ * signature that never expires.
191
+ */
192
+ validUntil?: string;
176
193
  }
177
194
  /**
178
195
  * Result returned by MajikSignature.verify() and all file-level verify methods.
179
196
  */
180
197
  export interface VerificationResult {
181
198
  valid: boolean;
182
- signerId?: string;
199
+ signerId?: MajikKeyFingerprint;
183
200
  contentHash?: string;
184
- timestamp: string;
201
+ timestamp: ISODateString;
185
202
  contentType?: string;
186
203
  /** Present when result came from a file verify — which handler processed it */
187
204
  handler?: string;
188
205
  /** Present when valid is false — human-readable failure reason */
189
206
  reason?: string;
207
+ /** True only when the sole reason valid=false is expiry (crypto checked out fine). */
208
+ expired?: boolean;
190
209
  }
191
210
  /**
192
211
  * Result returned by verifySeal().
@@ -195,7 +214,7 @@ export interface SealVerificationResult {
195
214
  /** Whether the seal hash is valid and matches all current signatories */
196
215
  valid: boolean;
197
216
  /** Fingerprint of who sealed the envelope */
198
- sealedBy?: string;
217
+ sealedBy?: MajikKeyFingerprint;
199
218
  /** ISO 8601 timestamp of when the seal was applied */
200
219
  sealTimestamp?: string;
201
220
  /** Human-readable failure reason when valid is false */
@@ -209,9 +228,9 @@ export interface SealInfo {
209
228
  /** SHA3-512 hash of the canonical seal payload, hex-encoded (128 chars) */
210
229
  sealHash: string;
211
230
  /** ISO 8601 timestamp of when the seal was applied */
212
- sealTimestamp: string;
231
+ sealTimestamp: ISODateString;
213
232
  /** Fingerprint of the issuer who applied the seal */
214
- sealedBy: string;
233
+ sealedBy: MajikKeyFingerprint;
215
234
  }
216
235
  /**
217
236
  * Full information about a single signatory.
@@ -219,7 +238,7 @@ export interface SealInfo {
219
238
  */
220
239
  export interface SignatoryInfo {
221
240
  /** MajikKey fingerprint */
222
- signerId: string;
241
+ signerId: MajikKeyFingerprint;
223
242
  /** Ed25519 public key, base64 */
224
243
  edPublicKey: string;
225
244
  /** ML-DSA-87 public key, base64 */
@@ -227,7 +246,7 @@ export interface SignatoryInfo {
227
246
  /** Whether this signatory has already signed */
228
247
  hasSigned: boolean;
229
248
  /** ISO 8601 timestamp of their signature — present only when hasSigned is true */
230
- signedAt?: string;
249
+ signedAt?: ISODateString;
231
250
  }
232
251
  /**
233
252
  * Result returned by getSignatories() and its aliases.
@@ -357,8 +376,14 @@ export interface BatchFileInput {
357
376
  }
358
377
  export interface BatchSignOptions {
359
378
  contentType?: string;
360
- timestamp?: string;
379
+ timestamp?: ISODateString;
361
380
  expectedSigners?: ExpectedSigner[];
381
+ /**
382
+ * ISO 8601 expiry applied to every signature in the batch. Optional —
383
+ * omit for signatures that never expire. Same field, same semantics as
384
+ * SignOptions.validUntil, just applied uniformly across the batch.
385
+ */
386
+ validUntil?: ISODateString;
362
387
  /** "map" (default) produces one MajikSignatureMap covering the whole
363
388
  * batch. "separate" produces one .mjksig Blob per file. */
364
389
  mode?: "map" | "separate";
@@ -405,7 +430,12 @@ export interface FileVerifyResult {
405
430
  relocatedFrom?: string;
406
431
  }
407
432
  export interface BatchVerifyOptions {
408
- expectedSignerId?: string;
433
+ expectedSignerId?: MajikKeyFingerprint;
434
+ /**
435
+ * Time to check validUntil against. Defaults to the current time.
436
+ * Every per-file verification in the batch uses this same value.
437
+ */
438
+ now?: Date;
409
439
  /**
410
440
  * If true, a file with status "not_in_map" is a hard error for the whole
411
441
  * batch call (throws). Default false — missing files are reported per-file
@@ -2,6 +2,7 @@
2
2
  * validator.ts
3
3
  * Input validation and assertion helpers for MajikSignature.
4
4
  */
5
+ import { ISODateString } from "@majikah/majik-key";
5
6
  import type { ExpectedSigner, MajikSignatureJSON, MajikSignerPublicKeys, MajikTimestamp, MajikTSAPayload, MultiSigEnvelope } from "./types";
6
7
  export declare class MajikSignatureValidator {
7
8
  static assert(condition: unknown, message: string, field?: string): asserts condition;
@@ -22,6 +23,7 @@ export declare class MajikSignatureValidator {
22
23
  */
23
24
  static validateSeal(env: MultiSigEnvelope): void;
24
25
  static validateSealHash(hash: string): void;
26
+ static validateValidUntil(value: ISODateString | undefined): void;
25
27
  static validateMultiSigEnvelope(env: unknown): asserts env is MultiSigEnvelope;
26
28
  static validateMajikTSAPayload(payload: unknown): asserts payload is MajikTSAPayload;
27
29
  static validateMajikTimestamp(tsa: unknown): asserts tsa is MajikTimestamp;
@@ -153,6 +153,13 @@ export class MajikSignatureValidator {
153
153
  if (typeof hash !== "string" || hash.length !== SEAL_HASH_HEX_LEN)
154
154
  throw new MajikSignatureValidationError(`sealHash must be exactly ${SEAL_HASH_HEX_LEN} hex chars (SHA3-512)`, "sealHash");
155
155
  }
156
+ static validateValidUntil(value) {
157
+ if (value === undefined)
158
+ return;
159
+ if (typeof value !== "string" || Number.isNaN(Date.parse(value))) {
160
+ throw new MajikSignatureValidationError("validUntil must be a valid ISO 8601 timestamp string", "validUntil");
161
+ }
162
+ }
156
163
  // ── MultiSigEnvelope validator ───────────────────────────────────────────────
157
164
  static validateMultiSigEnvelope(env) {
158
165
  if (typeof env !== "object" || env === null)
@@ -3,8 +3,8 @@
3
3
  * MajikSignature — hybrid Ed25519 + ML-DSA-87 content signing and verification.
4
4
  *
5
5
  */
6
- import type { MajikKey } from "@majikah/majik-key";
7
- import type { BatchFileInput, BatchSignOptions, BatchVerifyInput, BatchVerifyOptions, EnvelopeInfo, ExpectedSigner, FileVerifyResult, MajikSignatureCompactJSON, MajikSignatureEnvelopeJSON, MajikSignatureJSON, MajikSignerPublicKeys, MajikTimestamp, MajikTSARequest, SealInfo, SealVerificationResult, SignatoriesFilter, SignatoriesResult, SignatoryInfo, SignOptions, VerificationResult } from "./core/types";
6
+ import type { ISODateString, MajikKey, MajikKeyFingerprint } from "@majikah/majik-key";
7
+ import type { BatchFileInput, BatchSignOptions, BatchVerifyInput, BatchVerifyOptions, ED25519Signature, EnvelopeInfo, ExpectedSigner, FileVerifyResult, MajikSignatureCompactJSON, MajikSignatureEnvelopeJSON, MajikSignatureJSON, MajikSignerPublicKeys, MajikTimestamp, MajikTSARequest, MLDSA87Signature, 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";
@@ -42,10 +42,21 @@ import { SignatureOrderResult } from "./core/order";
42
42
  * - Sealing computes a SHA3-512 hash over all current signatories + timestamp
43
43
  * - A sealed envelope rejects all further signing attempts
44
44
  *
45
+ * Expiry:
46
+ * - A signer may optionally set validUntil (ISO 8601) when signing
47
+ * - Cryptographically committed to via the canonical payload — the field
48
+ * cannot be added, stripped, or extended post-hoc without breaking both
49
+ * Ed25519 and ML-DSA-87
50
+ * - verify() checks expiry only AFTER both signatures pass, so an
51
+ * expired-but-forged signature is reported as invalid, not "expired"
52
+ * - Absent = never expires, matching every signature that predates this
53
+ * feature — fully backward compatible, no migration needed
54
+ *
45
55
  * Canonical payload:
46
- * "majik-signature-v1:" + JSON({ v, id, ts, ct, hash[, alh] })
47
- * where hash = SHA-256(content), alh = SHA-256(canonical allowlist) — omitted
48
- * when no allowlist is set, preserving backward compat with old signatures.
56
+ * "majik-signature-v1:" + JSON({ v, id, ts, ct, hash[, alh][, vu] })
57
+ * where hash = SHA-256(content), alh = SHA-256(canonical allowlist), and
58
+ * vu = validUntil — each omitted (not nulled) when unset, preserving
59
+ * backward compat with signatures made before that field existed.
49
60
  */
50
61
  export declare class MajikSignature {
51
62
  private readonly _version;
@@ -58,30 +69,50 @@ export declare class MajikSignature {
58
69
  private readonly _edSignature;
59
70
  private readonly _mlDsaSignature;
60
71
  private readonly _allowlistHash?;
72
+ private readonly _validUntil?;
61
73
  private _tsa?;
62
74
  private constructor();
63
75
  get version(): 1;
64
- get signerId(): string;
76
+ get signerId(): MajikKeyFingerprint;
65
77
  get signerEdPublicKey(): string;
66
78
  get signerMlDsaPublicKey(): string;
67
79
  get contentHash(): string;
68
80
  get contentType(): string | undefined;
69
- get timestamp(): string;
70
- get edSignature(): string;
71
- get mlDsaSignature(): string;
81
+ get timestamp(): ISODateString;
82
+ get edSignature(): ED25519Signature;
83
+ get mlDsaSignature(): MLDSA87Signature;
72
84
  /**
73
85
  * SHA-256 hash of the canonical allowlist, base64.
74
86
  * Present only on the envelope of the signer who established the allowlist.
75
87
  * Undefined on all other signers and on pre-allowlist signatures.
76
88
  */
77
89
  get allowlistHash(): string | undefined;
90
+ /**
91
+ * ISO 8601 timestamp after which this signature is considered expired by
92
+ * verify(). Undefined means the signature never expires — this is the case
93
+ * for every signature created before expiry support existed.
94
+ */
95
+ get validUntil(): ISODateString | undefined;
78
96
  get tsa(): MajikTimestamp | undefined;
97
+ /**
98
+ * Structural expiry check against this signature's validUntil — does NOT
99
+ * verify the cryptographic signature. Always false when validUntil is unset.
100
+ * Use verify() for a full trust decision; use this for a quick UI-facing
101
+ * "is this stale" check without needing the original content or public keys.
102
+ *
103
+ * @param now - Time to check against. Defaults to the current time.
104
+ */
105
+ isExpired(now?: Date): boolean;
79
106
  /**
80
107
  * Sign content with an unlocked MajikKey.
81
108
  *
82
109
  * Both Ed25519 and ML-DSA-87 sign the same canonical payload.
83
110
  * When options.allowlistHash is provided (injected internally by signAndEmbed),
84
111
  * it is included in the payload so both algorithms cover the allowlist.
112
+ * When options.validUntil is provided, it is likewise included in the payload
113
+ * so both algorithms cover the expiry — a verifier cannot strip or extend it
114
+ * without invalidating the signature. Omit it for a signature that never
115
+ * expires.
85
116
  */
86
117
  static sign(content: Uint8Array | string, key: MajikKey, options?: SignOptions & {
87
118
  allowlistHash?: string;
@@ -89,8 +120,20 @@ export declare class MajikSignature {
89
120
  /**
90
121
  * Verify a MajikSignature against content and the signer's public keys.
91
122
  * Both Ed25519 AND ML-DSA-87 must verify — if either fails, returns invalid.
123
+ *
124
+ * If the envelope carries a validUntil, expiry is checked only after both
125
+ * signatures have already passed — so a forged-but-expired signature is
126
+ * reported as an invalid signature (wrong reason), never mistaken for a
127
+ * merely-expired-but-otherwise-valid one. A signature with no validUntil
128
+ * never expires.
129
+ *
130
+ * @param now - Time to check expiry against. Defaults to the current time;
131
+ * override for deterministic tests or to verify "as of" a past/future moment.
132
+ * @returns valid: false with reason "Signature expired at ..." and
133
+ * expired: true when the only failure is expiry; expired is omitted
134
+ * otherwise.
92
135
  */
93
- static verify(content: Uint8Array | string, signature: MajikSignature | MajikSignatureJSON, publicKeys: MajikSignerPublicKeys): VerificationResult;
136
+ static verify(content: Uint8Array | string, signature: MajikSignature | MajikSignatureJSON, publicKeys: MajikSignerPublicKeys, now?: Date): VerificationResult;
94
137
  validate(): void;
95
138
  isValid(): boolean;
96
139
  extractPublicKeys(): MajikSignerPublicKeys;
@@ -121,12 +164,19 @@ export declare class MajikSignature {
121
164
  * Non-allowlisted signers are rejected before any crypto (MajikSignatureAllowlistError).
122
165
  * Sealed files are always rejected.
123
166
  *
167
+ * Pass options.validUntil (ISO 8601) to make this specific signature expire —
168
+ * cryptographically bound into the payload, so it cannot be stripped or
169
+ * extended without invalidating the signature. Omit for a signature that
170
+ * never expires. In multi-sig files, expiry is per-signer: each signer's
171
+ * validUntil (or absence of one) applies only to their own entry.
172
+ *
124
173
  * @example
125
174
  * const { blob } = await MajikSignature.signFile(file, aliceKey, {
126
175
  * expectedSigners: [
127
176
  * MajikSignature.expectedSignerFromKey(aliceKey),
128
177
  * MajikSignature.expectedSignerFromKey(bobKey),
129
178
  * ],
179
+ * validUntil: "2027-01-01T00:00:00.000Z",
130
180
  * });
131
181
  */
132
182
  static signFile(file: Blob, key: MajikKey, options?: {
@@ -134,6 +184,7 @@ export declare class MajikSignature {
134
184
  timestamp?: string;
135
185
  mimeType?: string;
136
186
  expectedSigners?: ExpectedSigner[];
187
+ validUntil?: string;
137
188
  }): ReturnType<typeof MajikSignatureEmbed.signAndEmbed<MajikSignature>>;
138
189
  /**
139
190
  * Sign a file and return the signature envelope detached.
@@ -146,10 +197,15 @@ export declare class MajikSignature {
146
197
  * it's added to the envelope — the digest-match and TSA-signature checks
147
198
  * happen automatically inside addTSA().
148
199
  *
200
+ * Pass options.validUntil (ISO 8601) to make this signature expire — see
201
+ * signFile() for the same semantics; identical here since both funnel
202
+ * through the same underlying sign() call.
203
+ *
149
204
  * @example
150
205
  * const { blob, envelope, signature } = await MajikSignature.signFileDetached(file, aliceKey, {
151
- * existingEnvelope: outOfBandEnvelope, // Optionally pass state from an external source
152
- * tsa: myTsaTimestamp, // Optionally attach a Trusted Timestamp
206
+ * existingEnvelope: outOfBandEnvelope,
207
+ * tsa: myTsaTimestamp,
208
+ * validUntil: "2027-01-01T00:00:00.000Z",
153
209
  * });
154
210
  * console.log(signature.hasTSA); // true if tsa was provided and accepted
155
211
  */
@@ -158,6 +214,7 @@ export declare class MajikSignature {
158
214
  timestamp?: string;
159
215
  mimeType?: string;
160
216
  expectedSigners?: ExpectedSigner[];
217
+ validUntil?: string;
161
218
  existingEnvelope?: MajikSignatureEnvelope | MajikSignatureEnvelopeJSON | Uint8Array | Blob;
162
219
  tsa?: MajikTimestamp;
163
220
  }): ReturnType<typeof MajikSignatureEmbed.signDetached<MajikSignature>>;
@@ -167,6 +224,10 @@ export declare class MajikSignature {
167
224
  * batch (default — meant to sit at the root of the zip as one .mjksmap),
168
225
  * or as separate .mjksig Blobs per file when options.mode === "separate".
169
226
  *
227
+ * options.validUntil, if set, is applied identically to every signature in
228
+ * the batch — there is no per-file override. Sign files individually via
229
+ * signFileDetached() if different files need different expiries.
230
+ *
170
231
  * @example
171
232
  * const result = await MajikSignature.signBatchDetached(
172
233
  * [
@@ -174,6 +235,7 @@ export declare class MajikSignature {
174
235
  * { path: "docs/appendix.pdf", blob: appendixBlob },
175
236
  * ],
176
237
  * aliceKey,
238
+ * { validUntil: "2027-01-01T00:00:00.000Z" },
177
239
  * );
178
240
  * if (result.mode === "map") {
179
241
  * zip.file("signatures.mjksmap", await result.mapBlob.arrayBuffer());
@@ -184,20 +246,27 @@ export declare class MajikSignature {
184
246
  * Verify a file's embedded signatures.
185
247
  * Returns one VerificationResult per signer. Old single-sig files return a single-item array.
186
248
  * Pass options.expectedSignerId to verify only a specific signer.
249
+ * Pass options.now to check expiry as of a specific time instead of the
250
+ * current time. Any signer whose validUntil has passed comes back with
251
+ * valid: false, expired: true, and a reason noting the expiry.
187
252
  */
188
253
  static verifyFile(file: Blob, keyOrPublicKeys: MajikKey | MajikSignerPublicKeys, options?: {
189
254
  expectedSignerId?: string;
190
255
  mimeType?: string;
256
+ now?: Date;
191
257
  }, debug?: boolean): Promise<VerificationResult[]>;
192
258
  /**
193
259
  * Verify a file against a detached signature envelope.
194
260
  * Skips extraction and verifies the stripped file bytes directly against the provided envelope.
195
261
  * Returns one VerificationResult per signer.
196
262
  * Pass options.expectedSignerId to verify only a specific signer.
263
+ * Pass options.now to check expiry as of a specific time instead of the
264
+ * current time.
197
265
  */
198
266
  static verifyFileDetached(file: Blob, envelope: MajikSignatureEnvelope | MajikSignatureEnvelopeJSON | Uint8Array | Blob, keyOrPublicKeys: MajikKey | MajikSignerPublicKeys, options?: {
199
267
  expectedSignerId?: string;
200
268
  mimeType?: string;
269
+ now?: Date;
201
270
  }, debug?: boolean): Promise<VerificationResult[]>;
202
271
  /**
203
272
  * Verify a batch of extracted files against a MajikSignatureMap (loaded via
@@ -205,6 +274,11 @@ export declare class MajikSignature {
205
274
  * throwing — a missing, tampered, or invalidly-signed file is a normal
206
275
  * possible outcome to display, not an exceptional one to catch.
207
276
  *
277
+ * A file whose signature has passed its validUntil comes back with
278
+ * status "invalid" and results[].expired === true — same status as any
279
+ * other failed signature, so summarizeBatchVerification()'s allValid check
280
+ * still works unchanged. Pass options.now to check "as of" a specific time.
281
+ *
208
282
  * @example
209
283
  * const map = await MajikSignatureMap.fromMJKSMAP(mjksmapBlob);
210
284
  * const results = await MajikSignature.verifyFilesFromMjksMap(
@@ -505,12 +579,21 @@ export declare class MajikSignature {
505
579
  * The verifier must supply the signer's public keys out-of-band via
506
580
  * fromCompact() / verifyCompact() — never trust keys recovered from the
507
581
  * compact payload itself, because there are none.
582
+ *
583
+ * allowlistHash and validUntil, when present, carry over unchanged — both
584
+ * are already part of what was signed, so compacting doesn't affect their
585
+ * enforcement.
508
586
  */
509
587
  toCompact(): MajikSignatureCompactJSON;
510
588
  /**
511
589
  * Rehydrate a full MajikSignature from a compact payload + externally
512
590
  * resolved public keys. Throws if signerId doesn't match the supplied keys —
513
591
  * this is a cheap sanity check, not a substitute for verify().
592
+ *
593
+ * allowlistHash and validUntil are carried through unchanged from the
594
+ * compact payload; verify() will still enforce them via the recomputed
595
+ * canonical payload, so an out-of-band edit to either field here would
596
+ * simply fail verification rather than being silently trusted.
514
597
  */
515
598
  static fromCompact(compact: MajikSignatureCompactJSON, publicKeys: Pick<MajikSignerPublicKeys, "edPublicKey" | "mlDsaPublicKey">): MajikSignature;
516
599
  /**
@@ -518,6 +601,11 @@ export declare class MajikSignature {
518
601
  * publicKeys.signerId before any crypto runs, so a mismatched lookup fails
519
602
  * fast with a clear reason instead of a cryptic signature failure.
520
603
  *
604
+ * Delegates to verify() after rehydration, so allowlistHash and validUntil
605
+ * (when present in the compact payload) are enforced identically to the
606
+ * full-envelope path — including the "expired" reason/flag on a signature
607
+ * past its validUntil.
608
+ *
521
609
  * @example
522
610
  * const keys = await resolvePublicKeysForMuid(slink.muid); // your registry
523
611
  * const result = MajikSignature.verifyCompact(canonical, slink.signatureJSON, keys);
@@ -46,10 +46,21 @@ const secureFill = Uint8Array.prototype.fill;
46
46
  * - Sealing computes a SHA3-512 hash over all current signatories + timestamp
47
47
  * - A sealed envelope rejects all further signing attempts
48
48
  *
49
+ * Expiry:
50
+ * - A signer may optionally set validUntil (ISO 8601) when signing
51
+ * - Cryptographically committed to via the canonical payload — the field
52
+ * cannot be added, stripped, or extended post-hoc without breaking both
53
+ * Ed25519 and ML-DSA-87
54
+ * - verify() checks expiry only AFTER both signatures pass, so an
55
+ * expired-but-forged signature is reported as invalid, not "expired"
56
+ * - Absent = never expires, matching every signature that predates this
57
+ * feature — fully backward compatible, no migration needed
58
+ *
49
59
  * Canonical payload:
50
- * "majik-signature-v1:" + JSON({ v, id, ts, ct, hash[, alh] })
51
- * where hash = SHA-256(content), alh = SHA-256(canonical allowlist) — omitted
52
- * when no allowlist is set, preserving backward compat with old signatures.
60
+ * "majik-signature-v1:" + JSON({ v, id, ts, ct, hash[, alh][, vu] })
61
+ * where hash = SHA-256(content), alh = SHA-256(canonical allowlist), and
62
+ * vu = validUntil — each omitted (not nulled) when unset, preserving
63
+ * backward compat with signatures made before that field existed.
53
64
  */
54
65
  export class MajikSignature {
55
66
  _version;
@@ -62,6 +73,7 @@ export class MajikSignature {
62
73
  _edSignature;
63
74
  _mlDsaSignature;
64
75
  _allowlistHash;
76
+ _validUntil;
65
77
  _tsa;
66
78
  constructor(data) {
67
79
  this._version = data.version;
@@ -74,6 +86,7 @@ export class MajikSignature {
74
86
  this._edSignature = data.edSignature;
75
87
  this._mlDsaSignature = data.mlDsaSignature;
76
88
  this._allowlistHash = data.allowlistHash;
89
+ this._validUntil = data.validUntil;
77
90
  this._tsa = data.tsa;
78
91
  }
79
92
  // ── Getters ─────────────────────────────────────────────────────────────────
@@ -112,9 +125,29 @@ export class MajikSignature {
112
125
  get allowlistHash() {
113
126
  return this._allowlistHash;
114
127
  }
128
+ /**
129
+ * ISO 8601 timestamp after which this signature is considered expired by
130
+ * verify(). Undefined means the signature never expires — this is the case
131
+ * for every signature created before expiry support existed.
132
+ */
133
+ get validUntil() {
134
+ return this._validUntil;
135
+ }
115
136
  get tsa() {
116
137
  return this._tsa;
117
138
  }
139
+ /**
140
+ * Structural expiry check against this signature's validUntil — does NOT
141
+ * verify the cryptographic signature. Always false when validUntil is unset.
142
+ * Use verify() for a full trust decision; use this for a quick UI-facing
143
+ * "is this stale" check without needing the original content or public keys.
144
+ *
145
+ * @param now - Time to check against. Defaults to the current time.
146
+ */
147
+ isExpired(now = new Date()) {
148
+ return (this._validUntil !== undefined &&
149
+ now.getTime() > Date.parse(this._validUntil));
150
+ }
118
151
  // ── SIGN ────────────────────────────────────────────────────────────────────
119
152
  /**
120
153
  * Sign content with an unlocked MajikKey.
@@ -122,11 +155,16 @@ export class MajikSignature {
122
155
  * Both Ed25519 and ML-DSA-87 sign the same canonical payload.
123
156
  * When options.allowlistHash is provided (injected internally by signAndEmbed),
124
157
  * it is included in the payload so both algorithms cover the allowlist.
158
+ * When options.validUntil is provided, it is likewise included in the payload
159
+ * so both algorithms cover the expiry — a verifier cannot strip or extend it
160
+ * without invalidating the signature. Omit it for a signature that never
161
+ * expires.
125
162
  */
126
163
  static async sign(content, key, options, debug = false) {
127
164
  MajikSignatureValidator.validateContent(content);
128
165
  MajikSignatureValidator.assertDefined(key, "key");
129
166
  MajikSignatureValidator.validateContentType(options?.contentType);
167
+ MajikSignatureValidator.validateValidUntil(options?.validUntil);
130
168
  if (key.isLocked)
131
169
  throw new MajikSignatureKeyError("MajikKey is locked. Call unlock() before signing.");
132
170
  if (!key.hasSigningKeys)
@@ -148,12 +186,14 @@ export class MajikSignature {
148
186
  const signerId = key.fingerprint;
149
187
  const contentType = options?.contentType;
150
188
  const allowlistHash = options?.allowlistHash;
189
+ const validUntil = options?.validUntil; // NEW
151
190
  const payload = buildSigningPayload({
152
191
  signerId,
153
192
  timestamp,
154
193
  contentHash,
155
194
  contentType,
156
195
  allowlistHash,
196
+ validUntil,
157
197
  });
158
198
  if (debug)
159
199
  console.log("Signing Payload:", payload);
@@ -175,6 +215,7 @@ export class MajikSignature {
175
215
  edSignature: bytesToBase64(edSigBytes),
176
216
  mlDsaSignature: bytesToBase64(mlDsaSigBytes),
177
217
  ...(allowlistHash !== undefined ? { allowlistHash } : {}),
218
+ ...(validUntil !== undefined ? { validUntil } : {}), // NEW
178
219
  };
179
220
  return new MajikSignature(envelope);
180
221
  }
@@ -201,8 +242,20 @@ export class MajikSignature {
201
242
  /**
202
243
  * Verify a MajikSignature against content and the signer's public keys.
203
244
  * Both Ed25519 AND ML-DSA-87 must verify — if either fails, returns invalid.
245
+ *
246
+ * If the envelope carries a validUntil, expiry is checked only after both
247
+ * signatures have already passed — so a forged-but-expired signature is
248
+ * reported as an invalid signature (wrong reason), never mistaken for a
249
+ * merely-expired-but-otherwise-valid one. A signature with no validUntil
250
+ * never expires.
251
+ *
252
+ * @param now - Time to check expiry against. Defaults to the current time;
253
+ * override for deterministic tests or to verify "as of" a past/future moment.
254
+ * @returns valid: false with reason "Signature expired at ..." and
255
+ * expired: true when the only failure is expiry; expired is omitted
256
+ * otherwise.
204
257
  */
205
- static verify(content, signature, publicKeys) {
258
+ static verify(content, signature, publicKeys, now = new Date()) {
206
259
  try {
207
260
  MajikSignatureValidator.validateContent(content);
208
261
  MajikSignatureValidator.validateSignerPublicKeys(publicKeys);
@@ -226,6 +279,7 @@ export class MajikSignature {
226
279
  contentHash: env.contentHash,
227
280
  contentType: env.contentType,
228
281
  allowlistHash: env.allowlistHash,
282
+ validUntil: env.validUntil,
229
283
  });
230
284
  let edOk;
231
285
  try {
@@ -247,6 +301,14 @@ export class MajikSignature {
247
301
  }
248
302
  if (!mlDsaOk)
249
303
  return invalid("Invalid ML-DSA-87 signature");
304
+ // NEW — expiry check, only after crypto has checked out
305
+ if (env.validUntil !== undefined &&
306
+ now.getTime() > Date.parse(env.validUntil)) {
307
+ return {
308
+ ...invalid(`Signature expired at ${env.validUntil} (verified at ${now.toISOString()})`),
309
+ expired: true,
310
+ };
311
+ }
250
312
  return {
251
313
  valid: true,
252
314
  signerId: env.signerId,
@@ -304,6 +366,9 @@ export class MajikSignature {
304
366
  ...(this._allowlistHash !== undefined
305
367
  ? { allowlistHash: this._allowlistHash }
306
368
  : {}),
369
+ ...(this._validUntil !== undefined
370
+ ? { validUntil: this._validUntil }
371
+ : {}),
307
372
  ...(this._tsa !== undefined ? { tsa: this._tsa } : {}),
308
373
  };
309
374
  }
@@ -394,12 +459,19 @@ export class MajikSignature {
394
459
  * Non-allowlisted signers are rejected before any crypto (MajikSignatureAllowlistError).
395
460
  * Sealed files are always rejected.
396
461
  *
462
+ * Pass options.validUntil (ISO 8601) to make this specific signature expire —
463
+ * cryptographically bound into the payload, so it cannot be stripped or
464
+ * extended without invalidating the signature. Omit for a signature that
465
+ * never expires. In multi-sig files, expiry is per-signer: each signer's
466
+ * validUntil (or absence of one) applies only to their own entry.
467
+ *
397
468
  * @example
398
469
  * const { blob } = await MajikSignature.signFile(file, aliceKey, {
399
470
  * expectedSigners: [
400
471
  * MajikSignature.expectedSignerFromKey(aliceKey),
401
472
  * MajikSignature.expectedSignerFromKey(bobKey),
402
473
  * ],
474
+ * validUntil: "2027-01-01T00:00:00.000Z",
403
475
  * });
404
476
  */
405
477
  static async signFile(file, key, options) {
@@ -416,10 +488,15 @@ export class MajikSignature {
416
488
  * it's added to the envelope — the digest-match and TSA-signature checks
417
489
  * happen automatically inside addTSA().
418
490
  *
491
+ * Pass options.validUntil (ISO 8601) to make this signature expire — see
492
+ * signFile() for the same semantics; identical here since both funnel
493
+ * through the same underlying sign() call.
494
+ *
419
495
  * @example
420
496
  * const { blob, envelope, signature } = await MajikSignature.signFileDetached(file, aliceKey, {
421
- * existingEnvelope: outOfBandEnvelope, // Optionally pass state from an external source
422
- * tsa: myTsaTimestamp, // Optionally attach a Trusted Timestamp
497
+ * existingEnvelope: outOfBandEnvelope,
498
+ * tsa: myTsaTimestamp,
499
+ * validUntil: "2027-01-01T00:00:00.000Z",
423
500
  * });
424
501
  * console.log(signature.hasTSA); // true if tsa was provided and accepted
425
502
  */
@@ -432,6 +509,10 @@ export class MajikSignature {
432
509
  * batch (default — meant to sit at the root of the zip as one .mjksmap),
433
510
  * or as separate .mjksig Blobs per file when options.mode === "separate".
434
511
  *
512
+ * options.validUntil, if set, is applied identically to every signature in
513
+ * the batch — there is no per-file override. Sign files individually via
514
+ * signFileDetached() if different files need different expiries.
515
+ *
435
516
  * @example
436
517
  * const result = await MajikSignature.signBatchDetached(
437
518
  * [
@@ -439,6 +520,7 @@ export class MajikSignature {
439
520
  * { path: "docs/appendix.pdf", blob: appendixBlob },
440
521
  * ],
441
522
  * aliceKey,
523
+ * { validUntil: "2027-01-01T00:00:00.000Z" },
442
524
  * );
443
525
  * if (result.mode === "map") {
444
526
  * zip.file("signatures.mjksmap", await result.mapBlob.arrayBuffer());
@@ -451,6 +533,9 @@ export class MajikSignature {
451
533
  * Verify a file's embedded signatures.
452
534
  * Returns one VerificationResult per signer. Old single-sig files return a single-item array.
453
535
  * Pass options.expectedSignerId to verify only a specific signer.
536
+ * Pass options.now to check expiry as of a specific time instead of the
537
+ * current time. Any signer whose validUntil has passed comes back with
538
+ * valid: false, expired: true, and a reason noting the expiry.
454
539
  */
455
540
  static async verifyFile(file, keyOrPublicKeys, options, debug = false) {
456
541
  if (MajikSignature._isMajikKey(keyOrPublicKeys)) {
@@ -463,6 +548,8 @@ export class MajikSignature {
463
548
  * Skips extraction and verifies the stripped file bytes directly against the provided envelope.
464
549
  * Returns one VerificationResult per signer.
465
550
  * Pass options.expectedSignerId to verify only a specific signer.
551
+ * Pass options.now to check expiry as of a specific time instead of the
552
+ * current time.
466
553
  */
467
554
  static async verifyFileDetached(file, envelope, keyOrPublicKeys, options, debug = false) {
468
555
  if (MajikSignature._isMajikKey(keyOrPublicKeys)) {
@@ -476,6 +563,11 @@ export class MajikSignature {
476
563
  * throwing — a missing, tampered, or invalidly-signed file is a normal
477
564
  * possible outcome to display, not an exceptional one to catch.
478
565
  *
566
+ * A file whose signature has passed its validUntil comes back with
567
+ * status "invalid" and results[].expired === true — same status as any
568
+ * other failed signature, so summarizeBatchVerification()'s allValid check
569
+ * still works unchanged. Pass options.now to check "as of" a specific time.
570
+ *
479
571
  * @example
480
572
  * const map = await MajikSignatureMap.fromMJKSMAP(mjksmapBlob);
481
573
  * const results = await MajikSignature.verifyFilesFromMjksMap(
@@ -816,6 +908,10 @@ export class MajikSignature {
816
908
  * The verifier must supply the signer's public keys out-of-band via
817
909
  * fromCompact() / verifyCompact() — never trust keys recovered from the
818
910
  * compact payload itself, because there are none.
911
+ *
912
+ * allowlistHash and validUntil, when present, carry over unchanged — both
913
+ * are already part of what was signed, so compacting doesn't affect their
914
+ * enforcement.
819
915
  */
820
916
  toCompact() {
821
917
  return {
@@ -827,12 +923,18 @@ export class MajikSignature {
827
923
  edSignature: this._edSignature,
828
924
  mlDsaSignature: this._mlDsaSignature,
829
925
  allowlistHash: this._allowlistHash,
926
+ validUntil: this._validUntil,
830
927
  };
831
928
  }
832
929
  /**
833
930
  * Rehydrate a full MajikSignature from a compact payload + externally
834
931
  * resolved public keys. Throws if signerId doesn't match the supplied keys —
835
932
  * this is a cheap sanity check, not a substitute for verify().
933
+ *
934
+ * allowlistHash and validUntil are carried through unchanged from the
935
+ * compact payload; verify() will still enforce them via the recomputed
936
+ * canonical payload, so an out-of-band edit to either field here would
937
+ * simply fail verification rather than being silently trusted.
836
938
  */
837
939
  static fromCompact(compact, publicKeys) {
838
940
  if (!publicKeys?.edPublicKey || !publicKeys?.mlDsaPublicKey) {
@@ -849,6 +951,7 @@ export class MajikSignature {
849
951
  edSignature: compact.edSignature,
850
952
  mlDsaSignature: compact.mlDsaSignature,
851
953
  allowlistHash: compact.allowlistHash,
954
+ validUntil: compact.validUntil,
852
955
  };
853
956
  return MajikSignature.fromJSON(full);
854
957
  }
@@ -857,6 +960,11 @@ export class MajikSignature {
857
960
  * publicKeys.signerId before any crypto runs, so a mismatched lookup fails
858
961
  * fast with a clear reason instead of a cryptic signature failure.
859
962
  *
963
+ * Delegates to verify() after rehydration, so allowlistHash and validUntil
964
+ * (when present in the compact payload) are enforced identically to the
965
+ * full-envelope path — including the "expired" reason/flag on a signature
966
+ * past its validUntil.
967
+ *
860
968
  * @example
861
969
  * const keys = await resolvePublicKeysForMuid(slink.muid); // your registry
862
970
  * const result = MajikSignature.verifyCompact(canonical, slink.signatureJSON, keys);
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.8",
5
+ "version": "0.2.9",
6
6
  "license": "Apache-2.0",
7
7
  "author": "Zelijah",
8
8
  "main": "./dist/index.js",
@@ -52,13 +52,13 @@
52
52
  },
53
53
  "dependencies": {
54
54
  "@majikah/majik-key": "^0.3.4",
55
- "@noble/post-quantum": "^0.6.1",
55
+ "@noble/post-quantum": "^0.7.0",
56
56
  "@stablelib/ed25519": "^2.1.0",
57
57
  "@stablelib/sha256": "^2.0.1",
58
58
  "fflate": "^0.8.3"
59
59
  },
60
60
  "devDependencies": {
61
- "@types/node": "^26.1.2",
61
+ "@types/node": "^26.2.0",
62
62
  "typescript": "^7.0.2",
63
63
  "vitest": "^4.1.9"
64
64
  }