4.4 KiB
integrity/integrity — BLAKE3 per-entry digest and bundle-level Merkle root
(no submodules)
Purpose
@suaveplan/packer/integrity provides the content-addressable integrity layer
the rest of the package uses to detect any tampering or accidental corruption
of packed bundles. It computes a per-entry BLAKE3-256 digest and a
bundle-level Merkle root over the sorted entry digests; both are recorded in
the produced envelope (whole-file PackedDataMetadata.integrity, chunked
Manifest.merkleRoot plus per-entry ManifestChunkEntry.entryDigest) and
verified on read. Verification coexists with the existing ed25519 signing
pipeline — neither replaces the other — because BLAKE3 is fast enough to run
on every chunk without measurable overhead while ed25519 still anchors the
publisher's identity.
Features
- BLAKE3-256 one-shot digest of arbitrary bytes via
@noble/hashes/blake3. merkleRootBlake3(digests)over already-sorted entry digests.computeWholeFileIntegrity(payload)convenience producing the{ entryDigest, merkleRoot }pair stored onPackedDataMetadata.integrity.- Constant-time
bytesEqualfor use in verify paths. - Strict input validation: every digest must be exactly 32 bytes; mismatches
raise
TypeError(programmer error — not a packer error code).
Usage: Basic Example
import {
entryDigestBlake3,
merkleRootBlake3,
computeWholeFileIntegrity,
} from "@suaveplan/packer/integrity";
const payload = new TextEncoder().encode("hello world");
const digest = entryDigestBlake3(payload);
console.log(digest.byteLength); // 32
const integrity = computeWholeFileIntegrity(payload);
console.log(integrity.entryDigest.byteLength); // 32
console.log(integrity.merkleRoot.byteLength); // 32
Usage: Advanced Example
Build a Merkle root over a chunked bundle's entries, sorted by chunkId:
import {
entryDigestBlake3,
merkleRootBlake3,
bytesEqual,
} from "@suaveplan/packer/integrity";
const chunks = [/* wire bytes from packChunks(...) */];
const digests = chunks
.map((c) => ({ id: c.chunkId, d: entryDigestBlake3(c.bytes) }))
.sort((a, b) => a.id - b.id)
.map((x) => x.d);
const root = merkleRootBlake3(digests);
// Later, on read:
const recomputed = merkleRootBlake3(digests);
if (!bytesEqual(root, recomputed)) {
throw new Error("Merkle root drifted between pack and verify");
}
API Reference
entryDigestBlake3(data: Uint8Array): Uint8Array— BLAKE3-256 ofdata. Returns a fresh 32-byte array.merkleRootBlake3(digests: readonly Uint8Array[]): Uint8Array— BLAKE3-256 of the byte concatenation ofdigests(caller supplies sort order). Empty input maps toblake3(""). Each digest must be 32 bytes; mismatches throwTypeError.computeWholeFileIntegrity(payload): PackedDataIntegrity— single-entry convenience returning{ entryDigest: blake3(payload), merkleRoot: blake3(blake3(payload)) }.bytesEqual(a, b): boolean— constant-time byte-equality test on twoUint8Arrays.BLAKE3_DIGEST_LEN: 32— exported constant for callers that need the digest length without recomputing.PackedDataIntegrity—{ entryDigest, merkleRoot }shape stored onPackedDataMetadata.integrity.
Implementation Notes
- BLAKE3 is sourced from
@noble/hashes/blake3. The package adds@noble/hashesas a direct dependency (catalogcrypto) rather than going through@suaveplan/crypto-hash, which does not yet surface BLAKE3 — exposing it there is tracked separately and is out of scope for this change. - The Merkle root scheme is intentionally flat (single-level hash of sorted concatenated leaves) rather than a binary tree. The bundle-level root's job is to detect any leaf substitution, which a flat root accomplishes; binary Merkle proofs are not in the public surface and adding them later does not invalidate the flat root.
- Domain separation: the whole-file Merkle root wraps the lone entry digest
in a second BLAKE3 call so that a verifier never confuses a leaf digest
with a root. The same applies to chunked manifests —
merkleRootis alwaysblake3(concat(...)), never the bare concatenation. bytesEqualis intentionally constant-time. Integrity verification is not a cryptographic primitive (the signature does that), but the verify path sits on the request hot path and constant-time comparisons keep the semantics consistent with the rest of the package.