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…" }
] }
parentis the id of the containing folder,""for the top level.- An entry with
trashedis in the vault's trash; so is everything under a trashed folder. seqgrows by one on every save. Readers use the readable index with the highestseqamongindex.age,index.age.tmp,index.1.age,index.2.age,index.3.age.
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:
- Write the blob to
<name>.tmp, fsync, rename to<name>, fsync the folder. - Read the blob back, decrypt it and compare its SHA-256.
- Write
index.age.tmp, fsync; renameindex.2→3,index.1→2,index→1; renameindex.age.tmp → index.age; fsync the folder. - 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:
- removes
blobs/**/*.tmp(blobs that never finished writing); - moves blobs no index entry points to into
orphans/, unless an older index had to be used, in which case they are listed under a "Recovered files" folder (they are files added after that index); - moves a referenced blob found in
orphans/back intoblobs/.
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
- Not encrypted: the vault's name in
vault.json, the number of files and each blob's size (age adds a small, fixed overhead), and timestamps of the files on disk. -
Names in the index are not trusted when files are written out: implementations must never treat them as paths and must make them valid for the local file system (
internal/vault/names.go,safe()in vault-recover). -
Deleting a file from the vault deletes its blob, but on SSDs and synced folders the old encrypted bytes may survive in free space or version history. They stay encrypted.
- Apps used to open a decrypted copy may keep their own copies (thumbnails, autosave). The app deletes its temporary copies when the vault locks.
- Two computers writing the same synced vault at the same moment can conflict;
lock.jsonmakes the second one read-only.