Architecture
A normative description of what encino writes to disk. All multi-byte integers are little-endian; all binary values embedded in JSON are base64. Anything not specified in the format document is an implementation detail and may change without a version bump.
Key Hierarchy
The master key is generated by the OS CSPRNG and never derived from the passphrase. That single decision is what makes rotating a passphrase cheap: it rewraps one 32-byte key and rewrites one small file, touching no document.
passphrase ──Argon2id(salt_p)──► KEK_p ──┐
├──► master key (32 random bytes)
recovery code ──Argon2id(salt_r)──► KEK_r ┘ │
├─HKDF-SHA256 "encino:index:v1"────► index key
└─HKDF-SHA256 "encino:blobkey:v1"──► blob-key-wrapping keyTwo independent wraps
vault.json holds an array of wraps — one for the passphrase, one for the 24-word recovery code. Each is an independent encryption of the same 32-byte master key with its own 16-byte salt and 24-byte nonce. The kind field is a hint for error messages only; unwrapping is authenticated, so a mislabelled entry cannot be exploited.
Distinct HKDF contexts
The index key and the blob-key-wrapping key are derived with different HKDF-SHA256 context strings, so no two roles ever share key material. Neither is stored; both fall out of the master key on every unlock.
Per-document content keys
Each document additionally carries its own random 32-byte content key, stored wrapped under the blob-key-wrapping key inside the index. Rekeying or destroying one document never disturbs another.
Associated data binds every key to its context
A wrap carries "encino:wrap:v1" ‖ vault_id, so it cannot be transplanted between vaults. A content key carries "encino:contentkey:v1" ‖ blob_id, so repointing an index entry at a different blob cannot make one document's key apply to another's ciphertext. The index itself carries "ENCINOI1" ‖ vault_id, so an index from one vault cannot be substituted into another.
Directory Layout
The vault folder's root is the plaintext work area. When no document is checked out it contains nothing but .encino.
<vault folder>\
.encino\
vault.json format version, KDF params, wrapped keys
index.enc AEAD-sealed document index
index.enc.bak previous generation of the index
blobs\
0f\
0f3a91c7… one encrypted document
journal\
<op-id>.json in-flight operation records
staging\
<op-id> plaintext being assembled by a checkout
work.lock held open for the life of a session
<checked-out documents appear here as plaintext>vault.json is plaintext, intentionally
It holds only KDF parameters, salts, and wrapped key blobs, none of which are secret. Keeping it readable means a vault can be diagnosed and migrated without the passphrase. KDF parameters are calibrated at init to roughly one second on the machine that created the vault, floored at the OWASP minimum of 19 MiB and two iterations, and stored rather than hardcoded — so a future build can raise its defaults without becoming unable to open existing vaults.
staging shares a volume with its destination
A document is decrypted to staging\<op-id>, checked against the size and digest recorded in the index, and only then renamed into the work area. A failure anywhere in that sequence leaves a partial file in staging rather than one in the work area that looks whole — and the partial is shredded rather than unlinked, since it holds plaintext. Staging lives inside .encino precisely so that final move is an atomic rename and not a copy.
Blobs are sharded by their first byte
One file per document at .encino\blobs\<first byte hex>\<blob id hex>. The single-byte prefix exists only to keep directory sizes reasonable — it is not a security property. Blob IDs are 16 random bytes, not a hash of the content: content addressing would give identical documents identical names, letting an attacker confirm a guessed document by hashing a candidate and looking for the name.
Blob Encoding
Every chunk is sealed with XChaCha20-Poly1305 under the document's content key with a fresh random nonce. Chunking bounds memory: a multi-gigabyte document must not require full materialization in RAM to be verified.
| Field | Size | Meaning |
|---|---|---|
| magic | 8 bytes | "ENCINOB1" |
| chunk_size | 4 bytes | plaintext bytes per chunk (1,048,576 in v1) |
| chunk_count | 8 bytes | number of chunks that follow |
| plaintext_len | 8 bytes | the document's true length, before padding |
| chunks | variable | chunk_count × (24-byte nonce ‖ ciphertext ‖ 16-byte tag) |
Size padding — Padmé, with a floor
MIN_BLOB_BYTES = 4096
padded_len(L):
if L ≤ MIN_BLOB_BYTES: return MIN_BLOB_BYTES
E = ⌊log₂ L⌋
S = ⌊log₂ E⌋ + 1
return L rounded up to a multiple of 2^(E−S)Without padding, a blob's size approximates its document's — enough to confirm a guessed document such as a specific tax form or contract without any ability to decrypt it. Padmé bounds the relative leak: overhead is at most 11.9%, and what a size still reveals is roughly log log L bits.
The 4 KiB floor supplies what Padmé alone does not — every document below it is exactly the same size on disk. Padmé would pad a 200-byte note to 200 bytes and leave it as identifying as it was. padded_len is monotonic, which matters: a bucket function that let a larger document produce a smaller file would leak more than the padding hides.
The encrypted index
magic 8 bytes "ENCINOI1"
nonce 24 bytes
ciphertext … XChaCha20-Poly1305 under the
index key, AAD = magic ‖ vault_id
tag 16 bytesA single AEAD-sealed JSON document. Because logical paths exist only here, both filenames and the directory tree are hidden and the blob store reveals no structure. Each entry carries its path, blob ID, wrapped content key, plaintext size, a SHA-256 of the plaintext for verify, and modified and added timestamps.
The index is the single point of total failure: lose it and every blob becomes unrecoverable noise. It is replaced by write-temp, fsync, rename — atomic on NTFS — and the prior generation is kept as index.enc.bak. A crash leaves either the old index or the new one, never a torn one.
vault.json, the index and each journal record are all replaced the same way, through a .<name>.<random>.tmp sibling. Nothing is meant to survive in either that form or in staging: anything found in either place is debris from an interrupted run. Timestamps in the index are RFC 3339 and carry whatever sub-second precision the source had, so a reader must accept a fractional part.
The Journal
One JSON file per in-flight mutation, named for its operation ID, written before the mutation begins and deleted after it completes. A non-empty journal at startup means a previous run was interrupted, and triggers roll-forward or rollback.
| Stage | Meaning |
|---|---|
| started | The operation is recorded but nothing has been written yet |
| data-written | New plaintext or new blob exists and is fsynced |
| index-updated | The index reflects the new state; the old state may now be destroyed |
The invariant that must never break: a document is only removed from its old state once its new state is durable. Checkout fsyncs the plaintext before clearing the journal entry; check-in fsyncs the new blob before shredding the plaintext.
work.lock
Grants one session exclusive access to a vault, held open for the session's lifetime. On Windows it is opened for writing while denying write sharing; on Unix it carries a non-blocking exclusive flock.
The lock is the open handle, not the file
A work.lock left on disk by a process that died grants nothing and is simply reused by the next session. The operating system releases the handle when the holder exits for any reason, so there are no stale locks, no timeout to tune, and no override flag. Read sharing is permitted so a refused process can report who is in the way — but those contents are a courtesy for the error message and carry no authority. Only the handle does.
Why merging concurrent writers is not an option. Sessions replace the index wholesale, and each document's content key lives inside its index entry. Two concurrent writers would mean the later save destroying the earlier one's key, leaving an undecryptable blob and a shredded original. The document would be gone. A session therefore takes the lock before reading the index — reading first would leave a window in which another session could replace it in between.
Platform Differences
Confined to three things, and each takes the same shape on both systems. Windows and Linux are both verified in CI; macOS should work — it takes the Unix paths throughout — but has not been tested.
| Windows | Unix | |
|---|---|---|
| Vault lock | File opened denying write sharing | flock(LOCK_EX | LOCK_NB) |
| Agent transport | Named pipe, DACL'd to your SID | 0600 socket in a 0700 directory |
| Who may connect | The DACL, plus remote clients refused | The directory, plus SO_PEERCRED on Linux |
| Key kept out of swap | VirtualLock | mlock |
221 tests, and why the counts differ by platform
212 run on Windows and 206 on Linux. The difference is not coverage but platform: the Windows-only tests cover named pipes and SIDs, the Linux-only ones file permissions, socket paths, and the peer-credential check on an accepted connection. Both platforms are verified on every push, with Linux tested from Windows through WSL.
A separate CI job builds against the rust-version declared in Cargo.toml, reading it from the manifest rather than repeating it so the two cannot drift. Every other job runs on stable, so without it that floor would never be exercised and would quietly become fiction the first time a dependency raised the real one.
Two constraints invisible from reading the code
Do not set RUSTFLAGS. It replaces .cargo/config.toml rather than merging with it, discarding the -C target-feature=+crt-static that lets the binary run without the Visual C++ redistributable. CI asserts the binary imports no vcruntime.
The agent subcommand lives in encino-cli on purpose. cargo test builds only the binaries of the package under test, and the integration tests drive a real session daemon. Moving that target passes locally, where a stale binary is lying around, and fails on a clean clone.
Reserved for Future Versions
What a format_versionbump is being held for — recorded in the spec so the current design's known gaps are on the record rather than discovered later.
Hiding the document count
Padding hides how large each document is, but the number of blobs is still a directory listing away. Closing that needs decoy blobs, whose cost is ongoing rather than one-off.
Additional key wraps
The wraps array is already a list, so adding a TPM- or Windows-Hello-sealed entry is additive and may not even need a bump.
Compression — declined for v1
Compressing before encrypting leaks information through ciphertext length, and the documents this tool targets are mostly already-compressed PDFs and images. The cost is real and the gain is not.