8.3 KiB
OpenSpec Change: packer-port
- Change ID: packer-port
- Package:
@suaveplan/packer(new) - Status: PROPOSED
- Date: 2026-04-28
Summary
Port @suaveplan/packer from ../suaveplan/packages/universal/packer into genesis. Two modes ship as subpaths:
@suaveplan/packer— whole-file pack/unpack: gzip + AEAD + ed25519 sign in one shot, schema-driven via bitpacker. Self-contained pack-and-go for small assets. Carried over with minor cleanup.@suaveplan/packer/chunked— chunk-addressed streaming pack/unpack/verify emitting the locked v1 byte format specced indrive-plugin-chunks-port. Partial rewrite: keeps source's pipeline (streaming source normalisation, AEAD streaming, per-chunk signing, verifier) but drops bitpacker on the chunked path in favour of a byte-aligned fixed-layout codec.
Three structural changes versus source:
- Chunked wire format = locked v1 (164-byte fixed header + ciphertext). Source's u8/u64/u40 bitpacker schema is replaced. Whole-file mode keeps bitpacker (no fixed-size constraint there).
- No signature recovery. Source uses
signWithRecovery/verifyWithRecovery(1-bit recovery flag); v1 is plained25519_signbecause every consumer knows the server pubkey out-of-band. Saves a bit, simplifies verify path. - Streaming-first chunked pipeline. Encryption runs through a streaming AEAD; the ciphertext stream is chopped at
payload_per_chunk = chunkSize - 164byte offsets. Last chunk is naturally short (no zero-padding). HeaderpayloadLenis authoritative.
Source-side scope
../suaveplan/packages/universal/packer/ v2.0.0 — 37 .ts files, ~3 460 src + ~4 997 test LOC.
src/
├── abstract/ — AbstractPacker base (compression / AEAD / signing helpers)
├── streaming/ — normalizeSource(Uint8Array | ReadableStream | AsyncIterable)
├── packer/ — Packer (whole-file)
├── unpacker/ — Unpacker (whole-file)
├── chunked/
│ ├── packer/ — ChunkedPacker
│ ├── unpacker/ — ChunkedUnpacker
│ └── verifier/ — ChunkVerifier
├── types/
└── integration/
Source whole-file public surface: Packer, Unpacker, signedPacketSchema. Source chunked surface: ChunkedPacker, ChunkedUnpacker, ChunkVerifier, chunkedPacketSchema (replaced by v1 codec).
Dependencies
All present in genesis:
@suaveplan/bitpacker(packages/primitives/bitpacker) — used by whole-file mode@suaveplan/crypto(packages/crypto/crypto) —encrypt/decrypt, AEAD streaming, ed25519 sign/verify, hashing@suaveplan/events(packages/events) —EventEmitterbase for progress events@suaveplan/mime(packages/primitives/mime) — MIME detection on pack input@suaveplan/error,@suaveplan/types,@suaveplan/streams,@opentelemetry/api,zod
No new packages are required. No source-side blockers.
What changes (port-time)
1. Layout
packages/data/packer/
├── package.json # @suaveplan/packer @ 0.0.0
├── tsconfig.json # extends @suaveplan/typescript-config/library
├── src/
│ ├── abstract/ # ported with minor cleanup
│ ├── streaming/ # ported as-is
│ ├── packer/ # whole-file Packer (bitpacker schema)
│ ├── unpacker/ # whole-file Unpacker
│ ├── chunked/
│ │ ├── codec/ # NEW: v1 byte-format header encode/decode
│ │ ├── packer/ # ChunkedPacker — emits v1 chunks via streaming pipeline
│ │ ├── unpacker/ # ChunkedUnpacker — consumes v1 chunks
│ │ └── verifier/ # ChunkVerifier — sig + AEAD verify, no decrypt
│ ├── error-codes.ts # PACKER_CODES via defineCodes
│ ├── schemas/ # Zod boundary schemas for options
│ └── index.ts # barrel — whole-file surface
└── src/chunked/index.ts # barrel — chunked surface (subpath ./chunked)
2. Subpath exports
"exports": {
".": { "import": "./dist/index.js", "types": "./dist/index.d.ts" },
"./chunked": { "import": "./dist/chunked/index.js", "types": "./dist/chunked/index.d.ts" },
"./streaming":{ "import": "./dist/streaming/index.js", "types": "./dist/streaming/index.d.ts" },
"./errors": { "import": "./dist/error-codes.js", "types": "./dist/error-codes.d.ts" }
}
3. Chunked v1 codec (fixed-layout, byte-aligned, big-endian)
Header layout per drive-plugin-chunks-port CHUNKS-FORMAT-V1-01. Repeated here for completeness:
metadata(100 B):
0..2 version uint16 = 0x0001
2..34 fileId bytes(32) // content hash of plaintext
34..38 chunkId uint32
38..42 totalChunks uint32
42..50 fileSize uint64
50..54 chunkSize uint32 // budget for non-final chunks
54..58 payloadLen uint32
58..60 flags uint16 // v1 MUST be 0x0000
60..84 nonce bytes(24) // XChaCha20 nonce
84..100 aeadTag bytes(16) // Poly1305 tag
sig(64 B):
100..164 ed25519 signature over metadata[0..100] ‖ ciphertext[0..payloadLen]
ciphertext(payloadLen B):
164..(164+payloadLen)
Last chunk: payloadLen = fileSize − chunkId * (chunkSize − 164). Non-final chunks: payloadLen = chunkSize − 164. No zero padding.
4. Streaming chunked pipeline (write path)
PackSource ─► normalizeSource ─► AsyncIterable<Uint8Array>
─► hash(plaintext) → fileId (sha256, 32 B)
─► aead-encrypt-stream (XChaCha20-Poly1305, file key + per-chunk nonce)
─► chop at (chunkSize − 164) byte boundaries
─► for each slice:
derive nonce = baseNonce ⊕ (chunkId as 8 B little-endian, zero-extended to 24 B)
build metadata (100 B)
ed25519-sign(metadata ‖ ciphertext) → 64 B sig
emit metadata ‖ sig ‖ ciphertext
─► finally: build manifest (server-signed, fileId-addressed)
End-to-end streaming. No whole-file buffering.
5. Streaming chunked pipeline (read/verify path)
chunk-bytes ─► parseHeader (structural validation: version, flags, payloadLen invariants)
─► verifySig (metadata ‖ ciphertext) — reject on fail
─► aead-decrypt(ciphertext, nonce, aeadTag, fileKey)
─► emit plaintext slice
ChunkVerifier performs structural-parse + sig-verify only (no decrypt) — used by mesh peers that forward chunks without holding the file key.
6. Drops
signWithRecovery/verifyWithRecoverycalls on the chunked path — replaced with plained25519_sign/ed25519_verify. Whole-file mode keeps recovery semantics if the source had them (audit during port).- Bitpacker schema for chunks (
chunkedPacketSchema) — replaced by the v1 byte codec. - Source's
EventEmitter-based progress events — replaced by genesis@suaveplan/eventsEventBus<TMap>typed events.
7. Error codes
PACKER_CODES = defineCodes("PACKER", [INVALID_SOURCE, COMPRESSION_FAILED, ENCRYPTION_FAILED, SIGNATURE_FAILED, VERIFICATION_FAILED, INVALID_FORMAT, VERSION_UNSUPPORTED, FLAGS_RESERVED_NONZERO, PAYLOAD_LEN_INVALID, ABORTED] as const). PackerError extends SuaveplanError<PackerErrorCode>. Frozen.
Impact
- New package
packages/data/packer/at0.1.0. - New OpenSpec capability
packeratopenspec/specs/packer/spec.md. - Unblocks
drive-plugin-chunks-port(which depends on the chunked subpath). - Source-side compatibility: wire-format break on the chunked side. Whole-file packed data from source v2.0.0 is unaffected (whole-file format unchanged).
Out of scope
- Source's
integration/integration.test.tsend-to-end suite — re-authored as conformance tests against the new format, not ported byte-for-byte. - Compression on chunked path — v1 reserved
flagsbit 0 forcompressed; not implemented in v1. - Algorithm agility — v1 hardwires XChaCha20-Poly1305 + ed25519. Algo selector via
flagsbit 1 reserved for v2. - Browser-specific
packer-browser— separate change after this lands.