Files
2026-05-27 23:47:53 +01:00

3.8 KiB
Raw Permalink Blame History

encrypted-archive

(no submodules)

Purpose

Wire format for AES-256-GCM-encrypted packed archives served alongside unencrypted bundles. Adopts the stable magic prefix SP-ENC1 followed by a length-prefixed JSON header ({ alg, keyId, nonce, aad? }) and the GCM ciphertext+tag. The format keeps the encrypted path opt-in: bodies without the prefix flow through the loader unchanged, so consumers can ship a mixed-CDN of encrypted and unencrypted bundles without forking the loader.

Features

  • Stable 8-byte magic prefix (53 50 2D 45 4E 43 31 01) detected via a cheap byte-equality check on the loader hot path.
  • Self-describing header: alg, keyId, base64-encoded nonce, and an optional aad. No fragile binary layout for the header — JSON, with a hard 64 KiB cap to prevent malicious oversize headers from blowing the buffer.
  • AES-256-GCM via @suaveplan/crypto's aeadEncrypt/aeadDecrypt. Tag mismatch surfaces as PackerBrowserError(LOAD_FAILED) with the underlying CryptoError attached as cause for forensic logging.
  • Pure functions — no implicit RNG, no implicit key derivation. The caller supplies key + nonce; this module just shapes the bytes.

Usage: Basic Example

import {
    decodeEncryptedArchiveHeader,
    decryptEncryptedArchive,
    encodeEncryptedArchive,
} from "@suaveplan/packer-browser";

const key = new Uint8Array(32).fill(0xaa);
const nonce = new Uint8Array(12).fill(0x01);

const wire = encodeEncryptedArchive(
    new TextEncoder().encode("hello"),
    "tenant-a",
    key,
    nonce
);

const decoded = decodeEncryptedArchiveHeader(wire);
const plaintext = decryptEncryptedArchive(decoded, key);
// → Uint8Array of "hello"

Usage: Advanced Example

import {
    decodeEncryptedArchiveHeader,
    decryptEncryptedArchive,
    hasEncryptedArchiveMagic,
    MemoryKeyring,
} from "@suaveplan/packer-browser";

const keyring = new MemoryKeyring({
    "tenant-a": new Uint8Array(32).fill(0xaa),
});

async function unwrap(fetched: Uint8Array): Promise<Uint8Array> {
    if (!hasEncryptedArchiveMagic(fetched)) return fetched;
    const decoded = decodeEncryptedArchiveHeader(fetched);
    const key = await keyring.resolveKey(decoded.header.keyId);
    return decryptEncryptedArchive(decoded, key);
}

API Reference

  • SP_ENC1_MAGIC: Uint8Array — the 8-byte magic prefix.
  • hasEncryptedArchiveMagic(bytes: Uint8Array): boolean — detector.
  • encodeEncryptedArchive(plaintext, keyId, key, nonce, opts?): Uint8Array — encoder. opts.aad?: Uint8Array is bound into the GCM tag.
  • decodeEncryptedArchiveHeader(bytes: Uint8Array): DecodedEncryptedArchive — parses the header + slices the ciphertext. Throws LOAD_FAILED on a malformed frame.
  • decryptEncryptedArchive(decoded, key): Uint8Array — wraps aeadDecrypt. Throws LOAD_FAILED on tag mismatch with cause attached.

Implementation Notes

  • The header length is encoded as a 4-byte big-endian unsigned integer. Big-endian is the on-wire convention used by @suaveplan/packer's manifest decoder; matching it here keeps cross-package readers uniform.
  • The aad field, when present, MUST cover the same bytes at encrypt and decrypt time — typically a hash of the surrounding request URL or manifest hash. The wire format does NOT bind aad automatically; the caller decides whether to include it.
  • A 64 KiB header cap protects against pathological adversarial inputs that could trigger a large allocation before any AEAD work has run. Realistic headers are 200–400 bytes.
  • AES-256-GCM was chosen over XChaCha20-Poly1305 because the loader is expected to run in browsers where AES-NI is hardware-accelerated and Web Crypto can be plugged in via the engine option of aeadEncrypt/aeadDecrypt for further performance.