Skip to content
Tenbri pangolinTenbri

Privacy

An append-only, hash-chained log cannot delete a line without breaking verification. The right to erasure (GDPR) needs content to disappear. Tenbri resolves this by keeping content out of the chain:

  • an entry’s body is the address of an encrypted blob, never the content;
  • an entry’s input digest is a keyed hash, never a plain one.

Both depend on a key that belongs to the entry’s subject. Deleting that key erases the content. Every chain, checkpoint and receipt still verifies, because none of them depends on the key or the plaintext.

A data key is 32 random bytes, separate from the signing keys. A key store holds:

  • one or more per subject: the pseudonymous handle in an entry’s subject (such as subj_7f3c91d2);
  • one or more per tenant (the stream), used by entries with no subject.

Each further key of the same subject or tenant starts a new key epoch. New data uses the newest epoch; older epochs keep opening older data until they are erased.

Two keys are derived from each data key with HKDF-SHA-256:

Derived key info Used for
Blob key tenbri/blob/v1 Encrypting bodies
Digest key tenbri/digest/v1 Input digests

The digest key alone confirms a subject’s input digests and opens nothing. It is what an operator can disclose to an auditor for one subject.

A body (the prompt, the message, the document) is sealed with XChaCha20-Poly1305 under the blob key of the newest data key of the entry’s subject, or of the tenant. The stream id is bound in as associated data, so a blob cannot be moved to another stream.

  • The blob’s address is blob:ct-sha256: and the SHA-256 of the whole blob, nonce included. The entry’s body holds it. It depends on the ciphertext only, so it reveals nothing about the body, and two equal bodies have different addresses.
  • The blob is stored in the stream at blobs/ct-sha256/<2 hex>/<62 hex>, written once.
  • A reader checks the blob’s hash against the address (address_mismatch if it was replaced), then tries each epoch’s key in turn (not_opened if none opens it).
  • A missing blob shows as a missing body, never as a broken chain.
  • Blobs carry no key identifier, so blobs of the same subject cannot be linked by their bytes.

Sealing and opening bodies is in the Rust crate tenbri-crypt: DataKeys::seal_body seals a body under the subject’s newest key, stores the blob and returns the address to put in body; DataKeys::open_body reads an entry’s blob back, checks its address and opens it. The CLI and the Python and TypeScript bindings do not seal bodies.

Each input in inputs is {ref, version, digest}. ref and version are plaintext, so you can query by them. The digest is:

"hmac-sha256:" || hex HMAC-SHA-256(digest key, the input's content bytes)

Without the key, a guessed value (an email address, a short code) cannot be confirmed from the log. What the content bytes are (the document, the policy text, the message) is the integration’s choice, fixed per input ref.

In Rust, DataKeys::digest(subject, content) computes it under the subject’s newest data key, and DataKeys::confirm checks a digest against every epoch the store still holds.

The Python and TypeScript bindings compute input digests with a digest_key (digestKey) you pass when you open the stream: HMAC-SHA-256 directly under that key, in the same hmac-sha256: form. That key is yours to keep and to destroy; it is not in the data key store, so tenbri erase does not touch it.

from tenbri import Tenbri
log = Tenbri.open("audit/support", create=True, digest_key=b"a 32-byte secret, kept apart....")
with log.run("agent:support-assistant") as run:
# The next decision lists this input with its keyed digest.
run.read("policy:refund", "14", b"refunds under 50 EUR are automatic")
with run.decision("issue_refund", "stripe:ch_123"):
pass # issue the refund here
print(log.digest(b"refunds under 50 EUR are automatic"))
hmac-sha256:f9ed1ff2d344efdbd6fef3a3192c28862cce0140989468086a354ccae089dd99

Erasing a subject deletes every epoch of its data keys. Then:

  • its blobs can no longer be opened, and its input digests can no longer be confirmed;
  • every entry, HEAD, checkpoint, receipt and link still verifies;
  • a new key made later for the same handle starts again at epoch 1 and opens none of the old blobs.

Entries without a subject can only be erased tenant-wide, by erasing the tenant’s keys.

tenbri erase does it. It asks you to type the handle back unless you pass --yes:

Terminal window
tenbri erase audit/support --subject subj_7f3c91d2 --reason dsr:2026-1042 --by alice --yes
erased subject subj_7f3c91d2: 2 data key epochs deleted; erasure recorded at seq 7

--tenant instead of --subject erases the tenant’s keys. The stream must verify and this machine must hold a key to sign the note before anything is deleted.

The erasure also reaches the query index: the subject’s rows are deleted from it, and a rebuild leaves them out. The entries themselves stay in the stream, so a query without an index, which reads the whole stream, still lists them; only their bodies and input digests are gone.

An erasure is recorded in the stream as a note entry, written after the keys are deleted:

Member Value
kind note
subject The erased handle; absent for a tenant-wide erasure
action {"type": "tenbri.erase", "target": "<reason>"}, the reason a reference such as a request id
principal Who asked for it, when known

This is the note from the command above, as stored:

{"action":{"target":"dsr:2026-1042","type":"tenbri.erase"},"id":"01M5CWEH12ED41E89A10CKDV1Q","kind":"note","prev":"sha256:e7655243798daca21c8f52475135adbff946ff329bb61b647f3c3155e4e3976f","principal":{"id":"alice","kind":"person"},"seq":7,"sig":"ed25519:4e84ff53…","signer":"sess:2026-10-20:adb9599f29d0","stream":"tnb1:dc736478ac6b04bad25e3a3a6f8d71aa","subject":"subj_7f3c91d2","ts":"2026-10-20T14:46:33.762Z","v":1}

A new key epoch is recorded the same way, with action type tenbri.key_epoch and target epoch:<n>. Action types starting with tenbri. are reserved for notes Tenbri writes.

Data keys live beside the stream’s signing keys, at <keys dir>/<stream id, ':' as '-'>/data/, never under the stream’s directory or bucket prefix:

  • one file per subject and epoch, s-<hex SHA-256 of the handle>.<epoch>.json (the tenant’s: tenant.<epoch>.json), created exclusively and never rewritten;
  • directory mode 0700 and files 0600 on Unix; a store open to group or others, or a symlink in their place, is refused;
  • erasure overwrites each file with zeros, syncs it, deletes it and syncs the directory.

Erasure is immediate on a disk without snapshots. On copy-on-write filesystems, with snapshots, or with backups of the key store, old copies survive until those are gone. Keep the key store out of the log’s versioned or Object Lock bucket and out of its backups.

A subject’s first data key (epoch 1) is made the first time it is needed. Key management services and HSMs are not part of format v1.

tenbri keys rotate starts a new epoch and records it in the stream:

Terminal window
tenbri keys rotate audit/support --subject subj_7f3c91d2 --by alice
subject subj_7f3c91d2: new data key epoch 2, recorded at seq 6

tenbri keys list shows the signing keys and, for each subject and the tenant, the epochs held; never a secret:

data keys (<keys dir>/tnb1-dc736478ac6b04bad25e3a3a6f8d71aa/data):
subject subj_7f3c91d2: epochs 1..2, newest made 2026-10-20T14:46:33.668Z

tenbri keys export prints a subject’s keys as JSON for a backup kept apart from the log. With --digest it prints each epoch’s digest key instead: what you hand an auditor to confirm one subject’s input digests, without opening any body.

Terminal window
tenbri keys export audit/support --subject subj_7f3c91d2 --digest
{
"epochs": [
{
"created": "2026-10-20T14:46:33.624Z",
"digest_key": "89cbacb74df0ea3d422ba4ba2451dde06880097043dc449adedd2e684a63ee27",
"epoch": 1
},
{
"created": "2026-10-20T14:46:33.668Z",
"digest_key": "66bb69500486aecf6a24f7f060376274de859a74f7d4e5a4d893d3d9dcf5fc5a",
"epoch": 2
}
],
"stream": "tnb1:dc736478ac6b04bad25e3a3a6f8d71aa",
"subject": "subj_7f3c91d2"
}

After an erasure, export fails with no data key for subject "subj_7f3c91d2": erased, or never held here (exit 2). Every flag is in the CLI reference.

Whatever tools you use, keep personal data out of the plaintext members. Use pseudonymous subject handles and references in action.target and inputs[].ref. The spec documents action.target as possibly personal data.

  • Streams and entries: where subject, body and inputs sit in an entry.
  • Keys: signing keys, which erasure never touches.