first commit
This commit is contained in:
155
docs/foundation/bitpacker/bitpacker-crypto/README.md
Normal file
155
docs/foundation/bitpacker/bitpacker-crypto/README.md
Normal 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 |
|
||||
|
||||
150
docs/foundation/bitpacker/bitpacker-crypto/SUPERSEDED.md
Normal file
150
docs/foundation/bitpacker/bitpacker-crypto/SUPERSEDED.md
Normal 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.
|
||||
89
docs/foundation/bitpacker/bitpacker-crypto/src/crc32.md
Normal file
89
docs/foundation/bitpacker/bitpacker-crypto/src/crc32.md
Normal 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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
8
docs/foundation/bitpacker/bitpacker-crypto/src/index.md
Normal file
8
docs/foundation/bitpacker/bitpacker-crypto/src/index.md
Normal 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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
31
docs/foundation/bitpacker/bitpacker-crypto/src/xxhash32.md
Normal file
31
docs/foundation/bitpacker/bitpacker-crypto/src/xxhash32.md
Normal 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
|
||||
Reference in New Issue
Block a user