State stores
The state store keeps the secrets, the variables, their grants, and the delegation grants (servers acting for users). Material in it is only ever sealed: see Encryption.
| Kind | Replicas | For |
|---|---|---|
memory | one | tests; everything is lost when the process ends |
sqlite | one | a laptop, a VM, docker compose |
postgres | several | PostgreSQL, Azure Database for PostgreSQL |
sqlserver | several | SQL Server, Azure SQL |
kubernetes | several | a Kubernetes cluster, with no database |
What every store does
- Every write is compare-and-set on the secret's version and its row. A write read before another replica's change is run again on the new secret; it never overwrites it.
- Names are compared exactly, in every store. A write (a secret, a grant) is refused (
422) when its name or grant id is empty, longer than 200 characters, not UTF-8, begins or ends with whitespace, or holds a control character. - A list or a descriptor opens no material. Only a read with
useopens a secret's params. A secret whose params do not open fails that read with500 service_error(an operator must act; a KEK that does not answer is503, try later); an administrator may still delete it. - Delegation grants live in the store, for every replica to honour. A grant's id is never stored: rows are keyed by its SHA-256. The user's token and the tokens minted for them are sealed. Expired grants are purged every minute.
- Migrations are embedded and applied at start. A database migrated by a newer binary is refused, never written.
SQLite
state: {kind: sqlite, path: /data/tresor.db}
keys: {kind: local, key_env: TRESOR_KEK}
- Pure Go (no cgo). WAL, foreign keys, immediate write transactions.
- The file is created
0600. - One replica serves. It holds a lease row in the database and renews it every 5 s (15 s expiry). Another replica waits, serving nothing and not ready, and takes over when the first stops. A row, not a file lock: file locks are not reliable on network shares.
- The holder stops serving at its own deadline, 5 s before the expiry it wrote: a paused process stops before another may take over.
PostgreSQL
state:
kind: postgres
dsn: host=corp-pg.postgres.database.azure.com user=tresor-id dbname=tresor sslmode=verify-full
auth: entra
azure: {identity: managed}
- Several replicas. A transaction's lock is
pg_advisory_xact_lock; deadlocks and serialization failures are run again. - The DSN never carries a password. The login gives one for each new connection:
auth: entra: the service's Entra token, for Azure Database for PostgreSQL. The database role is the managed identity's;auth: password: frompassword_env,password_fileorpassword_ref(a Kubernetes Secret,ref+k8s://<namespace>/<secret>/<key>, a Key Vault secret, or a Vault KV field), read again for each connection - a rotation needs no restart;auth: vault: a user and a password a Vault/OpenBao database secrets engine makes for the service (OpenBao and Vault, spec 027) - the DSN's user is replaced by its.
- Off this machine,
sslmode=verify-fullis required. The password (a token) goes over the connection. pgx's default,prefer, falls back to plain text;requirechecks no certificate. - The pool: 10 connections unless
max_open_connssays otherwise, 30-minute connections, a 10 s connect timeout.
SQL Server, Azure SQL
state:
kind: sqlserver
dsn: sqlserver://corp-sql.database.windows.net?database=tresor&encrypt=true
auth: entra
azure: {identity: managed}
- Several replicas. A transaction's lock is
sp_getapplock; a deadlock victim is run again. - On Azure SQL, an access token: the managed identity logs in with no password at all. The database
user is created
FROM EXTERNAL PROVIDER, or the identity is the server's Entra administrator. - Off this machine,
encrypt=true(orstrict) with the certificate checked is required. - Compared texts are
COLLATE Latin1_General_100_BIN2: the default collation compares without case, and names are compared exactly.
The SQL stores' MAC (state.mac)
The material is sealed, but the columns around it - a secret's type and scope, its grants, a delegation grant's actor and user, a minted token's failure - are plain. Whoever can write the database (a DBA, a stolen database credential) could add a grant row, and the service would serve the material to that role. Every such row carries a MAC under a data key, which only the KEK's holder can make (spec 014), and the service checks it - on by default since 0.4 (spec 022):
state:
kind: postgres
mac: false # the checks off (a warning at start); unset or true: on
-
A row that does not verify is refused: a read fails, a list leaves it out (
tresor.state.left_out, logged), only a delete passes. Never served, never trusted silently. -
The MAC is written always, whatever the setting; the setting turns the checks on. With it off, a row whose MAC does not verify is still served, but logged and counted - and the service's next write of it gives it a valid MAC again: until the checks are on, the database is trusted as it is.
-
A database with rows that have no MAC (written before 0.2): with the checks on, the service does not start, and says so - run
tresor-server maconce, or setstate.mac: falsefor now. A replica still on a binary before 0.2 writes rows without one. Once every replica runs 0.2 or later (on SQLite: with the service stopped):tresor-server mac -config server.yaml # rows with no MAC, or a stale one, get one: you vouch for the databasethen start it with the checks on. Minted tokens with none are dropped (they are minted again); a row that cannot be given one (its data key gone) is named, and the command fails.
-
The installation's id (in every MAC, so a row copied from another database does not verify) is made by the migration, in the
installationtable. Keep it with the backups: if it is lost, every row is refused, and the service never makes a new one. -
What it does not stop: deleting rows, or putting a whole old row back (a rollback) - as on the Kubernetes store.
-
Rotation:
rewrapand a move to another KEK keep the data keys, and so every MAC.
Kubernetes
state: {kind: kubernetes} # namespace: the pod's own; instance: the namespace
keys: {kind: local, key_file: /run/secrets/kek}
- Custom resources in the service's namespace, group
tresor.hugr-lab.io/v1alpha1:TresorSecret,TresorVariable,TresorGrant,TresorMintedToken,TresorActor,TresorDataKey,TresorKeyring.kubectl get tresorlists them. - The CRDs are in
deploy/helm/tresor-server/crds. The service checks at start that the API server serves them, and does not start otherwise. - Several replicas. Compare-and-set is the API server's
resourceVersion; a delete carries the resource's UID and version. Every read goes to the API server: no cache, so a replica sees another's write at once. - Names are hashes (
s-…,g-…): any secret name works, whatever its case or characters. - What is not sealed is authenticated. A secret's descriptor and grants, a delegation grant's actor and
user: a MAC under a data key, which only the KEK's holder can make. A resource changed by hand
(
kubectl edit) is refused -500 service_errorto an administrator,404to anyone else - and a list leaves it out. A resource moved from another installation does not verify either:state.instance(the namespace by default) is in every MAC. Keep it stable: a backup restored with the same KEK and instance verifies (minted tokens are minted again). - A wrong KEK or instance is not ready: the
installationkeyring holds the instance, MACed, and readiness verifies it. - Limits: a secret's resource at most 256 KiB, at most 1000 grants (
422otherwise). - In a pod, the service reaches the API by its ServiceAccount; outside one, by
KUBECONFIG, and thenstate.namespaceis required. - Its rights: get, list, create, update, delete and deletecollection on its resources, in its namespace only.