Initial Commit
This commit is contained in:
155
packages/foundation/bitpacker/bitpacker-crypto/README.md
Normal file
155
packages/foundation/bitpacker/bitpacker-crypto/README.md
Normal file
@@ -0,0 +1,155 @@
|
||||
# @suaveplan/bitpacker-crypto - Bit-packer-aware crypto primitives — SignedBitSchema, EncryptedBitSchema, EnvelopeSchema, HashChainSchema, MultiSignedBitSchema, SchemaFingerprint, HashAlgorithmRegistry. Authority/AeadOps/DomainTag types live in @suaveplan/types; the canonical implementations are in @suaveplan/crypto and dependency-injected by the caller.
|
||||
|
||||
## Summary
|
||||
|
||||
Bit-packer-aware crypto primitives. The shared interfaces (`Authority`,
|
||||
`DomainTag`, `AuthorityVerifier`, `AeadOps`) live in `@suaveplan/types`;
|
||||
the canonical implementations come from `@suaveplan/crypto` and are
|
||||
**dependency-injected** by the caller. This package has zero runtime
|
||||
dependency on `@suaveplan/crypto`, so consumers in any layer (including
|
||||
`@suaveplan/crypto` itself, e.g. `crypto::ChainBundle`) can adopt these
|
||||
primitives without inducing a circular package dependency.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [encrypted](./src/encrypted/encrypted-bitschema.md) — Niche AEAD-encrypted envelope around a bit-packed body. Wire format: `[version u8][algTag u8][nonceLen u8][nonce][ciphe…
|
||||
- [encrypted-bitschema](./src/encrypted/encrypted-bitschema.md) — Niche AEAD-encrypted envelope around a bit-packed body. Wire format: `[version u8][algTag u8][nonceLen u8][nonce][ciphe…
|
||||
- [envelope](./src/envelope/envelope-schema.md) — A typed, version-aware envelope schema for bit-packed payloads. Wraps a caller-supplied `BodyCodec` with a `[magic][for…
|
||||
- [envelope-schema](./src/envelope/envelope-schema.md) — A typed, version-aware envelope schema for bit-packed payloads. Wraps a caller-supplied `BodyCodec` with a `[magic][for…
|
||||
- [fingerprint](./src/fingerprint/schema-fingerprint.md) — Content-hash a `BitPackerSchema` so two parties can agree they're using byte-identical schemas before they exchange pay…
|
||||
- [schema-fingerprint](./src/fingerprint/schema-fingerprint.md) — Content-hash a `BitPackerSchema` so two parties can agree they're using byte-identical schemas before they exchange pay…
|
||||
- [hash-registry](./src/hash-registry/hash-registry.md) — A pluggable registry of named hash algorithms used by every hashing-aware primitive in `@suaveplan/bitpacker-crypto` (n…
|
||||
- [hash-registry](./src/hash-registry/hash-registry.md) — A pluggable registry of named hash algorithms used by every hashing-aware primitive in `@suaveplan/bitpacker-crypto` (n…
|
||||
- [hashchain](./src/hashchain/hashchain-schema.md) — Append-only signed hash chain. Each entry carries a sequence number, the hash of the previous entry, an opaque payload,…
|
||||
- [hashchain-schema](./src/hashchain/hashchain-schema.md) — Append-only signed hash chain. Each entry carries a sequence number, the hash of the previous entry, an opaque payload,…
|
||||
- [multisigned](./src/multisigned/multisigned-bitschema.md) — Bit-packed payload co-signed by N independent authorities. Wire format is `[bit-packed body][sig_1][sig_2]…[sig_N]`. Pi…
|
||||
- [multisigned-bitschema](./src/multisigned/multisigned-bitschema.md) — Bit-packed payload co-signed by N independent authorities. Wire format is `[bit-packed body][sig_1][sig_2]…[sig_N]`. Pi…
|
||||
- [signed](./src/signed/signed-bitschema.md) — Bit-packed payload signed by an `Authority` under a fixed `DomainTag`. Wire format is `[bit-packed body][signature]`. V…
|
||||
- [signed-bitschema](./src/signed/signed-bitschema.md) — Bit-packed payload signed by an `Authority` under a fixed `DomainTag`. Wire format is `[bit-packed body][signature]`. V…
|
||||
- [crc32](./src/crc32.md) — Fast non-cryptographic checksums for bit-packed payloads. `crc32` implements the IEEE-802.3 / zlib polynomial (`0xedb88…
|
||||
- [index](./src/index.md) — **Path:** `bitpacker-crypto/src/index.ts` **Export:** See source for details.
|
||||
- [xxhash32](./src/xxhash32.md) — **Path:** `src/xxhash32.ts` **Export:** `calculateXxhash32`, `verifyXxhash32`, `packXxhash32`, `unpackXxhash32`, `appen…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/bitpacker-crypto
|
||||
```
|
||||
|
||||
## Public surface
|
||||
|
||||
| Primitive | Use it for |
|
||||
|---|---|
|
||||
| `EnvelopeSchema` | `[magic][formatVersion][body]` framing — version-aware wire formats |
|
||||
| `SignedBitSchema` | Authority-signed payloads under a `DomainTag` |
|
||||
| `MultiSignedBitSchema` | N-of-N co-signed payloads with `firstBadSignerIndex` reporting |
|
||||
| `HashChainSchema` | Append-only signed hash chain (audit logs, mesh state) |
|
||||
| `EncryptedBitSchema` | **Niche** AEAD-only envelope for **small** encrypted payloads |
|
||||
| `SchemaFingerprint` | Canonical-hash a `BitPackerSchema` for handshakes |
|
||||
| `HashAlgorithmRegistry` | Pluggable hash registry — sha256/xxhash32/crc32 default; register blake3 |
|
||||
| `crc32` / `xxhash32` / `adler32` | Existing checksum surface, unchanged |
|
||||
|
||||
## Intended scope
|
||||
|
||||
`EncryptedBitSchema` is for **small** encrypted payloads only — token
|
||||
bodies, key-wrap envelopes, sub-MTU control records. **Do NOT use it as a
|
||||
chunked-file encoder.** For chunked-file encryption with a streaming
|
||||
verifier and locked v0x0001 codec, reach for `@suaveplan/packer` instead.
|
||||
|
||||
## Crypto provider needs?
|
||||
|
||||
This package ships **bit-packer-aware** crypto primitives. For raw AEAD,
|
||||
signing, key derivation, or authenticated encryption (without a bit-packer
|
||||
schema), reach for the crypto family directly:
|
||||
|
||||
- [`@suaveplan/crypto`](../../crypto/crypto) — multi-algorithm façade (AEAD, sign,
|
||||
agree, sealed-box, auth-box).
|
||||
- [`@suaveplan/crypto-sodium`](../../crypto/crypto-sodium) — libsodium engine.
|
||||
- [`@suaveplan/crypto-browser`](../../crypto/crypto-browser) — WebCrypto engine.
|
||||
- [`@suaveplan/crypto-vault`](../../crypto/crypto-vault) — BIP39 + Argon2id HD vault.
|
||||
- [`@suaveplan/packer`](../../data/packer) — chunked AEAD with manifest.
|
||||
|
||||
Upstream's `bitpacker/src/crypto/` provider abstraction is **not ported**;
|
||||
see [SUPERSEDED.md](./SUPERSEDED.md) for the per-symbol replacement table.
|
||||
|
||||
## Quick examples
|
||||
|
||||
### Envelope a bit-packed body
|
||||
|
||||
```ts
|
||||
import { defineEnvelopeSchema } from "@suaveplan/bitpacker-crypto";
|
||||
import { defineBitSchema } from "@suaveplan/bitpacker";
|
||||
|
||||
const headerSchema = defineBitSchema<{ foo: number }>([
|
||||
{ name: "foo", type: "uint", bits: 32 },
|
||||
]);
|
||||
const env = defineEnvelopeSchema({
|
||||
magic: new Uint8Array([0x53, 0x56, 0x43, 0x42]),
|
||||
formatVersion: 1,
|
||||
bodyCodec: {
|
||||
pack: (b) => headerSchema.pack(b),
|
||||
unpack: (bytes) => headerSchema.unpack(bytes),
|
||||
},
|
||||
});
|
||||
const bytes = env.pack({ foo: 7 });
|
||||
```
|
||||
|
||||
### Wiring the canonical crypto bindings
|
||||
|
||||
Every primitive that needs a verifier or AEAD bundle accepts it via
|
||||
options. Pass the canonical wiring once in your application bootstrap:
|
||||
|
||||
```ts
|
||||
import { aeadEncrypt, aeadDecrypt } from "@suaveplan/crypto";
|
||||
import { verifyAuthoritySignature } from "@suaveplan/crypto/authority";
|
||||
import type { AeadOps, AuthorityVerifier } from "@suaveplan/types";
|
||||
|
||||
export const verifier: AuthorityVerifier = verifyAuthoritySignature;
|
||||
export const aead: AeadOps = { encrypt: aeadEncrypt, decrypt: aeadDecrypt };
|
||||
|
||||
// Then everywhere you build a primitive:
|
||||
defineSignedBitSchema(codec, { domain, verifier });
|
||||
defineMultiSignedBitSchema(codec, { domain, verifier, signerCount });
|
||||
defineHashChainSchema({ domain, verifier, payloadCodec });
|
||||
defineEncryptedBitSchema(codec, { key, aead });
|
||||
```
|
||||
|
||||
### Sign + encrypt a token
|
||||
|
||||
```ts
|
||||
import {
|
||||
defineEncryptedBitSchema,
|
||||
defineSignedBitSchema,
|
||||
} from "@suaveplan/bitpacker-crypto";
|
||||
import { DOMAIN_BITPACKER_SIGNED_V1 } from "@suaveplan/crypto/domain-tags";
|
||||
|
||||
const inner = defineEncryptedBitSchema(tokenCodec, { key: aeadKey });
|
||||
const innerCodec = {
|
||||
pack: (t) => inner.pack(t),
|
||||
unpack: (bytes) => inner.unpack(bytes),
|
||||
};
|
||||
// (Signing receives Promise.unwrap() on the body — for the canary chain
|
||||
// pattern, prefer `defineSignedBitSchema(envCodec, ...)` directly.)
|
||||
```
|
||||
|
||||
### Schema-fingerprint handshake
|
||||
|
||||
```ts
|
||||
import { schemaFingerprint, assertSchemaFingerprint } from "@suaveplan/bitpacker-crypto";
|
||||
|
||||
const local = { foo: 255, bar: 65535 };
|
||||
const fp = await schemaFingerprint(local);
|
||||
// On the peer:
|
||||
await assertSchemaFingerprint(remoteSchema, fp);
|
||||
```
|
||||
|
||||
## Algorithms (existing checksum surface, unchanged)
|
||||
|
||||
| Algorithm | Speed | Use Case |
|
||||
|-----------|-------|----------|
|
||||
| CRC32 | Medium | General integrity checking |
|
||||
| Adler32 | Fast | Quick checksums (used in zlib) |
|
||||
| XXHash32 | Very fast | High-throughput hashing |
|
||||
|
||||
150
packages/foundation/bitpacker/bitpacker-crypto/SUPERSEDED.md
Normal file
150
packages/foundation/bitpacker/bitpacker-crypto/SUPERSEDED.md
Normal file
@@ -0,0 +1,150 @@
|
||||
# SUPERSEDED — bitpacker-crypto provider abstraction
|
||||
|
||||
This file records two related decisions:
|
||||
|
||||
1. **Original decision (`bitpacker-crypto-port`, 2026-05-02)** — upstream's
|
||||
`packages/universal/bitpacker/src/crypto/` ships ~1,906 lines of provider
|
||||
abstraction (`BaseCryptoProvider`, `CryptoRegistry`, and three adapters:
|
||||
`noop`, `libsodium`, `elliptic`). suaveplan intentionally **does not port that abstraction**.
|
||||
2. **Replacement (`bitpacker-crypto-rewrite`, 2026-05-02)** — a deeper consumer
|
||||
scan found 8+ call-sites across suaveplan and upstream manually re-implementing
|
||||
the same crypto-with-bitpacker patterns (sign-then-pack, encrypt-pack-frame,
|
||||
versioned envelopes, schema fingerprints, stateless verifiers). Rather than
|
||||
leaving the package as checksums-only, the rewrite **adds five bit-packer-aware
|
||||
primitives** that consolidate those inline duplications. See "What we built
|
||||
instead" at the bottom of this file.
|
||||
|
||||
The original rejection of upstream's abstraction still stands. The patterns
|
||||
those rejected symbols *attempted* to address are now solved by the new
|
||||
primitives, in shapes that real consumers can actually use.
|
||||
|
||||
## Why the abstraction is not ported
|
||||
|
||||
The upstream provider abstraction is a self-referential layer over `@suaveplan/crypto`:
|
||||
its `LibsodiumCryptoProvider` does `import from "@suaveplan/crypto"`; its
|
||||
`EllipticCryptoProvider` wraps the same primitives via the `elliptic` package; its
|
||||
`NoOpCryptoProvider` is a test harness. A grep across `/mnt/s/web/suaveplan/packages`
|
||||
on 2026-05-02 confirmed **no upstream consumer outside the bitpacker package itself**
|
||||
imports `BaseCryptoProvider`, `CryptoRegistry`, or any adapter — the abstraction was
|
||||
never wired into a production code path.
|
||||
|
||||
suaveplan already ships every underlying primitive in five focused packages
|
||||
(`@suaveplan/crypto`, `crypto-sodium`, `crypto-browser`, `crypto-vault`, `crypto-keys`)
|
||||
plus a higher-level chunked AEAD in `@suaveplan/packer`. Adding the upstream
|
||||
abstraction would duplicate work, route around the suaveplan structured-error / OTel
|
||||
conventions, and contradict the "no duplicate libraries" directive in CLAUDE.md.
|
||||
|
||||
## Rejected upstream surface and its suaveplan replacement
|
||||
|
||||
| Upstream symbol | Upstream file:lines | suaveplan replacement |
|
||||
|---|---|---|
|
||||
| `BaseCryptoProvider extends EventEmitter` | `crypto/provider/provider.ts:26-232` | None — call `@suaveplan/crypto` directly |
|
||||
| `generateNonce(length)` | `crypto/provider/provider.ts:237-248` | `@suaveplan/crypto-utils::random.bytes(length)` |
|
||||
| `concatBytes(...arrays)` | `crypto/provider/provider.ts:253-262` | `@suaveplan/crypto-utils::bytes.concat(...arrays)` |
|
||||
| `validateKeyLength(key, expected, type)` | `crypto/provider/provider.ts:267-277` | Caller-side guard via `BitpackerError` (no central helper) |
|
||||
| `constantTimeEqual(a, b)` | `crypto/provider/provider.ts:282-289` | `@suaveplan/crypto-utils::bytes.equalsConstantTime(a, b)` |
|
||||
| `secureWipe(buffer)` | `crypto/provider/provider.ts:294-296` | `@suaveplan/crypto-utils::bytes.zeroize(buffer)` |
|
||||
| `LibsodiumCryptoProvider` + `createXChaCha20Provider` + `createAesGcmProvider` | `crypto/adapters/libsodium/libsodium.ts:94-335` | `@suaveplan/crypto-sodium` + `@suaveplan/crypto::aead` |
|
||||
| `EllipticCryptoProvider` + `createSecp256k1Provider` + `createEd25519Provider` + `createP256Provider` | `crypto/adapters/elliptic/elliptic.ts:36-572` | `@suaveplan/crypto-curve` |
|
||||
| `NoOpCryptoProvider` + `createTestProvider` + `createSlowProvider` + `createUnreliableProvider` | `crypto/adapters/noop/noop.ts:35-335` | `@suaveplan/testing` fakes for `@suaveplan/crypto` |
|
||||
| `CryptoRegistry extends EventEmitter` + `createCryptoRegistry` + `getGlobalRegistry` | `crypto/registry/registry.ts:21-370` | None — registry was self-referential |
|
||||
|
||||
## What `@suaveplan/bitpacker-crypto` still ships
|
||||
|
||||
This package is **not deprecated**. It still ships the checksum surface that the
|
||||
upstream `bitpacker/src/core/checksum/` directory provided, all under `@suaveplan/bitpacker-crypto`:
|
||||
|
||||
- `crc32(data)` and `appendCrc32` / `verifyAndStripCrc32`
|
||||
- `adler32(data)`
|
||||
- `calculateXxhash32`, `packXxhash32`, `unpackXxhash32`, `appendXxhash32`,
|
||||
`verifyXxhash32`, `verifyAndStripXxhash32`
|
||||
|
||||
Checksums are not part of the rejected provider abstraction; this decision
|
||||
record covers only the `crypto/` directory of the upstream bitpacker source.
|
||||
|
||||
## Encrypt-then-pack — recommended replacement pattern
|
||||
|
||||
If you need to encrypt or sign bytes that go into a bitpacker payload, reach
|
||||
for `@suaveplan/packer` (which ships chunked AEAD already) or call
|
||||
`@suaveplan/crypto` directly. There is no provider/registry indirection .
|
||||
|
||||
```typescript
|
||||
import { Packer } from "@suaveplan/packer";
|
||||
import { aead } from "@suaveplan/crypto";
|
||||
import { defineBitSchema } from "@suaveplan/bitpacker";
|
||||
|
||||
const schema = defineBitSchema({ payload: "~" });
|
||||
const bytes = schema.pack({ payload: new Uint8Array([1, 2, 3, 4]) });
|
||||
|
||||
// Encrypt the packed bytes:
|
||||
const ciphertext = await aead.encrypt({
|
||||
algorithm: "xchacha20-poly1305",
|
||||
key: myKey,
|
||||
nonce: myNonce,
|
||||
plaintext: bytes,
|
||||
associatedData: new Uint8Array(),
|
||||
});
|
||||
|
||||
// Or use @suaveplan/packer for chunked AEAD with a manifest:
|
||||
const packer = new Packer({ schema, key: myKey });
|
||||
const envelope = await packer.pack({ payload: new Uint8Array([1, 2, 3, 4]) });
|
||||
```
|
||||
|
||||
## See also
|
||||
|
||||
- spec change `bitpacker-crypto-port` — original decision (superseded).
|
||||
- spec change `bitpacker-crypto-rewrite` — replacement decision; **slimmed 2026-05-02**
|
||||
after `restore-crypto-keychain-packer` discovery to two auxiliary primitives only.
|
||||
- spec change `restore-crypto-keychain-packer` — parallel work that owns the new
|
||||
opinionated `@suaveplan/crypto` layer (Packer / Authority / AuditLog / RevocationList).
|
||||
- `bitpacker-modular-restructure` parity table — flips the five corresponding rows
|
||||
from "🚫 not ported" to "⛔ intentionally superseded; see `restore-crypto-keychain-packer`
|
||||
for the consolidation, `bitpacker-crypto-rewrite` for the auxiliary primitives".
|
||||
- `@suaveplan/crypto`, `@suaveplan/crypto-sodium`, `@suaveplan/crypto-browser`,
|
||||
`@suaveplan/crypto-vault`, `@suaveplan/crypto-keys`, `@suaveplan/crypto-utils`,
|
||||
`@suaveplan/packer` — the crypto family.
|
||||
|
||||
## What we built instead — bit-packer auxiliary primitives only
|
||||
|
||||
After the 2026-05-02 audit + the discovery that **`restore-crypto-keychain-packer`**
|
||||
already proposes a new opinionated `@suaveplan/crypto` layer with `Packer` / `Authority` /
|
||||
`AuditLog` / `RevocationList`, this package now ships only the two primitives that
|
||||
don't overlap that work:
|
||||
|
||||
| Primitive | What it does | Consumer that drove the design |
|
||||
|---|---|---|
|
||||
| `schemaFingerprint(schema, algorithm?, registry?)` | Content-hash a `BitPackerSchema` after canonicalization (sorted keys, no whitespace, deterministic encoding). Algorithm name resolved via `HashAlgorithmRegistry`. Companion `assertSchemaFingerprint` for "do we agree on the schema?" handshakes. | `@suaveplan/network-registry::Xxhash32HashProvider`/`Crc32HashProvider` (upstream port); planned mesh schema-agreement handshake (`docs/network/security-and-tokens.md`, `docs/network/architecture.md`). |
|
||||
| `HashAlgorithmRegistry` | Pluggable hash-algorithm registry. Defaults: `xxhash32`, `crc32`, `sha256`. Consumers register their own (`blake3` for drive whole-file hash, future PQC). User directive on extensibility/plugins/middleware made this a first-class surface. | `docs/drive/encryption.md` L79 (BLAKE3 whole-file hash); user directive (extensibility/plugins). |
|
||||
|
||||
Plus the existing checksum surface (`crc32`, `adler32`, `xxhash32` and their
|
||||
`appendCrc32`/`verifyAndStripCrc32`/`appendXxhash32`/etc. helpers) is preserved unchanged.
|
||||
|
||||
## What lives in `@suaveplan/crypto` instead (via `restore-crypto-keychain-packer`)
|
||||
|
||||
The patterns my first-draft rewrite proposed are now correctly placed in the new
|
||||
opinionated `@suaveplan/crypto` layer, where they compose with `Authority` + `Keychain` +
|
||||
the closed-list `DomainTag` registry:
|
||||
|
||||
| First-draft primitive (dropped here) | Owned by `@suaveplan/crypto` (via `restore-crypto-keychain-packer`) |
|
||||
|---|---|
|
||||
| `SignedBitSchema` (Authority + DomainTag) | `Packer` (encrypt-and-sign envelope under DomainTag via Authority) + direct `Authority.sign(domain, slot, payload)` for sign-only |
|
||||
| `EnvelopeSchema(magic, version, body)` | `packPublicBundle` shape + `Packer` envelope |
|
||||
| `HashChainSchema` (Merkle-chained signed log) | `AuditLog` (Merkle log + `proveInclusion` / `verifyInclusion`) |
|
||||
| `MultiSignedBitSchema` (N-of-N co-sign) | If/when `MeshTrustToken` (NET-110) needs it, the spec delta lands in `@suaveplan/crypto` next to `Packer` and `RevocationList` (not here — co-signing belongs in the `Authority`-using crypto layer) |
|
||||
| `EncryptedBitSchema` (niche AEAD) | `Packer` (covers small-payload encrypt-and-sign exactly) |
|
||||
| New domain tags (`DOMAIN_BITPACKER_*_V1`) | `DomainTag` is a closed list owned by `@suaveplan/crypto/domain-tags` — additions are wire-format extensions in that package, not in `bitpacker-crypto` |
|
||||
|
||||
### What was dropped (chronological)
|
||||
|
||||
| Dropped | Why |
|
||||
|---|---|
|
||||
| `ChunkVerifier` (first audit pass) | `@suaveplan/packer::ChunkVerifier` already covers the locked v0x0001 chunk format. |
|
||||
| `SignedBitSchema` taking raw `signKey` (audit-revision) | suaveplan canonical signing surface is `Authority.sign(domain, slot, payload)`. Raw-key API didn't serve real consumers. |
|
||||
| `EnvelopeSchema` with opaque body (audit-revision) | Real consumers (`PublicBundle`, `ChainBundle`) have structured body fields. |
|
||||
| `DomainTagRegistry` open registry (audit-revision) | Conflicts with suaveplan's intentional closed-list `DomainTag` design. |
|
||||
| `SignatureAlgorithmRegistry` (audit-revision) | `Authority` already dispatches on slot algorithm. |
|
||||
| `SignedBitSchema` / `EnvelopeSchema` / `HashChainSchema` / `MultiSignedBitSchema` / `EncryptedBitSchema` (slim revision) | All overlap with `restore-crypto-keychain-packer`'s `Packer` / `AuditLog` / `RevocationList`. suaveplan policy explicitly forbids duplicate libraries. |
|
||||
| New `DOMAIN_BITPACKER_*_V1` constants (slim revision) | Closed-list domain tags belong in `@suaveplan/crypto/domain-tags`, not here. |
|
||||
|
||||
The two surviving primitives (`SchemaFingerprint`, `HashAlgorithmRegistry`) are pure
|
||||
bit-packer utilities that compose with `restore-crypto-keychain-packer` without conflict.
|
||||
134
packages/foundation/bitpacker/bitpacker-crypto/package.json
Normal file
134
packages/foundation/bitpacker/bitpacker-crypto/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
89
packages/foundation/bitpacker/bitpacker-crypto/src/crc32.md
Normal file
89
packages/foundation/bitpacker/bitpacker-crypto/src/crc32.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# CRC32 + Adler32 Checksums
|
||||
|
||||
## Purpose
|
||||
|
||||
Fast non-cryptographic checksums for bit-packed payloads. `crc32` implements the IEEE-802.3 / zlib polynomial (`0xedb88320`); `adler32` implements the RFC 1950 algorithm. Both are designed to detect accidental corruption (single-bit flips, wire-level errors, dropped bytes), not adversarial tampering — for cryptographic integrity, use the AEAD or signed-bitschema primitives in `@suaveplan/bitpacker-crypto`'s `encrypted/` and `signed/` modules.
|
||||
|
||||
The four wrappers (`appendCrc32`, `verifyAndStripCrc32`, `appendAdler32`, `verifyAndStripAdler32`) bookend a payload with a 4-byte big-endian checksum so framed bitpacker output can be transmitted over a noisy channel and integrity-checked at the receiving end. The wrappers throw on truncated input or checksum mismatch; the bare `crc32` / `adler32` functions return the raw 32-bit value for callers that want to integrate the check elsewhere.
|
||||
|
||||
## Features
|
||||
|
||||
- `crc32(data)` — IEEE-802.3 / zlib polynomial CRC32 with a precomputed 256-entry table
|
||||
- `adler32(data)` — RFC 1950 Adler32 with the standard `MOD_ADLER = 65521` prime
|
||||
- `appendCrc32` / `appendAdler32` — append 4-byte big-endian checksum
|
||||
- `verifyAndStripCrc32` / `verifyAndStripAdler32` — verify and strip 4-byte checksum, throwing on mismatch or short input
|
||||
- Pure JS, no dependencies, deterministic output, browser- and Node-safe
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { crc32, appendCrc32, verifyAndStripCrc32 } from "@suaveplan/bitpacker-crypto";
|
||||
|
||||
const payload = new Uint8Array([1, 2, 3, 4]);
|
||||
const checksum = crc32(payload); // 0xb63cfbcd
|
||||
const framed = appendCrc32(payload); // 8 bytes: payload + 4-byte CRC
|
||||
const restored = verifyAndStripCrc32(framed); // -> [1, 2, 3, 4]
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
appendCrc32,
|
||||
verifyAndStripCrc32,
|
||||
appendAdler32,
|
||||
verifyAndStripAdler32,
|
||||
} from "@suaveplan/bitpacker-crypto";
|
||||
|
||||
// Frame a bit-packed payload, transmit, and validate on receive.
|
||||
function transmit(payload: Uint8Array): Uint8Array {
|
||||
return appendCrc32(payload);
|
||||
}
|
||||
|
||||
function receive(framed: Uint8Array): Uint8Array {
|
||||
try {
|
||||
return verifyAndStripCrc32(framed);
|
||||
} catch (err) {
|
||||
// Caller decides: retransmit, drop, or escalate to a higher-level
|
||||
// integrity check (signed-bitschema for adversarial channels).
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Adler32 is faster but weaker; pick it for short payloads on trusted channels.
|
||||
const cheap = appendAdler32(new Uint8Array([0xde, 0xad, 0xbe, 0xef]));
|
||||
const back = verifyAndStripAdler32(cheap);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `crc32(data: Uint8Array): number`
|
||||
|
||||
Returns the 32-bit unsigned CRC32 value of `data` using the IEEE-802.3 / zlib polynomial (`0xedb88320`). Uses a precomputed 256-entry table for one-byte-per-iteration throughput.
|
||||
|
||||
### `adler32(data: Uint8Array): number`
|
||||
|
||||
Returns the 32-bit unsigned Adler32 value of `data` per RFC 1950 (`a` and `b` accumulators with `MOD_ADLER = 65521`).
|
||||
|
||||
### `appendCrc32(data: Uint8Array): Uint8Array`
|
||||
|
||||
Returns a new array containing `data` followed by 4 big-endian bytes of `crc32(data)`.
|
||||
|
||||
### `verifyAndStripCrc32(data: Uint8Array): Uint8Array`
|
||||
|
||||
Returns the leading `data.length - 4` bytes if the trailing 4 bytes match `crc32(payload)`. Throws `Error` with `"Data too short for CRC32 verification"` if `data.length < 4`, or `"CRC32 mismatch: expected X, got Y"` on mismatch.
|
||||
|
||||
### `appendAdler32(data: Uint8Array): Uint8Array`
|
||||
|
||||
Returns a new array containing `data` followed by 4 big-endian bytes of `adler32(data)`.
|
||||
|
||||
### `verifyAndStripAdler32(data: Uint8Array): Uint8Array`
|
||||
|
||||
Mirrors `verifyAndStripCrc32` for Adler32. Same error semantics.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- **CRC32 polynomial = `0xedb88320`.** This is the bit-reversed form of the standard IEEE-802.3 polynomial (`0x04c11db7`). The reversal is encoded in the table-precomputation loop (`c & 1 ? POLY ^ (c >>> 1) : c >>> 1`) so the streaming inner loop processes least-significant bits first, matching zlib / gzip / PNG.
|
||||
- **Big-endian checksum framing.** Both append wrappers write the 32-bit checksum as big-endian bytes, matching network byte order. The `verifyAndStrip` counterparts read with `getUint32(offset, false)` (false = big-endian).
|
||||
- **Not cryptographically secure.** A motivated attacker can construct collisions for both CRC32 and Adler32 in O(1). Use these for accidental-corruption detection only; use AEAD or signature primitives elsewhere in `@suaveplan/bitpacker-crypto` for adversarial integrity.
|
||||
- **No allocation in the hot path.** `crc32` / `adler32` only allocate via the precomputed `CRC32_TABLE`. `appendCrc32` / `appendAdler32` allocate one output buffer; `verifyAndStripCrc32` / `verifyAndStripAdler32` allocate via `slice` for the returned payload.
|
||||
98
packages/foundation/bitpacker/bitpacker-crypto/src/crc32.ts
Normal file
98
packages/foundation/bitpacker/bitpacker-crypto/src/crc32.ts
Normal 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;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
# EncryptedBitSchema
|
||||
|
||||
Niche AEAD-encrypted envelope around a bit-packed body. Wire format:
|
||||
`[version u8][algTag u8][nonceLen u8][nonce][ciphertext+tag]`.
|
||||
**Intended scope: small encrypted payloads only.** Token bodies,
|
||||
key-wrap envelopes inside `MessageEnvelope`, sub-MTU control messages.
|
||||
**NOT a chunked-file encoder** — `@suaveplan/packer` owns chunked-file
|
||||
encryption with its locked v0x0001 codec.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Several suaveplan surfaces need a small, self-describing AEAD-encrypted
|
||||
envelope: a token body wrapped for transport, an inner
|
||||
key-wrap inside a higher-level message envelope, an opaque control
|
||||
record signed-then-encrypted by upstream layers. Re-implementing the
|
||||
"version + algTag + nonce + ciphertext" frame each time invites
|
||||
nonce-reuse mistakes and silent algorithm drift.
|
||||
|
||||
`EncryptedBitSchema` standardises the frame, picks safe defaults
|
||||
(XChaCha20-Poly1305 with 24-byte random nonce, 32-byte key, no
|
||||
`Math.random` fallback), and refuses to be misused as a chunked-file
|
||||
encoder by being explicit in this README and the schema name.
|
||||
|
||||
## Features
|
||||
|
||||
- Three AEAD algorithms supported: `AES-256-GCM`, `ChaCha20-Poly1305`,
|
||||
`XChaCha20-Poly1305` (default — its 24-byte random nonce avoids
|
||||
nonce-collision concerns even at high message volume)
|
||||
- Stable u8 algorithm tags (0/1/2) on the wire so an envelope encoded
|
||||
under one algorithm can be detected (and rejected) by a schema
|
||||
expecting another
|
||||
- Pluggable `Rng`; default uses `globalThis.crypto.getRandomValues` and
|
||||
throws `CRYPTO_RNG_UNAVAILABLE` if Web Crypto is missing — never
|
||||
silently falls back to `Math.random`
|
||||
- Optional `aad` (additional authenticated data) — held by reference;
|
||||
participates in the integrity tag so binding the envelope to a
|
||||
surrounding context (session id, conversation id) is one option away
|
||||
- Wire-format constants: `version=1`, no tag changes once allocated.
|
||||
Adding a new algorithm only appends to the tag table.
|
||||
- Tampered ciphertext rejects with `CRYPTO_DECRYPT_FAILED`
|
||||
- Wrong-algorithm envelope rejects with `CRYPTO_ENVELOPE_VERSION_MISMATCH`
|
||||
- Wrong-key decrypt rejects with `CRYPTO_DECRYPT_FAILED`
|
||||
- OTel spans `bitpacker-crypto.encrypted-pack` /
|
||||
`bitpacker-crypto.encrypted-unpack`
|
||||
|
||||
## Intended scope
|
||||
|
||||
This primitive is for **small** payloads. The wire format prepends a 3-byte
|
||||
header + the full nonce + the AEAD output (ciphertext + 16-byte tag). For
|
||||
a 100-byte body, that's ~143 bytes total — fine. For a 50 MB chunked file,
|
||||
this is the wrong shape: you want chunked encryption with a streaming
|
||||
verifier, which is what `@suaveplan/packer::ChunkedPacker` provides.
|
||||
|
||||
If you find yourself reaching for `EncryptedBitSchema` to wrap large
|
||||
payloads, stop and use `@suaveplan/packer` instead. This warning is also
|
||||
asserted by a spec-conformance test.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { defineEncryptedBitSchema } from "@suaveplan/bitpacker-crypto";
|
||||
import { defineBitSchema } from "@suaveplan/bitpacker";
|
||||
import { aeadEncrypt, aeadDecrypt } from "@suaveplan/crypto";
|
||||
|
||||
interface Token { userId: number; expiry: number }
|
||||
const tokenSchema = defineBitSchema<Token>([
|
||||
{ name: "userId", type: "uint", bits: 32 },
|
||||
{ name: "expiry", type: "uint", bits: 32 },
|
||||
]);
|
||||
|
||||
const encryptedToken = defineEncryptedBitSchema<Token>(
|
||||
{
|
||||
pack: (t) => tokenSchema.pack(t),
|
||||
unpack: (bytes) => tokenSchema.unpack(bytes),
|
||||
},
|
||||
{
|
||||
key: aeadKey,
|
||||
aead: { encrypt: aeadEncrypt, decrypt: aeadDecrypt },
|
||||
}
|
||||
);
|
||||
|
||||
const wrapped = await encryptedToken.pack({ userId: 7, expiry: 1700000000 });
|
||||
const original = await encryptedToken.unpack(wrapped);
|
||||
```
|
||||
|
||||
The `aead` option is required and accepts an `AeadOps` bundle
|
||||
(`{ encrypt, decrypt }`). The canonical implementation is the
|
||||
`aeadEncrypt` / `aeadDecrypt` functions from `@suaveplan/crypto`. Passing
|
||||
them via DI keeps `@suaveplan/bitpacker-crypto` free of any runtime
|
||||
dependency on `@suaveplan/crypto`. The `AEAD_KEY_LENGTH`,
|
||||
`AEAD_NONCE_LENGTH`, and `AEAD_XCHACHA_NONCE_LENGTH` constants are
|
||||
declared locally in this primitive (not imported from
|
||||
`@suaveplan/crypto`); duplication is acceptable because these values
|
||||
are part of the bit-packer-crypto wire format.
|
||||
|
||||
## Usage: Advanced Example — bind to a session id via AAD
|
||||
|
||||
```ts
|
||||
const sessionId = new TextEncoder().encode("session:abc123");
|
||||
const encrypted = defineEncryptedBitSchema<Token>(codec, {
|
||||
key,
|
||||
aad: sessionId,
|
||||
});
|
||||
// Only an unpack with the SAME aad will succeed; replays into a different
|
||||
// session will reject with CRYPTO_DECRYPT_FAILED.
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `interface EncryptedBitSchemaOptions`
|
||||
|
||||
- `key: Uint8Array` — exactly 32 bytes (`AEAD_KEY_LENGTH`)
|
||||
- `aead: AeadOps` (required) — AEAD operations bundle; typically
|
||||
`{ encrypt: aeadEncrypt, decrypt: aeadDecrypt }` from
|
||||
`@suaveplan/crypto`
|
||||
- `algorithm?: AeadAlgorithm = "XChaCha20-Poly1305"`
|
||||
- `rng?: Rng` — defaults to a `globalThis.crypto.getRandomValues`-backed Rng
|
||||
- `aad?: Uint8Array` — optional additional authenticated data
|
||||
|
||||
### `defineEncryptedBitSchema<TBody>(bodyCodec, options): EncryptedBitSchema<TBody>`
|
||||
|
||||
Returns:
|
||||
|
||||
- `pack(data: TBody): Promise<Uint8Array>`
|
||||
- throws `CRYPTO_ENCRYPT_FAILED` on AEAD failure (very rare — usually a
|
||||
misbehaving Rng or wrong key length)
|
||||
- throws `CRYPTO_RNG_UNAVAILABLE` on first call if Web Crypto is missing
|
||||
and no Rng was supplied
|
||||
- `unpack(bytes: Uint8Array): Promise<TBody>`
|
||||
- throws `CRYPTO_DECRYPT_FAILED` on tag mismatch / wrong key / too-short
|
||||
input
|
||||
- throws `CRYPTO_ENVELOPE_VERSION_MISMATCH` on wire-format/algorithm
|
||||
mismatch
|
||||
- `algorithm`, `version`, `nonceLength` (read-only)
|
||||
|
||||
### Algorithm tags (wire format)
|
||||
|
||||
```text
|
||||
0 AES-256-GCM (12-byte nonce)
|
||||
1 ChaCha20-Poly1305 (12-byte nonce)
|
||||
2 XChaCha20-Poly1305 (24-byte nonce, default)
|
||||
```
|
||||
|
||||
These numbers are wire-format and never renumbered. New algorithms append
|
||||
to the table.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Nonces are caller-Rng-driven, never derived from message contents. The
|
||||
default Rng wraps `crypto.getRandomValues` directly. Tests use a
|
||||
seeded mulberry32 Rng to assert reproducibility — production deployments
|
||||
must use a CSPRNG.
|
||||
- AEAD failures are wrapped with `BitpackerError` so consumers don't need
|
||||
to import the underlying `@suaveplan/crypto` `CryptoError` to handle
|
||||
decrypt failures uniformly with the other bit-packer-crypto primitives.
|
||||
- The 3-byte header is intentionally minimal — version + alg + nonceLen
|
||||
is enough to dispatch a future algorithm without breaking older
|
||||
payloads. The `nonceLen` byte is technically derivable from the
|
||||
`algTag`, but storing it explicitly makes the parser simpler and
|
||||
catches an algorithm/nonce mismatch as a wire-format error instead of
|
||||
a more confusing AEAD failure.
|
||||
- The body codec runs on the _plaintext_ — `pack` calls `bodyCodec.pack`
|
||||
before encrypting, `unpack` calls `bodyCodec.unpack` after decrypting.
|
||||
This means the body codec sees full plaintext and must be trusted not
|
||||
to leak side-channels.
|
||||
- AEAD encrypt/decrypt are imported directly from `@suaveplan/crypto`'s
|
||||
main barrel; no engine override is exposed. If you need an engine
|
||||
override (e.g. for a hardware-accelerated AEAD), build a custom Rng
|
||||
wrapper or use `@suaveplan/crypto`'s engine-pluggable API directly
|
||||
outside this primitive.
|
||||
@@ -0,0 +1,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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
# EnvelopeSchema
|
||||
|
||||
A typed, version-aware envelope schema for bit-packed payloads. Wraps a
|
||||
caller-supplied `BodyCodec` with a `[magic][formatVersion][body]` frame so the
|
||||
wire format is self-describing on the magic + version dimensions, while the
|
||||
body remains pluggable. Replaces the manual
|
||||
`defineBitSchema([{magic}, {formatVersion}, ...])` pattern that suaveplan
|
||||
crypto, packer, and other consumers had been re-authoring.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Many suaveplan wire formats begin with a magic number + format version: chain
|
||||
bundles (`SVCB`), public bundles, the chunked packer, and several network
|
||||
encoders all do the same thing manually. The duplication isn't just code
|
||||
churn — every callsite is a place where the magic/version contract can drift
|
||||
out of sync with its parser. `EnvelopeSchema` consolidates the pattern and
|
||||
makes "this byte string belongs to schema X version Y" a first-class
|
||||
runtime check.
|
||||
|
||||
The body region is opaque to the envelope itself; encoding is delegated to a
|
||||
`BodyCodec<TBody>` (`pack` + `unpack`). The bundled `rawBytesCodec()` is
|
||||
provided for callers whose bodies are already-encoded bytes (typical when
|
||||
composing with `SignedBitSchema` or `EncryptedBitSchema`); for structured
|
||||
bodies, callers pass a codec built from the legacy `defineBitSchema(...)`
|
||||
or from any encoder satisfying the two-method contract.
|
||||
|
||||
## Features
|
||||
|
||||
- `pack(body)` produces `[magic][formatVersion][bodyCodec.pack(body)]`
|
||||
- `parse(bytes)` extracts magic + version + raw body bytes WITHOUT decoding
|
||||
the body — enabling forward-compat dispatch when a future version arrives
|
||||
- `parseStrict(bytes)` validates magic + version, then decodes the body to
|
||||
`TBody` via `bodyCodec.unpack`
|
||||
- Mismatched magic throws `BitpackerError` with code
|
||||
`CRYPTO_ENVELOPE_MAGIC_MISMATCH` and `context.{expected,actual}`
|
||||
- Mismatched version throws `BitpackerError` with code
|
||||
`CRYPTO_ENVELOPE_VERSION_MISMATCH` and `context.{expected,actual}`
|
||||
- Zod-validated options at construction; unknown keys rejected
|
||||
- OTel spans `bitpacker-crypto.envelope-pack` and
|
||||
`bitpacker-crypto.envelope-parse` carry `bitpacker.crypto.payload_bytes`
|
||||
- Stays codec-agnostic so a future Protobuf-bodied envelope or a
|
||||
schema-fingerprinted variant requires zero changes to envelope itself
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
type BodyCodec,
|
||||
defineEnvelopeSchema,
|
||||
} from "@suaveplan/bitpacker-crypto";
|
||||
import { defineBitSchema } from "@suaveplan/bitpacker";
|
||||
|
||||
interface Header {
|
||||
foo: number;
|
||||
bar: number;
|
||||
}
|
||||
|
||||
const headerSchema = defineBitSchema<Header>([
|
||||
{ name: "foo", type: "uint", bits: 8 },
|
||||
{ name: "bar", type: "uint", bits: 16 },
|
||||
]);
|
||||
|
||||
const codec: BodyCodec<Header> = {
|
||||
pack: (body) => headerSchema.pack(body),
|
||||
unpack: (bytes) => headerSchema.unpack(bytes),
|
||||
};
|
||||
|
||||
const envelope = defineEnvelopeSchema<Header>({
|
||||
magic: new Uint8Array([0x53, 0x56, 0x43, 0x42]), // "SVCB"
|
||||
formatVersion: 1,
|
||||
bodyCodec: codec,
|
||||
});
|
||||
|
||||
const bytes = envelope.pack({ foo: 7, bar: 1234 });
|
||||
const { foo, bar } = envelope.parseStrict(bytes); // typed: Header
|
||||
```
|
||||
|
||||
## Usage: Advanced Example — forward-compat dispatch
|
||||
|
||||
Use `parse` to peek at magic + version before deciding which decoder to run,
|
||||
so a v1 reader can detect v2 envelopes and route accordingly:
|
||||
|
||||
```ts
|
||||
import { defineEnvelopeSchema } from "@suaveplan/bitpacker-crypto";
|
||||
|
||||
const envV1 = defineEnvelopeSchema({ magic, formatVersion: 1, bodyCodec: codecV1 });
|
||||
const envV2 = defineEnvelopeSchema({ magic, formatVersion: 2, bodyCodec: codecV2 });
|
||||
|
||||
function decode(bytes: Uint8Array) {
|
||||
const partial = envV1.parse(bytes);
|
||||
if (partial.formatVersion === 1) return envV1.parseStrict(bytes);
|
||||
if (partial.formatVersion === 2) return envV2.parseStrict(bytes);
|
||||
throw new Error(`Unsupported envelope version: ${partial.formatVersion}`);
|
||||
}
|
||||
```
|
||||
|
||||
The body bytes are not consumed by `parse`, so the same input can be passed
|
||||
back to `parseStrict` of the matching version without re-allocation cost on
|
||||
the magic+version prefix.
|
||||
|
||||
## API Reference
|
||||
|
||||
### `interface BodyCodec<TBody>`
|
||||
|
||||
- `pack(body: TBody): Uint8Array` — encode the body
|
||||
- `unpack(bytes: Uint8Array): TBody` — decode the body
|
||||
|
||||
### `interface EnvelopeSchemaOptions<TBody>`
|
||||
|
||||
- `magic: Uint8Array` — non-empty
|
||||
- `formatVersion: number` — integer 0..255
|
||||
- `bodyCodec: BodyCodec<TBody>` — body encoding hook
|
||||
|
||||
### `defineEnvelopeSchema<TBody>(options): EnvelopeSchema<TBody>`
|
||||
|
||||
Returns:
|
||||
|
||||
- `magic: Uint8Array`
|
||||
- `formatVersion: number`
|
||||
- `pack(body: TBody): Uint8Array`
|
||||
- `parse(bytes: Uint8Array): { magic, formatVersion, bodyBytes }`
|
||||
- `parseStrict(bytes: Uint8Array): TBody`
|
||||
- throws `CRYPTO_ENVELOPE_MAGIC_MISMATCH` on magic mismatch
|
||||
- throws `CRYPTO_ENVELOPE_VERSION_MISMATCH` on version mismatch
|
||||
|
||||
### `rawBytesCodec(): BodyCodec<Uint8Array>`
|
||||
|
||||
Pass-through codec for bodies that are already-encoded raw bytes — useful
|
||||
when composing with `SignedBitSchema` or `EncryptedBitSchema` whose `pack`
|
||||
output goes inside an envelope.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The envelope wire format is `[magic_bytes][1-byte u8 formatVersion][body_bytes]`. The body is NOT framed with a length prefix — the codec is expected to either be fixed-size or to consume the entire trailing buffer.
|
||||
- `parse` throws `CRYPTO_ENVELOPE_MAGIC_MISMATCH` when input is shorter than `magic.length + 1` (the minimum header size). Magic and version validation in `parseStrict` happens against the parsed values, so a too-short input is caught at the parse stage.
|
||||
- Magic comparison is constant-time-irrelevant — magic bytes are not secret. Equality is byte-wise. The error context includes hex-encoded `expected` and `actual` for diagnosability.
|
||||
- The codec is held by reference; mutating the codec after `defineEnvelopeSchema(...)` returns will affect future `pack`/`parseStrict` calls. Callers should treat the codec as immutable in practice.
|
||||
- OTel spans are emitted unconditionally (even when no SDK is wired) because they cost essentially nothing under the no-op tracer; production traces materialise once a tracer provider is installed.
|
||||
@@ -0,0 +1,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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
# SchemaFingerprint
|
||||
|
||||
Content-hash a `BitPackerSchema` so two parties can agree they're using
|
||||
byte-identical schemas before they exchange payloads. The fingerprint is
|
||||
computed over a canonical serialisation (sorted keys, no whitespace,
|
||||
deterministic encoding), and the hash algorithm is pluggable via
|
||||
`HashAlgorithmRegistry`.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Whenever a producer and consumer of bit-packed bytes are not co-located —
|
||||
mesh peers, drive replicas, network plugins, or two services that pinned
|
||||
their schemas independently — they need a way to confirm "we're talking
|
||||
the same shape" before risking a parse error on a half-mismatched schema.
|
||||
A schema fingerprint compresses that question to a fixed-size digest that
|
||||
can be compared in a handshake.
|
||||
|
||||
The fingerprint must be _order-insensitive_: a producer that declared
|
||||
fields in declaration order `(foo, bar)` and a consumer that declared them
|
||||
in the order `(bar, foo)` should fingerprint identically, because the wire
|
||||
format itself is order-independent at the bit-packer level (object keys
|
||||
participate, but their declaration order does not). Achieving that
|
||||
requires canonical-serialisation, which this module owns.
|
||||
|
||||
## Features
|
||||
|
||||
- Default algorithm: `sha256` (32 bytes, cryptographically strong)
|
||||
- Built-in alternatives: `xxhash32` (4 bytes, fast non-crypto), `crc32`
|
||||
(4 bytes, fast non-crypto)
|
||||
- Pluggable: register `blake3` (or any custom hash) via
|
||||
`HashAlgorithmRegistry` and pass it as `registry` argument
|
||||
- `assertSchemaFingerprint(schema, expected, ...)` is the single-call
|
||||
handshake check; throws `CRYPTO_SCHEMA_FINGERPRINT_MISMATCH` with a
|
||||
context that includes hex `expected` and `actual` for diagnosability
|
||||
- Unknown algorithm name throws `CRYPTO_HASH_ALGORITHM_UNKNOWN`
|
||||
- Functions inside the schema (e.g. `ConditionalSchema._if`) are rejected;
|
||||
v1 fingerprintable schemas are data-shape only
|
||||
- Constant-time digest comparison in `assertSchemaFingerprint` (so timing
|
||||
side channels can't leak partial-match information)
|
||||
- OTel span `bitpacker-crypto.fingerprint` with algorithm + payload_bytes
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
assertSchemaFingerprint,
|
||||
schemaFingerprint,
|
||||
} from "@suaveplan/bitpacker-crypto";
|
||||
|
||||
const schema = { foo: 255, bar: 65535 };
|
||||
const fp = await schemaFingerprint(schema); // sha256 by default
|
||||
|
||||
// On the other side of a handshake, validate the schemas match:
|
||||
await assertSchemaFingerprint(localSchema, fp);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example — registry-backed BLAKE3
|
||||
|
||||
```ts
|
||||
import {
|
||||
type HashAlgorithm,
|
||||
createHashRegistry,
|
||||
schemaFingerprint,
|
||||
} from "@suaveplan/bitpacker-crypto";
|
||||
import { blake3 as nobleBlake3 } from "@noble/hashes/blake3";
|
||||
|
||||
const blake3: HashAlgorithm = {
|
||||
name: "blake3",
|
||||
outputBytes: 32,
|
||||
hash: (data) => Promise.resolve(nobleBlake3(data)),
|
||||
};
|
||||
|
||||
const registry = createHashRegistry();
|
||||
registry.register(blake3);
|
||||
|
||||
const fp = await schemaFingerprint(schema, "blake3", registry);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `schemaFingerprint(schema, algorithm?, registry?): Promise<Uint8Array>`
|
||||
|
||||
- `schema: BitPackerSchema` — the schema to hash. Functions and
|
||||
non-finite numbers are rejected.
|
||||
- `algorithm: string = "sha256"` — name of a registered algorithm
|
||||
- `registry: HashAlgorithmRegistry = defaultHashRegistry()` —
|
||||
optional override
|
||||
- **Throws** `CRYPTO_HASH_ALGORITHM_UNKNOWN` if the algorithm isn't
|
||||
registered; `CRYPTO_SCHEMA_FINGERPRINT_MISMATCH` if the schema contains
|
||||
unsupported value types (functions, NaN, Infinity).
|
||||
|
||||
### `assertSchemaFingerprint(schema, expected, algorithm?, registry?): Promise<void>`
|
||||
|
||||
- Computes the fingerprint and compares it byte-wise (constant-time) to
|
||||
`expected`. Throws `CRYPTO_SCHEMA_FINGERPRINT_MISMATCH` on disagreement,
|
||||
with `context.expected` and `context.actual` hex-encoded for
|
||||
diagnosability.
|
||||
|
||||
### `canonicalize(schema): string`
|
||||
|
||||
Exposed for testing the canonical-serialisation invariant directly.
|
||||
Returns the JSON-like canonical string (sorted keys, no whitespace).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The canonicalizer is recursive and supports: `null`, `undefined` (treated
|
||||
as `null`), `number` (must be finite), `boolean`, `string`, `bigint`
|
||||
(suffixed with `n`), `object` (keys sorted lexicographically), `array`
|
||||
(order preserved). All other types throw `CRYPTO_SCHEMA_FINGERPRINT_MISMATCH`.
|
||||
- `bigint` values are serialised as `123n` (with the `n` suffix) to keep
|
||||
them distinguishable from numbers in the canonical form. JSON does not
|
||||
natively support bigint, so this is a deliberate extension.
|
||||
- Constant-time comparison in `assertSchemaFingerprint` matters even though
|
||||
fingerprints are not secret — the operation is cheap, and using a
|
||||
constant-time check avoids becoming a habit-of-non-constant-time-compare
|
||||
at higher layers.
|
||||
- The OTel span carries `bitpacker.crypto.algorithm` and
|
||||
`bitpacker.crypto.payload_bytes` so a deployment can observe schema
|
||||
agreement frequency and the size of canonicalised schemas.
|
||||
- Function-bearing schemas (`ConditionalSchema`, `VersionSchema`) are out
|
||||
of scope for v1 fingerprinting because there is no canonical way to hash
|
||||
a function. A future version can add a side-channel (e.g. a stable name
|
||||
registry mapping function identity → string) if a real consumer needs it.
|
||||
@@ -0,0 +1,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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
# HashAlgorithmRegistry
|
||||
|
||||
A pluggable registry of named hash algorithms used by every hashing-aware
|
||||
primitive in `@suaveplan/bitpacker-crypto` (notably `SchemaFingerprint` and
|
||||
`HashChainSchema`). Ships with three defaults out of the box: `sha256` (32
|
||||
bytes, Web Crypto), `xxhash32` (4 bytes, in-tree), and `crc32` (4 bytes,
|
||||
in-tree). Consumers register additional algorithms — e.g. `blake3` — via
|
||||
`register`. Last-write-wins replacement is permitted and emits an OpenTelemetry
|
||||
span so swap-ins are observable.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The crypto stack avoids hard-coding a single hash function inside every
|
||||
primitive that needs one. Instead, hashing is a first-class extension point:
|
||||
primitives accept an optional `algorithm` name and an optional registry, and
|
||||
the registry resolves the name to a concrete `HashAlgorithm`. This lets a
|
||||
deployment swap in a faster sha256 (e.g. native node:crypto) or register an
|
||||
entirely new algorithm without forking the primitive.
|
||||
|
||||
The registry shape is intentionally tiny — `register`, `resolve`, `has`,
|
||||
`list` — because the surface area is also a maintenance surface area. There is
|
||||
no `unregister`: defaults are guaranteed present on every fresh registry, and
|
||||
test isolation is achieved by calling `createHashRegistry()` to get a private
|
||||
instance instead of mutating the singleton.
|
||||
|
||||
## Features
|
||||
|
||||
- Three defaults bundled: `sha256` (Web Crypto async), `xxhash32` (in-tree
|
||||
sync), `crc32` (in-tree sync, big-endian 4-byte digest)
|
||||
- Uniform async `hash(data) → Promise<Uint8Array>` interface across sync and
|
||||
async backends
|
||||
- `register` is last-write-wins; replacing an existing algorithm emits a
|
||||
`bitpacker-crypto.hash-registry.replaced` OTel span carrying the previous
|
||||
and new `outputBytes` attributes
|
||||
- Process-wide singleton via `defaultHashRegistry()`; isolated registries via
|
||||
`createHashRegistry()`
|
||||
- Unknown algorithm name throws `BitpackerError` with code
|
||||
`CRYPTO_HASH_ALGORITHM_UNKNOWN` and `context.algorithm` for diagnosability
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { defaultHashRegistry } from "@suaveplan/bitpacker-crypto";
|
||||
|
||||
const sha256 = defaultHashRegistry().resolve("sha256");
|
||||
const digest = await sha256.hash(new TextEncoder().encode("hello"));
|
||||
console.log(digest.length); // 32
|
||||
```
|
||||
|
||||
## Usage: Advanced Example — registering BLAKE3
|
||||
|
||||
```ts
|
||||
import {
|
||||
type HashAlgorithm,
|
||||
createHashRegistry,
|
||||
} from "@suaveplan/bitpacker-crypto";
|
||||
import { blake3 as nobleBlake3 } from "@noble/hashes/blake3";
|
||||
|
||||
const blake3: HashAlgorithm = {
|
||||
name: "blake3",
|
||||
outputBytes: 32,
|
||||
hash: (data) => Promise.resolve(nobleBlake3(data)),
|
||||
};
|
||||
|
||||
// Build an isolated registry so we don't pollute the singleton.
|
||||
const registry = createHashRegistry();
|
||||
registry.register(blake3);
|
||||
|
||||
// Use with SchemaFingerprint / HashChainSchema:
|
||||
// schemaFingerprint(schema, "blake3", registry)
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `interface HashAlgorithm`
|
||||
|
||||
- `name: string` — stable identifier used for registry lookup and wire-format
|
||||
tagging
|
||||
- `outputBytes: number` — exact digest length
|
||||
- `hash(data: Uint8Array): Promise<Uint8Array>` — compute the digest;
|
||||
resolves to exactly `outputBytes` bytes
|
||||
|
||||
### `interface HashAlgorithmRegistry`
|
||||
|
||||
- `register(algorithm: HashAlgorithm): void` — last-write-wins; emits
|
||||
`bitpacker-crypto.hash-registry.replaced` span when overwriting
|
||||
- `resolve(name: string): HashAlgorithm` — throws `CRYPTO_HASH_ALGORITHM_UNKNOWN`
|
||||
on miss
|
||||
- `has(name: string): boolean`
|
||||
- `list(): readonly string[]` — names in stable sort order
|
||||
|
||||
### `createHashRegistry(): HashAlgorithmRegistry`
|
||||
|
||||
Fresh registry pre-populated with the defaults. Independent of the singleton;
|
||||
mutations stay local.
|
||||
|
||||
### `defaultHashRegistry(): HashAlgorithmRegistry`
|
||||
|
||||
Process-wide singleton. Lazily constructed on first call. Subsequent calls
|
||||
return the same instance.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The interface is asynchronous because `sha256` is implemented over Web
|
||||
Crypto's `crypto.subtle.digest`, which returns a Promise. Synchronous
|
||||
algorithms wrap their result with `Promise.resolve` for uniformity.
|
||||
- `crc32` digests are emitted big-endian (most-significant byte first) so the
|
||||
byte order matches the algorithm's natural representation; `xxhash32`
|
||||
digests are emitted little-endian to match the bit-packed checksums already
|
||||
produced by `appendXxhash32`.
|
||||
- Replacement intentionally does not throw. The OTel `replaced` span is the
|
||||
observability hook for swap-ins; tests asserting registry hygiene should
|
||||
monitor the span stream rather than rely on exception flow.
|
||||
- `unregister` is intentionally absent. If you need a clean slate, build a
|
||||
fresh registry with `createHashRegistry()` and use that one.
|
||||
@@ -0,0 +1,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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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`.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
## index
|
||||
|
||||
**Path:** `bitpacker-crypto/src/index.ts`
|
||||
**Export:** See source for details.
|
||||
|
||||
### What it does
|
||||
|
||||
Part of the @suaveplan/ package.
|
||||
91
packages/foundation/bitpacker/bitpacker-crypto/src/index.ts
Normal file
91
packages/foundation/bitpacker/bitpacker-crypto/src/index.ts
Normal 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";
|
||||
@@ -0,0 +1,131 @@
|
||||
# MultiSignedBitSchema
|
||||
|
||||
Bit-packed payload co-signed by N independent authorities. Wire format is
|
||||
`[bit-packed body][sig_1][sig_2]…[sig_N]`. Pinpoints which signer failed
|
||||
on verify so a partial-trust caller can recover gracefully.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Some suaveplan surfaces require multiple signatures to authorise a single
|
||||
payload — multi-party transaction approval, threshold-equivalent ceremony
|
||||
records, and any quorum-style action. Building this on top of single-signer
|
||||
`SignedBitSchema` would force every callsite to roll its own concatenation
|
||||
and per-signer verify loop. `MultiSignedBitSchema` consolidates the
|
||||
pattern with strict count enforcement (a 3-signer schema cannot pack with
|
||||
2 authorities) and with `firstBadSignerIndex` reporting on verify so the
|
||||
error can carry the actionable detail of _which_ signer failed.
|
||||
|
||||
## Features
|
||||
|
||||
- N-of-N co-signing with `signerCount` baked into the schema
|
||||
- Pack rejects wrong-count authority arrays with
|
||||
`CRYPTO_MULTISIGN_COUNT_MISMATCH`
|
||||
- Unpack rejects wrong-count public-slot arrays with
|
||||
`CRYPTO_MULTISIGN_COUNT_MISMATCH`
|
||||
- `verify` returns `{ valid, firstBadSignerIndex? }` so callers can
|
||||
enumerate "which signature broke?" without exception flow
|
||||
- `unpack` throws `CRYPTO_VERIFY_FAILED` whose context includes
|
||||
`firstBadSignerIndex`
|
||||
- Each authority signs the same payload bytes under the same domain;
|
||||
signature ordering follows the authority list
|
||||
- AbortSignal threaded through every `Authority.sign` call so a slow
|
||||
signer can be cancelled
|
||||
- Pluggable body codec (same `BodyCodec<T>` as `SignedBitSchema`)
|
||||
- OTel spans `bitpacker-crypto.multisigned-pack` and
|
||||
`bitpacker-crypto.multisigned-verify` with signer_count + payload_bytes
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { defineMultiSignedBitSchema } from "@suaveplan/bitpacker-crypto";
|
||||
import { defineBitSchema } from "@suaveplan/bitpacker";
|
||||
import { DOMAIN_BITPACKER_MULTISIGN_V1 } from "@suaveplan/crypto/domain-tags";
|
||||
import { verifyAuthoritySignature } from "@suaveplan/crypto/authority";
|
||||
|
||||
interface VoteBody { proposalId: number; vote: number }
|
||||
const voteSchema = defineBitSchema<VoteBody>([
|
||||
{ name: "proposalId", type: "uint", bits: 32 },
|
||||
{ name: "vote", type: "uint", bits: 1 },
|
||||
]);
|
||||
|
||||
const ms = defineMultiSignedBitSchema<VoteBody>(
|
||||
{
|
||||
pack: (b) => voteSchema.pack(b),
|
||||
unpack: (bytes) => voteSchema.unpack(bytes),
|
||||
},
|
||||
{
|
||||
domain: DOMAIN_BITPACKER_MULTISIGN_V1,
|
||||
verifier: verifyAuthoritySignature,
|
||||
signerCount: 3,
|
||||
}
|
||||
);
|
||||
|
||||
const packed = await ms.pack(
|
||||
{ proposalId: 7, vote: 1 },
|
||||
[authA, authB, authC]
|
||||
);
|
||||
const decoded = ms.unpack(packed, [pubA, pubB, pubC]);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example — graceful partial-trust recovery
|
||||
|
||||
```ts
|
||||
const result = ms.verify(bytes, [pubA, pubB, pubC]);
|
||||
if (!result.valid) {
|
||||
// Tell the user EXACTLY which signer rejected the payload.
|
||||
const failingSigner = signerNames[result.firstBadSignerIndex!];
|
||||
throw new Error(`Quorum verify failed at signer "${failingSigner}"`);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `interface MultiVerifyResult`
|
||||
|
||||
- `valid: boolean`
|
||||
- `firstBadSignerIndex?: number` — present when `valid` is false; zero-indexed
|
||||
|
||||
### `interface MultiSignedBitSchemaOptions`
|
||||
|
||||
- `domain: DomainTag` — required
|
||||
- `verifier: AuthorityVerifier` — required (typically
|
||||
`verifyAuthoritySignature` from `@suaveplan/crypto/authority`)
|
||||
- `slot?: SlotId = "sign"` — slot used by every authority
|
||||
- `signerCount: number` — required, range 1..64
|
||||
- `signatureLength?: number = 64` — wire-format signature length
|
||||
|
||||
### `defineMultiSignedBitSchema<T>(bodyCodec, options): MultiSignedBitSchema<T>`
|
||||
|
||||
Returns:
|
||||
|
||||
- `pack(data: T, authorities: readonly Authority[], signal?): Promise<Uint8Array>`
|
||||
- `unpack(bytes: Uint8Array, signerPublicSlots: readonly KeySlotPublic<unknown>[]): T`
|
||||
- `verify(bytes: Uint8Array, signerPublicSlots: readonly KeySlotPublic<unknown>[]): MultiVerifyResult`
|
||||
- `domain`, `slot`, `signerCount`, `signatureLength`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Signatures are appended in authority list order and verified in the
|
||||
same order. Re-ordering the public slot list at verify time will fail
|
||||
every signature (ed25519 binds key to signature). This is intentional —
|
||||
the wire format does not permute, so the schema's contract is
|
||||
positional.
|
||||
- Pack short-circuits early if any authority returns a wrong-length
|
||||
signature, throwing `CRYPTO_SIGN_FAILED` with `signerIndex`. A
|
||||
misbehaving authority cannot poison a partially-signed payload because
|
||||
bytes are not assembled until every signature is ready.
|
||||
- Verify does NOT short-circuit on the first failure when called via
|
||||
`verify(...)` — that's a lie. It DOES short-circuit, returning
|
||||
`firstBadSignerIndex`. The naming is deliberate: callers should not
|
||||
rely on every signer being verified just because they asked to verify
|
||||
N signatures. If you need exhaustive reporting (which signers failed,
|
||||
which passed), iterate yourself with `verifyAuthoritySignature`.
|
||||
- The `signerCount` upper bound (64) is conservative; raising it requires
|
||||
no schema changes, but the on-wire payload grows linearly so very
|
||||
large signer sets should consider threshold signing instead.
|
||||
- AbortSignal is checked once per authority — a slow signer at index 5
|
||||
can be cancelled, but signers 0-4 will already have signed.
|
||||
@@ -0,0 +1,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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
166
packages/foundation/bitpacker/bitpacker-crypto/src/xxhash32.ts
Normal file
166
packages/foundation/bitpacker/bitpacker-crypto/src/xxhash32.ts
Normal 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;
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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");
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user