Skip to content

Majikah/majik-file

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

27 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Majik File

Developed by Zelijah GitHub Sponsors

Post-quantum file encryption for the Majik Message platform. Produces self-contained .mjkb binary files — sealed with ML-KEM-768 + AES-256-GCM, optionally Zstd-compressed, readable without any network access.

This library is designed to work within the Majik Message ecosystem. It expects callers to supply ML-KEM-768 key material from an identity store; it does not generate or persist keys itself.

npm npm downloads npm bundle size License TypeScript


Contents

How it works

raw bytes
  │
  ├─ SHA-256 hash (pre-compression — used for dedup)
  │
  ├─ [chat_image / chat_attachment only]
  │   WebP conversion via Canvas API (PNG/JPEG/GIF/BMP → WebP at quality 0.88)
  │   Skipped for SVG, HEIC, HEIF, JXL — browser cannot encode these
  │
  ├─ Zstd compress at level 22
  │   Skipped for pre-compressed formats (JPEG, WebP, AVIF, video, audio, archives, Office XML)
  │
  ├─ [single recipient]
  │   ML-KEM-768 encapsulate(ownerPublicKey)
  │   → sharedSecret (32 bytes) used directly as AES-256-GCM key
  │
  ├─ [group — 2+ recipients]
  │   Random 32-byte AES key encrypts the file once
  │   Per recipient: ML-KEM-768 encapsulate(recipientPublicKey)
  │   → encryptedAesKey = aesKey XOR sharedSecret
  │
  └─ AES-256-GCM encrypt (12-byte random IV, 16-byte auth tag)
       → .mjkb binary

The encrypted binary is self-contained: the IV, all key material, original filename, and MIME type are embedded inside the file. No sidecar files or database records are required to decrypt.


The .mjkb binary format

Version: 0x01

┌──────────────────────────────────────────────────────┐
│  4 bytes  │  Magic: ASCII "MJKB"  (0x4D 0x4A 0x4B 0x42)  │
│  1 byte   │  Version (currently 0x01)                      │
│ 12 bytes  │  AES-GCM IV (random per file)                  │
│  4 bytes  │  Payload JSON length (big-endian uint32)        │
│  N bytes  │  Payload JSON (UTF-8)                           │
│  M bytes  │  AES-GCM ciphertext (compressed plaintext + 16-byte auth tag) │
└──────────────────────────────────────────────────────┘

Fixed header: 21 bytes (before variable payload JSON)

Single-recipient payload JSON:

{
  "mlKemCipherText": "<base64, 1088 bytes>",
  "n": "photo.png",
  "m": "image/png"
}

Group payload JSON:

{
  "keys": [
    {
      "fingerprint": "<base64 SHA-256 of public key>",
      "mlKemCipherText": "<base64, 1088 bytes>",
      "encryptedAesKey": "<base64, 32 bytes>"
    }
  ],
  "n": "photo.png",
  "m": "image/png"
}

n and m use short keys to minimise binary overhead (~30–50 extra bytes per file). Both fields are string | null — null when encryption was called without providing originalName / mimeType.

Per-recipient overhead in group mode: ~1.5 KB (1088-byte ML-KEM ciphertext + 32-byte encrypted AES key, base64-encoded).


Encryption modes

Single-recipient

Used when no recipients are passed, or when recipients is empty after deduplication. The ML-KEM shared secret is used directly as the AES-256-GCM key. The owner is the only entity who can decrypt.

Group

Used when one or more recipients are supplied. The file is encrypted once with a random 32-byte AES key. Each recipient — including the owner, who is always prepended automatically — gets their own ML-KEM encapsulation entry:

encryptedAesKey = groupAesKey XOR mlKemSharedSecret

This is safe because ML-KEM shared secrets are 32 uniformly random bytes, making the XOR a one-time pad for the group key. Each recipient can independently recover groupAesKey using only their own secret key.

Recipient deduplication: If the owner's own fingerprint appears in recipients, it is silently removed (not an error). Duplicate fingerprints in recipients are also silently deduplicated — first occurrence wins.

Limit: Maximum 100 recipients (excluding the owner) per file. Exceeding this throws MajikFileError("INVALID_INPUT").


Compression behaviour

Zstd compression at level 22 is applied selectively. Files whose MIME types indicate they are already compressed at the codec level are passed through uncompressed:

Skipped (already compressed) Compressed with Zstd lv.22
JPEG, WebP, AVIF, HEIC, HEIF, JXL PNG, BMP, TIFF, SVG, GIF
All video (mp4, webm, mkv, mov, …) WAV, FLAC, AIFF
Lossy audio (mp3, aac, ogg, opus, …) PDF, JSON, XML, CSV
ZIP, gzip, 7z, rar, bzip2, xz, zstd Plain text, source code
.docx, .xlsx, .pptx, .epub SQLite databases

If mimeType is null or unknown, compression is applied (safer default).


Installation

npm install @majikah/majik-file
# or
pnpm add @majikah/majik-file

This package requires a browser or browser-like environment (Electron, React Native with JSI) for:

  • crypto.randomUUID() and crypto.subtle (Web Crypto)
  • Blob / URL.createObjectURL for WebP conversion
  • HTMLCanvasElement for image re-encoding (only needed for chat_image / chat_attachment contexts)

Node.js is not a supported target.


Quick start

Encrypt a file (self, single recipient)

import { MajikFile } from '@majikah/majik-file'

// identity comes from your key store — MajikFile does not generate keys
const identity = {
  userId: 'user-uuid',
  fingerprint: 'base64-sha256-of-public-key',
  mlKemPublicKey: new Uint8Array(1184),  // ML-KEM-768 public key
  mlKemSecretKey: new Uint8Array(2400),  // ML-KEM-768 secret key
}

const fileBytes = await file.arrayBuffer()

const majikFile = await MajikFile.create({
  data: fileBytes,
  identity,
  context: 'user_upload',
  originalName: file.name,
  mimeType: file.type,
})

// Export the encrypted binary
const blob = majikFile.toMJKB()             // Blob — upload to R2
const metadata = majikFile.toJSON()         // MajikFileJSON — insert into Supabase

Decrypt a file

const { bytes, originalName, mimeType } = await MajikFile.decryptWithMetadata(
  mjkbBlob,
  { fingerprint: identity.fingerprint, mlKemSecretKey: identity.mlKemSecretKey }
)

const recovered = new Blob([bytes], { type: mimeType ?? 'application/octet-stream' })

Encrypt for multiple recipients

const majikFile = await MajikFile.create({
  data: fileBytes,
  identity: senderIdentity,
  recipients: [
    { fingerprint: 'recipient-a-fp', mlKemPublicKey: recipientAKey },
    { fingerprint: 'recipient-b-fp', mlKemPublicKey: recipientBKey },
  ],
  context: 'chat_attachment',
  originalName: 'report.pdf',
  mimeType: 'application/pdf',
})

Any of the three principals (sender, recipient A, recipient B) can decrypt using only their own mlKemSecretKey.

Temporary files

const majikFile = await MajikFile.create({
  data: fileBytes,
  identity,
  context: 'user_upload',
  isTemporary: true,
  expiresAt: MajikFile.buildExpiryDate(7), // 7 days from now
})

Chat images

const majikFile = await MajikFile.create({
  data: imageBytes,
  identity,
  context: 'chat_image',         // triggers automatic WebP conversion
  conversationId: 'conv-uuid',   // required for chat_image
  mimeType: 'image/png',
})

API reference

MajikFile.create(options)

static async create(options: CreateOptions): Promise<MajikFile>

Encrypts raw bytes and returns a MajikFile instance with both _binary (the .mjkb) and metadata populated. Throws MajikFileError on validation or crypto failure.

CreateOptions

Field Type Required Description
data Uint8Array | ArrayBuffer Raw file bytes to encrypt
identity MajikFileIdentity Owner's full identity (both keys)
context FileContext user_upload | chat_attachment | chat_image | thread_attachment
recipients MajikFileRecipient[] Additional recipients. Empty → single-recipient mode
originalName string Original filename (e.g. "photo.png"). Embedded in .mjkb payload
mimeType string MIME type. Inferred from originalName extension if omitted
isTemporary boolean Default false. Routes to files/public/ R2 prefix
isShared boolean Default false. Enables toggleSharing()
id string Pre-computed UUID. Auto-generated if omitted
bypassSizeLimit boolean Default false. Bypasses the 100 MB file size cap
expiresAt string ISO-8601. Required when isTemporary = true
chatMessageId string FK → majik_message_chat.id. Mutually exclusive with threadMessageId
threadMessageId string FK → majik_message_thread.id. Mutually exclusive with chatMessageId
conversationId string Required when context = "chat_image". Scopes the R2 key

Context behaviour:

Context WebP conversion R2 prefix
user_upload No files/user/<userId>/<hash>.mjkb
chat_attachment Yes (images only) files/user/<userId>/<hash>.mjkb
chat_image Yes (always) images/chats/<conversationId>/<userId>_<hash>.mjkb
thread_attachment No files/user/<userId>/<hash>.mjkb

MajikFile.decrypt(source, identity)

static async decrypt(
  source: Blob | Uint8Array | ArrayBuffer,
  identity: Pick<MajikFileIdentity, 'fingerprint' | 'mlKemSecretKey'>
): Promise<Uint8Array>

Decrypts a .mjkb binary and returns the raw plaintext bytes. Does not return filename or MIME type — use decryptWithMetadata() if you need those.

Note on wrong keys: ML-KEM decapsulation never throws on a wrong key — it silently returns a garbage shared secret. AES-GCM authentication detects this and causes a MajikFileError("DECRYPTION_FAILED").


MajikFile.decryptWithMetadata(source, identity)

static async decryptWithMetadata(
  source: Blob | Uint8Array | ArrayBuffer,
  identity: Pick<MajikFileIdentity, 'fingerprint' | 'mlKemSecretKey'>
): Promise<{
  bytes: Uint8Array
  originalName: string | null
  mimeType: string | null
}>

Preferred method for UI use. Returns decrypted bytes alongside originalName and mimeType read directly from the .mjkb payload JSON — no second parse required.

originalName and mimeType will be null for files encrypted without those fields (i.e. encrypted before the n/m payload fields were introduced, or when originalName/mimeType were not provided at encryption time). Callers should implement fallbacks.


MajikFile.fromJSON(json, binary?)

static fromJSON(
  json: MajikFileJSON,
  binary?: Uint8Array | ArrayBuffer | null
): MajikFile

Restores a MajikFile instance from a Supabase row. The binary is optional — if omitted, the instance is metadata-only (calling toMJKB() or decryptBinary() will throw MISSING_BINARY). R2 prefix validation is intentionally skipped here to tolerate rows from older schema versions.

static async fromJSONWithBlob(json: MajikFileJSON, binary: Blob): Promise<MajikFile>

Async variant that accepts a Blob (e.g. fetched from R2).


Instance methods

Method Returns Description
toJSON() MajikFileJSON Serialise metadata for Supabase. Binary is excluded
toMJKB() Blob Export encrypted binary as application/octet-stream Blob for R2 upload
toBinaryBytes() Uint8Array Export encrypted binary as raw bytes
decryptBinary(identity) Promise<Uint8Array> Decrypt the in-memory binary. Throws if binary not loaded
validate() void Validate all metadata invariants. Throws MajikFileError on failure
attachBinary(binary) void Load or replace the encrypted binary in memory
clearBinary() void Free the in-memory binary after upload
toggleSharing(token?) string | null Toggle share token on/off. Returns active token or null
userIsOwner(userId) boolean Check if userId matches this file's owner
exceedsSize(limitMB) boolean True if original size exceeds the given MB limit
isDuplicateOf(other) boolean Compare by SHA-256 file_hash
getStats() MajikFileStats Human-readable stats snapshot
toString() string Debug string: id, hash prefix, size, mode, storage type

Instance getters:

Getter Type Description
id string UUID primary key
userId string Owner's auth UUID
r2Key string Full R2 object key
originalName string | null Original filename from CreateOptions
mimeType string | null Resolved MIME type
sizeOriginal number Plaintext byte length
sizeStored number .mjkb byte length
sizeKB / sizeMB / sizeGB / sizeTB number Original size in various units (3 dp)
fileHash string SHA-256 hex of original bytes (pre-compression)
encryptionIv string Hex-encoded IV (audit record — authoritative IV is in the binary)
storageType StorageType "permanent" or "temporary"
isShared boolean Whether sharing is enabled
shareToken string | null Active share token
hasShareToken boolean Shorthand for shareToken !== null
context FileContext | null File context
chatMessageId string | null FK to chat message
threadMessageId string | null FK to thread message
conversationId string | null Conversation scope (chat_image only)
expiresAt string | null ISO-8601 expiry
timestamp string | null ISO-8601 creation time
lastUpdate string | null ISO-8601 last mutation time
hasBinary boolean Whether encrypted binary is loaded in memory
isGroup boolean Whether the file has multiple recipient key entries
isSingle boolean !isGroup
isExpired boolean Whether expiresAt is in the past
isTemporary boolean storageType === "temporary"
isInlineViewable boolean Whether MIME type can render inline in a browser
safeFilename string <fileHash><ext> — safe download name

Static helpers

Method Returns Description
MajikFile.buildExpiryDate(days?) string ISO-8601 expiry, default 15 days from now
MajikFile.formatBytes(bytes) string Human-readable size (e.g. "4.2 MB")
MajikFile.inferMimeType(filename) string | null MIME from file extension
MajikFile.isMjkbCandidate(data) boolean Magic byte check — does not fully parse
MajikFile.hasPublicKeyAccess(pk, fp) boolean SHA-256 fingerprint match — not a decryption proof
MajikFile.wouldBeDuplicate(bytes, hash) boolean Pre-flight dedup check by SHA-256

Type reference

MajikFileIdentity

interface MajikFileIdentity {
  userId: string          // auth.users UUID
  fingerprint: string     // base64 SHA-256 of mlKemPublicKey
  mlKemPublicKey: Uint8Array  // 1184 bytes — used during encryption
  mlKemSecretKey: Uint8Array  // 2400 bytes — used during decryption
}

MajikFileRecipient

interface MajikFileRecipient {
  fingerprint: string         // base64 SHA-256 of mlKemPublicKey
  mlKemPublicKey: Uint8Array  // 1184 bytes
  // No secret key — it never leaves the recipient's device
}

MajikFileGroupKey (embedded in group .mjkb)

interface MajikFileGroupKey {
  fingerprint: string      // identifies which recipient this entry belongs to
  mlKemCipherText: string  // base64, 1088 bytes
  encryptedAesKey: string  // base64, 32 bytes = groupAesKey XOR mlKemSharedSecret
}

MjkbSinglePayload

interface MjkbSinglePayload {
  mlKemCipherText: string  // base64, 1088 bytes
  n: string | null         // original filename
  m: string | null         // MIME type
}

MjkbGroupPayload

interface MjkbGroupPayload {
  keys: MajikFileGroupKey[]
  n: string | null         // original filename
  m: string | null         // MIME type
}

MajikFileJSON

Mirrors the majikah.majik_files Supabase table exactly. The encrypted binary is intentionally absent — it lives in R2.

interface MajikFileJSON {
  id: string
  user_id: string
  r2_key: string
  original_name: string | null
  mime_type: string | null
  size_original: number       // plaintext bytes
  size_stored: number         // .mjkb bytes (after compression + encryption overhead)
  file_hash: string           // SHA-256 hex of original bytes
  encryption_iv: string       // hex, 12 bytes — audit record; binary header is authoritative
  storage_type: 'permanent' | 'temporary'
  is_shared: boolean
  share_token: string | null
  context: FileContext | null
  chat_message_id: string | null
  thread_message_id: string | null
  conversation_id: string | null
  expires_at: string | null
  timestamp: string | null
  last_update: string | null
}

FileContext

type FileContext =
  | 'user_upload'        // general file vault — no WebP conversion, no size limit with bypassSizeLimit
  | 'chat_attachment'    // message attachment — images converted to WebP
  | 'chat_image'         // inline chat image — always converted to WebP, requires conversationId
  | 'thread_attachment'  // thread attachment — no WebP conversion

MajikFileStats

interface MajikFileStats {
  id: string
  originalName: string | null
  mimeType: string | null
  sizeOriginalHuman: string    // e.g. "4.2 MB"
  sizeStoredHuman: string      // e.g. "1.1 MB"
  compressionRatioPct: number  // percentage reduction, clamped to 0 minimum
  fileHash: string
  storageType: StorageType
  isGroup: boolean
  context: FileContext | null
  isShared: boolean
  isExpired: boolean
  expiresAt: string | null
  timestamp: string | null
  r2Key: string
}

Error handling

All errors thrown by this library are instances of MajikFileError.

import { MajikFileError } from '@majikah/majik-file'

try {
  const file = await MajikFile.create({ ... })
} catch (err) {
  if (err instanceof MajikFileError) {
    console.error(err.code)     // MajikFileErrorCode
    console.error(err.message)
    console.error(err.cause)    // original cause if available
  }
}

Error codes

Code When thrown
INVALID_INPUT Missing required fields, wrong key sizes, incompatible option combinations (e.g. both chatMessageId and threadMessageId), recipient limit exceeded
VALIDATION_FAILED validate() found inconsistent state (all violations reported at once)
ENCRYPTION_FAILED Unexpected error during the crypto or compression pipeline
DECRYPTION_FAILED Wrong key, corrupted ciphertext (AES-GCM auth tag mismatch), missing fingerprint in group key list
FORMAT_ERROR Magic byte mismatch, truncated binary, malformed payload JSON
SIZE_EXCEEDED data.byteLength > 100 MB and bypassSizeLimit is false
MISSING_BINARY toMJKB(), toBinaryBytes(), or decryptBinary() called when _binary is null
UNSUPPORTED_VERSION .mjkb version byte is not 0x01

Important: A wrong decryption key does not throw INVALID_INPUT — it reaches DECRYPTION_FAILED via AES-GCM authentication failure. This is by design: ML-KEM decapsulation is deterministic and never throws on bad input.


Storage model

This library produces two distinct artefacts that must be stored separately:

Artefact What it is Where it goes
toMJKB() → Blob Encrypted binary Cloudflare R2 at r2_key
toJSON() → object Metadata record Supabase majikah.majik_files table

The library does not perform R2 uploads or Supabase inserts itself — it only produces the data. Upload and persistence are the caller's responsibility (typically handled by MajikMessage.encryptFile()).

R2 key structure:

Permanent:  files/user/<userId>/<fileHash>.mjkb
Temporary:  files/public/<userId>_<fileHash>.mjkb
Chat image: images/chats/<conversationId>/<userId>_<fileHash>.mjkb

Temporary files (files/public/) are expected to be auto-deleted by an R2 lifecycle policy targeting that prefix after ~15 days. The library enforces expiresAt at the metadata level, but bucket-level deletion is an infrastructure concern.

File immutability: .mjkb files are write-once. There is no update() or patch() on encrypted fields. To replace a file, delete the R2 object and Supabase row, then call MajikFile.create() again.

Deduplication: file_hash is a SHA-256 hex digest of the original plaintext bytes, computed pre-compression and pre-WebP-conversion. This means the same source file always produces the same hash regardless of context. Use MajikFile.wouldBeDuplicate(rawBytes, existingHash) to short-circuit re-encryption.


Limitations and honest caveats

Key management is your problem. This library assumes you hand it a valid ML-KEM-768 key pair. It does not generate, store, rotate, or protect keys. Identity unlocking, passphrase-based key derivation, and secure key storage are handled elsewhere in the Majik Message stack.

No key revocation. Once a .mjkb file is encrypted for a recipient, that recipient retains access as long as they have their secret key. There is no mechanism to revoke access to an already-distributed .mjkb binary short of deleting it from R2.

Metadata is partially in-band, partially out-of-band. The .mjkb binary embeds originalName (n) and mimeType (m) in the payload JSON. All other metadata (user_id, r2_key, context, etc.) lives only in Supabase. If you have a .mjkb file but no database row, you can decrypt the content but not recover those fields.

Old binaries lack n/m fields. The payload n/m fields were added in a later revision. Files encrypted before this change will return null for both fields from decryptWithMetadata(). Callers must handle this gracefully.

WebP conversion is best-effort. The Canvas API conversion path can fail silently (unsupported format, canvas unavailable in the environment). In all failure cases the original bytes are used unchanged — the conversion is never a hard requirement.

No streaming. The entire file is read into memory before encryption and held in memory after decryption. This is a deliberate simplicity trade-off; it is not suitable for multi-GB files even with bypassSizeLimit: true.

MajikFile.hasPublicKeyAccess() is not a security primitive. It hashes a public key and compares to a stored fingerprint. It does not prove the caller controls the corresponding secret key. Use decrypt() for cryptographic proof of access.

Zstd WASM. Compression depends on @bokuweb/zstd-wasm. In Vite projects, the WASM file requires the dev server's fs.allow list to include the package's node_modules directory, and the package should be excluded from optimizeDeps. See the Vite config section of your project setup.

Format version. The current .mjkb format is version 0x01. Attempting to decode a binary with a different version byte throws MajikFileError("UNSUPPORTED_VERSION"). There is currently no migration path for old binaries.


Cryptographic Parameters

Primitive Parameters Role
ML-KEM-768 (FIPS 203) PK: 1184 B, SK: 2400 B, CT: 1088 B Key encapsulation — post-quantum
AES-256-GCM 32-byte key, 12-byte IV, 16-byte auth tag Symmetric authenticated encryption
Zstd Level 22 (maximum) Pre-encryption compression
SHA-256 File deduplication hash, public key fingerprints
CSPRNG crypto.getRandomValues IV generation, group AES key generation

ML-KEM-768 provides NIST security category 3 (roughly equivalent to AES-192). The hybrid construction (ML-KEM for key encapsulation + AES-256-GCM for bulk encryption) means the security of the scheme is bounded by both primitives — currently AES-256-GCM is the stronger of the two against classical adversaries, while ML-KEM-768 provides the post-quantum security.

Related Projects

Secure messaging platform using Majik Keys

npm npm downloads npm bundle size License TypeScript

Read more about Majik Message here

Majik Message Thumbnail

Click the image to try Majik Message live.

Read Docs

Also available on Microsoft Store for free.

Official Repository SDK Library


Majik Key is a seed phrase account library for creating, managing, and parsing mnemonic-based cryptographic accounts (Majik Keys). Generate deterministic key pairs from BIP39 seed phrases with simple, developer-friendly APIs. Now supports ML-KEM-768 post-quantum key derivation alongside X25519.

npm npm downloads npm bundle size License TypeScript

Read Docs Official Repository SDK Library


Majik Envelope is the core cryptographic engine of the Majik Message platform. It provides a post-quantum secure "envelope" format that handles message encryption, multi-recipient key encapsulation, and transparent compression using NIST-standardized algorithms.

npm npm downloads npm bundle size License TypeScript

Read Docs Official Repository SDK Library


Contributing

If you want to contribute or help extend support to more platforms, reach out via email. All contributions are welcome!


License

Apache-2.0 — free for personal and commercial use.


Author

Made with 💙 by @thezelijah

About the Developer


Contact

About

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.

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors