Privacy
The problem
Section titled “The problem”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
bodyis the address of an encrypted blob, never the content; - an entry’s input
digestis 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.
Data keys
Section titled “Data keys”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 assubj_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.
Encrypted bodies
Section titled “Encrypted bodies”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’sbodyholds 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_mismatchif it was replaced), then tries each epoch’s key in turn (not_openedif 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.
Input digests
Section titled “Input digests”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"))import { Tenbri } from 'tenbri'
const log = await Tenbri.open('audit/support', { create: true, digestKey: 'a 32-byte secret, kept apart....' })
await log.run({ principal: 'agent:support-assistant' }, async (run) => { // The next decision lists this input with its keyed digest. run.read('policy:refund', { version: '14', content: 'refunds under 50 EUR are automatic' }) await run.decision({ action: 'issue_refund', target: 'stripe:ch_123' }, async () => { // issue the refund here })})
console.log(log.digest('refunds under 50 EUR are automatic'))hmac-sha256:f9ed1ff2d344efdbd6fef3a3192c28862cce0140989468086a354ccae089dd99Erasure
Section titled “Erasure”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:
tenbri erase audit/support --subject subj_7f3c91d2 --reason dsr:2026-1042 --by alice --yeserased 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.
The local key store
Section titled “The local key store”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
0700and files0600on 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.
Managing data keys
Section titled “Managing data keys”tenbri keys rotate starts a new epoch and records it in the stream:
tenbri keys rotate audit/support --subject subj_7f3c91d2 --by alicesubject subj_7f3c91d2: new data key epoch 2, recorded at seq 6tenbri 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.668Ztenbri 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.
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,bodyandinputssit in an entry. - Keys: signing keys, which erasure never touches.
