Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,106 @@
# encrypted-archive
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## 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
```ts
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
```ts
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.