# TessariDB Vault > A company password manager its operator cannot read: records are encrypted in each member's browser, and the server keeps only ciphertext in TessariDB. Version 0.0.1 · Docs: https://docs.tessaridb.com/vault · Source: https://github.com/tessaridb/tessaridb-vault · Image: https://hub.docker.com/r/tessaridb/vault · Licence: Business Source License 1.1 ## What the server never holds Source: https://vault.tessaridb.com/ - **Sealed before it leaves the browser** — Contents, titles, field names and files are encrypted on the device that writes them, each revision under a key used once. - **An unlock password the server never sees** — Signing in and unlocking are separate. Argon2id runs in the page, and the unlock password never leaves it. - **Writing a row adds no reader** — A browser wraps a record key only to a key the company owner's signature chain vouches for, so the server cannot add a reader. - **A second layer in the database** — Each company's ciphertext is kept in its own TessariDB vault, opened by a key the database never stores. - **Shared by structure** — A record is shared with a person, a team, a department or the whole company, and a change of team changes who can read it. - **Removal at the next request** — Taking back a share or removing a member stops them at their next request and marks the keys they held for rotation. - **Recovery the member sees** — Owners can hand a member's keys back when the unlock password is forgotten, and every reach of those keys is shown to the member. - **Every change on the trail** — Each admin change and each read of a sealed record is recorded in the same transaction, so an act that cannot be recorded does not happen. - records: 11 types · 15 field types · files to 256 MiB - sign-in: password + TOTP · OpenID Connect · passkeys - keys: X25519 · Ed25519 · AES-256-GCM · Argon2id - store: TessariDB 0.39.3 or later ## What 0.0.1 holds Source: https://vault.tessaridb.com/features Companies with owners, admins and members, departments and teams; records encrypted in each member's browser and shared with exactly the people and groups named; organisation recovery an owner carries out and the member sees; a trail of every change. ### Encrypted in the browser, before anything is sent Each revision of a record is sealed under a key used once and bound to its company, record, revision and key. The server keeps the sealed body in the company's own TessariDB vault and never holds a key that opens it. | Part | What 0.0.1 does | | --- | --- | | record types | eleven built in, and types a company defines with the roles that may use each | | fields | fifteen field types in named sections, added freely to any record | | one-time codes | TOTP codes (RFC 6238) shown with the seconds left | | SSH keys | Ed25519 keys generated or imported in OpenSSH's formats, with their fingerprints | | files | encrypted in 64 KiB chunks up to 256 MiB, refused if a chunk is reordered, dropped or added | _What a record can carry_ ![A database record opened in the browser: kind, host, port, database, username and a hidden password, each with a copy button](https://vault.tessaridb.com/shots/record-view.png) ### Exactly the readers named - A record is shared with a member, a team, a department or the whole company; each share is written in one transaction with the record key wrapped to that member's or group's key. - A browser wraps a key only to a key the company owner's signature chain vouches for, so the server cannot add a reader by writing a row. - Removing a share stops the member at their next request, and the next revision is sealed under a new record key. - A member leaving the company marks every key and record they could open for rotation. ![The members page: the owner's key fingerprint, a member who has not set an unlock password yet, and the owner whose key is vouched for](https://vault.tessaridb.com/shots/members.png) ### An owner can hand a member's keys back, and the member sees it The unlock password is not the sign-in password, and nobody at the server can reset it. Each member's keys are escrowed to the company recovery key, which only owners hold. A member who forgot the unlock password asks with a one-time key their browser makes, reads a code to the owner, and sets a new unlock password once the owner has handed their keys back. An admin, another company, the platform admin and the store's administrator are refused. Every read of a member's escrowed keys is shown to that member. ### Own sign-in, an OpenID Connect issuer, or a passkey - A password and a TOTP code, with attempts backed off per address and per network, never locking the account. - Any OpenID Connect issuer by discovery, with PKCE, `state`, `nonce`, `iss` and the ID token checked. - Passkeys (Web Authentication Level 3, user verification required), used to sign in without naming an account first. ![The first unlock: setting an unlock password of at least 12 characters, separate from the sign-in password](https://vault.tessaridb.com/shots/unlock-set.png) ### Every change, with who, what, to whom, when and why Every admin change and every read of a sealed record is recorded in the same transaction as the act, so an act whose record cannot be written does not happen. A chain over each company's events shows where a row was altered. ![The company trail, newest first: who created and opened which record, who vouched for a key, filters by area and a button to check the trail](https://vault.tessaridb.com/shots/trail.png) ## Three layers, and the keys at each Source: https://vault.tessaridb.com/architecture Records are sealed in the member's browser, kept by the server in the company's own TessariDB vault, and stored by TessariDB. Each layer holds its own keys, and none below the browser holds a key that opens a record. ### Browser, company vault, store The browser is the only place a record is ever plaintext. The server holds each revision's sealed body in the company's TessariDB vault, which a key derived per request opens, and TessariDB stores that vault encrypted again. The store's own vault passphrase and its root account open none of it. ### The unlock password is not the sign-in password Signing in proves who you are to the server. Unlocking happens only in the browser: Argon2id turns the unlock password into a key that unwraps your private keys for this company, and the password never leaves the page. | Key | What it does | Algorithm | | --- | --- | --- | | unlock key | wraps your private keys for one company | Argon2id, then HKDF-SHA256 and AES-256-GCM | | agreement key | receives record keys and group keys wrapped to you | X25519, HPKE (RFC 9180) | | signing key | signs the revisions you write and the statements you vouch | Ed25519 | | record key | one per record, wrapped to each member or group it is shared with | random 256-bit | | content key | seals one revision, once, bound to its company, record, revision and key | HKDF-SHA256, AES-256-GCM | _The browser's keys_ ### Writing a row adds no reader The company's first owner pins the company owner key. Owners and admins extend a chain of signed statements from it, one member key or group key at a time, after comparing its fingerprint. A browser wraps a record key only to a key the chain covers, and trusts a revision only from an author it covers, so a server that inserts a key of its own gets nothing to read. A revision that was moved, altered or not signed is refused, and so is one older than a revision the browser has already seen. ### One key file, outside the store The server reads one service key file at start: 32 random bytes as 64 hexadecimal characters, readable by its own user only. From it the server derives the key that opens each company's vault (per company, per request, never stored), the pepper applied to sign-in passwords before Argon2id, and the key that seals authenticator secrets. The file itself is dropped from memory once those keys exist. The file is not in the store, so a backup of the store holds none of these keys. Without it nothing in the store can be opened by the server either. ### Every piece of state is a TessariDB record Companies, members, departments and teams, key sets, records and their revisions, shares, files, sessions, throttles and the audit trail live in TessariDB: records, vaults, spaces with expiry and buckets, with no second store beside it. A file up to 64 KiB stays in the company's vault; a larger one goes to a TessariDB bucket, encrypted in the browser in 64 KiB chunks. - Every admin change and every read of a sealed record is written to the company's trail in the same transaction as the act. - A chain over each company's events shows where a row was altered. - Sessions and attempt counters are TessariDB spaces that expire on their own. ### Owners can hand keys back, and the member is told Every member's browser escrows their private keys to the company's recovery key, which only owners hold. A member who forgot the unlock password asks with a one-time key their browser makes and reads a code to an owner; the owner's browser hands the keys over to that one-time key, and the member sets a new unlock password within 72 hours. Every read of a member's escrowed keys is shown to that member. An admin, another company, the platform admin and the store's administrator are refused. ### The web application is in the server The pages and their script are compiled into the `tessaridb-vault` binary and served from it, with no third-party code. Every page carries a content security policy that lets it run only its own script and its own WebAssembly, talk only to its own origin, and never be framed. ### What it does not protect against - **An unlocked browser and the device it runs on.** While your vault is unlocked, the keys are in that tab's memory. Malware on the device, or a browser extension allowed to read the page, can read what you can. - **The code the server delivers.** Whoever controls the server you sign in to can serve a modified application that captures your unlock password. A web client cannot prove to you that it is honest. - **The server host.** Someone with control of the host can read the database-side keys and session tokens in the service's memory, and the metadata below. Without your unlock password they still cannot decrypt a record. - **Your company's owners and admins.** Owners hold the recovery key, so they can restore any member's access and read every record shared with members of the company. Admins hold the keys of departments and teams, so they can read every record shared with a department, a team or the whole company, but not records shared only with individual people. Every member is shown this. - **Metadata.** The server sees who belongs to which company, department and team, who shares which record with whom, record types, sizes, counts and timestamps, and when records are fetched. Names of records are encrypted; their existence is not. - **Someone who already read a secret.** Removing a person stops their future access and rotates the record's key. It cannot make them forget a value they saw. Change the secret at its source. - **A restored old copy.** Someone who can write to the database can put back an old version of a record. Your browser refuses a version older than one it has already seen, but cannot detect an old version it has never seen newer of. - **A weak unlock password.** Anyone holding the database and the server's key file can try to guess your unlock password offline. Each guess costs a memory-hard key derivation, but a short or common password still falls. - **Your sign-in provider.** A compromised identity provider can sign you in, but it cannot unlock your vault: the unlock password is separate and never leaves your browser. - **Files kept in an external object store.** When a deployment stores files in TessariDB S3 instead of the database, those files carry the browser's encryption but not the database's second layer. - **Timing and traffic analysis, and future quantum computers** against today's ciphertext. The key exchange is classical X25519 in this version; the format names its algorithms so it can move. ## Run it with Docker Source: https://vault.tessaridb.com/start TessariDB Vault 0.0.1 runs as two containers: the server, with the web application inside it, and the TessariDB node that keeps everything it holds. One compose file starts both, declares the server's own database account and writes its key. ### Two containers and one command Set the two passwords and the platform admin in `.env`, then start it. The first start builds the server's image from the repository and takes a few minutes; later starts take seconds. ```sh git clone https://github.com/tessaridb/tessaridb-vault && cd tessaridb-vault cp .env.example .env # set TESSARIDB_PASSWORD, VAULT_STORE_PASSWORD and the platform admin docker compose up -d --wait ``` ### What the compose file starts - **tessaridb**: the TessariDB node, `tessaridb/tessaridb:0.39.6`, reachable only from the server over the compose network. Its first start declares the store's owner from `.env`. - **vault-account**: signs in as that owner once, declares the `vault` namespace and an account that may edit that namespace and nothing else, then exits. The server never signs in as the owner. - **service-key**: writes 32 random bytes as the service key into a volume of its own, readable by the server alone, and leaves an existing key untouched. - **vault**: the server, on `http://localhost:8080`, published on `127.0.0.1` only. Put a TLS-terminating proxy in front and set `VAULT_PUBLIC_URL` to the https address before anyone else reaches it. ### A single-use link for the platform admin While the platform admin has no credential, `enrolment-link` prints a single-use enrolment token, valid for 24 hours, to the terminal that ran it and to nowhere else. Open `http://localhost:8080/#/join`, paste the token, and set a sign-in password and an authenticator. ```sh docker compose run --rm vault enrolment-link # enrolment token for ops@example.com, valid for one use within 24 hours: # 3f9c... ``` ### A company and its owner The platform admin creates a company and names its owner; the answer carries the owner's enrolment token, which you send to them. The platform admin's session comes from the two sign-in steps the pages use: the password, then a code from the authenticator. The owner joins the same way, sets an unlock password the first time they open the company, and from then on adds members, departments and teams in the browser. ```sh VAULT=http://localhost:8080; JSON="content-type: application/json" CHALLENGE=$(curl -s $VAULT/api/sign-in -H "$JSON" \ -d '{"email":"ops@example.com","password":"..."}' | jq -r .challenge) SESSION=$(curl -s $VAULT/api/sign-in/code -H "$JSON" \ -d "{\"challenge\":\"$CHALLENGE\",\"code\":\"123456\"}" | jq -r .session) curl -s $VAULT/api/companies -H "authorization: Bearer $SESSION" -H "$JSON" \ -d '{"name":"Northwind","owner_email":"dana@example.com","owner_name":"Dana Owner"}' ``` ### Two volumes, backed up apart Everything the server keeps is in the `tessaridb` volume; the service key is in `vault-keys`. Back them up separately: the key derives every company's vault key, so a database restored without it cannot be opened, and a key kept with the database's backups gives whoever holds them one fewer lock to pick. ### The binary beside a node The server needs a TessariDB node, 0.39.3 or later (0.39.6 is current; 0.39.4 is withdrawn), an account on it that may edit one namespace, and the key file, which its group and others may not read. It refuses to start, naming the variable, when anything is missing. `docker/vault-account.sh` in the repository holds the statements the store's owner runs once to declare that account. | Variable | Default | What it is | | --- | --- | --- | | `TESSARIDB_VAULT_STORE` | required | the node's wire address, `host:port` | | `TESSARIDB_VAULT_STORE_USER` | required | the server's account on the node | | `TESSARIDB_VAULT_STORE_PASSWORD` | required | that account's password | | `TESSARIDB_VAULT_STORE_AUTHORITY` | none | a PEM file, for a node reached over TLS | | `TESSARIDB_VAULT_SERVICE_KEY_FILE` | required | the service key file | | `TESSARIDB_VAULT_NAMESPACE` | `vault` | the namespace it keeps everything in | | `TESSARIDB_VAULT_DATABASE` | `main` | the database inside it | | `TESSARIDB_VAULT_LISTEN` | `127.0.0.1:8080` | where it serves the pages and the API | | `TESSARIDB_VAULT_PUBLIC_URL` | none | the address people reach it at; needed for passkeys | | `TESSARIDB_VAULT_PLATFORM_ADMIN_EMAIL`, `_NAME` | none | the platform admin to ensure at start, both or neither | | `TESSARIDB_VAULT_OIDC_ISSUER`, `_CLIENT_ID`, `_REDIRECT` | none | an OpenID Connect issuer to sign in through, all three or none | _What the server reads_ ```sh openssl rand -hex 32 > service-key && chmod 600 service-key export TESSARIDB_VAULT_STORE=127.0.0.1:9080 TESSARIDB_VAULT_STORE_USER=vault export TESSARIDB_VAULT_STORE_PASSWORD="$VAULT_STORE_PASSWORD" TESSARIDB_VAULT_SERVICE_KEY_FILE="$PWD/service-key" export TESSARIDB_VAULT_PLATFORM_ADMIN_EMAIL=ops@example.com TESSARIDB_VAULT_PLATFORM_ADMIN_NAME="Platform Ops" tessaridb-vault ``` ### Building the binary A Cargo workspace on Rust 1.98. The web application's built output is committed, so building the server never needs Node. ```sh cargo build --release ./target/release/tessaridb-vault ```