@majikah/majik-file 0.0.2 → 0.0.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.
@@ -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) {
@@ -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.
@@ -457,6 +457,70 @@ export class MajikFile {
457
457
  throw MajikFileError.decryptionFailed("File decryption failed", err);
458
458
  }
459
459
  }
460
+ /**
461
+ * Decrypt a .mjkb binary and return the raw bytes together with the
462
+ * original filename and MIME type that were embedded in the payload at
463
+ * encryption time.
464
+ *
465
+ * This is the preferred method for the File Vault UI because it avoids a
466
+ * second parse of the binary — everything comes from the single decodeMjkb
467
+ * call that decryption already performs.
468
+ *
469
+ * @returns `{ bytes, originalName, mimeType }` where `originalName` and
470
+ * `mimeType` may be null if the file was encrypted without metadata.
471
+ */
472
+ static async decryptWithMetadata(source, identity) {
473
+ if (!identity)
474
+ throw MajikFileError.invalidInput("identity is required for decryption");
475
+ if (!(identity.mlKemSecretKey instanceof Uint8Array) ||
476
+ identity.mlKemSecretKey.length !== ML_KEM_SK_LEN) {
477
+ throw MajikFileError.invalidInput(`identity.mlKemSecretKey must be ${ML_KEM_SK_LEN} bytes (got ${identity.mlKemSecretKey?.length ?? "undefined"})`);
478
+ }
479
+ try {
480
+ const raw = await normaliseToUint8ArrayAsync(source);
481
+ const { iv, payload, ciphertext } = decodeMjkb(raw);
482
+ let aesKey;
483
+ if (isMjkbSinglePayload(payload)) {
484
+ const mlKemCT = base64ToUint8Array(payload.mlKemCipherText);
485
+ aesKey = mlKemDecapsulate(mlKemCT, identity.mlKemSecretKey);
486
+ }
487
+ else if (isMjkbGroupPayload(payload)) {
488
+ if (!identity.fingerprint?.trim()) {
489
+ throw MajikFileError.invalidInput("identity.fingerprint is required to decrypt group files");
490
+ }
491
+ const entry = payload.keys.find((k) => k.fingerprint === identity.fingerprint);
492
+ if (!entry) {
493
+ throw MajikFileError.decryptionFailed(`No key entry found for fingerprint "${identity.fingerprint}"`);
494
+ }
495
+ const mlKemCT = base64ToUint8Array(entry.mlKemCipherText);
496
+ const sharedSecret = mlKemDecapsulate(mlKemCT, identity.mlKemSecretKey);
497
+ const encAesKey = base64ToUint8Array(entry.encryptedAesKey);
498
+ aesKey = new Uint8Array(AES_KEY_LEN);
499
+ for (let i = 0; i < AES_KEY_LEN; i++) {
500
+ aesKey[i] = encAesKey[i] ^ sharedSecret[i];
501
+ }
502
+ }
503
+ else {
504
+ throw MajikFileError.formatError(".mjkb payload JSON is neither a single nor group payload");
505
+ }
506
+ const compressed = aesGcmDecrypt(aesKey, iv, ciphertext);
507
+ if (!compressed) {
508
+ throw MajikFileError.decryptionFailed("Decryption failed — wrong key or corrupted .mjkb file");
509
+ }
510
+ const bytes = await MajikCompressor.decompress(compressed);
511
+ // Extract original_name and mime_type from the payload.
512
+ // Both fields are written at encryption time by MajikFile.create().
513
+ // They may be null for files encrypted without metadata.
514
+ const originalName = payload.original_name ?? null;
515
+ const mimeType = payload.mime_type ?? null;
516
+ return { bytes, originalName, mimeType };
517
+ }
518
+ catch (err) {
519
+ if (err instanceof MajikFileError)
520
+ throw err;
521
+ throw MajikFileError.decryptionFailed("File decryption failed", err);
522
+ }
523
+ }
460
524
  /**
461
525
  * Decrypt the .mjkb binary already loaded on this instance.
462
526
  * 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.3",
6
6
  "license": "Apache-2.0",
7
7
  "author": "Zelijah",
8
8
  "main": "./dist/index.js",