first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,155 @@
# @suaveplan/bitpacker-crypto - Bit-packer-aware crypto primitives — SignedBitSchema, EncryptedBitSchema, EnvelopeSchema, HashChainSchema, MultiSignedBitSchema, SchemaFingerprint, HashAlgorithmRegistry. Authority/AeadOps/DomainTag types live in @suaveplan/types; the canonical implementations are in @suaveplan/crypto and dependency-injected by the caller.
## Summary
Bit-packer-aware crypto primitives. The shared interfaces (`Authority`,
`DomainTag`, `AuthorityVerifier`, `AeadOps`) live in `@suaveplan/types`;
the canonical implementations come from `@suaveplan/crypto` and are
**dependency-injected** by the caller. This package has zero runtime
dependency on `@suaveplan/crypto`, so consumers in any layer (including
`@suaveplan/crypto` itself, e.g. `crypto::ChainBundle`) can adopt these
primitives without inducing a circular package dependency.
<!-- begin:auto-docs -->
## Documentation tree
- [encrypted](./src/encrypted/encrypted-bitschema.md) — Niche AEAD-encrypted envelope around a bit-packed body. Wire format: `[version u8][algTag u8][nonceLen u8][nonce][ciphe…
- [encrypted-bitschema](./src/encrypted/encrypted-bitschema.md) — Niche AEAD-encrypted envelope around a bit-packed body. Wire format: `[version u8][algTag u8][nonceLen u8][nonce][ciphe…
- [envelope](./src/envelope/envelope-schema.md) — A typed, version-aware envelope schema for bit-packed payloads. Wraps a caller-supplied `BodyCodec` with a `[magic][for…
- [envelope-schema](./src/envelope/envelope-schema.md) — A typed, version-aware envelope schema for bit-packed payloads. Wraps a caller-supplied `BodyCodec` with a `[magic][for…
- [fingerprint](./src/fingerprint/schema-fingerprint.md) — Content-hash a `BitPackerSchema` so two parties can agree they're using byte-identical schemas before they exchange pay…
- [schema-fingerprint](./src/fingerprint/schema-fingerprint.md) — Content-hash a `BitPackerSchema` so two parties can agree they're using byte-identical schemas before they exchange pay…
- [hash-registry](./src/hash-registry/hash-registry.md) — A pluggable registry of named hash algorithms used by every hashing-aware primitive in `@suaveplan/bitpacker-crypto` (n…
- [hash-registry](./src/hash-registry/hash-registry.md) — A pluggable registry of named hash algorithms used by every hashing-aware primitive in `@suaveplan/bitpacker-crypto` (n…
- [hashchain](./src/hashchain/hashchain-schema.md) — Append-only signed hash chain. Each entry carries a sequence number, the hash of the previous entry, an opaque payload,…
- [hashchain-schema](./src/hashchain/hashchain-schema.md) — Append-only signed hash chain. Each entry carries a sequence number, the hash of the previous entry, an opaque payload,…
- [multisigned](./src/multisigned/multisigned-bitschema.md) — Bit-packed payload co-signed by N independent authorities. Wire format is `[bit-packed body][sig_1][sig_2]…[sig_N]`. Pi…
- [multisigned-bitschema](./src/multisigned/multisigned-bitschema.md) — Bit-packed payload co-signed by N independent authorities. Wire format is `[bit-packed body][sig_1][sig_2]…[sig_N]`. Pi…
- [signed](./src/signed/signed-bitschema.md) — Bit-packed payload signed by an `Authority` under a fixed `DomainTag`. Wire format is `[bit-packed body][signature]`. V…
- [signed-bitschema](./src/signed/signed-bitschema.md) — Bit-packed payload signed by an `Authority` under a fixed `DomainTag`. Wire format is `[bit-packed body][signature]`. V…
- [crc32](./src/crc32.md) — Fast non-cryptographic checksums for bit-packed payloads. `crc32` implements the IEEE-802.3 / zlib polynomial (`0xedb88…
- [index](./src/index.md) — **Path:** `bitpacker-crypto/src/index.ts` **Export:** See source for details.
- [xxhash32](./src/xxhash32.md) — **Path:** `src/xxhash32.ts` **Export:** `calculateXxhash32`, `verifyXxhash32`, `packXxhash32`, `unpackXxhash32`, `appen…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/bitpacker-crypto
```
## Public surface
| Primitive | Use it for |
|---|---|
| `EnvelopeSchema` | `[magic][formatVersion][body]` framing — version-aware wire formats |
| `SignedBitSchema` | Authority-signed payloads under a `DomainTag` |
| `MultiSignedBitSchema` | N-of-N co-signed payloads with `firstBadSignerIndex` reporting |
| `HashChainSchema` | Append-only signed hash chain (audit logs, mesh state) |
| `EncryptedBitSchema` | **Niche** AEAD-only envelope for **small** encrypted payloads |
| `SchemaFingerprint` | Canonical-hash a `BitPackerSchema` for handshakes |
| `HashAlgorithmRegistry` | Pluggable hash registry — sha256/xxhash32/crc32 default; register blake3 |
| `crc32` / `xxhash32` / `adler32` | Existing checksum surface, unchanged |
## Intended scope
`EncryptedBitSchema` is for **small** encrypted payloads only — token
bodies, key-wrap envelopes, sub-MTU control records. **Do NOT use it as a
chunked-file encoder.** For chunked-file encryption with a streaming
verifier and locked v0x0001 codec, reach for `@suaveplan/packer` instead.
## Crypto provider needs?
This package ships **bit-packer-aware** crypto primitives. For raw AEAD,
signing, key derivation, or authenticated encryption (without a bit-packer
schema), reach for the crypto family directly:
- [`@suaveplan/crypto`](../../crypto/crypto) — multi-algorithm façade (AEAD, sign,
agree, sealed-box, auth-box).
- [`@suaveplan/crypto-sodium`](../../crypto/crypto-sodium) — libsodium engine.
- [`@suaveplan/crypto-browser`](../../crypto/crypto-browser) — WebCrypto engine.
- [`@suaveplan/crypto-vault`](../../crypto/crypto-vault) — BIP39 + Argon2id HD vault.
- [`@suaveplan/packer`](../../data/packer) — chunked AEAD with manifest.
Upstream's `bitpacker/src/crypto/` provider abstraction is **not ported**;
see [SUPERSEDED.md](./SUPERSEDED.md) for the per-symbol replacement table.
## Quick examples
### Envelope a bit-packed body
```ts
import { defineEnvelopeSchema } from "@suaveplan/bitpacker-crypto";
import { defineBitSchema } from "@suaveplan/bitpacker";
const headerSchema = defineBitSchema<{ foo: number }>([
{ name: "foo", type: "uint", bits: 32 },
]);
const env = defineEnvelopeSchema({
magic: new Uint8Array([0x53, 0x56, 0x43, 0x42]),
formatVersion: 1,
bodyCodec: {
pack: (b) => headerSchema.pack(b),
unpack: (bytes) => headerSchema.unpack(bytes),
},
});
const bytes = env.pack({ foo: 7 });
```
### Wiring the canonical crypto bindings
Every primitive that needs a verifier or AEAD bundle accepts it via
options. Pass the canonical wiring once in your application bootstrap:
```ts
import { aeadEncrypt, aeadDecrypt } from "@suaveplan/crypto";
import { verifyAuthoritySignature } from "@suaveplan/crypto/authority";
import type { AeadOps, AuthorityVerifier } from "@suaveplan/types";
export const verifier: AuthorityVerifier = verifyAuthoritySignature;
export const aead: AeadOps = { encrypt: aeadEncrypt, decrypt: aeadDecrypt };
// Then everywhere you build a primitive:
defineSignedBitSchema(codec, { domain, verifier });
defineMultiSignedBitSchema(codec, { domain, verifier, signerCount });
defineHashChainSchema({ domain, verifier, payloadCodec });
defineEncryptedBitSchema(codec, { key, aead });
```
### Sign + encrypt a token
```ts
import {
defineEncryptedBitSchema,
defineSignedBitSchema,
} from "@suaveplan/bitpacker-crypto";
import { DOMAIN_BITPACKER_SIGNED_V1 } from "@suaveplan/crypto/domain-tags";
const inner = defineEncryptedBitSchema(tokenCodec, { key: aeadKey });
const innerCodec = {
pack: (t) => inner.pack(t),
unpack: (bytes) => inner.unpack(bytes),
};
// (Signing receives Promise.unwrap() on the body — for the canary chain
// pattern, prefer `defineSignedBitSchema(envCodec, ...)` directly.)
```
### Schema-fingerprint handshake
```ts
import { schemaFingerprint, assertSchemaFingerprint } from "@suaveplan/bitpacker-crypto";
const local = { foo: 255, bar: 65535 };
const fp = await schemaFingerprint(local);
// On the peer:
await assertSchemaFingerprint(remoteSchema, fp);
```
## Algorithms (existing checksum surface, unchanged)
| Algorithm | Speed | Use Case |
|-----------|-------|----------|
| CRC32 | Medium | General integrity checking |
| Adler32 | Fast | Quick checksums (used in zlib) |
| XXHash32 | Very fast | High-throughput hashing |

View File

@@ -0,0 +1,150 @@
# SUPERSEDED — bitpacker-crypto provider abstraction
This file records two related decisions:
1. **Original decision (`bitpacker-crypto-port`, 2026-05-02)** — upstream's
`packages/universal/bitpacker/src/crypto/` ships ~1,906 lines of provider
abstraction (`BaseCryptoProvider`, `CryptoRegistry`, and three adapters:
`noop`, `libsodium`, `elliptic`). suaveplan intentionally **does not port that abstraction**.
2. **Replacement (`bitpacker-crypto-rewrite`, 2026-05-02)** — a deeper consumer
scan found 8+ call-sites across suaveplan and upstream manually re-implementing
the same crypto-with-bitpacker patterns (sign-then-pack, encrypt-pack-frame,
versioned envelopes, schema fingerprints, stateless verifiers). Rather than
leaving the package as checksums-only, the rewrite **adds five bit-packer-aware
primitives** that consolidate those inline duplications. See "What we built
instead" at the bottom of this file.
The original rejection of upstream's abstraction still stands. The patterns
those rejected symbols *attempted* to address are now solved by the new
primitives, in shapes that real consumers can actually use.
## Why the abstraction is not ported
The upstream provider abstraction is a self-referential layer over `@suaveplan/crypto`:
its `LibsodiumCryptoProvider` does `import from "@suaveplan/crypto"`; its
`EllipticCryptoProvider` wraps the same primitives via the `elliptic` package; its
`NoOpCryptoProvider` is a test harness. A grep across `/mnt/s/web/suaveplan/packages`
on 2026-05-02 confirmed **no upstream consumer outside the bitpacker package itself**
imports `BaseCryptoProvider`, `CryptoRegistry`, or any adapter — the abstraction was
never wired into a production code path.
suaveplan already ships every underlying primitive in five focused packages
(`@suaveplan/crypto`, `crypto-sodium`, `crypto-browser`, `crypto-vault`, `crypto-keys`)
plus a higher-level chunked AEAD in `@suaveplan/packer`. Adding the upstream
abstraction would duplicate work, route around the suaveplan structured-error / OTel
conventions, and contradict the "no duplicate libraries" directive in CLAUDE.md.
## Rejected upstream surface and its suaveplan replacement
| Upstream symbol | Upstream file:lines | suaveplan replacement |
|---|---|---|
| `BaseCryptoProvider extends EventEmitter` | `crypto/provider/provider.ts:26-232` | None — call `@suaveplan/crypto` directly |
| `generateNonce(length)` | `crypto/provider/provider.ts:237-248` | `@suaveplan/crypto-utils::random.bytes(length)` |
| `concatBytes(...arrays)` | `crypto/provider/provider.ts:253-262` | `@suaveplan/crypto-utils::bytes.concat(...arrays)` |
| `validateKeyLength(key, expected, type)` | `crypto/provider/provider.ts:267-277` | Caller-side guard via `BitpackerError` (no central helper) |
| `constantTimeEqual(a, b)` | `crypto/provider/provider.ts:282-289` | `@suaveplan/crypto-utils::bytes.equalsConstantTime(a, b)` |
| `secureWipe(buffer)` | `crypto/provider/provider.ts:294-296` | `@suaveplan/crypto-utils::bytes.zeroize(buffer)` |
| `LibsodiumCryptoProvider` + `createXChaCha20Provider` + `createAesGcmProvider` | `crypto/adapters/libsodium/libsodium.ts:94-335` | `@suaveplan/crypto-sodium` + `@suaveplan/crypto::aead` |
| `EllipticCryptoProvider` + `createSecp256k1Provider` + `createEd25519Provider` + `createP256Provider` | `crypto/adapters/elliptic/elliptic.ts:36-572` | `@suaveplan/crypto-curve` |
| `NoOpCryptoProvider` + `createTestProvider` + `createSlowProvider` + `createUnreliableProvider` | `crypto/adapters/noop/noop.ts:35-335` | `@suaveplan/testing` fakes for `@suaveplan/crypto` |
| `CryptoRegistry extends EventEmitter` + `createCryptoRegistry` + `getGlobalRegistry` | `crypto/registry/registry.ts:21-370` | None — registry was self-referential |
## What `@suaveplan/bitpacker-crypto` still ships
This package is **not deprecated**. It still ships the checksum surface that the
upstream `bitpacker/src/core/checksum/` directory provided, all under `@suaveplan/bitpacker-crypto`:
- `crc32(data)` and `appendCrc32` / `verifyAndStripCrc32`
- `adler32(data)`
- `calculateXxhash32`, `packXxhash32`, `unpackXxhash32`, `appendXxhash32`,
`verifyXxhash32`, `verifyAndStripXxhash32`
Checksums are not part of the rejected provider abstraction; this decision
record covers only the `crypto/` directory of the upstream bitpacker source.
## Encrypt-then-pack — recommended replacement pattern
If you need to encrypt or sign bytes that go into a bitpacker payload, reach
for `@suaveplan/packer` (which ships chunked AEAD already) or call
`@suaveplan/crypto` directly. There is no provider/registry indirection .
```typescript
import { Packer } from "@suaveplan/packer";
import { aead } from "@suaveplan/crypto";
import { defineBitSchema } from "@suaveplan/bitpacker";
const schema = defineBitSchema({ payload: "~" });
const bytes = schema.pack({ payload: new Uint8Array([1, 2, 3, 4]) });
// Encrypt the packed bytes:
const ciphertext = await aead.encrypt({
algorithm: "xchacha20-poly1305",
key: myKey,
nonce: myNonce,
plaintext: bytes,
associatedData: new Uint8Array(),
});
// Or use @suaveplan/packer for chunked AEAD with a manifest:
const packer = new Packer({ schema, key: myKey });
const envelope = await packer.pack({ payload: new Uint8Array([1, 2, 3, 4]) });
```
## See also
- spec change `bitpacker-crypto-port` — original decision (superseded).
- spec change `bitpacker-crypto-rewrite` — replacement decision; **slimmed 2026-05-02**
after `restore-crypto-keychain-packer` discovery to two auxiliary primitives only.
- spec change `restore-crypto-keychain-packer` — parallel work that owns the new
opinionated `@suaveplan/crypto` layer (Packer / Authority / AuditLog / RevocationList).
- `bitpacker-modular-restructure` parity table — flips the five corresponding rows
from "🚫 not ported" to "⛔ intentionally superseded; see `restore-crypto-keychain-packer`
for the consolidation, `bitpacker-crypto-rewrite` for the auxiliary primitives".
- `@suaveplan/crypto`, `@suaveplan/crypto-sodium`, `@suaveplan/crypto-browser`,
`@suaveplan/crypto-vault`, `@suaveplan/crypto-keys`, `@suaveplan/crypto-utils`,
`@suaveplan/packer` — the crypto family.
## What we built instead — bit-packer auxiliary primitives only
After the 2026-05-02 audit + the discovery that **`restore-crypto-keychain-packer`**
already proposes a new opinionated `@suaveplan/crypto` layer with `Packer` / `Authority` /
`AuditLog` / `RevocationList`, this package now ships only the two primitives that
don't overlap that work:
| Primitive | What it does | Consumer that drove the design |
|---|---|---|
| `schemaFingerprint(schema, algorithm?, registry?)` | Content-hash a `BitPackerSchema` after canonicalization (sorted keys, no whitespace, deterministic encoding). Algorithm name resolved via `HashAlgorithmRegistry`. Companion `assertSchemaFingerprint` for "do we agree on the schema?" handshakes. | `@suaveplan/network-registry::Xxhash32HashProvider`/`Crc32HashProvider` (upstream port); planned mesh schema-agreement handshake (`docs/network/security-and-tokens.md`, `docs/network/architecture.md`). |
| `HashAlgorithmRegistry` | Pluggable hash-algorithm registry. Defaults: `xxhash32`, `crc32`, `sha256`. Consumers register their own (`blake3` for drive whole-file hash, future PQC). User directive on extensibility/plugins/middleware made this a first-class surface. | `docs/drive/encryption.md` L79 (BLAKE3 whole-file hash); user directive (extensibility/plugins). |
Plus the existing checksum surface (`crc32`, `adler32`, `xxhash32` and their
`appendCrc32`/`verifyAndStripCrc32`/`appendXxhash32`/etc. helpers) is preserved unchanged.
## What lives in `@suaveplan/crypto` instead (via `restore-crypto-keychain-packer`)
The patterns my first-draft rewrite proposed are now correctly placed in the new
opinionated `@suaveplan/crypto` layer, where they compose with `Authority` + `Keychain` +
the closed-list `DomainTag` registry:
| First-draft primitive (dropped here) | Owned by `@suaveplan/crypto` (via `restore-crypto-keychain-packer`) |
|---|---|
| `SignedBitSchema` (Authority + DomainTag) | `Packer` (encrypt-and-sign envelope under DomainTag via Authority) + direct `Authority.sign(domain, slot, payload)` for sign-only |
| `EnvelopeSchema(magic, version, body)` | `packPublicBundle` shape + `Packer` envelope |
| `HashChainSchema` (Merkle-chained signed log) | `AuditLog` (Merkle log + `proveInclusion` / `verifyInclusion`) |
| `MultiSignedBitSchema` (N-of-N co-sign) | If/when `MeshTrustToken` (NET-110) needs it, the spec delta lands in `@suaveplan/crypto` next to `Packer` and `RevocationList` (not here — co-signing belongs in the `Authority`-using crypto layer) |
| `EncryptedBitSchema` (niche AEAD) | `Packer` (covers small-payload encrypt-and-sign exactly) |
| New domain tags (`DOMAIN_BITPACKER_*_V1`) | `DomainTag` is a closed list owned by `@suaveplan/crypto/domain-tags` — additions are wire-format extensions in that package, not in `bitpacker-crypto` |
### What was dropped (chronological)
| Dropped | Why |
|---|---|
| `ChunkVerifier` (first audit pass) | `@suaveplan/packer::ChunkVerifier` already covers the locked v0x0001 chunk format. |
| `SignedBitSchema` taking raw `signKey` (audit-revision) | suaveplan canonical signing surface is `Authority.sign(domain, slot, payload)`. Raw-key API didn't serve real consumers. |
| `EnvelopeSchema` with opaque body (audit-revision) | Real consumers (`PublicBundle`, `ChainBundle`) have structured body fields. |
| `DomainTagRegistry` open registry (audit-revision) | Conflicts with suaveplan's intentional closed-list `DomainTag` design. |
| `SignatureAlgorithmRegistry` (audit-revision) | `Authority` already dispatches on slot algorithm. |
| `SignedBitSchema` / `EnvelopeSchema` / `HashChainSchema` / `MultiSignedBitSchema` / `EncryptedBitSchema` (slim revision) | All overlap with `restore-crypto-keychain-packer`'s `Packer` / `AuditLog` / `RevocationList`. suaveplan policy explicitly forbids duplicate libraries. |
| New `DOMAIN_BITPACKER_*_V1` constants (slim revision) | Closed-list domain tags belong in `@suaveplan/crypto/domain-tags`, not here. |
The two surviving primitives (`SchemaFingerprint`, `HashAlgorithmRegistry`) are pure
bit-packer utilities that compose with `restore-crypto-keychain-packer` without conflict.

View File

@@ -0,0 +1,89 @@
# CRC32 + Adler32 Checksums
## Purpose
Fast non-cryptographic checksums for bit-packed payloads. `crc32` implements the IEEE-802.3 / zlib polynomial (`0xedb88320`); `adler32` implements the RFC 1950 algorithm. Both are designed to detect accidental corruption (single-bit flips, wire-level errors, dropped bytes), not adversarial tampering — for cryptographic integrity, use the AEAD or signed-bitschema primitives in `@suaveplan/bitpacker-crypto`'s `encrypted/` and `signed/` modules.
The four wrappers (`appendCrc32`, `verifyAndStripCrc32`, `appendAdler32`, `verifyAndStripAdler32`) bookend a payload with a 4-byte big-endian checksum so framed bitpacker output can be transmitted over a noisy channel and integrity-checked at the receiving end. The wrappers throw on truncated input or checksum mismatch; the bare `crc32` / `adler32` functions return the raw 32-bit value for callers that want to integrate the check elsewhere.
## Features
- `crc32(data)` — IEEE-802.3 / zlib polynomial CRC32 with a precomputed 256-entry table
- `adler32(data)` — RFC 1950 Adler32 with the standard `MOD_ADLER = 65521` prime
- `appendCrc32` / `appendAdler32` — append 4-byte big-endian checksum
- `verifyAndStripCrc32` / `verifyAndStripAdler32` — verify and strip 4-byte checksum, throwing on mismatch or short input
- Pure JS, no dependencies, deterministic output, browser- and Node-safe
## Usage: Basic Example
```ts
import { crc32, appendCrc32, verifyAndStripCrc32 } from "@suaveplan/bitpacker-crypto";
const payload = new Uint8Array([1, 2, 3, 4]);
const checksum = crc32(payload); // 0xb63cfbcd
const framed = appendCrc32(payload); // 8 bytes: payload + 4-byte CRC
const restored = verifyAndStripCrc32(framed); // -> [1, 2, 3, 4]
```
## Usage: Advanced Example
```ts
import {
appendCrc32,
verifyAndStripCrc32,
appendAdler32,
verifyAndStripAdler32,
} from "@suaveplan/bitpacker-crypto";
// Frame a bit-packed payload, transmit, and validate on receive.
function transmit(payload: Uint8Array): Uint8Array {
return appendCrc32(payload);
}
function receive(framed: Uint8Array): Uint8Array {
try {
return verifyAndStripCrc32(framed);
} catch (err) {
// Caller decides: retransmit, drop, or escalate to a higher-level
// integrity check (signed-bitschema for adversarial channels).
throw err;
}
}
// Adler32 is faster but weaker; pick it for short payloads on trusted channels.
const cheap = appendAdler32(new Uint8Array([0xde, 0xad, 0xbe, 0xef]));
const back = verifyAndStripAdler32(cheap);
```
## API Reference
### `crc32(data: Uint8Array): number`
Returns the 32-bit unsigned CRC32 value of `data` using the IEEE-802.3 / zlib polynomial (`0xedb88320`). Uses a precomputed 256-entry table for one-byte-per-iteration throughput.
### `adler32(data: Uint8Array): number`
Returns the 32-bit unsigned Adler32 value of `data` per RFC 1950 (`a` and `b` accumulators with `MOD_ADLER = 65521`).
### `appendCrc32(data: Uint8Array): Uint8Array`
Returns a new array containing `data` followed by 4 big-endian bytes of `crc32(data)`.
### `verifyAndStripCrc32(data: Uint8Array): Uint8Array`
Returns the leading `data.length - 4` bytes if the trailing 4 bytes match `crc32(payload)`. Throws `Error` with `"Data too short for CRC32 verification"` if `data.length < 4`, or `"CRC32 mismatch: expected X, got Y"` on mismatch.
### `appendAdler32(data: Uint8Array): Uint8Array`
Returns a new array containing `data` followed by 4 big-endian bytes of `adler32(data)`.
### `verifyAndStripAdler32(data: Uint8Array): Uint8Array`
Mirrors `verifyAndStripCrc32` for Adler32. Same error semantics.
## Implementation Notes
- **CRC32 polynomial = `0xedb88320`.** This is the bit-reversed form of the standard IEEE-802.3 polynomial (`0x04c11db7`). The reversal is encoded in the table-precomputation loop (`c & 1 ? POLY ^ (c >>> 1) : c >>> 1`) so the streaming inner loop processes least-significant bits first, matching zlib / gzip / PNG.
- **Big-endian checksum framing.** Both append wrappers write the 32-bit checksum as big-endian bytes, matching network byte order. The `verifyAndStrip` counterparts read with `getUint32(offset, false)` (false = big-endian).
- **Not cryptographically secure.** A motivated attacker can construct collisions for both CRC32 and Adler32 in O(1). Use these for accidental-corruption detection only; use AEAD or signature primitives elsewhere in `@suaveplan/bitpacker-crypto` for adversarial integrity.
- **No allocation in the hot path.** `crc32` / `adler32` only allocate via the precomputed `CRC32_TABLE`. `appendCrc32` / `appendAdler32` allocate one output buffer; `verifyAndStripCrc32` / `verifyAndStripAdler32` allocate via `slice` for the returned payload.

View File

@@ -0,0 +1,174 @@
# EncryptedBitSchema
Niche AEAD-encrypted envelope around a bit-packed body. Wire format:
`[version u8][algTag u8][nonceLen u8][nonce][ciphertext+tag]`.
**Intended scope: small encrypted payloads only.** Token bodies,
key-wrap envelopes inside `MessageEnvelope`, sub-MTU control messages.
**NOT a chunked-file encoder** — `@suaveplan/packer` owns chunked-file
encryption with its locked v0x0001 codec.
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Several suaveplan surfaces need a small, self-describing AEAD-encrypted
envelope: a token body wrapped for transport, an inner
key-wrap inside a higher-level message envelope, an opaque control
record signed-then-encrypted by upstream layers. Re-implementing the
"version + algTag + nonce + ciphertext" frame each time invites
nonce-reuse mistakes and silent algorithm drift.
`EncryptedBitSchema` standardises the frame, picks safe defaults
(XChaCha20-Poly1305 with 24-byte random nonce, 32-byte key, no
`Math.random` fallback), and refuses to be misused as a chunked-file
encoder by being explicit in this README and the schema name.
## Features
- Three AEAD algorithms supported: `AES-256-GCM`, `ChaCha20-Poly1305`,
`XChaCha20-Poly1305` (default — its 24-byte random nonce avoids
nonce-collision concerns even at high message volume)
- Stable u8 algorithm tags (0/1/2) on the wire so an envelope encoded
under one algorithm can be detected (and rejected) by a schema
expecting another
- Pluggable `Rng`; default uses `globalThis.crypto.getRandomValues` and
throws `CRYPTO_RNG_UNAVAILABLE` if Web Crypto is missing — never
silently falls back to `Math.random`
- Optional `aad` (additional authenticated data) — held by reference;
participates in the integrity tag so binding the envelope to a
surrounding context (session id, conversation id) is one option away
- Wire-format constants: `version=1`, no tag changes once allocated.
Adding a new algorithm only appends to the tag table.
- Tampered ciphertext rejects with `CRYPTO_DECRYPT_FAILED`
- Wrong-algorithm envelope rejects with `CRYPTO_ENVELOPE_VERSION_MISMATCH`
- Wrong-key decrypt rejects with `CRYPTO_DECRYPT_FAILED`
- OTel spans `bitpacker-crypto.encrypted-pack` /
`bitpacker-crypto.encrypted-unpack`
## Intended scope
This primitive is for **small** payloads. The wire format prepends a 3-byte
header + the full nonce + the AEAD output (ciphertext + 16-byte tag). For
a 100-byte body, that's ~143 bytes total — fine. For a 50 MB chunked file,
this is the wrong shape: you want chunked encryption with a streaming
verifier, which is what `@suaveplan/packer::ChunkedPacker` provides.
If you find yourself reaching for `EncryptedBitSchema` to wrap large
payloads, stop and use `@suaveplan/packer` instead. This warning is also
asserted by a spec-conformance test.
## Usage: Basic Example
```ts
import { defineEncryptedBitSchema } from "@suaveplan/bitpacker-crypto";
import { defineBitSchema } from "@suaveplan/bitpacker";
import { aeadEncrypt, aeadDecrypt } from "@suaveplan/crypto";
interface Token { userId: number; expiry: number }
const tokenSchema = defineBitSchema<Token>([
{ name: "userId", type: "uint", bits: 32 },
{ name: "expiry", type: "uint", bits: 32 },
]);
const encryptedToken = defineEncryptedBitSchema<Token>(
{
pack: (t) => tokenSchema.pack(t),
unpack: (bytes) => tokenSchema.unpack(bytes),
},
{
key: aeadKey,
aead: { encrypt: aeadEncrypt, decrypt: aeadDecrypt },
}
);
const wrapped = await encryptedToken.pack({ userId: 7, expiry: 1700000000 });
const original = await encryptedToken.unpack(wrapped);
```
The `aead` option is required and accepts an `AeadOps` bundle
(`{ encrypt, decrypt }`). The canonical implementation is the
`aeadEncrypt` / `aeadDecrypt` functions from `@suaveplan/crypto`. Passing
them via DI keeps `@suaveplan/bitpacker-crypto` free of any runtime
dependency on `@suaveplan/crypto`. The `AEAD_KEY_LENGTH`,
`AEAD_NONCE_LENGTH`, and `AEAD_XCHACHA_NONCE_LENGTH` constants are
declared locally in this primitive (not imported from
`@suaveplan/crypto`); duplication is acceptable because these values
are part of the bit-packer-crypto wire format.
## Usage: Advanced Example — bind to a session id via AAD
```ts
const sessionId = new TextEncoder().encode("session:abc123");
const encrypted = defineEncryptedBitSchema<Token>(codec, {
key,
aad: sessionId,
});
// Only an unpack with the SAME aad will succeed; replays into a different
// session will reject with CRYPTO_DECRYPT_FAILED.
```
## API Reference
### `interface EncryptedBitSchemaOptions`
- `key: Uint8Array` — exactly 32 bytes (`AEAD_KEY_LENGTH`)
- `aead: AeadOps` (required) — AEAD operations bundle; typically
`{ encrypt: aeadEncrypt, decrypt: aeadDecrypt }` from
`@suaveplan/crypto`
- `algorithm?: AeadAlgorithm = "XChaCha20-Poly1305"`
- `rng?: Rng` — defaults to a `globalThis.crypto.getRandomValues`-backed Rng
- `aad?: Uint8Array` — optional additional authenticated data
### `defineEncryptedBitSchema<TBody>(bodyCodec, options): EncryptedBitSchema<TBody>`
Returns:
- `pack(data: TBody): Promise<Uint8Array>`
- throws `CRYPTO_ENCRYPT_FAILED` on AEAD failure (very rare — usually a
misbehaving Rng or wrong key length)
- throws `CRYPTO_RNG_UNAVAILABLE` on first call if Web Crypto is missing
and no Rng was supplied
- `unpack(bytes: Uint8Array): Promise<TBody>`
- throws `CRYPTO_DECRYPT_FAILED` on tag mismatch / wrong key / too-short
input
- throws `CRYPTO_ENVELOPE_VERSION_MISMATCH` on wire-format/algorithm
mismatch
- `algorithm`, `version`, `nonceLength` (read-only)
### Algorithm tags (wire format)
```text
0 AES-256-GCM (12-byte nonce)
1 ChaCha20-Poly1305 (12-byte nonce)
2 XChaCha20-Poly1305 (24-byte nonce, default)
```
These numbers are wire-format and never renumbered. New algorithms append
to the table.
## Implementation Notes
- Nonces are caller-Rng-driven, never derived from message contents. The
default Rng wraps `crypto.getRandomValues` directly. Tests use a
seeded mulberry32 Rng to assert reproducibility — production deployments
must use a CSPRNG.
- AEAD failures are wrapped with `BitpackerError` so consumers don't need
to import the underlying `@suaveplan/crypto` `CryptoError` to handle
decrypt failures uniformly with the other bit-packer-crypto primitives.
- The 3-byte header is intentionally minimal — version + alg + nonceLen
is enough to dispatch a future algorithm without breaking older
payloads. The `nonceLen` byte is technically derivable from the
`algTag`, but storing it explicitly makes the parser simpler and
catches an algorithm/nonce mismatch as a wire-format error instead of
a more confusing AEAD failure.
- The body codec runs on the _plaintext_ — `pack` calls `bodyCodec.pack`
before encrypting, `unpack` calls `bodyCodec.unpack` after decrypting.
This means the body codec sees full plaintext and must be trusted not
to leak side-channels.
- AEAD encrypt/decrypt are imported directly from `@suaveplan/crypto`'s
main barrel; no engine override is exposed. If you need an engine
override (e.g. for a hardware-accelerated AEAD), build a custom Rng
wrapper or use `@suaveplan/crypto`'s engine-pluggable API directly
outside this primitive.

View File

@@ -0,0 +1,142 @@
# EnvelopeSchema
A typed, version-aware envelope schema for bit-packed payloads. Wraps a
caller-supplied `BodyCodec` with a `[magic][formatVersion][body]` frame so the
wire format is self-describing on the magic + version dimensions, while the
body remains pluggable. Replaces the manual
`defineBitSchema([{magic}, {formatVersion}, ...])` pattern that suaveplan
crypto, packer, and other consumers had been re-authoring.
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Many suaveplan wire formats begin with a magic number + format version: chain
bundles (`SVCB`), public bundles, the chunked packer, and several network
encoders all do the same thing manually. The duplication isn't just code
churn — every callsite is a place where the magic/version contract can drift
out of sync with its parser. `EnvelopeSchema` consolidates the pattern and
makes "this byte string belongs to schema X version Y" a first-class
runtime check.
The body region is opaque to the envelope itself; encoding is delegated to a
`BodyCodec<TBody>` (`pack` + `unpack`). The bundled `rawBytesCodec()` is
provided for callers whose bodies are already-encoded bytes (typical when
composing with `SignedBitSchema` or `EncryptedBitSchema`); for structured
bodies, callers pass a codec built from the legacy `defineBitSchema(...)`
or from any encoder satisfying the two-method contract.
## Features
- `pack(body)` produces `[magic][formatVersion][bodyCodec.pack(body)]`
- `parse(bytes)` extracts magic + version + raw body bytes WITHOUT decoding
the body — enabling forward-compat dispatch when a future version arrives
- `parseStrict(bytes)` validates magic + version, then decodes the body to
`TBody` via `bodyCodec.unpack`
- Mismatched magic throws `BitpackerError` with code
`CRYPTO_ENVELOPE_MAGIC_MISMATCH` and `context.{expected,actual}`
- Mismatched version throws `BitpackerError` with code
`CRYPTO_ENVELOPE_VERSION_MISMATCH` and `context.{expected,actual}`
- Zod-validated options at construction; unknown keys rejected
- OTel spans `bitpacker-crypto.envelope-pack` and
`bitpacker-crypto.envelope-parse` carry `bitpacker.crypto.payload_bytes`
- Stays codec-agnostic so a future Protobuf-bodied envelope or a
schema-fingerprinted variant requires zero changes to envelope itself
## Usage: Basic Example
```ts
import {
type BodyCodec,
defineEnvelopeSchema,
} from "@suaveplan/bitpacker-crypto";
import { defineBitSchema } from "@suaveplan/bitpacker";
interface Header {
foo: number;
bar: number;
}
const headerSchema = defineBitSchema<Header>([
{ name: "foo", type: "uint", bits: 8 },
{ name: "bar", type: "uint", bits: 16 },
]);
const codec: BodyCodec<Header> = {
pack: (body) => headerSchema.pack(body),
unpack: (bytes) => headerSchema.unpack(bytes),
};
const envelope = defineEnvelopeSchema<Header>({
magic: new Uint8Array([0x53, 0x56, 0x43, 0x42]), // "SVCB"
formatVersion: 1,
bodyCodec: codec,
});
const bytes = envelope.pack({ foo: 7, bar: 1234 });
const { foo, bar } = envelope.parseStrict(bytes); // typed: Header
```
## Usage: Advanced Example — forward-compat dispatch
Use `parse` to peek at magic + version before deciding which decoder to run,
so a v1 reader can detect v2 envelopes and route accordingly:
```ts
import { defineEnvelopeSchema } from "@suaveplan/bitpacker-crypto";
const envV1 = defineEnvelopeSchema({ magic, formatVersion: 1, bodyCodec: codecV1 });
const envV2 = defineEnvelopeSchema({ magic, formatVersion: 2, bodyCodec: codecV2 });
function decode(bytes: Uint8Array) {
const partial = envV1.parse(bytes);
if (partial.formatVersion === 1) return envV1.parseStrict(bytes);
if (partial.formatVersion === 2) return envV2.parseStrict(bytes);
throw new Error(`Unsupported envelope version: ${partial.formatVersion}`);
}
```
The body bytes are not consumed by `parse`, so the same input can be passed
back to `parseStrict` of the matching version without re-allocation cost on
the magic+version prefix.
## API Reference
### `interface BodyCodec<TBody>`
- `pack(body: TBody): Uint8Array` — encode the body
- `unpack(bytes: Uint8Array): TBody` — decode the body
### `interface EnvelopeSchemaOptions<TBody>`
- `magic: Uint8Array` — non-empty
- `formatVersion: number` — integer 0..255
- `bodyCodec: BodyCodec<TBody>` — body encoding hook
### `defineEnvelopeSchema<TBody>(options): EnvelopeSchema<TBody>`
Returns:
- `magic: Uint8Array`
- `formatVersion: number`
- `pack(body: TBody): Uint8Array`
- `parse(bytes: Uint8Array): { magic, formatVersion, bodyBytes }`
- `parseStrict(bytes: Uint8Array): TBody`
- throws `CRYPTO_ENVELOPE_MAGIC_MISMATCH` on magic mismatch
- throws `CRYPTO_ENVELOPE_VERSION_MISMATCH` on version mismatch
### `rawBytesCodec(): BodyCodec<Uint8Array>`
Pass-through codec for bodies that are already-encoded raw bytes — useful
when composing with `SignedBitSchema` or `EncryptedBitSchema` whose `pack`
output goes inside an envelope.
## Implementation Notes
- The envelope wire format is `[magic_bytes][1-byte u8 formatVersion][body_bytes]`. The body is NOT framed with a length prefix — the codec is expected to either be fixed-size or to consume the entire trailing buffer.
- `parse` throws `CRYPTO_ENVELOPE_MAGIC_MISMATCH` when input is shorter than `magic.length + 1` (the minimum header size). Magic and version validation in `parseStrict` happens against the parsed values, so a too-short input is caught at the parse stage.
- Magic comparison is constant-time-irrelevant — magic bytes are not secret. Equality is byte-wise. The error context includes hex-encoded `expected` and `actual` for diagnosability.
- The codec is held by reference; mutating the codec after `defineEnvelopeSchema(...)` returns will affect future `pack`/`parseStrict` calls. Callers should treat the codec as immutable in practice.
- OTel spans are emitted unconditionally (even when no SDK is wired) because they cost essentially nothing under the no-op tracer; production traces materialise once a tracer provider is installed.

View File

@@ -0,0 +1,127 @@
# SchemaFingerprint
Content-hash a `BitPackerSchema` so two parties can agree they're using
byte-identical schemas before they exchange payloads. The fingerprint is
computed over a canonical serialisation (sorted keys, no whitespace,
deterministic encoding), and the hash algorithm is pluggable via
`HashAlgorithmRegistry`.
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Whenever a producer and consumer of bit-packed bytes are not co-located —
mesh peers, drive replicas, network plugins, or two services that pinned
their schemas independently — they need a way to confirm "we're talking
the same shape" before risking a parse error on a half-mismatched schema.
A schema fingerprint compresses that question to a fixed-size digest that
can be compared in a handshake.
The fingerprint must be _order-insensitive_: a producer that declared
fields in declaration order `(foo, bar)` and a consumer that declared them
in the order `(bar, foo)` should fingerprint identically, because the wire
format itself is order-independent at the bit-packer level (object keys
participate, but their declaration order does not). Achieving that
requires canonical-serialisation, which this module owns.
## Features
- Default algorithm: `sha256` (32 bytes, cryptographically strong)
- Built-in alternatives: `xxhash32` (4 bytes, fast non-crypto), `crc32`
(4 bytes, fast non-crypto)
- Pluggable: register `blake3` (or any custom hash) via
`HashAlgorithmRegistry` and pass it as `registry` argument
- `assertSchemaFingerprint(schema, expected, ...)` is the single-call
handshake check; throws `CRYPTO_SCHEMA_FINGERPRINT_MISMATCH` with a
context that includes hex `expected` and `actual` for diagnosability
- Unknown algorithm name throws `CRYPTO_HASH_ALGORITHM_UNKNOWN`
- Functions inside the schema (e.g. `ConditionalSchema._if`) are rejected;
v1 fingerprintable schemas are data-shape only
- Constant-time digest comparison in `assertSchemaFingerprint` (so timing
side channels can't leak partial-match information)
- OTel span `bitpacker-crypto.fingerprint` with algorithm + payload_bytes
## Usage: Basic Example
```ts
import {
assertSchemaFingerprint,
schemaFingerprint,
} from "@suaveplan/bitpacker-crypto";
const schema = { foo: 255, bar: 65535 };
const fp = await schemaFingerprint(schema); // sha256 by default
// On the other side of a handshake, validate the schemas match:
await assertSchemaFingerprint(localSchema, fp);
```
## Usage: Advanced Example — registry-backed BLAKE3
```ts
import {
type HashAlgorithm,
createHashRegistry,
schemaFingerprint,
} from "@suaveplan/bitpacker-crypto";
import { blake3 as nobleBlake3 } from "@noble/hashes/blake3";
const blake3: HashAlgorithm = {
name: "blake3",
outputBytes: 32,
hash: (data) => Promise.resolve(nobleBlake3(data)),
};
const registry = createHashRegistry();
registry.register(blake3);
const fp = await schemaFingerprint(schema, "blake3", registry);
```
## API Reference
### `schemaFingerprint(schema, algorithm?, registry?): Promise<Uint8Array>`
- `schema: BitPackerSchema` — the schema to hash. Functions and
non-finite numbers are rejected.
- `algorithm: string = "sha256"` — name of a registered algorithm
- `registry: HashAlgorithmRegistry = defaultHashRegistry()` —
optional override
- **Throws** `CRYPTO_HASH_ALGORITHM_UNKNOWN` if the algorithm isn't
registered; `CRYPTO_SCHEMA_FINGERPRINT_MISMATCH` if the schema contains
unsupported value types (functions, NaN, Infinity).
### `assertSchemaFingerprint(schema, expected, algorithm?, registry?): Promise<void>`
- Computes the fingerprint and compares it byte-wise (constant-time) to
`expected`. Throws `CRYPTO_SCHEMA_FINGERPRINT_MISMATCH` on disagreement,
with `context.expected` and `context.actual` hex-encoded for
diagnosability.
### `canonicalize(schema): string`
Exposed for testing the canonical-serialisation invariant directly.
Returns the JSON-like canonical string (sorted keys, no whitespace).
## Implementation Notes
- The canonicalizer is recursive and supports: `null`, `undefined` (treated
as `null`), `number` (must be finite), `boolean`, `string`, `bigint`
(suffixed with `n`), `object` (keys sorted lexicographically), `array`
(order preserved). All other types throw `CRYPTO_SCHEMA_FINGERPRINT_MISMATCH`.
- `bigint` values are serialised as `123n` (with the `n` suffix) to keep
them distinguishable from numbers in the canonical form. JSON does not
natively support bigint, so this is a deliberate extension.
- Constant-time comparison in `assertSchemaFingerprint` matters even though
fingerprints are not secret — the operation is cheap, and using a
constant-time check avoids becoming a habit-of-non-constant-time-compare
at higher layers.
- The OTel span carries `bitpacker.crypto.algorithm` and
`bitpacker.crypto.payload_bytes` so a deployment can observe schema
agreement frequency and the size of canonicalised schemas.
- Function-bearing schemas (`ConditionalSchema`, `VersionSchema`) are out
of scope for v1 fingerprinting because there is no canonical way to hash
a function. A future version can add a side-channel (e.g. a stable name
registry mapping function identity → string) if a real consumer needs it.

View File

@@ -0,0 +1,119 @@
# HashAlgorithmRegistry
A pluggable registry of named hash algorithms used by every hashing-aware
primitive in `@suaveplan/bitpacker-crypto` (notably `SchemaFingerprint` and
`HashChainSchema`). Ships with three defaults out of the box: `sha256` (32
bytes, Web Crypto), `xxhash32` (4 bytes, in-tree), and `crc32` (4 bytes,
in-tree). Consumers register additional algorithms — e.g. `blake3` — via
`register`. Last-write-wins replacement is permitted and emits an OpenTelemetry
span so swap-ins are observable.
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The crypto stack avoids hard-coding a single hash function inside every
primitive that needs one. Instead, hashing is a first-class extension point:
primitives accept an optional `algorithm` name and an optional registry, and
the registry resolves the name to a concrete `HashAlgorithm`. This lets a
deployment swap in a faster sha256 (e.g. native node:crypto) or register an
entirely new algorithm without forking the primitive.
The registry shape is intentionally tiny — `register`, `resolve`, `has`,
`list` — because the surface area is also a maintenance surface area. There is
no `unregister`: defaults are guaranteed present on every fresh registry, and
test isolation is achieved by calling `createHashRegistry()` to get a private
instance instead of mutating the singleton.
## Features
- Three defaults bundled: `sha256` (Web Crypto async), `xxhash32` (in-tree
sync), `crc32` (in-tree sync, big-endian 4-byte digest)
- Uniform async `hash(data) → Promise<Uint8Array>` interface across sync and
async backends
- `register` is last-write-wins; replacing an existing algorithm emits a
`bitpacker-crypto.hash-registry.replaced` OTel span carrying the previous
and new `outputBytes` attributes
- Process-wide singleton via `defaultHashRegistry()`; isolated registries via
`createHashRegistry()`
- Unknown algorithm name throws `BitpackerError` with code
`CRYPTO_HASH_ALGORITHM_UNKNOWN` and `context.algorithm` for diagnosability
## Usage: Basic Example
```ts
import { defaultHashRegistry } from "@suaveplan/bitpacker-crypto";
const sha256 = defaultHashRegistry().resolve("sha256");
const digest = await sha256.hash(new TextEncoder().encode("hello"));
console.log(digest.length); // 32
```
## Usage: Advanced Example — registering BLAKE3
```ts
import {
type HashAlgorithm,
createHashRegistry,
} from "@suaveplan/bitpacker-crypto";
import { blake3 as nobleBlake3 } from "@noble/hashes/blake3";
const blake3: HashAlgorithm = {
name: "blake3",
outputBytes: 32,
hash: (data) => Promise.resolve(nobleBlake3(data)),
};
// Build an isolated registry so we don't pollute the singleton.
const registry = createHashRegistry();
registry.register(blake3);
// Use with SchemaFingerprint / HashChainSchema:
// schemaFingerprint(schema, "blake3", registry)
```
## API Reference
### `interface HashAlgorithm`
- `name: string` — stable identifier used for registry lookup and wire-format
tagging
- `outputBytes: number` — exact digest length
- `hash(data: Uint8Array): Promise<Uint8Array>` — compute the digest;
resolves to exactly `outputBytes` bytes
### `interface HashAlgorithmRegistry`
- `register(algorithm: HashAlgorithm): void` — last-write-wins; emits
`bitpacker-crypto.hash-registry.replaced` span when overwriting
- `resolve(name: string): HashAlgorithm` — throws `CRYPTO_HASH_ALGORITHM_UNKNOWN`
on miss
- `has(name: string): boolean`
- `list(): readonly string[]` — names in stable sort order
### `createHashRegistry(): HashAlgorithmRegistry`
Fresh registry pre-populated with the defaults. Independent of the singleton;
mutations stay local.
### `defaultHashRegistry(): HashAlgorithmRegistry`
Process-wide singleton. Lazily constructed on first call. Subsequent calls
return the same instance.
## Implementation Notes
- The interface is asynchronous because `sha256` is implemented over Web
Crypto's `crypto.subtle.digest`, which returns a Promise. Synchronous
algorithms wrap their result with `Promise.resolve` for uniformity.
- `crc32` digests are emitted big-endian (most-significant byte first) so the
byte order matches the algorithm's natural representation; `xxhash32`
digests are emitted little-endian to match the bit-packed checksums already
produced by `appendXxhash32`.
- Replacement intentionally does not throw. The OTel `replaced` span is the
observability hook for swap-ins; tests asserting registry hygiene should
monitor the span stream rather than rely on exception flow.
- `unregister` is intentionally absent. If you need a clean slate, build a
fresh registry with `createHashRegistry()` and use that one.

View File

@@ -0,0 +1,175 @@
# HashChainSchema
Append-only signed hash chain. Each entry carries a sequence number, the
hash of the previous entry, an opaque payload, and a host signature over
the bit-packed `(seq, prevHash, payload)` triplet. Tampering anywhere in
the chain is detected at the first bad index by `verifyChain`.
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Audit logs, replicated mesh state, and any "I cannot edit history,
only append to it" scenario need a chain shape: each new entry binds to
the previous via a hash, and the host signs the binding so a third party
can verify the chain wasn't forged. suaveplan has the building blocks —
`Authority`, `DomainTag`, `HashAlgorithmRegistry`, `BodyCodec` — but no
primitive that composes them into a chain. `HashChainSchema` is that
primitive.
The signing input is `(seq, prevHash, payload)`, NOT just `payload`. This
is load-bearing: a signature on a payload alone could be lifted into a
different position in the chain (different seq) or grafted onto a different
prev-link without invalidating the signature. By binding seq + prev-hash
into the signed bytes, the signature is non-portable across positions.
## Features
- `appendEntry(seq, prevHash, payload, authority)` — sign the triplet,
return a complete `HashChainEntry`
- `verifyEntry(entry, expectedPrevHash, hostPublicSlot)` — boolean
result, never throws on bad signature (callers may probe candidate
parents)
- `verifyChain(entries, hostPublicSlot)` — walks the chain in order;
returns `{valid: false, firstBadIndex}` on the first failure
- `packEntry(entry)` / `unpackEntry(bytes)` — wire-format codec for
storage / network transport
- `hashEntry(entry)` — compute the entry's digest, used as the next
entry's `prevHash`
- Pluggable hash algorithm via `HashAlgorithmRegistry` (default
`sha256` 32 bytes)
- Strict prev-hash length check at append time (must match algorithm's
`outputBytes`) — catches schema/algorithm mismatches before they
reach the wire
- AbortSignal threaded through `Authority.sign`
- OTel spans `bitpacker-crypto.hashchain-append` and
`bitpacker-crypto.hashchain-verify`
## Wire Format
```text
┌────────────┬────────────────────┬──────────────────────┬──────────────────┐
│ seq u64BE │ prevHash N bytes │ payload bit-packed │ signature 64 B │
└────────────┴────────────────────┴──────────────────────┴──────────────────┘
```
`N` is the chosen hash algorithm's `outputBytes` (32 for sha256). The
signature is 64 bytes (ed25519); other lengths are configurable via
`signatureLength`.
## Usage: Basic Example
```ts
import { defineHashChainSchema } from "@suaveplan/bitpacker-crypto";
import { defineBitSchema } from "@suaveplan/bitpacker";
import { DOMAIN_BITPACKER_HASHCHAIN_V1 } from "@suaveplan/crypto/domain-tags";
import { verifyAuthoritySignature } from "@suaveplan/crypto/authority";
interface AuditPayload { actorId: number; eventCode: number }
const auditSchema = defineBitSchema<AuditPayload>([
{ name: "actorId", type: "uint", bits: 32 },
{ name: "eventCode", type: "uint", bits: 16 },
]);
const audit = defineHashChainSchema<AuditPayload>({
domain: DOMAIN_BITPACKER_HASHCHAIN_V1,
verifier: verifyAuthoritySignature,
payloadCodec: {
pack: (b) => auditSchema.pack(b),
unpack: (bytes) => auditSchema.unpack(bytes),
},
});
let prevHash = new Uint8Array(audit.hashLength); // suaveplan sentinel
const e0 = await audit.appendEntry(0n, prevHash, { actorId: 1, eventCode: 100 }, auth);
prevHash = await audit.hashEntry(e0);
const e1 = await audit.appendEntry(1n, prevHash, { actorId: 1, eventCode: 200 }, auth);
const result = await audit.verifyChain([e0, e1], hostPublicSlot);
// → { valid: true }
```
## Usage: Advanced Example — BLAKE3-backed chain
```ts
import {
type HashAlgorithm,
createHashRegistry,
defineHashChainSchema,
} from "@suaveplan/bitpacker-crypto";
import { blake3 as nobleBlake3 } from "@noble/hashes/blake3";
const registry = createHashRegistry();
registry.register({
name: "blake3",
outputBytes: 32,
hash: (data) => Promise.resolve(nobleBlake3(data)),
});
const chain = defineHashChainSchema({
domain: DOMAIN_BITPACKER_HASHCHAIN_V1,
payloadCodec,
hashAlgorithm: "blake3",
hashRegistry: registry,
});
```
## API Reference
### `interface HashChainEntry<TPayload>`
- `seq: bigint` — non-negative sequence number, packed as u64 BE
- `prevHash: Uint8Array` — exactly `hashLength` bytes
- `payload: TPayload` — caller-typed body
- `hostSignature: Uint8Array` — exactly `signatureLength` bytes
### `interface HashChainSchemaOptions<TPayload>`
- `domain: DomainTag` — required
- `verifier: AuthorityVerifier` — required (typically
`verifyAuthoritySignature` from `@suaveplan/crypto/authority`)
- `slot?: SlotId = "sign"` — slot used by the host authority
- `payloadCodec: BodyCodec<TPayload>` — required
- `hashAlgorithm?: string = "sha256"` — must be registered
- `hashRegistry?: HashAlgorithmRegistry` — defaults to singleton
- `signatureLength?: number = 64`
### `defineHashChainSchema<TPayload>(options): HashChainSchema<TPayload>`
Returns:
- `hashEntry(entry): Promise<Uint8Array>` — `outputBytes`-byte digest
- `appendEntry(seq, prevHash, payload, authority, signal?): Promise<HashChainEntry<TPayload>>`
- throws `VALUE_OUT_OF_RANGE` if seq is negative or > 2^64-1
- throws `CRYPTO_KEY_INVALID` if prevHash length ≠ hashLength
- throws `CRYPTO_SIGN_FAILED` if signature length ≠ signatureLength
- `verifyEntry(entry, expectedPrevHash, hostPublicSlot): Promise<boolean>`
- `verifyChain(entries, hostPublicSlot): Promise<ChainVerifyResult>`
- `packEntry(entry): Uint8Array`
- `unpackEntry(bytes): HashChainEntry<TPayload>`
- throws `CRYPTO_VERIFY_FAILED` if bytes too short
## Implementation Notes
- The suaveplan entry's `prevHash` is caller-supplied — typically all-zero
bytes, but any deterministic value works as long as both producers and
consumers agree on it. `verifyChain` treats the first entry's
`prevHash` as the trust anchor (it is not derived from anything).
- Tamper detection is positional: `firstBadIndex` is the LOWEST index at
which either the prev-hash linkage or the signature failed. A chain
with multiple tamper points reports only the first; a strict caller
who needs to know every bad index should iterate `verifyEntry`
directly.
- The hash algorithm is resolved once at construction. Swapping the
registry's algorithm later does not affect already-built schemas. This
is deliberate: the chain's wire format is fixed at construction.
- `appendEntry` does NOT compute the new `prevHash` — it only appends.
Callers are responsible for calling `hashEntry(prev)` between appends.
This separation allows callers to batch-append from a known
starting hash without forcing every append to be sequential.
- `verifyChain` rebuilds the `expectedPrev` for index `i ≥ 1` by hashing
`entries[i-1]`. The suaveplan entry's `prevHash` is taken as the trust
anchor; if it does not match what the producer supplied, every chain
built atop it will fail at `firstBadIndex: 0`.

View File

@@ -0,0 +1,8 @@
## index
**Path:** `bitpacker-crypto/src/index.ts`
**Export:** See source for details.
### What it does
Part of the @suaveplan/ package.

View File

@@ -0,0 +1,131 @@
# MultiSignedBitSchema
Bit-packed payload co-signed by N independent authorities. Wire format is
`[bit-packed body][sig_1][sig_2]…[sig_N]`. Pinpoints which signer failed
on verify so a partial-trust caller can recover gracefully.
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Some suaveplan surfaces require multiple signatures to authorise a single
payload — multi-party transaction approval, threshold-equivalent ceremony
records, and any quorum-style action. Building this on top of single-signer
`SignedBitSchema` would force every callsite to roll its own concatenation
and per-signer verify loop. `MultiSignedBitSchema` consolidates the
pattern with strict count enforcement (a 3-signer schema cannot pack with
2 authorities) and with `firstBadSignerIndex` reporting on verify so the
error can carry the actionable detail of _which_ signer failed.
## Features
- N-of-N co-signing with `signerCount` baked into the schema
- Pack rejects wrong-count authority arrays with
`CRYPTO_MULTISIGN_COUNT_MISMATCH`
- Unpack rejects wrong-count public-slot arrays with
`CRYPTO_MULTISIGN_COUNT_MISMATCH`
- `verify` returns `{ valid, firstBadSignerIndex? }` so callers can
enumerate "which signature broke?" without exception flow
- `unpack` throws `CRYPTO_VERIFY_FAILED` whose context includes
`firstBadSignerIndex`
- Each authority signs the same payload bytes under the same domain;
signature ordering follows the authority list
- AbortSignal threaded through every `Authority.sign` call so a slow
signer can be cancelled
- Pluggable body codec (same `BodyCodec<T>` as `SignedBitSchema`)
- OTel spans `bitpacker-crypto.multisigned-pack` and
`bitpacker-crypto.multisigned-verify` with signer_count + payload_bytes
## Usage: Basic Example
```ts
import { defineMultiSignedBitSchema } from "@suaveplan/bitpacker-crypto";
import { defineBitSchema } from "@suaveplan/bitpacker";
import { DOMAIN_BITPACKER_MULTISIGN_V1 } from "@suaveplan/crypto/domain-tags";
import { verifyAuthoritySignature } from "@suaveplan/crypto/authority";
interface VoteBody { proposalId: number; vote: number }
const voteSchema = defineBitSchema<VoteBody>([
{ name: "proposalId", type: "uint", bits: 32 },
{ name: "vote", type: "uint", bits: 1 },
]);
const ms = defineMultiSignedBitSchema<VoteBody>(
{
pack: (b) => voteSchema.pack(b),
unpack: (bytes) => voteSchema.unpack(bytes),
},
{
domain: DOMAIN_BITPACKER_MULTISIGN_V1,
verifier: verifyAuthoritySignature,
signerCount: 3,
}
);
const packed = await ms.pack(
{ proposalId: 7, vote: 1 },
[authA, authB, authC]
);
const decoded = ms.unpack(packed, [pubA, pubB, pubC]);
```
## Usage: Advanced Example — graceful partial-trust recovery
```ts
const result = ms.verify(bytes, [pubA, pubB, pubC]);
if (!result.valid) {
// Tell the user EXACTLY which signer rejected the payload.
const failingSigner = signerNames[result.firstBadSignerIndex!];
throw new Error(`Quorum verify failed at signer "${failingSigner}"`);
}
```
## API Reference
### `interface MultiVerifyResult`
- `valid: boolean`
- `firstBadSignerIndex?: number` — present when `valid` is false; zero-indexed
### `interface MultiSignedBitSchemaOptions`
- `domain: DomainTag` — required
- `verifier: AuthorityVerifier` — required (typically
`verifyAuthoritySignature` from `@suaveplan/crypto/authority`)
- `slot?: SlotId = "sign"` — slot used by every authority
- `signerCount: number` — required, range 1..64
- `signatureLength?: number = 64` — wire-format signature length
### `defineMultiSignedBitSchema<T>(bodyCodec, options): MultiSignedBitSchema<T>`
Returns:
- `pack(data: T, authorities: readonly Authority[], signal?): Promise<Uint8Array>`
- `unpack(bytes: Uint8Array, signerPublicSlots: readonly KeySlotPublic<unknown>[]): T`
- `verify(bytes: Uint8Array, signerPublicSlots: readonly KeySlotPublic<unknown>[]): MultiVerifyResult`
- `domain`, `slot`, `signerCount`, `signatureLength`
## Implementation Notes
- Signatures are appended in authority list order and verified in the
same order. Re-ordering the public slot list at verify time will fail
every signature (ed25519 binds key to signature). This is intentional —
the wire format does not permute, so the schema's contract is
positional.
- Pack short-circuits early if any authority returns a wrong-length
signature, throwing `CRYPTO_SIGN_FAILED` with `signerIndex`. A
misbehaving authority cannot poison a partially-signed payload because
bytes are not assembled until every signature is ready.
- Verify does NOT short-circuit on the first failure when called via
`verify(...)` — that's a lie. It DOES short-circuit, returning
`firstBadSignerIndex`. The naming is deliberate: callers should not
rely on every signer being verified just because they asked to verify
N signatures. If you need exhaustive reporting (which signers failed,
which passed), iterate yourself with `verifyAuthoritySignature`.
- The `signerCount` upper bound (64) is conservative; raising it requires
no schema changes, but the on-wire payload grows linearly so very
large signer sets should consider threshold signing instead.
- AbortSignal is checked once per authority — a slow signer at index 5
can be cancelled, but signers 0-4 will already have signed.

View File

@@ -0,0 +1,158 @@
# SignedBitSchema
Bit-packed payload signed by an `Authority` under a fixed `DomainTag`. Wire
format is `[bit-packed body][signature]`. Verifies before decoding so a
tampered payload never even reaches the body codec. Replaces the ad-hoc
`BitPacker.pack(...) → sign(...) → concat(...)` pattern that crypto
chain bundles, public bundles, packer chunks, and game-server state-sync
encoders had been re-authoring.
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Every signed wire format in the stack follows the same recipe:
bit-pack a structured body, sign the bytes with an authority slot, frame
`[body][signature]`, then on the verify side strip the signature, verify it
against a parent's public slot, and only then decode the body. Five plus
suaveplan/upstream call-sites had been manually re-authoring this dance, each
slightly differently. `SignedBitSchema` consolidates the pattern so the
domain separation, signature length, sign-slot dispatch, and verify-then-decode
order are all driven by a single primitive.
The domain `DomainTag` is required, not optional, so a signature produced
under one domain (e.g. `DOMAIN_PUBLIC_KEYCHAIN_V1`) cannot be replayed
under a different domain (e.g. `DOMAIN_AUDIT_V1`) — the tag bytes
participate in the signing input via
`@suaveplan/crypto::buildSigningInput`. Cross-protocol replay is rejected
at the bytes-don't-match level rather than at any application-layer check.
## Features
- Pluggable body codec (`BodyCodec<T>`) so the body can be a legacy
`defineBitSchema` codec, a modern `BitPacker`-backed codec, an outer
envelope, or any future encoder satisfying the two-method contract
- `pack(data, authority, signal?)` is async — delegates signing to
`Authority.sign(domain, slot, payload, signal)` so HSM/threshold backends
Just Work without the primitive having to know
- `unpack(bytes, parentPublicSlot)` enforces verify-then-decode order —
body codec is NEVER invoked on a tampered payload
- `verify(bytes, parentPublicSlot)` returns `boolean` so callers can
enumerate candidate parent keys without exception flow
- `unpackSelf(bytes, getEmbeddedSlot)` supports trust-anchor bundles whose
own embedded sign slot is the verifier
- AbortSignal threaded through `Authority.sign` per suaveplan cancellation
rules
- `signatureLength` configurable (defaults to ed25519's 64) so future
primitives with different signature shapes inherit the schema for free
- OTel spans `bitpacker-crypto.signed-pack` and
`bitpacker-crypto.signed-verify` with `domain` + `payload_bytes` attrs
## Usage: Basic Example
```ts
import { defineSignedBitSchema } from "@suaveplan/bitpacker-crypto";
import { defineBitSchema } from "@suaveplan/bitpacker";
import { DOMAIN_PUBLIC_KEYCHAIN_V1 } from "@suaveplan/crypto/domain-tags";
import { verifyAuthoritySignature } from "@suaveplan/crypto/authority";
interface LinkBody {
parentSeq: number;
purposeBits: number;
}
const linkSchema = defineBitSchema<LinkBody>([
{ name: "parentSeq", type: "uint", bits: 32 },
{ name: "purposeBits", type: "uint", bits: 16 },
]);
const link = defineSignedBitSchema<LinkBody>(
{
pack: (b) => linkSchema.pack(b),
unpack: (bytes) => linkSchema.unpack(bytes),
},
{
domain: DOMAIN_PUBLIC_KEYCHAIN_V1,
verifier: verifyAuthoritySignature,
}
);
const packed = await link.pack(
{ parentSeq: 7, purposeBits: 0xf0 },
authority
);
const decoded = link.unpack(packed, parentPublicSlot);
```
The `verifier` option is required and accepts an `AuthorityVerifier`
function (`(domain, publicSlot, payload, signature) => boolean`). The
canonical implementation is `verifyAuthoritySignature` from
`@suaveplan/crypto/authority`. Passing it via DI keeps
`@suaveplan/bitpacker-crypto` free of any runtime dependency on
`@suaveplan/crypto`, which is what allows future
`crypto::ChainBundle`-style consumers to adopt this primitive without
inducing a circular package dependency.
## Usage: Advanced Example — trust anchor via `unpackSelf`
```ts
const trustAnchor = defineSignedBitSchema<TrustAnchorBundle>(codec, {
domain: DOMAIN_BITPACKER_SIGNED_V1,
});
// Verify against the bundle's OWN embedded sign slot.
const bundle = trustAnchor.unpackSelf(bytes, (decoded) => decoded.embeddedSlot);
```
`unpackSelf` decodes the body once to extract the embedded sign slot, then
verifies the signature against that slot. Only used for the root link of a
chain (every other link verifies against its parent).
## API Reference
### `interface SignedBitSchemaOptions`
- `domain: DomainTag` (required) — the domain under which the body is signed
- `verifier: AuthorityVerifier` (required) — pure verifier function;
typically `verifyAuthoritySignature` from `@suaveplan/crypto/authority`
- `slot?: SlotId = "sign"` — the authority slot used for signing
- `signatureLength?: number = 64` — wire-format signature length
### `defineSignedBitSchema<T>(bodyCodec, options): SignedBitSchema<T>`
Returns:
- `pack(data: T, authority: Authority, signal?: AbortSignal): Promise<Uint8Array>`
- throws `CRYPTO_SIGN_FAILED` if the authority returns a wrong-length
signature
- `unpack(bytes: Uint8Array, parentPublicSlot: KeySlotPublic<unknown>): T`
- throws `CRYPTO_VERIFY_FAILED` on verify failure (BEFORE body decode)
- `verify(bytes: Uint8Array, parentPublicSlot: KeySlotPublic<unknown>): boolean`
- `unpackSelf(bytes: Uint8Array, getEmbeddedSlot: (decoded: T) => KeySlotPublic<unknown>): T`
- `domain: DomainTag`, `slot: SlotId`, `signatureLength: number`
## Implementation Notes
- The order `verify → decode` is load-bearing. A tampered byte string must
never reach the body codec because the codec may have side effects, may
log unexpected payloads, or may be slow. Verify-then-decode is asserted
by a spec-conformance test that injects a tracking codec and checks
`decodeCalls === 0` after a tampered unpack.
- `verify(bytes, slot)` deliberately swallows structural exceptions and
returns `false` so callers can probe candidate parent keys against an
arbitrary byte string without try/catch boilerplate. `unpack(...)` is
the throw-on-failure variant.
- The body codec is held by reference; mutating it after construction
changes future pack/unpack behavior. Treat the codec as immutable.
- `Authority.sign` is async because real backends (HSM, TPM, threshold
signing, network-attached signers) are async. Even the in-memory v1
authority returns a Promise to preserve the contract.
- Signature length is enforced on pack to surface a misbehaving authority
early. The check is identity-checking the `Promise<Uint8Array>.length`,
not the algorithm — a future ECDSA-backed authority signing into an
ed25519-shaped slot would be caught here.
- OTel spans cost essentially nothing under the no-op tracer; they
materialise once a real provider is wired. The spans deliberately do
NOT carry the signature itself (sensitive material) or the body bytes
(potentially sensitive); only metadata: domain, slot, payload_bytes.

View File

@@ -0,0 +1,31 @@
## xxhash32
**Path:** `src/xxhash32.ts`
**Export:** `calculateXxhash32`, `verifyXxhash32`, `packXxhash32`, `unpackXxhash32`, `appendXxhash32`, `verifyAndStripXxhash32`
### What it does
Pure TypeScript implementation of the XXHash32 non-cryptographic hash algorithm (spec v0.8.2). No external dependencies.
### Key API
| Function | Description |
|----------|-------------|
| `calculateXxhash32(data, seed?)` | Compute 32-bit hash of a `Uint8Array` |
| `verifyXxhash32(data, expected, seed?)` | Verify a hash matches |
| `packXxhash32(checksum)` | Pack hash as 4 little-endian bytes |
| `unpackXxhash32(bytes, offset?)` | Unpack 4 bytes to hash value |
| `appendXxhash32(data, seed?)` | Append 4-byte hash to data |
| `verifyAndStripXxhash32(data, seed?)` | Verify and strip appended hash |
### Constants
- `PRIME1` = `0x9e3779b1`
- `PRIME2` = `0x85ebca77`
- `PRIME3` = `0xc2b2ae3d`
- `PRIME4` = `0x27d4eb2f`
- `PRIME5` = `0x165667b1`
### Used by
- Checksum validation in downstream network protocols