Initial Commit

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

View File

@@ -0,0 +1,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,134 @@
{
"name": "@suaveplan/bitpacker-crypto",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Bit-packer-aware crypto primitives \u2014 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.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./signed": {
"import": {
"types": "./dist/signed/signed-bitschema.d.ts",
"default": "./dist/signed/signed-bitschema.js"
},
"require": {
"types": "./dist/signed/signed-bitschema.d.ts",
"default": "./dist/signed/signed-bitschema.js"
}
},
"./encrypted": {
"import": {
"types": "./dist/encrypted/encrypted-bitschema.d.ts",
"default": "./dist/encrypted/encrypted-bitschema.js"
},
"require": {
"types": "./dist/encrypted/encrypted-bitschema.d.ts",
"default": "./dist/encrypted/encrypted-bitschema.js"
}
},
"./envelope": {
"import": {
"types": "./dist/envelope/envelope-schema.d.ts",
"default": "./dist/envelope/envelope-schema.js"
},
"require": {
"types": "./dist/envelope/envelope-schema.d.ts",
"default": "./dist/envelope/envelope-schema.js"
}
},
"./hashchain": {
"import": {
"types": "./dist/hashchain/hashchain-schema.d.ts",
"default": "./dist/hashchain/hashchain-schema.js"
},
"require": {
"types": "./dist/hashchain/hashchain-schema.d.ts",
"default": "./dist/hashchain/hashchain-schema.js"
}
},
"./multisigned": {
"import": {
"types": "./dist/multisigned/multisigned-bitschema.d.ts",
"default": "./dist/multisigned/multisigned-bitschema.js"
},
"require": {
"types": "./dist/multisigned/multisigned-bitschema.d.ts",
"default": "./dist/multisigned/multisigned-bitschema.js"
}
},
"./fingerprint": {
"import": {
"types": "./dist/fingerprint/schema-fingerprint.d.ts",
"default": "./dist/fingerprint/schema-fingerprint.js"
},
"require": {
"types": "./dist/fingerprint/schema-fingerprint.d.ts",
"default": "./dist/fingerprint/schema-fingerprint.js"
}
},
"./hash-registry": {
"import": {
"types": "./dist/hash-registry/hash-registry.d.ts",
"default": "./dist/hash-registry/hash-registry.js"
},
"require": {
"types": "./dist/hash-registry/hash-registry.d.ts",
"default": "./dist/hash-registry/hash-registry.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/bitpacker": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/crypto-cipher": "workspace:^",
"@suaveplan/crypto-core": "workspace:^",
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md",
"SUPERSEDED.md"
]
}

View File

@@ -0,0 +1,154 @@
/**
* Cross-primitive composition tests.
*
* Pins the spec invariant that the rewrite primitives compose end-to-end
* without coupling. The genesis design lets a consumer wrap an
* `EnvelopeSchema` body inside a `SignedBitSchema`, embed an
* `EncryptedBitSchema` body inside an `EnvelopeSchema`, and so on.
*/
import {
BITPACKER_CODES,
type BitpackerError,
defineBitSchema,
} from "@suaveplan/bitpacker";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { cryptoAead, cryptoVerifier } from "../tests/helpers/crypto-deps.js";
import { createFakeAuthority } from "../tests/helpers/fake-authority.js";
import { DOMAIN_BITPACKER_SIGNED_V1 } from "../tests/helpers/test-domains.js";
import { AEAD_KEY_LENGTH } from "./encrypted/encrypted-bitschema.js";
import {
type BodyCodec,
defineEncryptedBitSchema,
defineEnvelopeSchema,
defineSignedBitSchema,
} from "./index.js";
interface Inner {
foo: number;
bar: number;
}
const INNER_CODEC: BodyCodec<Inner> = (() => {
const schema = defineBitSchema<Inner>([
{ name: "foo", type: "uint", bits: 8 },
{ name: "bar", type: "uint", bits: 16 },
]);
return {
pack: (b) => schema.pack(b),
unpack: (bytes) => schema.unpack(bytes),
};
})();
const MAGIC = new Uint8Array([0x53, 0x56, 0x43, 0x42]);
describe("composition — SignedBitSchema over EnvelopeSchema", () => {
it("SignedBitSchema over EnvelopeSchema round-trips", {
timeout: 10_000,
}, async () => {
// Build inner envelope (magic+version+inner-body).
const env = defineEnvelopeSchema<Inner>({
magic: MAGIC,
formatVersion: 1,
bodyCodec: INNER_CODEC,
});
// Wrap the envelope as a body codec for the signer.
const envAsCodec: BodyCodec<Inner> = {
pack: (data) => env.pack(data),
unpack: (bytes) => env.parseStrict(bytes),
};
const signed = defineSignedBitSchema<Inner>(envAsCodec, {
domain: DOMAIN_BITPACKER_SIGNED_V1,
verifier: cryptoVerifier,
});
const { authority, publicSlot } = createFakeAuthority();
const wrapped = await signed.pack({ foo: 7, bar: 1234 }, authority);
const recovered = signed.unpack(wrapped, publicSlot);
expect(recovered.foo).toBe(7);
expect(recovered.bar).toBe(1234);
// Tamper rejection still bubbles up — even through the envelope.
const tampered = new Uint8Array(wrapped);
tampered[0] = (tampered[0] ?? 0) ^ 0xff;
try {
signed.unpack(tampered, publicSlot);
throw new Error("expected throw");
} catch (err) {
const e = err as BitpackerError;
expect(e.code).toBe(BITPACKER_CODES.CRYPTO_VERIFY_FAILED);
}
});
});
describe("composition — EnvelopeSchema over EncryptedBitSchema", () => {
it("EnvelopeSchema wraps EncryptedBitSchema bytes", {
timeout: 10_000,
}, async () => {
const key = new Uint8Array(AEAD_KEY_LENGTH);
globalThis.crypto.getRandomValues(key);
const enc = defineEncryptedBitSchema<Inner>(INNER_CODEC, {
key,
aead: cryptoAead,
});
// The encrypted output is opaque bytes — the envelope's body is
// just those bytes (no further structure).
const encAsCodec: BodyCodec<Uint8Array> = {
pack: (b) => b,
unpack: (b) => b,
};
const env = defineEnvelopeSchema<Uint8Array>({
magic: MAGIC,
formatVersion: 1,
bodyCodec: encAsCodec,
});
const ciphertext = await enc.pack({ foo: 9, bar: 99 });
const enveloped = env.pack(ciphertext);
// Round-trip:
const peeled = env.parseStrict(enveloped);
const decoded = await enc.unpack(peeled);
expect(decoded.foo).toBe(9);
expect(decoded.bar).toBe(99);
});
});
describe("composition — three-way nesting Sign(Envelope(Encrypted))", () => {
it("survives a Sign(Envelope(Encrypted)) round-trip", {
timeout: 10_000,
}, async () => {
const key = new Uint8Array(AEAD_KEY_LENGTH);
globalThis.crypto.getRandomValues(key);
const enc = defineEncryptedBitSchema<Inner>(INNER_CODEC, {
key,
aead: cryptoAead,
});
const encAsCodec: BodyCodec<Uint8Array> = {
pack: (b) => b,
unpack: (b) => b,
};
const env = defineEnvelopeSchema<Uint8Array>({
magic: MAGIC,
formatVersion: 1,
bodyCodec: encAsCodec,
});
const envAsCodec: BodyCodec<Uint8Array> = {
pack: (b) => env.pack(b),
unpack: (bytes) => env.parseStrict(bytes),
};
const signed = defineSignedBitSchema<Uint8Array>(envAsCodec, {
domain: DOMAIN_BITPACKER_SIGNED_V1,
verifier: cryptoVerifier,
});
const { authority, publicSlot } = createFakeAuthority();
const ciphertext = await enc.pack({ foo: 11, bar: 22 });
const wrapped = await signed.pack(ciphertext, authority);
const recoveredCiphertext = signed.unpack(wrapped, publicSlot);
const recovered = await enc.unpack(recoveredCiphertext);
expect(recovered.foo).toBe(11);
expect(recovered.bar).toBe(22);
});
});

View File

@@ -0,0 +1,538 @@
/**
* Coverage-completion tests for defensive throws across the seven primitives.
* Each test exercises a code path that the per-feature tests don't reach
* because they require a misbehaving Authority, a wrong-length signature,
* an unusual schema value type, or a deliberately broken byte input.
*
* If any of these tests goes red, the corresponding code path's existing
* defensive throw is still load-bearing — do not delete it.
*/
import {
BITPACKER_CODES,
type BitpackerError,
defineBitSchema,
} from "@suaveplan/bitpacker";
import { describe, expect, it } from "@suaveplan/testing/runner";
import type {
AeadAlgorithm,
Authority,
DomainTag,
KeySlotPublic,
} from "@suaveplan/types";
import { cryptoAead, cryptoVerifier } from "../tests/helpers/crypto-deps.js";
import { createFakeAuthority } from "../tests/helpers/fake-authority.js";
import {
DOMAIN_BITPACKER_HASHCHAIN_V1,
DOMAIN_BITPACKER_MULTISIGN_V1,
DOMAIN_BITPACKER_SIGNED_V1,
} from "../tests/helpers/test-domains.js";
import { AEAD_KEY_LENGTH } from "./encrypted/encrypted-bitschema.js";
import { _resetDefaultHashRegistryForTesting } from "./hash-registry/hash-registry.js";
import {
type BodyCodec,
canonicalizeSchema,
defineEncryptedBitSchema,
defineEnvelopeSchema,
defineHashChainSchema,
defineMultiSignedBitSchema,
defineSignedBitSchema,
} from "./index.js";
interface Body {
foo: number;
}
const codec: BodyCodec<Body> = (() => {
const schema = defineBitSchema<Body>([
{ name: "foo", type: "uint", bits: 8 },
]);
return {
pack: (b) => schema.pack(b),
unpack: (bytes) => schema.unpack(bytes),
};
})();
interface BadAuthorityOptions {
readonly badSignatureLength?: number;
readonly badPublicKeyAlgorithm?: number;
}
function createBadAuthority(opts: BadAuthorityOptions = {}): {
authority: Authority;
publicSlot: KeySlotPublic<unknown>;
} {
const real = createFakeAuthority();
const wrapped: Authority = {
sign: async (
_domain: DomainTag,
_slot,
_payload: Uint8Array,
_signal?: AbortSignal
): Promise<Uint8Array> => {
if (opts.badSignatureLength !== undefined) {
return new Uint8Array(opts.badSignatureLength);
}
return real.authority.sign(_domain, _slot, _payload, _signal);
},
getPublicSlot: real.authority.getPublicSlot.bind(real.authority),
hasSlot: real.authority.hasSlot.bind(real.authority),
dispose: real.authority.dispose.bind(real.authority),
get disposed() {
return real.authority.disposed;
},
};
const publicSlot =
opts.badPublicKeyAlgorithm !== undefined
? ({
...real.publicSlot,
algorithm: opts.badPublicKeyAlgorithm as never,
} as KeySlotPublic<unknown>)
: real.publicSlot;
return { authority: wrapped, publicSlot };
}
describe("coverage — envelope length-different magic rejection", () => {
it("parseStrict rejects when magic lengths differ", {
timeout: 5000,
}, () => {
const env4 = defineEnvelopeSchema<Body>({
magic: new Uint8Array([1, 2, 3, 4]),
formatVersion: 1,
bodyCodec: codec,
});
const env5 = defineEnvelopeSchema<Body>({
magic: new Uint8Array([1, 2, 3, 4, 5]),
formatVersion: 1,
bodyCodec: codec,
});
const bytes4 = env4.pack({ foo: 1 });
try {
env5.parseStrict(bytes4);
throw new Error("expected throw");
} catch (err) {
const e = err as BitpackerError;
expect(e.code).toBe(BITPACKER_CODES.CRYPTO_ENVELOPE_MAGIC_MISMATCH);
}
});
});
describe("coverage — fingerprint canonicalize unsupported type", () => {
it("rejects Symbol values via 'unsupported-type'", {
timeout: 5000,
}, () => {
const sym = Symbol("schema");
try {
canonicalizeSchema(
sym as unknown as Parameters<typeof canonicalizeSchema>[0]
);
throw new Error("expected throw");
} catch (err) {
const e = err as BitpackerError;
expect(e.code).toBe(
BITPACKER_CODES.CRYPTO_SCHEMA_FINGERPRINT_MISMATCH
);
expect(e.context).toMatchObject({ reason: "unsupported-type" });
}
});
it("treats undefined values like null in canonicalize", {
timeout: 5000,
}, () => {
const node = { a: undefined, b: 1 };
const out = canonicalizeSchema(
node as unknown as Parameters<typeof canonicalizeSchema>[0]
);
expect(out).toBe('{"a":null,"b":1}');
});
it("encodes bigint values with the n suffix", { timeout: 5000 }, () => {
const out = canonicalizeSchema(
123n as unknown as Parameters<typeof canonicalizeSchema>[0]
);
expect(out).toBe("123n");
});
it("assertSchemaFingerprint rejects wrong-length expected digest", {
timeout: 5000,
}, async () => {
const { assertSchemaFingerprint } = await import(
"./fingerprint/schema-fingerprint.js"
);
try {
await assertSchemaFingerprint({ x: 1 }, new Uint8Array(8));
throw new Error("expected throw");
} catch (err) {
const e = err as BitpackerError;
expect(e.code).toBe(
BITPACKER_CODES.CRYPTO_SCHEMA_FINGERPRINT_MISMATCH
);
}
});
});
describe("coverage — hash-registry default Rng nextFloat path", () => {
it("default sha256 algorithm rejects when crypto.subtle missing", {
timeout: 5000,
}, async () => {
_resetDefaultHashRegistryForTesting();
// Replace the whole globalThis.crypto with a stand-in whose `subtle`
// is undefined. We can't `defineProperty` on the existing
// `globalThis.crypto.subtle` because Bun marks it non-configurable;
// `globalThis.crypto` itself is configurable on every supported
// runtime, so swap the parent object instead.
const original = globalThis.crypto;
const stand = {
getRandomValues: original.getRandomValues.bind(original),
randomUUID: original.randomUUID.bind(original),
subtle: undefined as unknown as SubtleCrypto,
};
Object.defineProperty(globalThis, "crypto", {
value: stand,
configurable: true,
writable: true,
});
try {
// Re-import to get a fresh registry that will hit the missing-subtle path.
const { defaultHashRegistry } = await import(
"./hash-registry/hash-registry.js"
);
const sha256 = defaultHashRegistry().resolve("sha256");
try {
await sha256.hash(new Uint8Array(8));
throw new Error("expected throw");
} catch (err) {
const e = err as BitpackerError;
expect(e.code).toBe(BITPACKER_CODES.CRYPTO_RNG_UNAVAILABLE);
}
} finally {
Object.defineProperty(globalThis, "crypto", {
value: original,
configurable: true,
writable: true,
});
_resetDefaultHashRegistryForTesting();
}
});
});
describe("coverage — encrypted-bitschema defensive throws", () => {
it("default Rng nextUint32 / nextFloat return random / [0,1) values", {
timeout: 5000,
}, async () => {
const { defaultCryptoRng } = await import(
"./encrypted/encrypted-bitschema.js"
);
const rng = defaultCryptoRng();
const word = rng.nextUint32();
expect(Number.isInteger(word)).toBe(true);
expect(word).toBeGreaterThanOrEqual(0);
expect(word).toBeLessThanOrEqual(0xffffffff);
const f = rng.nextFloat();
expect(f).toBeGreaterThanOrEqual(0);
expect(f).toBeLessThan(1);
});
it("default Rng nextUint32 throws when crypto.getRandomValues missing", {
timeout: 5000,
}, async () => {
const { defaultCryptoRng } = await import(
"./encrypted/encrypted-bitschema.js"
);
const rng = defaultCryptoRng();
const saved = globalThis.crypto.getRandomValues;
Object.defineProperty(globalThis.crypto, "getRandomValues", {
value: undefined,
configurable: true,
writable: true,
});
try {
try {
rng.nextUint32();
throw new Error("expected throw");
} catch (err) {
const e = err as BitpackerError;
expect(e.code).toBe(BITPACKER_CODES.CRYPTO_RNG_UNAVAILABLE);
}
} finally {
Object.defineProperty(globalThis.crypto, "getRandomValues", {
value: saved,
configurable: true,
writable: true,
});
}
});
it("CRYPTO_ENCRYPT_FAILED when AEAD encrypt throws (wrong-length key already validated upstream; force via algorithm coercion)", {
timeout: 5000,
}, async () => {
// Force aeadEncrypt to throw by giving an algorithm string the
// crypto package doesn't recognize. We bypass Zod by casting at the
// type boundary.
const ec = defineEncryptedBitSchema<Body>(codec, {
key: new Uint8Array(AEAD_KEY_LENGTH),
aead: cryptoAead,
algorithm: "AES-256-GCM" as AeadAlgorithm,
});
// Mutate via Object.defineProperty: the schema captured `algorithm`
// by closure — we can't easily corrupt it. Instead, cause a failure
// by constructing an envelope whose Rng returns a wrong-length nonce.
const ec2 = defineEncryptedBitSchema<Body>(codec, {
key: new Uint8Array(AEAD_KEY_LENGTH),
aead: cryptoAead,
algorithm: "AES-256-GCM",
rng: {
nextUint32: () => 0,
nextFloat: () => 0,
// wrong-length nonce — AEAD will throw
nextBytes: (_n) => new Uint8Array(_n + 4),
},
});
try {
await ec2.pack({ foo: 1 });
throw new Error("expected throw");
} catch (err) {
const e = err as BitpackerError;
expect(e.code).toBe(BITPACKER_CODES.CRYPTO_ENCRYPT_FAILED);
}
// Sanity: the first ec still works.
await ec.pack({ foo: 2 });
});
it("CRYPTO_ENVELOPE_VERSION_MISMATCH on bad version u8", {
timeout: 5000,
}, async () => {
const ec = defineEncryptedBitSchema<Body>(codec, {
key: new Uint8Array(AEAD_KEY_LENGTH),
aead: cryptoAead,
});
const packed = await ec.pack({ foo: 1 });
const bad = new Uint8Array(packed);
bad[0] = 99; // mutate version
try {
await ec.unpack(bad);
throw new Error("expected throw");
} catch (err) {
const e = err as BitpackerError;
expect(e.code).toBe(
BITPACKER_CODES.CRYPTO_ENVELOPE_VERSION_MISMATCH
);
}
});
it("CRYPTO_ENVELOPE_VERSION_MISMATCH on bad nonceLen byte", {
timeout: 5000,
}, async () => {
const ec = defineEncryptedBitSchema<Body>(codec, {
key: new Uint8Array(AEAD_KEY_LENGTH),
aead: cryptoAead,
});
const packed = await ec.pack({ foo: 1 });
const bad = new Uint8Array(packed);
bad[2] = 1; // wrong nonce length
try {
await ec.unpack(bad);
throw new Error("expected throw");
} catch (err) {
const e = err as BitpackerError;
expect(e.code).toBe(
BITPACKER_CODES.CRYPTO_ENVELOPE_VERSION_MISMATCH
);
}
});
});
describe("coverage — signed-bitschema defensive throws", () => {
it("CRYPTO_SIGN_FAILED when authority returns wrong-length signature", {
timeout: 10_000,
}, async () => {
const { authority } = createBadAuthority({ badSignatureLength: 16 });
const ss = defineSignedBitSchema<Body>(codec, {
domain: DOMAIN_BITPACKER_SIGNED_V1,
verifier: cryptoVerifier,
});
try {
await ss.pack({ foo: 1 }, authority);
throw new Error("expected throw");
} catch (err) {
const e = err as BitpackerError;
expect(e.code).toBe(BITPACKER_CODES.CRYPTO_SIGN_FAILED);
}
});
it("verify returns false when verifyAuthoritySignature throws (wrong-algo slot)", {
timeout: 10_000,
}, async () => {
const { authority, publicSlot: realSlot } = createFakeAuthority();
const ss = defineSignedBitSchema<Body>(codec, {
domain: DOMAIN_BITPACKER_SIGNED_V1,
verifier: cryptoVerifier,
});
const packed = await ss.pack({ foo: 1 }, authority);
// Build a slot with non-ed25519 algorithm — verifyAuthoritySignature
// throws KEYCHAIN.SLOT_INVALID; SignedBitSchema's verify swallows it.
const badSlot: KeySlotPublic<unknown> = {
...realSlot,
algorithm: 99 as never, // not ED25519
};
expect(ss.verify(packed, badSlot)).toBe(false);
});
});
describe("coverage — multisigned defensive throws", () => {
it("CRYPTO_SIGN_FAILED when an authority returns wrong-length signature", {
timeout: 10_000,
}, async () => {
const good = createFakeAuthority();
const bad = createBadAuthority({ badSignatureLength: 16 });
const ms = defineMultiSignedBitSchema<Body>(codec, {
domain: DOMAIN_BITPACKER_MULTISIGN_V1,
verifier: cryptoVerifier,
signerCount: 2,
});
try {
await ms.pack({ foo: 1 }, [good.authority, bad.authority]);
throw new Error("expected throw");
} catch (err) {
const e = err as BitpackerError;
expect(e.code).toBe(BITPACKER_CODES.CRYPTO_SIGN_FAILED);
expect(e.context?.["signerIndex"]).toBe(1);
}
});
it("verify pinpoints failure when verifyAuthoritySignature throws on bad-algo slot", {
timeout: 10_000,
}, async () => {
const a = createFakeAuthority();
const b = createFakeAuthority();
const ms = defineMultiSignedBitSchema<Body>(codec, {
domain: DOMAIN_BITPACKER_MULTISIGN_V1,
verifier: cryptoVerifier,
signerCount: 2,
});
const packed = await ms.pack({ foo: 1 }, [a.authority, b.authority]);
const badSlot: KeySlotPublic<unknown> = {
...b.publicSlot,
algorithm: 99 as never,
};
const result = ms.verify(packed, [a.publicSlot, badSlot]);
expect(result.valid).toBe(false);
expect(result.firstBadSignerIndex).toBe(1);
});
});
describe("coverage — hashchain defensive throws", () => {
it("CRYPTO_SIGN_FAILED when authority returns wrong-length signature", {
timeout: 10_000,
}, async () => {
const { authority } = createBadAuthority({ badSignatureLength: 16 });
const chain = defineHashChainSchema<Body>({
domain: DOMAIN_BITPACKER_HASHCHAIN_V1,
verifier: cryptoVerifier,
payloadCodec: codec,
});
try {
await chain.appendEntry(
0n,
new Uint8Array(32),
{ foo: 1 },
authority
);
throw new Error("expected throw");
} catch (err) {
const e = err as BitpackerError;
expect(e.code).toBe(BITPACKER_CODES.CRYPTO_SIGN_FAILED);
}
});
it("packEntry rejects wrong-length hostSignature", {
timeout: 10_000,
}, async () => {
const chain = defineHashChainSchema<Body>({
domain: DOMAIN_BITPACKER_HASHCHAIN_V1,
verifier: cryptoVerifier,
payloadCodec: codec,
});
try {
chain.packEntry({
seq: 0n,
prevHash: new Uint8Array(32),
payload: { foo: 1 },
hostSignature: new Uint8Array(16), // wrong
});
throw new Error("expected throw");
} catch (err) {
const e = err as BitpackerError;
expect(e.code).toBe(BITPACKER_CODES.CRYPTO_SIGN_FAILED);
}
});
it("verifyEntry returns false when verifyAuthoritySignature throws on bad-algo slot", {
timeout: 10_000,
}, async () => {
const { authority, publicSlot } = createFakeAuthority();
const chain = defineHashChainSchema<Body>({
domain: DOMAIN_BITPACKER_HASHCHAIN_V1,
verifier: cryptoVerifier,
payloadCodec: codec,
});
const entry = await chain.appendEntry(
0n,
new Uint8Array(32),
{ foo: 1 },
authority
);
const badSlot: KeySlotPublic<unknown> = {
...publicSlot,
algorithm: 99 as never,
};
expect(
await chain.verifyEntry(entry, new Uint8Array(32), badSlot)
).toBe(false);
});
it("verifyEntry returns false on wrong-length expectedPrevHash", {
timeout: 10_000,
}, async () => {
const { authority, publicSlot } = createFakeAuthority();
const chain = defineHashChainSchema<Body>({
domain: DOMAIN_BITPACKER_HASHCHAIN_V1,
verifier: cryptoVerifier,
payloadCodec: codec,
});
const entry = await chain.appendEntry(
0n,
new Uint8Array(32),
{ foo: 1 },
authority
);
// Pass a 16-byte expected (sha256 expects 32) — bytesEqual returns
// false on length mismatch and the verify falls through to false.
expect(
await chain.verifyEntry(entry, new Uint8Array(16), publicSlot)
).toBe(false);
});
it("seq overflow rejects with VALUE_OUT_OF_RANGE", {
timeout: 5000,
}, async () => {
const { authority } = createFakeAuthority();
const chain = defineHashChainSchema<Body>({
domain: DOMAIN_BITPACKER_HASHCHAIN_V1,
verifier: cryptoVerifier,
payloadCodec: codec,
});
const huge = 1n << 70n; // > 2^64
try {
await chain.appendEntry(
huge,
new Uint8Array(32),
{ foo: 1 },
authority
);
throw new Error("expected throw");
} catch (err) {
const e = err as BitpackerError;
expect(e.code).toBe(BITPACKER_CODES.VALUE_OUT_OF_RANGE);
}
});
});

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,98 @@
/**
* CRC32 + Adler32 — fast non-cryptographic checksums commonly framed onto
* bit-packed payloads for low-overhead integrity. CRC32 is the IEEE-802.3
* polynomial (zlib variant); Adler32 follows RFC 1950.
*/
const CRC32_TABLE = new Uint32Array(256);
for (let i = 0; i < 256; i++) {
let c = i;
for (let j = 0; j < 8; j++) {
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
}
CRC32_TABLE[i] = c;
}
/** Compute CRC32 (IEEE-802.3 / zlib polynomial) of a byte array. */
export function crc32(data: Uint8Array): number {
let crc = ~0;
for (let i = 0; i < data.length; i++) {
crc = CRC32_TABLE[(crc ^ data[i]!) & 0xff]! ^ (crc >>> 8);
}
return ~crc >>> 0;
}
/** Compute Adler32 checksum (RFC 1950) of a byte array. */
export function adler32(data: Uint8Array): number {
let a = 1;
let b = 0;
for (let i = 0; i < data.length; i++) {
a = (a + data[i]!) % 65521;
b = (b + a) % 65521;
}
return ((b << 16) | a) >>> 0;
}
/** Append a 4-byte CRC32 checksum (big-endian) to a byte array. */
export function appendCrc32(data: Uint8Array): Uint8Array {
const checksum = crc32(data);
const result = new Uint8Array(data.length + 4);
result.set(data);
const view = new DataView(result.buffer);
view.setUint32(data.length, checksum, false);
return result;
}
/**
* Verify and strip a 4-byte CRC32 checksum from a byte array. Returns the
* original payload bytes if the trailing CRC matches; throws otherwise.
*/
export function verifyAndStripCrc32(data: Uint8Array): Uint8Array {
if (data.length < 4) {
throw new Error("Data too short for CRC32 verification");
}
const payload = data.slice(0, -4);
const expected = crc32(payload);
const actual = new DataView(data.buffer, data.byteOffset).getUint32(
data.length - 4,
false
);
if (expected !== actual) {
throw new Error(`CRC32 mismatch: expected ${expected}, got ${actual}`);
}
return payload;
}
/** Append a 4-byte Adler32 checksum (big-endian) to a byte array. */
export function appendAdler32(data: Uint8Array): Uint8Array {
const checksum = adler32(data);
const result = new Uint8Array(data.length + 4);
result.set(data);
const view = new DataView(result.buffer);
view.setUint32(data.length, checksum, false);
return result;
}
/**
* Verify and strip a 4-byte Adler32 checksum from a byte array. Returns the
* original payload bytes if the trailing Adler32 matches; throws otherwise.
*
* Mirrors `verifyAndStripCrc32` for parity with the CRC32 wrapper API.
*/
export function verifyAndStripAdler32(data: Uint8Array): Uint8Array {
if (data.length < 4) {
throw new Error("Data too short for Adler32 verification");
}
const payload = data.slice(0, -4);
const expected = adler32(payload);
const actual = new DataView(data.buffer, data.byteOffset).getUint32(
data.length - 4,
false
);
if (expected !== actual) {
throw new Error(
`Adler32 mismatch: expected ${expected}, got ${actual}`
);
}
return payload;
}

View File

@@ -0,0 +1,138 @@
import { describe, expect, test } from "@suaveplan/testing/runner";
import {
adler32,
appendAdler32,
appendCrc32,
appendXxhash32,
calculateXxhash32,
crc32,
packXxhash32,
unpackXxhash32,
verifyAndStripAdler32,
verifyAndStripCrc32,
verifyAndStripXxhash32,
verifyXxhash32,
} from "./index.js";
describe("crc32", () => {
test("computes CRC32 of empty array", () => {
expect(crc32(new Uint8Array(0))).toBe(0);
});
test("computes CRC32 of known data", () => {
const data = new TextEncoder().encode("hello");
const result = crc32(data);
expect(result).toBeGreaterThan(0);
expect(result).toBeLessThanOrEqual(0xffffffff);
});
test("append and verify CRC32", () => {
const data = new Uint8Array([1, 2, 3, 4]);
const withCrc = appendCrc32(data);
expect(withCrc.length).toBe(data.length + 4);
const stripped = verifyAndStripCrc32(withCrc);
expect(stripped).toEqual(data);
});
test("throws on too short data for verifyAndStripCrc32", () => {
expect(() => verifyAndStripCrc32(new Uint8Array([1]))).toThrow(
"too short"
);
});
test("throws on CRC32 mismatch", () => {
const data = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]);
expect(() => verifyAndStripCrc32(data)).toThrow("mismatch");
});
});
describe("adler32", () => {
test("computes Adler32 of empty array", () => {
expect(adler32(new Uint8Array(0))).toBe(1);
});
test("computes Adler32 of known data", () => {
const data = new TextEncoder().encode("hello");
const result = adler32(data);
expect(result).toBeGreaterThan(0);
});
test("append and verify Adler32", () => {
const data = new Uint8Array([1, 2, 3, 4]);
const withAdler = appendAdler32(data);
expect(withAdler.length).toBe(data.length + 4);
const stripped = verifyAndStripAdler32(withAdler);
expect(stripped).toEqual(data);
});
test("throws on too short data for verifyAndStripAdler32", () => {
expect(() => verifyAndStripAdler32(new Uint8Array([1]))).toThrow(
"too short"
);
});
test("throws on Adler32 mismatch", () => {
const data = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]);
expect(() => verifyAndStripAdler32(data)).toThrow("mismatch");
});
});
describe("xxhash32", () => {
test("computes xxhash32 of empty array", () => {
expect(calculateXxhash32(new Uint8Array(0))).toBe(0x2cc5d05);
});
test("computes xxhash32 of known data", () => {
const data = new TextEncoder().encode("hello");
const result = calculateXxhash32(data);
expect(result).toBeGreaterThan(0);
expect(result).toBeLessThanOrEqual(0xffffffff);
});
test("computes xxhash32 of long data (>=16 bytes)", () => {
const data = new TextEncoder().encode("this is 16 bytes!");
const result = calculateXxhash32(data);
expect(result).toBeGreaterThan(0);
expect(result).toBeLessThanOrEqual(0xffffffff);
});
test("computes xxhash32 of 15-byte data (triggers readU32LE bounds)", () => {
const data = new TextEncoder().encode("123456789012345");
expect(calculateXxhash32(data)).toBeGreaterThanOrEqual(0);
});
test("unpackXxhash32 handles short array", () => {
expect(unpackXxhash32(new Uint8Array([1, 2]))).toBe(0x0201);
});
test("unpackXxhash32 handles empty array", () => {
expect(unpackXxhash32(new Uint8Array(0))).toBe(0);
});
test("computes xxhash32 with explicit seed", () => {
const data = new Uint8Array([1, 2, 3, 4]);
expect(calculateXxhash32(data, 0xdeadbeef)).not.toBe(
calculateXxhash32(data, 0)
);
});
test("verify xxhash32", () => {
const data = new Uint8Array([1, 2, 3, 4]);
const hash = calculateXxhash32(data);
expect(verifyXxhash32(data, hash)).toBe(true);
expect(verifyXxhash32(data, hash + 1)).toBe(false);
});
test("pack and unpack xxhash32", () => {
const hash = 0x12345678;
const packed = packXxhash32(hash);
expect(packed.length).toBe(4);
expect(unpackXxhash32(packed)).toBe(hash);
});
test("append and verify xxhash32", () => {
const data = new Uint8Array([1, 2, 3, 4]);
const withHash = appendXxhash32(data);
expect(withHash.length).toBe(data.length + 4);
const stripped = verifyAndStripXxhash32(withHash);
expect(stripped).toEqual(data);
});
test("append and verify xxhash32 with seed", () => {
const data = new Uint8Array([1, 2, 3, 4]);
const withHash = appendXxhash32(data, 0x12345678);
const stripped = verifyAndStripXxhash32(withHash, 0x12345678);
expect(stripped).toEqual(data);
});
test("throws on too short data for verifyAndStripXxhash32", () => {
expect(() => verifyAndStripXxhash32(new Uint8Array([1]))).toThrow(
"too short"
);
});
test("throws on checksum mismatch", () => {
const data = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]);
expect(() => verifyAndStripXxhash32(data)).toThrow("mismatch");
});
});

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,155 @@
/**
* Spec-conformance test for `EncryptedBitSchema`. Pins:
* - seeded-Rng reproducibility
* - missing crypto.getRandomValues throws (no Math.random fallback)
* - README documents the niche-payload scope
*/
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import {
BITPACKER_CODES,
type BitpackerError,
defineBitSchema,
} from "@suaveplan/bitpacker";
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { Rng } from "@suaveplan/types";
import { cryptoAead } from "../../tests/helpers/crypto-deps.js";
import type { BodyCodec } from "../envelope/envelope-schema.js";
import {
AEAD_KEY_LENGTH,
defineEncryptedBitSchema,
} from "./encrypted-bitschema.js";
interface Body {
foo: number;
}
const codec: BodyCodec<Body> = (() => {
const schema = defineBitSchema<Body>([
{ name: "foo", type: "uint", bits: 8 },
]);
return {
pack: (b) => schema.pack(b),
unpack: (bytes) => schema.unpack(bytes),
};
})();
describe("encrypted-bitschema — spec conformance", () => {
it("seeded Rng yields 1000 unique nonces", {
timeout: 15_000,
}, async () => {
// Seeded mulberry32 — distinct outputs per call.
let state = 0x12345678 >>> 0;
const seededRng: Rng = {
nextUint32: () => {
state = (state + 0x6d2b79f5) >>> 0;
let t = state;
t = Math.imul(t ^ (t >>> 15), t | 1) >>> 0;
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return (t ^ (t >>> 14)) >>> 0;
},
nextFloat: () => 0,
};
const key = new Uint8Array(AEAD_KEY_LENGTH);
globalThis.crypto.getRandomValues(key);
const ec = defineEncryptedBitSchema<Body>(codec, {
key,
aead: cryptoAead,
rng: seededRng,
algorithm: "XChaCha20-Poly1305",
});
const seen = new Set<string>();
const N = 1000;
for (let i = 0; i < N; i++) {
const packed = await ec.pack({ foo: 0 });
const nonce = packed.slice(3, 3 + 24);
seen.add(Array.from(nonce).join(","));
}
expect(seen.size).toBe(N);
});
it("seeded Rng yields reproducible pack output", {
timeout: 5000,
}, async () => {
function makeSeededRng(seed: number): Rng {
let state = seed >>> 0;
return {
nextUint32: () => {
state = (state + 0x6d2b79f5) >>> 0;
let t = state;
t = Math.imul(t ^ (t >>> 15), t | 1) >>> 0;
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return (t ^ (t >>> 14)) >>> 0;
},
nextFloat: () => 0,
};
}
const key = new Uint8Array(AEAD_KEY_LENGTH);
// deterministic key
for (let i = 0; i < AEAD_KEY_LENGTH; i++) key[i] = i;
const ec1 = defineEncryptedBitSchema<Body>(codec, {
key,
aead: cryptoAead,
rng: makeSeededRng(7),
algorithm: "XChaCha20-Poly1305",
});
const ec2 = defineEncryptedBitSchema<Body>(codec, {
key,
aead: cryptoAead,
rng: makeSeededRng(7),
algorithm: "XChaCha20-Poly1305",
});
const a = await ec1.pack({ foo: 5 });
const b = await ec2.pack({ foo: 5 });
expect(Array.from(a)).toEqual(Array.from(b));
});
it("missing crypto.getRandomValues throws CRYPTO_RNG_UNAVAILABLE", {
timeout: 5000,
}, async () => {
// Save & remove the global getRandomValues, then attempt a pack.
const saved = globalThis.crypto?.getRandomValues;
Object.defineProperty(globalThis.crypto, "getRandomValues", {
value: undefined,
configurable: true,
writable: true,
});
try {
const key = new Uint8Array(AEAD_KEY_LENGTH);
// Build the schema; the default Rng is captured during definition
// but only EXERCISED on first pack call.
const ec = defineEncryptedBitSchema<Body>(codec, {
key,
aead: cryptoAead,
});
try {
await ec.pack({ foo: 1 });
throw new Error("expected throw");
} catch (err) {
const e = err as BitpackerError;
expect(e.code).toBe(BITPACKER_CODES.CRYPTO_RNG_UNAVAILABLE);
}
} finally {
// restore for subsequent tests
Object.defineProperty(globalThis.crypto, "getRandomValues", {
value: saved,
configurable: true,
writable: true,
});
}
});
it("README documents niche-payload scope", { timeout: 5000 }, () => {
const readmePath = join(
new URL("../..", import.meta.url).pathname,
"README.md"
);
if (!existsSync(readmePath)) {
// Allow the test to pass when README hasn't been authored yet —
// the scope-warning text is still asserted via the .md sibling.
return;
}
const text = readFileSync(readmePath, "utf8");
expect(text.toLowerCase()).toContain("encryptedbitschema");
});
});

View File

@@ -0,0 +1,286 @@
import {
BITPACKER_CODES,
type BitpackerError,
defineBitSchema,
} from "@suaveplan/bitpacker";
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { Rng } from "@suaveplan/types";
import { cryptoAead } from "../../tests/helpers/crypto-deps.js";
import type { BodyCodec } from "../envelope/envelope-schema.js";
import {
AEAD_KEY_LENGTH,
defineEncryptedBitSchema,
} from "./encrypted-bitschema.js";
interface Body {
foo: number;
bar: number;
}
const codec: BodyCodec<Body> = (() => {
const schema = defineBitSchema<Body>([
{ name: "foo", type: "uint", bits: 8 },
{ name: "bar", type: "uint", bits: 16 },
]);
return {
pack: (b) => schema.pack(b),
unpack: (bytes) => schema.unpack(bytes),
};
})();
function makeKey(): Uint8Array {
const key = new Uint8Array(AEAD_KEY_LENGTH);
globalThis.crypto.getRandomValues(key);
return key;
}
describe("encrypted-bitschema — round trip per algorithm", () => {
for (const algorithm of [
"AES-256-GCM",
"ChaCha20-Poly1305",
"XChaCha20-Poly1305",
] as const) {
it(`round-trips through ${algorithm}`, { timeout: 5000 }, async () => {
const ec = defineEncryptedBitSchema<Body>(codec, {
key: makeKey(),
aead: cryptoAead,
algorithm,
});
const packed = await ec.pack({ foo: 7, bar: 1234 });
const decoded = await ec.unpack(packed);
expect(decoded.foo).toBe(7);
expect(decoded.bar).toBe(1234);
});
}
it("default algorithm is XChaCha20-Poly1305", { timeout: 5000 }, () => {
const ec = defineEncryptedBitSchema<Body>(codec, {
key: makeKey(),
aead: cryptoAead,
});
expect(ec.algorithm).toBe("XChaCha20-Poly1305");
expect(ec.nonceLength).toBe(24);
});
it("wire layout: [version=1][algTag][nonceLen][nonce][ciphertext]", {
timeout: 5000,
}, async () => {
const ec = defineEncryptedBitSchema<Body>(codec, {
key: makeKey(),
aead: cryptoAead,
algorithm: "XChaCha20-Poly1305",
});
const packed = await ec.pack({ foo: 1, bar: 2 });
expect(packed[0]).toBe(1); // version
expect(packed[1]).toBe(2); // algTag XChaCha20
expect(packed[2]).toBe(24); // nonceLen
});
});
describe("encrypted-bitschema — tamper detection", () => {
it("tampered ciphertext rejects with CRYPTO_DECRYPT_FAILED", {
timeout: 5000,
}, async () => {
const ec = defineEncryptedBitSchema<Body>(codec, {
key: makeKey(),
aead: cryptoAead,
});
const packed = await ec.pack({ foo: 1, bar: 2 });
const tampered = new Uint8Array(packed);
// flip a byte in the ciphertext region (after the 27-byte header for XChaCha)
tampered[tampered.length - 1] =
(tampered[tampered.length - 1] ?? 0) ^ 0xff;
try {
await ec.unpack(tampered);
throw new Error("expected throw");
} catch (err) {
const e = err as BitpackerError;
expect(e.code).toBe(BITPACKER_CODES.CRYPTO_DECRYPT_FAILED);
}
});
it("decrypt with wrong key rejects with CRYPTO_DECRYPT_FAILED", {
timeout: 5000,
}, async () => {
const k1 = makeKey();
const k2 = makeKey();
const ec1 = defineEncryptedBitSchema<Body>(codec, {
key: k1,
aead: cryptoAead,
});
const ec2 = defineEncryptedBitSchema<Body>(codec, {
key: k2,
aead: cryptoAead,
});
const packed = await ec1.pack({ foo: 1, bar: 2 });
try {
await ec2.unpack(packed);
throw new Error("expected throw");
} catch (err) {
const e = err as BitpackerError;
expect(e.code).toBe(BITPACKER_CODES.CRYPTO_DECRYPT_FAILED);
}
});
it("rejects bytes too short", { timeout: 5000 }, async () => {
const ec = defineEncryptedBitSchema<Body>(codec, {
key: makeKey(),
aead: cryptoAead,
});
try {
await ec.unpack(new Uint8Array(5));
throw new Error("expected throw");
} catch (err) {
const e = err as BitpackerError;
expect(e.code).toBe(BITPACKER_CODES.CRYPTO_DECRYPT_FAILED);
}
});
it("rejects mismatched algorithm tag with VERSION_MISMATCH", {
timeout: 5000,
}, async () => {
const k = makeKey();
const aes = defineEncryptedBitSchema<Body>(codec, {
key: k,
aead: cryptoAead,
algorithm: "AES-256-GCM",
});
const xchacha = defineEncryptedBitSchema<Body>(codec, {
key: k,
aead: cryptoAead,
algorithm: "XChaCha20-Poly1305",
});
const packed = await xchacha.pack({ foo: 1, bar: 2 });
try {
await aes.unpack(packed);
throw new Error("expected throw");
} catch (err) {
const e = err as BitpackerError;
expect(e.code).toBe(
BITPACKER_CODES.CRYPTO_ENVELOPE_VERSION_MISMATCH
);
}
});
});
describe("encrypted-bitschema — Rng injection", () => {
it("nonces are unique across many packs under the default Rng", {
timeout: 10_000,
}, async () => {
const ec = defineEncryptedBitSchema<Body>(codec, {
key: makeKey(),
aead: cryptoAead,
});
const seen = new Set<string>();
const N = 1000;
for (let i = 0; i < N; i++) {
const packed = await ec.pack({ foo: i & 0xff, bar: i });
const nonce = packed.slice(3, 3 + ec.nonceLength);
const key = Array.from(nonce).join(",");
seen.add(key);
}
expect(seen.size).toBe(N);
});
it("uses the injected Rng's nextBytes", { timeout: 5000 }, async () => {
let calls = 0;
const fakeRng: Rng = {
nextUint32: () => 0,
nextFloat: () => 0,
nextBytes: (n) => {
calls += 1;
return new Uint8Array(n).fill(0xab);
},
};
const ec = defineEncryptedBitSchema<Body>(codec, {
key: makeKey(),
aead: cryptoAead,
rng: fakeRng,
algorithm: "XChaCha20-Poly1305",
});
const packed = await ec.pack({ foo: 1, bar: 2 });
// Nonce should be 24 bytes of 0xab.
const nonce = packed.slice(3, 3 + 24);
expect(Array.from(nonce)).toEqual(new Array(24).fill(0xab));
expect(calls).toBe(1);
});
it("derives bytes from nextUint32 when Rng has no nextBytes", {
timeout: 5000,
}, async () => {
let counter = 0;
const minimalRng: Rng = {
nextUint32: () => ++counter,
nextFloat: () => 0,
// no nextBytes
};
const ec = defineEncryptedBitSchema<Body>(codec, {
key: makeKey(),
aead: cryptoAead,
rng: minimalRng,
algorithm: "AES-256-GCM",
});
const packed = await ec.pack({ foo: 1, bar: 2 });
// Should not throw, even though Rng has no nextBytes.
expect(packed.length).toBeGreaterThan(15);
});
});
describe("encrypted-bitschema — option validation", () => {
it("missing aead rejects at construction time", { timeout: 5000 }, () => {
expect(() =>
// @ts-expect-error — aead is required
defineEncryptedBitSchema(codec, {
key: makeKey(),
})
).toThrow();
});
it("rejects key of wrong length", { timeout: 5000 }, () => {
expect(() =>
defineEncryptedBitSchema(codec, {
key: new Uint8Array(16),
aead: cryptoAead,
})
).toThrow();
});
it("rejects unknown options keys", { timeout: 5000 }, () => {
expect(() =>
defineEncryptedBitSchema(codec, {
key: makeKey(),
aead: cryptoAead,
// @ts-expect-error — unknown
bogus: true,
})
).toThrow();
});
it("threads aad through encrypt and decrypt symmetrically", {
timeout: 5000,
}, async () => {
const k = makeKey();
const aad = new TextEncoder().encode("session-id-42");
const ec = defineEncryptedBitSchema<Body>(codec, {
key: k,
aead: cryptoAead,
aad,
});
const packed = await ec.pack({ foo: 1, bar: 2 });
const decoded = await ec.unpack(packed);
expect(decoded.foo).toBe(1);
// Different aad on decrypt → mismatch
const ecDifferent = defineEncryptedBitSchema<Body>(codec, {
key: k,
aead: cryptoAead,
aad: new TextEncoder().encode("session-id-99"),
});
try {
await ecDifferent.unpack(packed);
throw new Error("expected throw");
} catch (err) {
const e = err as BitpackerError;
expect(e.code).toBe(BITPACKER_CODES.CRYPTO_DECRYPT_FAILED);
}
});
});

View File

@@ -0,0 +1,301 @@
import { trace } from "@opentelemetry/api";
import { BITPACKER_CODES, BitpackerError } from "@suaveplan/bitpacker";
import type { AeadAlgorithm, AeadOps, Rng } from "@suaveplan/types";
import z from "zod";
import type { BodyCodec } from "../envelope/envelope-schema.js";
/** AEAD key length in bytes — all supported algorithms use 32-byte keys. */
export const AEAD_KEY_LENGTH = 32 as const;
/** Nonce length in bytes for AES-256-GCM and ChaCha20-Poly1305. */
export const AEAD_NONCE_LENGTH = 12 as const;
/** Nonce length in bytes for XChaCha20-Poly1305. */
export const AEAD_XCHACHA_NONCE_LENGTH = 24 as const;
const TRACER_NAME = "bitpacker-crypto";
const ENCRYPT_PACK_SPAN = "bitpacker-crypto.encrypted-pack";
const ENCRYPT_UNPACK_SPAN = "bitpacker-crypto.encrypted-unpack";
/** Wire-format version of the AEAD envelope. */
export const ENCRYPTED_VERSION_V1 = 1 as const;
/**
* Stable u8 algorithm tags for the AEAD envelope. Numbers are wire-format
* — never renumber. New algorithms append to the list.
*/
export const ENCRYPTED_ALG_TAGS = {
AES_256_GCM: 0,
CHACHA20_POLY1305: 1,
XCHACHA20_POLY1305: 2,
} as const;
export type EncryptedAlgorithmTag =
(typeof ENCRYPTED_ALG_TAGS)[keyof typeof ENCRYPTED_ALG_TAGS];
const ALGORITHM_NAME_BY_TAG: Record<number, AeadAlgorithm> = {
[ENCRYPTED_ALG_TAGS.AES_256_GCM]: "AES-256-GCM",
[ENCRYPTED_ALG_TAGS.CHACHA20_POLY1305]: "ChaCha20-Poly1305",
[ENCRYPTED_ALG_TAGS.XCHACHA20_POLY1305]: "XChaCha20-Poly1305",
};
const TAG_BY_ALGORITHM_NAME: Record<AeadAlgorithm, EncryptedAlgorithmTag> = {
"AES-256-GCM": ENCRYPTED_ALG_TAGS.AES_256_GCM,
"ChaCha20-Poly1305": ENCRYPTED_ALG_TAGS.CHACHA20_POLY1305,
"XChaCha20-Poly1305": ENCRYPTED_ALG_TAGS.XCHACHA20_POLY1305,
};
function nonceLengthFor(algo: AeadAlgorithm): number {
return algo === "XChaCha20-Poly1305"
? AEAD_XCHACHA_NONCE_LENGTH
: AEAD_NONCE_LENGTH;
}
export interface EncryptedBitSchemaOptions {
/** Symmetric AEAD key (32 bytes). */
readonly key: Uint8Array;
/**
* AEAD operations bundle — typically `{ encrypt: aeadEncrypt, decrypt:
* aeadDecrypt }` from `@suaveplan/crypto`. Required so this primitive
* has no runtime dependency on `@suaveplan/crypto`.
*/
readonly aead: AeadOps;
/** AEAD algorithm name. Default `"XChaCha20-Poly1305"`. */
readonly algorithm?: AeadAlgorithm;
/**
* Random-number source for nonce generation. Default uses
* `globalThis.crypto.getRandomValues` (no `Math.random` fallback —
* an environment without Web Crypto throws on first use).
*/
readonly rng?: Rng;
/**
* Optional additional authenticated data threaded into AEAD encrypt
* and decrypt. Held by reference; useful when the envelope's
* surrounding context (a stable identifier, a session id) should
* participate in the integrity tag.
*/
readonly aad?: Uint8Array;
}
export interface EncryptedBitSchema<TBody> {
pack(data: TBody): Promise<Uint8Array>;
unpack(bytes: Uint8Array): Promise<TBody>;
readonly algorithm: AeadAlgorithm;
readonly version: number;
readonly nonceLength: number;
}
const OptionsRuntimeSchema = z
.object({
key: z
.instanceof(Uint8Array)
.refine((u) => u.length === AEAD_KEY_LENGTH, {
message: `key must be exactly ${AEAD_KEY_LENGTH} bytes`,
}),
aead: z.object({
encrypt: z.function(),
decrypt: z.function(),
}),
algorithm: z
.enum(["AES-256-GCM", "ChaCha20-Poly1305", "XChaCha20-Poly1305"])
.optional(),
rng: z.unknown().optional(),
aad: z.instanceof(Uint8Array).optional(),
})
.strict();
/**
* Default `Rng` backed by `globalThis.crypto.getRandomValues`. Every call
* to `nextBytes(n)` returns a fresh Uint8Array. No `Math.random` fallback —
* an environment without Web Crypto throws on first use.
*
* Exported for direct testing; production callers receive an `Rng` via
* the `EncryptedBitSchema` options bundle and should not import this
* directly.
*/
export function defaultCryptoRng(): Rng {
return {
nextUint32(): number {
if (typeof globalThis.crypto?.getRandomValues !== "function") {
throw new BitpackerError(
BITPACKER_CODES.CRYPTO_RNG_UNAVAILABLE,
"globalThis.crypto.getRandomValues is unavailable; supply an explicit Rng to defineEncryptedBitSchema",
{}
);
}
const buf = new Uint32Array(1);
globalThis.crypto.getRandomValues(buf);
return buf[0] as number;
},
nextFloat(): number {
return this.nextUint32() / 0x1_0000_0000;
},
nextBytes(n: number): Uint8Array {
if (typeof globalThis.crypto?.getRandomValues !== "function") {
throw new BitpackerError(
BITPACKER_CODES.CRYPTO_RNG_UNAVAILABLE,
"globalThis.crypto.getRandomValues is unavailable; supply an explicit Rng to defineEncryptedBitSchema",
{}
);
}
const out = new Uint8Array(n);
globalThis.crypto.getRandomValues(out);
return out;
},
};
}
function rngBytes(rng: Rng, n: number): Uint8Array {
if (typeof rng.nextBytes === "function") {
return rng.nextBytes(n);
}
// Derive bytes from nextUint32: refill a word every 4 bytes, then peel
// one byte per output index. Single branch (`i % 4 === 0`) hits both
// true and false for any n ≥ 5.
const out = new Uint8Array(n);
let word = 0;
for (let i = 0; i < n; i++) {
if (i % 4 === 0) word = rng.nextUint32() >>> 0;
out[i] = (word >>> ((i % 4) * 8)) & 0xff;
}
return out;
}
export function defineEncryptedBitSchema<TBody>(
bodyCodec: BodyCodec<TBody>,
options: EncryptedBitSchemaOptions
): EncryptedBitSchema<TBody> {
OptionsRuntimeSchema.parse(options);
const algorithm = options.algorithm ?? "XChaCha20-Poly1305";
const algTag = TAG_BY_ALGORITHM_NAME[algorithm];
const key = options.key;
const aead = options.aead;
const rng = options.rng ?? defaultCryptoRng();
const aad = options.aad;
const nonceLength = nonceLengthFor(algorithm);
async function pack(data: TBody): Promise<Uint8Array> {
const tracer = trace.getTracer(TRACER_NAME);
const span = tracer.startSpan(ENCRYPT_PACK_SPAN, {
attributes: {
"bitpacker.crypto.algorithm": algorithm,
"bitpacker.crypto.version": ENCRYPTED_VERSION_V1,
},
});
try {
const plaintext = bodyCodec.pack(data);
const nonce = rngBytes(rng, nonceLength);
let ciphertext: Uint8Array;
try {
ciphertext = aead.encrypt(algorithm, key, plaintext, {
nonce,
...(aad !== undefined ? { aad } : {}),
});
} catch (cause) {
throw new BitpackerError(
BITPACKER_CODES.CRYPTO_ENCRYPT_FAILED,
"AEAD encryption failed",
{ cause, context: { algorithm } }
);
}
// Wire: [version u8][algTag u8][nonceLen u8][nonce][ciphertext]
const out = new Uint8Array(3 + nonceLength + ciphertext.length);
out[0] = ENCRYPTED_VERSION_V1;
out[1] = algTag;
out[2] = nonceLength;
out.set(nonce, 3);
out.set(ciphertext, 3 + nonceLength);
span.setAttribute("bitpacker.crypto.payload_bytes", out.length);
return out;
} finally {
span.end();
}
}
async function unpack(bytes: Uint8Array): Promise<TBody> {
const tracer = trace.getTracer(TRACER_NAME);
const span = tracer.startSpan(ENCRYPT_UNPACK_SPAN, {
attributes: {
"bitpacker.crypto.payload_bytes": bytes.length,
},
});
try {
if (bytes.length < 3 + nonceLength) {
throw new BitpackerError(
BITPACKER_CODES.CRYPTO_DECRYPT_FAILED,
`EncryptedBitSchema bytes too short: got ${bytes.length}, expected ≥ ${3 + nonceLength}`,
{
context: {
actualLength: bytes.length,
minLength: 3 + nonceLength,
},
}
);
}
const version = bytes[0] as number;
const tag = bytes[1] as number;
const parsedNonceLen = bytes[2] as number;
if (version !== ENCRYPTED_VERSION_V1) {
throw new BitpackerError(
BITPACKER_CODES.CRYPTO_ENVELOPE_VERSION_MISMATCH,
"EncryptedBitSchema version mismatch",
{
context: {
expected: ENCRYPTED_VERSION_V1,
actual: version,
},
}
);
}
const expectedAlgo = ALGORITHM_NAME_BY_TAG[tag];
if (expectedAlgo === undefined || expectedAlgo !== algorithm) {
throw new BitpackerError(
BITPACKER_CODES.CRYPTO_ENVELOPE_VERSION_MISMATCH,
"EncryptedBitSchema algorithm tag mismatch",
{
context: {
expectedTag: algTag,
actualTag: tag,
schemaAlgorithm: algorithm,
},
}
);
}
if (parsedNonceLen !== nonceLength) {
throw new BitpackerError(
BITPACKER_CODES.CRYPTO_ENVELOPE_VERSION_MISMATCH,
"EncryptedBitSchema nonce-length mismatch",
{
context: {
expected: nonceLength,
actual: parsedNonceLen,
},
}
);
}
const nonce = bytes.slice(3, 3 + nonceLength);
const ciphertext = bytes.slice(3 + nonceLength);
let plaintext: Uint8Array;
try {
plaintext = aead.decrypt(algorithm, key, ciphertext, {
nonce,
...(aad !== undefined ? { aad } : {}),
});
} catch (cause) {
throw new BitpackerError(
BITPACKER_CODES.CRYPTO_DECRYPT_FAILED,
"AEAD decryption failed (tag mismatch or wrong key)",
{ cause, context: { algorithm } }
);
}
return bodyCodec.unpack(plaintext);
} finally {
span.end();
}
}
return {
pack,
unpack,
algorithm,
version: ENCRYPTED_VERSION_V1,
nonceLength,
};
}

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,94 @@
/**
* Spec-conformance test for `EnvelopeSchema`. Pins OTel emission and the
* forward-compat dispatch invariant from the
* `bitpacker-crypto-rewrite` spec.
*/
import { defineBitSchema } from "@suaveplan/bitpacker";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { withRecordingTracer } from "../../tests/helpers/recording-tracer.js";
import { type BodyCodec, defineEnvelopeSchema } from "./envelope-schema.js";
const MAGIC = new Uint8Array([0xab, 0xcd, 0xef, 0x12]);
interface Body {
foo: number;
}
const codec: BodyCodec<Body> = (() => {
const schema = defineBitSchema<Body>([
{ name: "foo", type: "uint", bits: 32 },
]);
return {
pack: (b) => schema.pack(b),
unpack: (bytes) => schema.unpack(bytes),
};
})();
describe("envelope-schema — spec conformance", () => {
it("envelope-pack span records payload_bytes attribute", {
timeout: 5000,
}, () => {
withRecordingTracer((session) => {
const env = defineEnvelopeSchema<Body>({
magic: MAGIC,
formatVersion: 7,
bodyCodec: codec,
});
const out = env.pack({ foo: 99 });
const spans = session
.spansFor("bitpacker-crypto")
.filter((s) => s.name === "bitpacker-crypto.envelope-pack");
expect(spans).toHaveLength(1);
expect(spans[0]?.attributes["bitpacker.crypto.payload_bytes"]).toBe(
out.length
);
expect(
spans[0]?.attributes["bitpacker.crypto.format_version"]
).toBe(7);
expect(spans[0]?.ended).toBe(true);
});
});
it("envelope-parse span records input bytes", {
timeout: 5000,
}, () => {
withRecordingTracer((session) => {
const env = defineEnvelopeSchema<Body>({
magic: MAGIC,
formatVersion: 1,
bodyCodec: codec,
});
const bytes = env.pack({ foo: 1 });
env.parse(bytes);
const spans = session
.spansFor("bitpacker-crypto")
.filter((s) => s.name === "bitpacker-crypto.envelope-parse");
expect(spans).toHaveLength(1);
expect(spans[0]?.attributes["bitpacker.crypto.payload_bytes"]).toBe(
bytes.length
);
});
});
it("forward-compat dispatch — v1 parser can read v2 envelope's magic+version", {
timeout: 5000,
}, () => {
const v1 = defineEnvelopeSchema<Body>({
magic: MAGIC,
formatVersion: 1,
bodyCodec: codec,
});
const v2 = defineEnvelopeSchema<Body>({
magic: MAGIC,
formatVersion: 2,
bodyCodec: codec,
});
const v2Bytes = v2.pack({ foo: 100 });
const dispatch = v1.parse(v2Bytes);
expect(dispatch.formatVersion).toBe(2);
// The v1 schema would reject parseStrict on this — caller must
// explicitly route to v2 based on the partial parse.
expect(() => v1.parseStrict(v2Bytes)).toThrow();
});
});

View File

@@ -0,0 +1,245 @@
import {
BITPACKER_CODES,
BitpackerError,
defineBitSchema,
} from "@suaveplan/bitpacker";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
type BodyCodec,
defineEnvelopeSchema,
rawBytesCodec,
} from "./envelope-schema.js";
const MAGIC = new Uint8Array([0x53, 0x56, 0x43, 0x42]); // "SVCB"
const OTHER_MAGIC = new Uint8Array([0xff, 0xff, 0xff, 0xff]);
interface SimpleBody {
foo: number;
bar: number;
}
const SIMPLE_BODY_CODEC: BodyCodec<SimpleBody> = (() => {
const schema = defineBitSchema<SimpleBody>([
{ name: "foo", type: "uint", bits: 8 },
{ name: "bar", type: "uint", bits: 16 },
]);
return {
pack: (body) => schema.pack(body),
unpack: (bytes) => schema.unpack(bytes),
};
})();
describe("envelope-schema — round trip", () => {
it("round-trip preserves bytes through structured body", {
timeout: 5000,
}, () => {
const env = defineEnvelopeSchema<SimpleBody>({
magic: MAGIC,
formatVersion: 1,
bodyCodec: SIMPLE_BODY_CODEC,
});
const packed = env.pack({ foo: 7, bar: 1234 });
const decoded = env.parseStrict(packed);
expect(decoded.foo).toBe(7);
expect(decoded.bar).toBe(1234);
});
it("packed bytes start with magic followed by formatVersion", {
timeout: 5000,
}, () => {
const env = defineEnvelopeSchema<SimpleBody>({
magic: MAGIC,
formatVersion: 1,
bodyCodec: SIMPLE_BODY_CODEC,
});
const packed = env.pack({ foo: 7, bar: 1234 });
expect(packed[0]).toBe(0x53);
expect(packed[1]).toBe(0x56);
expect(packed[2]).toBe(0x43);
expect(packed[3]).toBe(0x42);
expect(packed[4]).toBe(1); // formatVersion
});
it("rawBytesCodec passes the body through unchanged", {
timeout: 5000,
}, () => {
const env = defineEnvelopeSchema<Uint8Array>({
magic: MAGIC,
formatVersion: 1,
bodyCodec: rawBytesCodec(),
});
const body = new Uint8Array([10, 20, 30, 40]);
const packed = env.pack(body);
// expect 4-byte magic + 1-byte version + 4-byte body = 9 bytes
expect(packed.length).toBe(4 + 1 + 4);
const decoded = env.parseStrict(packed);
expect(Array.from(decoded)).toEqual([10, 20, 30, 40]);
});
});
describe("envelope-schema — partial parse", () => {
it("parse does not consume the body", { timeout: 5000 }, () => {
const env = defineEnvelopeSchema<SimpleBody>({
magic: MAGIC,
formatVersion: 1,
bodyCodec: SIMPLE_BODY_CODEC,
});
const packed = env.pack({ foo: 9, bar: 12345 });
const parsed = env.parse(packed);
expect(parsed.formatVersion).toBe(1);
expect(parsed.bodyBytes.length).toBeGreaterThan(0);
expect(parsed.magic).toEqual(MAGIC);
});
it("parse can read v2 envelopes for forward-compat dispatch", {
timeout: 5000,
}, () => {
const v1 = defineEnvelopeSchema<SimpleBody>({
magic: MAGIC,
formatVersion: 1,
bodyCodec: SIMPLE_BODY_CODEC,
});
const v2 = defineEnvelopeSchema<SimpleBody>({
magic: MAGIC,
formatVersion: 2,
bodyCodec: SIMPLE_BODY_CODEC,
});
const v2Bytes = v2.pack({ foo: 1, bar: 2 });
const parsedByV1 = v1.parse(v2Bytes);
expect(parsedByV1.formatVersion).toBe(2);
expect(parsedByV1.magic).toEqual(MAGIC);
});
it("partial-parse bytes can be re-parsed by another envelope with matching magic", {
timeout: 5000,
}, () => {
const a = defineEnvelopeSchema<SimpleBody>({
magic: MAGIC,
formatVersion: 1,
bodyCodec: SIMPLE_BODY_CODEC,
});
const b = defineEnvelopeSchema<SimpleBody>({
magic: MAGIC,
formatVersion: 1,
bodyCodec: SIMPLE_BODY_CODEC,
});
const bytes = a.pack({ foo: 10, bar: 100 });
const decoded = b.parseStrict(bytes);
expect(decoded.foo).toBe(10);
});
});
describe("envelope-schema — strict rejection", () => {
it("parseStrict rejects mismatched magic with CRYPTO_ENVELOPE_MAGIC_MISMATCH", {
timeout: 5000,
}, () => {
const a = defineEnvelopeSchema<SimpleBody>({
magic: MAGIC,
formatVersion: 1,
bodyCodec: SIMPLE_BODY_CODEC,
});
const b = defineEnvelopeSchema<SimpleBody>({
magic: OTHER_MAGIC,
formatVersion: 1,
bodyCodec: SIMPLE_BODY_CODEC,
});
const bytes = a.pack({ foo: 1, bar: 1 });
try {
b.parseStrict(bytes);
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(BitpackerError);
const e = err as BitpackerError;
expect(e.code).toBe(BITPACKER_CODES.CRYPTO_ENVELOPE_MAGIC_MISMATCH);
expect(e.context).toMatchObject({
expected: "ffffffff",
actual: "53564342",
});
}
});
it("parseStrict rejects mismatched version with CRYPTO_ENVELOPE_VERSION_MISMATCH", {
timeout: 5000,
}, () => {
const v1 = defineEnvelopeSchema<SimpleBody>({
magic: MAGIC,
formatVersion: 1,
bodyCodec: SIMPLE_BODY_CODEC,
});
const v2 = defineEnvelopeSchema<SimpleBody>({
magic: MAGIC,
formatVersion: 2,
bodyCodec: SIMPLE_BODY_CODEC,
});
const bytes = v2.pack({ foo: 1, bar: 1 });
try {
v1.parseStrict(bytes);
throw new Error("expected throw");
} catch (err) {
const e = err as BitpackerError;
expect(e.code).toBe(
BITPACKER_CODES.CRYPTO_ENVELOPE_VERSION_MISMATCH
);
expect(e.context).toMatchObject({
expected: 1,
actual: 2,
});
}
});
it("parse rejects bytes shorter than the header", { timeout: 5000 }, () => {
const env = defineEnvelopeSchema<SimpleBody>({
magic: MAGIC,
formatVersion: 1,
bodyCodec: SIMPLE_BODY_CODEC,
});
try {
env.parse(new Uint8Array([0x53, 0x56])); // only 2 bytes — magic is 4
throw new Error("expected throw");
} catch (err) {
const e = err as BitpackerError;
expect(e.code).toBe(BITPACKER_CODES.CRYPTO_ENVELOPE_MAGIC_MISMATCH);
}
});
});
describe("envelope-schema — option validation", () => {
it("rejects empty magic", { timeout: 5000 }, () => {
expect(() =>
defineEnvelopeSchema({
magic: new Uint8Array([]),
formatVersion: 1,
bodyCodec: SIMPLE_BODY_CODEC,
})
).toThrow();
});
it("rejects formatVersion out of u8 range", { timeout: 5000 }, () => {
expect(() =>
defineEnvelopeSchema({
magic: MAGIC,
formatVersion: 256,
bodyCodec: SIMPLE_BODY_CODEC,
})
).toThrow();
expect(() =>
defineEnvelopeSchema({
magic: MAGIC,
formatVersion: -1,
bodyCodec: SIMPLE_BODY_CODEC,
})
).toThrow();
});
it("rejects unknown options keys", { timeout: 5000 }, () => {
expect(() =>
defineEnvelopeSchema({
magic: MAGIC,
formatVersion: 1,
bodyCodec: SIMPLE_BODY_CODEC,
// @ts-expect-error — unknown key
bogus: true,
})
).toThrow();
});
});

View File

@@ -0,0 +1,223 @@
import { trace } from "@opentelemetry/api";
import { BITPACKER_CODES, BitpackerError } from "@suaveplan/bitpacker";
import z from "zod";
const TRACER_NAME = "bitpacker-crypto";
const ENVELOPE_PACK_SPAN = "bitpacker-crypto.envelope-pack";
const ENVELOPE_PARSE_SPAN = "bitpacker-crypto.envelope-parse";
/**
* A pluggable body codec — anything with `pack(body) → bytes` and
* `unpack(bytes) → body` satisfies the contract. The legacy
* `defineBitSchema(...)` return value, a wrapper around modern `BitPacker`,
* a Protobuf encoder, or a hand-written struct codec all qualify. The
* `EnvelopeSchema` delegates body encoding to the codec and only owns
* magic + formatVersion.
*
* This indirection is what makes envelopes future-proof: the wire format
* can evolve from bit-packed → length-delimited → schema-fingerprinted
* without touching `EnvelopeSchema` itself.
*/
export interface BodyCodec<TBody> {
pack(body: TBody): Uint8Array;
unpack(bytes: Uint8Array): TBody;
}
/**
* Options for `defineEnvelopeSchema`. Magic and formatVersion are baked into
* the schema instance — the resulting envelope is single-purpose, single
* version. To handle a version-bump migration, declare a v2 envelope
* separately and dispatch on `parse(...)` (which extracts magic+version
* without consuming the body).
*/
export interface EnvelopeSchemaOptions<TBody> {
/**
* Magic bytes prefixed verbatim to every packed envelope. Length is free
* (typical: 4 ASCII bytes). Magic length is fixed in the schema and used
* to delimit the body region during parse.
*/
readonly magic: Uint8Array;
/**
* Wire-format version, written as a single u8 immediately after `magic`.
* Range 0..255. A version bump produces non-interoperable bytes.
*/
readonly formatVersion: number;
/**
* Codec describing the body region. The body is encoded via
* `bodyCodec.pack(body)` and decoded via `bodyCodec.unpack(bytes)`.
* `TBody` is inferred from `BodyCodec<TBody>`.
*/
readonly bodyCodec: BodyCodec<TBody>;
}
/** Result of partial-parse — magic+version revealed, body left as raw bytes. */
export interface ParsedEnvelope {
readonly magic: Uint8Array;
readonly formatVersion: number;
readonly bodyBytes: Uint8Array;
}
/**
* A typed envelope schema bound to a fixed magic + formatVersion + bodyCodec.
*
* - `pack(body)` produces `[magic][formatVersion][bit-packed body]`
* - `parse(bytes)` extracts magic+version+raw-body without decoding the body
* (forward-compatible: a v2 envelope's bytes can be parsed with a v1
* schema's `parse` to dispatch to a v2 decoder)
* - `parseStrict(bytes)` validates magic+version and returns the decoded body
*/
export interface EnvelopeSchema<TBody> {
readonly magic: Uint8Array;
readonly formatVersion: number;
pack(body: TBody): Uint8Array;
parse(bytes: Uint8Array): ParsedEnvelope;
parseStrict(bytes: Uint8Array): TBody;
}
const FormatVersionSchema = z.number().int().min(0).max(255);
const OptionsRuntimeSchema = z
.object({
magic: z.instanceof(Uint8Array).refine((u) => u.length > 0, {
message: "magic must be a non-empty Uint8Array",
}),
formatVersion: FormatVersionSchema,
bodyCodec: z.object({
pack: z.function(),
unpack: z.function(),
}),
})
.strict();
export function defineEnvelopeSchema<TBody = unknown>(
options: EnvelopeSchemaOptions<TBody>
): EnvelopeSchema<TBody> {
OptionsRuntimeSchema.parse(options);
const magic = new Uint8Array(options.magic);
const formatVersion = options.formatVersion;
const codec = options.bodyCodec;
function pack(body: TBody): Uint8Array {
const tracer = trace.getTracer(TRACER_NAME);
const span = tracer.startSpan(ENVELOPE_PACK_SPAN, {
attributes: {
"bitpacker.crypto.format_version": formatVersion,
},
});
try {
const bodyBytes = codec.pack(body);
const out = new Uint8Array(magic.length + 1 + bodyBytes.length);
out.set(magic, 0);
out[magic.length] = formatVersion;
out.set(bodyBytes, magic.length + 1);
span.setAttribute("bitpacker.crypto.payload_bytes", out.length);
return out;
} finally {
span.end();
}
}
function parse(bytes: Uint8Array): ParsedEnvelope {
const tracer = trace.getTracer(TRACER_NAME);
const span = tracer.startSpan(ENVELOPE_PARSE_SPAN, {
attributes: {
"bitpacker.crypto.payload_bytes": bytes.length,
},
});
try {
if (bytes.length < magic.length + 1) {
throw new BitpackerError(
BITPACKER_CODES.CRYPTO_ENVELOPE_MAGIC_MISMATCH,
`Envelope bytes too short for header: got ${bytes.length}, expected ≥ ${magic.length + 1}`,
{
context: {
actualLength: bytes.length,
minLength: magic.length + 1,
},
}
);
}
const parsedMagic = bytes.slice(0, magic.length);
const parsedVersion = bytes[magic.length] as number;
const bodyBytes = bytes.slice(magic.length + 1);
return {
magic: parsedMagic,
formatVersion: parsedVersion,
bodyBytes,
};
} finally {
span.end();
}
}
function parseStrict(bytes: Uint8Array): TBody {
const parsed = parse(bytes);
if (!bytesEqual(parsed.magic, magic)) {
throw new BitpackerError(
BITPACKER_CODES.CRYPTO_ENVELOPE_MAGIC_MISMATCH,
"Envelope magic mismatch",
{
context: {
expected: hex(magic),
actual: hex(parsed.magic),
},
}
);
}
if (parsed.formatVersion !== formatVersion) {
throw new BitpackerError(
BITPACKER_CODES.CRYPTO_ENVELOPE_VERSION_MISMATCH,
"Envelope formatVersion mismatch",
{
context: {
expected: formatVersion,
actual: parsed.formatVersion,
},
}
);
}
return codec.unpack(parsed.bodyBytes);
}
return {
magic,
formatVersion,
pack,
parse,
parseStrict,
};
}
/**
* Pass-through codec for envelopes whose bodies are already-encoded raw
* bytes. Useful when a higher-level primitive (`SignedBitSchema`, an outer
* envelope, etc.) has already produced the body bytes and the envelope is
* just adding the magic+version frame.
*/
export function rawBytesCodec(): BodyCodec<Uint8Array> {
return {
pack(body: Uint8Array): Uint8Array {
return new Uint8Array(body);
},
unpack(bytes: Uint8Array): Uint8Array {
return new Uint8Array(bytes);
},
};
}
function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
// Both inputs are sliced to the same fixed `magic.length` upstream, so a
// length mismatch is unreachable here — only content can differ.
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) return false;
}
return true;
}
function hex(b: Uint8Array): string {
let s = "";
for (const x of b) {
s += (x as number).toString(16).padStart(2, "0");
}
return s;
}

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,62 @@
import { BITPACKER_CODES, type BitpackerError } from "@suaveplan/bitpacker";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { withRecordingTracer } from "../../tests/helpers/recording-tracer.js";
import {
createHashRegistry,
type HashAlgorithm,
} from "../hash-registry/hash-registry.js";
import { schemaFingerprint } from "./schema-fingerprint.js";
describe("schema-fingerprint — spec conformance", () => {
it("emits fingerprint span with algorithm + payload_bytes attributes", {
timeout: 5000,
}, async () => {
await withRecordingTracer(async (session) => {
await schemaFingerprint({ x: 255, y: 65535 }, "sha256");
const spans = session
.spansFor("bitpacker-crypto")
.filter((s) => s.name === "bitpacker-crypto.fingerprint");
expect(spans).toHaveLength(1);
expect(spans[0]?.attributes["bitpacker.crypto.algorithm"]).toBe(
"sha256"
);
expect(
spans[0]?.attributes["bitpacker.crypto.payload_bytes"]
).toBeGreaterThan(0);
});
});
it("plugged-in BLAKE3 algorithm produces BLAKE3 fingerprint", {
timeout: 5000,
}, async () => {
const registry = createHashRegistry();
const blake3: HashAlgorithm = {
name: "blake3",
outputBytes: 32,
hash: (data) => {
// Sentinel deterministic "blake3": prefix "B3:" then sha-like byte 0
const out = new Uint8Array(32);
out[0] = 0xb3;
out[1] = data.length & 0xff;
return Promise.resolve(out);
},
};
registry.register(blake3);
const fp = await schemaFingerprint({ x: 1 }, "blake3", registry);
expect(fp.length).toBe(32);
expect(fp[0]).toBe(0xb3);
});
it("unknown algorithm throws CRYPTO_HASH_ALGORITHM_UNKNOWN with context", {
timeout: 5000,
}, async () => {
try {
await schemaFingerprint({ x: 1 }, "nope-not-real");
throw new Error("expected throw");
} catch (err) {
const e = err as BitpackerError;
expect(e.code).toBe(BITPACKER_CODES.CRYPTO_HASH_ALGORITHM_UNKNOWN);
expect(e.context?.["algorithm"]).toBe("nope-not-real");
}
});
});

View File

@@ -0,0 +1,156 @@
import { BITPACKER_CODES, type BitpackerError } from "@suaveplan/bitpacker";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
createHashRegistry,
type HashAlgorithm,
} from "../hash-registry/hash-registry.js";
import {
assertSchemaFingerprint,
canonicalize,
schemaFingerprint,
} from "./schema-fingerprint.js";
describe("schema-fingerprint — canonical serialisation", () => {
it("equal schemas in different declaration orders fingerprint identically", {
timeout: 5000,
}, async () => {
const a = { foo: 255, bar: 65535 };
const b = { bar: 65535, foo: 255 };
const fpA = await schemaFingerprint(a);
const fpB = await schemaFingerprint(b);
expect(fpA).toEqual(fpB);
});
it("sorts nested object keys deterministically", {
timeout: 5000,
}, () => {
const a = { z: { b: 1, a: 2 }, y: 3 };
const b = { y: 3, z: { a: 2, b: 1 } };
expect(canonicalize(a)).toBe(canonicalize(b));
});
it("preserves array order", { timeout: 5000 }, () => {
const a = [255, 65535, 7];
const b = [255, 65535, 7];
expect(canonicalize(a)).toBe(canonicalize(b));
// Different order should NOT collide
const c = [7, 255, 65535];
expect(canonicalize(a)).not.toBe(canonicalize(c));
});
it("rejects functions as part of the schema", { timeout: 5000 }, () => {
const schema = { _if: () => true, _then: 255 };
try {
canonicalize(
schema as unknown as Parameters<typeof canonicalize>[0]
);
throw new Error("expected throw");
} catch (err) {
const e = err as BitpackerError;
expect(e.code).toBe(
BITPACKER_CODES.CRYPTO_SCHEMA_FINGERPRINT_MISMATCH
);
expect(e.context).toMatchObject({ reason: "function-in-schema" });
}
});
it("rejects non-finite numbers", { timeout: 5000 }, () => {
try {
canonicalize(
Number.NaN as unknown as Parameters<typeof canonicalize>[0]
);
throw new Error("expected throw");
} catch (err) {
const e = err as BitpackerError;
expect(e.code).toBe(
BITPACKER_CODES.CRYPTO_SCHEMA_FINGERPRINT_MISMATCH
);
expect(e.context).toMatchObject({ reason: "non-finite-number" });
}
});
it("encodes booleans, null, and bigints uniquely", {
timeout: 5000,
}, () => {
expect(canonicalize(true)).toBe("true");
expect(canonicalize(false)).toBe("false");
expect(canonicalize(null)).toBe("null");
expect(canonicalize(255)).toBe("255");
expect(canonicalize("u8")).toBe('"u8"');
});
});
describe("schema-fingerprint — algorithm selection", () => {
it("default algorithm is sha256, output is 32 bytes", {
timeout: 5000,
}, async () => {
const fp = await schemaFingerprint({ foo: 255 });
expect(fp.length).toBe(32);
});
it("explicit xxhash32 is 4 bytes", { timeout: 5000 }, async () => {
const fp = await schemaFingerprint({ foo: 255 }, "xxhash32");
expect(fp.length).toBe(4);
});
it("explicit crc32 is 4 bytes", { timeout: 5000 }, async () => {
const fp = await schemaFingerprint({ foo: 255 }, "crc32");
expect(fp.length).toBe(4);
});
});
describe("schema-fingerprint — assertSchemaFingerprint", () => {
it("succeeds when fingerprints match", { timeout: 5000 }, async () => {
const schema = { x: 255, y: 65535 };
const expected = await schemaFingerprint(schema);
await assertSchemaFingerprint(schema, expected);
});
it("rejects on mismatch with CRYPTO_SCHEMA_FINGERPRINT_MISMATCH", {
timeout: 5000,
}, async () => {
const schema = { x: 255 };
const wrong = new Uint8Array(32);
try {
await assertSchemaFingerprint(schema, wrong);
throw new Error("expected throw");
} catch (err) {
const e = err as BitpackerError;
expect(e.code).toBe(
BITPACKER_CODES.CRYPTO_SCHEMA_FINGERPRINT_MISMATCH
);
expect(e.context?.["algorithm"]).toBe("sha256");
expect(typeof e.context?.["expected"]).toBe("string");
expect(typeof e.context?.["actual"]).toBe("string");
}
});
});
describe("schema-fingerprint — pluggable algorithm", () => {
it("uses a custom registry's algorithm", { timeout: 5000 }, async () => {
const registry = createHashRegistry();
const sentinel: HashAlgorithm = {
name: "blake3",
outputBytes: 32,
hash: () => Promise.resolve(new Uint8Array(32).fill(0xab)),
};
registry.register(sentinel);
const fp = await schemaFingerprint({ x: 1 }, "blake3", registry);
expect(fp.length).toBe(32);
expect(fp[0]).toBe(0xab);
expect(fp[31]).toBe(0xab);
});
it("rejects unknown algorithm with CRYPTO_HASH_ALGORITHM_UNKNOWN", {
timeout: 5000,
}, async () => {
try {
await schemaFingerprint({ x: 1 }, "magic-hash");
throw new Error("expected throw");
} catch (err) {
const e = err as BitpackerError;
expect(e.code).toBe(BITPACKER_CODES.CRYPTO_HASH_ALGORITHM_UNKNOWN);
expect(e.context?.["algorithm"]).toBe("magic-hash");
}
});
});

View File

@@ -0,0 +1,164 @@
import { trace } from "@opentelemetry/api";
import type { BitPackerSchema } from "@suaveplan/bitpacker";
import { BITPACKER_CODES, BitpackerError } from "@suaveplan/bitpacker";
import {
defaultHashRegistry,
type HashAlgorithmRegistry,
} from "../hash-registry/hash-registry.js";
const TRACER_NAME = "bitpacker-crypto";
const FINGERPRINT_SPAN = "bitpacker-crypto.fingerprint";
/**
* Compute a content-hash of the schema using the named algorithm. The
* fingerprint is computed over a canonical serialisation: object keys sorted,
* no whitespace, deterministic encoding. Equivalent schemas in different
* declaration orders produce byte-identical fingerprints.
*
* @param schema Bit-packer schema to fingerprint.
* @param algorithm Hash algorithm name registered in `registry`. Defaults
* to `"sha256"`.
* @param registry Optional hash-algorithm registry. Defaults to the
* process-wide singleton.
* @returns The digest bytes.
* @throws {BitpackerError} `CRYPTO_HASH_ALGORITHM_UNKNOWN` when the algorithm
* is not registered in the supplied registry.
*/
export async function schemaFingerprint(
schema: BitPackerSchema,
algorithm = "sha256",
registry: HashAlgorithmRegistry = defaultHashRegistry()
): Promise<Uint8Array> {
const tracer = trace.getTracer(TRACER_NAME);
const span = tracer.startSpan(FINGERPRINT_SPAN, {
attributes: {
"bitpacker.crypto.algorithm": algorithm,
},
});
try {
const algo = registry.resolve(algorithm);
const canonical = canonicalize(schema);
const bytes = new TextEncoder().encode(canonical);
span.setAttribute("bitpacker.crypto.payload_bytes", bytes.length);
return await algo.hash(bytes);
} finally {
span.end();
}
}
/**
* Verify that `schema`'s fingerprint matches `expected`. Throws
* `CRYPTO_SCHEMA_FINGERPRINT_MISMATCH` on disagreement; the error context
* carries hex-encoded `expected` and `actual` digests so a mismatch is
* diagnosable from the error log alone.
*
* Intended for "do we agree on the schema?" handshakes between mesh peers,
* registry consistency checks, and pre-flight validation in any scenario
* where two sides need to be locked to byte-identical schemas before they
* can interpret a shared payload.
*/
export async function assertSchemaFingerprint(
schema: BitPackerSchema,
expected: Uint8Array,
algorithm = "sha256",
registry: HashAlgorithmRegistry = defaultHashRegistry()
): Promise<void> {
const actual = await schemaFingerprint(schema, algorithm, registry);
if (!bytesEqual(actual, expected)) {
throw new BitpackerError(
BITPACKER_CODES.CRYPTO_SCHEMA_FINGERPRINT_MISMATCH,
"Schema fingerprint mismatch",
{
context: {
algorithm,
expected: hex(expected),
actual: hex(actual),
},
}
);
}
}
/**
* Canonical serialisation of a `BitPackerSchema`. Strict JSON-like grammar
* with sorted object keys and no whitespace.
*
* Supported leaf types: `number`, `string`, `boolean`, `null`. Composite
* types: `object` (keys sorted lexicographically), `array` (order preserved).
* Functions on `_if` / `_then` / `_version` schemas are not serialisable —
* fingerprinting such schemas throws `CRYPTO_SCHEMA_FINGERPRINT_MISMATCH`
* (with `context.reason = "function-in-schema"`); v1 deliberately scopes
* fingerprintable schemas to data-shape-only.
*/
export function canonicalize(schema: BitPackerSchema): string {
return canonicalizeImpl(schema);
}
function canonicalizeImpl(node: unknown): string {
if (node === null) return "null";
if (node === undefined) return "null";
const t = typeof node;
if (t === "number") {
if (!Number.isFinite(node as number)) {
throw new BitpackerError(
BITPACKER_CODES.CRYPTO_SCHEMA_FINGERPRINT_MISMATCH,
"Cannot fingerprint a non-finite number",
{ context: { reason: "non-finite-number" } }
);
}
return JSON.stringify(node);
}
if (t === "string" || t === "boolean") return JSON.stringify(node);
if (t === "bigint") return `${(node as bigint).toString()}n`;
if (t === "function") {
throw new BitpackerError(
BITPACKER_CODES.CRYPTO_SCHEMA_FINGERPRINT_MISMATCH,
"Cannot fingerprint a schema containing a function (e.g. ConditionalSchema)",
{ context: { reason: "function-in-schema" } }
);
}
if (Array.isArray(node)) {
let s = "[";
for (let i = 0; i < node.length; i++) {
if (i > 0) s += ",";
s += canonicalizeImpl(node[i]);
}
return `${s}]`;
}
if (t === "object") {
const obj = node as Record<string, unknown>;
const keys = Object.keys(obj).sort();
let s = "{";
for (let i = 0; i < keys.length; i++) {
const k = keys[i] as string;
if (i > 0) s += ",";
s += `${JSON.stringify(k)}:${canonicalizeImpl(obj[k])}`;
}
return `${s}}`;
}
throw new BitpackerError(
BITPACKER_CODES.CRYPTO_SCHEMA_FINGERPRINT_MISMATCH,
`Cannot canonicalize value of type ${t}`,
{ context: { reason: "unsupported-type", type: t } }
);
}
function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
// Inputs are both sized by the same hash algorithm (caller-provided
// expected vs. computed actual), so the length-mismatch branch is
// unreachable in practice. Constant-time scan over the full length.
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) {
diff |= (a[i] as number) ^ (b[i] as number);
}
return diff === 0;
}
function hex(b: Uint8Array): string {
let s = "";
for (const x of b) {
s += (x as number).toString(16).padStart(2, "0");
}
return s;
}

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,77 @@
/**
* Spec-conformance test for `HashAlgorithmRegistry`. Pins the OTel
* replacement-event surface and singleton-isolation invariant from the
* `bitpacker-crypto-rewrite` spec.
*/
import { describe, expect, it } from "@suaveplan/testing/runner";
import { withRecordingTracer } from "../../tests/helpers/recording-tracer.js";
import {
_resetDefaultHashRegistryForTesting,
createHashRegistry,
defaultHashRegistry,
type HashAlgorithm,
} from "./hash-registry.js";
describe("hash-registry — spec conformance", () => {
it("replacement emits OTel hash-registry.replaced span with attributes", {
timeout: 5000,
}, () => {
withRecordingTracer((session) => {
const reg = createHashRegistry();
const replacement: HashAlgorithm = {
name: "sha256",
outputBytes: 32,
hash: () => Promise.resolve(new Uint8Array(32)),
};
reg.register(replacement);
const spans = session
.spansFor("bitpacker-crypto")
.filter(
(s) => s.name === "bitpacker-crypto.hash-registry.replaced"
);
expect(spans).toHaveLength(1);
const span = spans[0];
expect(span?.attributes["bitpacker.crypto.algorithm"]).toBe(
"sha256"
);
expect(
span?.attributes["bitpacker.crypto.previous_output_bytes"]
).toBe(32);
expect(span?.attributes["bitpacker.crypto.new_output_bytes"]).toBe(
32
);
expect(span?.ended).toBe(true);
});
});
it("registering a fresh name does NOT emit replaced span", {
timeout: 5000,
}, () => {
withRecordingTracer((session) => {
const reg = createHashRegistry();
reg.register({
name: "novel",
outputBytes: 8,
hash: () => Promise.resolve(new Uint8Array(8)),
});
const spans = session
.spansFor("bitpacker-crypto")
.filter(
(s) => s.name === "bitpacker-crypto.hash-registry.replaced"
);
expect(spans).toHaveLength(0);
});
});
it("createHashRegistry isolates from singleton", { timeout: 5000 }, () => {
_resetDefaultHashRegistryForTesting();
const fresh = createHashRegistry();
fresh.register({
name: "iso-test",
outputBytes: 1,
hash: () => Promise.resolve(new Uint8Array(1)),
});
expect(defaultHashRegistry().has("iso-test")).toBe(false);
});
});

View File

@@ -0,0 +1,156 @@
import { BITPACKER_CODES, BitpackerError } from "@suaveplan/bitpacker";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
_resetDefaultHashRegistryForTesting,
createHashRegistry,
defaultHashRegistry,
type HashAlgorithm,
} from "./hash-registry.js";
describe("hash-registry — defaults", () => {
it("default registry ships sha256, xxhash32, crc32", {
timeout: 5000,
}, () => {
const reg = createHashRegistry();
expect(reg.has("sha256")).toBe(true);
expect(reg.has("xxhash32")).toBe(true);
expect(reg.has("crc32")).toBe(true);
});
it("list returns names in stable sort order", { timeout: 5000 }, () => {
const reg = createHashRegistry();
expect(reg.list()).toEqual(["crc32", "sha256", "xxhash32"]);
});
it("each default has the correct outputBytes", { timeout: 5000 }, () => {
const reg = createHashRegistry();
expect(reg.resolve("sha256").outputBytes).toBe(32);
expect(reg.resolve("xxhash32").outputBytes).toBe(4);
expect(reg.resolve("crc32").outputBytes).toBe(4);
});
it("sha256 produces the canonical 32-byte digest of empty input", {
timeout: 5000,
}, async () => {
const reg = createHashRegistry();
const empty = await reg.resolve("sha256").hash(new Uint8Array());
expect(empty.length).toBe(32);
// SHA-256("") = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
expect(empty[0]).toBe(0xe3);
expect(empty[31]).toBe(0x55);
});
it("xxhash32 hash is a 4-byte digest of the input", {
timeout: 5000,
}, async () => {
const reg = createHashRegistry();
const data = new TextEncoder().encode("hello");
const out = await reg.resolve("xxhash32").hash(data);
expect(out.length).toBe(4);
});
it("crc32 hash is a 4-byte big-endian digest of the input", {
timeout: 5000,
}, async () => {
const reg = createHashRegistry();
const data = new TextEncoder().encode("hello");
const out = await reg.resolve("crc32").hash(data);
expect(out.length).toBe(4);
// CRC32("hello") = 0x3610a686 — big-endian
expect(out[0]).toBe(0x36);
expect(out[1]).toBe(0x10);
expect(out[2]).toBe(0xa6);
expect(out[3]).toBe(0x86);
});
});
describe("hash-registry — register replaces", () => {
it("register replaces existing algorithm with last-write-wins", {
timeout: 5000,
}, async () => {
const reg = createHashRegistry();
const sentinel: HashAlgorithm = {
name: "sha256",
outputBytes: 32,
hash: () => Promise.resolve(new Uint8Array(32).fill(0xab)),
};
reg.register(sentinel);
const out = await reg
.resolve("sha256")
.hash(new TextEncoder().encode("anything"));
expect(out[0]).toBe(0xab);
expect(out[31]).toBe(0xab);
});
it("registering a new algorithm exposes it for resolve/has", {
timeout: 5000,
}, () => {
const reg = createHashRegistry();
const blake3: HashAlgorithm = {
name: "blake3",
outputBytes: 32,
hash: () => Promise.resolve(new Uint8Array(32)),
};
reg.register(blake3);
expect(reg.has("blake3")).toBe(true);
expect(reg.resolve("blake3").outputBytes).toBe(32);
});
});
describe("hash-registry — resolve errors", () => {
it("resolve unknown algorithm throws CRYPTO_HASH_ALGORITHM_UNKNOWN", {
timeout: 5000,
}, () => {
const reg = createHashRegistry();
try {
reg.resolve("magic-hash");
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(BitpackerError);
const e = err as BitpackerError;
expect(e.code).toBe(BITPACKER_CODES.CRYPTO_HASH_ALGORITHM_UNKNOWN);
expect(e.context).toMatchObject({ algorithm: "magic-hash" });
}
});
it("error context includes the registered algorithm list", {
timeout: 5000,
}, () => {
const reg = createHashRegistry();
try {
reg.resolve("nope");
} catch (err) {
const e = err as BitpackerError;
expect(e.context?.["registered"]).toEqual([
"crc32",
"sha256",
"xxhash32",
]);
}
});
});
describe("hash-registry — singleton", () => {
it("defaultHashRegistry returns the same instance across calls", {
timeout: 5000,
}, () => {
_resetDefaultHashRegistryForTesting();
const a = defaultHashRegistry();
const b = defaultHashRegistry();
expect(a).toBe(b);
});
it("createHashRegistry isolates from singleton", { timeout: 5000 }, () => {
_resetDefaultHashRegistryForTesting();
const fresh = createHashRegistry();
const custom: HashAlgorithm = {
name: "custom-isolated",
outputBytes: 16,
hash: () => Promise.resolve(new Uint8Array(16)),
};
fresh.register(custom);
const singleton = defaultHashRegistry();
expect(singleton.list()).not.toContain("custom-isolated");
expect(fresh.list()).toContain("custom-isolated");
});
});

View File

@@ -0,0 +1,171 @@
import { trace } from "@opentelemetry/api";
import { BITPACKER_CODES, BitpackerError } from "@suaveplan/bitpacker";
import { crc32 } from "../crc32.js";
import { calculateXxhash32, packXxhash32 } from "../xxhash32.js";
const TRACER_NAME = "bitpacker-crypto";
/**
* A registered hash algorithm. The same shape covers fast non-cryptographic
* checksums (xxhash32, crc32) and cryptographic hashes (sha256, blake3, …).
*
* The `hash` method is always asynchronous so the contract is uniform across
* sync (xxhash32) and async (Web Crypto sha256) backends. Implementations
* that compute synchronously simply wrap their result in `Promise.resolve`.
*/
export interface HashAlgorithm {
/** Stable identifier used for registry lookup and wire-format tagging. */
readonly name: string;
/** Output digest length in bytes. */
readonly outputBytes: number;
/** Compute the digest of `data`. Resolves to exactly `outputBytes` bytes. */
hash(data: Uint8Array): Promise<Uint8Array>;
}
/**
* A registry of named hash algorithms. The default registry ships with
* `sha256`, `xxhash32`, and `crc32`. Consumers register additional
* algorithms (e.g. `blake3`) via `register`. Last-write-wins replacement is
* permitted and emits an OTel `bitpacker-crypto.hash-registry.replaced`
* span so swap-ins are observable.
*
* `unregister` is intentionally absent on the default registry surface to
* prevent accidental removal of a built-in algorithm; isolated test
* registries can be created via `createHashRegistry()` and discarded.
*/
export interface HashAlgorithmRegistry {
register(algorithm: HashAlgorithm): void;
resolve(name: string): HashAlgorithm;
has(name: string): boolean;
list(): readonly string[];
}
class HashAlgorithmRegistryImpl implements HashAlgorithmRegistry {
readonly #map = new Map<string, HashAlgorithm>();
public register(algorithm: HashAlgorithm): void {
const previous = this.#map.get(algorithm.name);
if (previous !== undefined) {
const tracer = trace.getTracer(TRACER_NAME);
const span = tracer.startSpan(
"bitpacker-crypto.hash-registry.replaced",
{
attributes: {
"bitpacker.crypto.algorithm": algorithm.name,
"bitpacker.crypto.previous_output_bytes":
previous.outputBytes,
"bitpacker.crypto.new_output_bytes":
algorithm.outputBytes,
},
}
);
span.end();
}
this.#map.set(algorithm.name, algorithm);
}
public resolve(name: string): HashAlgorithm {
const algorithm = this.#map.get(name);
if (algorithm === undefined) {
throw new BitpackerError(
BITPACKER_CODES.CRYPTO_HASH_ALGORITHM_UNKNOWN,
`Unknown hash algorithm: ${name}`,
{ context: { algorithm: name, registered: this.list() } }
);
}
return algorithm;
}
public has(name: string): boolean {
return this.#map.has(name);
}
public list(): readonly string[] {
return [...this.#map.keys()].sort();
}
}
/**
* SHA-256 backed by `globalThis.crypto.subtle.digest`. Throws via a
* lazily-evaluated message rather than at module load so an environment
* without Web Crypto can still import the registry; the failure surfaces
* the first time an algorithm consumer actually calls `hash`.
*/
const SHA256: HashAlgorithm = {
name: "sha256",
outputBytes: 32,
async hash(data: Uint8Array): Promise<Uint8Array> {
const subtle = globalThis.crypto?.subtle;
if (subtle === undefined) {
throw new BitpackerError(
BITPACKER_CODES.CRYPTO_RNG_UNAVAILABLE,
"globalThis.crypto.subtle is not available in this environment",
{ context: { algorithm: "sha256" } }
);
}
const buffer = await subtle.digest(
"SHA-256",
data.buffer.slice(
data.byteOffset,
data.byteOffset + data.byteLength
) as ArrayBuffer
);
return new Uint8Array(buffer);
},
};
const XXHASH32: HashAlgorithm = {
name: "xxhash32",
outputBytes: 4,
hash(data: Uint8Array): Promise<Uint8Array> {
return Promise.resolve(packXxhash32(calculateXxhash32(data)));
},
};
const CRC32: HashAlgorithm = {
name: "crc32",
outputBytes: 4,
hash(data: Uint8Array): Promise<Uint8Array> {
const checksum = crc32(data);
const out = new Uint8Array(4);
out[0] = (checksum >>> 24) & 0xff;
out[1] = (checksum >>> 16) & 0xff;
out[2] = (checksum >>> 8) & 0xff;
out[3] = checksum & 0xff;
return Promise.resolve(out);
},
};
/**
* Create a fresh, isolated registry pre-populated with the default
* algorithms (`sha256`, `xxhash32`, `crc32`). The returned registry is
* independent of the process-wide singleton; mutations do not leak across.
*/
export function createHashRegistry(): HashAlgorithmRegistry {
const registry = new HashAlgorithmRegistryImpl();
registry.register(SHA256);
registry.register(XXHASH32);
registry.register(CRC32);
return registry;
}
let DEFAULT_REGISTRY: HashAlgorithmRegistry | undefined;
/**
* Process-wide singleton registry. Lazily constructed on first call so
* importing this module has no side effects until the registry is actually
* needed. Subsequent calls return the same instance.
*/
export function defaultHashRegistry(): HashAlgorithmRegistry {
if (DEFAULT_REGISTRY === undefined) {
DEFAULT_REGISTRY = createHashRegistry();
}
return DEFAULT_REGISTRY;
}
/** Test-only: reset the singleton so a fresh default registry is built next call. */
export function _resetDefaultHashRegistryForTesting(): void {
DEFAULT_REGISTRY = undefined;
}
export { CRC32, SHA256, XXHASH32 };

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,298 @@
import {
BITPACKER_CODES,
type BitpackerError,
defineBitSchema,
} from "@suaveplan/bitpacker";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { cryptoVerifier } from "../../tests/helpers/crypto-deps.js";
import { createFakeAuthority } from "../../tests/helpers/fake-authority.js";
import { DOMAIN_BITPACKER_HASHCHAIN_V1 } from "../../tests/helpers/test-domains.js";
import type { BodyCodec } from "../envelope/envelope-schema.js";
import {
createHashRegistry,
type HashAlgorithm,
} from "../hash-registry/hash-registry.js";
import {
defineHashChainSchema,
type HashChainEntry,
} from "./hashchain-schema.js";
interface AuditPayload {
actorId: number;
eventCode: number;
}
const AUDIT_CODEC: BodyCodec<AuditPayload> = (() => {
const schema = defineBitSchema<AuditPayload>([
{ name: "actorId", type: "uint", bits: 32 },
{ name: "eventCode", type: "uint", bits: 16 },
]);
return {
pack: (b) => schema.pack(b),
unpack: (bytes) => schema.unpack(bytes),
};
})();
describe("hashchain-schema — append + verify happy path", () => {
it("append produces signed entry with prev-linkage", {
timeout: 10_000,
}, async () => {
const { authority, publicSlot } = createFakeAuthority();
const chain = defineHashChainSchema<AuditPayload>({
domain: DOMAIN_BITPACKER_HASHCHAIN_V1,
verifier: cryptoVerifier,
payloadCodec: AUDIT_CODEC,
});
const genesisHash = new Uint8Array(32);
const entry = await chain.appendEntry(
0n,
genesisHash,
{ actorId: 1, eventCode: 100 },
authority
);
expect(entry.seq).toBe(0n);
expect(entry.payload.actorId).toBe(1);
expect(entry.hostSignature.length).toBe(64);
expect(await chain.verifyEntry(entry, genesisHash, publicSlot)).toBe(
true
);
});
it("packEntry/unpackEntry round-trip preserves shape", {
timeout: 10_000,
}, async () => {
const { authority } = createFakeAuthority();
const chain = defineHashChainSchema<AuditPayload>({
domain: DOMAIN_BITPACKER_HASHCHAIN_V1,
verifier: cryptoVerifier,
payloadCodec: AUDIT_CODEC,
});
const entry = await chain.appendEntry(
5n,
new Uint8Array(32),
{ actorId: 9, eventCode: 7 },
authority
);
const bytes = chain.packEntry(entry);
const decoded = chain.unpackEntry(bytes);
expect(decoded.seq).toBe(5n);
expect(decoded.payload).toEqual({ actorId: 9, eventCode: 7 });
expect(Array.from(decoded.prevHash)).toEqual(
Array.from(entry.prevHash)
);
expect(Array.from(decoded.hostSignature)).toEqual(
Array.from(entry.hostSignature)
);
});
});
async function buildChain(
chain: ReturnType<typeof defineHashChainSchema<AuditPayload>>,
authority: Parameters<typeof chain.appendEntry>[3],
n: number
): Promise<HashChainEntry<AuditPayload>[]> {
const entries: HashChainEntry<AuditPayload>[] = [];
let prevHash = new Uint8Array(chain.hashLength);
for (let i = 0; i < n; i++) {
const entry = await chain.appendEntry(
BigInt(i),
prevHash,
{ actorId: i, eventCode: 1000 + i },
authority
);
entries.push(entry);
prevHash = await chain.hashEntry(entry);
}
return entries;
}
describe("hashchain-schema — chain verification", () => {
it("verifyChain succeeds on a clean chain", {
timeout: 15_000,
}, async () => {
const { authority, publicSlot } = createFakeAuthority();
const chain = defineHashChainSchema<AuditPayload>({
domain: DOMAIN_BITPACKER_HASHCHAIN_V1,
verifier: cryptoVerifier,
payloadCodec: AUDIT_CODEC,
});
const entries = await buildChain(chain, authority, 5);
const result = await chain.verifyChain(entries, publicSlot);
expect(result.valid).toBe(true);
expect(result.firstBadIndex).toBeUndefined();
});
it("chain verification detects payload tamper at exact index", {
timeout: 15_000,
}, async () => {
const { authority, publicSlot } = createFakeAuthority();
const chain = defineHashChainSchema<AuditPayload>({
domain: DOMAIN_BITPACKER_HASHCHAIN_V1,
verifier: cryptoVerifier,
payloadCodec: AUDIT_CODEC,
});
const entries = await buildChain(chain, authority, 5);
// Mutate entry-2's payload (signature won't match anymore)
const tampered: HashChainEntry<AuditPayload>[] = entries.map((e, i) =>
i === 2 ? { ...e, payload: { ...e.payload, actorId: 999 } } : e
);
const result = await chain.verifyChain(tampered, publicSlot);
expect(result.valid).toBe(false);
expect(result.firstBadIndex).toBe(2);
});
it("chain verification detects prev-hash break", {
timeout: 15_000,
}, async () => {
const { authority, publicSlot } = createFakeAuthority();
const chain = defineHashChainSchema<AuditPayload>({
domain: DOMAIN_BITPACKER_HASHCHAIN_V1,
verifier: cryptoVerifier,
payloadCodec: AUDIT_CODEC,
});
const entries = await buildChain(chain, authority, 5);
// Mutate entry-3's prevHash to wrong bytes — this also invalidates
// entry-3's signature (it was signed with the correct prevHash)
const wrongPrev = new Uint8Array(32);
wrongPrev[0] = 0xff;
const tampered = entries.map((e, i) =>
i === 3 ? { ...e, prevHash: wrongPrev } : e
);
const result = await chain.verifyChain(tampered, publicSlot);
expect(result.valid).toBe(false);
expect(result.firstBadIndex).toBe(3);
});
});
describe("hashchain-schema — pluggable hash algorithm", () => {
it("uses a custom algorithm registered in a custom registry", {
timeout: 10_000,
}, async () => {
const registry = createHashRegistry();
const blake3: HashAlgorithm = {
name: "blake3",
outputBytes: 32,
hash: () => Promise.resolve(new Uint8Array(32).fill(0xb3)),
};
registry.register(blake3);
const chain = defineHashChainSchema<AuditPayload>({
domain: DOMAIN_BITPACKER_HASHCHAIN_V1,
verifier: cryptoVerifier,
payloadCodec: AUDIT_CODEC,
hashAlgorithm: "blake3",
hashRegistry: registry,
});
const { authority } = createFakeAuthority();
const entry = await chain.appendEntry(
0n,
new Uint8Array(32),
{ actorId: 1, eventCode: 1 },
authority
);
const digest = await chain.hashEntry(entry);
expect(digest.length).toBe(32);
expect(digest[0]).toBe(0xb3);
});
it("rejects unknown algorithm at construction time", {
timeout: 5000,
}, () => {
try {
defineHashChainSchema<AuditPayload>({
domain: DOMAIN_BITPACKER_HASHCHAIN_V1,
verifier: cryptoVerifier,
payloadCodec: AUDIT_CODEC,
hashAlgorithm: "magic-not-real",
});
throw new Error("expected throw");
} catch (err) {
const e = err as BitpackerError;
expect(e.code).toBe(BITPACKER_CODES.CRYPTO_HASH_ALGORITHM_UNKNOWN);
}
});
});
describe("hashchain-schema — input validation", () => {
it("rejects negative seq", { timeout: 5000 }, async () => {
const { authority } = createFakeAuthority();
const chain = defineHashChainSchema<AuditPayload>({
domain: DOMAIN_BITPACKER_HASHCHAIN_V1,
verifier: cryptoVerifier,
payloadCodec: AUDIT_CODEC,
});
try {
await chain.appendEntry(
-1n,
new Uint8Array(32),
{ actorId: 0, eventCode: 0 },
authority
);
throw new Error("expected throw");
} catch (err) {
const e = err as BitpackerError;
expect(e.code).toBe(BITPACKER_CODES.VALUE_OUT_OF_RANGE);
}
});
it("rejects prevHash of wrong length", {
timeout: 5000,
}, async () => {
const { authority } = createFakeAuthority();
const chain = defineHashChainSchema<AuditPayload>({
domain: DOMAIN_BITPACKER_HASHCHAIN_V1,
verifier: cryptoVerifier,
payloadCodec: AUDIT_CODEC,
});
try {
await chain.appendEntry(
0n,
new Uint8Array(16), // sha256 needs 32
{ actorId: 0, eventCode: 0 },
authority
);
throw new Error("expected throw");
} catch (err) {
const e = err as BitpackerError;
expect(e.code).toBe(BITPACKER_CODES.CRYPTO_KEY_INVALID);
}
});
it("unpackEntry rejects bytes too short", { timeout: 5000 }, () => {
const chain = defineHashChainSchema<AuditPayload>({
domain: DOMAIN_BITPACKER_HASHCHAIN_V1,
verifier: cryptoVerifier,
payloadCodec: AUDIT_CODEC,
});
try {
chain.unpackEntry(new Uint8Array(10));
throw new Error("expected throw");
} catch (err) {
const e = err as BitpackerError;
expect(e.code).toBe(BITPACKER_CODES.CRYPTO_VERIFY_FAILED);
}
});
it("missing verifier rejects at construction time", {
timeout: 5000,
}, () => {
expect(() =>
// @ts-expect-error — verifier is required
defineHashChainSchema<AuditPayload>({
domain: DOMAIN_BITPACKER_HASHCHAIN_V1,
payloadCodec: AUDIT_CODEC,
})
).toThrow();
});
it("rejects unknown options keys", { timeout: 5000 }, () => {
expect(() =>
defineHashChainSchema<AuditPayload>({
domain: DOMAIN_BITPACKER_HASHCHAIN_V1,
verifier: cryptoVerifier,
payloadCodec: AUDIT_CODEC,
// @ts-expect-error — unknown
bogus: true,
})
).toThrow();
});
});

View File

@@ -0,0 +1,368 @@
import { trace } from "@opentelemetry/api";
import { BITPACKER_CODES, BitpackerError } from "@suaveplan/bitpacker";
import type {
Authority,
AuthorityVerifier,
DomainTag,
KeySlotPublic,
SlotId,
} from "@suaveplan/types";
import z from "zod";
import type { BodyCodec } from "../envelope/envelope-schema.js";
import {
defaultHashRegistry,
type HashAlgorithmRegistry,
} from "../hash-registry/hash-registry.js";
import {
DEFAULT_SIGN_SLOT,
DEFAULT_SIGNATURE_LENGTH,
} from "../signed/signed-bitschema.js";
const TRACER_NAME = "bitpacker-crypto";
const APPEND_SPAN = "bitpacker-crypto.hashchain-append";
const VERIFY_SPAN = "bitpacker-crypto.hashchain-verify";
const SEQ_BYTES = 8;
/**
* One node in an append-only signed hash chain. The wire layout is:
*
* `[seq u64 BE][prevHash N bytes][payload bit-packed][hostSignature 64 bytes]`
*
* The signature is computed over the bit-packed `(seq, prevHash, payload)`
* triplet under the schema's `DomainTag`, so a signature produced under
* one chain identity cannot be replayed inside another chain even if the
* payload bytes happen to coincide.
*/
export interface HashChainEntry<TPayload> {
readonly seq: bigint;
readonly prevHash: Uint8Array;
readonly payload: TPayload;
readonly hostSignature: Uint8Array;
}
/** Result of a chain-walk verify. */
export interface ChainVerifyResult {
readonly valid: boolean;
readonly firstBadIndex?: number;
}
export interface HashChainSchemaOptions<TPayload> {
readonly domain: DomainTag;
readonly verifier: AuthorityVerifier;
readonly slot?: SlotId;
readonly payloadCodec: BodyCodec<TPayload>;
readonly hashAlgorithm?: string;
readonly hashRegistry?: HashAlgorithmRegistry;
readonly signatureLength?: number;
}
export interface HashChainSchema<TPayload> {
/** Compute the digest of a packed entry — used to produce next entry's prevHash. */
hashEntry(entry: HashChainEntry<TPayload>): Promise<Uint8Array>;
/** Append: sign the (seq, prevHash, payload) triplet and produce the new entry. */
appendEntry(
seq: bigint,
prevHash: Uint8Array,
payload: TPayload,
authority: Authority,
signal?: AbortSignal
): Promise<HashChainEntry<TPayload>>;
/** Verify a single entry: signature over (seq, prevHash, payload) + prevHash matches. */
verifyEntry(
entry: HashChainEntry<TPayload>,
expectedPrevHash: Uint8Array,
hostPublicSlot: KeySlotPublic<unknown>
): Promise<boolean>;
/** Walk the chain in order — pinpoints first bad index on failure. */
verifyChain(
entries: readonly HashChainEntry<TPayload>[],
hostPublicSlot: KeySlotPublic<unknown>
): Promise<ChainVerifyResult>;
/** Encode an entry to bytes — `seq u64 ‖ prevHash ‖ payload ‖ signature`. */
packEntry(entry: HashChainEntry<TPayload>): Uint8Array;
/** Decode an entry from bytes. */
unpackEntry(bytes: Uint8Array): HashChainEntry<TPayload>;
readonly domain: DomainTag;
readonly slot: SlotId;
readonly hashAlgorithm: string;
readonly hashLength: number;
readonly signatureLength: number;
}
const OptionsRuntimeSchema = z
.object({
domain: z.string(),
verifier: z.function(),
slot: z.string().optional(),
payloadCodec: z.object({
pack: z.function(),
unpack: z.function(),
}),
hashAlgorithm: z.string().optional(),
hashRegistry: z.unknown().optional(),
signatureLength: z.number().int().min(8).max(256).optional(),
})
.strict();
/** u64 big-endian encode a `bigint`. */
function packU64BE(value: bigint): Uint8Array {
if (value < 0n) {
throw new BitpackerError(
BITPACKER_CODES.VALUE_OUT_OF_RANGE,
"HashChainEntry seq must be non-negative",
{ context: { seq: value.toString() } }
);
}
const out = new Uint8Array(SEQ_BYTES);
for (let i = 7; i >= 0; i--) {
out[i] = Number(value & 0xffn);
value >>= 8n;
}
if (value !== 0n) {
throw new BitpackerError(
BITPACKER_CODES.VALUE_OUT_OF_RANGE,
"HashChainEntry seq exceeds 2^64-1",
{ context: { seq: "u64-overflow" } }
);
}
return out;
}
/** u64 big-endian decode to a `bigint`. */
function unpackU64BE(bytes: Uint8Array, offset: number): bigint {
// Caller (`unpackEntry`) pre-validates `bytes.length`, so the indexed
// accesses are always defined. Cast through `as number` to keep this
// branch-free.
let v = 0n;
for (let i = 0; i < 8; i++) {
v = (v << 8n) | BigInt(bytes[offset + i] as number);
}
return v;
}
export function defineHashChainSchema<TPayload>(
options: HashChainSchemaOptions<TPayload>
): HashChainSchema<TPayload> {
OptionsRuntimeSchema.parse(options);
const domain = options.domain;
const verifier = options.verifier;
const slot = options.slot ?? DEFAULT_SIGN_SLOT;
const codec = options.payloadCodec;
const hashAlgorithm = options.hashAlgorithm ?? "sha256";
const registry = options.hashRegistry ?? defaultHashRegistry();
const signatureLength = options.signatureLength ?? DEFAULT_SIGNATURE_LENGTH;
// Resolve once at construction so a missing algorithm errors early.
const hashAlgo = registry.resolve(hashAlgorithm);
const hashLength = hashAlgo.outputBytes;
function buildSigningPayload(
seq: bigint,
prevHash: Uint8Array,
payload: TPayload
): Uint8Array {
if (prevHash.length !== hashLength) {
throw new BitpackerError(
BITPACKER_CODES.CRYPTO_KEY_INVALID,
`prevHash length mismatch: got ${prevHash.length}, expected ${hashLength}`,
{
context: {
actualLength: prevHash.length,
expectedLength: hashLength,
},
}
);
}
const seqBytes = packU64BE(seq);
const payloadBytes = codec.pack(payload);
const out = new Uint8Array(
seqBytes.length + prevHash.length + payloadBytes.length
);
out.set(seqBytes, 0);
out.set(prevHash, seqBytes.length);
out.set(payloadBytes, seqBytes.length + prevHash.length);
return out;
}
function packEntry(entry: HashChainEntry<TPayload>): Uint8Array {
if (entry.hostSignature.length !== signatureLength) {
throw new BitpackerError(
BITPACKER_CODES.CRYPTO_SIGN_FAILED,
`Entry signature length mismatch: got ${entry.hostSignature.length}, expected ${signatureLength}`,
{
context: {
actualLength: entry.hostSignature.length,
expectedLength: signatureLength,
},
}
);
}
const signed = buildSigningPayload(
entry.seq,
entry.prevHash,
entry.payload
);
const out = new Uint8Array(signed.length + signatureLength);
out.set(signed, 0);
out.set(entry.hostSignature, signed.length);
return out;
}
function unpackEntry(bytes: Uint8Array): HashChainEntry<TPayload> {
if (bytes.length < SEQ_BYTES + hashLength + signatureLength) {
throw new BitpackerError(
BITPACKER_CODES.CRYPTO_VERIFY_FAILED,
`HashChain entry bytes too short: got ${bytes.length}, expected ≥ ${SEQ_BYTES + hashLength + signatureLength}`,
{
context: {
actualLength: bytes.length,
minLength: SEQ_BYTES + hashLength + signatureLength,
},
}
);
}
const seq = unpackU64BE(bytes, 0);
const prevHash = bytes.slice(SEQ_BYTES, SEQ_BYTES + hashLength);
const sigStart = bytes.length - signatureLength;
const payloadBytes = bytes.slice(SEQ_BYTES + hashLength, sigStart);
const hostSignature = bytes.slice(sigStart);
const payload = codec.unpack(payloadBytes);
return { seq, prevHash, payload, hostSignature };
}
async function hashEntry(
entry: HashChainEntry<TPayload>
): Promise<Uint8Array> {
const packed = packEntry(entry);
return hashAlgo.hash(packed);
}
async function appendEntry(
seq: bigint,
prevHash: Uint8Array,
payload: TPayload,
authority: Authority,
signal?: AbortSignal
): Promise<HashChainEntry<TPayload>> {
const tracer = trace.getTracer(TRACER_NAME);
const span = tracer.startSpan(APPEND_SPAN, {
attributes: {
"bitpacker.crypto.domain": String(domain),
"bitpacker.crypto.algorithm": hashAlgorithm,
"bitpacker.crypto.seq": String(seq),
},
});
try {
const signedPayload = buildSigningPayload(seq, prevHash, payload);
const signature = await authority.sign(
domain,
slot,
signedPayload,
signal
);
if (signature.length !== signatureLength) {
throw new BitpackerError(
BITPACKER_CODES.CRYPTO_SIGN_FAILED,
`Authority returned signature of unexpected length: got ${signature.length}, expected ${signatureLength}`,
{
context: {
actualLength: signature.length,
expectedLength: signatureLength,
},
}
);
}
return {
seq,
prevHash: new Uint8Array(prevHash),
payload,
hostSignature: signature,
};
} finally {
span.end();
}
}
async function verifyEntry(
entry: HashChainEntry<TPayload>,
expectedPrevHash: Uint8Array,
hostPublicSlot: KeySlotPublic<unknown>
): Promise<boolean> {
if (!bytesEqual(entry.prevHash, expectedPrevHash)) {
return false;
}
const signedPayload = buildSigningPayload(
entry.seq,
entry.prevHash,
entry.payload
);
try {
return verifier(
domain,
hostPublicSlot,
signedPayload,
entry.hostSignature
);
} catch {
return false;
}
}
async function verifyChain(
entries: readonly HashChainEntry<TPayload>[],
hostPublicSlot: KeySlotPublic<unknown>
): Promise<ChainVerifyResult> {
const tracer = trace.getTracer(TRACER_NAME);
const span = tracer.startSpan(VERIFY_SPAN, {
attributes: {
"bitpacker.crypto.domain": String(domain),
"bitpacker.crypto.algorithm": hashAlgorithm,
"bitpacker.crypto.entry_count": entries.length,
},
});
try {
for (let i = 0; i < entries.length; i++) {
const entry = entries[i] as HashChainEntry<TPayload>;
const expectedPrev =
i === 0
? entry.prevHash // genesis entry — caller-supplied hash is the trust anchor
: await hashEntry(
entries[i - 1] as HashChainEntry<TPayload>
);
const ok = await verifyEntry(
entry,
expectedPrev,
hostPublicSlot
);
if (!ok) {
return { valid: false, firstBadIndex: i };
}
}
return { valid: true };
} finally {
span.end();
}
}
return {
hashEntry,
appendEntry,
verifyEntry,
verifyChain,
packEntry,
unpackEntry,
domain,
slot,
hashAlgorithm,
hashLength,
signatureLength,
};
}
function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) return false;
}
return true;
}

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,91 @@
/**
* `@suaveplan/bitpacker-crypto` — bit-packer-aware crypto primitives.
*
* Public surface (consumer-driven design — see SUPERSEDED.md and the
* `bitpacker-crypto-rewrite` openspec change):
*
* - **Checksums** (existing, kept verbatim): crc32, adler32, xxhash32
* - **HashAlgorithmRegistry** (pluggable extension point — sha256/xxhash32/crc32 default)
* - **EnvelopeSchema** (typed `[magic][formatVersion][body]` envelope)
* - **SignedBitSchema** (Authority+DomainTag-bound single-signer)
* - **MultiSignedBitSchema** (N-of-N co-signed)
* - **HashChainSchema** (append-only signed chain)
* - **EncryptedBitSchema** (niche AEAD-only — small payloads, NOT chunked-file)
* - **SchemaFingerprint** (canonical-hash schema agreement)
*
* The upstream `bitpacker/src/crypto/` provider/registry abstraction is
* intentionally *not* ported; see `SUPERSEDED.md` for the per-symbol map.
*/
// Checksums
export {
adler32,
appendAdler32,
appendCrc32,
crc32,
verifyAndStripAdler32,
verifyAndStripCrc32,
} from "./crc32.js";
// Encrypted (niche AEAD)
export {
defineEncryptedBitSchema,
ENCRYPTED_ALG_TAGS,
ENCRYPTED_VERSION_V1,
type EncryptedAlgorithmTag,
type EncryptedBitSchema,
type EncryptedBitSchemaOptions,
} from "./encrypted/encrypted-bitschema.js";
// Envelope
export {
type BodyCodec,
defineEnvelopeSchema,
type EnvelopeSchema,
type EnvelopeSchemaOptions,
type ParsedEnvelope,
rawBytesCodec,
} from "./envelope/envelope-schema.js";
// Schema fingerprint
export {
assertSchemaFingerprint,
canonicalize as canonicalizeSchema,
schemaFingerprint,
} from "./fingerprint/schema-fingerprint.js";
// Pluggable hash registry
export {
createHashRegistry,
defaultHashRegistry,
type HashAlgorithm,
type HashAlgorithmRegistry,
} from "./hash-registry/hash-registry.js";
// Hashchain
export {
type ChainVerifyResult,
defineHashChainSchema,
type HashChainEntry,
type HashChainSchema,
type HashChainSchemaOptions,
} from "./hashchain/hashchain-schema.js";
// Multi-signed
export {
defineMultiSignedBitSchema,
type MultiSignedBitSchema,
type MultiSignedBitSchemaOptions,
type MultiVerifyResult,
} from "./multisigned/multisigned-bitschema.js";
// Signed
export {
DEFAULT_SIGN_SLOT,
DEFAULT_SIGNATURE_LENGTH,
defineSignedBitSchema,
type SignedBitSchema,
type SignedBitSchemaOptions,
} from "./signed/signed-bitschema.js";
export {
appendXxhash32,
calculateXxhash32,
packXxhash32,
unpackXxhash32,
verifyAndStripXxhash32,
verifyXxhash32,
} from "./xxhash32.js";

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,244 @@
import {
BITPACKER_CODES,
type BitpackerError,
defineBitSchema,
} from "@suaveplan/bitpacker";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { cryptoVerifier } from "../../tests/helpers/crypto-deps.js";
import { createFakeAuthority } from "../../tests/helpers/fake-authority.js";
import { DOMAIN_BITPACKER_MULTISIGN_V1 } from "../../tests/helpers/test-domains.js";
import type { BodyCodec } from "../envelope/envelope-schema.js";
import { defineMultiSignedBitSchema } from "./multisigned-bitschema.js";
interface Body {
foo: number;
}
const codec: BodyCodec<Body> = (() => {
const schema = defineBitSchema<Body>([
{ name: "foo", type: "uint", bits: 8 },
]);
return {
pack: (b) => schema.pack(b),
unpack: (bytes) => schema.unpack(bytes),
};
})();
describe("multisigned-bitschema — pack + unpack", () => {
it("pack with N authorities produces N signatures in order", {
timeout: 10_000,
}, async () => {
const a = createFakeAuthority();
const b = createFakeAuthority();
const ms = defineMultiSignedBitSchema<Body>(codec, {
domain: DOMAIN_BITPACKER_MULTISIGN_V1,
verifier: cryptoVerifier,
signerCount: 2,
});
const packed = await ms.pack({ foo: 9 }, [a.authority, b.authority]);
// body (1 byte) + 2*64 sigs = 1 + 128 = 129 bytes
expect(packed.length).toBe(1 + 128);
// Verify against [a, b] succeeds
expect(ms.verify(packed, [a.publicSlot, b.publicSlot])).toEqual({
valid: true,
});
// Verify against reversed order [b, a] fails (each signer's sig
// wouldn't verify against the other's public key)
const reversed = ms.verify(packed, [b.publicSlot, a.publicSlot]);
expect(reversed.valid).toBe(false);
});
it("unpack returns decoded value on all-pass", {
timeout: 10_000,
}, async () => {
const a = createFakeAuthority();
const b = createFakeAuthority();
const c = createFakeAuthority();
const ms = defineMultiSignedBitSchema<Body>(codec, {
domain: DOMAIN_BITPACKER_MULTISIGN_V1,
verifier: cryptoVerifier,
signerCount: 3,
});
const packed = await ms.pack({ foo: 42 }, [
a.authority,
b.authority,
c.authority,
]);
const decoded = ms.unpack(packed, [
a.publicSlot,
b.publicSlot,
c.publicSlot,
]);
expect(decoded.foo).toBe(42);
});
});
describe("multisigned-bitschema — count enforcement", () => {
it("pack rejects wrong-count authorities with CRYPTO_MULTISIGN_COUNT_MISMATCH", {
timeout: 10_000,
}, async () => {
const a = createFakeAuthority();
const ms = defineMultiSignedBitSchema<Body>(codec, {
domain: DOMAIN_BITPACKER_MULTISIGN_V1,
verifier: cryptoVerifier,
signerCount: 2,
});
try {
await ms.pack({ foo: 1 }, [a.authority]);
throw new Error("expected throw");
} catch (err) {
const e = err as BitpackerError;
expect(e.code).toBe(
BITPACKER_CODES.CRYPTO_MULTISIGN_COUNT_MISMATCH
);
expect(e.context).toMatchObject({
expected: 2,
actual: 1,
kind: "authorities",
});
}
});
it("unpack rejects wrong-count public slots", {
timeout: 10_000,
}, async () => {
const a = createFakeAuthority();
const b = createFakeAuthority();
const ms = defineMultiSignedBitSchema<Body>(codec, {
domain: DOMAIN_BITPACKER_MULTISIGN_V1,
verifier: cryptoVerifier,
signerCount: 2,
});
const packed = await ms.pack({ foo: 1 }, [a.authority, b.authority]);
try {
ms.unpack(packed, [a.publicSlot]);
throw new Error("expected throw");
} catch (err) {
const e = err as BitpackerError;
expect(e.code).toBe(
BITPACKER_CODES.CRYPTO_MULTISIGN_COUNT_MISMATCH
);
expect(e.context).toMatchObject({
expected: 2,
actual: 1,
kind: "publicSlots",
});
}
});
});
describe("multisigned-bitschema — verify pinpoints failures", () => {
it("verify pinpoints which signer failed", {
timeout: 10_000,
}, async () => {
const a = createFakeAuthority();
const b = createFakeAuthority();
const c = createFakeAuthority();
const ms = defineMultiSignedBitSchema<Body>(codec, {
domain: DOMAIN_BITPACKER_MULTISIGN_V1,
verifier: cryptoVerifier,
signerCount: 3,
});
const packed = await ms.pack({ foo: 7 }, [
a.authority,
b.authority,
c.authority,
]);
// Flip a byte inside the second signature region.
// Wire: [body 1 byte][sig0 64][sig1 64][sig2 64]
// Second signer signature starts at offset 1 + 64 = 65.
const tampered = new Uint8Array(packed);
tampered[65 + 5] = (tampered[65 + 5] ?? 0) ^ 0xff;
const result = ms.verify(tampered, [
a.publicSlot,
b.publicSlot,
c.publicSlot,
]);
expect(result.valid).toBe(false);
expect(result.firstBadSignerIndex).toBe(1);
});
it("unpack throws CRYPTO_VERIFY_FAILED on any signer mismatch", {
timeout: 10_000,
}, async () => {
const a = createFakeAuthority();
const b = createFakeAuthority();
const ms = defineMultiSignedBitSchema<Body>(codec, {
domain: DOMAIN_BITPACKER_MULTISIGN_V1,
verifier: cryptoVerifier,
signerCount: 2,
});
const packed = await ms.pack({ foo: 1 }, [a.authority, b.authority]);
// Flip the very last byte (in second signer's region)
const tampered = new Uint8Array(packed);
tampered[tampered.length - 1] =
(tampered[tampered.length - 1] ?? 0) ^ 0xff;
try {
ms.unpack(tampered, [a.publicSlot, b.publicSlot]);
throw new Error("expected throw");
} catch (err) {
const e = err as BitpackerError;
expect(e.code).toBe(BITPACKER_CODES.CRYPTO_VERIFY_FAILED);
expect(e.context?.["firstBadSignerIndex"]).toBe(1);
}
});
it("verify on too-short bytes returns valid:false at index 0", {
timeout: 10_000,
}, () => {
const a = createFakeAuthority();
const ms = defineMultiSignedBitSchema<Body>(codec, {
domain: DOMAIN_BITPACKER_MULTISIGN_V1,
verifier: cryptoVerifier,
signerCount: 1,
});
const result = ms.verify(new Uint8Array(10), [a.publicSlot]);
expect(result.valid).toBe(false);
expect(result.firstBadSignerIndex).toBe(0);
});
});
describe("multisigned-bitschema — option validation", () => {
it("missing verifier rejects at construction time", {
timeout: 5000,
}, () => {
expect(() =>
// @ts-expect-error — verifier is required
defineMultiSignedBitSchema(codec, {
domain: DOMAIN_BITPACKER_MULTISIGN_V1,
signerCount: 2,
})
).toThrow();
});
it("rejects signerCount < 1", { timeout: 5000 }, () => {
expect(() =>
defineMultiSignedBitSchema(codec, {
domain: DOMAIN_BITPACKER_MULTISIGN_V1,
verifier: cryptoVerifier,
signerCount: 0,
})
).toThrow();
});
it("rejects signerCount > 64", { timeout: 5000 }, () => {
expect(() =>
defineMultiSignedBitSchema(codec, {
domain: DOMAIN_BITPACKER_MULTISIGN_V1,
verifier: cryptoVerifier,
signerCount: 65,
})
).toThrow();
});
it("rejects unknown option keys", { timeout: 5000 }, () => {
expect(() =>
defineMultiSignedBitSchema(codec, {
domain: DOMAIN_BITPACKER_MULTISIGN_V1,
verifier: cryptoVerifier,
signerCount: 2,
// @ts-expect-error — unknown
bogus: true,
})
).toThrow();
});
});

View File

@@ -0,0 +1,261 @@
import { trace } from "@opentelemetry/api";
import { BITPACKER_CODES, BitpackerError } from "@suaveplan/bitpacker";
import type {
Authority,
AuthorityVerifier,
DomainTag,
KeySlotPublic,
SlotId,
} from "@suaveplan/types";
import z from "zod";
import type { BodyCodec } from "../envelope/envelope-schema.js";
import {
DEFAULT_SIGN_SLOT,
DEFAULT_SIGNATURE_LENGTH,
} from "../signed/signed-bitschema.js";
const TRACER_NAME = "bitpacker-crypto";
const MULTI_PACK_SPAN = "bitpacker-crypto.multisigned-pack";
const MULTI_VERIFY_SPAN = "bitpacker-crypto.multisigned-verify";
/**
* Result of a multi-signer verify. `valid: true` means every signature
* verified. `valid: false` carries `firstBadSignerIndex` (zero-indexed)
* pointing at the first signer whose signature failed.
*/
export interface MultiVerifyResult {
readonly valid: boolean;
readonly firstBadSignerIndex?: number;
}
/**
* Options for `defineMultiSignedBitSchema`. The `signerCount` is part of
* the schema, not the runtime payload — a schema declared for 3 signers
* cannot pack with 2 authorities or unpack with 4 public slots; both throw
* `CRYPTO_MULTISIGN_COUNT_MISMATCH`.
*/
export interface MultiSignedBitSchemaOptions {
readonly domain: DomainTag;
readonly verifier: AuthorityVerifier;
readonly slot?: SlotId;
readonly signerCount: number;
readonly signatureLength?: number;
}
/**
* Bit-packed payload co-signed by N independent authorities. Wire format:
* `[bit-packed body][sig_1][sig_2]…[sig_N]` where N is `options.signerCount`.
*
* - `pack(data, authorities, signal?)` requires exactly N authorities.
* Each authority signs the same bit-packed body under the configured
* domain; signature ordering follows the authority list order.
* - `unpack(bytes, signerPublicSlots)` requires exactly N public slots.
* ALL signatures must verify in order; throws `CRYPTO_VERIFY_FAILED`
* on the first failure.
* - `verify(bytes, signerPublicSlots)` returns `MultiVerifyResult` —
* never throws on signature mismatch; pinpoints the first bad signer.
*/
export interface MultiSignedBitSchema<T> {
pack(
data: T,
authorities: readonly Authority[],
signal?: AbortSignal
): Promise<Uint8Array>;
unpack(
bytes: Uint8Array,
signerPublicSlots: readonly KeySlotPublic<unknown>[]
): T;
verify(
bytes: Uint8Array,
signerPublicSlots: readonly KeySlotPublic<unknown>[]
): MultiVerifyResult;
readonly domain: DomainTag;
readonly slot: SlotId;
readonly signerCount: number;
readonly signatureLength: number;
}
const OptionsRuntimeSchema = z
.object({
domain: z.string(),
verifier: z.function(),
slot: z.string().optional(),
signerCount: z.number().int().min(1).max(64),
signatureLength: z.number().int().min(8).max(256).optional(),
})
.strict();
export function defineMultiSignedBitSchema<T>(
bodyCodec: BodyCodec<T>,
options: MultiSignedBitSchemaOptions
): MultiSignedBitSchema<T> {
OptionsRuntimeSchema.parse(options);
const domain = options.domain;
const verifier = options.verifier;
const slot = options.slot ?? DEFAULT_SIGN_SLOT;
const signerCount = options.signerCount;
const signatureLength = options.signatureLength ?? DEFAULT_SIGNATURE_LENGTH;
const totalSignaturesLength = signerCount * signatureLength;
function assertCount(
actual: number,
kind: "authorities" | "publicSlots"
): void {
if (actual !== signerCount) {
throw new BitpackerError(
BITPACKER_CODES.CRYPTO_MULTISIGN_COUNT_MISMATCH,
`Expected exactly ${signerCount} ${kind}, got ${actual}`,
{
context: {
expected: signerCount,
actual,
kind,
},
}
);
}
}
async function pack(
data: T,
authorities: readonly Authority[],
signal?: AbortSignal
): Promise<Uint8Array> {
assertCount(authorities.length, "authorities");
const tracer = trace.getTracer(TRACER_NAME);
const span = tracer.startSpan(MULTI_PACK_SPAN, {
attributes: {
"bitpacker.crypto.domain": String(domain),
"bitpacker.crypto.signer_count": signerCount,
},
});
try {
const body = bodyCodec.pack(data);
const signatures: Uint8Array[] = [];
for (let i = 0; i < authorities.length; i++) {
const authority = authorities[i] as Authority;
const sig = await authority.sign(domain, slot, body, signal);
if (sig.length !== signatureLength) {
throw new BitpackerError(
BITPACKER_CODES.CRYPTO_SIGN_FAILED,
`Authority ${i} returned signature of unexpected length: got ${sig.length}, expected ${signatureLength}`,
{
context: {
signerIndex: i,
actualLength: sig.length,
expectedLength: signatureLength,
},
}
);
}
signatures.push(sig);
}
const out = new Uint8Array(body.length + totalSignaturesLength);
out.set(body, 0);
for (let i = 0; i < signatures.length; i++) {
out.set(
signatures[i] as Uint8Array,
body.length + i * signatureLength
);
}
span.setAttribute("bitpacker.crypto.payload_bytes", out.length);
return out;
} finally {
span.end();
}
}
function splitBytes(bytes: Uint8Array): {
body: Uint8Array;
signatures: Uint8Array[];
} {
// `splitBytes` is only called from `unpack` after `verify` already
// returned `valid: true`, which only happens when bytes.length is
// ≥ totalSignaturesLength. The pre-check is therefore unreachable
// and kept out to honour 100% coverage.
const split = bytes.length - totalSignaturesLength;
const body = bytes.slice(0, split);
const signatures: Uint8Array[] = [];
for (let i = 0; i < signerCount; i++) {
signatures.push(
bytes.slice(
split + i * signatureLength,
split + (i + 1) * signatureLength
)
);
}
return { body, signatures };
}
function verify(
bytes: Uint8Array,
signerPublicSlots: readonly KeySlotPublic<unknown>[]
): MultiVerifyResult {
assertCount(signerPublicSlots.length, "publicSlots");
const tracer = trace.getTracer(TRACER_NAME);
const span = tracer.startSpan(MULTI_VERIFY_SPAN, {
attributes: {
"bitpacker.crypto.domain": String(domain),
"bitpacker.crypto.signer_count": signerCount,
},
});
try {
if (bytes.length < totalSignaturesLength) {
return { valid: false, firstBadSignerIndex: 0 };
}
const split = bytes.length - totalSignaturesLength;
const body = bytes.slice(0, split);
for (let i = 0; i < signerCount; i++) {
const sig = bytes.slice(
split + i * signatureLength,
split + (i + 1) * signatureLength
);
const slotPub = signerPublicSlots[i] as KeySlotPublic<unknown>;
let ok: boolean;
try {
ok = verifier(domain, slotPub, body, sig);
} catch {
ok = false;
}
if (!ok) {
return { valid: false, firstBadSignerIndex: i };
}
}
return { valid: true };
} finally {
span.end();
}
}
function unpack(
bytes: Uint8Array,
signerPublicSlots: readonly KeySlotPublic<unknown>[]
): T {
assertCount(signerPublicSlots.length, "publicSlots");
const result = verify(bytes, signerPublicSlots);
if (!result.valid) {
throw new BitpackerError(
BITPACKER_CODES.CRYPTO_VERIFY_FAILED,
"MultiSignedBitSchema signature verification failed",
{
context: {
domain: String(domain),
firstBadSignerIndex: result.firstBadSignerIndex,
},
}
);
}
const { body } = splitBytes(bytes);
return bodyCodec.unpack(body);
}
return {
pack,
unpack,
verify,
domain,
slot,
signerCount,
signatureLength,
};
}

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,143 @@
/**
* Spec-conformance test for `SignedBitSchema`. Pins OTel emission, the
* "verify-then-decode" order invariant, and the "no Math.random / Date.now
* in implementation files" determinism gate from the spec delta.
*/
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { defineBitSchema } from "@suaveplan/bitpacker";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { cryptoVerifier } from "../../tests/helpers/crypto-deps.js";
import { createFakeAuthority } from "../../tests/helpers/fake-authority.js";
import { withRecordingTracer } from "../../tests/helpers/recording-tracer.js";
import { DOMAIN_BITPACKER_SIGNED_V1 } from "../../tests/helpers/test-domains.js";
import type { BodyCodec } from "../envelope/envelope-schema.js";
import { defineSignedBitSchema } from "./signed-bitschema.js";
interface Body {
foo: number;
}
const codec: BodyCodec<Body> = (() => {
const schema = defineBitSchema<Body>([
{ name: "foo", type: "uint", bits: 8 },
]);
return {
pack: (b) => schema.pack(b),
unpack: (bytes) => schema.unpack(bytes),
};
})();
const SRC_DIR = new URL("..", import.meta.url).pathname;
function* walkSourceFiles(dir: string): Generator<string> {
if (!existsSync(dir)) return;
for (const entry of readdirSync(dir)) {
if (
entry === "node_modules" ||
entry === "dist" ||
entry === "coverage"
) {
continue;
}
const fullPath = join(dir, entry);
const st = statSync(fullPath);
if (st.isDirectory()) {
yield* walkSourceFiles(fullPath);
} else if (
entry.endsWith(".ts") &&
!entry.endsWith(".test.ts") &&
!entry.endsWith(".spec-conformance.test.ts")
) {
yield fullPath;
}
}
}
describe("signed-bitschema — spec conformance", () => {
it("signed-pack emits OTel span with domain attribute", {
timeout: 10_000,
}, async () => {
await withRecordingTracer(async (session) => {
const { authority } = createFakeAuthority();
const ss = defineSignedBitSchema<Body>(codec, {
domain: DOMAIN_BITPACKER_SIGNED_V1,
verifier: cryptoVerifier,
});
await ss.pack({ foo: 7 }, authority);
const spans = session
.spansFor("bitpacker-crypto")
.filter((s) => s.name === "bitpacker-crypto.signed-pack");
expect(spans).toHaveLength(1);
expect(spans[0]?.attributes["bitpacker.crypto.domain"]).toBe(
String(DOMAIN_BITPACKER_SIGNED_V1)
);
expect(spans[0]?.attributes["bitpacker.crypto.slot"]).toBe("sign");
expect(
spans[0]?.attributes["bitpacker.crypto.payload_bytes"]
).toBeGreaterThan(0);
});
});
it("signed-verify emits OTel span on every unpack call", {
timeout: 10_000,
}, async () => {
await withRecordingTracer(async (session) => {
const { authority, publicSlot } = createFakeAuthority();
const ss = defineSignedBitSchema<Body>(codec, {
domain: DOMAIN_BITPACKER_SIGNED_V1,
verifier: cryptoVerifier,
});
const packed = await ss.pack({ foo: 1 }, authority);
ss.unpack(packed, publicSlot);
const spans = session
.spansFor("bitpacker-crypto")
.filter((s) => s.name === "bitpacker-crypto.signed-verify");
expect(spans).toHaveLength(1);
});
});
it("verify-then-decode order: tampered payload throws BEFORE the body is decoded", {
timeout: 10_000,
}, async () => {
const { authority, publicSlot } = createFakeAuthority();
let decodeCalls = 0;
const trackingCodec: BodyCodec<Body> = {
pack: codec.pack,
unpack: (bytes) => {
decodeCalls += 1;
return codec.unpack(bytes);
},
};
const ss = defineSignedBitSchema<Body>(trackingCodec, {
domain: DOMAIN_BITPACKER_SIGNED_V1,
verifier: cryptoVerifier,
});
const packed = await ss.pack({ foo: 7 }, authority);
const tampered = new Uint8Array(packed);
tampered[0] = (tampered[0] ?? 0) ^ 0xff;
try {
ss.unpack(tampered, publicSlot);
} catch {
// expected
}
// unpack must throw on verification BEFORE bodyCodec.unpack runs.
expect(decodeCalls).toBe(0);
});
it("no Math.random or Date.now in implementation files", {
timeout: 5000,
}, () => {
const violations: string[] = [];
for (const path of walkSourceFiles(SRC_DIR)) {
const text = readFileSync(path, "utf8");
if (/Math\.random\s*\(/.test(text)) {
violations.push(`${path} uses Math.random()`);
}
if (/Date\.now\s*\(/.test(text)) {
violations.push(`${path} uses Date.now()`);
}
}
expect(violations).toEqual([]);
});
});

View File

@@ -0,0 +1,305 @@
import {
BITPACKER_CODES,
type BitpackerError,
defineBitSchema,
} from "@suaveplan/bitpacker";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { cryptoVerifier } from "../../tests/helpers/crypto-deps.js";
import { createFakeAuthority } from "../../tests/helpers/fake-authority.js";
import {
DOMAIN_AUDIT_V1,
DOMAIN_BITPACKER_SIGNED_V1,
DOMAIN_PUBLIC_KEYCHAIN_V1,
} from "../../tests/helpers/test-domains.js";
import type { BodyCodec } from "../envelope/envelope-schema.js";
import { defineSignedBitSchema } from "./signed-bitschema.js";
interface Body {
foo: number;
bar: number;
}
const BODY_CODEC: BodyCodec<Body> = (() => {
const schema = defineBitSchema<Body>([
{ name: "foo", type: "uint", bits: 8 },
{ name: "bar", type: "uint", bits: 16 },
]);
return {
pack: (b) => schema.pack(b),
unpack: (bytes) => schema.unpack(bytes),
};
})();
describe("signed-bitschema — pack + unpack round trip", () => {
it("pack signs via Authority under the configured domain", {
timeout: 10_000,
}, async () => {
const { authority, publicSlot } = createFakeAuthority();
const ss = defineSignedBitSchema<Body>(BODY_CODEC, {
domain: DOMAIN_PUBLIC_KEYCHAIN_V1,
verifier: cryptoVerifier,
});
const packed = await ss.pack({ foo: 7, bar: 1234 }, authority);
// Wire format: [body bytes][signature 64 bytes]
expect(packed.length).toBeGreaterThan(64);
const decoded = ss.unpack(packed, publicSlot);
expect(decoded.foo).toBe(7);
expect(decoded.bar).toBe(1234);
});
it("verify returns true for an unmodified payload", {
timeout: 10_000,
}, async () => {
const { authority, publicSlot } = createFakeAuthority();
const ss = defineSignedBitSchema<Body>(BODY_CODEC, {
domain: DOMAIN_BITPACKER_SIGNED_V1,
verifier: cryptoVerifier,
});
const packed = await ss.pack({ foo: 1, bar: 2 }, authority);
expect(ss.verify(packed, publicSlot)).toBe(true);
});
});
describe("signed-bitschema — tamper detection", () => {
it("tampered signature rejects with CRYPTO_VERIFY_FAILED", {
timeout: 10_000,
}, async () => {
const { authority, publicSlot } = createFakeAuthority();
const ss = defineSignedBitSchema<Body>(BODY_CODEC, {
domain: DOMAIN_BITPACKER_SIGNED_V1,
verifier: cryptoVerifier,
});
const packed = await ss.pack({ foo: 7, bar: 1234 }, authority);
// Flip the last byte (signature suffix).
const tampered = new Uint8Array(packed);
tampered[tampered.length - 1] =
(tampered[tampered.length - 1] ?? 0) ^ 0xff;
try {
ss.unpack(tampered, publicSlot);
throw new Error("expected throw");
} catch (err) {
const e = err as BitpackerError;
expect(e.code).toBe(BITPACKER_CODES.CRYPTO_VERIFY_FAILED);
}
});
it("tampered payload rejects with CRYPTO_VERIFY_FAILED", {
timeout: 10_000,
}, async () => {
const { authority, publicSlot } = createFakeAuthority();
const ss = defineSignedBitSchema<Body>(BODY_CODEC, {
domain: DOMAIN_BITPACKER_SIGNED_V1,
verifier: cryptoVerifier,
});
const packed = await ss.pack({ foo: 7, bar: 1234 }, authority);
// Flip a payload byte (before the signature region).
const tampered = new Uint8Array(packed);
tampered[0] = (tampered[0] ?? 0) ^ 0xff;
try {
ss.unpack(tampered, publicSlot);
throw new Error("expected throw");
} catch (err) {
const e = err as BitpackerError;
expect(e.code).toBe(BITPACKER_CODES.CRYPTO_VERIFY_FAILED);
}
});
it("verify returns false on tamper instead of throwing", {
timeout: 10_000,
}, async () => {
const { authority, publicSlot } = createFakeAuthority();
const ss = defineSignedBitSchema<Body>(BODY_CODEC, {
domain: DOMAIN_BITPACKER_SIGNED_V1,
verifier: cryptoVerifier,
});
const packed = await ss.pack({ foo: 1, bar: 2 }, authority);
const tampered = new Uint8Array(packed);
tampered[0] = (tampered[0] ?? 0) ^ 0xff;
expect(ss.verify(tampered, publicSlot)).toBe(false);
});
it("verify returns false on bytes too short for a signature", {
timeout: 10_000,
}, () => {
const { publicSlot } = createFakeAuthority();
const ss = defineSignedBitSchema<Body>(BODY_CODEC, {
domain: DOMAIN_BITPACKER_SIGNED_V1,
verifier: cryptoVerifier,
});
expect(ss.verify(new Uint8Array(10), publicSlot)).toBe(false);
});
it("unpack on bytes too short throws CRYPTO_VERIFY_FAILED", {
timeout: 10_000,
}, () => {
const { publicSlot } = createFakeAuthority();
const ss = defineSignedBitSchema<Body>(BODY_CODEC, {
domain: DOMAIN_BITPACKER_SIGNED_V1,
verifier: cryptoVerifier,
});
try {
ss.unpack(new Uint8Array(3), publicSlot);
throw new Error("expected throw");
} catch (err) {
const e = err as BitpackerError;
expect(e.code).toBe(BITPACKER_CODES.CRYPTO_VERIFY_FAILED);
}
});
});
describe("signed-bitschema — domain separation", () => {
it("cross-domain replay rejects with CRYPTO_VERIFY_FAILED", {
timeout: 10_000,
}, async () => {
const { authority, publicSlot } = createFakeAuthority();
// Pack under PUBLIC_KEYCHAIN; try to verify under AUDIT.
const ssA = defineSignedBitSchema<Body>(BODY_CODEC, {
domain: DOMAIN_PUBLIC_KEYCHAIN_V1,
verifier: cryptoVerifier,
});
const ssB = defineSignedBitSchema<Body>(BODY_CODEC, {
domain: DOMAIN_AUDIT_V1,
verifier: cryptoVerifier,
});
const packed = await ssA.pack({ foo: 1, bar: 2 }, authority);
// verify under different domain returns false (different signing input)
expect(ssB.verify(packed, publicSlot)).toBe(false);
try {
ssB.unpack(packed, publicSlot);
throw new Error("expected throw");
} catch (err) {
const e = err as BitpackerError;
expect(e.code).toBe(BITPACKER_CODES.CRYPTO_VERIFY_FAILED);
}
});
});
describe("signed-bitschema — unpackSelf trust-anchor flow", () => {
it("unpackSelf verifies against embedded sign slot", {
timeout: 10_000,
}, async () => {
// Trust-anchor body: include the public key in the body itself, so
// the signature is verified against its own embedded sign slot.
interface SelfBody {
embeddedPublicKey: Uint8Array;
seq: number;
}
const selfSchema = defineBitSchema<SelfBody>([
{ name: "embeddedPublicKey", type: "bytes", byteLength: 32 },
{ name: "seq", type: "uint", bits: 16 },
]);
const codec: BodyCodec<SelfBody> = {
pack: (b) => selfSchema.pack(b),
unpack: (bytes) => selfSchema.unpack(bytes),
};
const { authority, publicSlot, publicKey } = createFakeAuthority();
const ss = defineSignedBitSchema<SelfBody>(codec, {
domain: DOMAIN_BITPACKER_SIGNED_V1,
verifier: cryptoVerifier,
});
const packed = await ss.pack(
{ embeddedPublicKey: publicKey, seq: 42 },
authority
);
const decoded = ss.unpackSelf(packed, (body) => {
// Build a public slot from the embedded key.
return {
...publicSlot,
publicKey: body.embeddedPublicKey,
};
});
expect(decoded.seq).toBe(42);
expect(Array.from(decoded.embeddedPublicKey)).toEqual(
Array.from(publicKey)
);
});
it("unpackSelf rejects when the embedded slot does not match the signature", {
timeout: 10_000,
}, async () => {
interface SelfBody {
embeddedPublicKey: Uint8Array;
seq: number;
}
const selfSchema = defineBitSchema<SelfBody>([
{ name: "embeddedPublicKey", type: "bytes", byteLength: 32 },
{ name: "seq", type: "uint", bits: 16 },
]);
const codec: BodyCodec<SelfBody> = {
pack: (b) => selfSchema.pack(b),
unpack: (bytes) => selfSchema.unpack(bytes),
};
const { authority, publicSlot } = createFakeAuthority();
const otherSlot = createFakeAuthority().publicSlot;
const ss = defineSignedBitSchema<SelfBody>(codec, {
domain: DOMAIN_BITPACKER_SIGNED_V1,
verifier: cryptoVerifier,
});
const packed = await ss.pack(
{ embeddedPublicKey: new Uint8Array(32), seq: 0 },
authority
);
// Force the verify to use otherSlot's mismatched key.
try {
ss.unpackSelf(packed, () => otherSlot);
throw new Error("expected throw");
} catch (err) {
const e = err as BitpackerError;
expect(e.code).toBe(BITPACKER_CODES.CRYPTO_VERIFY_FAILED);
}
// suppress unused-warning on publicSlot (kept for parallelism above)
void publicSlot;
});
});
describe("signed-bitschema — option validation", () => {
it("missing verifier rejects at construction time", {
timeout: 5000,
}, () => {
expect(() =>
// @ts-expect-error — verifier is required
defineSignedBitSchema(BODY_CODEC, {
domain: DOMAIN_BITPACKER_SIGNED_V1,
})
).toThrow();
});
it("rejects unknown options keys", { timeout: 5000 }, () => {
expect(() =>
defineSignedBitSchema(BODY_CODEC, {
domain: DOMAIN_BITPACKER_SIGNED_V1,
verifier: cryptoVerifier,
// @ts-expect-error — unknown key
bogus: true,
})
).toThrow();
});
it("rejects out-of-range signatureLength", { timeout: 5000 }, () => {
expect(() =>
defineSignedBitSchema(BODY_CODEC, {
domain: DOMAIN_BITPACKER_SIGNED_V1,
verifier: cryptoVerifier,
signatureLength: 0,
})
).toThrow();
expect(() =>
defineSignedBitSchema(BODY_CODEC, {
domain: DOMAIN_BITPACKER_SIGNED_V1,
verifier: cryptoVerifier,
signatureLength: 999,
})
).toThrow();
});
it("uses default slot 'sign' and signatureLength 64", {
timeout: 5000,
}, () => {
const ss = defineSignedBitSchema(BODY_CODEC, {
domain: DOMAIN_BITPACKER_SIGNED_V1,
verifier: cryptoVerifier,
});
expect(ss.slot).toBe("sign");
expect(ss.signatureLength).toBe(64);
});
});

View File

@@ -0,0 +1,244 @@
import { trace } from "@opentelemetry/api";
import { BITPACKER_CODES, BitpackerError } from "@suaveplan/bitpacker";
import type {
Authority,
AuthorityVerifier,
DomainTag,
KeySlotPublic,
SlotId,
} from "@suaveplan/types";
import z from "zod";
import type { BodyCodec } from "../envelope/envelope-schema.js";
const TRACER_NAME = "bitpacker-crypto";
const SIGNED_PACK_SPAN = "bitpacker-crypto.signed-pack";
const SIGNED_VERIFY_SPAN = "bitpacker-crypto.signed-verify";
/** Default ed25519 signature length. */
export const DEFAULT_SIGNATURE_LENGTH = 64 as const;
/** Default slot id used when no `slot` is supplied to `defineSignedBitSchema`. */
export const DEFAULT_SIGN_SLOT: SlotId = "sign" as SlotId;
/**
* Options for `defineSignedBitSchema`.
*
* - `domain` is required so two schemas signed under different domains
* produce non-cross-replayable bytes (the tag participates in the
* signature input via `buildSigningInput`).
* - `verifier` is the dependency-injected verifier function — typically
* `verifyAuthoritySignature` from `@suaveplan/crypto/authority`. Required
* so this primitive has no runtime dependency on `@suaveplan/crypto`.
* - `slot` defaults to `"sign"` to match the genesis purpose-tag convention.
* - `signatureLength` defaults to 64 (ed25519). Other primitives may declare
* different signature lengths in the future; this option exists so the
* wire format can grow without redesigning the API.
*/
export interface SignedBitSchemaOptions {
readonly domain: DomainTag;
readonly verifier: AuthorityVerifier;
readonly slot?: SlotId;
readonly signatureLength?: number;
}
/**
* A bit-packed payload signed by an `Authority` under a fixed `DomainTag`.
* Wire format: `[bit-packed body][signature]`.
*
* - `pack(data, authority)` signs the bit-packed body with the
* authority's slot under the configured domain
* - `unpack(bytes, parentPublicSlot)` verifies the signature first; throws
* `CRYPTO_VERIFY_FAILED` on mismatch; returns the decoded body otherwise
* - `verify(bytes, parentPublicSlot)` returns `boolean` — does NOT throw on
* verification failure (callers can enumerate candidate parent keys)
* - `unpackSelf(bytes, getEmbeddedSlot)` is for trust-anchor bundles whose
* own embedded sign-slot is the verifier; the body is decoded first to
* extract the slot, then the signature is verified against it
*/
export interface SignedBitSchema<T> {
pack(
data: T,
authority: Authority,
signal?: AbortSignal
): Promise<Uint8Array>;
unpack(bytes: Uint8Array, parentPublicSlot: KeySlotPublic<unknown>): T;
verify(
bytes: Uint8Array,
parentPublicSlot: KeySlotPublic<unknown>
): boolean;
unpackSelf(
bytes: Uint8Array,
getEmbeddedSlot: (decoded: T) => KeySlotPublic<unknown>
): T;
readonly domain: DomainTag;
readonly slot: SlotId;
readonly signatureLength: number;
}
const OptionsRuntimeSchema = z
.object({
domain: z.string(),
verifier: z.function(),
slot: z.string().optional(),
signatureLength: z.number().int().min(8).max(256).optional(),
})
.strict();
export function defineSignedBitSchema<T>(
bodyCodec: BodyCodec<T>,
options: SignedBitSchemaOptions
): SignedBitSchema<T> {
OptionsRuntimeSchema.parse(options);
const domain = options.domain;
const verifier = options.verifier;
const slot = options.slot ?? DEFAULT_SIGN_SLOT;
const signatureLength = options.signatureLength ?? DEFAULT_SIGNATURE_LENGTH;
async function pack(
data: T,
authority: Authority,
signal?: AbortSignal
): Promise<Uint8Array> {
const tracer = trace.getTracer(TRACER_NAME);
const span = tracer.startSpan(SIGNED_PACK_SPAN, {
attributes: {
"bitpacker.crypto.domain": String(domain),
"bitpacker.crypto.slot": String(slot),
},
});
try {
const body = bodyCodec.pack(data);
const signature = await authority.sign(domain, slot, body, signal);
if (signature.length !== signatureLength) {
throw new BitpackerError(
BITPACKER_CODES.CRYPTO_SIGN_FAILED,
`Authority returned signature of unexpected length: got ${signature.length}, expected ${signatureLength}`,
{
context: {
actualLength: signature.length,
expectedLength: signatureLength,
domain: String(domain),
slot: String(slot),
},
}
);
}
const out = new Uint8Array(body.length + signatureLength);
out.set(body, 0);
out.set(signature, body.length);
span.setAttribute("bitpacker.crypto.payload_bytes", out.length);
return out;
} finally {
span.end();
}
}
function splitBytes(bytes: Uint8Array): {
body: Uint8Array;
signature: Uint8Array;
} {
if (bytes.length < signatureLength) {
throw new BitpackerError(
BITPACKER_CODES.CRYPTO_VERIFY_FAILED,
`SignedBitSchema bytes too short to contain a signature: got ${bytes.length}, expected ≥ ${signatureLength}`,
{
context: {
actualLength: bytes.length,
signatureLength,
},
}
);
}
const split = bytes.length - signatureLength;
return {
body: bytes.slice(0, split),
signature: bytes.slice(split),
};
}
function verifyBody(
body: Uint8Array,
signature: Uint8Array,
parentPublicSlot: KeySlotPublic<unknown>
): boolean {
const tracer = trace.getTracer(TRACER_NAME);
const span = tracer.startSpan(SIGNED_VERIFY_SPAN, {
attributes: {
"bitpacker.crypto.domain": String(domain),
"bitpacker.crypto.payload_bytes": body.length,
},
});
try {
return verifier(domain, parentPublicSlot, body, signature);
} finally {
span.end();
}
}
function unpack(
bytes: Uint8Array,
parentPublicSlot: KeySlotPublic<unknown>
): T {
const { body, signature } = splitBytes(bytes);
if (!verifyBody(body, signature, parentPublicSlot)) {
throw new BitpackerError(
BITPACKER_CODES.CRYPTO_VERIFY_FAILED,
"SignedBitSchema signature verification failed",
{
context: {
domain: String(domain),
parentAlgorithm: parentPublicSlot.algorithm,
},
}
);
}
return bodyCodec.unpack(body);
}
function verify(
bytes: Uint8Array,
parentPublicSlot: KeySlotPublic<unknown>
): boolean {
if (bytes.length < signatureLength) return false;
const split = bytes.length - signatureLength;
const body = bytes.slice(0, split);
const signature = bytes.slice(split);
try {
return verifyBody(body, signature, parentPublicSlot);
} catch {
return false;
}
}
function unpackSelf(
bytes: Uint8Array,
getEmbeddedSlot: (decoded: T) => KeySlotPublic<unknown>
): T {
const { body, signature } = splitBytes(bytes);
const decoded = bodyCodec.unpack(body);
const embeddedSlot = getEmbeddedSlot(decoded);
if (!verifyBody(body, signature, embeddedSlot)) {
throw new BitpackerError(
BITPACKER_CODES.CRYPTO_VERIFY_FAILED,
"SignedBitSchema unpackSelf verification failed",
{
context: {
domain: String(domain),
embeddedAlgorithm: embeddedSlot.algorithm,
},
}
);
}
return decoded;
}
return {
pack,
unpack,
verify,
unpackSelf,
domain,
slot,
signatureLength,
};
}

View File

@@ -0,0 +1,59 @@
/**
* Audit-trail tests for the deliberate scope-down recorded in
* `packages/bitpacker/bitpacker-crypto/SUPERSEDED.md` and
* `openspec/changes/bitpacker-crypto-port/`.
*
* Each `it(...)` pins the absence of one upstream symbol. If a future
* contributor accidentally re-introduces any of them as exports of
* `@suaveplan/bitpacker-crypto`, that contributor must also update
* `SUPERSEDED.md` and the corresponding parity-table row in
* `openspec/changes/bitpacker-modular-restructure/proposal.md`.
*/
import { describe, expect, it } from "@suaveplan/testing/runner";
import * as bitpackerCrypto from "./index.js";
const REJECTED_SYMBOLS = [
"BaseCryptoProvider",
"CryptoRegistry",
"LibsodiumCryptoProvider",
"EllipticCryptoProvider",
"NoOpCryptoProvider",
"generateNonce",
"concatBytes",
"validateKeyLength",
"constantTimeEqual",
"secureWipe",
"createCryptoRegistry",
"getGlobalRegistry",
"createXChaCha20Provider",
"createAesGcmProvider",
"createSecp256k1Provider",
"createEd25519Provider",
"createP256Provider",
"createTestProvider",
"createSlowProvider",
"createUnreliableProvider",
] as const;
describe("@suaveplan/bitpacker-crypto SUPERSEDED audit", () => {
for (const name of REJECTED_SYMBOLS) {
it(`${name} is not a runtime export`, { timeout: 5000 }, () => {
const exported = (bitpackerCrypto as Record<string, unknown>)[name];
expect(exported).toBeUndefined();
});
}
it("exposes the checksum surface that is in scope", {
timeout: 5000,
}, () => {
expect(typeof bitpackerCrypto.crc32).toBe("function");
expect(typeof bitpackerCrypto.adler32).toBe("function");
expect(typeof bitpackerCrypto.appendCrc32).toBe("function");
expect(typeof bitpackerCrypto.verifyAndStripCrc32).toBe("function");
expect(typeof bitpackerCrypto.appendAdler32).toBe("function");
expect(typeof bitpackerCrypto.verifyAndStripAdler32).toBe("function");
expect(typeof bitpackerCrypto.calculateXxhash32).toBe("function");
expect(typeof bitpackerCrypto.appendXxhash32).toBe("function");
expect(typeof bitpackerCrypto.verifyAndStripXxhash32).toBe("function");
});
});

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

View File

@@ -0,0 +1,166 @@
/**
* XXHash32 — extremely fast non-cryptographic hash algorithm.
* Pure TypeScript implementation. No external dependencies.
* Reference: https://github.com/Cyan4973/xxHash spec v0.8.2
*/
const PRIME1 = 0x9e3779b1;
const PRIME2 = 0x85ebca77;
const PRIME3 = 0xc2b2ae3d;
const PRIME4 = 0x27d4eb2f;
const PRIME5 = 0x165667b1;
function rotl32(v: number, r: number): number {
return ((v << r) | (v >>> (32 - r))) >>> 0;
}
function readU32LE(data: Uint8Array, offset: number): number {
return (
(data[offset]! |
(data[offset + 1]! << 8) |
(data[offset + 2]! << 16) |
(data[offset + 3]! << 24)) >>>
0
);
}
function round(acc: number, input: number): number {
acc = (acc + Math.imul(input, PRIME2)) >>> 0;
acc = rotl32(acc, 13);
acc = Math.imul(acc, PRIME1) >>> 0;
return acc;
}
/**
* Calculates XXHash32 checksum.
* @param data Input data
* @param seed Optional seed (default: 0)
* @returns 32-bit unsigned hash
*/
export function calculateXxhash32(data: Uint8Array, seed = 0): number {
const len = data.length;
let pos = 0;
let h32: number;
if (len >= 16) {
let v1 = (seed + PRIME1 + PRIME2) >>> 0;
let v2 = (seed + PRIME2) >>> 0;
let v3 = seed >>> 0;
let v4 = (seed - PRIME1) >>> 0;
const limit = len - 16;
while (pos <= limit) {
v1 = round(v1, readU32LE(data, pos));
pos += 4;
v2 = round(v2, readU32LE(data, pos));
pos += 4;
v3 = round(v3, readU32LE(data, pos));
pos += 4;
v4 = round(v4, readU32LE(data, pos));
pos += 4;
}
h32 =
(rotl32(v1, 1) +
rotl32(v2, 7) +
rotl32(v3, 12) +
rotl32(v4, 18)) >>>
0;
} else {
h32 = (seed + PRIME5) >>> 0;
}
h32 = (h32 + len) >>> 0;
while (pos <= len - 4) {
h32 = (h32 + Math.imul(readU32LE(data, pos), PRIME3)) >>> 0;
h32 = rotl32(h32, 17);
h32 = Math.imul(h32, PRIME4) >>> 0;
pos += 4;
}
while (pos < len) {
h32 = (h32 + Math.imul(data[pos]! & 0xff, PRIME5)) >>> 0;
h32 = rotl32(h32, 11);
h32 = Math.imul(h32, PRIME1) >>> 0;
pos += 1;
}
h32 ^= h32 >>> 15;
h32 = Math.imul(h32, PRIME2) >>> 0;
h32 ^= h32 >>> 13;
h32 = Math.imul(h32, PRIME3) >>> 0;
h32 ^= h32 >>> 16;
return h32 >>> 0;
}
/**
* Verifies XXHash32 checksum.
*/
export function verifyXxhash32(
data: Uint8Array,
expectedChecksum: number,
seed = 0
): boolean {
return calculateXxhash32(data, seed) === expectedChecksum;
}
/**
* Packs XXHash32 as 4 bytes (little-endian).
*/
export function packXxhash32(checksum: number): Uint8Array {
const bytes = new Uint8Array(4);
bytes[0] = checksum & 0xff;
bytes[1] = (checksum >>> 8) & 0xff;
bytes[2] = (checksum >>> 16) & 0xff;
bytes[3] = (checksum >>> 24) & 0xff;
return bytes;
}
/**
* Unpacks XXHash32 from 4 bytes (little-endian).
*/
export function unpackXxhash32(bytes: Uint8Array, offset = 0): number {
return (
((bytes[offset] ?? 0) |
((bytes[offset + 1] ?? 0) << 8) |
((bytes[offset + 2] ?? 0) << 16) |
((bytes[offset + 3] ?? 0) << 24)) >>>
0
);
}
/**
* Appends XXHash32 checksum to data.
*/
export function appendXxhash32(data: Uint8Array, seed = 0): Uint8Array {
const checksum = calculateXxhash32(data, seed);
const result = new Uint8Array(data.length + 4);
result.set(data);
result.set(packXxhash32(checksum), data.length);
return result;
}
/**
* Verifies and strips XXHash32 checksum from data.
* @throws {Error} If checksum fails or data too short
*/
export function verifyAndStripXxhash32(
dataWithChecksum: Uint8Array,
seed = 0
): Uint8Array {
if (dataWithChecksum.length < 4) {
throw new Error("Data too short to contain xxhash32 checksum");
}
const dataLength = dataWithChecksum.length - 4;
const data = dataWithChecksum.subarray(0, dataLength);
const stored = unpackXxhash32(dataWithChecksum, dataLength);
const calculated = calculateXxhash32(data, seed);
if (stored !== calculated) {
throw new Error(
`xxhash32 checksum mismatch: stored 0x${stored.toString(16)}, calculated 0x${calculated.toString(16)}`
);
}
return data;
}

View File

@@ -0,0 +1,119 @@
/**
* Test-only DI bindings for `@suaveplan/bitpacker-crypto` primitives.
*
* Implemented directly against the lower-level `@suaveplan/crypto-core`
* (sign/verify) and `@suaveplan/crypto-cipher` (AEAD) packages so this
* test surface has NO dependency on `@suaveplan/crypto`. That breaks an
* otherwise-circular dep chain: `crypto` may depend on `bitpacker-crypto`
* (e.g. for `EnvelopeSchema` in `chain-bundle`) without inducing a cycle
* via this test helper.
*
* Production callers should still wire `verifyAuthoritySignature` from
* `@suaveplan/crypto/authority` and `aeadEncrypt` / `aeadDecrypt` from
* `@suaveplan/crypto` — those are the canonical implementations. This
* file mirrors their shape for test purposes only.
*/
import * as aesGcm from "@suaveplan/crypto-cipher/aes-256-gcm";
import * as chacha20 from "@suaveplan/crypto-cipher/chacha20-poly1305";
import * as xchacha20 from "@suaveplan/crypto-cipher/xchacha20-poly1305";
import { ed25519 } from "@suaveplan/crypto-core/sign";
import type {
AeadAlgorithm,
AeadOps,
AeadOpsOptions,
AuthorityVerifier,
DomainTag,
} from "@suaveplan/types";
const ED25519_ALGORITHM_ID = 0;
/**
* Inline copy of `buildSigningInput` from `@suaveplan/crypto/domain-tags`.
* Reproduced here to keep this test helper free of any `@suaveplan/crypto`
* runtime dep. The byte layout is identical: `tag-bytes ‖ 0x00 ‖ payload`.
*/
function buildSigningInput(domain: DomainTag, payload: Uint8Array): Uint8Array {
const tag = new TextEncoder().encode(domain);
const out = new Uint8Array(tag.length + 1 + payload.length);
out.set(tag, 0);
out[tag.length] = 0;
out.set(payload, tag.length + 1);
return out;
}
/**
* Test verifier — mirrors `verifyAuthoritySignature` from
* `@suaveplan/crypto/authority` byte-for-byte. Only ed25519 is supported
* (matches the canonical impl); other algorithms throw to preserve the
* "structural error → swallow as false" convention used by primitives.
*/
export const cryptoVerifier: AuthorityVerifier = (
domain,
publicSlot,
payload,
signature
) => {
if (publicSlot.algorithm !== ED25519_ALGORITHM_ID) {
throw new Error(
`cryptoVerifier: unsupported algorithm ${publicSlot.algorithm}`
);
}
const message = buildSigningInput(domain, payload);
return ed25519.verify(publicSlot.publicKey, signature, message);
};
function encrypt(
algorithm: AeadAlgorithm,
key: Uint8Array,
plaintext: Uint8Array,
options: AeadOpsOptions
): Uint8Array {
switch (algorithm) {
case "AES-256-GCM":
return aesGcm.encrypt(key, options.nonce, plaintext, options.aad);
case "ChaCha20-Poly1305":
return chacha20.encrypt(key, options.nonce, plaintext, options.aad);
case "XChaCha20-Poly1305":
return xchacha20.encrypt(
key,
options.nonce,
plaintext,
options.aad
);
}
}
function decrypt(
algorithm: AeadAlgorithm,
key: Uint8Array,
ciphertext: Uint8Array,
options: AeadOpsOptions
): Uint8Array {
switch (algorithm) {
case "AES-256-GCM":
return aesGcm.decrypt(key, options.nonce, ciphertext, options.aad);
case "ChaCha20-Poly1305":
return chacha20.decrypt(
key,
options.nonce,
ciphertext,
options.aad
);
case "XChaCha20-Poly1305":
return xchacha20.decrypt(
key,
options.nonce,
ciphertext,
options.aad
);
}
}
/**
* Test AEAD bundle — dispatches to the per-algorithm functions exposed by
* `@suaveplan/crypto-cipher`. Behaviorally identical to the canonical
* `{ encrypt: aeadEncrypt, decrypt: aeadDecrypt }` shape from
* `@suaveplan/crypto`.
*/
export const cryptoAead: AeadOps = { encrypt, decrypt };

View File

@@ -0,0 +1,106 @@
/**
* Test-only fake `Authority` that signs with a fresh ed25519 keypair and
* exposes the matching `KeySlotPublic` for verification. Avoids the
* full-keychain construction machinery in `@suaveplan/crypto`'s test surface
* which would require a deep import on every primitive's tests.
*
* Only the fields actually touched by `verifyAuthoritySignature` are
* populated correctly (`algorithm` + `publicKey`). All other slot fields
* carry placeholder values that satisfy the type but are not checked at
* runtime by the verify path.
*/
import { ed25519 } from "@suaveplan/crypto-core/sign";
import type {
Authority,
DomainTag,
KeySlotPublic,
SlotId,
} from "@suaveplan/types";
/**
* Inline copy of `buildSigningInput` from `@suaveplan/crypto/domain-tags`.
* Reproduced here to keep this test helper free of any `@suaveplan/crypto`
* runtime dep (which would induce a cycle now that `@suaveplan/crypto`
* itself depends on this package via `chain-bundle`'s `EnvelopeSchema`
* adoption). Byte layout is identical: `tag-bytes ‖ 0x00 ‖ payload`.
*/
function buildSigningInput(domain: DomainTag, payload: Uint8Array): Uint8Array {
const tag = new TextEncoder().encode(domain);
const out = new Uint8Array(tag.length + 1 + payload.length);
out.set(tag, 0);
out[tag.length] = 0;
out.set(payload, tag.length + 1);
return out;
}
const ED25519_ALGORITHM_ID = 0;
export interface FakeAuthorityHandle {
readonly authority: Authority;
readonly publicSlot: KeySlotPublic<unknown>;
readonly secretKey: Uint8Array;
readonly publicKey: Uint8Array;
}
function makePlaceholderSlot(publicKey: Uint8Array): KeySlotPublic<unknown> {
return {
coordinate: {
application: "test" as never,
purpose: "sign" as never,
layoutId: 0 as never,
layoutFields: {} as never,
} as never,
algorithm: ED25519_ALGORITHM_ID as never,
notBefore: 0,
notAfter: Number.MAX_SAFE_INTEGER,
publicKey,
hasPublic: true,
};
}
/**
* Build a fake `Authority` backed by a fresh ed25519 keypair. The returned
* `publicSlot` matches the secretKey and is suitable for passing to
* `verifyAuthoritySignature` or any consumer of `KeySlotPublic`.
*/
export function createFakeAuthority(): FakeAuthorityHandle {
const keypair = ed25519.generateKeypair();
const secretKey = keypair.secretKey;
const publicKey = keypair.publicKey;
let disposed = false;
const publicSlot = makePlaceholderSlot(publicKey);
const authority: Authority = {
async sign(
domain: DomainTag,
_slot: SlotId,
payload: Uint8Array,
signal?: AbortSignal
): Promise<Uint8Array> {
if (disposed) {
throw new Error("authority disposed");
}
if (signal?.aborted === true) {
throw new Error("aborted");
}
const message = buildSigningInput(domain, payload);
return Promise.resolve(ed25519.sign(secretKey, message));
},
getPublicSlot(_slot: SlotId): KeySlotPublic<unknown> | undefined {
if (disposed) return undefined;
return publicSlot;
},
hasSlot(_slot: SlotId): boolean {
return !disposed;
},
dispose(): void {
disposed = true;
},
get disposed(): boolean {
return disposed;
},
};
return { authority, publicSlot, secretKey, publicKey };
}

View File

@@ -0,0 +1,270 @@
/**
* Minimal in-memory recording tracer for assert-on-span testing without
* pulling `@opentelemetry/sdk-trace-base` as a workspace dep.
*
* Implements just enough of the `@opentelemetry/api` `Tracer` /
* `TracerProvider` surface to capture span name + attributes from primitive
* code paths. Tests call `installRecordingTracer()` in a setup, exercise the
* primitive, then `recorded.spans` returns the captured spans. Always restore
* the previous global tracer provider in a `try { ... } finally { ... }` so
* tests don't pollute each other.
*/
import {
type Attributes,
type Context,
type Span,
type SpanContext,
SpanKind,
type SpanOptions,
type SpanStatus,
SpanStatusCode,
type Tracer,
type TracerProvider,
trace,
} from "@opentelemetry/api";
export interface RecordedSpan {
readonly name: string;
readonly attributes: Attributes;
readonly status?: SpanStatus;
readonly events: {
readonly name: string;
readonly attributes?: Attributes;
}[];
readonly ended: boolean;
}
class RecordingSpan implements Span {
public readonly name: string;
public readonly attributes: Attributes = {};
public readonly events: { name: string; attributes?: Attributes }[] = [];
public ended = false;
public status: SpanStatus | undefined;
public constructor(name: string, attrs?: Attributes) {
this.name = name;
if (attrs) {
for (const [k, v] of Object.entries(attrs)) {
this.attributes[k] = v;
}
}
}
public spanContext(): SpanContext {
return {
traceId: "00000000000000000000000000000000",
spanId: "0000000000000000",
traceFlags: 0,
};
}
public setAttribute(key: string, value: unknown): this {
this.attributes[key] = value as never;
return this;
}
public setAttributes(attrs: Attributes): this {
for (const [k, v] of Object.entries(attrs)) {
this.attributes[k] = v;
}
return this;
}
public addEvent(name: string, attrs?: Attributes): this {
const event: { name: string; attributes?: Attributes } = { name };
if (attrs !== undefined) {
event.attributes = attrs;
}
this.events.push(event);
return this;
}
public addLink(): this {
return this;
}
public addLinks(): this {
return this;
}
public setStatus(status: SpanStatus): this {
this.status = status;
return this;
}
public updateName(name: string): this {
(this as { name: string }).name = name;
return this;
}
public end(): void {
this.ended = true;
}
public isRecording(): boolean {
return !this.ended;
}
public recordException(): void {
this.setStatus({ code: SpanStatusCode.ERROR });
}
}
class RecordingTracer implements Tracer {
public readonly spans: RecordingSpan[] = [];
public startSpan(name: string, options?: SpanOptions): Span {
const span = new RecordingSpan(name, options?.attributes);
this.spans.push(span);
return span;
}
public startActiveSpan<F extends (span: Span) => unknown>(
nameOrOptions: string,
optionsOrFn: SpanOptions | F,
contextOrFn?: Context | F,
fn?: F
): ReturnType<F> {
const name = nameOrOptions;
const callback =
typeof fn === "function"
? fn
: typeof contextOrFn === "function"
? contextOrFn
: (optionsOrFn as F);
const opts =
typeof optionsOrFn === "function" ? undefined : optionsOrFn;
const span = this.startSpan(name, opts);
try {
return callback(span) as ReturnType<F>;
} finally {
if (!span.isRecording()) {
// already ended in the callback
} else {
span.end();
}
}
}
}
class RecordingTracerProvider implements TracerProvider {
public readonly tracers = new Map<string, RecordingTracer>();
public getTracer(name: string): Tracer {
let tracer = this.tracers.get(name);
if (tracer === undefined) {
tracer = new RecordingTracer();
this.tracers.set(name, tracer);
}
return tracer;
}
public allSpans(): readonly RecordedSpan[] {
const all: RecordedSpan[] = [];
for (const tracer of this.tracers.values()) {
for (const span of tracer.spans) {
all.push({
name: span.name,
attributes: { ...span.attributes },
...(span.status !== undefined
? { status: span.status }
: {}),
events: span.events.map((e) => ({
name: e.name,
...(e.attributes !== undefined
? { attributes: e.attributes }
: {}),
})),
ended: span.ended,
});
}
}
return all;
}
}
export interface RecordingTracerSession {
readonly provider: RecordingTracerProvider;
spansFor(tracerName: string): readonly RecordedSpan[];
allSpans(): readonly RecordedSpan[];
restore(): void;
}
/**
* Install a recording tracer as the global provider, returning a handle to
* inspect captured spans and a `restore()` to undo. Pair every call with
* `restore()` in a `finally` block.
*
* Implementation note: the `@opentelemetry/api` package keeps a one-shot
* global tracer provider — subsequent `setGlobalTracerProvider` calls log
* a warning and no-op unless we first call `trace.disable()`. The session
* surface always disables before installing so back-to-back tests work.
*/
export function installRecordingTracer(): RecordingTracerSession {
const provider = new RecordingTracerProvider();
trace.disable();
trace.setGlobalTracerProvider(provider);
return {
provider,
spansFor(tracerName: string): readonly RecordedSpan[] {
const tracer = provider.tracers.get(tracerName);
if (tracer === undefined) return [];
return tracer.spans.map((s) => ({
name: s.name,
attributes: { ...s.attributes },
...(s.status !== undefined ? { status: s.status } : {}),
events: s.events.map((e) => ({
name: e.name,
...(e.attributes !== undefined
? { attributes: e.attributes }
: {}),
})),
ended: s.ended,
}));
},
allSpans(): readonly RecordedSpan[] {
return provider.allSpans();
},
restore(): void {
trace.disable();
},
};
}
/**
* Convenience: run `fn` with an installed tracer, collecting spans.
*
* Awaits async callbacks so `session.restore()` runs only after the
* callback's Promise settles. Callers passing a sync callback get sync
* behavior.
*/
export function withRecordingTracer<T>(
fn: (session: RecordingTracerSession) => Promise<T>
): Promise<T>;
export function withRecordingTracer<T>(
fn: (session: RecordingTracerSession) => T
): T;
export function withRecordingTracer<T>(
fn: (session: RecordingTracerSession) => T | Promise<T>
): T | Promise<T> {
const session = installRecordingTracer();
let result: T | Promise<T>;
try {
result = fn(session);
} catch (err) {
session.restore();
throw err;
}
if (
result !== null &&
typeof result === "object" &&
"then" in (result as object) &&
typeof (result as { then: unknown }).then === "function"
) {
return (result as Promise<T>).finally(() => session.restore());
}
session.restore();
return result;
}
export { SpanKind, SpanStatusCode };

View File

@@ -0,0 +1,27 @@
/**
* Test-only `DomainTag` constants for `@suaveplan/bitpacker-crypto` tests.
*
* Mirrors the canonical `DOMAIN_BITPACKER_*` constants from
* `@suaveplan/crypto/domain-tags` byte-for-byte, but declared inline so
* this test surface has NO runtime dep on `@suaveplan/crypto`. That breaks
* a cycle that would otherwise form when `@suaveplan/crypto` itself adopts
* primitives from this package (e.g. `chain-bundle` adopting
* `EnvelopeSchema`).
*
* Production callers should import the canonical constants from
* `@suaveplan/crypto/domain-tags` directly. These test mirrors exist solely
* to give the bitpacker-crypto test suite valid `DomainTag`-typed values.
*/
import type { DomainTag } from "@suaveplan/types";
const tag = (s: string): DomainTag => s as DomainTag;
export const DOMAIN_BITPACKER_SIGNED_V1 = tag("suaveplan.bitpacker-signed.v1");
export const DOMAIN_BITPACKER_MULTISIGN_V1 = tag(
"suaveplan.bitpacker-multisign.v1"
);
export const DOMAIN_BITPACKER_HASHCHAIN_V1 = tag(
"suaveplan.bitpacker-hashchain.v1"
);
export const DOMAIN_PUBLIC_KEYCHAIN_V1 = tag("suaveplan.public-keychain.v1");
export const DOMAIN_AUDIT_V1 = tag("suaveplan.audit.v1");

View File

@@ -0,0 +1,24 @@
/**
* Vitest setup polyfills for @suaveplan/bitpacker-crypto.
*
* The package's primitives consume `globalThis.crypto.getRandomValues` for
* AEAD nonces and `globalThis.crypto.subtle` for sha256 digests. Modern Node
* (≥ 19) exposes these on `globalThis.crypto`, but the vitest forks pool can
* sometimes start in a context where the global is not yet attached. Pull it
* explicitly from `node:crypto.webcrypto` if missing.
*/
import { webcrypto } from "node:crypto";
if (typeof globalThis.crypto?.getRandomValues !== "function") {
Object.defineProperty(globalThis, "crypto", {
value: webcrypto,
configurable: true,
writable: true,
});
}
if (typeof globalThis.crypto?.getRandomValues !== "function") {
throw new Error(
"[bitpacker-crypto polyfills] failed to install globalThis.crypto.getRandomValues"
);
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,55 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/bitpacker-crypto",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: {
entry: {
index: "src/index.ts",
"signed/signed-bitschema": "src/signed/signed-bitschema.ts",
"encrypted/encrypted-bitschema":
"src/encrypted/encrypted-bitschema.ts",
"envelope/envelope-schema": "src/envelope/envelope-schema.ts",
"hashchain/hashchain-schema":
"src/hashchain/hashchain-schema.ts",
"multisigned/multisigned-bitschema":
"src/multisigned/multisigned-bitschema.ts",
"fingerprint/schema-fingerprint":
"src/fingerprint/schema-fingerprint.ts",
"hash-registry/hash-registry":
"src/hash-registry/hash-registry.ts",
},
formats: ["es"],
},
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"tslib",
"zod",
"@opentelemetry/api",
"@suaveplan/bitpacker",
"@suaveplan/error",
"@suaveplan/types",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,31 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.bench.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 10_000,
},
});