How a file becomes a signed root.
BitSeal is a client-side integrity service. Files are chunked and hashed in the browser, a BLAKE3 Merkle tree is assembled, and the Authority signs the manifest with Ed25519. Web and CLI produce the same manifest format, so a seal of the same bytes yields the same root everywhere.
The three stages
Client-side hashing
The file is streamed in 64 KiB chunks and hashed locally with BLAKE3. Only the resulting 32-byte fingerprints leave the device.
Merkle aggregation
Leaf hashes fold into a binary BLAKE3 tree with the odd-layer-duplicate rule, producing a single 32-byte root that commits to every byte of the file.
Ed25519 signature
The Authority signs the manifest with Ed25519. v2 (default) binds the SHA3-512 of the canonical manifest, so every signed field is covered. v1 (legacy) binds the 40-byte root || LE_f64(timestamp). Both verify against the published public key.
The unified Merkle construction
Both the web service and the BitSeal SDK produce the same Merkle manifest. A seal you created locally with the CLI and a seal the cloud signed for the identical bytes will share the exact same root_hash. You can re-derive that root from the raw file, with any BLAKE3 library, on any platform, no BitSeal code required.
The file is split into fixed 64 KiB windows from offset 0. The final chunk may be shorter, it is hashed as-is with no padding.
Each chunk is hashed with BLAKE3 (32-byte output). Hex digests of the leaves are stored in the merkle_tree array in file order.
A parent is BLAKE3(left || right) over the raw 32-byte child digests. No domain separator, no length prefix. Lowercase hex everywhere.
If a layer has an odd number of nodes, the last node is duplicated (paired with itself) before hashing up. A single-leaf tree's root is simply that leaf.
import { blake3 } from '@noble/hashes/blake3';
const CHUNK_SIZE = 64 * 1024;
function computeRootFromLeaves(leafHexArray) {
let level = leafHexArray.map(h => Buffer.from(h, 'hex'));
while (level.length > 1) {
const next = [];
for (let i = 0; i < level.length; i += 2) {
const left = level[i];
const right = (i + 1 < level.length) ? level[i + 1] : left;
next.push(Buffer.from(blake3(Buffer.concat([left, right]))));
}
level = next;
}
return level[0].toString('hex');
}Anatomy of a root
Three leaves, odd layer, so L3 is paired with itself to form N2. The root commits to every byte. Flip a single bit anywhere in the file and the root changes.
Seal manifest
Every seal, web or CLI, produces a JSON manifest with the fields below. Anything new verifiers need to reproduce the root is in the manifest itself, so the format is self-describing. Every manifest carries a seal_mode field that tells verifiers which signing layout to use.
{
"seal_mode": "merkle-blake3-64k-v2",
"chunk_size_bytes": 65536,
"root_hash": "6d56f74e63d5dfabbc81cdbfef6d5e782d80df8752fd60c7c747e4c84158371e",
"merkle_tree": ["a1b2c3...", "d4e5f6...", "..."],
"blake3_hash": "...",
"sha3_512_hash": "...",
"size_bytes": 130000,
"filename": "contract.pdf",
"entropy": "7.9100",
"mime_type": "application/pdf",
"timestamp_utc": "1730000000.123000",
"client_tag": "BitSeal Cloud",
"signer": "Orygn Authority",
"signature": "...64-byte Ed25519 signature in hex...",
"type": "BitSealManifest",
"@context": "https://w3id.org/security/v1"
}Legacy v1 seals (issued before 2026-05-29) carry seal_mode = "merkle-blake3-64k-v1", timestamp_utc and entropy as JSON numbers rather than strings, and use the field name machine_fingerprint in place of client_tag. The Merkle construction is identical between v1 and v2; only the signing layout differs (see the next section).
Signing payload
The Authority signs the manifest with Ed25519, but the exact bytes that the signature covers depend on the seal_mode field. Verifiers read seal_mode first and dispatch to the matching recipe. The BitSeal SDK does this automatically; the manual recipes are below.
v2, default for all new seals since 2026-05-29
The Ed25519 signature covers the SHA3-512 (FIPS 202) digest of the canonical UTF-8 bytes of the manifest with the signature field removed. Canonical bytes are produced by sorting every JSON object's keys lexicographically, emitting JSON with no whitespace, no floats, with non-integer numeric values carried as fixed-precision strings. The signature binds every signed field, so any field-level tampering breaks it.
import json, hashlib
from nacl.signing import VerifyKey
m = dict(manifest); m.pop("signature", None)
canonical = json.dumps(m, sort_keys=True, separators=(",", ":"),
ensure_ascii=False, allow_nan=False).encode("utf-8")
digest = hashlib.sha3_512(canonical).digest() # 64 bytes
verify_key = VerifyKey(public_key_raw_bytes)
verify_key.verify(digest, signature_bytes)v1, legacy, for seals issued before 2026-05-29
The Ed25519 signature covers exactly 40 bytes: the 32-byte Merkle root concatenated with an 8-byte little-endian IEEE 754 double of the UTC timestamp. No JSON, no canonicalization.
import struct
from nacl.signing import VerifyKey
payload = bytes.fromhex(root_hash) + struct.pack("<d", timestamp_utc)
# len(payload) == 40
verify_key = VerifyKey(public_key_raw_bytes)
verify_key.verify(payload, signature_bytes)What never leaves the client
- File bytes. Chunking and hashing happen in your browser before anything is sent.
- Private keys. BitSeal does not generate, receive, or custody user private keys. The Authority's own private key is held inside an AWS KMS HSM and cannot be exported.
- Filename ambiguity. The ledger does store filename, size, and MIME type as metadata, but the seal's cryptographic identity is the root. Those fields are advisory, not part of what was signed.