Skip to content

Secrets vault

Governed by ADR 0003. Spec: docs/v0.5-implementation-spec.md.

Rivet’s default posture is to exclude secrets: .ssh, .gnupg, keyrings, and files that match key/token signatures are refused before they can be captured. That keeps a plaintext Git snapshot safe, but it also means a restored machine is missing exactly the things that make it yours. The vault is the sanctioned way to include those files — encrypted end to end, with the private key never leaving your machine.

Two layers, not one: the plaintext scanner stays on to block accidental secrets in ordinary tracked paths; the vault is the deliberate, encrypted channel for the ones you actually want to carry.

  • Identity — one age X25519 keypair generated by rivet vault init. The private half lives only at ~/.config/rivet/identity.age (mode 600), optionally passphrase-wrapped. It is never written into a snapshot, a backend, or rivet.toml.
  • Recipients — the public half (age1…), recorded in rivet.toml under [vault]. Safe to commit. You can add more (a backup key, a second machine) so one sealed secret restores in several places.
  • Declared secrets[[secret]] entries in rivet.toml naming a file to seal. Declaring ~/.ssh/config is the explicit opt-in that lets an otherwise excluded path be captured — always as ciphertext.
  • Sealed blobssecrets/<name>.age inside the snapshot. Only ciphertext is ever written; plaintext exists on disk only transiently, on the local machine, during a seal/unseal call.
Terminal window
# Generate the vault identity; --passphrase wraps it at rest (recommended).
rivet vault init --repo ~/rivet-snapshots --passphrase
# STRONGLY recommended: add a backup/offline recipient so a lost identity is
# not a lost vault. Can also be passed to `init` as --recipient <age1...>.
rivet vault add-recipient --repo ~/rivet-snapshots age1<backup-public-key>
# Declare a file to seal (repeat per file). ~ / $HOME are expanded.
rivet vault add --repo ~/rivet-snapshots ~/.ssh/config
rivet vault add --repo ~/rivet-snapshots ~/.netrc --store age
rivet vault list --repo ~/rivet-snapshots # recipients + declared secrets
rivet vault rm --repo ~/rivet-snapshots ssh-config

Sealing then happens automatically as part of rivet snapshot, and unsealing as part of rivet restore --apply --yes (which prompts for the identity passphrase if the identity is wrapped, or reads RIVET_VAULT_PASSPHRASE for non-interactive use). rivet vault seal seals into the repo without a full snapshot, as a preview.

Sealing needs only the public recipients, so you can snapshot a machine that holds no private key at all. Unsealing needs the private identity.

  • age (default, built-in) — public-key encryption to the vault recipients via the same age primitive Rivet uses everywhere else. No external binary, no keyring, no configuration beyond vault init.
  • sops (optional) — for users already invested in Mozilla SOPS (age/GPG/KMS). Declare a secret with --store sops. Rivet orchestrates; sops owns the crypto and the keys (your .sops.yaml / SOPS_AGE_* / KMS setup). sops must be installed and configured with a recipient.

pass and Bitwarden are not yet implemented as stores; they map awkwardly onto per-file seal/unseal and are deferred to a later, dedicated design.

What the vault protects against

  • A snapshot repository (local, GitHub, NAS, S3/B2/Drive via rclone, restic) being read by anyone who does not hold a listed identity. Every declared secret is age ciphertext at rest; names and modes are visible, contents are not.
  • The private identity leaking through a backend. It is written only to ~/.config/rivet/identity.age and is asserted-absent from every backend artifact by tests that walk the on-disk output.
  • Accidental plaintext capture. The scanner still blocks key/token signatures in ordinary tracked paths; a declared secret is the only path that bypasses it, and even then only ciphertext is written.
  • With a passphrase-wrapped identity: an attacker who obtains the identity file still needs the passphrase (the file is itself an age scrypt blob).

What it does NOT protect against

  • A compromised local machine at restore time. Unsealing writes plaintext to its real home location (e.g. ~/.ssh/config) with its recorded mode. Anything that can read your home directory after a restore can read those files — that is the point of restoring them. The vault protects secrets in the snapshot, not your live machine.
  • Transient plaintext in memory. During a single seal/unseal, the plaintext exists in a normal buffer in Rivet’s process. The identity and any passphrase are held in zeroized-on-drop SecretStrings; the per-secret plaintext buffer is not separately zeroized (it is short-lived and never written anywhere but its ciphertext form / final destination). Hardening this is a possible future change.
  • Metadata. A sealed secret’s name, home-relative destination path, and mode are recorded in the manifest in cleartext (a backend needs them to place the file on restore). Do not encode a secret in a filename.
  • A weak passphrase. Passphrase wrapping is only as strong as the passphrase.
  • Backend-native encryption gaps. restic and an encrypted fs/rclone backend add a second at-rest layer, but the vault ciphertext already stands on its own; do not rely on backend encryption as the only protection.

A lost identity means every secret sealed only to it is lost forever. There is no recovery path and no backdoor — that is a property of the encryption, not a missing feature.

Mitigate it before it happens:

  1. At vault init, add at least one backup recipient — an offline/hardware key, or the identity of another machine you control:
    Terminal window
    rivet vault init --repo <repo> --passphrase --recipient age1<backup-public-key>
    Every secret sealed afterward can then be unsealed by either key. (Adding a recipient does not re-encrypt already-sealed secrets — re-run rivet snapshot to re-seal existing ones to the new recipient too.)
  2. Back up ~/.config/rivet/identity.age itself to safe offline storage, separate from your snapshots. Passphrase-wrap it so the backup copy is not plaintext key material.
  3. Keep the passphrase somewhere you will not lose it either — a wrapped identity with a forgotten passphrase is as lost as a deleted one.
  • Write your identity or passphrase into any snapshot or backend.
  • Unseal without an available identity. On a machine that does not hold one, restore reports a clear error and writes nothing — it never prompts you to copy a key into a shared or repository location.
  • Rotate keys automatically. Re-encrypting existing snapshots to new recipients (true key rotation) is deliberately out of scope for v0.5 and is flagged for a reviewed design before it is built.