File Encryption Vault

The .encvault format, version 1

A vault is an ordinary folder. Everything secret in it is a standard age file, so the vault can be opened without File Encryption Vault: with vault-recover (open source) or by hand with the age command-line tool. This page is the complete specification; the reference implementation is internal/vault.

MyVault.encvault/
  vault.json            format marker, no secrets
  keys/password.age     the vault identity, encrypted with the password
  keys/recovery.age     the same identity, encrypted with the recovery key
  index.age             names, folders, sizes, checksums → blob ids
  index.1.age … .3.age  the three previous indexes
  blobs/ab/abcdef…      one age file per stored file
  lock.json             present while an app has the vault open for writing
  orphans/              set-aside blobs no index points to (purged after 30 days)
  README.txt            how to recover, in plain words

vault.json

{ "format": "encvault", "version": 1, "id": "<32 hex>", "name": "Client files",
  "created": "2026-10-24T09:00:00Z", "workFactor": 18 }

A reader must refuse a version it does not know. workFactor is informational (the real value is in each key file's scrypt stanza).

Keys

The vault has one age X25519 identity (AGE-SECRET-KEY-1…). It is stored twice, each time as the whole content (identity followed by \n) of an age file with a single scrypt recipient:

File Passphrase
keys/password.age the password, as typed (UTF-8)
keys/recovery.age the recovery key in canonical form

Recovery key: 32 characters from Crockford's base32 alphabet 0123456789ABCDEFGHJKMNPQRSTVWXYZ (160 random bits), written in 8 groups of 4 separated by -, for example K7QM-4PXD-W2HN-Q8RT-J5VC-M3YB-T9GF-E6LA. That exact string (upper case, with dashes) is the scrypt passphrase. Implementations should accept user input in any case, with spaces or without dashes, and map O→0, I/L→1, before using the canonical form.

Changing the password rewrites only keys/password.age; a new recovery key rewrites only keys/recovery.age. File data is never re-encrypted.

Index

index.age is an age file encrypted to the vault identity. Its plaintext is JSON:

{ "version": 1, "seq": 42, "updated": "2026-10-24T09:12:00Z",
  "entries": [
    { "id": "8f2c…", "parent": "", "name": "2026", "dir": true, "mtime": "…", "added": "…" },
    { "id": "1a9b…", "parent": "8f2c…", "name": "contract.pdf", "size": 486000,
      "mtime": "…", "added": "…", "sha256": "<64 hex of the plaintext>", "blob": "<32 hex>" },
    { "id": "…", "parent": "…", "name": "old.docx", "…": "…", "trashed": "2026-10-22T…" }
  ] }

Blobs

Each file is stored as one age file encrypted to the vault identity (age's ChaCha20-Poly1305 STREAM, 64 KiB chunks) at blobs/<first 2 hex>/<32 hex>. Blob names are random and say nothing about the file; its name, folder and size live only in the encrypted index. The SHA-256 of the plaintext is in the index entry and is checked on every read.

Writing safely

Writers keep this order, so a crash or power cut never leaves an index entry without its data:

  1. Write the blob to <name>.tmp, fsync, rename to <name>, fsync the folder.
  2. Read the blob back, decrypt it and compare its SHA-256.
  3. Write index.age.tmp, fsync; rename index.2→3, index.1→2, index→1; rename index.age.tmp → index.age; fsync the folder.
  4. Only then delete what is no longer referenced: an original file that was moved into the vault, a replaced blob, the blobs of deleted entries.

On open, a writer:

lock.json ({"owner": "<random install id>", "pid": 123, "beat": "<time>"}) is refreshed every minute by the app that writes. Another app opens the vault read-only unless the lock is older than 5 minutes or the user takes over. It never contains a host or user name.

Recover without the app

With the open-source tool (from this repository, go install ./cmd/vault-recover):

vault-recover "MyVault.encvault" "Recovered"            # asks for the recovery key
vault-recover -password "MyVault.encvault" "Recovered"  # or the password
vault-recover -trash "MyVault.encvault" "Recovered"     # include the trash

By hand with age and jq:

cd MyVault.encvault
age -d keys/recovery.age > /tmp/identity.txt            # type the recovery key exactly, with dashes
age -d -i /tmp/identity.txt index.age | jq '.entries[] | {name, blob, sha256}'
age -d -i /tmp/identity.txt blobs/1a/1a9b… > contract.pdf
shasum -a 256 contract.pdf                              # compare with the index

Limits and honesty