3.8 KiB
3.8 KiB
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-encodednonce, and an optionalaad. 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'saeadEncrypt/aeadDecrypt. Tag mismatch surfaces asPackerBrowserError(LOAD_FAILED)with the underlyingCryptoErrorattached ascausefor 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?: Uint8Arrayis bound into the GCM tag.decodeEncryptedArchiveHeader(bytes: Uint8Array): DecodedEncryptedArchive— parses the header + slices the ciphertext. ThrowsLOAD_FAILEDon a malformed frame.decryptEncryptedArchive(decoded, key): Uint8Array— wrapsaeadDecrypt. ThrowsLOAD_FAILEDon tag mismatch withcauseattached.
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
aadfield, 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 bindaadautomatically; 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
engineoption ofaeadEncrypt/aeadDecryptfor further performance.