Files
suaveplan/packages/foundation/bitpacker/bitpacker-crypto/src/fingerprint/schema-fingerprint.md
2026-05-27 23:47:53 +01:00

5.2 KiB

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.

(no submodules)

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

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

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.