---
title: Ship sessions to a vault you run
description: What nd7 ship does, how sessions are encrypted before they leave the machine, what the vault can and cannot read, and how to deploy one.
order: 100
section: Audit log
---

A hash chain on your own disk proves a log has not been edited only if you trust the disk. Anyone who can write to that directory can rewrite the whole chain. A copy held on a machine the recording agent cannot reach turns that self-check into evidence: if the local log is rewritten later, it no longer matches the copy. Keeping that copy is what a vault is for, a vault being a server you run yourself. This page covers what `nd7 enroll` and `nd7 ship` do, what is encrypted and by whom, what a vault operator can see, and how you deploy a vault. It follows [VAULT.md](https://github.com/nd7-dev/nd7-core/blob/main/docs/VAULT.md) and [src/ship.rs](https://github.com/nd7-dev/nd7-core/blob/main/src/ship.rs).

The machine side is implemented in nd7-core. The server and its browser viewer live in a separate repository, `nd7-vault`. VAULT.md records that the vault's own admin command line was still being written when that document was written, and that the end-to-end acceptance run had not yet been done.

## Enrol a machine

```sh
nd7 enroll https://vault.example.com <token>     # --rotate to replace the key
```

Enrolment binds one machine to one vault. It generates an Ed25519 signing key for the machine, fetches the vault's admin public keys, and — this is the point — refuses to finish if those keys do not hash to the fingerprint carried inside the enrolment token. An admin sees that fingerprint printed when they create the token, so the two can be compared through some channel other than the vault itself. Without that check, a dishonest vault could hand over its own key instead and then receive everything encrypted to itself.

If the check fails, **nd7 writes nothing at all on the machine**. If it succeeds, it stores four files under `$XDG_STATE_HOME/nd7/vault/`, all mode 0600, which means readable and writable by your user account and nobody else:

- **`machine.key`**: the Ed25519 private key that signs every request
- **`machine.id`**: the id the vault assigned
- **`server`**: the base URL
- **`recipients`**: the pinned admin public keys and their signed metadata

Tokens expire after an hour, and using one uses it up. nd7 refuses to enrol a machine that already has a key unless you pass `--rotate`, which registers a new signing key under the same machine id and retires the old one.

A machine that is never enrolled behaves exactly as it did before. `nd7 record` never opens a network connection, enrolled or not.

## Ship

```sh
nd7 ship                      # push everything pending, exit 0 if all acked
nd7 ship --every 30s          # loop; a launchd or systemd timer running the one-shot form is preferred
nd7 ship --prune-after 30d    # after shipping, delete sessions the vault has fully acknowledged
```

By default `nd7 ship` runs once and exits. It never runs inside a hook, and it is the only part of nd7 that uses the network. Per session it:

1. Refreshes the set of admin keys from the vault. If that set has changed and the change is not signed by a key the machine already pinned at enrolment, it ships nothing.
2. Reads `head` and `shipped`. If they are equal there is nothing to do — which is the usual case, and costs one `stat`, a single look at a file's metadata.
3. Reads the bytes after the last acknowledged entry, finding them by seeking backwards from the end rather than reading the whole log.
4. Runs the same chain verification the local `nd7 verify` runs, over exactly those bytes. A failure here means the local log is corrupt or has been rewritten: that session stops and the run reports it.
5. Splits the bytes into batches of at most 8 MiB, cutting only at entry boundaries. It then compresses each batch, encrypts it, builds a clear index for it (a short list, left unencrypted, giving each entry's position, hashes and time: see below), and pushes it.
6. Once the vault acknowledges a batch, rewrites `shipped` through a temporary file and a rename.

Two per-session files sit next to `head`. `shipped` holds the sequence number and hash of the last entry the vault acknowledged. `chain.key`, mode 0600, holds the key for this chain while the chain is open. When a session's last entry is `session_end` and the vault has acknowledged everything, nd7 deletes `chain.key`, and the machine then holds no key for that chain at all.

`nd7 ship` takes the session lock for the reads and for the `shipped` write, and never holds it across a network request: `nd7 record` waits on that same lock on every hook, and its budget is milliseconds.

**Exit codes.** `nd7 ship` exits 1 when a session stopped on a divergence, meaning the vault already holds a different entry at that position, or on a local verification failure. Neither of those resolves itself, and both want a person to look. A vault that cannot be reached is a different matter: nd7 leaves the files on disk alone and the entries go on the next run. The run-once form returns 1 in that case, so that a timer notices the machine is falling behind; `--every`, which will try again shortly anyway, returns 0 and keeps looping.

Pruning removes a session directory only when `shipped` equals `head` and the last entry is older than the threshold. It never removes a session that stopped on a divergence.

## What is encrypted, and by whom

Encryption happens **on the machine**, before anything leaves it, to keys the vault does not have. The building blocks are standard ones, and nd7 invents none of them: Ed25519 for signatures, X25519 sealed boxes for wrapping keys (encrypting one key so that only the holder of a particular private key can unwrap it), XChaCha20-Poly1305 for encrypting the batches themselves, and BLAKE3 for hashes.

- **admin key**: an X25519 pair per admin. The private half never reaches the vault; it lives in the admin's OS keychain or a hardware token. The public half is registered and fingerprinted.
- **chain key**: a random 256-bit key per session-and-host, generated on the machine the first time it ships that chain, and wrapped once per admin as a sealed box to that admin's public key. The wraps travel with the first batch.
- **machine key**: an Ed25519 pair per machine. It signs every request. It is not an encryption key: leaking it lets someone push entries as that machine, never read any.

There is no organisation key and no vault master key, by design. Each batch is encrypted together with its `session_id`, `host`, `first_seq` and `last_seq` as associated data. That means those four values are bound into the encryption without being hidden by it, so an encrypted batch cannot be moved to another chain, or to another position in the same chain.

Every request from a machine carries its machine id, a timestamp, a BLAKE3 hash of the body as sent, and an Ed25519 signature over the method, path, machine, timestamp and body hash. The vault rejects a timestamp more than five minutes from its own clock. It keeps no record of past requests to spot replays, because every write is safe to repeat: sending a captured request again changes nothing. Transport is HTTPS with TLS 1.3 as the minimum. `http://localhost` and `http://127.0.0.1` are accepted for development, and there is no flag that turns off certificate checking for anything else.

## What the vault stores, and who can read what

The vault stores encrypted batches as blobs it cannot read, plus a small **clear index**, which is left unencrypted: for each entry its `seq`, `prev`, `hash` and `ts`. No `kind`, no body. It also stores the wrapped copies of each chain key, chain heads, machines, organisations and their login providers, admins with their public keys, sessions, and a receipt log. It stores the bytes it was given and never rewrites them.

It verifies **chain linkage only**: that a batch continues from the head it holds, that sequence numbers run on with no gaps, and that each `prev` equals the previous `hash`. It cannot check that an entry's `hash` is the BLAKE3 of the entry's bytes, because it does not have the bytes. That check happens wherever the plain text exists: on the machine before shipping, and on the admin's side after decrypting. What the linkage check does guarantee is that a later local rewrite, which changes hashes, shows up on the next push as a conflict. A hash that differs from the one already stored at that sequence number is reported, logged as a security event, and never resolved automatically.

What a leak exposes:

- **the vault database and disk**: exposes ciphertext, hashes, timestamps, session ids, hostnames, admin public keys
- **one admin's private key**: exposes that organisation's chains, for as long as the wraps to that key exist
- **one chain key**: exposes that chain
- **one machine key**: exposes nothing readable; the ability to push signed garbage as that machine
- **vault operator credentials**: exposes the same as the database

The honest limit, as VAULT.md puts it: an admin key opens everything in its organisation. That is what an admin is. What keeps the damage down is holding admin keys in hardware, having few admins, and removing a key as soon as it is suspected. Note also how much the surrounding data gives away on its own, even unread: session ids, hostnames, entry counts and timestamps, and therefore when people work and how much.

One more consequence, worth stating plainly: entries hold whatever the agent saw, including any secrets it read, unencrypted on the local disk. That is the premise the vault is designed around, and the reason the machine encrypts entries itself rather than leaving it to the server.

## Admins

An admin is identified by a login provider and by the unchanging identifier that provider gives for the person. The vault stores no passwords, ever. Two provider implementations are specified. `oidc` is an OpenID Connect authorization code flow with PKCE (Proof Key for Code Exchange) and discovery from the issuer URL; it covers Google Workspace, Okta, Microsoft Entra, Auth0 and Keycloak. `dev` is a form that accepts an email address; it is enabled only when the server listens on loopback, the local machine's own address, with `ND7_VAULT_DEV_LOGIN=1`, and refused on any other bind address.

Logging in gives an admin access to the clear index and to the encrypted batches. **Reading an entry additionally needs the admin's private key**, which is used in the browser or in the admin command line and never sent to the vault.

Adding an admin means an existing admin unwrapping every chain key and wrapping it again to the new public key. Enrolled machines pick the new key set up on their next ship, and accept it only if the change is signed by a key already in the set they pinned. Removing an admin deletes their wrapped keys and their public key. New chains are never wrapped to them again, and chains they have already decrypted are already known to them, which nothing can undo. Existing chains do not get new keys automatically. If the removal follows a suspected leak, an admin runs the rekey command explicitly, which generates a new key for each chain, re-encrypts the chain and wraps the new key again.

The admin command line does not log in through a provider. It signs each request with the admin's Ed25519 key, derived from the same seed as the X25519 key, using exactly the scheme machines use. There is no API token, no service account, and no second kind of secret anywhere in the system.

## Deploying a vault

Only what VAULT.md specifies:

- **Rust, a single binary, a single process.** SQLite for metadata, blobs on disk in a directory tree keyed by chain. Postgres is a later decision.
- **Configuration is a handful of environment variables**: bind address, data directory, the public base URL used for OIDC redirects, and `ND7_VAULT_DEV_LOGIN`. There is no master key to configure, by design.
- **No background jobs.**
- **Bootstrap** with `nd7-vault admin bootstrap`, which creates the organisation, its first login provider, and the first admin, and sets that admin's public key. You add, remove or rekey further admins through `nd7-vault admin` subcommands.
- **The viewer** is a set of static files, with no server code of its own, served by the same binary. It gives an admin three pages. One lists the sessions from the clear index. One shows a single session: the browser decrypts its entries with the admin's private key, checks them against the index, and renders them in order, marking in red any entry where the two disagree. One is a verify page, showing the vault's linkage result, the browser's own byte-hash result, and the receipt log. Nothing else.

Left out of the first version on purpose: search, dashboards, alerting, secret detection, roles other than admin, running in more than one region, and any change to the entry format.

## Related

- [How the audit log works](/docs/audit-log): what is in a session before it ships.
- [The log format](/docs/log-format): the entries, and the chain the vault checks the linkage of.
- [Design decisions](/docs/decisions): ADR-0005 is the decision to encrypt on the machine rather than on the server.
