@majikah/majik-file 0.0.2 → 0.0.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.
@@ -8,12 +8,13 @@
8
8
  * lifetime of the module. Subsequent calls are synchronous after the first
9
9
  * await.
10
10
  */
11
- /**
12
- * Ensure the Zstd WASM module is initialised.
13
- * Safe to call concurrently — the second caller will await the same promise.
14
- */
15
- export declare function ensureZstd(): Promise<void>;
16
11
  export declare class MajikCompressor {
12
+ private static initialized;
13
+ /**
14
+ * Ensure the Zstd WASM module is initialised.
15
+ * Safe to call concurrently — the second caller will await the same promise.
16
+ */
17
+ private static ensureInit;
17
18
  /**
18
19
  * Compress raw bytes using Zstd at the specified level.
19
20
  *
@@ -11,20 +11,19 @@
11
11
  import { init, compress as zstdCompress, decompress as zstdDecompress, } from "@bokuweb/zstd-wasm";
12
12
  import { MajikFileError } from "../error";
13
13
  import { ZSTD_MAX_LEVEL } from "../crypto/constants";
14
- // ─── Init Guard ───────────────────────────────────────────────────────────────
15
- let zstdReady = false;
16
- /**
17
- * Ensure the Zstd WASM module is initialised.
18
- * Safe to call concurrently — the second caller will await the same promise.
19
- */
20
- export async function ensureZstd() {
21
- if (!zstdReady) {
22
- await init();
23
- zstdReady = true;
24
- }
25
- }
26
14
  // ─── MajikCompressor ──────────────────────────────────────────────────────────
27
15
  export class MajikCompressor {
16
+ static initialized = false;
17
+ /**
18
+ * Ensure the Zstd WASM module is initialised.
19
+ * Safe to call concurrently — the second caller will await the same promise.
20
+ */
21
+ static async ensureInit() {
22
+ if (!this.initialized) {
23
+ await init(); // only init Zstd for binary mode
24
+ this.initialized = true;
25
+ }
26
+ }
28
27
  /**
29
28
  * Compress raw bytes using Zstd at the specified level.
30
29
  *
@@ -41,7 +40,7 @@ export class MajikCompressor {
41
40
  throw MajikFileError.invalidInput(`MajikCompressor.compress: level must be an integer between 1 and 22 (got ${level})`);
42
41
  }
43
42
  try {
44
- await ensureZstd();
43
+ await this.ensureInit();
45
44
  return zstdCompress(data, level);
46
45
  }
47
46
  catch (err) {
@@ -60,7 +59,7 @@ export class MajikCompressor {
60
59
  throw MajikFileError.invalidInput("MajikCompressor.decompress: data must be a non-empty Uint8Array");
61
60
  }
62
61
  try {
63
- await ensureZstd();
62
+ await this.ensureInit();
64
63
  return zstdDecompress(data);
65
64
  }
66
65
  catch (err) {
@@ -49,6 +49,10 @@ export interface MajikFileGroupKey {
49
49
  export interface MjkbSinglePayload {
50
50
  /** Base64-encoded ML-KEM-768 ciphertext (1088 bytes). */
51
51
  mlKemCipherText: string;
52
+ /** Original filename (e.g. "photo.png"). Short key keeps the binary compact. */
53
+ n?: string | null;
54
+ /** Original MIME type (e.g. "image/png"). Short key keeps the binary compact. */
55
+ m?: string | null;
52
56
  }
53
57
  /**
54
58
  * JSON payload embedded in a group .mjkb binary.
@@ -58,6 +62,10 @@ export interface MjkbSinglePayload {
58
62
  export interface MjkbGroupPayload {
59
63
  /** Per-recipient key entries. */
60
64
  keys: MajikFileGroupKey[];
65
+ /** Original filename (e.g. "photo.png"). Short key keeps the binary compact. */
66
+ n?: string | null;
67
+ /** Original MIME type (e.g. "image/png"). Short key keeps the binary compact. */
68
+ m?: string | null;
61
69
  }
62
70
  export type MjkbPayload = MjkbSinglePayload | MjkbGroupPayload;
63
71
  export declare function isMjkbGroupPayload(p: MjkbPayload): p is MjkbGroupPayload;
@@ -147,6 +147,23 @@ export declare class MajikFile {
147
147
  * @throws MajikFileError on wrong key, missing key entry, corrupt data, or format errors.
148
148
  */
149
149
  static decrypt(source: Blob | Uint8Array | ArrayBuffer, identity: Pick<MajikFileIdentity, "fingerprint" | "mlKemSecretKey">): Promise<Uint8Array>;
150
+ /**
151
+ * Decrypt a .mjkb binary and return the raw bytes together with the
152
+ * original filename and MIME type that were embedded in the payload at
153
+ * encryption time.
154
+ *
155
+ * This is the preferred method for the File Vault UI because it avoids a
156
+ * second parse of the binary — everything comes from the single decodeMjkb
157
+ * call that decryption already performs.
158
+ *
159
+ * @returns `{ bytes, originalName, mimeType }` where `originalName` and
160
+ * `mimeType` may be null if the file was encrypted without metadata.
161
+ */
162
+ static decryptWithMetadata(source: Blob | Uint8Array | ArrayBuffer, identity: Pick<MajikFileIdentity, "fingerprint" | "mlKemSecretKey">): Promise<{
163
+ bytes: Uint8Array;
164
+ originalName: string | null;
165
+ mimeType: string | null;
166
+ }>;
150
167
  /**
151
168
  * Decrypt the .mjkb binary already loaded on this instance.
152
169
  * Convenience wrapper around MajikFile.decrypt() — avoids re-fetching from R2.
@@ -315,6 +315,8 @@ export class MajikFile {
315
315
  ciphertext = aesGcmEncrypt(sharedSecret, iv, compressed);
316
316
  payload = {
317
317
  mlKemCipherText: arrayToBase64(mlKemCT),
318
+ n: originalName ?? null,
319
+ m: resolvedMimeType ?? null,
318
320
  };
319
321
  }
320
322
  else {
@@ -343,7 +345,11 @@ export class MajikFile {
343
345
  encryptedAesKey: arrayToBase64(encryptedAesKey),
344
346
  };
345
347
  });
346
- payload = { keys };
348
+ payload = {
349
+ keys,
350
+ n: originalName ?? null,
351
+ m: resolvedMimeType ?? null,
352
+ };
347
353
  }
348
354
  // ── 6. Encode .mjkb ───────────────────────────────────────────────
349
355
  const mjkbBytes = encodeMjkb(iv, payload, ciphertext);
@@ -457,6 +463,71 @@ export class MajikFile {
457
463
  throw MajikFileError.decryptionFailed("File decryption failed", err);
458
464
  }
459
465
  }
466
+ /**
467
+ * Decrypt a .mjkb binary and return the raw bytes together with the
468
+ * original filename and MIME type that were embedded in the payload at
469
+ * encryption time.
470
+ *
471
+ * This is the preferred method for the File Vault UI because it avoids a
472
+ * second parse of the binary — everything comes from the single decodeMjkb
473
+ * call that decryption already performs.
474
+ *
475
+ * @returns `{ bytes, originalName, mimeType }` where `originalName` and
476
+ * `mimeType` may be null if the file was encrypted without metadata.
477
+ */
478
+ static async decryptWithMetadata(source, identity) {
479
+ if (!identity)
480
+ throw MajikFileError.invalidInput("identity is required for decryption");
481
+ if (!(identity.mlKemSecretKey instanceof Uint8Array) ||
482
+ identity.mlKemSecretKey.length !== ML_KEM_SK_LEN) {
483
+ throw MajikFileError.invalidInput(`identity.mlKemSecretKey must be ${ML_KEM_SK_LEN} bytes (got ${identity.mlKemSecretKey?.length ?? "undefined"})`);
484
+ }
485
+ try {
486
+ const raw = await normaliseToUint8ArrayAsync(source);
487
+ const { iv, payload, ciphertext } = decodeMjkb(raw);
488
+ let aesKey;
489
+ if (isMjkbSinglePayload(payload)) {
490
+ const mlKemCT = base64ToUint8Array(payload.mlKemCipherText);
491
+ aesKey = mlKemDecapsulate(mlKemCT, identity.mlKemSecretKey);
492
+ }
493
+ else if (isMjkbGroupPayload(payload)) {
494
+ if (!identity.fingerprint?.trim()) {
495
+ throw MajikFileError.invalidInput("identity.fingerprint is required to decrypt group files");
496
+ }
497
+ const entry = payload.keys.find((k) => k.fingerprint === identity.fingerprint);
498
+ if (!entry) {
499
+ throw MajikFileError.decryptionFailed(`No key entry found for fingerprint "${identity.fingerprint}"`);
500
+ }
501
+ const mlKemCT = base64ToUint8Array(entry.mlKemCipherText);
502
+ const sharedSecret = mlKemDecapsulate(mlKemCT, identity.mlKemSecretKey);
503
+ const encAesKey = base64ToUint8Array(entry.encryptedAesKey);
504
+ aesKey = new Uint8Array(AES_KEY_LEN);
505
+ for (let i = 0; i < AES_KEY_LEN; i++) {
506
+ aesKey[i] = encAesKey[i] ^ sharedSecret[i];
507
+ }
508
+ }
509
+ else {
510
+ throw MajikFileError.formatError(".mjkb payload JSON is neither a single nor group payload");
511
+ }
512
+ const compressed = aesGcmDecrypt(aesKey, iv, ciphertext);
513
+ if (!compressed) {
514
+ throw MajikFileError.decryptionFailed("Decryption failed — wrong key or corrupted .mjkb file");
515
+ }
516
+ const bytes = await MajikCompressor.decompress(compressed);
517
+ // Extract original filename and MIME type from the payload.
518
+ // Written at encryption time as short keys n/m to keep the binary compact.
519
+ // Older .mjkb files without these fields return null — callers should fall
520
+ // back to stripping ".mjkb" from the filename and using "application/octet-stream".
521
+ const originalName = payload.n ?? null;
522
+ const mimeType = payload.m ?? null;
523
+ return { bytes, originalName, mimeType };
524
+ }
525
+ catch (err) {
526
+ if (err instanceof MajikFileError)
527
+ throw err;
528
+ throw MajikFileError.decryptionFailed("File decryption failed", err);
529
+ }
530
+ }
460
531
  /**
461
532
  * Decrypt the .mjkb binary already loaded on this instance.
462
533
  * Convenience wrapper around MajikFile.decrypt() — avoids re-fetching from R2.
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@majikah/majik-file",
3
3
  "type": "module",
4
4
  "description": "Majik File is the core cryptographic engine for secure file handling in the Majikah ecosystem. It provides a post-quantum secure \"MJKB\" format designed for file encryption, multi-recipient key encapsulation, and transparent compression using NIST-standardized algorithms.",
5
- "version": "0.0.2",
5
+ "version": "0.0.4",
6
6
  "license": "Apache-2.0",
7
7
  "author": "Zelijah",
8
8
  "main": "./dist/index.js",