@majikah/majik-signature 0.2.3 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -59,3 +59,15 @@ 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";
@@ -58,3 +58,22 @@ 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";
@@ -75,7 +75,7 @@ export declare class MajikSignatureEmbed {
75
75
  contentType?: string;
76
76
  timestamp?: string;
77
77
  expectedSigners?: ExpectedSigner[];
78
- existingEnvelope?: MajikSignatureEnvelope | MajikSignatureEnvelopeJSON;
78
+ existingEnvelope?: MajikSignatureEnvelope | MajikSignatureEnvelopeJSON | Uint8Array | Blob;
79
79
  }, debug?: boolean): Promise<{
80
80
  blob: Blob;
81
81
  envelope: MajikSignatureEnvelope;
@@ -98,14 +98,14 @@ export declare class MajikSignatureEmbed {
98
98
  expectedSignerId?: string;
99
99
  }, debug?: boolean): Promise<VerificationResult[]>;
100
100
  /**
101
- * Verify a file against a provided, detached envelope (instance or JSON).
101
+ * Verify a file against a provided, detached envelope (instance, blob or JSON).
102
102
  * Still strips the file in case it also contains an embedded envelope,
103
103
  * ensuring verification runs against the clean original bytes.
104
104
  */
105
- static verifyDetached(file: Blob, envelopeInput: MajikSignatureEnvelope | MajikSignatureEnvelopeJSON, publicKeys: MajikSignerPublicKeys, MajikSig: MajikSignatureStaticAdapter, options?: ExtractOptions & {
105
+ static verifyDetached(file: Blob, envelopeInput: MajikSignatureEnvelope | MajikSignatureEnvelopeJSON | Uint8Array | Blob, publicKeys: MajikSignerPublicKeys, MajikSig: MajikSignatureStaticAdapter, options?: ExtractOptions & {
106
106
  expectedSignerId?: string;
107
107
  }, debug?: boolean): Promise<VerificationResult[]>;
108
- static verifyDetachedWithKey(file: Blob, envelopeInput: MajikSignatureEnvelope | MajikSignatureEnvelopeJSON, key: MajikKey, MajikSig: MajikSignatureStaticAdapter, options?: ExtractOptions & {
108
+ static verifyDetachedWithKey(file: Blob, envelopeInput: MajikSignatureEnvelope | MajikSignatureEnvelopeJSON | Uint8Array | Blob, key: MajikKey, MajikSig: MajikSignatureStaticAdapter, options?: ExtractOptions & {
109
109
  expectedSignerId?: string;
110
110
  }, debug?: boolean): Promise<VerificationResult[]>;
111
111
  /**
@@ -133,9 +133,8 @@ export class MajikSignatureEmbed {
133
133
  */
134
134
  static async signDetached(file, key, MajikSig, options, debug = false) {
135
135
  const { bytes, mimeType, handler } = await MajikSignatureEmbed._prepare(file, options);
136
- // ── Resolve the working envelope ─────────────────────────────────────────
137
136
  const envelope = options?.existingEnvelope
138
- ? MajikSignatureEnvelope.from(options.existingEnvelope)
137
+ ? await MajikSignatureEnvelope.from(options.existingEnvelope)
139
138
  : await MajikSignatureEmbed._readEnvelope(handler, bytes);
140
139
  envelope.assertCanSign(key);
141
140
  const originalBytes = await handler.strip(bytes);
@@ -218,13 +217,13 @@ export class MajikSignatureEmbed {
218
217
  }
219
218
  // ── verifyDetached ─────────────────────────────────────────────────────────
220
219
  /**
221
- * Verify a file against a provided, detached envelope (instance or JSON).
220
+ * Verify a file against a provided, detached envelope (instance, blob or JSON).
222
221
  * Still strips the file in case it also contains an embedded envelope,
223
222
  * ensuring verification runs against the clean original bytes.
224
223
  */
225
224
  static async verifyDetached(file, envelopeInput, publicKeys, MajikSig, options, debug = false) {
226
225
  const { bytes, handler } = await MajikSignatureEmbed._prepare(file, options);
227
- const envelope = MajikSignatureEnvelope.from(envelopeInput);
226
+ const envelope = await MajikSignatureEnvelope.from(envelopeInput);
228
227
  const originalBytes = await handler.strip(bytes);
229
228
  if (debug) {
230
229
  console.log("verifyDetached — original bytes hash:", bytesToBase64(hashContent(originalBytes)));
@@ -161,6 +161,43 @@ export declare class MajikSignatureEnvelope {
161
161
  toJSON(): MajikSignatureEnvelopeJSON;
162
162
  serialize(): string;
163
163
  static deserialize(base64: string): MajikSignatureEnvelope;
164
+ /**
165
+ * Raw MJKSIG bytes, no Blob wrapper. Exposed as a public escape hatch for
166
+ * non-browser contexts (Node scripts, tests, direct fs writes) where
167
+ * wrapping in a Blob just to immediately unwrap it again is pure overhead.
168
+ * toMJKSIG() is the primary API for anything Blob-facing.
169
+ */
170
+ toMJKSIGBytes(): Uint8Array;
171
+ /**
172
+ * Encode this envelope as an MJKSIG file Blob.
173
+ * Always writes the current MJKSIG_VERSION — encoding an old payload
174
+ * shape under an old version tag is not supported; old versions only
175
+ * ever appear when *reading* pre-existing MJKSIG binaries.
176
+ */
177
+ toMJKSIG(): Blob;
178
+ /**
179
+ * Decode MJKSIG bytes back into a MajikSignatureEnvelope.
180
+ * Validates magic bytes, version, and declared payload length before
181
+ * attempting to parse — a truncated or corrupted buffer fails fast with
182
+ * a clear reason rather than an obscure JSON.parse error.
183
+ *
184
+ * Accepts either a Blob (as produced by toMJKSIG()) or raw Uint8Array
185
+ * (as produced by toMJKSIGBytes(), or read directly off disk) — mirrors
186
+ * the same "accept either shape" pattern as from(). Reading a Blob
187
+ * requires awaiting its bytes, which is why this method is async.
188
+ */
189
+ static fromMJKSIG(input: Blob | Uint8Array): Promise<MajikSignatureEnvelope>;
190
+ /**
191
+ * Cheap structural sniff — checks magic bytes only, does not parse or
192
+ * validate the payload. For a Blob, slices only the header bytes rather
193
+ * than reading the whole file, so this stays cheap even on large inputs.
194
+ */
195
+ static isMJKSIG(input: Blob | Uint8Array): Promise<boolean>;
196
+ /**
197
+ * Read just the version byte without parsing the payload.
198
+ * Returns null if the input isn't MJKSIG-shaped at all.
199
+ */
200
+ static getMJKSIGVersion(input: Blob | Uint8Array): Promise<number | null>;
164
201
  static empty(): MajikSignatureEnvelope;
165
202
  /**
166
203
  * Parse a raw string or plain object into a MajikSignatureEnvelope.
@@ -174,8 +211,18 @@ export declare class MajikSignatureEnvelope {
174
211
  * This is the only place that knows about the legacy on-disk shape.
175
212
  */
176
213
  static fromJSON(json: MajikSignatureEnvelopeJSON | MajikSignatureJSON | string): MajikSignatureEnvelope;
177
- /** Accepts either an instance or its JSON shape — normalizes to an instance. */
178
- static from(input: MajikSignatureEnvelope | MajikSignatureEnvelopeJSON): MajikSignatureEnvelope;
214
+ /**
215
+ * Accepts an instance, its JSON shape, or MJKSIG bytes/Blob — normalizes to
216
+ * an instance. This is the single entry point that lets every downstream
217
+ * caller (verifyDetached, verifyDetachedWithKey, signDetached's
218
+ * existingEnvelope option) transparently accept a detached envelope
219
+ * regardless of which form it arrived in.
220
+ *
221
+ * Now async: resolving a Blob input requires awaiting its bytes. Every
222
+ * existing call site is already inside an async method, so this only
223
+ * costs an added `await` at each call site — no structural changes.
224
+ */
225
+ static from(input: MajikSignatureEnvelope | MajikSignatureEnvelopeJSON | Uint8Array | Blob): Promise<MajikSignatureEnvelope>;
179
226
  validate(): void;
180
227
  isValid(): boolean;
181
228
  }
@@ -19,7 +19,7 @@
19
19
  */
20
20
  import { sha3_512 } from "@noble/hashes/sha3.js";
21
21
  import { bytesToHex } from "@noble/hashes/utils.js";
22
- import { MAJIK_ENVELOPE_VERSION, MAJIK_SEAL_DOMAIN } from "./constants";
22
+ import { MAJIK_ENVELOPE_VERSION, MAJIK_SEAL_DOMAIN, MJKSIG_HEADER_LEN, MJKSIG_MAGIC, MJKSIG_MAGIC_LEN, MJKSIG_MEDIA_TYPE, MJKSIG_SUPPORTED_VERSIONS, MJKSIG_VERSION, } from "./constants";
23
23
  import { MajikSignatureAllowlistError, MajikSignatureError, MajikSignatureKeyError, MajikSignatureSerializationError, MajikSignatureValidationError, } from "./errors";
24
24
  import { hashContent, bytesToBase64 } from "./hash";
25
25
  // ─── MajikSignatureEnvelope ────────────────────────────────────────────────────
@@ -490,6 +490,131 @@ export class MajikSignatureEnvelope {
490
490
  throw new MajikSignatureSerializationError("Failed to deserialize envelope from base64", err);
491
491
  }
492
492
  }
493
+ // ── MJKSIG binary format ─────────────────────────────────────────────────
494
+ //
495
+ // A dedicated, versioned, self-identifying binary container for a detached
496
+ // envelope — distinct from serialize()/deserialize() (plain base64 of the
497
+ // JSON, no header) which remains for lightweight in-app round-tripping.
498
+ // MJKSIG is the wire/on-disk format intended for out-of-band travel,
499
+ // storage, and eventual IANA registration.
500
+ // ── MJKSIG binary format ─────────────────────────────────────────────────
501
+ //
502
+ // A dedicated, versioned, self-identifying binary container for a detached
503
+ // envelope — distinct from serialize()/deserialize() (plain base64 of the
504
+ // JSON, no header) which remains for lightweight in-app round-tripping.
505
+ // MJKSIG is the wire/on-disk format intended for out-of-band travel,
506
+ // storage, and eventual IANA registration.
507
+ /**
508
+ * Raw MJKSIG bytes, no Blob wrapper. Exposed as a public escape hatch for
509
+ * non-browser contexts (Node scripts, tests, direct fs writes) where
510
+ * wrapping in a Blob just to immediately unwrap it again is pure overhead.
511
+ * toMJKSIG() is the primary API for anything Blob-facing.
512
+ */
513
+ toMJKSIGBytes() {
514
+ const payloadJson = new TextEncoder().encode(JSON.stringify(this.toJSON()));
515
+ const out = new Uint8Array(MJKSIG_HEADER_LEN + payloadJson.length);
516
+ out.set(MJKSIG_MAGIC, 0);
517
+ out[MJKSIG_MAGIC_LEN] = MJKSIG_VERSION;
518
+ out[MJKSIG_MAGIC_LEN + 1] = 0x00; // reserved — always 0 in v1
519
+ const lenOffset = MJKSIG_MAGIC_LEN + 2;
520
+ out[lenOffset] = (payloadJson.length >>> 24) & 0xff;
521
+ out[lenOffset + 1] = (payloadJson.length >>> 16) & 0xff;
522
+ out[lenOffset + 2] = (payloadJson.length >>> 8) & 0xff;
523
+ out[lenOffset + 3] = payloadJson.length & 0xff;
524
+ out.set(payloadJson, MJKSIG_HEADER_LEN);
525
+ return out;
526
+ }
527
+ /**
528
+ * Encode this envelope as an MJKSIG file Blob.
529
+ * Always writes the current MJKSIG_VERSION — encoding an old payload
530
+ * shape under an old version tag is not supported; old versions only
531
+ * ever appear when *reading* pre-existing MJKSIG binaries.
532
+ */
533
+ toMJKSIG() {
534
+ const bytes = this.toMJKSIGBytes();
535
+ return new Blob([bytes], {
536
+ type: MJKSIG_MEDIA_TYPE,
537
+ });
538
+ }
539
+ /**
540
+ * Decode MJKSIG bytes back into a MajikSignatureEnvelope.
541
+ * Validates magic bytes, version, and declared payload length before
542
+ * attempting to parse — a truncated or corrupted buffer fails fast with
543
+ * a clear reason rather than an obscure JSON.parse error.
544
+ *
545
+ * Accepts either a Blob (as produced by toMJKSIG()) or raw Uint8Array
546
+ * (as produced by toMJKSIGBytes(), or read directly off disk) — mirrors
547
+ * the same "accept either shape" pattern as from(). Reading a Blob
548
+ * requires awaiting its bytes, which is why this method is async.
549
+ */
550
+ static async fromMJKSIG(input) {
551
+ const raw = input instanceof Blob ? new Uint8Array(await input.arrayBuffer()) : input;
552
+ return MajikSignatureEnvelope.#parseMJKSIGBytes(raw);
553
+ }
554
+ /** Core MJKSIG byte parser — shared by fromMJKSIG() after Blob resolution. */
555
+ static #parseMJKSIGBytes(raw) {
556
+ if (raw.length < MJKSIG_HEADER_LEN + 1) {
557
+ // +1 == at least one byte of payload JSON
558
+ throw new MajikSignatureSerializationError("Malformed MJKSIG: too short to contain a valid header");
559
+ }
560
+ for (let i = 0; i < MJKSIG_MAGIC_LEN; i++) {
561
+ if (raw[i] !== MJKSIG_MAGIC[i]) {
562
+ throw new MajikSignatureSerializationError('Malformed MJKSIG: missing "MJKSIG" magic bytes');
563
+ }
564
+ }
565
+ const version = raw[MJKSIG_MAGIC_LEN];
566
+ if (!MJKSIG_SUPPORTED_VERSIONS.includes(version)) {
567
+ throw new MajikSignatureSerializationError(`Unsupported MJKSIG version: ${version} (supported: ${MJKSIG_SUPPORTED_VERSIONS.join(", ")})`);
568
+ }
569
+ const lenOffset = MJKSIG_MAGIC_LEN + 2;
570
+ const payloadLen = (raw[lenOffset] << 24) |
571
+ (raw[lenOffset + 1] << 16) |
572
+ (raw[lenOffset + 2] << 8) |
573
+ raw[lenOffset + 3];
574
+ if (payloadLen <= 0) {
575
+ throw new MajikSignatureSerializationError("Malformed MJKSIG: invalid payload length");
576
+ }
577
+ const payloadStart = MJKSIG_HEADER_LEN;
578
+ const payloadEnd = payloadStart + payloadLen;
579
+ if (payloadEnd > raw.length) {
580
+ throw new MajikSignatureSerializationError("Malformed MJKSIG: declared payload length exceeds buffer");
581
+ }
582
+ const json = new TextDecoder().decode(raw.slice(payloadStart, payloadEnd));
583
+ // fromJSON() runs full #validateShape() — MJKSIG framing validity does
584
+ // NOT imply envelope validity, so this is not a redundant check.
585
+ return MajikSignatureEnvelope.fromJSON(json);
586
+ }
587
+ /**
588
+ * Cheap structural sniff — checks magic bytes only, does not parse or
589
+ * validate the payload. For a Blob, slices only the header bytes rather
590
+ * than reading the whole file, so this stays cheap even on large inputs.
591
+ */
592
+ static async isMJKSIG(input) {
593
+ const header = input instanceof Blob
594
+ ? new Uint8Array(await input.slice(0, MJKSIG_MAGIC_LEN).arrayBuffer())
595
+ : input;
596
+ if (header.length < MJKSIG_MAGIC_LEN)
597
+ return false;
598
+ for (let i = 0; i < MJKSIG_MAGIC_LEN; i++) {
599
+ if (header[i] !== MJKSIG_MAGIC[i])
600
+ return false;
601
+ }
602
+ return true;
603
+ }
604
+ /**
605
+ * Read just the version byte without parsing the payload.
606
+ * Returns null if the input isn't MJKSIG-shaped at all.
607
+ */
608
+ static async getMJKSIGVersion(input) {
609
+ const header = input instanceof Blob
610
+ ? new Uint8Array(await input.slice(0, MJKSIG_MAGIC_LEN + 1).arrayBuffer())
611
+ : input;
612
+ if (!(await MajikSignatureEnvelope.isMJKSIG(header)))
613
+ return null;
614
+ if (header.length < MJKSIG_MAGIC_LEN + 1)
615
+ return null;
616
+ return header[MJKSIG_MAGIC_LEN];
617
+ }
493
618
  // ── Creation / parsing ───────────────────────────────────────────────────────
494
619
  static empty() {
495
620
  return new MajikSignatureEnvelope({
@@ -533,11 +658,24 @@ export class MajikSignatureEnvelope {
533
658
  MajikSignatureEnvelope.#validateShape(promoted);
534
659
  return new MajikSignatureEnvelope(promoted);
535
660
  }
536
- /** Accepts either an instance or its JSON shape — normalizes to an instance. */
537
- static from(input) {
538
- return input instanceof MajikSignatureEnvelope
539
- ? input
540
- : MajikSignatureEnvelope.fromJSON(input);
661
+ /**
662
+ * Accepts an instance, its JSON shape, or MJKSIG bytes/Blob — normalizes to
663
+ * an instance. This is the single entry point that lets every downstream
664
+ * caller (verifyDetached, verifyDetachedWithKey, signDetached's
665
+ * existingEnvelope option) transparently accept a detached envelope
666
+ * regardless of which form it arrived in.
667
+ *
668
+ * Now async: resolving a Blob input requires awaiting its bytes. Every
669
+ * existing call site is already inside an async method, so this only
670
+ * costs an added `await` at each call site — no structural changes.
671
+ */
672
+ static async from(input) {
673
+ if (input instanceof MajikSignatureEnvelope)
674
+ return input;
675
+ if (input instanceof Uint8Array || input instanceof Blob) {
676
+ return MajikSignatureEnvelope.fromMJKSIG(input);
677
+ }
678
+ return MajikSignatureEnvelope.fromJSON(input);
541
679
  }
542
680
  validate() {
543
681
  MajikSignatureEnvelope.#validateShape(this.toJSON());
@@ -150,7 +150,7 @@ export declare class MajikSignature {
150
150
  timestamp?: string;
151
151
  mimeType?: string;
152
152
  expectedSigners?: ExpectedSigner[];
153
- existingEnvelope?: MajikSignatureEnvelope | MajikSignatureEnvelopeJSON;
153
+ existingEnvelope?: MajikSignatureEnvelope | MajikSignatureEnvelopeJSON | Uint8Array | Blob;
154
154
  }): ReturnType<typeof MajikSignatureEmbed.signDetached>;
155
155
  /**
156
156
  * Verify a file's embedded signatures.
@@ -167,7 +167,7 @@ export declare class MajikSignature {
167
167
  * Returns one VerificationResult per signer.
168
168
  * Pass options.expectedSignerId to verify only a specific signer.
169
169
  */
170
- static verifyFileDetached(file: Blob, envelope: MajikSignatureEnvelope | MajikSignatureEnvelopeJSON, keyOrPublicKeys: MajikKey | MajikSignerPublicKeys, options?: {
170
+ static verifyFileDetached(file: Blob, envelope: MajikSignatureEnvelope | MajikSignatureEnvelopeJSON | Uint8Array | Blob, keyOrPublicKeys: MajikKey | MajikSignerPublicKeys, options?: {
171
171
  expectedSignerId?: string;
172
172
  mimeType?: string;
173
173
  }, debug?: boolean): Promise<VerificationResult[]>;
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.3",
5
+ "version": "0.2.4",
6
6
  "license": "Apache-2.0",
7
7
  "author": "Zelijah",
8
8
  "main": "./dist/index.js",