Sealrdocs
Concepts

Evidence format

The .seal bundle in detail — records, the BLAKE3 hash chain, signed Merkle checkpoints, countersignatures, identity, time anchors and payload commitments.

The evidence format is the part of Sealr that has to outlive Sealr. An auditor should be able to verify a bundle in ten years with a tool nobody at this company wrote. So the format is specified, the reference implementation is Apache-2.0, and everything below is what the code actually does.

Reference implementation: crates/evidence, crates/bundle, crates/verify. Normative specification: docs/03-EVIDENCE-FORMAT-SPEC.md.

Encoding

AspectChoice
SerializationJSON, canonicalized per RFC 8785 (JCS) for all hashing and signing
HashesBLAKE3-256, lowercase hex
SignaturesEd25519 over the BLAKE3-256 digest of the JCS bytes of the signed structure
Timestampsts_wall RFC 3339 UTC with milliseconds; ts_mono_ns monotonic nanoseconds since segment start
IdentifiersULID, 26-character Crockford base32

JSON over a compact binary encoding is a deliberate trade: an auditor can open a record in a text editor. Every record carries schema_version so a CBOR/COSE migration remains possible.

Records

A record is one signed-into-chain unit describing an event. Types: operation, outcome, approval, policy_event, lifecycle, coverage_gap, guard_error, spool_shed, clock_anomaly, generic_exec.

The Console column marks fields included in the metadata projection. Everything else stays inside your boundary.

FieldConsoleNotes
schema_versionyes"1"
record_id, stream_id, segment_idyesULIDs
seqyesStrictly monotonic per stream, no gaps within a segment
ts_wall, ts_mono_nsyesDual clocks
record_typeyesSee the list above
recorderyesrecorder_id, version, host fingerprint, platform
subjectyesAgent kind, session, optional human principal, attribution quality, source
actionyesIntegration, operation, verb class, target resource, labels — structural, no literals
action_detailnoStatement skeleton, resource addresses — may be sensitive
payload_commitmentyesalg and the salted hash
payload_refnoVault URL, aes-256-gcm, wrapping key reference
verdictyesDecision, mode, risk class, policy version, rule ids, reason codes, guard, eval_ms
approval_ref, linksyesApproval that released the operation; outcome-to-request link
prev_hash, record_hashyesChain fields

Committed versus stored

The distinction runs through the whole format. A record commits to the payload and stores structure:

  • Committed, not stored: the operation payload — the SQL text, the plan JSON, the diff. What the record holds is BLAKE3(salt || payload_bytes).
  • Stored, locally: action_detail, the guard's structural output — statement kinds, schema-qualified relations, WHERE skeletons with typed placeholders like email = <string>. Never projected to the Console.
  • Stored and projected: the classification and the verdict.

Writers reject unknown fields. Verifiers of a higher minor version preserve them and flag them, and the hash is computed schema-agnostically over the raw JSON so a record from a future minor version still chain-verifies.

The hash chain

record_hash_i = BLAKE3(JCS(record_i without record_hash))
record_i.prev_hash = record_hash_{i-1}

The first record of a stream uses a genesis sentinel, BLAKE3("SEALR-GENESIS" || stream_id), so a stream cannot silently start mid-air.

Verification asserts, per stream: hash correctness, prev_hash linkage, seq strictly +1 within a segment, ts_mono_ns non-decreasing, and ts_wall non-decreasing within a two-second tolerance — a larger regression requires an adjacent clock_anomaly record or it is reported. Findings accumulate rather than aborting, so one report tells you everything that is wrong.

Segments

One recorder boot is one segment. A segment opens with a lifecycle:segment_start record carrying the recorder certificate chain, host fingerprint, recorder version, config digest, policy bundle version and digest, the previous segment's final hash when locally known, and a boot nonce. Clean shutdown appends lifecycle:segment_end and a final checkpoint. Its absence is itself evidence of a crash, and the verifier reports it.

If local state is lost entirely, a segment opens with a distinct sentinel, BLAKE3("SEALR-SEGMENT-BREAK" || stream_id || segment_id). Using it is a reported finding, not a way to paper over a gap.

Checkpoints

Records are not individually signed. Integrity flows up to checkpoints, and a record counts as attested once a valid checkpoint covers it.

A CheckpointNote carries:

{
  "checkpoint_id": "01J8Z...",
  "stream_id": "01J8Y...",
  "segment_id": "01J8Y...",
  "seq_from": 0,
  "seq_to": 999,
  "chain_head_hash": "…",
  "merkle_root": "…",
  "tree_size": 1000,
  "ts_wall": "2026-08-12T14:02:31.114Z",
  "recorder_cert_fingerprint": "…",
  "prev_checkpoint_hash": "…"
}

The Merkle tree follows RFC 6962 conventions over BLAKE3: leaf hash is H(0x00 || leaf), node hash is H(0x01 || left || right), and the tree splits at the largest power of two strictly less than the leaf count. Leaves are the raw 32-byte record hashes over [seq_from, seq_to].

prev_checkpoint_hash links each checkpoint to its predecessor. Without it, removing an interior checkpoint together with its records would leave a self-consistent set; with it, only a suffix can be dropped — which is why the verifier's remaining worry is the tail and not the middle.

The note is signed Ed25519 with the recorder's identity key.

Emission thresholds. The recorder emits a checkpoint every 1,000 records and at clean shutdown. The specification also calls for a 10-second activity timer; that timer is not implemented in V1. Short-lived surfaces — a hook invocation, a wrapped command — check point on close, so their records are attested; a long-running proxy accumulates up to 1,000 records between checkpoints.

Console countersignature

When the Console receives a checkpoint it verifies the recorder signature, checks that seq_from equals its stored head plus one, and only then signs:

{
  "checkpoint_id": "01J8Z...",
  "received_at": "2026-08-12T14:02:33.900Z",
  "console_key_id": "console-key-1",
  "stream_high_water_seq": 41822
}

stream_high_water_seq is the anti-rollback mechanism. It is the Console's signed assertion of how far the stream had advanced, and a recorder-side attacker cannot forge a lower value. A bundle whose records stop below a countersigned high-water mark is demonstrably incomplete.

A seq_from that does not match the stored head is a fork: the Console records a chain_conflict and refuses to countersign the divergent branch.

Identity

Sealr Root (offline)
  └── Tenant CA (per tenant, Console-held)
        └── Recorder identity certificate (public key from an on-device keypair)

All three are Ed25519. Validity: 3650 days for the root, 730 for a tenant CA, 90 for a recorder certificate. The recorder generates its keypair at enrollment and sends a CSR; the private key never leaves the device.

Validation is fully offline: the verifier walks the leaf up through the bundled intermediates to a trusted root, checking each signature, each validity window against the evaluation time, and CA basic constraints at every hop.

Revocation is a Console-signed list of certificate fingerprints with effective times. A checkpoint signed after the effective time is invalid and the verifier reports the boundary rather than silently dropping the records.

V1 stores the recorder private key in a file, obfuscated with a host-derived keystream and written with restrictive permissions. That is obfuscation, not encryption: anyone who can read the file on that host can sign as that recorder. TPM 2.0, Secure Enclave and OS-keychain backends are enumerated in the code and not implemented. sealr doctor reports the backend and flags the file fallback as a downgrade.

Time anchors

Anchoring binds checkpoints to a time nobody in the system controls.

The Console batches checkpoint note digests into a Merkle tree. Each batch record carries the root twice — root_blake3, and root_sha256 as SHA-256 over the raw 32-byte BLAKE3 root, which is the RFC 3161 message imprint, since TSAs do not speak BLAKE3. The verifier checks that binding before it trusts anything else about the batch. Per-checkpoint inclusion proofs are stored alongside, and the leaf must equal the digest of the checkpoint note actually present in the bundle.

Token verification is offline: parse TimeStampResp or a bare ContentInfo, confirm the message imprint, verify the CMS SignedData signature (RSA PKCS#1 v1.5 with SHA-256/384/512) against the signer certificate embedded in the token, and read genTime. Qualification status is reported as recorded by the exporter, not independently validated against an EU trust list.

More anchors never invalidate a bundle — tokens accumulate.

Implementation status. The format, the bundle structures, the dev TSA used for test vectors and the offline verifier are implemented. The hosted Console does not yet submit checkpoints to a timestamp authority — TSA_URL is present in its configuration and nothing consumes it. The daily consolidated root, its eIDAS qualified timestamp and public publication are specified and not implemented. Bundles produced today therefore carry no anchors, and the verifier reports no earliest anchor.

Payload commitments and selective disclosure

commitment = BLAKE3(salt || payload_bytes)      salt: 32 random bytes

The salt is never in the record. It travels with the payload in the vault, or is discarded in commitment-only mode. Three consequences follow, and they are the point of the design:

  1. A metadata-level bundle verifies fully — chain, checkpoints, signatures, anchors — with no payloads present at all.
  2. Whoever holds the payload and its salt can prove to anyone that this payload matches this record, without revealing any other payload. That is the selective-disclosure path: hand a court one statement, not a database.
  3. Without the salt the commitment reveals nothing practical about a low-entropy payload. Two commitments over the same bytes differ. Nobody can confirm a guess by hashing a dictionary — which is exactly why the salt must not travel with the record.

The V1 recorder runs in commitment-only mode: it generates a salt per record, computes the commitment and discards the salt immediately, and never populates payload_ref. Selective disclosure of recorder-produced records is therefore not available in V1 — the format and the Ledger support vaulted payloads (AES-256-GCM per-payload data key, wrapped by a customer KMS key, key_ref naming the wrapping key and never key material), but the recorder does not use that path yet.

The .seal bundle

A deterministic zip container. Media type application/vnd.sealr.bundle+zip as declared by the specification; IANA registration is not complete.

manifest.json                              # bundle id, created_at, tool, scope, content hashes
records/<stream>.part-00001.ndjson.zst     # canonical JCS record lines, zstd, 512 MB shards
records/index.json                         # per-stream shard list and seq range
checkpoints/<stream>.json                  # signed checkpoints + countersignatures
anchors/rfc3161/<batch_id>.tsr             # RFC 3161 tokens
anchors/qts/<day>.der                      # qualified timestamp tokens
anchors/proofs.json                        # batches, inclusion proofs, daily roots
identity/certs.pem                         # certificate chain
identity/revocations.json                  # signed revocation list
identity/roots.json                        # bundle-pinned roots
vault/                                     # optional, customer-side exports only
VERIFY.md                                  # how to verify offline, verifier version pins

The record lines in the bundle are byte-for-byte the lines from the recorder's spool: the spool bytes are the bundle bytes, so export cannot reshape evidence.

manifest.json lists every other entry with its BLAKE3 hash and byte length, and is itself JCS-hashed. The exporter prints that hash:

sealr export -o evidence.seal --level full

Use --level metadata for a bundle with no vault entries. Quote the manifest hash when you hand a bundle to someone: it is the one value that identifies this exact export.

Verification procedure

The verifier must, in order: parse the manifest and check schema versions; validate certificate chains to a trusted root; apply the revocation list and compute validity windows; verify every record hash, link, sequence and clock rule per stream; verify every checkpoint signature, recompute every Merkle root, verify countersignatures; verify RFC 3161 and qualified tokens and bind each covered checkpoint to its earliest proven time; then cross-check coverage — unanchored tails, segments with no clean end, coverage_gap records, revocation boundaries.

Output is a human report plus machine JSON: result of pass, pass_with_findings or fail, the findings, what was proven (record count, span, earliest anchor, attested and anchored counts), and the standing limits. Exit codes: 0, 10, 11, and 30 for a usage error.

Findings carry stable machine classes — record_hash_mismatch, link_broken, anchor_inclusion_invalid, anchor_digest_binding_broken, tail_completeness_unproven — so they can be asserted on in CI.

Conformance and licensing

sealr-evidence, sealr-bundle and sealr-verify are Apache-2.0. A third party can implement a writer or a verifier without Sealr's involvement, which is the only version of "verify it yourself" that means anything.

The repository publishes golden bundles: one valid, and one per single-fault class — bad hash, reordered records, sequence gap, forged checkpoint, revoked key, bad TSA token, fork. CI runs the verifier against all of them, and a property test asserts that any single-bit mutation of any record produces a finding.

Next

On this page