Perseus Vault Encryption Specification

September 4, 2026 · View on GitHub

This document specifies exactly how Perseus Vault encrypts data at rest: the algorithm, key format, what is and is not encrypted, the default installation posture, and the security properties (and limits) you can rely on. It is intentionally precise so you can reason about the Vault in a threat model — see THREAT-MODEL.md.

Encryption is enabled by default for fresh installations. The first write path that opens a new database (e.g. serve, write, capture, prepare, maintain) automatically generates a secure standard key at ~/.perseus-vault/secret.key (owner-only on Unix) and establishes the encrypted canary. Existing plaintext databases are not silently converted: they fail closed with an actionable init --rekey migration path, or an explicit, documented plaintext opt-out (see §2.3).


1. Algorithm

PropertyValue
CipherAES-256-GCM (AEAD)
Key size256 bits (32 bytes)
Nonce96-bit (12-byte), random per message, from the OS CSPRNG (OsRng)
Authentication tag128-bit (GCM default), verified on every decrypt
AAD (additional authenticated data)"{category_utf8_byte_len}:{category}:{key_utf8_byte_len}:{key}" of the entity
Implementationaes-gcm crate (RustCrypto)

Each ciphertext record is stored as base64( nonce_12_bytes || ciphertext || tag ). The nonce is generated fresh for every encryption and prepended to the output; decryption splits it back off.

Why AAD matters

The entity's length-prefixed category and key are bound into the ciphertext as AAD. Decryption fails if the AAD does not match — so an attacker who can write to the database cannot move a valid ciphertext from one entity to another (a copy/replace attack) without detection. Both UTF-8 byte lengths remove delimiter ambiguity, including the legacy category:key encoding. The tag covers both the body and the identity it belongs to.


2. Key format and management

Perseus Vault uses a raw 256-bit key, not a passphrase. There is no password-based key derivation (no Argon2/PBKDF/scrypt). The key is read verbatim from a key file.

Key file

  • Content: a single base64-encoded 32-byte key (trailing whitespace trimmed).
  • Default path: ~/.perseus-vault/secret.key (%USERPROFILE%\.perseus-vault\secret.key on Windows). The legacy ~/.perseus-vault/secret.key path is honored for existing installs so an encrypted vault is never orphaned by the rename.
  • A key of the wrong length is rejected at startup.

Generating a key

perseus-vault keygen                          # writes ~/.perseus-vault/secret.key
perseus-vault keygen --key-file /path/to.key  # custom location

Fresh default databases also generate a key automatically on first write (encrypted by default — see above), so keygen is needed only for custom key locations or rotation.

keygen draws 32 bytes from the OS CSPRNG and base64-encodes them.

Filesystem permissions caveat. On Unix, keygen sets the key file to 0o600 (owner read/write only). On Windows, the file is created with the directory's default ACL — Perseus Vault does not tighten Windows ACLs. If you run on Windows, restrict the key file's ACL yourself.

Using a key

perseus-vault --encryption-key ~/.perseus-vault/secret.key

Perseus Vault never stores, transmits, escrows, or logs the key. Key custody, rotation, and backup are entirely the operator's responsibility. Back up the key file immediately — a fresh default install creates it automatically, and losing it makes all encrypted bodies unrecoverable.

serve, write, prepare, capture, maintain, obsidian-sync, and the maintenance commands automatically use the standard key path when it already exists (~/.perseus-vault/secret.key, or the legacy ~/.perseus-vault/secret.key during migration) and create it for a fresh database. An explicit --encryption-key always takes precedence. Client configuration generated by connect/install-client also includes the detected key path, so an encrypted vault is not silently opened for plaintext writes.

Existing plaintext databases

Encryption is the default for fresh databases. An existing plaintext database is never silently converted into a mixed store that looks encrypted while old bodies stay readable:

  • Fail closed: a write command against an existing plaintext database refuses to start with an actionable message pointing at perseus-vault init --rekey --db <path>, which encrypts existing bodies in place under the standard key.
  • Explicit opt-out: setting PERSEUS_VAULT_ALLOW_PLAINTEXT=1 in the environment permits plaintext writes to an existing plaintext database (the same escape hatch also suppresses default key creation for a fresh database). This is a documented, visible bypass — it prints a warning on every run and is intended for scripting legacy migrations, not for permanent operation.
  • Legacy behavior preserved: init without --rekey still establishes the canary on a plaintext database exactly as before, and init --rekey encrypts existing bodies; the key file is never silently replaced.

If an encrypted database is started without a key, the Vault refuses to open it with a fatal error — never a silent fallback to plaintext writes.

Rotation and recovery

  • No automatic rotation. There is no built-in scheduled rotation. Rotating a key means decrypting with the old key and re-writing with the new one (e.g. perseus-vault init --rekey --key-file /path/to/new-key, see below).
  • Key recovery is manual. If the key is lost, encrypted body_json is unrecoverable — back up the key file immediately after init or keygen. See Key recovery procedure below.
  • Wrong-key startup fails closed. When a key is loaded, the canary is verified before any read/write. If authentication fails, Vault refuses to start with a fatal error — it no longer silently returns ciphertext as plaintext.
  • Keys are never stored in SQLite or printed in diagnostics. The key file is read once at startup and kept only in process memory. The doctor command reads the canary table but never reveals the key material.

Key recovery procedure

  1. Back up the key file immediately after generating it:

    cp ~/.perseus-vault/secret.key ~/.perseus-vault/secret.key.backup-$(date +%F)
    

    Store a second copy off-site (e.g. encrypted vault, password manager).

  2. Back up the database before any rekey or migration:

    cp ~/.perseus-vault/data/perseus-vault.db ~/.perseus-vault/data/perseus-vault.db.backup-$(date +%F)
    
  3. Test your backup by restoring it on a different machine:

    perseus-vault doctor --db /tmp/restored.db  # confirms encryption state
    perseus-vault serve --db /tmp/restored.db --encryption-key /path/to/secret.key
    
  4. If the key is lost and the database is encrypted:

    • body_json content is permanently unrecoverable.
    • Metadata (categories, keys, timestamps, FTS index) is still readable.
    • Run perseus-vault init --rekey with a new key to encrypt plaintext-only write targets (new writes only; existing ciphertext stays unrecoverable).
    • Filesystem-level recovery tools (extundelete, PhotoRec) on the key file's directory may help if the key was recently deleted.

Plaintext, encrypted, and mixed databases

Perseus Vault categorises a database into one of three storage states, reported by perseus-vault doctor without requiring an encryption key:

Statedoctor outputMeaning
Plaintextplaintext (not encrypted ...)The encryption_canary table has no authoritative id=1 row and all sampled body_json values are raw JSON. A key has never been provided, or was removed after creation. Safe to read without a key.
Encrypted[ENCRYPTED] AES-256-GCM canary presentThe authoritative encryption_canary id=1 row exists; body_json values are ciphertext. A key is required for reads and writes. The canary is verified on every startup — a wrong key is rejected with a fatal error.
Mixed legacy[WARN] mixed — some bodies appear encryptedThe canary is absent but some body_json values match the ciphertext format. This happens when encryption was enabled and later the canary was lost (e.g. a partial restore from backup). Run perseus-vault init --rekey to establish a canary and normalise the state.

The migration contract is exercised by tests/encryption_bootstrap.rs::init_rekey_migrates_existing_plaintext_rows_and_is_idempotent: the first explicit rekey encrypts the legacy row and establishes the canary; re-running it skips already encrypted rows rather than double-encrypting them.

The perseus-vault init command always produces an encrypted database. A fresh default database is encrypted by default — the standard key is generated and the canary established on first write. A plaintext database only exists when it was created before this default, or when the operator explicitly opted out with PERSEUS_VAULT_ALLOW_PLAINTEXT=1.


3. Encryption scope — what is and is NOT encrypted

Under the protected encryption profile, AES-256-GCM covers the canonical live and historical bodies plus prospective-query hints. The two FTS5 tables use a separate keyed blind-index representation so keyword search remains available without storing body text or raw ciphertext in the index.

Encrypted with AES-256-GCM

DataWhere
Live entity bodyentities.body_json
Historical entity bodyentity_history.body_json
Prospective query hints (JSON array)entities.hints

The same length-prefixed AAD binds each body or hint value to its entity category and key. Re-keying covers live rows, history rows, hints, dedup signatures, the encryption canary, and the keyed journal audit chain in one verified transaction before the new protected-search profile is advertised.

Protected search indexes

DataWhereRepresentation
Live keyword indexentities_fts (FTS5)hmac-sha256-blind-token-v1 tokens
Historical keyword indexentity_history_fts (FTS5)hmac-sha256-blind-token-v1 tokens

The blind-index key is a domain-separated HMAC-SHA256 subkey derived from the operator key; the raw key is not stored in SQLite. Text is lowercased and split on non-alphanumeric boundaries. Each term contributes its full keyed token and bounded tokens for prefixes of three through 32 characters, preserving exact keyword and common prefix queries without writing body text to FTS5. The index is deterministic by design, so it leaks token equality/frequency, match relationships, and bounded prefix/token-count information. It does not by itself provide an offline plaintext dictionary without the search key, and it is not SQLite page encryption.

NOT encrypted by this profile (plaintext on disk)

DataWhereWhy
Category, keyentities.category, entities.keyLookup keys; also used as AAD
Tags, topic path, type, sourceentities.*Filtering / routing
Status, layer, decay score, counts, timestampsentities.*Ranking / lifecycle
Workspace hash, agent id, visibilityentities.*Multi-tenant scoping
Embedding vectorsembedding storageDerived from body content; stored as raw floats
Journal entries, state key/value, linkstheir tablesNot covered by body encryption; keyed audit integrity is separate from confidentiality

⚠️ The database file is not opaque

Protected FTS prevents body recovery from FTS shadow tables, but it does not hide metadata, embeddings, journal/state payloads, or deterministic blind-index relationships. SQLite WAL/SHM files and backups must be treated as part of the same storage boundary. If your threat model requires the complete database file to be unreadable, also protect the file and its transient copies with full-disk/filesystem encryption (LUKS, FileVault, BitLocker) or an encrypted volume. Do not describe this profile as whole-database/page encryption.

Governance overlay and physical-copy handling

Permanent erasure mandates live in the separate governance sidecar <database>.governance.db. The sidecar is a rollback-resistant policy boundary, not an encrypted copy of the primary database. backup_to and restore_backup preserve it at the corresponding destination path when it exists; both the primary snapshot and the sidecar snapshot are produced with SQLite VACUUM INTO and validated with PRAGMA quick_check. Existing destinations, broken or non-regular sidecars, and sidecar symlink substitutions are rejected rather than silently omitted.

Encrypted migration and repair paths checkpoint before and after VACUUM, require a non-busy complete checkpoint, and reject non-empty -wal or -journal residue for the primary database and governance sidecar before activation. VACUUM INTO snapshots do not copy those transient files. This is not a guarantee that deleted filesystem blocks, every SQLite auxiliary file, or all metadata are cryptographically erased; use filesystem/full-disk encryption when that stronger property is required.


4. Encryption in transit

At-rest encryption is independent of transport. Perseus Vault's default transport is MCP over local stdio (no network). If you enable an HTTP/SSE transport, secure it with TLS and authentication at the deployment layer — see transport.md. The encryption key is not involved in transport security.


5. Properties you can rely on (and cannot)

You can rely on:

  • entities.body_json, entity_history.body_json, and entities.hints are confidential at rest under AES-256-GCM, given a secret key.
  • Body integrity/authenticity is verified on read (GCM tag), bound to the entity's length-prefixed category and key via AAD.
  • Protected live/history FTS indexes contain keyed blind tokens rather than plaintext body text, with the leakage limits described above.
  • Keys never leave the machine; no telemetry, no escrow.
  • Fresh default installs are encrypted out of the box — no operator step required.

You cannot rely on (today):

  • The database file being opaque — metadata, embeddings, journal/state payloads, and blind-index equality relationships remain observable without the key (this is why the headline claim is "encrypted bodies with protected search", not "the entire database is encrypted").
  • Passphrase strength — the key is a raw 32-byte value; protect the key file.
  • Forward secrecy or per-record keys — one static key encrypts all bodies.
  • Automatic rotation or key recovery.

Verified against src/encryption.rs and src/db.rs at v2.23.2. If the implementation changes, update this spec in the same PR.