On-disk format, version 1

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.

key-hierarchy
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 key

Two 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
<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.

FieldSizeMeaning
magic8 bytes"ENCINOB1"
chunk_size4 bytesplaintext bytes per chunk (1,048,576 in v1)
chunk_count8 bytesnumber of chunks that follow
plaintext_len8 bytesthe document's true length, before padding
chunksvariablechunk_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 bytes

A 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.

StageMeaning
startedThe operation is recorded but nothing has been written yet
data-writtenNew plaintext or new blob exists and is fsynced
index-updatedThe 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.

WindowsUnix
Vault lockFile opened denying write sharingflock(LOCK_EX | LOCK_NB)
Agent transportNamed pipe, DACL'd to your SID0600 socket in a 0700 directory
Who may connectThe DACL, plus remote clients refusedThe directory, plus SO_PEERCRED on Linux
Key kept out of swapVirtualLockmlock

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.