Initial Commit
This commit is contained in:
22
packages/foundation/bitpacker/README.md
Normal file
22
packages/foundation/bitpacker/README.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# `bitpacker`
|
||||
|
||||
Bit-level binary packing, schemas, compression, and streaming codecs.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Packages in `bitpacker`
|
||||
|
||||
10 packages.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/bitpacker`](./bitpacker/README.md) | Core bit-level binary packing library. Provides `BitWriter`, `BitReader`, `BitPacker`, and a pluggable strategy system for schema-driven serialization. |
|
||||
| [`@suaveplan/bitpacker-compressed`](./bitpacker-compressed/README.md) | Compression pipeline integration for bitpacker. |
|
||||
| [`@suaveplan/bitpacker-crypto`](./bitpacker-crypto/README.md) | Bit-packer-aware crypto primitives. The shared interfaces (`Authority`, `DomainTag`, `AuthorityVerifier`, `AeadOps`) live in `@suaveplan/types`; the canonical… |
|
||||
| [`@suaveplan/bitpacker-delta`](./bitpacker-delta/README.md) | Delta encoding utilities for efficient repeated state transmission. Detects changes between successive values and encodes differences rather than full values. |
|
||||
| [`@suaveplan/bitpacker-game`](./bitpacker-game/README.md) | Game state utilities: quantization, prediction, interpolation, and client-server reconciliation. |
|
||||
| [`@suaveplan/bitpacker-network`](./bitpacker-network/README.md) | Network simulation utilities for testing bitpacker under realistic network conditions. |
|
||||
| [`@suaveplan/bitpacker-schema`](./bitpacker-schema/README.md) | Schema registry and validation for bitpacker. |
|
||||
| [`@suaveplan/bitpacker-strategies`](./bitpacker-strategies/README.md) | Built-in packing strategies for `@suaveplan/bitpacker`. Provides strategies for numbers, strings, booleans, objects, arrays, enums, optional fields, oneOf unio… |
|
||||
| [`@suaveplan/bitpacker-streaming`](./bitpacker-streaming/README.md) | Streaming APIs for bitpacker including chunked packing/unpacking and true TransformStream support with framing. |
|
||||
| [`@suaveplan/bitpacker-validation`](./bitpacker-validation/README.md) | Safety limits and validation reports for bitpacker schemas. |
|
||||
<!-- end:auto-docs -->
|
||||
1
packages/foundation/bitpacker/_intro.md
Normal file
1
packages/foundation/bitpacker/_intro.md
Normal file
@@ -0,0 +1 @@
|
||||
Bit-level binary packing, schemas, compression, and streaming codecs.
|
||||
91
packages/foundation/bitpacker/bitpacker-compressed/README.md
Normal file
91
packages/foundation/bitpacker/bitpacker-compressed/README.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# @suaveplan/bitpacker-compressed - Bitpacker compressed plugin
|
||||
|
||||
## Summary
|
||||
|
||||
Compression pipeline integration for bitpacker.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [deflate](./src/adapters/deflate/deflate.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [deflate](./src/adapters/deflate/deflate.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [gzip](./src/adapters/gzip/gzip.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [gzip](./src/adapters/gzip/gzip.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [none](./src/adapters/none/none.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [none](./src/adapters/none/none.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [api](./src/api/api.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [api](./src/api/api.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [compressed-bit-packer](./src/compressed-bit-packer/compressed-bit-packer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - compressed-bit-packer.schema — Zod schema for `Comp…
|
||||
- [compressed-bit-packer](./src/compressed-bit-packer/compressed-bit-packer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - compressed-bit-packer.schema — Zod schema for `Comp…
|
||||
- [compressed-bit-packer.schema](./src/compressed-bit-packer/compressed-bit-packer.schema.md) — Zod schema for `CompressedBitPackerOptions`, the constructor options bundle accepted by `CompressedBitPacker`. Validate…
|
||||
- [otel](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [instruments](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [register-schemas](./src/register-schemas/register-schemas.md) — Idempotent integration with `@suaveplan/schema-registry` for the `@suaveplan/bitpacker-compressed` package. Exposes `re…
|
||||
- [register-schemas](./src/register-schemas/register-schemas.md) — Idempotent integration with `@suaveplan/schema-registry` for the `@suaveplan/bitpacker-compressed` package. Exposes `re…
|
||||
- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [rle](./src/rle/rle.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - rle.schema — Zod schema for the per-call `Compressi…
|
||||
- [rle](./src/rle/rle.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - rle.schema — Zod schema for the per-call `Compressi…
|
||||
- [rle.schema](./src/rle/rle.schema.md) — Zod schema for the per-call `CompressionOptions` accepted by `compressRle` / `decompressRle`. The RLE codec is byte-onl…
|
||||
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - types.schema — Zod schemas for the public type comp…
|
||||
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - types.schema — Zod schemas for the public type comp…
|
||||
- [types.schema](./src/types/types.schema.md) — Zod schemas for the public type companions of `@suaveplan/bitpacker-compressed`'s compression pipeline: the open-record…
|
||||
- [index](./src/index.md) — **Path:** `bitpacker-compressed/src/index.ts` **Export:** See source for details.
|
||||
<!-- end:auto-docs --> - [deflate](./src/adapters/deflate/deflate.md) — <!-- begin:auto-docs --> _(no submodules)_- [gzip](./src/adapters/gzip/gzip.md) — <!-- begin:auto-docs --> _(no submodules)_- [gzip](./src/adapters/gzip/gzip.md) — <!-- begin:auto-docs --> _(no submodules)_- [none](./src/adapters/none/none.md) — <!-- begin:auto-docs --> _(no submodules)_- [none](./src/adapters/none/none.md) — <!-- begin:auto-docs --> _(no submodules)_- [api](./src/api/api.md) — <!-- begin:auto-docs --> _(no submodules)_- [api](./src/api/api.md) — <!-- begin:auto-docs --> _(no submodules)_- [compressed-bit-packer](./src/compressed-bit-packer/compressed-bit-packer.md) — <!-- begin:auto-docs --> _(no submodules)_- compressed-bit-packer.schema — Zod schema for `Comp…
|
||||
- [compressed-bit-packer](./src/compressed-bit-packer/compressed-bit-packer.md) — <!-- begin:auto-docs --> _(no submodules)_- compressed-bit-packer.schema — Zod schema for `Comp…
|
||||
- [compressed-bit-packer.schema](./src/compressed-bit-packer/compressed-bit-packer.schema.md) — Zod schema for `CompressedBitPackerOptions`, the constructor options bundle accepted by `CompressedBitPacker`. Validate…
|
||||
- [otel](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [instruments](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [rle](./src/rle/rle.md) — <!-- begin:auto-docs --> _(no submodules)_- rle.schema — Zod schema for the per-call `Compressi…
|
||||
- [rle](./src/rle/rle.md) — <!-- begin:auto-docs --> _(no submodules)_- rle.schema — Zod schema for the per-call `Compressi…
|
||||
- [rle.schema](./src/rle/rle.schema.md) — Zod schema for the per-call `CompressionOptions` accepted by `compressRle` / `decompressRle`. The RLE codec is byte-onl…
|
||||
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- types.schema — Zod schemas for the public type comp…
|
||||
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- types.schema — Zod schemas for the public type comp…
|
||||
- [types.schema](./src/types/types.schema.md) — Zod schemas for the public type companions of `@suaveplan/bitpacker-compressed`'s compression pipeline: the open-record…
|
||||
- [index](./src/index.md) — **Path:** `bitpacker-compressed/src/index.ts` **Export:** See source for details.
|
||||
- [register-schemas](./src/register-schemas.md) — Idempotent integration with `@suaveplan/schema-registry` for the `@suaveplan/bitpacker-compressed` package. Exposes `re…
|
||||
- [deflate](./src/adapters/deflate/deflate.md) — <!-- begin:auto-docs --> _(no submodules)_- [gzip](./src/adapters/gzip/gzip.md) — <!-- begin:auto-docs --> _(no submodules)_- [gzip](./src/adapters/gzip/gzip.md) — <!-- begin:auto-docs --> _(no submodules)_- [none](./src/adapters/none/none.md) — <!-- begin:auto-docs --> _(no submodules)_- [none](./src/adapters/none/none.md) — <!-- begin:auto-docs --> _(no submodules)_- [api](./src/api/api.md) — <!-- begin:auto-docs --> _(no submodules)_- [api](./src/api/api.md) — <!-- begin:auto-docs --> _(no submodules)_- [compressed-bit-packer](./src/compressed-bit-packer/compressed-bit-packer.md) — <!-- begin:auto-docs --> _(no submodules)_- compressed-bit-packer.schema — Zod schema for `Comp…
|
||||
- [compressed-bit-packer](./src/compressed-bit-packer/compressed-bit-packer.md) — <!-- begin:auto-docs --> _(no submodules)_- compressed-bit-packer.schema — Zod schema for `Comp…
|
||||
- [compressed-bit-packer.schema](./src/compressed-bit-packer/compressed-bit-packer.schema.md) — Zod schema for `CompressedBitPackerOptions`, the constructor options bundle accepted by `CompressedBitPacker`. Validate…
|
||||
- [otel](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [instruments](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [rle](./src/rle/rle.md) — <!-- begin:auto-docs --> _(no submodules)_- rle.schema — Zod schema for the per-call `Compressi…
|
||||
- [rle](./src/rle/rle.md) — <!-- begin:auto-docs --> _(no submodules)_- rle.schema — Zod schema for the per-call `Compressi…
|
||||
- [rle.schema](./src/rle/rle.schema.md) — Zod schema for the per-call `CompressionOptions` accepted by `compressRle` / `decompressRle`. The RLE codec is byte-onl…
|
||||
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- types.schema — Zod schemas for the public type comp…
|
||||
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- types.schema — Zod schemas for the public type comp…
|
||||
- [types.schema](./src/types/types.schema.md) — Zod schemas for the public type companions of `@suaveplan/bitpacker-compressed`'s compression pipeline: the open-record…
|
||||
- [index](./src/index.md) — **Path:** `bitpacker-compressed/src/index.ts` **Export:** See source for details.
|
||||
- [register-schemas](./src/register-schemas.md) — Idempotent integration with `@suaveplan/schema-registry` for the `@suaveplan/bitpacker-compressed` package. Exposes `re…
|
||||
- [deflate](./src/adapters/deflate/deflate.md) — <!-- begin:auto-docs --> _(no submodules)_- [gzip](./src/adapters/gzip/gzip.md) — <!-- begin:auto-docs --> _(no submodules)_- [gzip](./src/adapters/gzip/gzip.md) — <!-- begin:auto-docs --> _(no submodules)_- [none](./src/adapters/none/none.md) — <!-- begin:auto-docs --> _(no submodules)_- [none](./src/adapters/none/none.md) — <!-- begin:auto-docs --> _(no submodules)_- [api](./src/api/api.md) — <!-- begin:auto-docs --> _(no submodules)_- [api](./src/api/api.md) — <!-- begin:auto-docs --> _(no submodules)_- [compressed-bit-packer](./src/compressed-bit-packer/compressed-bit-packer.md) — <!-- begin:auto-docs --> _(no submodules)_- compressed-bit-packer.schema — Zod schema for `Comp…
|
||||
- [compressed-bit-packer](./src/compressed-bit-packer/compressed-bit-packer.md) — <!-- begin:auto-docs --> _(no submodules)_- compressed-bit-packer.schema — Zod schema for `Comp…
|
||||
- [compressed-bit-packer.schema](./src/compressed-bit-packer/compressed-bit-packer.schema.md) — Zod schema for `CompressedBitPackerOptions`, the constructor options bundle accepted by `CompressedBitPacker`. Validate…
|
||||
- [otel](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [instruments](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [rle](./src/rle/rle.md) — <!-- begin:auto-docs --> _(no submodules)_- rle.schema — Zod schema for the per-call `Compressi…
|
||||
- [rle](./src/rle/rle.md) — <!-- begin:auto-docs --> _(no submodules)_- rle.schema — Zod schema for the per-call `Compressi…
|
||||
- [rle.schema](./src/rle/rle.schema.md) — Zod schema for the per-call `CompressionOptions` accepted by `compressRle` / `decompressRle`. The RLE codec is byte-onl…
|
||||
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- types.schema — Zod schemas for the public type comp…
|
||||
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- types.schema — Zod schemas for the public type comp…
|
||||
- [types.schema](./src/types/types.schema.md) — Zod schemas for the public type companions of `@suaveplan/bitpacker-compressed`'s compression pipeline: the open-record…
|
||||
- [index](./src/index.md) — **Path:** `bitpacker-compressed/src/index.ts` **Export:** See source for details.
|
||||
- [register-schemas](./src/register-schemas.md) — Idempotent integration with `@suaveplan/schema-registry` for the `@suaveplan/bitpacker-compressed` package. Exposes `re…
|
||||
- [deflate](./src/adapters/deflate/deflate.md) — <!-- begin:auto-docs --> _(no submodules)_- [gzip](./src/adapters/gzip/gzip.md) — <!-- begin:auto-docs --> _(no submodules)_ - [gzip](./src/adapters/gzip/gzip.md) — <!-- begin:auto-docs --> _(no submodules)_- [none](./src/adapters/none/none.md) — <!-- begin:auto-docs --> _(no submodules)_ - [none](./src/adapters/none/none.md) — <!-- begin:auto-docs --> _(no submodules)_- [api](./src/api/api.md) — <!-- begin:auto-docs --> _(no submodules)_ - [api](./src/api/api.md) — <!-- begin:auto-docs --> _(no submodules)_- [compressed-bit-packer](./src/compressed-bit-packer/compressed-bit-packer.md) — <!-- begin:auto-docs --> _(no submodules)_ - [compressed-bit-packer](./src/compressed-bit-packer/compressed-bit-packer.md) — <!-- begin:auto-docs --> _(no submodules)_ - [compressed-bit-packer.schema](./src/compressed-bit-packer/compressed-bit-packer.schema.md) — Zod schema for `CompressedBitPackerOptions`, the constructor options bundle accepted by `CompressedBitPacker`. Validate…
|
||||
- [otel](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_ - [instruments](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_ - [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [rle](./src/rle/rle.md) — <!-- begin:auto-docs --> _(no submodules)_ - [rle](./src/rle/rle.md) — <!-- begin:auto-docs --> _(no submodules)_ - [rle.schema](./src/rle/rle.schema.md) — Zod schema for the per-call `CompressionOptions` accepted by `compressRle` / `decompressRle`. The RLE codec is byte-onl…
|
||||
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_ - [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_ - [types.schema](./src/types/types.schema.md) — Zod schemas for the public type companions of `@suaveplan/bitpacker-compressed`'s compression pipeline: the open-record…
|
||||
- [index](./src/index.md) — **Path:** `bitpacker-compressed/src/index.ts` **Export:** See source for details.
|
||||
- [register-schemas](./src/register-schemas.md) — Idempotent integration with `@suaveplan/schema-registry` for the `@suaveplan/bitpacker-compressed` package. Exposes `re…
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/bitpacker-compressed
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
import { compress, decompress } from "@suaveplan/bitpacker-compressed";
|
||||
|
||||
const data = new Uint8Array([1, 2, 3, 4, 5]);
|
||||
const compressed = compress(data);
|
||||
const restored = decompress(compressed);
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
- `src/index.ts` — `compress`, `decompress`
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"name": "@suaveplan/bitpacker-compressed",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Bitpacker compressed plugin",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"./register-schemas": {
|
||||
"import": {
|
||||
"types": "./dist/register-schemas/index.d.ts",
|
||||
"default": "./dist/register-schemas/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/register-schemas/index.d.ts",
|
||||
"default": "./dist/register-schemas/index.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"@suaveplan/bitpacker": "workspace:^",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/schema-registry": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/bitpacker-strategies": "workspace:^",
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { BITPACKER_CODES, BitpackerError } from "@suaveplan/bitpacker";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { DeflateCompressionAdapter } from "./deflate/deflate.js";
|
||||
import { GzipCompressionAdapter } from "./gzip/gzip.js";
|
||||
import { NoCompressionAdapter } from "./none/none.js";
|
||||
|
||||
describe("NoCompressionAdapter", () => {
|
||||
it("compress passes bytes through unchanged", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const adapter = new NoCompressionAdapter();
|
||||
const input = new Uint8Array([1, 2, 3, 4]);
|
||||
const out = await adapter.compress(input);
|
||||
expect(out).toEqual(input);
|
||||
});
|
||||
|
||||
it("decompress passes bytes through unchanged", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const adapter = new NoCompressionAdapter();
|
||||
const input = new Uint8Array([1, 2, 3, 4]);
|
||||
const out = await adapter.decompress(input);
|
||||
expect(out).toEqual(input);
|
||||
});
|
||||
|
||||
it("name is 'none'", { timeout: 5000 }, () => {
|
||||
expect(new NoCompressionAdapter().name).toBe("none");
|
||||
});
|
||||
});
|
||||
|
||||
const hasStreams = typeof CompressionStream !== "undefined";
|
||||
|
||||
describe.skipIf(!hasStreams)("GzipCompressionAdapter (stream-backed)", () => {
|
||||
it("round-trip preserves bytes for 1 KiB of zeros", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const adapter = new GzipCompressionAdapter();
|
||||
const input = new Uint8Array(1024).fill(0);
|
||||
const compressed = await adapter.compress(input);
|
||||
// gzip on 1 KiB of zeros compresses dramatically:
|
||||
expect(compressed.length).toBeLessThan(50);
|
||||
const decompressed = await adapter.decompress(compressed);
|
||||
expect(decompressed).toEqual(input);
|
||||
});
|
||||
|
||||
it("round-trip preserves bytes for random 1 KiB", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const adapter = new GzipCompressionAdapter();
|
||||
const input = new Uint8Array(1024);
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
input[i] = (i * 17 + 7) & 0xff;
|
||||
}
|
||||
const compressed = await adapter.compress(input);
|
||||
const decompressed = await adapter.decompress(compressed);
|
||||
expect(decompressed).toEqual(input);
|
||||
});
|
||||
|
||||
it("decompress throws DECOMPRESSION_FAILED for truncated stream", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const adapter = new GzipCompressionAdapter();
|
||||
const original = new Uint8Array(1024).fill(42);
|
||||
const compressed = await adapter.compress(original);
|
||||
const truncated = compressed.slice(
|
||||
0,
|
||||
Math.floor(compressed.length / 2)
|
||||
);
|
||||
await expect(adapter.decompress(truncated)).rejects.toBeInstanceOf(
|
||||
BitpackerError
|
||||
);
|
||||
try {
|
||||
await adapter.decompress(truncated);
|
||||
} catch (e) {
|
||||
expect((e as BitpackerError).code).toBe(
|
||||
BITPACKER_CODES.DECOMPRESSION_FAILED
|
||||
);
|
||||
expect((e as BitpackerError).context?.algorithm).toBe("gzip");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!hasStreams)(
|
||||
"DeflateCompressionAdapter (stream-backed)",
|
||||
() => {
|
||||
it("round-trip preserves bytes for 1 KiB", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const adapter = new DeflateCompressionAdapter();
|
||||
const input = new Uint8Array(1024);
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
input[i] = (i * 31 + 11) & 0xff;
|
||||
}
|
||||
const compressed = await adapter.compress(input);
|
||||
const decompressed = await adapter.decompress(compressed);
|
||||
expect(decompressed).toEqual(input);
|
||||
});
|
||||
|
||||
it("name is 'deflate'", { timeout: 5000 }, () => {
|
||||
expect(new DeflateCompressionAdapter().name).toBe("deflate");
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
describe("GzipCompressionAdapter (no streams branch)", () => {
|
||||
it("compress throws COMPRESSION_FAILED when CompressionStream is missing", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const g = globalThis as unknown as {
|
||||
CompressionStream?: typeof CompressionStream;
|
||||
};
|
||||
const saved = g.CompressionStream;
|
||||
g.CompressionStream = undefined;
|
||||
try {
|
||||
const adapter = new GzipCompressionAdapter();
|
||||
await adapter.compress(new Uint8Array([1, 2, 3]));
|
||||
throw new Error("expected throw, did not throw");
|
||||
} catch (e) {
|
||||
expect((e as BitpackerError).code).toBe(
|
||||
BITPACKER_CODES.COMPRESSION_FAILED
|
||||
);
|
||||
expect((e as BitpackerError).context?.algorithm).toBe("gzip");
|
||||
} finally {
|
||||
g.CompressionStream = saved;
|
||||
}
|
||||
});
|
||||
|
||||
it("decompress throws DECOMPRESSION_FAILED when DecompressionStream is missing", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const g = globalThis as unknown as {
|
||||
DecompressionStream?: typeof DecompressionStream;
|
||||
};
|
||||
const saved = g.DecompressionStream;
|
||||
g.DecompressionStream = undefined;
|
||||
try {
|
||||
const adapter = new GzipCompressionAdapter();
|
||||
await adapter.decompress(new Uint8Array([1, 2, 3]));
|
||||
throw new Error("expected throw, did not throw");
|
||||
} catch (e) {
|
||||
expect((e as BitpackerError).code).toBe(
|
||||
BITPACKER_CODES.DECOMPRESSION_FAILED
|
||||
);
|
||||
expect((e as BitpackerError).context?.algorithm).toBe("gzip");
|
||||
} finally {
|
||||
g.DecompressionStream = saved;
|
||||
}
|
||||
});
|
||||
|
||||
it("compress wraps thrown stream-constructor error as COMPRESSION_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// Force runStream's try-block to throw by stubbing CompressionStream
|
||||
// with a constructor that always throws — exercises the
|
||||
// direction === "compress" branch of the catch's BitpackerError code
|
||||
// selection.
|
||||
const g = globalThis as unknown as {
|
||||
CompressionStream?: typeof CompressionStream;
|
||||
};
|
||||
const saved = g.CompressionStream;
|
||||
class ThrowingStream {
|
||||
constructor() {
|
||||
throw new Error("constructor blew up");
|
||||
}
|
||||
}
|
||||
g.CompressionStream =
|
||||
ThrowingStream as unknown as typeof CompressionStream;
|
||||
try {
|
||||
const adapter = new GzipCompressionAdapter();
|
||||
await adapter.compress(new Uint8Array([1, 2, 3]));
|
||||
throw new Error("expected throw, did not throw");
|
||||
} catch (e) {
|
||||
expect((e as BitpackerError).code).toBe(
|
||||
BITPACKER_CODES.COMPRESSION_FAILED
|
||||
);
|
||||
expect((e as BitpackerError).context?.algorithm).toBe("gzip");
|
||||
} finally {
|
||||
g.CompressionStream = saved;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
# DeflateCompressionAdapter
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`DeflateCompressionAdapter` is the WHATWG Streams-backed deflate codec for `@suaveplan/bitpacker-compressed`. It mirrors `GzipCompressionAdapter` exactly but switches the underlying stream to `CompressionStream("deflate")` / `DecompressionStream("deflate")`. The chunk-concatenation pump is shared with the gzip adapter via `_runStreamForTests` so the two adapters do not duplicate the same loop. Use this when you need raw deflate (no gzip header) — typically for inter-op with consumers that expect that wire format (HTTP `Content-Encoding: deflate`, certain binary protocols, etc.).
|
||||
|
||||
## Features
|
||||
|
||||
- Drop-in `CompressionAdapter` implementation registered as `"deflate"` in the default registry.
|
||||
- Re-uses the gzip adapter's streaming pump for chunk concatenation, eliminating duplicated logic.
|
||||
- Same coded-error treatment as gzip: `COMPRESSION_FAILED` / `DECOMPRESSION_FAILED` with `cause`.
|
||||
- Identical async signature to all other adapters so swapping codecs is a `registry.resolve(otherName)` change.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { DeflateCompressionAdapter } from "@suaveplan/bitpacker-compressed";
|
||||
|
||||
const deflate = new DeflateCompressionAdapter();
|
||||
const compressed = await deflate.compress(payload);
|
||||
const restored = await deflate.decompress(compressed);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
compressData,
|
||||
decompressData,
|
||||
} from "@suaveplan/bitpacker-compressed";
|
||||
|
||||
// HTTP body that arrived with Content-Encoding: deflate.
|
||||
const body = await req.arrayBuffer();
|
||||
const decoded = await decompressData(new Uint8Array(body), "deflate");
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class DeflateCompressionAdapter implements CompressionAdapter`
|
||||
|
||||
- `readonly name: "deflate"`
|
||||
- `async compress(data: Uint8Array, options?: { level?: number }): Promise<Uint8Array>`
|
||||
- `async decompress(data: Uint8Array, options?: { level?: number }): Promise<Uint8Array>`
|
||||
|
||||
**Throws:** `BitpackerError(COMPRESSION_FAILED, { cause })` / `BitpackerError(DECOMPRESSION_FAILED, { cause })` on stream failure or environments without `globalThis.CompressionStream`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- "Deflate" here means raw RFC 1951 (no zlib header). If you need the zlib-wrapped variant, the platform `CompressionStream("deflate")` already produces it on most browsers — this is a known compatibility nuance and callers MUST be aware of which side they need.
|
||||
- The shared `_runStreamForTests` helper is intentionally underscore-prefixed to mark it as adapter-internal; consumers should not import it directly.
|
||||
- WHATWG `CompressionStream` ignores the `level` option for deflate the same way it does for gzip.
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* `DeflateCompressionAdapter` — WHATWG Streams-backed deflate codec.
|
||||
*
|
||||
* Same shape as `GzipCompressionAdapter`; uses
|
||||
* `globalThis.CompressionStream("deflate")` and the matching decompression
|
||||
* stream. Re-uses the streaming pump from the gzip adapter to avoid
|
||||
* duplicating the chunk-concatenation loop.
|
||||
*/
|
||||
|
||||
import type {
|
||||
BitPackerCompressionAlgorithm,
|
||||
BitPackerCompressionOptions,
|
||||
CompressionAdapter,
|
||||
} from "../../types/types.js";
|
||||
import { _runStreamForTests as runStream } from "../gzip/gzip.js";
|
||||
|
||||
export class DeflateCompressionAdapter implements CompressionAdapter {
|
||||
public readonly name: BitPackerCompressionAlgorithm = "deflate";
|
||||
|
||||
public async compress(
|
||||
data: Uint8Array,
|
||||
_options?: BitPackerCompressionOptions
|
||||
): Promise<Uint8Array> {
|
||||
return runStream("deflate", "compress", data);
|
||||
}
|
||||
|
||||
public async decompress(
|
||||
data: Uint8Array,
|
||||
_options?: BitPackerCompressionOptions
|
||||
): Promise<Uint8Array> {
|
||||
return runStream("deflate", "decompress", data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
# GzipCompressionAdapter
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`GzipCompressionAdapter` is the WHATWG Streams-backed gzip codec. It uses `globalThis.CompressionStream("gzip")` and `DecompressionStream("gzip")` — available in modern browsers, Bun, Deno, and Node 18+. The adapter exists so callers don't have to reach into Web Streams APIs directly: the pipeline framing, error coding, and chunk-concatenation loop are all handled here so `compressData(data, "gzip")` is a one-liner. If the host environment lacks `CompressionStream`, the registry's `defaultCompressionRegistry()` skips registering this adapter; constructing one directly and calling `compress` in such a host throws `BitpackerError(COMPRESSION_FAILED)` with a descriptive cause chain.
|
||||
|
||||
## Features
|
||||
|
||||
- Drop-in `CompressionAdapter` implementation registered as `"gzip"` in the default registry.
|
||||
- Streaming pump under the hood — large payloads are chunked through `CompressionStream` rather than buffered whole.
|
||||
- Coded errors (`COMPRESSION_FAILED` / `DECOMPRESSION_FAILED`) wrap any underlying stream exception with `cause`.
|
||||
- Re-exported `_runStreamForTests` helper used by `DeflateCompressionAdapter` to share the chunk-concatenation loop.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { GzipCompressionAdapter } from "@suaveplan/bitpacker-compressed";
|
||||
|
||||
const gzip = new GzipCompressionAdapter();
|
||||
const compressed = await gzip.compress(myBytes);
|
||||
const restored = await gzip.decompress(compressed);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { defaultCompressionRegistry } from "@suaveplan/bitpacker-compressed";
|
||||
|
||||
// Production code typically goes through the registry.
|
||||
const registry = defaultCompressionRegistry();
|
||||
const adapter = registry.resolve("gzip");
|
||||
const out = await adapter.compress(payload, { level: 9 });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class GzipCompressionAdapter implements CompressionAdapter`
|
||||
|
||||
- `readonly name: "gzip"`
|
||||
- `async compress(data: Uint8Array, options?: { level?: number }): Promise<Uint8Array>`
|
||||
- `async decompress(data: Uint8Array, options?: { level?: number }): Promise<Uint8Array>`
|
||||
|
||||
**Throws:** `BitpackerError(COMPRESSION_FAILED, { cause })` / `BitpackerError(DECOMPRESSION_FAILED, { cause })` if the underlying `CompressionStream` rejects, including environments without `globalThis.CompressionStream`.
|
||||
|
||||
### `_runStreamForTests(stream, data): Promise<Uint8Array>` (internal)
|
||||
|
||||
Exposed for the deflate adapter to re-use the pump loop. Not part of the public API.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- WHATWG `CompressionStream` does not honour the `level` option — gzip level is fixed at the platform default. The option is accepted for API symmetry with adapters that DO honour it (e.g. a future `BrotliAdapter`).
|
||||
- The pump loop uses `ReadableStream.prototype.getReader()` and concatenates chunks into a single `Uint8Array` at the end. For very large payloads, callers should consider invoking `CompressionStream` directly to get backpressure-aware streaming output.
|
||||
- Every async method awaits inside a try/catch and re-throws as a coded `BitpackerError`. There is no silent fallback — if gzip fails, the caller sees a fully-typed coded error.
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* `GzipCompressionAdapter` — WHATWG Streams-backed gzip codec.
|
||||
*
|
||||
* Uses `globalThis.CompressionStream("gzip")` and `DecompressionStream("gzip")`
|
||||
* — available in modern browsers, Bun, Deno, and Node 18+. If the host
|
||||
* lacks `CompressionStream`, every method throws so the registry's
|
||||
* `defaultCompressionRegistry()` factory only registers this adapter when
|
||||
* the API is detected.
|
||||
*/
|
||||
|
||||
import { BITPACKER_CODES, BitpackerError } from "@suaveplan/bitpacker";
|
||||
import type {
|
||||
BitPackerCompressionAlgorithm,
|
||||
BitPackerCompressionOptions,
|
||||
CompressionAdapter,
|
||||
} from "../../types/types.js";
|
||||
|
||||
export class GzipCompressionAdapter implements CompressionAdapter {
|
||||
public readonly name: BitPackerCompressionAlgorithm = "gzip";
|
||||
|
||||
public async compress(
|
||||
data: Uint8Array,
|
||||
_options?: BitPackerCompressionOptions
|
||||
): Promise<Uint8Array> {
|
||||
return runStream("gzip", "compress", data);
|
||||
}
|
||||
|
||||
public async decompress(
|
||||
data: Uint8Array,
|
||||
_options?: BitPackerCompressionOptions
|
||||
): Promise<Uint8Array> {
|
||||
return runStream("gzip", "decompress", data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Common pump that pushes `data` through a `CompressionStream` /
|
||||
* `DecompressionStream` and concatenates the chunks. Threading a single
|
||||
* `Uint8Array` chunk avoids the back-pressure considerations the streams
|
||||
* API otherwise exposes (we have all the bytes in memory upfront, so a
|
||||
* one-shot write + close is the right shape).
|
||||
*/
|
||||
async function runStream(
|
||||
format: "gzip" | "deflate",
|
||||
direction: "compress" | "decompress",
|
||||
data: Uint8Array
|
||||
): Promise<Uint8Array> {
|
||||
const StreamCtor =
|
||||
direction === "compress"
|
||||
? (globalThis as { CompressionStream?: typeof CompressionStream })
|
||||
.CompressionStream
|
||||
: (
|
||||
globalThis as {
|
||||
DecompressionStream?: typeof DecompressionStream;
|
||||
}
|
||||
).DecompressionStream;
|
||||
if (StreamCtor === undefined) {
|
||||
throw new BitpackerError(
|
||||
direction === "compress"
|
||||
? BITPACKER_CODES.COMPRESSION_FAILED
|
||||
: BITPACKER_CODES.DECOMPRESSION_FAILED,
|
||||
`${format} ${direction} unavailable: host lacks CompressionStream/DecompressionStream`,
|
||||
{ context: { algorithm: format } }
|
||||
);
|
||||
}
|
||||
try {
|
||||
const stream = new StreamCtor(format);
|
||||
const writer = stream.writable.getWriter();
|
||||
// Force the data to be backed by an ArrayBuffer (not SharedArrayBuffer)
|
||||
// before passing to the streams API — required by the spec. Allocating
|
||||
// a fresh `Uint8Array(data)` always produces an ArrayBuffer-backed view.
|
||||
const payload = new Uint8Array(data) as Uint8Array<ArrayBuffer>;
|
||||
await writer.write(payload);
|
||||
await writer.close();
|
||||
|
||||
const chunks: Uint8Array[] = [];
|
||||
const reader = stream.readable.getReader();
|
||||
// Pump until the readable side reports done. Express the loop
|
||||
// condition via the read result so the reader's flag drives the
|
||||
// exit (avoids `while (true)` which biome flags as a constant
|
||||
// condition).
|
||||
for (
|
||||
let result = await reader.read();
|
||||
!result.done;
|
||||
result = await reader.read()
|
||||
) {
|
||||
chunks.push(result.value);
|
||||
}
|
||||
const totalLength = chunks.reduce((sum, c) => sum + c.length, 0);
|
||||
const out = new Uint8Array(totalLength);
|
||||
let offset = 0;
|
||||
for (const c of chunks) {
|
||||
out.set(c, offset);
|
||||
offset += c.length;
|
||||
}
|
||||
return out;
|
||||
} catch (cause) {
|
||||
throw new BitpackerError(
|
||||
direction === "compress"
|
||||
? BITPACKER_CODES.COMPRESSION_FAILED
|
||||
: BITPACKER_CODES.DECOMPRESSION_FAILED,
|
||||
`${format} ${direction} failed`,
|
||||
{
|
||||
cause: cause as Error,
|
||||
context: { algorithm: format },
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export { runStream as _runStreamForTests };
|
||||
@@ -0,0 +1,55 @@
|
||||
# NoCompressionAdapter
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`NoCompressionAdapter` is the pass-through codec used as the default when `CompressionConfig.algorithm` is `"none"` or omitted. It exists so that `compressData`, `decompressData`, and `CompressedBitPacker` can ALWAYS route through the compression pipeline without special-casing the "compression disabled" branch — the bytes flow through `compress` and `decompress` calls that resolve immediately with the input. This keeps the framing logic in `CompressedBitPacker` uniform: every payload has the same header shape regardless of whether real compression occurred.
|
||||
|
||||
## Features
|
||||
|
||||
- Pass-through `compress` / `decompress` — returns the input `Uint8Array` reference unchanged.
|
||||
- Drop-in `CompressionAdapter` implementation registered as `"none"` in the default registry.
|
||||
- Always available — does not depend on `CompressionStream` and never throws.
|
||||
- Default fallback when no algorithm is specified.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { NoCompressionAdapter } from "@suaveplan/bitpacker-compressed";
|
||||
|
||||
const none = new NoCompressionAdapter();
|
||||
const same = await none.compress(myBytes); // myBytes itself
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
compressData,
|
||||
defaultCompressionRegistry,
|
||||
} from "@suaveplan/bitpacker-compressed";
|
||||
|
||||
// "none" routes through the pipeline so framing stays uniform with
|
||||
// gzip / deflate. Useful in tests that want to assert framing is
|
||||
// orthogonal to the codec.
|
||||
const out = await compressData(payload, "none");
|
||||
const adapter = defaultCompressionRegistry().resolve("none");
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class NoCompressionAdapter implements CompressionAdapter`
|
||||
|
||||
- `readonly name: "none"`
|
||||
- `async compress(data: Uint8Array): Promise<Uint8Array>` — returns `data` unchanged.
|
||||
- `async decompress(data: Uint8Array): Promise<Uint8Array>` — returns `data` unchanged.
|
||||
|
||||
The `options` parameter is accepted for interface symmetry but ignored.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The returned reference is the SAME `Uint8Array` instance as the input — the adapter does not copy. Callers that need an independent buffer should `slice()` separately.
|
||||
- This adapter is unconditionally registered in `defaultCompressionRegistry()`. The other two defaults (`"gzip"`, `"deflate"`) are gated on `CompressionStream` availability; "none" always works.
|
||||
- Tests that need to prove "compression is opt-in" use this adapter to confirm the pipeline behaves identically when the caller chose to skip real compression.
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* `NoCompressionAdapter` — pass-through. Lets `CompressedBitPacker` always
|
||||
* route through the compression pipeline without special-casing the
|
||||
* "compression disabled" branch. Used as the default when
|
||||
* `CompressionConfig.algorithm` is `"none"` or omitted.
|
||||
*/
|
||||
|
||||
import type {
|
||||
BitPackerCompressionAlgorithm,
|
||||
BitPackerCompressionOptions,
|
||||
CompressionAdapter,
|
||||
} from "../../types/types.js";
|
||||
|
||||
export class NoCompressionAdapter implements CompressionAdapter {
|
||||
public readonly name: BitPackerCompressionAlgorithm = "none";
|
||||
|
||||
public async compress(
|
||||
data: Uint8Array,
|
||||
_options?: BitPackerCompressionOptions
|
||||
): Promise<Uint8Array> {
|
||||
return data;
|
||||
}
|
||||
|
||||
public async decompress(
|
||||
data: Uint8Array,
|
||||
_options?: BitPackerCompressionOptions
|
||||
): Promise<Uint8Array> {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
# compression API (`compressData` / `decompressData`)
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`compressData` and `decompressData` are the primary public surface of `@suaveplan/bitpacker-compressed`. They are algorithm-by-name helpers that route through a `CompressionRegistry` (defaulting to the process-wide singleton) so callers can switch between `"gzip"`, `"deflate"`, `"none"`, and any custom registered adapter without touching adapter classes directly. They are the layer that hot-path consumers like `CompressedBitPacker` use; it is also the layer that wraps codec failures in `BitpackerError` codes so application code never has to handle a raw `DOMException` or platform-specific stream error.
|
||||
|
||||
## Features
|
||||
|
||||
- Algorithm-by-name routing through a pluggable `CompressionRegistry`.
|
||||
- Coded errors: unknown algorithm → `UNKNOWN_COMPRESSION_ALGORITHM`, codec failure → `COMPRESSION_FAILED` / `DECOMPRESSION_FAILED` with the underlying error chained.
|
||||
- Optional registry parameter — pass a custom `CompressionRegistry` for isolation in tests.
|
||||
- Symmetric: `compressData(data, "gzip")` round-trips through `decompressData(bytes, "gzip")`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { compressData, decompressData } from "@suaveplan/bitpacker-compressed";
|
||||
|
||||
const data = new TextEncoder().encode("hello world".repeat(100));
|
||||
const compressed = await compressData(data, "gzip");
|
||||
const restored = await decompressData(compressed, "gzip");
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
compressData,
|
||||
createCompressionRegistry,
|
||||
} from "@suaveplan/bitpacker-compressed";
|
||||
|
||||
// Test isolation: create a fresh registry that ONLY has the "none" adapter
|
||||
// registered, then prove the unknown-algorithm path throws coded errors.
|
||||
const registry = createCompressionRegistry({ withDefaults: false });
|
||||
try {
|
||||
await compressData(data, "gzip", { registry });
|
||||
} catch (err) {
|
||||
// BitpackerError with code BITPACKER.UNKNOWN_COMPRESSION_ALGORITHM
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `compressData(data, algorithm, options?): Promise<Uint8Array>`
|
||||
|
||||
- `data: Uint8Array` — bytes to compress.
|
||||
- `algorithm: string` — registry key (e.g. `"gzip"`, `"deflate"`, `"none"`).
|
||||
- `options?: { registry?, level? }` — `registry` overrides the default singleton; `level` is forwarded to the adapter.
|
||||
- **Throws:** `BitpackerError(UNKNOWN_COMPRESSION_ALGORITHM)` or `BitpackerError(COMPRESSION_FAILED, { cause })`.
|
||||
|
||||
### `decompressData(data, algorithm, options?): Promise<Uint8Array>`
|
||||
|
||||
- Inverse of `compressData`. Throws `BitpackerError(DECOMPRESSION_FAILED, { cause })` on codec failure.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The default singleton is initialized lazily; it registers `"none"`, `"gzip"`, `"deflate"` only if the host supports `CompressionStream`.
|
||||
- Adapters are async because `CompressionStream` is async; `NoCompressionAdapter` returns immediately.
|
||||
- Hot-path callers should resolve the adapter once and call it directly to avoid the per-call registry lookup.
|
||||
@@ -0,0 +1,268 @@
|
||||
import { BITPACKER_CODES, BitpackerError } from "@suaveplan/bitpacker";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { NoCompressionAdapter } from "../adapters/none/none.js";
|
||||
import {
|
||||
type CompressionRegistry,
|
||||
createCompressionRegistry,
|
||||
} from "../registry/registry.js";
|
||||
import type { CompressionAdapter } from "../types/types.js";
|
||||
import { compressData, decompressData } from "./api.js";
|
||||
|
||||
describe("compressData / decompressData", () => {
|
||||
it("round-trip via 'none' returns original bytes", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const reg = createCompressionRegistry();
|
||||
const input = new Uint8Array([1, 2, 3, 4, 5]);
|
||||
const c = await compressData(input, "none", undefined, reg);
|
||||
const d = await decompressData(c, "none", undefined, reg);
|
||||
expect(d).toEqual(input);
|
||||
});
|
||||
|
||||
it("compressData throws UNKNOWN_COMPRESSION_ALGORITHM for unregistered algorithm", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const reg = createCompressionRegistry();
|
||||
await expect(
|
||||
compressData(new Uint8Array([1]), "zstd", undefined, reg)
|
||||
).rejects.toBeInstanceOf(BitpackerError);
|
||||
});
|
||||
|
||||
it("decompressData throws UNKNOWN_COMPRESSION_ALGORITHM for unregistered algorithm", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const reg = createCompressionRegistry();
|
||||
try {
|
||||
await decompressData(new Uint8Array([1]), "zstd", undefined, reg);
|
||||
} catch (e) {
|
||||
expect((e as BitpackerError).code).toBe(
|
||||
BITPACKER_CODES.UNKNOWN_COMPRESSION_ALGORITHM
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("compressData wraps non-Bitpacker codec errors as COMPRESSION_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// Custom adapter whose compress() throws a plain Error.
|
||||
class AngryAdapter implements CompressionAdapter {
|
||||
public readonly name = "angry";
|
||||
public compress(): Promise<Uint8Array> {
|
||||
return Promise.reject(new Error("boom"));
|
||||
}
|
||||
public decompress(): Promise<Uint8Array> {
|
||||
return Promise.reject(new Error("boom-d"));
|
||||
}
|
||||
}
|
||||
const reg: CompressionRegistry = createCompressionRegistry();
|
||||
reg.register(new AngryAdapter());
|
||||
try {
|
||||
await compressData(
|
||||
new Uint8Array([1, 2, 3]),
|
||||
"angry",
|
||||
undefined,
|
||||
reg
|
||||
);
|
||||
} catch (e) {
|
||||
expect((e as BitpackerError).code).toBe(
|
||||
BITPACKER_CODES.COMPRESSION_FAILED
|
||||
);
|
||||
expect((e as BitpackerError).context?.algorithm).toBe("angry");
|
||||
}
|
||||
});
|
||||
|
||||
it("decompressData wraps non-Bitpacker codec errors as DECOMPRESSION_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
class AngryAdapter implements CompressionAdapter {
|
||||
public readonly name = "angry";
|
||||
public compress(): Promise<Uint8Array> {
|
||||
return Promise.reject(new Error("boom"));
|
||||
}
|
||||
public decompress(): Promise<Uint8Array> {
|
||||
return Promise.reject(new Error("boom-d"));
|
||||
}
|
||||
}
|
||||
const reg: CompressionRegistry = createCompressionRegistry();
|
||||
reg.register(new AngryAdapter());
|
||||
try {
|
||||
await decompressData(
|
||||
new Uint8Array([1, 2, 3]),
|
||||
"angry",
|
||||
undefined,
|
||||
reg
|
||||
);
|
||||
} catch (e) {
|
||||
expect((e as BitpackerError).code).toBe(
|
||||
BITPACKER_CODES.DECOMPRESSION_FAILED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("passes through BitpackerError codes from adapter without re-wrap", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
class CodedAdapter implements CompressionAdapter {
|
||||
public readonly name = "coded";
|
||||
public compress(): Promise<Uint8Array> {
|
||||
return Promise.reject(
|
||||
new BitpackerError(
|
||||
BITPACKER_CODES.COMPRESSION_FAILED,
|
||||
"preserved",
|
||||
{ context: { sentinel: true } }
|
||||
)
|
||||
);
|
||||
}
|
||||
public decompress(): Promise<Uint8Array> {
|
||||
return this.compress();
|
||||
}
|
||||
}
|
||||
const reg = createCompressionRegistry();
|
||||
reg.register(new CodedAdapter());
|
||||
try {
|
||||
await compressData(new Uint8Array([1]), "coded", undefined, reg);
|
||||
} catch (e) {
|
||||
expect((e as BitpackerError).context?.sentinel).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("decompressData also passes BitpackerError through without re-wrap", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
class CodedAdapter implements CompressionAdapter {
|
||||
public readonly name = "coded-d";
|
||||
public compress(): Promise<Uint8Array> {
|
||||
return Promise.reject(
|
||||
new BitpackerError(
|
||||
BITPACKER_CODES.DECOMPRESSION_FAILED,
|
||||
"preserved-d",
|
||||
{ context: { sentinel: "decompress" } }
|
||||
)
|
||||
);
|
||||
}
|
||||
public decompress(): Promise<Uint8Array> {
|
||||
return this.compress();
|
||||
}
|
||||
}
|
||||
const reg = createCompressionRegistry();
|
||||
reg.register(new CodedAdapter());
|
||||
try {
|
||||
await decompressData(
|
||||
new Uint8Array([1]),
|
||||
"coded-d",
|
||||
undefined,
|
||||
reg
|
||||
);
|
||||
} catch (e) {
|
||||
expect((e as BitpackerError).context?.sentinel).toBe("decompress");
|
||||
}
|
||||
});
|
||||
|
||||
it("uses defaultCompressionRegistry when registry omitted", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// Default registry always has 'none'.
|
||||
const input = new Uint8Array([7, 7, 7]);
|
||||
const out = await compressData(input, "none");
|
||||
expect(out).toEqual(input);
|
||||
});
|
||||
|
||||
it("decompressData also defaults the registry argument", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const input = new Uint8Array([9, 9, 9]);
|
||||
const out = await decompressData(input, "none");
|
||||
expect(out).toEqual(input);
|
||||
});
|
||||
|
||||
it("options arg is forwarded to adapter", { timeout: 5000 }, async () => {
|
||||
let observed: unknown;
|
||||
class ObservingAdapter implements CompressionAdapter {
|
||||
public readonly name = "observe";
|
||||
public async compress(
|
||||
data: Uint8Array,
|
||||
options?: Record<string, unknown>
|
||||
): Promise<Uint8Array> {
|
||||
observed = options;
|
||||
return data;
|
||||
}
|
||||
public async decompress(data: Uint8Array): Promise<Uint8Array> {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
const reg = createCompressionRegistry();
|
||||
reg.register(new ObservingAdapter());
|
||||
await compressData(new Uint8Array([1]), "observe", { level: 9 }, reg);
|
||||
expect(observed).toEqual({ level: 9 });
|
||||
});
|
||||
|
||||
it("smoke-tests NoCompressionAdapter", { timeout: 5000 }, () => {
|
||||
// Coverage anchor for the NoCompressionAdapter constructor.
|
||||
const a = new NoCompressionAdapter();
|
||||
expect(a.name).toBe("none");
|
||||
});
|
||||
|
||||
it("compressData wraps non-Error throws as COMPRESSION_FAILED with String() cause", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// Adapter rejects with a non-Error value (string). The api wrapper
|
||||
// must coerce via `new Error(String(cause))` to satisfy the
|
||||
// BitpackerError cause contract.
|
||||
class StringThrower implements CompressionAdapter {
|
||||
public readonly name = "string-thrower";
|
||||
public compress(): Promise<Uint8Array> {
|
||||
return Promise.reject("boom-string" as unknown as Error);
|
||||
}
|
||||
public decompress(): Promise<Uint8Array> {
|
||||
return Promise.reject("boom-string-d" as unknown as Error);
|
||||
}
|
||||
}
|
||||
const reg = createCompressionRegistry();
|
||||
reg.register(new StringThrower());
|
||||
try {
|
||||
await compressData(
|
||||
new Uint8Array([1]),
|
||||
"string-thrower",
|
||||
undefined,
|
||||
reg
|
||||
);
|
||||
} catch (e) {
|
||||
expect((e as BitpackerError).code).toBe(
|
||||
BITPACKER_CODES.COMPRESSION_FAILED
|
||||
);
|
||||
expect((e as BitpackerError).cause).toBeInstanceOf(Error);
|
||||
expect(((e as BitpackerError).cause as Error).message).toBe(
|
||||
"boom-string"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("decompressData wraps non-Error throws as DECOMPRESSION_FAILED with String() cause", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
class StringThrower implements CompressionAdapter {
|
||||
public readonly name = "string-thrower-d";
|
||||
public compress(): Promise<Uint8Array> {
|
||||
return Promise.reject("boom-string" as unknown as Error);
|
||||
}
|
||||
public decompress(): Promise<Uint8Array> {
|
||||
return Promise.reject(42 as unknown as Error);
|
||||
}
|
||||
}
|
||||
const reg = createCompressionRegistry();
|
||||
reg.register(new StringThrower());
|
||||
try {
|
||||
await decompressData(
|
||||
new Uint8Array([1]),
|
||||
"string-thrower-d",
|
||||
undefined,
|
||||
reg
|
||||
);
|
||||
} catch (e) {
|
||||
expect((e as BitpackerError).code).toBe(
|
||||
BITPACKER_CODES.DECOMPRESSION_FAILED
|
||||
);
|
||||
expect(((e as BitpackerError).cause as Error).message).toBe("42");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* `compressData` / `decompressData` — algorithm-by-name helpers backed by a
|
||||
* `CompressionRegistry`. Mirrors upstream `compression.ts::compressData` /
|
||||
* `decompressData` but routes through coded errors and a caller-supplied
|
||||
* registry (defaults to the process-wide singleton).
|
||||
*
|
||||
* Hot-path consumers (`CompressedBitPacker`) use these directly. They
|
||||
* throw `BitpackerError(UNKNOWN_COMPRESSION_ALGORITHM)` on missing adapter
|
||||
* and wrap codec failures as `BitpackerError(COMPRESSION_FAILED)` /
|
||||
* `BitpackerError(DECOMPRESSION_FAILED)` with the underlying error chained.
|
||||
*/
|
||||
|
||||
import { BITPACKER_CODES, BitpackerError } from "@suaveplan/bitpacker";
|
||||
import {
|
||||
type CompressionRegistry,
|
||||
defaultCompressionRegistry,
|
||||
} from "../registry/registry.js";
|
||||
import type {
|
||||
BitPackerCompressionAlgorithm,
|
||||
BitPackerCompressionOptions,
|
||||
} from "../types/types.js";
|
||||
|
||||
export async function compressData(
|
||||
data: Uint8Array,
|
||||
algorithm: BitPackerCompressionAlgorithm,
|
||||
options?: BitPackerCompressionOptions,
|
||||
registry: CompressionRegistry = defaultCompressionRegistry()
|
||||
): Promise<Uint8Array> {
|
||||
const adapter = registry.resolve(algorithm);
|
||||
try {
|
||||
return await adapter.compress(data, options);
|
||||
} catch (cause) {
|
||||
if (cause instanceof BitpackerError) {
|
||||
// Adapter already raised a coded error (e.g. unsupported host).
|
||||
throw cause;
|
||||
}
|
||||
throw new BitpackerError(
|
||||
BITPACKER_CODES.COMPRESSION_FAILED,
|
||||
`Compression with algorithm "${algorithm}" failed`,
|
||||
{
|
||||
cause:
|
||||
cause instanceof Error ? cause : new Error(String(cause)),
|
||||
context: { algorithm },
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function decompressData(
|
||||
data: Uint8Array,
|
||||
algorithm: BitPackerCompressionAlgorithm,
|
||||
options?: BitPackerCompressionOptions,
|
||||
registry: CompressionRegistry = defaultCompressionRegistry()
|
||||
): Promise<Uint8Array> {
|
||||
const adapter = registry.resolve(algorithm);
|
||||
try {
|
||||
return await adapter.decompress(data, options);
|
||||
} catch (cause) {
|
||||
if (cause instanceof BitpackerError) {
|
||||
throw cause;
|
||||
}
|
||||
throw new BitpackerError(
|
||||
BITPACKER_CODES.DECOMPRESSION_FAILED,
|
||||
`Decompression with algorithm "${algorithm}" failed`,
|
||||
{
|
||||
cause:
|
||||
cause instanceof Error ? cause : new Error(String(cause)),
|
||||
context: { algorithm },
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
# CompressedBitPacker
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs --> - compressed-bit-packer.schema — Zod schema for `Comp…
|
||||
- [compressed-bit-packer.schema](./compressed-bit-packer.schema.md) — Zod schema for `CompressedBitPackerOptions`, the constructor options bundle accepted by `CompressedBitPacker`. Validate…
|
||||
- compressed-bit-packer.schema — Zod schema for `Comp…
|
||||
|
||||
- [compressed-bit-packer.schema](./compressed-bit-packer.schema.md) — Zod schema for `CompressedBitPackerOptions`, the constructor options bundle accepted by `CompressedBitPacker`. Validate…
|
||||
|
||||
- compressed-bit-packer.schema — Zod schema for `Comp…
|
||||
|
||||
- [compressed-bit-packer.schema](./compressed-bit-packer.schema.md) — Zod schema for `CompressedBitPackerOptions`, the constructor options bundle accepted by `CompressedBitPacker`. Validate…
|
||||
|
||||
- compressed-bit-packer.schema — Zod schema for `Comp…
|
||||
|
||||
- [compressed-bit-packer.schema](./compressed-bit-packer.schema.md) — Zod schema for `CompressedBitPackerOptions`, the constructor options bundle accepted by `CompressedBitPacker`. Validate…
|
||||
- [compressed-bit-packer.schema](./compressed-bit-packer.schema.md) — Zod schema for `CompressedBitPackerOptions`, the constructor options bundle accepted by `CompressedBitPacker`. Validate…
|
||||
|
||||
## Purpose
|
||||
|
||||
`CompressedBitPacker<T>` extends `@suaveplan/bitpacker::BitPacker<T>` to add a configurable compression stage between bit-packing and the wire bytes. The compressed bytes are framed with a self-describing algorithm header so a receiver can decompress without knowing the sender's algorithm choice in advance — the byte stream itself names the codec.
|
||||
|
||||
This is the port of upstream `bitpacker/src/compressed/bitpacker.ts`. The port keeps the upstream wire shape (`[headerLen: u8][header: ASCII algorithm][compressed payload]`) so any consumer carrying upstream-encoded bytes round-trips unchanged. Hardening over upstream:
|
||||
|
||||
- Plain `Error` throws → `BitpackerError` with stable codes (`UNKNOWN_COMPRESSION_ALGORITHM`, `COMPRESSION_FAILED`, `DECOMPRESSION_FAILED`, `COMPRESSED_BAD_HEADER`).
|
||||
- The synchronous `pack`/`unpack` overrides from upstream — which threw when compression was enabled — are dropped. Consumers call the explicit async `packWithCompression` / `unpackWithDecompression` methods. This separates the sync no-compression hot path from the async compressed path cleanly.
|
||||
- Adapter resolution goes through the injected `CompressionRegistry`, not a private static singleton.
|
||||
- No `EventEmitter`. suaveplan's `BitPacker` does not extend an emitter; observability is OTel-driven via `bitpacker-compressed.*` spans.
|
||||
|
||||
## Features
|
||||
|
||||
- `packWithCompression(data, schema?)` — bit-pack, compress, frame.
|
||||
- `unpackWithDecompression(data, schema?)` — read frame, decompress, bit-unpack.
|
||||
- Configurable algorithm + options + enabled flag via constructor `compression` config or runtime accessors.
|
||||
- Self-describing wire format: receiver dispatches to the named algorithm without prior knowledge.
|
||||
- Synchronous `pack`/`unpack` from `BitPacker` remain for the no-compression case (no header added — bytes are exactly what `BitPacker.pack` produces).
|
||||
- Pluggable `CompressionRegistry` via constructor option (defaults to the process-wide singleton).
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { CompressedBitPacker } from "@suaveplan/bitpacker-compressed";
|
||||
|
||||
const schema = { x: 255, y: 255 } as const;
|
||||
const packer = new CompressedBitPacker(schema);
|
||||
|
||||
// Compression disabled (default) — frames with "none" header
|
||||
const bytes = await packer.packWithCompression({ x: 42, y: 99 });
|
||||
const decoded = await packer.unpackWithDecompression(bytes);
|
||||
// → { x: 42, y: 99 }
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import {
|
||||
CompressedBitPacker,
|
||||
createCompressionRegistry,
|
||||
} from "@suaveplan/bitpacker-compressed";
|
||||
|
||||
// Custom registry isolated from process-wide singleton, useful for tests
|
||||
// or multi-tenant servers where adapter sets must not leak.
|
||||
const registry = createCompressionRegistry();
|
||||
// Register a custom adapter (e.g. brotli) into this isolated registry.
|
||||
registry.register(new MyBrotliAdapter());
|
||||
|
||||
const packer = new CompressedBitPacker(
|
||||
{
|
||||
position: { x: "f32", y: "f32", z: "f32" },
|
||||
velocity: { x: "f32", y: "f32", z: "f32" },
|
||||
},
|
||||
{
|
||||
compression: {
|
||||
algorithm: "brotli",
|
||||
enabled: true,
|
||||
options: { quality: 11 },
|
||||
},
|
||||
compressionRegistry: registry,
|
||||
}
|
||||
);
|
||||
|
||||
const wireBytes = await packer.packWithCompression({
|
||||
position: { x: 1, y: 2, z: 3 },
|
||||
velocity: { x: 0.1, y: 0, z: 0 },
|
||||
});
|
||||
// wireBytes is [headerLen=6][b][r][o][t][l][i][brotli-compressed bit-packed payload]
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `new CompressedBitPacker<T>(schema, options?)`
|
||||
|
||||
`schema` — a `BitPackerSchema` describing the data shape.
|
||||
`options.compression?` — `{ algorithm?, options?, enabled? }`. Default is `{ algorithm: "none", options: {}, enabled: false }`.
|
||||
`options.compressionRegistry?` — optional `CompressionRegistry` override. Defaults to `defaultCompressionRegistry()`.
|
||||
|
||||
### `packWithCompression(data, schema?) → Promise<Uint8Array>`
|
||||
|
||||
Bit-packs `data`, runs the bytes through the configured compression adapter (or `NoCompressionAdapter` if `enabled` is false or `algorithm` is `"none"`), and frames the result with `[headerLen: u8][header: ASCII algorithm name][payload]`. Returns the framed wire bytes.
|
||||
|
||||
### `unpackWithDecompression(data?, schema?) → Promise<unknown>`
|
||||
|
||||
Reverse of `packWithCompression`. Reads the framing header, dispatches to the named adapter for decompression (or routes the body through unchanged if the header is `"none"`), then bit-unpacks the result. If `data` is `undefined` or empty, falls back to `super.unpack(empty)` for parity with upstream.
|
||||
|
||||
### Accessors
|
||||
|
||||
`compressionAlgorithm` / `compressionOptions` / `compressionEnabled` — getters and setters for the runtime configuration. Allow callers to switch algorithms or toggle compression mid-flight.
|
||||
|
||||
### Inherited from `BitPacker`
|
||||
|
||||
Synchronous `pack(data, schema?) → Uint8Array` and `unpack(bytes, schema?) → T` remain available. They produce/consume bytes that are NOT framed (no `[headerLen][header]` prefix) — use them when compression is genuinely off and you don't want the framing overhead.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The wire format's `headerLen` byte limits algorithm names to 1–255 ASCII bytes. The four shipping defaults (`none`, `gzip`, `deflate`, plus future `brotli`/`zstd`) are all well under that. If a future custom algorithm name exceeds the limit, register the adapter under a shorter alias.
|
||||
|
||||
The synchronous `pack`/`unpack` from `BitPacker` are NOT overridden here. The upstream version overrode them but threw an error when compression was enabled — that mixed sync and async expectations confusingly. suaveplan splits cleanly: callers wanting compression call the async methods; callers OK with no compression use the inherited sync methods.
|
||||
|
||||
`CompressionRegistry` is injectable so tests can use isolated registries (`createCompressionRegistry()`) without polluting the process-wide singleton. The lazy registration of `gzip`/`deflate` on the singleton (when `CompressionStream` is available) means the singleton is environment-aware without forcing every consumer through the streams API path.
|
||||
|
||||
The `compressed-bit-packer.test.ts` test file uses `vitest.skipIf(!hasStreams)` to skip stream-backed tests in environments without `CompressionStream`. The frame/unframe helpers are tested directly via `_frameForTests` / `_unframeForTests` so the wire format is locked even in non-streaming environments.
|
||||
@@ -0,0 +1,65 @@
|
||||
# Compressed BitPacker Options Schema
|
||||
|
||||
## Purpose
|
||||
|
||||
Zod schema for `CompressedBitPackerOptions`, the constructor options bundle accepted by `CompressedBitPacker`. Validates the shape at the package boundary so a typo in the algorithm name (`alogrithm`), a non-`CompressionRegistry` value passed as the registry (a plain object that "quacks like" one), or a stray unknown key all fail at parse time rather than producing a packer that silently runs without the consumer's intended compression configuration.
|
||||
|
||||
The schema composes `compressionConfigSchema` for the `compression` field and uses an `instanceof` check for `compressionRegistry`. Identity of the registry is preserved so the constructor receives the same instance the caller built.
|
||||
|
||||
## Features
|
||||
|
||||
- `compressedBitPackerOptionsSchema` — strict, two optional fields (`compression`, `compressionRegistry`)
|
||||
- Composes `compressionConfigSchema` for nested validation
|
||||
- `instanceof CompressionRegistry` check with identity preservation
|
||||
- `as unknown as z.ZodType<T>` cast for the EOPT mismatch
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
CompressedBitPacker,
|
||||
compressedBitPackerOptionsSchema,
|
||||
} from "@suaveplan/bitpacker-compressed";
|
||||
|
||||
const opts = compressedBitPackerOptionsSchema.parse({
|
||||
compression: { algorithm: "gzip", enabled: true },
|
||||
});
|
||||
const packer = new CompressedBitPacker({ x: 255 }, opts);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
CompressedBitPacker,
|
||||
compressedBitPackerOptionsSchema,
|
||||
createCompressionRegistry,
|
||||
GzipCompressionAdapter,
|
||||
} from "@suaveplan/bitpacker-compressed";
|
||||
|
||||
const registry = createCompressionRegistry();
|
||||
registry.register(new GzipCompressionAdapter());
|
||||
|
||||
const opts = compressedBitPackerOptionsSchema.parse({
|
||||
compression: {
|
||||
algorithm: "gzip",
|
||||
options: { level: 9 },
|
||||
enabled: true,
|
||||
},
|
||||
compressionRegistry: registry,
|
||||
});
|
||||
|
||||
const packer = new CompressedBitPacker({ x: 255 }, opts);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `compressedBitPackerOptionsSchema: z.ZodType<CompressedBitPackerOptions>`
|
||||
|
||||
Strict schema. `compression` is an optional `CompressionConfig` (validated by `compressionConfigSchema`). `compressionRegistry` is an optional `CompressionRegistry` instance (validated by `instanceof` with identity preservation).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- **Composition over duplication.** The nested `compression` field is validated by `compressionConfigSchema` from the same package. Composing rather than inlining the shape keeps the per-field error messages consistent across all entry points that accept a `CompressionConfig`.
|
||||
- **`instanceof` over duck-typing.** A duck-typed registry would compile but would not pass the prototype-aware checks the registry's lookup methods perform internally. Refusing duck-types prevents an entire class of "registry doesn't find my adapter" bugs.
|
||||
- **`.strict()` is non-negotiable.** Same rationale as elsewhere in this change: typo'd flags would otherwise silently default and hide configuration bugs.
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { defaultCompressionRegistry } from "../registry/registry.js";
|
||||
import { compressedBitPackerOptionsSchema } from "./compressed-bit-packer.schema.js";
|
||||
|
||||
describe("compressedBitPackerOptionsSchema", () => {
|
||||
it("accepts an empty options bundle", () => {
|
||||
expect(compressedBitPackerOptionsSchema.parse({})).toEqual({});
|
||||
});
|
||||
|
||||
it("accepts a populated compression config", () => {
|
||||
const opts = {
|
||||
compression: { algorithm: "gzip", enabled: true },
|
||||
};
|
||||
expect(compressedBitPackerOptionsSchema.parse(opts)).toEqual(opts);
|
||||
});
|
||||
|
||||
it("preserves the registry identity", () => {
|
||||
const registry = defaultCompressionRegistry();
|
||||
const parsed = compressedBitPackerOptionsSchema.parse({
|
||||
compressionRegistry: registry,
|
||||
});
|
||||
expect(parsed.compressionRegistry).toBe(registry);
|
||||
});
|
||||
|
||||
it("rejects a non-CompressionRegistry value", () => {
|
||||
expect(() =>
|
||||
compressedBitPackerOptionsSchema.parse({
|
||||
compressionRegistry: { register() {} },
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects unknown keys", () => {
|
||||
expect(() =>
|
||||
compressedBitPackerOptionsSchema.parse({ bogus: true })
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import z from "zod";
|
||||
import { CompressionRegistry } from "../registry/registry.js";
|
||||
import { compressionConfigSchema } from "../types/types.schema.js";
|
||||
import type { CompressedBitPackerOptions } from "./compressed-bit-packer.js";
|
||||
|
||||
const compressionRegistrySchema = z.custom<CompressionRegistry>(
|
||||
(v): v is CompressionRegistry => v instanceof CompressionRegistry,
|
||||
{ message: "compressionRegistry must be a CompressionRegistry instance" }
|
||||
);
|
||||
|
||||
/**
|
||||
* Strict Zod schema for {@link CompressedBitPackerOptions}.
|
||||
*
|
||||
* Validates the constructor options for `CompressedBitPacker`. Composes the
|
||||
* `compressionConfigSchema` for the `compression` field and an `instanceof`
|
||||
* check on the optional `compressionRegistry`.
|
||||
*/
|
||||
export const compressedBitPackerOptionsSchema = z
|
||||
.object({
|
||||
compression: compressionConfigSchema.optional(),
|
||||
compressionRegistry: compressionRegistrySchema.optional(),
|
||||
})
|
||||
.strict() as unknown as z.ZodType<CompressedBitPackerOptions>;
|
||||
@@ -0,0 +1,202 @@
|
||||
import { BITPACKER_CODES, BitpackerError } from "@suaveplan/bitpacker";
|
||||
import { registerDefaultStrategies } from "@suaveplan/bitpacker-strategies";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
_frameForTests,
|
||||
_unframeForTests,
|
||||
CompressedBitPacker,
|
||||
} from "./compressed-bit-packer.js";
|
||||
|
||||
const SIMPLE_SCHEMA = { x: 255, y: 255 } as const;
|
||||
type SimpleData = { x: number; y: number };
|
||||
|
||||
describe("CompressedBitPacker (compression disabled)", () => {
|
||||
it("packWithCompression frames with 'none' header", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const packer = new CompressedBitPacker(SIMPLE_SCHEMA);
|
||||
registerDefaultStrategies(packer.strategyManager);
|
||||
const bytes = await packer.packWithCompression({
|
||||
x: 1,
|
||||
y: 2,
|
||||
} as SimpleData);
|
||||
// Header: [4 (len)][n][o][n][e][...packed bytes]
|
||||
expect(bytes[0]).toBe(4);
|
||||
expect(new TextDecoder().decode(bytes.slice(1, 5))).toBe("none");
|
||||
});
|
||||
|
||||
it("round-trip via packWithCompression / unpackWithDecompression", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const packer = new CompressedBitPacker(SIMPLE_SCHEMA);
|
||||
registerDefaultStrategies(packer.strategyManager);
|
||||
const original: SimpleData = { x: 100, y: 200 };
|
||||
const bytes = await packer.packWithCompression(original);
|
||||
const decoded = (await packer.unpackWithDecompression(
|
||||
bytes
|
||||
)) as SimpleData;
|
||||
expect(decoded).toEqual(original);
|
||||
});
|
||||
|
||||
it("synchronous super.pack still works (no header added)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const packer = new CompressedBitPacker(SIMPLE_SCHEMA);
|
||||
registerDefaultStrategies(packer.strategyManager);
|
||||
const bytes = packer.pack({ x: 7, y: 8 } as SimpleData);
|
||||
// The synchronous path returns BitPacker.pack output directly,
|
||||
// no [headerLen] prefix.
|
||||
expect(bytes[0]).not.toBe(4);
|
||||
expect(packer.unpack(bytes)).toEqual({ x: 7, y: 8 });
|
||||
});
|
||||
|
||||
it("compressionEnabled / Algorithm / Options accessors round-trip", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const packer = new CompressedBitPacker(SIMPLE_SCHEMA);
|
||||
expect(packer.compressionEnabled).toBe(false);
|
||||
expect(packer.compressionAlgorithm).toBe("none");
|
||||
expect(packer.compressionOptions).toEqual({});
|
||||
packer.compressionEnabled = true;
|
||||
packer.compressionAlgorithm = "gzip";
|
||||
packer.compressionOptions = { level: 9 };
|
||||
expect(packer.compressionEnabled).toBe(true);
|
||||
expect(packer.compressionAlgorithm).toBe("gzip");
|
||||
expect(packer.compressionOptions).toEqual({ level: 9 });
|
||||
});
|
||||
|
||||
it("constructor honours compression config", { timeout: 5000 }, () => {
|
||||
const packer = new CompressedBitPacker(SIMPLE_SCHEMA, {
|
||||
compression: {
|
||||
algorithm: "deflate",
|
||||
options: { level: 6 },
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
expect(packer.compressionAlgorithm).toBe("deflate");
|
||||
expect(packer.compressionOptions).toEqual({ level: 6 });
|
||||
expect(packer.compressionEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it("constructor with partial compression config defaults missing fields", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// No `algorithm` — should default to "none". No `options` — should
|
||||
// default to {}. `enabled` omitted — should default to true (since
|
||||
// the user passed compression at all, the !== false rule kicks in).
|
||||
const packer = new CompressedBitPacker(SIMPLE_SCHEMA, {
|
||||
compression: {},
|
||||
});
|
||||
expect(packer.compressionAlgorithm).toBe("none");
|
||||
expect(packer.compressionOptions).toEqual({});
|
||||
expect(packer.compressionEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it("unpackWithDecompression on undefined takes the fallback (no unframe)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const packer = new CompressedBitPacker({ x: 255 } as const);
|
||||
registerDefaultStrategies(packer.strategyManager);
|
||||
// undefined data must skip the unframe path. Genesis BitPacker.unpack
|
||||
// will itself throw on a 0-byte buffer when a field expects bits,
|
||||
// but the throw must NOT be a framing error — that proves we took
|
||||
// the empty-buffer fallback branch.
|
||||
try {
|
||||
await packer.unpackWithDecompression(undefined);
|
||||
// If genesis ever softens unpack-on-empty, getting here is fine too.
|
||||
} catch (e) {
|
||||
expect((e as BitpackerError).code).not.toBe(
|
||||
BITPACKER_CODES.COMPRESSED_BAD_HEADER
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const hasStreams = typeof CompressionStream !== "undefined";
|
||||
|
||||
describe.skipIf(!hasStreams)(
|
||||
"CompressedBitPacker with stream-backed gzip",
|
||||
() => {
|
||||
it("round-trip with gzip enabled", { timeout: 10000 }, async () => {
|
||||
// Default registry lazy-registers gzip when CompressionStream
|
||||
// is available. Wait one microtask cycle for the lazy import
|
||||
// promise to resolve before exercising compression.
|
||||
const packer = new CompressedBitPacker(SIMPLE_SCHEMA, {
|
||||
compression: {
|
||||
algorithm: "gzip",
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
registerDefaultStrategies(packer.strategyManager);
|
||||
// Give the lazy registration a chance to settle.
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
const original: SimpleData = { x: 42, y: 99 };
|
||||
const bytes = await packer.packWithCompression(original);
|
||||
// Header: [4][g][z][i][p][compressed payload]
|
||||
expect(bytes[0]).toBe(4);
|
||||
expect(new TextDecoder().decode(bytes.slice(1, 5))).toBe("gzip");
|
||||
const decoded = (await packer.unpackWithDecompression(
|
||||
bytes
|
||||
)) as SimpleData;
|
||||
expect(decoded).toEqual(original);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
describe("frame / unframe helpers", () => {
|
||||
it("frame round-trips through unframe", { timeout: 5000 }, () => {
|
||||
const payload = new Uint8Array([10, 20, 30]);
|
||||
const framed = _frameForTests("gzip", payload);
|
||||
const { algorithm, payload: out } = _unframeForTests(framed);
|
||||
expect(algorithm).toBe("gzip");
|
||||
expect(out).toEqual(payload);
|
||||
});
|
||||
|
||||
it("frame rejects empty algorithm name with COMPRESSED_BAD_HEADER", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
_frameForTests("", new Uint8Array([1]));
|
||||
} catch (e) {
|
||||
expect((e as BitpackerError).code).toBe(
|
||||
BITPACKER_CODES.COMPRESSED_BAD_HEADER
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("frame rejects algorithm name longer than 255 bytes", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const longName = "x".repeat(256);
|
||||
expect(() => _frameForTests(longName, new Uint8Array([1]))).toThrow(
|
||||
BitpackerError
|
||||
);
|
||||
});
|
||||
|
||||
it("unframe rejects bytes shorter than 2", { timeout: 5000 }, () => {
|
||||
try {
|
||||
_unframeForTests(new Uint8Array([1]));
|
||||
} catch (e) {
|
||||
expect((e as BitpackerError).code).toBe(
|
||||
BITPACKER_CODES.COMPRESSED_BAD_HEADER
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("unframe rejects header length out of range", { timeout: 5000 }, () => {
|
||||
// headerLen = 5, but only 3 bytes follow:
|
||||
const bad = new Uint8Array([5, 0x67, 0x7a, 0x00]);
|
||||
try {
|
||||
_unframeForTests(bad);
|
||||
} catch (e) {
|
||||
expect((e as BitpackerError).code).toBe(
|
||||
BITPACKER_CODES.COMPRESSED_BAD_HEADER
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("unframe rejects zero header length", { timeout: 5000 }, () => {
|
||||
const bad = new Uint8Array([0, 0x67, 0x7a]);
|
||||
expect(() => _unframeForTests(bad)).toThrow(BitpackerError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* `CompressedBitPacker<T>` extends `BitPacker<T>` to add a compress-and-frame
|
||||
* stage between bit-packing and the wire bytes. The wire format is:
|
||||
*
|
||||
* [headerLen: u8][header: ASCII algorithm name][compressed payload]
|
||||
*
|
||||
* The header makes the wire payload self-describing: a receiver can call
|
||||
* `unpackWithDecompression` without knowing the sender's algorithm, and the
|
||||
* "no compression" case is encoded via the `"none"` header so the parsing
|
||||
* path doesn't need to special-case the disabled state.
|
||||
*
|
||||
* Genesis port of upstream `bitpacker/src/compressed/bitpacker.ts`. Genesis-side
|
||||
* adjustments:
|
||||
*
|
||||
* - The synchronous `pack`/`unpack` methods are NOT overridden. Compression
|
||||
* is async; consumers must call the async `packWithCompression` /
|
||||
* `unpackWithDecompression` methods explicitly. The parent's sync methods
|
||||
* remain available for the no-compression hot path (no header added in
|
||||
* that case — bytes are exactly what `BitPacker.pack` produced).
|
||||
* - Plain `Error` throws → `BitpackerError` with stable codes.
|
||||
* - Adapter resolution goes through the injected `CompressionRegistry`,
|
||||
* not a private static singleton.
|
||||
* - No `EventEmitter` events. Genesis's `BitPacker` does not extend an
|
||||
* emitter; consumers wanting observability use OTel via the package's
|
||||
* `bitpacker-compressed.*` spans.
|
||||
*/
|
||||
|
||||
import {
|
||||
BITPACKER_CODES,
|
||||
BitPacker,
|
||||
type BitPackerSchema,
|
||||
BitpackerError,
|
||||
} from "@suaveplan/bitpacker";
|
||||
import { compressData, decompressData } from "../api/api.js";
|
||||
import {
|
||||
type CompressionRegistry,
|
||||
defaultCompressionRegistry,
|
||||
} from "../registry/registry.js";
|
||||
import type {
|
||||
BitPackerCompressionAlgorithm,
|
||||
BitPackerCompressionOptions,
|
||||
CompressionConfig,
|
||||
} from "../types/types.js";
|
||||
|
||||
export interface CompressedBitPackerOptions {
|
||||
/** Compression configuration; defaults to disabled. */
|
||||
readonly compression?: CompressionConfig;
|
||||
/** Adapter registry; defaults to `defaultCompressionRegistry()`. */
|
||||
readonly compressionRegistry?: CompressionRegistry;
|
||||
}
|
||||
|
||||
export class CompressedBitPacker<
|
||||
T extends BitPackerSchema = BitPackerSchema,
|
||||
> extends BitPacker<T> {
|
||||
private _compressionAlgorithm: BitPackerCompressionAlgorithm = "none";
|
||||
private _compressionOptions: BitPackerCompressionOptions = {};
|
||||
private _compressionEnabled = false;
|
||||
private readonly compressionRegistry: CompressionRegistry;
|
||||
|
||||
public constructor(
|
||||
schema: BitPackerSchema,
|
||||
options?: CompressedBitPackerOptions
|
||||
) {
|
||||
super(schema);
|
||||
if (options?.compression) {
|
||||
this._compressionAlgorithm =
|
||||
options.compression.algorithm ?? "none";
|
||||
this._compressionOptions = options.compression.options ?? {};
|
||||
this._compressionEnabled = options.compression.enabled !== false;
|
||||
}
|
||||
this.compressionRegistry =
|
||||
options?.compressionRegistry ?? defaultCompressionRegistry();
|
||||
}
|
||||
|
||||
public get compressionAlgorithm(): BitPackerCompressionAlgorithm {
|
||||
return this._compressionAlgorithm;
|
||||
}
|
||||
|
||||
public set compressionAlgorithm(algorithm: BitPackerCompressionAlgorithm) {
|
||||
this._compressionAlgorithm = algorithm;
|
||||
}
|
||||
|
||||
public get compressionOptions(): BitPackerCompressionOptions {
|
||||
return this._compressionOptions;
|
||||
}
|
||||
|
||||
public set compressionOptions(options: BitPackerCompressionOptions) {
|
||||
this._compressionOptions = options;
|
||||
}
|
||||
|
||||
public get compressionEnabled(): boolean {
|
||||
return this._compressionEnabled;
|
||||
}
|
||||
|
||||
public set compressionEnabled(enabled: boolean) {
|
||||
this._compressionEnabled = enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pack `data` to bytes, compress through the configured adapter (if
|
||||
* enabled and not `"none"`), and frame with `[headerLen][algo][payload]`.
|
||||
* The frame is what consumers send on the wire; receivers call
|
||||
* `unpackWithDecompression` to reverse.
|
||||
*/
|
||||
public async packWithCompression(
|
||||
data: unknown,
|
||||
schema?: BitPackerSchema
|
||||
): Promise<Uint8Array> {
|
||||
const packed = super.pack(data, schema);
|
||||
const algorithm =
|
||||
this._compressionEnabled && this._compressionAlgorithm !== "none"
|
||||
? this._compressionAlgorithm
|
||||
: "none";
|
||||
|
||||
let payload: Uint8Array;
|
||||
if (algorithm === "none") {
|
||||
payload = packed;
|
||||
} else {
|
||||
payload = await compressData(
|
||||
packed,
|
||||
algorithm,
|
||||
this._compressionOptions,
|
||||
this.compressionRegistry
|
||||
);
|
||||
}
|
||||
return frame(algorithm, payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse of `packWithCompression`. Reads the framing header, dispatches
|
||||
* to the named adapter for decompression (or routes the body straight
|
||||
* through if the header is `"none"`), then bit-unpacks the result.
|
||||
*
|
||||
* If `data` is empty, returns the result of unpacking an empty buffer
|
||||
* via the parent class (handles the "no-data" idiom upstream callers
|
||||
* relied on).
|
||||
*/
|
||||
public async unpackWithDecompression(
|
||||
data?: Uint8Array,
|
||||
schema?: BitPackerSchema
|
||||
): Promise<unknown> {
|
||||
if (data === undefined || data.length === 0) {
|
||||
return super.unpack(data ?? new Uint8Array(0), schema);
|
||||
}
|
||||
const { algorithm, payload } = unframe(data);
|
||||
const decompressed =
|
||||
algorithm === "none"
|
||||
? payload
|
||||
: await decompressData(
|
||||
payload,
|
||||
algorithm,
|
||||
this._compressionOptions,
|
||||
this.compressionRegistry
|
||||
);
|
||||
return super.unpack(decompressed, schema);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Frame: `[headerLen: u8][header: ASCII algorithm name][payload bytes]`.
|
||||
*
|
||||
* The header length is bounded to 255 because `headerLen` is one byte. The
|
||||
* registered algorithm names ("none", "gzip", "deflate", future "brotli" /
|
||||
* "zstd") are all well under that. If a future custom algorithm name
|
||||
* exceeds 255 bytes when ASCII-encoded, the framing throws — caller can
|
||||
* then choose a shorter alias to register the same adapter under.
|
||||
*/
|
||||
function frame(algorithm: string, payload: Uint8Array): Uint8Array {
|
||||
const header = new TextEncoder().encode(algorithm);
|
||||
if (header.length === 0 || header.length > 255) {
|
||||
throw new BitpackerError(
|
||||
BITPACKER_CODES.COMPRESSED_BAD_HEADER,
|
||||
`Compression algorithm name "${algorithm}" must encode to 1–255 bytes (got ${header.length})`,
|
||||
{ context: { algorithm, headerLength: header.length } }
|
||||
);
|
||||
}
|
||||
const out = new Uint8Array(1 + header.length + payload.length);
|
||||
out[0] = header.length;
|
||||
out.set(header, 1);
|
||||
out.set(payload, 1 + header.length);
|
||||
return out;
|
||||
}
|
||||
|
||||
function unframe(data: Uint8Array): {
|
||||
algorithm: string;
|
||||
payload: Uint8Array;
|
||||
} {
|
||||
if (data.length < 2) {
|
||||
throw new BitpackerError(
|
||||
BITPACKER_CODES.COMPRESSED_BAD_HEADER,
|
||||
`Frame too short: ${data.length} bytes (need ≥ 2)`,
|
||||
{ context: { length: data.length } }
|
||||
);
|
||||
}
|
||||
const headerLen = data[0] as number;
|
||||
if (headerLen === 0 || headerLen >= data.length) {
|
||||
throw new BitpackerError(
|
||||
BITPACKER_CODES.COMPRESSED_BAD_HEADER,
|
||||
`Frame header length ${headerLen} out of range for ${data.length}-byte frame`,
|
||||
{ context: { headerLength: headerLen, frameLength: data.length } }
|
||||
);
|
||||
}
|
||||
const algorithm = new TextDecoder().decode(data.slice(1, 1 + headerLen));
|
||||
const payload = data.slice(1 + headerLen);
|
||||
return { algorithm, payload };
|
||||
}
|
||||
|
||||
export { frame as _frameForTests, unframe as _unframeForTests };
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { compressRle, decompressRle } from "./index.js";
|
||||
|
||||
describe("compressed", () => {
|
||||
it("round-trips RLE compression", () => {
|
||||
const data = new Uint8Array([1, 1, 1, 1, 2, 3, 3, 3, 3, 3]);
|
||||
const compressed = compressRle(data);
|
||||
const decompressed = decompressRle(compressed);
|
||||
expect(Array.from(decompressed)).toEqual(Array.from(data));
|
||||
});
|
||||
|
||||
it("handles incompressible data", () => {
|
||||
const data = new Uint8Array([1, 2, 3, 4, 5]);
|
||||
const compressed = compressRle(data);
|
||||
const decompressed = decompressRle(compressed);
|
||||
expect(Array.from(decompressed)).toEqual(Array.from(data));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
## index
|
||||
|
||||
**Path:** `bitpacker-compressed/src/index.ts`
|
||||
**Export:** See source for details.
|
||||
|
||||
### What it does
|
||||
|
||||
Part of the @suaveplan/ package.
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Compression pipeline for bitpacker.
|
||||
*
|
||||
* Public surface:
|
||||
*
|
||||
* - `CompressedBitPacker<T>` — `BitPacker` subclass that compresses packed
|
||||
* bytes through a configured `CompressionAdapter` and frames them with a
|
||||
* self-describing algorithm header.
|
||||
* - `CompressionRegistry` + `defaultCompressionRegistry()` /
|
||||
* `createCompressionRegistry()` — plug-in registry for compression adapters.
|
||||
* Defaults: `none` always; `gzip` / `deflate` lazy-registered when the
|
||||
* host environment exposes `CompressionStream`.
|
||||
* - `NoCompressionAdapter`, `GzipCompressionAdapter`, `DeflateCompressionAdapter`
|
||||
* — built-in adapters.
|
||||
* - `CompressionAdapter`, `CompressionConfig`, `BitPackerCompressionAlgorithm`,
|
||||
* `BitPackerCompressionOptions` — types.
|
||||
* - `compressData`/`decompressData` — algorithm-by-name helpers.
|
||||
* - `compressRle`/`decompressRle` — bytes-only RLE fast path (also re-exported
|
||||
* from `./rle` for callers that want the focused subpath).
|
||||
*/
|
||||
|
||||
export { DeflateCompressionAdapter } from "./adapters/deflate/deflate.js";
|
||||
export { GzipCompressionAdapter } from "./adapters/gzip/gzip.js";
|
||||
export { NoCompressionAdapter } from "./adapters/none/none.js";
|
||||
export { compressData, decompressData } from "./api/api.js";
|
||||
export {
|
||||
CompressedBitPacker,
|
||||
type CompressedBitPackerOptions,
|
||||
} from "./compressed-bit-packer/compressed-bit-packer.js";
|
||||
export {
|
||||
_resetDefaultRegistryForTests,
|
||||
CompressionRegistry,
|
||||
createCompressionRegistry,
|
||||
defaultCompressionRegistry,
|
||||
} from "./registry/registry.js";
|
||||
export {
|
||||
type CompressionOptions,
|
||||
compressRle,
|
||||
decompressRle,
|
||||
} from "./rle/rle.js";
|
||||
export type {
|
||||
BitPackerCompressionAlgorithm,
|
||||
BitPackerCompressionOptions,
|
||||
CompressionAdapter,
|
||||
CompressionConfig,
|
||||
} from "./types/types.js";
|
||||
@@ -0,0 +1,83 @@
|
||||
# Bitpacker Compressed OTel Instruments
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
OTel meter, tracer, counter, and histogram declarations for `@suaveplan/bitpacker-compressed`. Ships canonical instrumentation primitives for the compression/decompression pipeline (`gzip`, `deflate`, `none`, `rle`, future custom adapters) plus the `CompressedBitPacker.pack` / `unpack` boundaries that wrap the compression step. Every operation can be observed with the same vocabulary regardless of which adapter is selected.
|
||||
|
||||
The metric and span names follow the suaveplan convention with snake-case for the multi-word package: tracer/meter named `@suaveplan/bitpacker-compressed`; spans named `bitpacker_compressed.<operation>`; counters/histograms named `bitpacker_compressed.<scope>.<metric>`.
|
||||
|
||||
## Features
|
||||
|
||||
- `getBitPackerCompressedTracer()` — returns the package's tracer
|
||||
- `compressionOpsTotal` — counter labelled by `op`, `algorithm`, `success`
|
||||
- `compressionOpDurationMs` — histogram of compress/decompress duration
|
||||
- `compressionRatio` — histogram of `output_bytes / input_bytes` per successful compress
|
||||
- `BITPACKER_COMPRESSED_SPAN_NAMES` — four canonical span names
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
BITPACKER_COMPRESSED_SPAN_NAMES,
|
||||
compressionOpsTotal,
|
||||
getBitPackerCompressedTracer,
|
||||
} from "@suaveplan/bitpacker-compressed";
|
||||
|
||||
const tracer = getBitPackerCompressedTracer();
|
||||
tracer.startActiveSpan(
|
||||
BITPACKER_COMPRESSED_SPAN_NAMES.compress,
|
||||
(span) => {
|
||||
try {
|
||||
// compress work
|
||||
compressionOpsTotal.add(1, {
|
||||
op: "compress",
|
||||
algorithm: "gzip",
|
||||
success: "true",
|
||||
});
|
||||
} finally {
|
||||
span.end();
|
||||
}
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { compressionRatio } from "@suaveplan/bitpacker-compressed";
|
||||
|
||||
function recordCompressionRatio(input: Uint8Array, output: Uint8Array, algorithm: string) {
|
||||
compressionRatio.record(output.byteLength / input.byteLength, { algorithm });
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `getBitPackerCompressedTracer(): Tracer`
|
||||
|
||||
Returns the OTel `Tracer` instance for the package.
|
||||
|
||||
### `compressionOpsTotal: Counter`
|
||||
|
||||
Counter labelled by `op: "compress" | "decompress"`, `algorithm: string`, `success: "true" | "false"`.
|
||||
|
||||
### `compressionOpDurationMs: Histogram`
|
||||
|
||||
Histogram of elapsed milliseconds per compress/decompress call.
|
||||
|
||||
### `compressionRatio: Histogram`
|
||||
|
||||
Histogram of compression ratio (`output_bytes / input_bytes`), labelled by `algorithm`. Recorded only on successful `compress` calls — decompress doesn't have a meaningful ratio.
|
||||
|
||||
### `BITPACKER_COMPRESSED_SPAN_NAMES`
|
||||
|
||||
Const map: `{ compress, decompress, pack, unpack }` → canonical span names.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- **Algorithm is a label, not a counter prefix.** `compressionOpsTotal{algorithm="gzip"}` is more flexible than separate `gzip_compress_total` / `deflate_compress_total` counters; new adapters fold in without schema changes.
|
||||
- **`compressionRatio` is not a counter.** The ratio is per-call and stored as a histogram so dashboards can plot p50/p95/p99 ratios over time. A counter would only show throughput, not effectiveness.
|
||||
- **No per-chunk spans.** Compression adapters often process bytes in internal chunks; one span each would explode trace volume on large inputs.
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
BITPACKER_COMPRESSED_SPAN_NAMES,
|
||||
compressionOpDurationMs,
|
||||
compressionOpsTotal,
|
||||
compressionRatio,
|
||||
getBitPackerCompressedTracer,
|
||||
} from "./instruments.js";
|
||||
|
||||
describe("OTel instruments — declarations", () => {
|
||||
it("exposes the canonical span names", () => {
|
||||
expect(BITPACKER_COMPRESSED_SPAN_NAMES.compress).toBe(
|
||||
"bitpacker_compressed.compress"
|
||||
);
|
||||
expect(BITPACKER_COMPRESSED_SPAN_NAMES.decompress).toBe(
|
||||
"bitpacker_compressed.decompress"
|
||||
);
|
||||
expect(BITPACKER_COMPRESSED_SPAN_NAMES.pack).toBe(
|
||||
"bitpacker_compressed.pack"
|
||||
);
|
||||
expect(BITPACKER_COMPRESSED_SPAN_NAMES.unpack).toBe(
|
||||
"bitpacker_compressed.unpack"
|
||||
);
|
||||
});
|
||||
|
||||
it("counters and histograms are constructed without error", () => {
|
||||
expect(compressionOpsTotal).toBeDefined();
|
||||
expect(compressionOpDurationMs).toBeDefined();
|
||||
expect(compressionRatio).toBeDefined();
|
||||
});
|
||||
|
||||
it("tracer is reachable", () => {
|
||||
expect(getBitPackerCompressedTracer()).toBeDefined();
|
||||
});
|
||||
|
||||
it("counters accept the documented labels", () => {
|
||||
expect(() =>
|
||||
compressionOpsTotal.add(1, {
|
||||
op: "compress",
|
||||
algorithm: "gzip",
|
||||
success: "true",
|
||||
})
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { metrics, type Tracer, trace } from "@opentelemetry/api";
|
||||
|
||||
const meter = metrics.getMeter("@suaveplan/bitpacker-compressed");
|
||||
|
||||
export function getBitPackerCompressedTracer(): Tracer {
|
||||
return trace.getTracer("@suaveplan/bitpacker-compressed");
|
||||
}
|
||||
|
||||
/**
|
||||
* Counter incremented per top-level compress / decompress call. Labels:
|
||||
* `op` (`compress` | `decompress`), `algorithm` (e.g. `gzip`, `deflate`,
|
||||
* `none`, `rle`), and `success`.
|
||||
*/
|
||||
export const compressionOpsTotal = meter.createCounter(
|
||||
"bitpacker_compressed.ops.total",
|
||||
{
|
||||
description: "Total compression / decompression operations",
|
||||
unit: "{ops}",
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Histogram of operation duration in milliseconds.
|
||||
*/
|
||||
export const compressionOpDurationMs = meter.createHistogram(
|
||||
"bitpacker_compressed.op.duration_ms",
|
||||
{
|
||||
description: "Duration of compression / decompression in milliseconds",
|
||||
unit: "ms",
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Histogram of compression ratio (compressed bytes / input bytes), labelled
|
||||
* by `algorithm`. Recorded only on successful `compress` calls.
|
||||
*/
|
||||
export const compressionRatio = meter.createHistogram(
|
||||
"bitpacker_compressed.compression.ratio",
|
||||
{
|
||||
description:
|
||||
"Compression ratio (output_bytes / input_bytes) per successful compress",
|
||||
unit: "1",
|
||||
}
|
||||
);
|
||||
|
||||
export const BITPACKER_COMPRESSED_SPAN_NAMES = {
|
||||
compress: "bitpacker_compressed.compress",
|
||||
decompress: "bitpacker_compressed.decompress",
|
||||
pack: "bitpacker_compressed.pack",
|
||||
unpack: "bitpacker_compressed.unpack",
|
||||
} as const;
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
BITPACKER_COMPRESSED_SCHEMA_VERSION,
|
||||
BITPACKER_COMPRESSED_SUBJECT,
|
||||
registerBitPackerCompressedSchemas,
|
||||
} from "./register-schemas.js";
|
||||
@@ -0,0 +1,61 @@
|
||||
# Register Schemas — `@suaveplan/bitpacker-compressed`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Idempotent integration with `@suaveplan/schema-registry` for the `@suaveplan/bitpacker-compressed` package. Exposes `registerBitPackerCompressedSchemas(registry)` which converts the package's five public boundary schemas into JSON Schema form and registers them under `bitpacker:@suaveplan/bitpacker-compressed` at version 1. Re-registration on the same registry is a no-op.
|
||||
|
||||
The five schemas covered are: `bitPackerCompressionOptions` (the open-record adapter options), `compressionAdapter` (the structural contract every adapter implements), `compressionConfig` (the `{ algorithm, options, enabled }` config), `compressedBitPackerOptions` (the `CompressedBitPacker` constructor bundle), and `rleCompressionOptions` (the RLE-only `level` knob).
|
||||
|
||||
## Features
|
||||
|
||||
- `BITPACKER_COMPRESSED_SUBJECT` constant — `"bitpacker:@suaveplan/bitpacker-compressed"`
|
||||
- `BITPACKER_COMPRESSED_SCHEMA_VERSION` constant — `1`
|
||||
- `registerBitPackerCompressedSchemas(registry)` — idempotent registration
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { createRegistry } from "@suaveplan/schema-registry";
|
||||
import {
|
||||
BITPACKER_COMPRESSED_SUBJECT,
|
||||
registerBitPackerCompressedSchemas,
|
||||
} from "@suaveplan/bitpacker-compressed/register-schemas";
|
||||
|
||||
const registry = createRegistry();
|
||||
registerBitPackerCompressedSchemas(registry);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { createRegistry, SchemaRegistry } from "@suaveplan/schema-registry";
|
||||
import { registerBitPackerSchemas } from "@suaveplan/bitpacker/register-schemas";
|
||||
import { registerBitPackerCompressedSchemas } from "@suaveplan/bitpacker-compressed/register-schemas";
|
||||
|
||||
const registry = createRegistry();
|
||||
registerBitPackerSchemas(registry);
|
||||
registerBitPackerCompressedSchemas(registry);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `BITPACKER_COMPRESSED_SUBJECT: "bitpacker:@suaveplan/bitpacker-compressed"`
|
||||
|
||||
Const string.
|
||||
|
||||
### `BITPACKER_COMPRESSED_SCHEMA_VERSION: 1`
|
||||
|
||||
Const number.
|
||||
|
||||
### `registerBitPackerCompressedSchemas(registry: SchemaRegistry): void`
|
||||
|
||||
Idempotent registration. Returns without writing if the subject is already present.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- **Five-key payload.** One key per public boundary schema.
|
||||
- **`unrepresentable: "any"`.** Required because `compressionAdapter` and `compressedBitPackerOptions` use `z.custom` for the structural contract and registry instance check.
|
||||
@@ -0,0 +1,42 @@
|
||||
import { createRegistry } from "@suaveplan/schema-registry";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
BITPACKER_COMPRESSED_SCHEMA_VERSION,
|
||||
BITPACKER_COMPRESSED_SUBJECT,
|
||||
registerBitPackerCompressedSchemas,
|
||||
} from "./register-schemas.js";
|
||||
|
||||
describe("registerBitPackerCompressedSchemas", () => {
|
||||
it("registers under the namespaced subject at version 1", () => {
|
||||
const registry = createRegistry();
|
||||
registerBitPackerCompressedSchemas(registry);
|
||||
expect(registry.getLatest(BITPACKER_COMPRESSED_SUBJECT).version).toBe(
|
||||
BITPACKER_COMPRESSED_SCHEMA_VERSION
|
||||
);
|
||||
});
|
||||
|
||||
it("includes every public schema in the payload", () => {
|
||||
const registry = createRegistry();
|
||||
registerBitPackerCompressedSchemas(registry);
|
||||
const payload = registry.getLatest(BITPACKER_COMPRESSED_SUBJECT)
|
||||
.schema as Record<string, unknown>;
|
||||
for (const key of [
|
||||
"bitPackerCompressionOptions",
|
||||
"compressionAdapter",
|
||||
"compressionConfig",
|
||||
"compressedBitPackerOptions",
|
||||
"rleCompressionOptions",
|
||||
]) {
|
||||
expect(payload).toHaveProperty(key);
|
||||
}
|
||||
});
|
||||
|
||||
it("is idempotent across repeated calls", () => {
|
||||
const registry = createRegistry();
|
||||
registerBitPackerCompressedSchemas(registry);
|
||||
registerBitPackerCompressedSchemas(registry);
|
||||
expect(registry.getLatest(BITPACKER_COMPRESSED_SUBJECT).version).toBe(
|
||||
1
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { SchemaRegistry } from "@suaveplan/schema-registry";
|
||||
import z from "zod";
|
||||
import { compressedBitPackerOptionsSchema } from "../compressed-bit-packer/compressed-bit-packer.schema.js";
|
||||
import { rleCompressionOptionsSchema } from "../rle/rle.schema.js";
|
||||
import {
|
||||
bitPackerCompressionOptionsSchema,
|
||||
compressionAdapterSchema,
|
||||
compressionConfigSchema,
|
||||
} from "../types/types.schema.js";
|
||||
|
||||
export const BITPACKER_COMPRESSED_SUBJECT =
|
||||
"bitpacker:@suaveplan/bitpacker-compressed" as const;
|
||||
export const BITPACKER_COMPRESSED_SCHEMA_VERSION = 1;
|
||||
|
||||
interface Payload {
|
||||
readonly bitPackerCompressionOptions: unknown;
|
||||
readonly compressionAdapter: unknown;
|
||||
readonly compressionConfig: unknown;
|
||||
readonly compressedBitPackerOptions: unknown;
|
||||
readonly rleCompressionOptions: unknown;
|
||||
}
|
||||
|
||||
function buildPayload(): Payload {
|
||||
const opts = { unrepresentable: "any" } as const;
|
||||
return {
|
||||
bitPackerCompressionOptions: z.toJSONSchema(
|
||||
bitPackerCompressionOptionsSchema,
|
||||
opts
|
||||
),
|
||||
compressionAdapter: z.toJSONSchema(compressionAdapterSchema, opts),
|
||||
compressionConfig: z.toJSONSchema(compressionConfigSchema, opts),
|
||||
compressedBitPackerOptions: z.toJSONSchema(
|
||||
compressedBitPackerOptionsSchema,
|
||||
opts
|
||||
),
|
||||
rleCompressionOptions: z.toJSONSchema(
|
||||
rleCompressionOptionsSchema,
|
||||
opts
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotently register `@suaveplan/bitpacker-compressed`'s public Zod
|
||||
* schemas with the given `SchemaRegistry`.
|
||||
*/
|
||||
export function registerBitPackerCompressedSchemas(
|
||||
registry: SchemaRegistry
|
||||
): void {
|
||||
if (registry.listSubjects().includes(BITPACKER_COMPRESSED_SUBJECT)) {
|
||||
return;
|
||||
}
|
||||
registry.register(
|
||||
BITPACKER_COMPRESSED_SUBJECT,
|
||||
buildPayload(),
|
||||
"json-schema",
|
||||
{
|
||||
package: "@suaveplan/bitpacker-compressed",
|
||||
namespace: "bitpacker",
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
# CompressionRegistry
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`CompressionRegistry` is the pluggable map from algorithm name to `CompressionAdapter` that backs `@suaveplan/bitpacker-compressed`'s public API. It is the single source of truth that `compressData`, `decompressData`, and `CompressedBitPacker` consult to look up an adapter by string name. Two flavours are exposed: a process-wide singleton (`defaultCompressionRegistry()`) for production code, and a fresh-instance factory (`createCompressionRegistry()`) for tests that need isolation. This mirrors the registry conventions used by `@suaveplan/random` and `@suaveplan/bitpacker-crypto` so the codebase stays uniform.
|
||||
|
||||
## Features
|
||||
|
||||
- Singleton + fresh-instance factory pattern (no class-static singletons).
|
||||
- Coded errors: unknown algorithm → `BitpackerError(UNKNOWN_COMPRESSION_ALGORITHM)`; soft-fail variant `tryResolve` returns `undefined`.
|
||||
- Lazy default registration — `defaultCompressionRegistry()` registers `"none"`, `"gzip"`, `"deflate"` on first access (only registers gzip/deflate when `CompressionStream` is available).
|
||||
- `withDefaults: false` option on `createCompressionRegistry` produces a completely empty registry for negative-path tests.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { defaultCompressionRegistry } from "@suaveplan/bitpacker-compressed";
|
||||
|
||||
const registry = defaultCompressionRegistry();
|
||||
const adapter = registry.resolve("gzip");
|
||||
const compressed = await adapter.compress(myBytes);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
createCompressionRegistry,
|
||||
type CompressionAdapter,
|
||||
} from "@suaveplan/bitpacker-compressed";
|
||||
|
||||
// Plug in a custom Brotli adapter (caller-owned WASM Brotli wrapper).
|
||||
class BrotliAdapter implements CompressionAdapter {
|
||||
readonly name = "brotli";
|
||||
async compress(data: Uint8Array) { /* WASM call */ return new Uint8Array(); }
|
||||
async decompress(data: Uint8Array) { /* WASM call */ return new Uint8Array(); }
|
||||
}
|
||||
|
||||
const registry = createCompressionRegistry();
|
||||
registry.register(new BrotliAdapter());
|
||||
const adapter = registry.resolve("brotli");
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `defaultCompressionRegistry(): CompressionRegistry`
|
||||
|
||||
Returns the lazily-initialized process-wide singleton. Same reference every call.
|
||||
|
||||
### `createCompressionRegistry(options?: { withDefaults?: boolean }): CompressionRegistry`
|
||||
|
||||
Returns a fresh registry. `withDefaults` defaults to `true`; pass `false` to start empty.
|
||||
|
||||
### `CompressionRegistry`
|
||||
|
||||
- `register(adapter: CompressionAdapter): void` — Last-write-wins by `adapter.name`.
|
||||
- `resolve(name: string): CompressionAdapter` — Throws on unknown.
|
||||
- `tryResolve(name: string): CompressionAdapter | undefined` — Soft-fail variant.
|
||||
- `list(): readonly string[]` — All registered adapter names.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The singleton is module-scope inside `registry.ts`; tests that need to mutate it create a fresh `createCompressionRegistry()` rather than poking the singleton.
|
||||
- Default registration of gzip/deflate is gated on `typeof CompressionStream === "function"` so the registry stays usable in environments without WHATWG Streams (the gzip/deflate adapters themselves throw when called there, but lookup never fails).
|
||||
- `register` mutates the registry; this is the documented behaviour for plug-in adapters. There is no immutable variant.
|
||||
@@ -0,0 +1,122 @@
|
||||
import { BITPACKER_CODES, BitpackerError } from "@suaveplan/bitpacker";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { NoCompressionAdapter } from "../adapters/none/none.js";
|
||||
import {
|
||||
_resetDefaultRegistryForTests,
|
||||
CompressionRegistry,
|
||||
createCompressionRegistry,
|
||||
defaultCompressionRegistry,
|
||||
} from "./registry.js";
|
||||
|
||||
describe("CompressionRegistry", () => {
|
||||
it("registers and resolves an adapter by name", { timeout: 5000 }, () => {
|
||||
const reg = new CompressionRegistry();
|
||||
const adapter = new NoCompressionAdapter();
|
||||
reg.register(adapter);
|
||||
expect(reg.resolve("none")).toBe(adapter);
|
||||
expect(reg.has("none")).toBe(true);
|
||||
});
|
||||
|
||||
it("resolve throws UNKNOWN_COMPRESSION_ALGORITHM for missing adapter", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const reg = new CompressionRegistry();
|
||||
expect(() => reg.resolve("zstd")).toThrow(BitpackerError);
|
||||
try {
|
||||
reg.resolve("zstd");
|
||||
} catch (e) {
|
||||
expect((e as BitpackerError).code).toBe(
|
||||
BITPACKER_CODES.UNKNOWN_COMPRESSION_ALGORITHM
|
||||
);
|
||||
expect((e as BitpackerError).context?.algorithm).toBe("zstd");
|
||||
}
|
||||
});
|
||||
|
||||
it("tryResolve returns undefined for missing adapter (soft-fail)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const reg = new CompressionRegistry();
|
||||
expect(reg.tryResolve("zstd")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("register replaces an existing adapter (last-write-wins)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const reg = new CompressionRegistry();
|
||||
const a = new NoCompressionAdapter();
|
||||
const b = new NoCompressionAdapter();
|
||||
reg.register(a);
|
||||
reg.register(b);
|
||||
expect(reg.resolve("none")).toBe(b);
|
||||
});
|
||||
|
||||
it("list and getAll surface registered adapters", { timeout: 5000 }, () => {
|
||||
const reg = new CompressionRegistry();
|
||||
reg.register(new NoCompressionAdapter());
|
||||
expect(reg.list()).toEqual(["none"]);
|
||||
expect(reg.getAll()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("createCompressionRegistry seeds defaults including 'none'", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const reg = createCompressionRegistry();
|
||||
expect(reg.has("none")).toBe(true);
|
||||
});
|
||||
|
||||
it("defaultCompressionRegistry returns a singleton", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const a = defaultCompressionRegistry();
|
||||
const b = defaultCompressionRegistry();
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it("createCompressionRegistry isolates from singleton", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const fresh = createCompressionRegistry();
|
||||
const singleton = defaultCompressionRegistry();
|
||||
// Mutate the fresh one — singleton must not see it.
|
||||
class Marker extends NoCompressionAdapter {
|
||||
public override readonly name = "test-marker-only";
|
||||
}
|
||||
fresh.register(new Marker());
|
||||
expect(fresh.has("test-marker-only")).toBe(true);
|
||||
expect(singleton.has("test-marker-only")).toBe(false);
|
||||
});
|
||||
|
||||
it("_resetDefaultRegistryForTests rebuilds the singleton", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const before = defaultCompressionRegistry();
|
||||
_resetDefaultRegistryForTests();
|
||||
const after = defaultCompressionRegistry();
|
||||
// Different instance after reset proves the cache was dropped.
|
||||
expect(after).not.toBe(before);
|
||||
});
|
||||
|
||||
it("defaultCompressionRegistry skips lazy gzip/deflate when CompressionStream is missing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Force the singleton to be rebuilt while CompressionStream is hidden,
|
||||
// so we exercise the `typeof CompressionStream === "undefined"` arm of
|
||||
// the lazy-registration block. The finally clauses keep this isolated.
|
||||
const g = globalThis as unknown as {
|
||||
CompressionStream?: typeof CompressionStream;
|
||||
};
|
||||
const saved = g.CompressionStream;
|
||||
_resetDefaultRegistryForTests();
|
||||
try {
|
||||
g.CompressionStream = undefined;
|
||||
const fresh = defaultCompressionRegistry();
|
||||
// Only "none" is seeded synchronously; gzip/deflate are not added.
|
||||
expect(fresh.has("none")).toBe(true);
|
||||
expect(fresh.has("gzip")).toBe(false);
|
||||
expect(fresh.has("deflate")).toBe(false);
|
||||
} finally {
|
||||
g.CompressionStream = saved;
|
||||
_resetDefaultRegistryForTests();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* `CompressionRegistry` — pluggable map from algorithm name to
|
||||
* `CompressionAdapter`. Genesis port of upstream
|
||||
* `bitpacker/src/compressed/compression.ts::CompressionRegistry` with two
|
||||
* shape changes:
|
||||
*
|
||||
* 1. **No singleton-via-private-static**. Genesis convention is the same as
|
||||
* `@suaveplan/random` and `@suaveplan/bitpacker-crypto`'s registries:
|
||||
* a process-wide singleton accessor (`defaultCompressionRegistry()`) plus
|
||||
* a fresh-instance factory (`createCompressionRegistry()`).
|
||||
* 2. **Coded errors**. `resolve` of an unknown algorithm throws
|
||||
* `BitpackerError(UNKNOWN_COMPRESSION_ALGORITHM)` rather than returning
|
||||
* `undefined` and forcing every caller to write the same `if (!adapter)`
|
||||
* branch. `tryResolve` is provided for the rare case a caller wants the
|
||||
* soft-fail path.
|
||||
*/
|
||||
|
||||
import { BITPACKER_CODES, BitpackerError } from "@suaveplan/bitpacker";
|
||||
import { NoCompressionAdapter } from "../adapters/none/none.js";
|
||||
import type { CompressionAdapter } from "../types/types.js";
|
||||
|
||||
export class CompressionRegistry {
|
||||
private readonly adapters = new Map<string, CompressionAdapter>();
|
||||
|
||||
/**
|
||||
* Register an adapter. If `adapter.name` is already registered, the new
|
||||
* adapter replaces the old (last-write-wins). Used both for shipping
|
||||
* defaults at construction time and for downstream consumers plugging in
|
||||
* custom algorithms (e.g. `"brotli"`, `"zstd"`).
|
||||
*/
|
||||
public register(adapter: CompressionAdapter): void {
|
||||
this.adapters.set(adapter.name, adapter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an adapter by name. Throws `BitpackerError` with code
|
||||
* `UNKNOWN_COMPRESSION_ALGORITHM` and `context.algorithm` when no
|
||||
* adapter is registered for `name`. Use this on the production hot path.
|
||||
*/
|
||||
public resolve(name: string): CompressionAdapter {
|
||||
const adapter = this.adapters.get(name);
|
||||
if (adapter === undefined) {
|
||||
throw new BitpackerError(
|
||||
BITPACKER_CODES.UNKNOWN_COMPRESSION_ALGORITHM,
|
||||
`Compression algorithm "${name}" is not registered`,
|
||||
{ context: { algorithm: name } }
|
||||
);
|
||||
}
|
||||
return adapter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-fail variant of `resolve`. Returns the adapter or `undefined`
|
||||
* without throwing. Use this when a caller wants to feature-detect
|
||||
* before deciding what to do.
|
||||
*/
|
||||
public tryResolve(name: string): CompressionAdapter | undefined {
|
||||
return this.adapters.get(name);
|
||||
}
|
||||
|
||||
public has(name: string): boolean {
|
||||
return this.adapters.has(name);
|
||||
}
|
||||
|
||||
public list(): readonly string[] {
|
||||
return Array.from(this.adapters.keys());
|
||||
}
|
||||
|
||||
public getAll(): readonly CompressionAdapter[] {
|
||||
return Array.from(this.adapters.values());
|
||||
}
|
||||
}
|
||||
|
||||
let singleton: CompressionRegistry | undefined;
|
||||
|
||||
/**
|
||||
* Build a fresh registry pre-seeded with the always-shipping defaults
|
||||
* (`none`). Browser-only `gzip`/`deflate` adapters are registered lazily by
|
||||
* `defaultCompressionRegistry()` based on environment detection — using
|
||||
* `createCompressionRegistry()` directly skips that registration so tests
|
||||
* can exercise the registry without `CompressionStream` polyfills.
|
||||
*/
|
||||
export function createCompressionRegistry(): CompressionRegistry {
|
||||
const registry = new CompressionRegistry();
|
||||
registry.register(new NoCompressionAdapter());
|
||||
return registry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the process-wide registry. Lazy-initialised; gzip/deflate adapters
|
||||
* are registered if `CompressionStream` is available in the host
|
||||
* environment. Re-importing the module does NOT rebuild the singleton.
|
||||
*/
|
||||
export function defaultCompressionRegistry(): CompressionRegistry {
|
||||
if (singleton === undefined) {
|
||||
singleton = createCompressionRegistry();
|
||||
if (typeof CompressionStream !== "undefined") {
|
||||
// Lazy-import the browser-stream adapters to avoid a hard
|
||||
// dependency on `CompressionStream` at module load.
|
||||
void import("../adapters/gzip/gzip.js").then((m) => {
|
||||
singleton?.register(new m.GzipCompressionAdapter());
|
||||
});
|
||||
void import("../adapters/deflate/deflate.js").then((m) => {
|
||||
singleton?.register(new m.DeflateCompressionAdapter());
|
||||
});
|
||||
}
|
||||
}
|
||||
return singleton;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test-only escape hatch — drop the cached singleton so the next
|
||||
* `defaultCompressionRegistry()` call rebuilds. Production code MUST NOT
|
||||
* call this; tests use it to verify isolation.
|
||||
*/
|
||||
export function _resetDefaultRegistryForTests(): void {
|
||||
singleton = undefined;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
# Run-length encoding (`compressRle` / `decompressRle`)
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs --> - rle.schema — Zod schema for the per-call `Compressi…
|
||||
- [rle.schema](./rle.schema.md) — Zod schema for the per-call `CompressionOptions` accepted by `compressRle` / `decompressRle`. The RLE codec is byte-onl…
|
||||
- rle.schema — Zod schema for the per-call `Compressi…
|
||||
|
||||
- [rle.schema](./rle.schema.md) — Zod schema for the per-call `CompressionOptions` accepted by `compressRle` / `decompressRle`. The RLE codec is byte-onl…
|
||||
|
||||
- rle.schema — Zod schema for the per-call `Compressi…
|
||||
|
||||
- [rle.schema](./rle.schema.md) — Zod schema for the per-call `CompressionOptions` accepted by `compressRle` / `decompressRle`. The RLE codec is byte-onl…
|
||||
|
||||
- rle.schema — Zod schema for the per-call `Compressi…
|
||||
|
||||
- [rle.schema](./rle.schema.md) — Zod schema for the per-call `CompressionOptions` accepted by `compressRle` / `decompressRle`. The RLE codec is byte-onl…
|
||||
- [rle.schema](./rle.schema.md) — Zod schema for the per-call `CompressionOptions` accepted by `compressRle` / `decompressRle`. The RLE codec is byte-onl…
|
||||
|
||||
## Purpose
|
||||
|
||||
`compressRle` / `decompressRle` are byte-level run-length encoding helpers shipped as a separate module within `@suaveplan/bitpacker-compressed` so embedded / no-streams environments can import them without pulling in `CompressionStream`-using gzip/deflate adapters or the `CompressedBitPacker` class. They are not part of the `CompressionRegistry` framing — bytes produced by `compressRle` are NOT the same shape as bytes produced by the registry's `"none"`/`"gzip"`/`"deflate"` adapters, which include the `CompressedBitPacker` header. If you need framed RLE, write a thin `RleCompressionAdapter` that internally calls these helpers.
|
||||
|
||||
## Features
|
||||
|
||||
- Bytes-only fast path; no streams required.
|
||||
- Single-byte runs longer than 3 are emitted as `[byte][0][count]`; everything else passes through verbatim.
|
||||
- Symmetric round-trip with `decompressRle`.
|
||||
- Zero allocation overhead beyond the output `Uint8Array`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { compressRle, decompressRle } from "@suaveplan/bitpacker-compressed";
|
||||
|
||||
const data = new Uint8Array([1, 1, 1, 1, 1, 2, 3]);
|
||||
const compressed = compressRle(data); // [1, 0, 5, 2, 3]
|
||||
const restored = decompressRle(compressed); // back to original
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
compressRle,
|
||||
decompressRle,
|
||||
type CompressionAdapter,
|
||||
} from "@suaveplan/bitpacker-compressed";
|
||||
|
||||
// Wrap RLE in a CompressionAdapter shape so it plugs into the registry.
|
||||
class RleAdapter implements CompressionAdapter {
|
||||
readonly name = "rle";
|
||||
async compress(data: Uint8Array) { return compressRle(data); }
|
||||
async decompress(data: Uint8Array) { return decompressRle(data); }
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `compressRle(data: Uint8Array): Uint8Array`
|
||||
|
||||
- Encodes runs of >3 identical bytes as `[byte][0][count]`.
|
||||
- Returns a fresh `Uint8Array`; input is not mutated.
|
||||
|
||||
### `decompressRle(data: Uint8Array): Uint8Array`
|
||||
|
||||
- Reverses `compressRle`. Looks ahead for `[0][count]` markers after each byte.
|
||||
- Returns a fresh `Uint8Array`.
|
||||
|
||||
### `CompressionOptions`
|
||||
|
||||
Type alias for `{ readonly level?: number }` — accepted but ignored by the bytes-only RLE path; included so adapter authors can re-export the canonical compression-options shape.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The marker byte is `0`, which would collide with a literal `0` in input data; the encoder handles this by always emitting the run-length form when `byte === 0` runs.
|
||||
- This is not the most aggressive RLE possible — it does not pack runs of distinct bytes into literal blocks. The intent is "smaller than verbatim with a single linear pass", not "competitive with gzip on text".
|
||||
- Use this when you have known byte-pattern data (e.g. graphics with large flat regions, sparse network frames). For general-purpose data, use the gzip / deflate adapters.
|
||||
@@ -0,0 +1,48 @@
|
||||
# RLE Compression Options Schema
|
||||
|
||||
## Purpose
|
||||
|
||||
Zod schema for the per-call `CompressionOptions` accepted by `compressRle` / `decompressRle`. The RLE codec is byte-only and exposes a single tuning knob (`level`) — this schema validates that knob at the boundary so untrusted input can be normalized before reaching the codec.
|
||||
|
||||
## Features
|
||||
|
||||
- `rleCompressionOptionsSchema` — strict, single optional field
|
||||
- `level` is a non-negative integer; the codec ignores values outside its supported range
|
||||
- `as unknown as z.ZodType<T>` cast for the EOPT mismatch
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { compressRle, rleCompressionOptionsSchema } from "@suaveplan/bitpacker-compressed";
|
||||
|
||||
const opts = rleCompressionOptionsSchema.parse({ level: 1 });
|
||||
const compressed = compressRle(new Uint8Array([0, 0, 0, 0, 1, 2]), opts);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
compressRle,
|
||||
decompressRle,
|
||||
rleCompressionOptionsSchema,
|
||||
} from "@suaveplan/bitpacker-compressed";
|
||||
|
||||
function safeRoundTrip(data: Uint8Array, rawOpts: unknown) {
|
||||
const opts = rleCompressionOptionsSchema.parse(rawOpts);
|
||||
const compressed = compressRle(data, opts);
|
||||
const decompressed = decompressRle(compressed);
|
||||
return decompressed;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `rleCompressionOptionsSchema: z.ZodType<CompressionOptions>`
|
||||
|
||||
Strict schema. `level` is an optional non-negative integer. Throws `ZodError` on negative or non-integer values, or on unknown keys.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- **Why a one-field schema.** Even a single-field bundle benefits from boundary validation: a `{ level: "9" }` (string instead of number) would otherwise reach the codec and produce undefined behaviour. Refusing it at parse time keeps the codec's contract simple.
|
||||
- **Codec ignores out-of-range levels.** The schema enforces non-negative integer; the codec itself ignores values outside `[0, 9]`. The schema is the boundary; the codec is the implementation.
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { rleCompressionOptionsSchema } from "./rle.schema.js";
|
||||
|
||||
describe("rleCompressionOptionsSchema", () => {
|
||||
it("accepts an empty bundle", () => {
|
||||
expect(rleCompressionOptionsSchema.parse({})).toEqual({});
|
||||
});
|
||||
|
||||
it("accepts a non-negative integer level", () => {
|
||||
expect(rleCompressionOptionsSchema.parse({ level: 0 })).toEqual({
|
||||
level: 0,
|
||||
});
|
||||
expect(rleCompressionOptionsSchema.parse({ level: 9 })).toEqual({
|
||||
level: 9,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a negative level", () => {
|
||||
expect(() =>
|
||||
rleCompressionOptionsSchema.parse({ level: -1 })
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects unknown keys", () => {
|
||||
expect(() =>
|
||||
rleCompressionOptionsSchema.parse({ level: 1, bogus: true })
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import z from "zod";
|
||||
import type { CompressionOptions } from "./rle.js";
|
||||
|
||||
/**
|
||||
* Strict Zod schema for the RLE-only {@link CompressionOptions}.
|
||||
*
|
||||
* Validates the per-call options for `compressRle` / `decompressRle`. The
|
||||
* RLE codec is byte-only and accepts a single `level` knob; this schema
|
||||
* exists primarily so callers can normalize untrusted input before reaching
|
||||
* the codec.
|
||||
*/
|
||||
export const rleCompressionOptionsSchema = z
|
||||
.object({
|
||||
level: z.number().int().nonnegative().optional(),
|
||||
})
|
||||
.strict() as unknown as z.ZodType<CompressionOptions>;
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Run-length encoding helpers — bytes-only fast path.
|
||||
*
|
||||
* Lives alongside the full compression pipeline as a separate module so
|
||||
* consumers wanting RLE for embedded / no-streams environments can import
|
||||
* it without paying the cost of pulling in `CompressionStream`-using gzip /
|
||||
* deflate adapters or the `CompressedBitPacker` class.
|
||||
*
|
||||
* The format is intentionally simple — a single byte that repeats more
|
||||
* than three times is emitted as `[byte][0][count]`; everything else
|
||||
* passes through verbatim. Decompression reverses by looking ahead for the
|
||||
* `0` marker. There is no header — RLE bytes are NOT compatible with the
|
||||
* `CompressedBitPacker` framing (which would require an "rle" header).
|
||||
* If you need the framing, register an `RleCompressionAdapter` with the
|
||||
* registry that internally calls these helpers.
|
||||
*/
|
||||
|
||||
export interface CompressionOptions {
|
||||
readonly level?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compress a byte array using run-length encoding. Runs of more than three
|
||||
* identical bytes collapse to `[byte][0][count]`; shorter runs pass through.
|
||||
* The output is at worst the same length as the input plus 2× the number
|
||||
* of zero bytes (which would otherwise be ambiguous with the marker).
|
||||
*/
|
||||
export function compressRle(data: Uint8Array): Uint8Array {
|
||||
const result: number[] = [];
|
||||
let i = 0;
|
||||
while (i < data.length) {
|
||||
const byte = data[i] as number;
|
||||
let count = 1;
|
||||
while (
|
||||
i + count < data.length &&
|
||||
data[i + count] === byte &&
|
||||
count < 255
|
||||
) {
|
||||
count++;
|
||||
}
|
||||
if (count > 3) {
|
||||
result.push(byte, 0, count);
|
||||
i += count;
|
||||
} else {
|
||||
result.push(byte);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return new Uint8Array(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decompress a byte array previously produced by `compressRle`. The reverse
|
||||
* of the encoding rule: a `[byte][0][count]` triple expands to `count`
|
||||
* copies of `byte`; every other byte passes through verbatim.
|
||||
*/
|
||||
export function decompressRle(data: Uint8Array): Uint8Array {
|
||||
const result: number[] = [];
|
||||
let i = 0;
|
||||
while (i < data.length) {
|
||||
const byte = data[i] as number;
|
||||
if (i + 2 < data.length && data[i + 1] === 0) {
|
||||
const count = data[i + 2] as number;
|
||||
for (let j = 0; j < count; j++) {
|
||||
result.push(byte);
|
||||
}
|
||||
i += 3;
|
||||
} else {
|
||||
result.push(byte);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return new Uint8Array(result);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
# Compression types
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs --> - types.schema — Zod schemas for the public type comp…
|
||||
- [types.schema](./types.schema.md) — Zod schemas for the public type companions of `@suaveplan/bitpacker-compressed`'s compression pipeline: the open-record…
|
||||
- types.schema — Zod schemas for the public type comp…
|
||||
|
||||
- [types.schema](./types.schema.md) — Zod schemas for the public type companions of `@suaveplan/bitpacker-compressed`'s compression pipeline: the open-record…
|
||||
|
||||
- types.schema — Zod schemas for the public type comp…
|
||||
|
||||
- [types.schema](./types.schema.md) — Zod schemas for the public type companions of `@suaveplan/bitpacker-compressed`'s compression pipeline: the open-record…
|
||||
|
||||
- types.schema — Zod schemas for the public type comp…
|
||||
|
||||
- [types.schema](./types.schema.md) — Zod schemas for the public type companions of `@suaveplan/bitpacker-compressed`'s compression pipeline: the open-record…
|
||||
- [types.schema](./types.schema.md) — Zod schemas for the public type companions of `@suaveplan/bitpacker-compressed`'s compression pipeline: the open-record…
|
||||
|
||||
## Purpose
|
||||
|
||||
This module collects the public TypeScript type companions used across `@suaveplan/bitpacker-compressed`: the algorithm-name string brand, the per-call options shape, and the `CompressionAdapter` interface that any plug-in codec must implement. Keeping them in one file means downstream consumers only need a single import path to write a custom adapter or to type a config object that flows through the pipeline. The shapes mirror the upstream `bitpacker/src/types.ts` compression types verbatim so adapters and consumers see the same contracts whether they imported from upstream or the port.
|
||||
|
||||
## Features
|
||||
|
||||
- `BitPackerCompressionAlgorithm` — string-typed registry key (e.g. `"none"`, `"gzip"`, `"deflate"`, `"brotli"`).
|
||||
- `BitPackerCompressionOptions` — caller-supplied options forwarded to the adapter (currently `level?: number`).
|
||||
- `CompressionAdapter` — the contract every codec implements: `name`, `compress`, `decompress`.
|
||||
- All async to match the WHATWG Streams reality; sync codecs simply resolve immediately.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
BitPackerCompressionAlgorithm,
|
||||
BitPackerCompressionOptions,
|
||||
CompressionAdapter,
|
||||
} from "@suaveplan/bitpacker-compressed";
|
||||
|
||||
const algorithm: BitPackerCompressionAlgorithm = "gzip";
|
||||
const options: BitPackerCompressionOptions = { level: 9 };
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type { CompressionAdapter } from "@suaveplan/bitpacker-compressed";
|
||||
|
||||
// Custom adapter for a hypothetical FastLZ codec.
|
||||
class FastLzAdapter implements CompressionAdapter {
|
||||
readonly name = "fastlz";
|
||||
async compress(data: Uint8Array, options?: { level?: number }) {
|
||||
// Call into FastLZ-WASM module here.
|
||||
return data; // placeholder
|
||||
}
|
||||
async decompress(data: Uint8Array, _options?: { level?: number }) {
|
||||
return data; // placeholder
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `BitPackerCompressionAlgorithm`
|
||||
|
||||
Type alias for `string`. Used as the registry lookup key. Default registered names: `"none"`, `"gzip"`, `"deflate"`. Consumers MAY register additional algorithms.
|
||||
|
||||
### `BitPackerCompressionOptions`
|
||||
|
||||
```ts
|
||||
interface BitPackerCompressionOptions {
|
||||
readonly level?: number;
|
||||
}
|
||||
```
|
||||
|
||||
Forwarded to the adapter. Adapters that don't support `level` (e.g. `none`) ignore it.
|
||||
|
||||
### `CompressionAdapter`
|
||||
|
||||
```ts
|
||||
interface CompressionAdapter {
|
||||
readonly name: BitPackerCompressionAlgorithm;
|
||||
compress(data: Uint8Array, options?: BitPackerCompressionOptions): Promise<Uint8Array>;
|
||||
decompress(data: Uint8Array, options?: BitPackerCompressionOptions): Promise<Uint8Array>;
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `BitPackerCompressionAlgorithm` is intentionally `string`, not a closed union — module augmentation is overkill for plug-in codecs and the registry already validates names at lookup.
|
||||
- All methods are async because the standard browser/Node codecs (`CompressionStream`) are async. The pass-through `NoCompressionAdapter` resolves immediately so async overhead is negligible.
|
||||
- Adapters MUST be deterministic: same input + same options ⇒ same output bytes (modulo internal nondeterminism in the underlying codec, which is allowed but unusual).
|
||||
@@ -0,0 +1,77 @@
|
||||
# Compression Type Schemas
|
||||
|
||||
## Purpose
|
||||
|
||||
Zod schemas for the public type companions of `@suaveplan/bitpacker-compressed`'s compression pipeline: the open-record `BitPackerCompressionOptions`, the `CompressionAdapter` interface (the contract every adapter implements), and the `CompressionConfig` configuration shape consumed by `CompressedBitPacker`. Each schema is the runtime gatekeeper at the corresponding boundary so an adapter loaded from a plugin manifest, or a config bundle parsed from environment variables, can be validated before reaching the compression hot path.
|
||||
|
||||
The schemas use `z.custom<T>((v): v is T => …)` for the adapter validation so adapter prototype chains are preserved (registries depend on prototype-aware `instanceof` checks), and `z.record(z.string(), z.unknown())` for the open-record `BitPackerCompressionOptions` because each adapter accepts its own option keys — the schema validates "is this an object?" and lets the adapter validate its own option semantics.
|
||||
|
||||
## Features
|
||||
|
||||
- `bitPackerCompressionOptionsSchema` — open record (any string-keyed object)
|
||||
- `compressionAdapterSchema` — structural contract: `name: string`, `compress: function`, `decompress: function`
|
||||
- `compressionConfigSchema` — strict, three optional fields (`algorithm`, `options`, `enabled`)
|
||||
- Identity-preserving `z.custom` for adapter validation
|
||||
- `as unknown as z.ZodType<T>` casts where Zod's `T | undefined` collides with `exactOptionalPropertyTypes`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
CompressedBitPacker,
|
||||
compressionAdapterSchema,
|
||||
compressionConfigSchema,
|
||||
NoCompressionAdapter,
|
||||
} from "@suaveplan/bitpacker-compressed";
|
||||
|
||||
const adapter = compressionAdapterSchema.parse(new NoCompressionAdapter());
|
||||
const cfg = compressionConfigSchema.parse({ algorithm: "none", enabled: true });
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
CompressedBitPacker,
|
||||
bitPackerCompressionOptionsSchema,
|
||||
compressionAdapterSchema,
|
||||
compressionConfigSchema,
|
||||
} from "@suaveplan/bitpacker-compressed";
|
||||
|
||||
async function loadVendorAdapter(url: URL) {
|
||||
const mod = await import(url.href);
|
||||
const adapter = compressionAdapterSchema.parse(mod.default);
|
||||
return adapter;
|
||||
}
|
||||
|
||||
const opts = bitPackerCompressionOptionsSchema.parse({
|
||||
level: 9,
|
||||
windowBits: 15,
|
||||
});
|
||||
|
||||
const cfg = compressionConfigSchema.parse({
|
||||
algorithm: "deflate",
|
||||
options: opts,
|
||||
enabled: true,
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `bitPackerCompressionOptionsSchema: z.ZodType<BitPackerCompressionOptions>`
|
||||
|
||||
Open-record schema. Accepts any object with string keys. Adapter-specific validation is the adapter's responsibility.
|
||||
|
||||
### `compressionAdapterSchema: z.ZodType<CompressionAdapter>`
|
||||
|
||||
Structural contract validator. Asserts `name: string`, `compress: function`, `decompress: function`. Identity is preserved.
|
||||
|
||||
### `compressionConfigSchema: z.ZodType<CompressionConfig>`
|
||||
|
||||
Strict schema. Three optional fields: `algorithm: string`, `options: BitPackerCompressionOptions`, `enabled: boolean`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- **Open-record options.** Adapters accept arbitrary keys (`level`, `windowBits`, `dictionary`, …). Validating them at this layer would force this package to know every adapter's surface, which it can't. The contract is "is this an object?"; semantic validation belongs to the adapter.
|
||||
- **Structural adapter check.** A duck-typed object that exposes `name/compress/decompress` works at runtime. Refusing duck-types would break the plugin model where adapters are loaded from external modules without inheriting from a base class.
|
||||
- **Identity preservation.** `z.custom` for the adapter; the registry's `register(adapter)` method uses `===` and `instanceof` checks downstream.
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { NoCompressionAdapter } from "../adapters/none/none.js";
|
||||
import {
|
||||
bitPackerCompressionOptionsSchema,
|
||||
compressionAdapterSchema,
|
||||
compressionConfigSchema,
|
||||
} from "./types.schema.js";
|
||||
|
||||
describe("bitPackerCompressionOptionsSchema", () => {
|
||||
it("accepts an empty record", () => {
|
||||
expect(bitPackerCompressionOptionsSchema.parse({})).toEqual({});
|
||||
});
|
||||
|
||||
it("accepts adapter-specific keys", () => {
|
||||
const opts = { level: 6, dictionary: new Uint8Array() };
|
||||
expect(bitPackerCompressionOptionsSchema.parse(opts)).toEqual(opts);
|
||||
});
|
||||
|
||||
it("rejects non-objects", () => {
|
||||
expect(() =>
|
||||
bitPackerCompressionOptionsSchema.parse("level=6")
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("compressionAdapterSchema", () => {
|
||||
it("accepts NoCompressionAdapter", () => {
|
||||
const adapter = new NoCompressionAdapter();
|
||||
expect(compressionAdapterSchema.parse(adapter)).toBe(adapter);
|
||||
});
|
||||
|
||||
it("rejects an object missing compress", () => {
|
||||
expect(() =>
|
||||
compressionAdapterSchema.parse({
|
||||
name: "x",
|
||||
decompress: async () => new Uint8Array(),
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects an object whose name is not a string", () => {
|
||||
expect(() =>
|
||||
compressionAdapterSchema.parse({
|
||||
name: 1,
|
||||
compress: async () => new Uint8Array(),
|
||||
decompress: async () => new Uint8Array(),
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("compressionConfigSchema", () => {
|
||||
it("accepts an empty config", () => {
|
||||
expect(compressionConfigSchema.parse({})).toEqual({});
|
||||
});
|
||||
|
||||
it("accepts a populated config", () => {
|
||||
const cfg = {
|
||||
algorithm: "gzip",
|
||||
options: { level: 6 },
|
||||
enabled: true,
|
||||
};
|
||||
expect(compressionConfigSchema.parse(cfg)).toEqual(cfg);
|
||||
});
|
||||
|
||||
it("rejects unknown keys", () => {
|
||||
expect(() =>
|
||||
compressionConfigSchema.parse({ algorithm: "gzip", bogus: 1 })
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import z from "zod";
|
||||
import type {
|
||||
BitPackerCompressionOptions,
|
||||
CompressionAdapter,
|
||||
CompressionConfig,
|
||||
} from "./types.js";
|
||||
|
||||
/**
|
||||
* Strict Zod schema for {@link BitPackerCompressionOptions}.
|
||||
*
|
||||
* The shape is an open record (`Record<string, unknown>`) because each
|
||||
* adapter accepts its own option keys. Validation is therefore a structural
|
||||
* "is this an object?" check — adapters validate their own options.
|
||||
*/
|
||||
export const bitPackerCompressionOptionsSchema = z.record(
|
||||
z.string(),
|
||||
z.unknown()
|
||||
) as unknown as z.ZodType<BitPackerCompressionOptions>;
|
||||
|
||||
/**
|
||||
* Strict Zod schema for {@link CompressionAdapter}.
|
||||
*
|
||||
* Validates that an external adapter exposes a string `name` and the two
|
||||
* required async methods (`compress`, `decompress`). Identity is preserved
|
||||
* via `z.custom` so the registered adapter keeps its prototype chain.
|
||||
*/
|
||||
export const compressionAdapterSchema = z.custom<CompressionAdapter>(
|
||||
(v): v is CompressionAdapter => {
|
||||
if (v === null || typeof v !== "object") return false;
|
||||
const obj = v as Record<string, unknown>;
|
||||
return (
|
||||
typeof obj["name"] === "string" &&
|
||||
typeof obj["compress"] === "function" &&
|
||||
typeof obj["decompress"] === "function"
|
||||
);
|
||||
},
|
||||
{
|
||||
message:
|
||||
"CompressionAdapter must expose name (string) and compress/decompress (functions)",
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Strict Zod schema for {@link CompressionConfig}.
|
||||
*
|
||||
* Validates the `compression` field of `CompressedBitPackerOptions`. Every
|
||||
* field is optional with documented defaults.
|
||||
*/
|
||||
export const compressionConfigSchema = z
|
||||
.object({
|
||||
algorithm: z.string().optional(),
|
||||
options: bitPackerCompressionOptionsSchema.optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
})
|
||||
.strict() as unknown as z.ZodType<CompressionConfig>;
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Public type companions for the compression pipeline.
|
||||
*
|
||||
* These mirror the upstream `bitpacker/src/types.ts` compression types
|
||||
* (`BitPackerCompressionAlgorithm`, `BitPackerCompressionOptions`,
|
||||
* `CompressionAdapter`) so adapters and downstream consumers see the same
|
||||
* shapes whether they imported from upstream or the genesis port.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Algorithm name used to look up an adapter in the `CompressionRegistry`.
|
||||
*
|
||||
* Default registered algorithms are `"none"`, `"gzip"`, `"deflate"`.
|
||||
* Consumers MAY register additional algorithms (e.g. `"brotli"`, `"zstd"`)
|
||||
* via `CompressionRegistry.register(adapter)`.
|
||||
*/
|
||||
export type BitPackerCompressionAlgorithm = string;
|
||||
|
||||
/**
|
||||
* Caller-supplied options forwarded to the compression / decompression call.
|
||||
* Adapter-specific (e.g. gzip level, deflate window bits) — open record so
|
||||
* future adapters can read whatever options they accept.
|
||||
*/
|
||||
export type BitPackerCompressionOptions = Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* The contract every compression adapter implements. Both methods are async
|
||||
* because the canonical browser path (`CompressionStream` / `Decompression
|
||||
* Stream`) is async; pure-byte adapters (e.g. `NoCompressionAdapter`) wrap
|
||||
* their synchronous result in `Promise.resolve` to match the interface.
|
||||
*/
|
||||
export interface CompressionAdapter {
|
||||
/** Algorithm name. Used as the registry key. */
|
||||
readonly name: BitPackerCompressionAlgorithm;
|
||||
/**
|
||||
* Compress `data` with the supplied `options`. Adapters MAY ignore options
|
||||
* they don't understand. Throws on codec failure (e.g. truncated input,
|
||||
* unsupported environment); the caller wraps as `BitpackerError(COMPRESSION_FAILED)`.
|
||||
*/
|
||||
compress(
|
||||
data: Uint8Array,
|
||||
options?: BitPackerCompressionOptions
|
||||
): Promise<Uint8Array>;
|
||||
/**
|
||||
* Decompress `data` with the supplied `options`. Throws on codec failure
|
||||
* (truncated stream, bad header). Caller wraps as
|
||||
* `BitpackerError(DECOMPRESSION_FAILED)` with the underlying error chained.
|
||||
*/
|
||||
decompress(
|
||||
data: Uint8Array,
|
||||
options?: BitPackerCompressionOptions
|
||||
): Promise<Uint8Array>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compression configuration accepted by `CompressedBitPacker`. Mirrors the
|
||||
* upstream `BitPackerConfig.compression` shape so any caller carrying that
|
||||
* shape works without re-mapping.
|
||||
*/
|
||||
export interface CompressionConfig {
|
||||
readonly algorithm?: BitPackerCompressionAlgorithm;
|
||||
readonly options?: BitPackerCompressionOptions;
|
||||
/** Default `true`. When `false`, packing skips the compression step entirely. */
|
||||
readonly enabled?: boolean;
|
||||
}
|
||||
@@ -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,37 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/bitpacker",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
"tslib",
|
||||
"zod",
|
||||
"@opentelemetry/api",
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/types",
|
||||
/^node:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
},
|
||||
},
|
||||
});
|
||||
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,
|
||||
},
|
||||
});
|
||||
61
packages/foundation/bitpacker/bitpacker-delta/README.md
Normal file
61
packages/foundation/bitpacker/bitpacker-delta/README.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# @suaveplan/bitpacker-delta - Bitpacker delta plugin
|
||||
|
||||
## Summary
|
||||
|
||||
Delta encoding utilities for efficient repeated state transmission. Detects changes between successive values and encodes differences rather than full values.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [delta-manager](./src/delta-manager/delta-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [delta-manager](./src/delta-manager/delta-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [delta-state](./src/delta-state/delta-state.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [delta-state](./src/delta-state/delta-state.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [index](./src/index.md) — **Path:** `bitpacker-delta/src/index.ts` **Export:** See source for details.
|
||||
- [string-diff](./src/string-diff.md) — **Path:** `src/string-diff.ts` **Export:** `calculateStringDiff`, `applyStringDiff`, `calculateDiffSavings`, `shouldUse…
|
||||
- [xor-encoder](./src/xor-encoder.md) — **Path:** `src/xor-encoder.ts` **Export:** `calculateXorDelta`, `applyXorDelta`, `calculateDeltaBitSize`, `isXorEfficie…
|
||||
<!-- end:auto-docs --> - [delta-manager](./src/delta-manager/delta-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [delta-state](./src/delta-state/delta-state.md) — <!-- begin:auto-docs --> _(no submodules)_- [delta-state](./src/delta-state/delta-state.md) — <!-- begin:auto-docs --> _(no submodules)_- [index](./src/index.md) — **Path:** `bitpacker-delta/src/index.ts` **Export:** See source for details.
|
||||
- [string-diff](./src/string-diff.md) — **Path:** `src/string-diff.ts` **Export:** `calculateStringDiff`, `applyStringDiff`, `calculateDiffSavings`, `shouldUse…
|
||||
- [xor-encoder](./src/xor-encoder.md) — **Path:** `src/xor-encoder.ts` **Export:** `calculateXorDelta`, `applyXorDelta`, `calculateDeltaBitSize`, `isXorEfficie…
|
||||
- [delta-manager](./src/delta-manager/delta-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [delta-state](./src/delta-state/delta-state.md) — <!-- begin:auto-docs --> _(no submodules)_- [delta-state](./src/delta-state/delta-state.md) — <!-- begin:auto-docs --> _(no submodules)_- [index](./src/index.md) — **Path:** `bitpacker-delta/src/index.ts` **Export:** See source for details.
|
||||
- [string-diff](./src/string-diff.md) — **Path:** `src/string-diff.ts` **Export:** `calculateStringDiff`, `applyStringDiff`, `calculateDiffSavings`, `shouldUse…
|
||||
- [xor-encoder](./src/xor-encoder.md) — **Path:** `src/xor-encoder.ts` **Export:** `calculateXorDelta`, `applyXorDelta`, `calculateDeltaBitSize`, `isXorEfficie…
|
||||
- [delta-manager](./src/delta-manager/delta-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [delta-state](./src/delta-state/delta-state.md) — <!-- begin:auto-docs --> _(no submodules)_- [delta-state](./src/delta-state/delta-state.md) — <!-- begin:auto-docs --> _(no submodules)_- [index](./src/index.md) — **Path:** `bitpacker-delta/src/index.ts` **Export:** See source for details.
|
||||
- [string-diff](./src/string-diff.md) — **Path:** `src/string-diff.ts` **Export:** `calculateStringDiff`, `applyStringDiff`, `calculateDiffSavings`, `shouldUse…
|
||||
- [xor-encoder](./src/xor-encoder.md) — **Path:** `src/xor-encoder.ts` **Export:** `calculateXorDelta`, `applyXorDelta`, `calculateDeltaBitSize`, `isXorEfficie…
|
||||
- [delta-manager](./src/delta-manager/delta-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [delta-state](./src/delta-state/delta-state.md) — <!-- begin:auto-docs --> _(no submodules)_ - [delta-state](./src/delta-state/delta-state.md) — <!-- begin:auto-docs --> _(no submodules)_- [index](./src/index.md) — **Path:** `bitpacker-delta/src/index.ts` **Export:** See source for details.
|
||||
- [string-diff](./src/string-diff.md) — **Path:** `src/string-diff.ts` **Export:** `calculateStringDiff`, `applyStringDiff`, `calculateDiffSavings`, `shouldUse…
|
||||
- [xor-encoder](./src/xor-encoder.md) — **Path:** `src/xor-encoder.ts` **Export:** `calculateXorDelta`, `applyXorDelta`, `calculateDeltaBitSize`, `isXorEfficie…
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/bitpacker-delta
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
import { DeltaManager, calculateStringDiff, applyStringDiff, numberXor } from "@suaveplan/bitpacker-delta";
|
||||
|
||||
// Change detection
|
||||
const dm = new DeltaManager();
|
||||
dm.detectChanges({ x: 1, y: 2 }); // returns { x: 1, y: 2 }
|
||||
dm.detectChanges({ x: 1, y: 2 }); // returns undefined (no change)
|
||||
dm.detectChanges({ x: 1, y: 3 }); // returns { x: 1, y: 3 }
|
||||
|
||||
// String diff
|
||||
const diff = calculateStringDiff("hello world", "hello there");
|
||||
// { prefixLen: 6, suffixLen: 0, middleLen: 5, middle: "there" }
|
||||
const reconstructed = applyStringDiff("hello world", diff);
|
||||
// "hello there"
|
||||
|
||||
// Numeric XOR delta
|
||||
const delta = numberXor(3.14159, 2.71828);
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
- `src/index.ts` — `DeltaManager`, change detection API
|
||||
- `src/xor-encoder.ts` — XOR-based numeric delta encoding
|
||||
- `src/string-diff.ts` — Common prefix/suffix string diff encoding
|
||||
48
packages/foundation/bitpacker/bitpacker-delta/package.json
Normal file
48
packages/foundation/bitpacker/bitpacker-delta/package.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "@suaveplan/bitpacker-delta",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Bitpacker delta plugin",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@suaveplan/bitpacker": "workspace:^",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
# DeltaManager
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`DeltaManager` is the field-level delta detector for bit-packed objects. Given a current state and a `BitPackerSchema` describing it, it returns the ordered list of fields whose value has changed since the previous snapshot stored under the same state key. Encoders use the returned `FieldChange[]` to build a fixed-width bitmask and send only the changed fields, dramatically reducing bandwidth for high-frequency state-sync workloads (game replication, multi-peer document sync, telemetry deltas).
|
||||
|
||||
This is the port of suaveplan's upstream `core/delta/delta-manager.ts`. hardening replaces upstream's `Date.now()` with injectable `Clock` (CLAUDE.md determinism rule), upstream's `JSON.stringify`-based equality with a structural walker that respects `BitPackerSchema` field order (avoiding subtle false-positive diffs from key-order changes in nested objects), upstream's plain `Error` throws with `BitpackerError` codes, and validates `DeltaConfig` via Zod with strict-mode key rejection.
|
||||
|
||||
## Features
|
||||
|
||||
- Field-indexed `FieldChange[]` output matching `Object.keys(schema)` order for stable bit offsets
|
||||
- Per-state-key snapshot store with LRU eviction (delegates to `DeltaStateManager`)
|
||||
- Configurable `maxFieldCount` cap with `BITPACKER.DELTA_FIELD_LIMIT_EXCEEDED` enforcement
|
||||
- Structural equality default (matches schema semantics) with opt-in `equalityMode: "json"` for upstream byte-compat
|
||||
- Zod-validated `DeltaConfig`; unknown keys rejected with `BITPACKER.DELTA_INVALID_CONFIG`
|
||||
- Threshold-driven `shouldUseDelta(originalBits, deltaBits)` for bandwidth-savings decisions
|
||||
- Zero `Math.random` / `Date.now` in the implementation — fully deterministic given identical inputs
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { DeltaManager } from "@suaveplan/bitpacker-delta";
|
||||
|
||||
const schema = { x: "u32", y: "u32" } as const;
|
||||
const dm = new DeltaManager({ mode: "field-level" });
|
||||
|
||||
// First call: no previous state, so no changes.
|
||||
dm.detectChanges({ x: 0, y: 0 }, schema); // → []
|
||||
dm.storePreviousState({ x: 0, y: 0 });
|
||||
|
||||
// Second call: x changed.
|
||||
dm.detectChanges({ x: 5, y: 0 }, schema);
|
||||
// → [{ index: 0, key: "x", previousValue: 0, currentValue: 5 }]
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import { DeltaManager } from "@suaveplan/bitpacker-delta";
|
||||
|
||||
const schema = {
|
||||
pos: { x: "f32", y: "f32" },
|
||||
vel: { x: "f32", y: "f32" },
|
||||
health: "u8",
|
||||
} as const;
|
||||
|
||||
// Per-peer state streams: one DeltaManager, many state keys.
|
||||
const dm = new DeltaManager(
|
||||
{ mode: "field-level", threshold: 25, maxFieldCount: 16 },
|
||||
{ maxStates: 256 } // 256 simultaneous peers
|
||||
);
|
||||
|
||||
for (const peer of peers) {
|
||||
// Each peer is its own state stream.
|
||||
const previous = dm.getPreviousState(peer.id);
|
||||
const changes = dm.detectChanges(peer.state, schema, peer.id);
|
||||
|
||||
// Only send a delta if it saves at least 25% of bits.
|
||||
const fullSize = bitWidthOf(schema);
|
||||
const deltaSize = changes.length * 32 + Math.ceil(3 / 8) * 8;
|
||||
if (dm.shouldUseDelta(fullSize, deltaSize)) {
|
||||
sendDelta(peer.id, changes);
|
||||
} else {
|
||||
sendFull(peer.id, peer.state);
|
||||
}
|
||||
|
||||
dm.storePreviousState(peer.state, peer.id);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `new DeltaManager(config?: DeltaConfig, options?: DeltaManagerOptions)`
|
||||
|
||||
`config.mode` ∈ `"none" | "xor" | "string-diff" | "field-level"`. Default `"none"`.
|
||||
`config.threshold` — bandwidth-savings %, 0–100. Default `0`.
|
||||
`config.maxFieldCount` — max fields the bitmask can address. Default `64`.
|
||||
`config.stateKey` — default key for `detectChanges` calls without one. Default `"default"`.
|
||||
`config.equalityMode` — `"structural"` (default) or `"json"` (upstream-compat).
|
||||
`options.maxStates` — capacity of the underlying state store. Default `100`.
|
||||
|
||||
Throws `BitpackerError(BITPACKER.DELTA_INVALID_CONFIG)` on invalid config.
|
||||
|
||||
### `detectChanges(current, schema, stateKey?) → FieldChange[]`
|
||||
|
||||
Returns the ordered list of fields whose `current` value differs from the stored snapshot under `stateKey`. First call (no snapshot) returns `[]`. Schema with more keys than `maxFieldCount` throws `BITPACKER.DELTA_FIELD_LIMIT_EXCEEDED`.
|
||||
|
||||
### `storePreviousState(state, stateKey?) → void`
|
||||
|
||||
Stores a deep-cloned snapshot of `state` for the next diff.
|
||||
|
||||
### `getPreviousState(stateKey?) → unknown | undefined`
|
||||
|
||||
### `hasPreviousState(stateKey?) → boolean`
|
||||
|
||||
Snapshot inspection helpers.
|
||||
|
||||
### `calculateSavings(originalBits, deltaBits) → number`
|
||||
|
||||
### `shouldUseDelta(originalBits, deltaBits) → boolean`
|
||||
|
||||
Bandwidth-savings calculation against the configured `threshold`.
|
||||
|
||||
### `clear() → void`
|
||||
|
||||
### `clearState(stateKey) → void`
|
||||
|
||||
### `getConfig() → Readonly<Required<DeltaConfig>>`
|
||||
|
||||
Lifecycle and inspection helpers.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The structural-equality walker treats every `ArrayBuffer.isView` value (every typed-array variant) as a byte-by-byte comparison. `Date` instances compare via `getTime()`. Plain objects compare key-sets first (size + key membership), then recurse on values. Arrays compare length-then-elements. This matches `BitPackerSchema` semantics: two values that pack to identical bytes are equal.
|
||||
|
||||
The `"json"` equality mode is provided for upstream byte-compat but has known limitations: `JSON.stringify({ a: 1, b: 2 })` and `JSON.stringify({ b: 2, a: 1 })` produce different strings even though the values are semantically equal in a `BitPackerSchema` context. Consumers porting from upstream should switch to `"structural"` once they've verified no callers depend on key-order-sensitive equality.
|
||||
|
||||
The `maxFieldCount` cap is a defence: a hostile peer that sends a schema with thousands of fields could exhaust memory if the bitmask were unbounded. The default (64) accommodates typical state-sync workloads; consumers with larger schemas raise it explicitly.
|
||||
|
||||
Threshold semantics: `shouldUseDelta` returns `true` when `(originalBits - deltaBits) / originalBits * 100 >= threshold`. A `threshold` of `0` means "always prefer delta when smaller"; a `threshold` of `25` means "only prefer delta if it saves at least a quarter of the bits."
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Spec-conformance tests for DeltaManager. These tests pin the behaviours
|
||||
* documented in `openspec/changes/bitpacker-stub-completion/specs/bitpacker-delta/spec.md`
|
||||
* and the audit-driven additions: invalid-config rejection via Zod, no
|
||||
* module-scope determinism violations.
|
||||
*/
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { BITPACKER_CODES, BitpackerError } from "@suaveplan/bitpacker";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { BitPackerSchema } from "@suaveplan/types";
|
||||
import { type DeltaConfig, DeltaManager } from "./delta-manager.js";
|
||||
|
||||
const __dirname = fileURLToPath(new URL(".", import.meta.url));
|
||||
const SRC_ROOT = join(__dirname, "..");
|
||||
|
||||
describe("DeltaManager spec conformance", () => {
|
||||
it("rejects invalid DeltaConfig with DELTA_INVALID_CONFIG (unknown mode)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const bad = { mode: "magic" } as unknown as DeltaConfig;
|
||||
expect(() => new DeltaManager(bad)).toThrow(BitpackerError);
|
||||
try {
|
||||
new DeltaManager(bad);
|
||||
} catch (e) {
|
||||
expect((e as BitpackerError).code).toBe(
|
||||
BITPACKER_CODES.DELTA_INVALID_CONFIG
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects invalid DeltaConfig with DELTA_INVALID_CONFIG (unknown key)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const bad = {
|
||||
mode: "field-level",
|
||||
wrongKey: 123,
|
||||
} as unknown as DeltaConfig;
|
||||
expect(() => new DeltaManager(bad)).toThrow(BitpackerError);
|
||||
try {
|
||||
new DeltaManager(bad);
|
||||
} catch (e) {
|
||||
expect((e as BitpackerError).code).toBe(
|
||||
BITPACKER_CODES.DELTA_INVALID_CONFIG
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects invalid DeltaConfig with DELTA_INVALID_CONFIG (out-of-range threshold)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const bad = {
|
||||
mode: "field-level",
|
||||
threshold: 150,
|
||||
} as unknown as DeltaConfig;
|
||||
expect(() => new DeltaManager(bad)).toThrow(BitpackerError);
|
||||
});
|
||||
|
||||
it("uses structural equality by default (key-order-insensitive on nested objects)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const dm = new DeltaManager({ mode: "field-level" });
|
||||
const schema = { o: "u32" } as Record<string, BitPackerSchema>;
|
||||
dm.storePreviousState({ o: { a: 1, b: 2 } });
|
||||
// Different declaration order, same data:
|
||||
const changes = dm.detectChanges({ o: { b: 2, a: 1 } }, schema);
|
||||
expect(changes).toEqual([]);
|
||||
});
|
||||
|
||||
it("opts into JSON equality when equalityMode is 'json'", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const dm = new DeltaManager({
|
||||
mode: "field-level",
|
||||
equalityMode: "json",
|
||||
});
|
||||
const schema = { o: "u32" } as Record<string, BitPackerSchema>;
|
||||
dm.storePreviousState({ o: { a: 1, b: 2 } });
|
||||
const changes = dm.detectChanges({ o: { b: 2, a: 1 } }, schema);
|
||||
// JSON.stringify is sensitive to key order — flagged as change.
|
||||
expect(changes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("no module-scope Date.now in implementation files", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const matches = grepImpl(/Date\.now\(\)/g);
|
||||
expect(matches, JSON.stringify(matches, null, 2)).toEqual([]);
|
||||
});
|
||||
|
||||
it("no module-scope Math.random in implementation files", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const matches = grepImpl(/Math\.random\(\)/g);
|
||||
expect(matches, JSON.stringify(matches, null, 2)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Recursively walk `src/`, collect every `.ts` that's NOT a test file, and
|
||||
* return all matches of `pattern` in non-comment lines. Used to verify the
|
||||
* determinism rule. Comment lines are skipped because docblocks legitimately
|
||||
* mention the forbidden tokens to explain the rule.
|
||||
*/
|
||||
function grepImpl(
|
||||
pattern: RegExp
|
||||
): readonly { file: string; line: number; text: string }[] {
|
||||
const matches: { file: string; line: number; text: string }[] = [];
|
||||
const visit = (dir: string): void => {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
const st = statSync(full);
|
||||
if (st.isDirectory()) {
|
||||
visit(full);
|
||||
continue;
|
||||
}
|
||||
if (!entry.endsWith(".ts")) continue;
|
||||
if (entry.endsWith(".test.ts")) continue;
|
||||
if (entry.endsWith(".spec-conformance.test.ts")) continue;
|
||||
const content = readFileSync(full, "utf8");
|
||||
const lines = content.split("\n");
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const raw = lines[i] ?? "";
|
||||
if (isCommentLine(raw)) continue;
|
||||
if (pattern.test(raw)) {
|
||||
matches.push({
|
||||
file: full,
|
||||
line: i + 1,
|
||||
text: raw.trim(),
|
||||
});
|
||||
}
|
||||
pattern.lastIndex = 0; // reset for /g
|
||||
}
|
||||
}
|
||||
};
|
||||
visit(SRC_ROOT);
|
||||
return matches;
|
||||
}
|
||||
|
||||
function isCommentLine(line: string): boolean {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed === "") return true;
|
||||
if (trimmed.startsWith("//")) return true;
|
||||
if (trimmed.startsWith("*")) return true; // inside /** ... */
|
||||
if (trimmed.startsWith("/*")) return true;
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
import { BITPACKER_CODES, BitpackerError } from "@suaveplan/bitpacker";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { BitPackerSchema } from "@suaveplan/types";
|
||||
import { DeltaManager } from "./delta-manager.js";
|
||||
|
||||
describe("DeltaManager", () => {
|
||||
it("returns empty array on first call for a state key", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const dm = new DeltaManager({ mode: "field-level" });
|
||||
const schema = { a: "u8" } as Record<string, BitPackerSchema>;
|
||||
const result = dm.detectChanges({ a: 1 }, schema);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("detects changed fields in schema order", { timeout: 5000 }, () => {
|
||||
const dm = new DeltaManager({ mode: "field-level" });
|
||||
const schema = {
|
||||
a: "u8",
|
||||
b: "-32",
|
||||
c: "u16",
|
||||
} as Record<string, BitPackerSchema>;
|
||||
|
||||
dm.storePreviousState({ a: 1, b: "x", c: 100 });
|
||||
const changes = dm.detectChanges({ a: 1, b: "y", c: 200 }, schema);
|
||||
|
||||
expect(changes).toHaveLength(2);
|
||||
expect(changes[0]).toEqual({
|
||||
index: 1,
|
||||
key: "b",
|
||||
previousValue: "x",
|
||||
currentValue: "y",
|
||||
});
|
||||
expect(changes[1]).toEqual({
|
||||
index: 2,
|
||||
key: "c",
|
||||
previousValue: 100,
|
||||
currentValue: 200,
|
||||
});
|
||||
});
|
||||
|
||||
it("detects no changes when values are identical (primitives)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const dm = new DeltaManager({ mode: "field-level" });
|
||||
const schema = { a: "u8" } as Record<string, BitPackerSchema>;
|
||||
dm.storePreviousState({ a: 1 });
|
||||
expect(dm.detectChanges({ a: 1 }, schema)).toEqual([]);
|
||||
});
|
||||
|
||||
it("structural equality treats different key orders as equal in nested objects", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const dm = new DeltaManager({ mode: "field-level" });
|
||||
const schema = { pos: "u32" } as Record<string, BitPackerSchema>;
|
||||
dm.storePreviousState({ pos: { x: 1, y: 2 } });
|
||||
const changes = dm.detectChanges({ pos: { y: 2, x: 1 } }, schema);
|
||||
expect(changes).toEqual([]);
|
||||
});
|
||||
|
||||
it("json equality mode flags key-order-different nested objects as changes", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const dm = new DeltaManager({
|
||||
mode: "field-level",
|
||||
equalityMode: "json",
|
||||
});
|
||||
const schema = { pos: "u32" } as Record<string, BitPackerSchema>;
|
||||
dm.storePreviousState({ pos: { x: 1, y: 2 } });
|
||||
const changes = dm.detectChanges({ pos: { y: 2, x: 1 } }, schema);
|
||||
// JSON stringify is sensitive to key order — upstream-compat mode.
|
||||
expect(changes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("throws DELTA_FIELD_LIMIT_EXCEEDED when schema exceeds maxFieldCount", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const dm = new DeltaManager({
|
||||
mode: "field-level",
|
||||
maxFieldCount: 4,
|
||||
});
|
||||
const schema = {
|
||||
a: "u8",
|
||||
b: "u8",
|
||||
c: "u8",
|
||||
d: "u8",
|
||||
e: "u8",
|
||||
} as Record<string, BitPackerSchema>;
|
||||
dm.storePreviousState({ a: 1, b: 2, c: 3, d: 4, e: 5 });
|
||||
expect(() =>
|
||||
dm.detectChanges({ a: 1, b: 2, c: 3, d: 4, e: 5 }, schema)
|
||||
).toThrow(BitpackerError);
|
||||
try {
|
||||
dm.detectChanges({ a: 1, b: 2, c: 3, d: 4, e: 5 }, schema);
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(BitpackerError);
|
||||
expect((e as BitpackerError).code).toBe(
|
||||
BITPACKER_CODES.DELTA_FIELD_LIMIT_EXCEEDED
|
||||
);
|
||||
expect((e as BitpackerError).context).toMatchObject({
|
||||
fieldCount: 5,
|
||||
max: 4,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("scopes diffs by stateKey — multiple peers don't cross-contaminate", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const dm = new DeltaManager({ mode: "field-level" });
|
||||
const schema = { v: "u32" } as Record<string, BitPackerSchema>;
|
||||
dm.storePreviousState({ v: 100 }, "peer-A");
|
||||
dm.storePreviousState({ v: 200 }, "peer-B");
|
||||
|
||||
// Diffing peer-A's current against peer-A's snapshot:
|
||||
expect(dm.detectChanges({ v: 105 }, schema, "peer-A")).toHaveLength(1);
|
||||
// peer-B's current value happens to equal its snapshot:
|
||||
expect(dm.detectChanges({ v: 200 }, schema, "peer-B")).toEqual([]);
|
||||
});
|
||||
|
||||
it("storePreviousState deep-clones the snapshot", { timeout: 5000 }, () => {
|
||||
const dm = new DeltaManager({ mode: "field-level" });
|
||||
const schema = {
|
||||
obj: "u32",
|
||||
} as Record<string, BitPackerSchema>;
|
||||
const original = { obj: { x: 1 } };
|
||||
dm.storePreviousState(original);
|
||||
(original.obj as { x: number }).x = 999;
|
||||
|
||||
// Diff against current (matches the original pre-mutation state):
|
||||
const changes = dm.detectChanges({ obj: { x: 1 } }, schema);
|
||||
expect(changes).toEqual([]);
|
||||
});
|
||||
|
||||
it("calculateSavings is correct", { timeout: 5000 }, () => {
|
||||
const dm = new DeltaManager({ mode: "field-level" });
|
||||
expect(dm.calculateSavings(100, 25)).toBe(75);
|
||||
expect(dm.calculateSavings(100, 100)).toBe(0);
|
||||
expect(dm.calculateSavings(0, 0)).toBe(0); // /0 guard
|
||||
});
|
||||
|
||||
it("shouldUseDelta respects threshold", { timeout: 5000 }, () => {
|
||||
const dm = new DeltaManager({
|
||||
mode: "field-level",
|
||||
threshold: 25,
|
||||
});
|
||||
expect(dm.shouldUseDelta(100, 80)).toBe(false); // 20% savings, < 25%
|
||||
expect(dm.shouldUseDelta(100, 75)).toBe(true); // 25% savings, exactly threshold
|
||||
expect(dm.shouldUseDelta(100, 50)).toBe(true); // 50% savings
|
||||
});
|
||||
|
||||
it("getPreviousState / hasPreviousState reflect storePreviousState", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const dm = new DeltaManager({ mode: "field-level" });
|
||||
expect(dm.hasPreviousState()).toBe(false);
|
||||
expect(dm.getPreviousState()).toBeUndefined();
|
||||
|
||||
dm.storePreviousState({ x: 1 });
|
||||
expect(dm.hasPreviousState()).toBe(true);
|
||||
expect(dm.getPreviousState()).toEqual({ x: 1 });
|
||||
|
||||
dm.storePreviousState({ x: 2 }, "peer-A");
|
||||
expect(dm.hasPreviousState("peer-A")).toBe(true);
|
||||
expect(dm.getPreviousState("peer-A")).toEqual({ x: 2 });
|
||||
expect(dm.hasPreviousState("missing")).toBe(false);
|
||||
expect(dm.getPreviousState("missing")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clear drops all snapshots", { timeout: 5000 }, () => {
|
||||
const dm = new DeltaManager({ mode: "field-level" });
|
||||
dm.storePreviousState({ a: 1 }, "k1");
|
||||
dm.storePreviousState({ a: 2 }, "k2");
|
||||
dm.clear();
|
||||
expect(dm.hasPreviousState("k1")).toBe(false);
|
||||
expect(dm.hasPreviousState("k2")).toBe(false);
|
||||
});
|
||||
|
||||
it("clearState drops one snapshot", { timeout: 5000 }, () => {
|
||||
const dm = new DeltaManager({ mode: "field-level" });
|
||||
dm.storePreviousState({ a: 1 }, "k1");
|
||||
dm.storePreviousState({ a: 2 }, "k2");
|
||||
dm.clearState("k1");
|
||||
expect(dm.hasPreviousState("k1")).toBe(false);
|
||||
expect(dm.hasPreviousState("k2")).toBe(true);
|
||||
});
|
||||
|
||||
it("constructor with no args defaults to { mode: 'none' }", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const dm = new DeltaManager();
|
||||
expect(dm.getConfig().mode).toBe("none");
|
||||
});
|
||||
|
||||
it("structural equality treats null vs object as different", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const dm = new DeltaManager({ mode: "field-level" });
|
||||
const schema = { v: "u32" } as Record<string, BitPackerSchema>;
|
||||
// First store: previous = { v: { x: 1 } }
|
||||
dm.storePreviousState({ v: { x: 1 } });
|
||||
// Current has v: null — null vs object asymmetric branches
|
||||
const changes = dm.detectChanges(
|
||||
{ v: null as unknown as object },
|
||||
schema
|
||||
);
|
||||
expect(changes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("structural equality treats different typeof as different", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const dm = new DeltaManager({ mode: "field-level" });
|
||||
const schema = { v: "u32" } as Record<string, BitPackerSchema>;
|
||||
dm.storePreviousState({ v: 1 });
|
||||
const changes = dm.detectChanges(
|
||||
{ v: "1" as unknown as number },
|
||||
schema
|
||||
);
|
||||
expect(changes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("structural equality detects typed-array length mismatch", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const dm = new DeltaManager({ mode: "field-level" });
|
||||
const schema = { buf: "u32" } as Record<string, BitPackerSchema>;
|
||||
dm.storePreviousState({ buf: new Uint8Array([1, 2, 3]) });
|
||||
const changes = dm.detectChanges(
|
||||
{ buf: new Uint8Array([1, 2]) },
|
||||
schema
|
||||
);
|
||||
expect(changes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("structural equality detects object key-count mismatch", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const dm = new DeltaManager({ mode: "field-level" });
|
||||
const schema = { o: "u32" } as Record<string, BitPackerSchema>;
|
||||
dm.storePreviousState({ o: { a: 1, b: 2 } });
|
||||
const changes = dm.detectChanges(
|
||||
{ o: { a: 1 } as unknown as { a: number; b: number } },
|
||||
schema
|
||||
);
|
||||
expect(changes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("structural equality detects object disjoint-keys mismatch", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const dm = new DeltaManager({ mode: "field-level" });
|
||||
const schema = { o: "u32" } as Record<string, BitPackerSchema>;
|
||||
dm.storePreviousState({ o: { a: 1, b: 2 } });
|
||||
// Same key count but different keys
|
||||
const changes = dm.detectChanges(
|
||||
{
|
||||
o: { a: 1, c: 2 } as unknown as { a: number; b: number },
|
||||
},
|
||||
schema
|
||||
);
|
||||
expect(changes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("structural equality detects object same-keys-different-values", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const dm = new DeltaManager({ mode: "field-level" });
|
||||
const schema = { o: "u32" } as Record<string, BitPackerSchema>;
|
||||
dm.storePreviousState({ o: { a: 1, b: 2 } });
|
||||
// Same keys, different value for `b`
|
||||
const changes = dm.detectChanges({ o: { a: 1, b: 999 } }, schema);
|
||||
expect(changes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("getConfig returns the resolved config", { timeout: 5000 }, () => {
|
||||
const dm = new DeltaManager({ mode: "xor", threshold: 50 });
|
||||
const cfg = dm.getConfig();
|
||||
expect(cfg.mode).toBe("xor");
|
||||
expect(cfg.threshold).toBe(50);
|
||||
expect(cfg.xorNumbers).toBe(true); // default
|
||||
expect(cfg.diffStrings).toBe(false); // default
|
||||
expect(cfg.maxFieldCount).toBe(64); // default
|
||||
expect(cfg.stateKey).toBe("default"); // default
|
||||
expect(cfg.equalityMode).toBe("structural"); // default
|
||||
});
|
||||
|
||||
it("structural equality compares typed arrays element-wise", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const dm = new DeltaManager({ mode: "field-level" });
|
||||
const schema = { buf: "u32" } as Record<string, BitPackerSchema>;
|
||||
dm.storePreviousState({ buf: new Uint8Array([1, 2, 3]) });
|
||||
|
||||
// Same content → no change
|
||||
expect(
|
||||
dm.detectChanges({ buf: new Uint8Array([1, 2, 3]) }, schema)
|
||||
).toEqual([]);
|
||||
|
||||
// Different content → one change
|
||||
expect(
|
||||
dm.detectChanges({ buf: new Uint8Array([1, 2, 4]) }, schema)
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("structural equality compares arrays length-then-elements", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const dm = new DeltaManager({ mode: "field-level" });
|
||||
const schema = { arr: "u32" } as Record<string, BitPackerSchema>;
|
||||
dm.storePreviousState({ arr: [1, 2, 3] });
|
||||
expect(dm.detectChanges({ arr: [1, 2, 3] }, schema)).toEqual([]);
|
||||
expect(dm.detectChanges({ arr: [1, 2] }, schema)).toHaveLength(1); // length differs
|
||||
expect(dm.detectChanges({ arr: [1, 2, 4] }, schema)).toHaveLength(1); // element differs
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* Field-level delta detector for bit-packed objects.
|
||||
*
|
||||
* Ported from suaveplan upstream `core/delta/delta-manager.ts`. Genesis-side
|
||||
* hardening:
|
||||
* - Replaces upstream's `Date.now()` with an injectable `Clock` (CLAUDE.md
|
||||
* determinism rule).
|
||||
* - Replaces upstream's `JSON.stringify`-based equality (which is sensitive
|
||||
* to key order) with a structural walker that respects `BitPackerSchema`
|
||||
* field order. The legacy stringify behaviour is available behind opt-in
|
||||
* `equalityMode: "json"` for byte-compat with upstream payloads.
|
||||
* - Replaces upstream's plain-`Error` throws with `BitpackerError` codes.
|
||||
* - Validates `DeltaConfig` via Zod at construction; unknown fields and
|
||||
* invalid `mode` values are rejected with `BITPACKER.DELTA_INVALID_CONFIG`.
|
||||
*/
|
||||
|
||||
import { BITPACKER_CODES, BitpackerError } from "@suaveplan/bitpacker";
|
||||
import type { BitPackerSchema } from "@suaveplan/types";
|
||||
import z from "zod";
|
||||
import { DeltaStateManager } from "../delta-state/delta-state.js";
|
||||
|
||||
const DEFAULT_STATE_KEY = "default";
|
||||
const DEFAULT_MAX_FIELD_COUNT = 64;
|
||||
const DEFAULT_THRESHOLD = 0;
|
||||
|
||||
/**
|
||||
* One field that changed between the previous and current state. Indexed by
|
||||
* `Object.keys(schema)` order so encoders can rely on stable bit offsets.
|
||||
*/
|
||||
export interface FieldChange {
|
||||
readonly index: number;
|
||||
readonly key: string;
|
||||
readonly previousValue: unknown;
|
||||
readonly currentValue: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration accepted by `new DeltaManager`. Validated via Zod at
|
||||
* construction; unknown keys are rejected.
|
||||
*/
|
||||
export interface DeltaConfig {
|
||||
/** Strategy mode. `"none"` returns the value unchanged on every detect. */
|
||||
readonly mode: "none" | "xor" | "string-diff" | "field-level";
|
||||
/** XOR numeric fields when packing the delta. Default `true`. */
|
||||
readonly xorNumbers?: boolean;
|
||||
/** Diff string fields when packing. Default `false`. */
|
||||
readonly diffStrings?: boolean;
|
||||
/** Bandwidth-saving threshold (% bits saved) before delta is preferred. Default `0`. */
|
||||
readonly threshold?: number;
|
||||
/** Maximum fields per object the field-level mask can address. Default `64`. */
|
||||
readonly maxFieldCount?: number;
|
||||
/** Default state key for `detectChanges` calls without one. */
|
||||
readonly stateKey?: string;
|
||||
/**
|
||||
* `"structural"` (default) walks the value respecting schema field order.
|
||||
* `"json"` falls back to `JSON.stringify` equality (upstream-compat mode).
|
||||
*/
|
||||
readonly equalityMode?: "structural" | "json";
|
||||
}
|
||||
|
||||
const DeltaConfigSchema = z
|
||||
.object({
|
||||
mode: z.enum(["none", "xor", "string-diff", "field-level"]),
|
||||
xorNumbers: z.boolean().optional(),
|
||||
diffStrings: z.boolean().optional(),
|
||||
threshold: z.number().min(0).max(100).optional(),
|
||||
maxFieldCount: z.number().int().min(1).optional(),
|
||||
stateKey: z.string().min(1).optional(),
|
||||
equalityMode: z.enum(["structural", "json"]).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
type ResolvedConfig = Required<DeltaConfig>;
|
||||
|
||||
/**
|
||||
* Detects field-level changes between successive states of an object.
|
||||
*
|
||||
* Stateful: each `DeltaManager` owns a `DeltaStateManager` keyed by string.
|
||||
* Consumers store one snapshot per logical state stream (per peer, per
|
||||
* session, etc.) and call `detectChanges(current, schema)` on each tick to
|
||||
* get an ordered `FieldChange[]`. The returned indices match
|
||||
* `Object.keys(schema)` order so encoders can pack a fixed-width bitmask.
|
||||
*/
|
||||
export class DeltaManager {
|
||||
private readonly stateManager: DeltaStateManager;
|
||||
private readonly config: ResolvedConfig;
|
||||
|
||||
public constructor(
|
||||
config: DeltaConfig = { mode: "none" },
|
||||
options: DeltaManagerOptions = {}
|
||||
) {
|
||||
const parsed = DeltaConfigSchema.safeParse(config);
|
||||
if (!parsed.success) {
|
||||
throw new BitpackerError(
|
||||
BITPACKER_CODES.DELTA_INVALID_CONFIG,
|
||||
`Invalid DeltaConfig: ${parsed.error.message}`,
|
||||
{ context: { issues: parsed.error.issues } }
|
||||
);
|
||||
}
|
||||
this.stateManager = new DeltaStateManager(options.maxStates);
|
||||
this.config = {
|
||||
mode: parsed.data.mode,
|
||||
xorNumbers: parsed.data.xorNumbers ?? true,
|
||||
diffStrings: parsed.data.diffStrings ?? false,
|
||||
threshold: parsed.data.threshold ?? DEFAULT_THRESHOLD,
|
||||
maxFieldCount: parsed.data.maxFieldCount ?? DEFAULT_MAX_FIELD_COUNT,
|
||||
stateKey: parsed.data.stateKey ?? DEFAULT_STATE_KEY,
|
||||
equalityMode: parsed.data.equalityMode ?? "structural",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the ordered list of fields whose value changed between the
|
||||
* stored previous state (under `stateKey ?? config.stateKey`) and
|
||||
* `current`. On the first call for a state key (no previous stored), the
|
||||
* result is an empty array. Field indices match `Object.keys(schema)`.
|
||||
*/
|
||||
public detectChanges(
|
||||
current: Record<string, unknown>,
|
||||
schema: Record<string, BitPackerSchema>,
|
||||
stateKey?: string
|
||||
): FieldChange[] {
|
||||
const fieldKeys = Object.keys(schema);
|
||||
if (fieldKeys.length > this.config.maxFieldCount) {
|
||||
throw new BitpackerError(
|
||||
BITPACKER_CODES.DELTA_FIELD_LIMIT_EXCEEDED,
|
||||
`Schema has ${fieldKeys.length} fields; maxFieldCount is ${this.config.maxFieldCount}`,
|
||||
{
|
||||
context: {
|
||||
fieldCount: fieldKeys.length,
|
||||
max: this.config.maxFieldCount,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const key = stateKey ?? this.config.stateKey;
|
||||
const previous = this.stateManager.getPreviousState(key) as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
|
||||
if (previous === undefined) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const changes: FieldChange[] = [];
|
||||
for (let index = 0; index < fieldKeys.length; index++) {
|
||||
// `Object.keys` always returns defined strings, so the
|
||||
// index-access is sound; cast tames `noUncheckedIndexedAccess`.
|
||||
const fieldKey = fieldKeys[index] as string;
|
||||
const currentValue = current[fieldKey];
|
||||
const previousValue = previous[fieldKey];
|
||||
|
||||
if (!this.valuesEqual(currentValue, previousValue)) {
|
||||
changes.push({
|
||||
index,
|
||||
key: fieldKey,
|
||||
previousValue,
|
||||
currentValue,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store `state` as the new "previous" snapshot under `key`. The next
|
||||
* `detectChanges` call will diff against this. Snapshot is deep-cloned by
|
||||
* the underlying `DeltaStateManager`.
|
||||
*/
|
||||
public storePreviousState(state: unknown, stateKey?: string): void {
|
||||
this.stateManager.storePreviousState(
|
||||
stateKey ?? this.config.stateKey,
|
||||
state
|
||||
);
|
||||
}
|
||||
|
||||
public getPreviousState(stateKey?: string): unknown | undefined {
|
||||
return this.stateManager.getPreviousState(
|
||||
stateKey ?? this.config.stateKey
|
||||
);
|
||||
}
|
||||
|
||||
public hasPreviousState(stateKey?: string): boolean {
|
||||
return this.stateManager.hasPreviousState(
|
||||
stateKey ?? this.config.stateKey
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a savings percentage. `0%` means delta is the same size as
|
||||
* full; `100%` means delta is empty. Used by `shouldUseDelta` to decide
|
||||
* whether the delta is worth sending.
|
||||
*/
|
||||
public calculateSavings(originalBits: number, deltaBits: number): number {
|
||||
if (originalBits === 0) return 0;
|
||||
return ((originalBits - deltaBits) / originalBits) * 100;
|
||||
}
|
||||
|
||||
public shouldUseDelta(originalBits: number, deltaBits: number): boolean {
|
||||
return (
|
||||
this.calculateSavings(originalBits, deltaBits) >=
|
||||
this.config.threshold
|
||||
);
|
||||
}
|
||||
|
||||
public clear(): void {
|
||||
this.stateManager.clear();
|
||||
}
|
||||
|
||||
public clearState(stateKey: string): void {
|
||||
this.stateManager.clearState(stateKey);
|
||||
}
|
||||
|
||||
public getConfig(): Readonly<ResolvedConfig> {
|
||||
return this.config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structural deep equality respecting bit-packer field semantics.
|
||||
*
|
||||
* - In `"json"` mode, falls back to `JSON.stringify` equality
|
||||
* (upstream-compat — sensitive to key order and not safe for typed
|
||||
* arrays without custom replacer).
|
||||
* - In `"structural"` (default), walks values: primitives compare via
|
||||
* `===`, typed arrays compare element-wise, arrays compare deeply,
|
||||
* plain objects compare key-set-then-values.
|
||||
*
|
||||
* Note: Maps and Sets are NOT supported in structural mode (they would
|
||||
* never be packed via a `BitPackerSchema` anyway). If supplied they
|
||||
* compare by reference (identity).
|
||||
*/
|
||||
private valuesEqual(a: unknown, b: unknown): boolean {
|
||||
if (this.config.equalityMode === "json") {
|
||||
return JSON.stringify(a) === JSON.stringify(b);
|
||||
}
|
||||
|
||||
if (a === b) return true;
|
||||
if (a == null || b == null) return false;
|
||||
if (typeof a !== typeof b) return false;
|
||||
|
||||
if (Array.isArray(a) && Array.isArray(b)) {
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (!this.valuesEqual(a[i], b[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (ArrayBuffer.isView(a) && ArrayBuffer.isView(b)) {
|
||||
const aArr = new Uint8Array(a.buffer, a.byteOffset, a.byteLength);
|
||||
const bArr = new Uint8Array(b.buffer, b.byteOffset, b.byteLength);
|
||||
if (aArr.length !== bArr.length) return false;
|
||||
for (let i = 0; i < aArr.length; i++) {
|
||||
if (aArr[i] !== bArr[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof a === "object" && typeof b === "object") {
|
||||
const ao = a as Record<string, unknown>;
|
||||
const bo = b as Record<string, unknown>;
|
||||
const aKeys = Object.keys(ao);
|
||||
const bKeys = Object.keys(bo);
|
||||
if (aKeys.length !== bKeys.length) return false;
|
||||
for (const key of aKeys) {
|
||||
if (!Object.hasOwn(bo, key)) return false;
|
||||
if (!this.valuesEqual(ao[key], bo[key])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Construction-time options for `DeltaManager`. Distinct from runtime
|
||||
* `DeltaConfig` (which controls behaviour); this controls the underlying
|
||||
* state-store capacity.
|
||||
*/
|
||||
export interface DeltaManagerOptions {
|
||||
/** Capacity of the underlying `DeltaStateManager`. Default 100. */
|
||||
readonly maxStates?: number;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
# DeltaStateManager
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`DeltaStateManager` keeps deep-cloned snapshots of previous values per state key, so that a delta encoder can diff the current value against its predecessor without worrying about caller-side mutations. It is the storage layer for `DeltaManager`; consumers usually go through `DeltaManager` rather than calling this class directly.
|
||||
|
||||
The store is bounded: an LRU eviction policy drops the oldest key once the configured capacity is reached. Default capacity is 100 keys, which is plenty for most workloads (one key per per-peer state stream). Re-storing under an existing key updates the value in place without advancing its LRU position — this matches upstream semantics so that frequently-updated keys do not unfairly evict rarely-updated ones.
|
||||
|
||||
## Features
|
||||
|
||||
- Deep-cloning on store: `Date`, all typed-array variants (`Uint8Array`, `Int8Array`, `Uint16Array`, `Int16Array`, `Uint32Array`, `Int32Array`, `Float32Array`, `Float64Array`), arrays, and plain objects are all isolated from caller mutations.
|
||||
- Insertion-order LRU eviction; `maxStates` configurable (default 100).
|
||||
- `clear()`, `clearState(key)`, `getStateCount()`, `getMaxStates()` accessors for inspection and reset.
|
||||
- Zero `Math.random` / `Date.now` calls — fully deterministic given identical inputs.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { DeltaStateManager } from "@suaveplan/bitpacker-delta";
|
||||
|
||||
const states = new DeltaStateManager(100);
|
||||
|
||||
states.storePreviousState("player-1", { x: 0, y: 0 });
|
||||
const previous = states.getPreviousState("player-1");
|
||||
// → { x: 0, y: 0 }
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import { DeltaStateManager } from "@suaveplan/bitpacker-delta";
|
||||
|
||||
const states = new DeltaStateManager(3);
|
||||
states.storePreviousState("a", { v: 1 });
|
||||
states.storePreviousState("b", { v: 2 });
|
||||
states.storePreviousState("c", { v: 3 });
|
||||
states.storePreviousState("d", { v: 4 }); // evicts "a"
|
||||
|
||||
states.hasPreviousState("a"); // → false (evicted)
|
||||
states.hasPreviousState("d"); // → true
|
||||
|
||||
// Mutating the original after storing does NOT touch the snapshot:
|
||||
const original = { x: 0 };
|
||||
states.storePreviousState("e", original);
|
||||
original.x = 999;
|
||||
states.getPreviousState("e"); // → { x: 0 }
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `new DeltaStateManager(maxStates?: number)`
|
||||
|
||||
- `maxStates` — positive integer; default `100`. Throws `RangeError` if non-integer or `< 1`.
|
||||
|
||||
### `storePreviousState(key: string, state: unknown): void`
|
||||
|
||||
Stores a deep-cloned copy of `state` under `key`. If `key` already exists, the value is updated in place; LRU position is unchanged. If the store is at capacity and this is a new key, the oldest key is evicted.
|
||||
|
||||
### `getPreviousState(key: string): unknown | undefined`
|
||||
|
||||
Returns the stored snapshot, or `undefined` if the key has never been stored or has been evicted.
|
||||
|
||||
### `hasPreviousState(key: string): boolean`
|
||||
|
||||
True if a snapshot exists for `key`.
|
||||
|
||||
### `clear(): void`
|
||||
|
||||
Drops every snapshot.
|
||||
|
||||
### `clearState(key: string): void`
|
||||
|
||||
Drops the snapshot for one key. No-op if absent.
|
||||
|
||||
### `getStateCount(): number`
|
||||
|
||||
Number of stored snapshots.
|
||||
|
||||
### `getMaxStates(): number`
|
||||
|
||||
Configured maximum capacity.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The deep-clone walker handles the eight typed-array variants explicitly because the generic object-clone path (`Object.hasOwn` over keys) does not preserve `Uint8Array`-vs-plain-array distinction. Maps and Sets are intentionally NOT cloned: delta-state semantics assume keys are strings and values are plain data; if a consumer needs `Map`/`Set`, they should canonicalise to a plain object first.
|
||||
|
||||
Performance: insertion is O(1) amortised; eviction is O(1) (Array `.shift()` on a small bounded array). Lookup is O(1). The `stateKeys` array tracks insertion order without using a `Map`'s native iteration (some legacy environments don't preserve insertion order — though every modern runtime does — so the explicit array is defensive).
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user