Everything SilentSilo writes that outlives a run, what version it is at, and what an older build does when it meets a newer one.
A vault that will not open is unrecoverable by the person who owns it. That makes every change on this page higher risk than the code around it, so each one answers the same question before it lands: what does a client on the previous release do with the new bytes? Only two answers are acceptable, and they have to be true by test, not by argument.
- It ignores the new thing safely.
- It refuses explicitly and says to update.
Anything else needs a version discriminator first.
| What | Where | Version | An older build meeting a newer one |
|---|---|---|---|
| Manifest | vault.json |
MANIFEST_VERSION = 1, silentsilo-sync |
Refuses to open the silo and says to update |
| Snapshot | snapshots/….snap |
SNAPSHOT_VERSION = 1, silentsilo-vfs/snapshot.rs, sealed |
Refuses, naming the version |
| Operation record | ops/… |
skippable flag per record, plus seq and prev, silentsilo-vfs/oplog.rs |
Skips a record marked ignorable, refuses on anything else. A record without seq is refused outright. ReplaceFileContent.replaces is optional and absent when unset, so a record without it decodes and is applied the old way, without conflict detection |
| Sealed payload | wraps every op record | SEAL_VERSION = 1, silentsilo-crypto/sealed.rs |
Refuses, naming the version |
| Blob | blobs/….sslo |
SSLO_VERSION = 1, silentsilo-crypto/blob.rs |
Refuses, naming the version |
| Recovery envelope | recovery.env |
RECOVERY_ENVELOPE_VERSION = 1, plus stored KDF parameters and an optional auth tag |
Refuses on a newer structure; a parameter change needs no bump. auth is absent from the JSON when there is none, so a client that does not know it reads and rewrites the rest unchanged (below) |
| Key envelopes | keys/….env |
No file version. Plain JSON of one enrolled key, carrying kind (what unwraps its wrapped_dek) and derivation (how that key was derived from the authenticator) |
Ignores an envelope whose kind or derivation it does not know, and refuses by name when none is left it can use |
| Content KEK | keys/content.kek |
Sealed payload under the vault DEK | Refuses, naming the version |
| Inbox key | inbox/keys/….sealed |
INBOX_VERSION = 1 inside a sealed payload under the content KEK, silentsilo-sync/inbox.rs |
1.0.0 never lists inbox/. A build with the inbox skips a key it cannot read |
| Inbox sender | inbox/senders/….sealed |
INBOX_VERSION = 1, sealed under the content KEK |
As above |
| Revocation marker | keys/revoked/….sealed |
version: 1 inside a sealed payload under the content KEK, silentsilo-sync/key_sync.rs |
1.0.0 lists it with the key envelopes and skips it as unreadable |
| Rotation proof | keys/rotated/<fingerprint>.sealed |
version: 1 inside a sealed payload under the vault key a rotation retired, silentsilo-sync |
From core 1.10.0. Earlier builds skip keys/ objects that are not envelopes or markers (below) |
| Inbox item | inbox/items/….sslo and ….env |
The blob as in blobs/; the envelope carries INBOX_VERSION = 1 |
1.0.0 never lists inbox/. A build with the inbox leaves an item of another version where it is and says to update |
| Activity log segment | audit/<device>/<seq>.seg |
SEGMENT_VERSION = 2 in its header from core 1.10 (tagged), 1 before (untagged, still read); each record inside carries RECORD_VERSION = 1 and its HPKE suite, silentsilo-audit |
No release before core 1.9.0 lists audit/. A build with the log names a newer segment, record or suite rather than misreading it: 1.9.0 refuses a version 2 segment by name, leaves it in storage and reports its number missing (silentsilo-fixture/tests/audit_tags_on_1_9_0.rs) |
| Activity log key | audit/keys/<key id>.json |
version: 1, silentsilo-audit/keyring.rs |
As above; a newer version is refused by name |
| Activity log policy | audit/policy.sealed |
version: 1 inside a sealed payload under the content KEK, with an optional auth from core 1.10 |
As above. A release before 1.10 reads the policy and ignores auth |
The order below is the whole of it, and it is written out because a format
nobody can read without our source is not a format. silentsilo-extract
follows exactly these steps; a reader written from this page alone should
arrive at the same files.
Everything is in one bucket or folder. Nothing else is needed but the recovery code.
-
vault.jsongivesvault_idandMANIFEST_VERSION. Refuse a version above the one you understand rather than guessing at the rest. -
recovery.envholds the vault DEK wrapped under the recovery code. Derive the wrapping key with Argon2id over the normalised code and the salt stored beside it, using the parameters in the envelope rather than assumed ones, then open the sealed payload. Out comes the 32-byte DEK. Normalising means uppercasing, dropping separators, and mapping the letter and digit pairs the alphabet excludes. Anauthfield, if the envelope has one, is for devices deciding which envelope to keep (below); a reader with the code can ignore it, since the code either opens the envelope or does not. -
keys/content.kekis a sealed payload under the DEK. Inside is the 32-byte content KEK. Every file's key is wrapped under this, not under the DEK, which is what lets the vault key be rotated without touching content. -
snapshots/, if it is not empty. Take the highest-numbered object, which is also the last in a plain listing because the horizon is zero-padded. It is a sealed payload under the DEK holding the whole tree at that point. Start from it: after a compaction the records below its horizon are gone, and replaying only what is left rebuilds the tail of the history rather than the silo. -
ops/, in listing order, which is already the order to apply them: the key begins with the zero-padded Lamport counter. Each object is a sealed payload under the DEK holding one JSON operation record. Apply them onto the snapshot, or onto an empty tree if there was none. A record whose operation you do not recognise is skippable if it says so and fatal if it does not. -
The tree is then folders, files, and for each file its
blob_idand itsblob_key. Unwrapblob_keywith the content KEK from step 3 to get that file's own 32-byte key. -
blobs/<blob_id>.sslois the content. The 82-byte header gives the version, the chunk size, the file and blob ids, the BLAKE3 hash of the plaintext and the nonce prefix. After it, chunks ofchunk_sizeciphertext each followed by a 16-byte tag. Decrypt each with AES-256-GCM under the file's key, nonce being the 8-byte prefix followed by the big-endian chunk index, with the blob id as additional authenticated data. Check the plaintext against the hash in the header: the header is not authenticated, so that comparison is what catches content swapped for other valid content.
inbox/ holds content a device sent but no unlocked device has imported
yet. It is not part of the tree, and a reader rebuilding the silo may ignore
it. To recover those items too, follow "The inbox" below.
Two things worth stating because they are easy to get wrong. Records are
never rewritten, so a record's fingerprint covers a body that does not
change, and the prev chain per device holds. And a blob can be in storage
before the record naming it, so content nothing refers to is normal rather
than a sign of damage.
Everything here travels with the folder, which is the point: a silo is restored as a unit. Nothing that would let the holder of a copy open it is in this list; that lives on the machine instead, in the table after this one.
| What | Where | Version | Notes |
|---|---|---|---|
| Wrapped DEK | master.dek.enc |
Sealed payload envelope | Same format as an op record, one place to change |
| Content KEK | content.kek.enc |
Sealed payload under the vault DEK | Refuses, naming the version |
| Salt | vault.salt |
No version. 16 raw bytes | Any other length is refused as bad credentials rather than read short |
| Which silo this is | silo.json |
MARKER_VERSION = 1, silentsilo-vault/registry.rs |
Refuses a newer one; unlock uses it to tell a leftover working copy from this silo's |
| Enrolled keys | keys/fido.json |
SILO_FILE_VERSION, plus a kind per key |
|
| Recovery envelope | keys/recovery.json |
Its own, as above | Its version means the shape of one wrapped key, not the file |
| Index | vault.db.enc |
SCHEMA_VERSION = 5, silentsilo-vfs/schema.rs |
Not a format: see below |
| Index, mid-rotation | vault.db.enc.next |
Same envelope as vault.db.enc |
Transient; unlock adopts it, see below |
| Keys, mid-rotation | keys/fido.json.next, keys/recovery.json.next |
Same as the files they replace | Transient, from core 1.7.0; see below |
| New key, mid-rotation | master.dek.enc.next |
From core 1.10: SILO_FILE_VERSION, {local}, naming the file on this machine that holds the key. Before: the new DEK sealed under the old one |
Transient; its presence is what says a rotation is pending. See below |
| Base snapshot | vault_base table in vault.db |
SNAPSHOT_VERSION = 1, silentsilo-vfs/snapshot.rs |
Refuses, naming the version |
| Activity queue | audit-queue/pending, state.json, outbox/*.seg, key.json, policy.json |
No file version. pending is framed records; state.json plain JSON whose added fields read as their defaults (pin_check and pinned.proven from core 1.10); policy.json the policy with a check beside its fields from 1.10; the rest are the storage formats |
From core 1.9.0. Not synced. A release before it never opens the folder. 1.9.0 ignores the checks and drops pin_check when it saves. See "The activity log" |
| Segments read before | audit-cache/<device>/<seq>.seg |
The segment format | From core 1.9.0. A cache: one that does not read, names another place or carries a forged tag is removed and fetched again |
Kept in the OS keychain where it will hold them, and in these files where it will not. Never in the silo folder: that folder is made to be carried around and may sit in a synced directory, and a device secret or an access key travelling with it would hand the vault, or the storage behind it, to whoever holds a copy.
| What | Where | Version | Notes |
|---|---|---|---|
| Credentials | credentials.json |
SILO_FILE_VERSION = 1, silentsilo-vault/format.rs |
Refuses rather than reading as "no silo here". From core 1.10 device_secret is empty once a key opens the silo (retire_device_secret); same shape, so an earlier release reads it, and it only ever uses the secret on a silo with no key |
| Staged key | staged/<id>.dek in the work folder |
Sealed payload, the new DEK under the old one | From core 1.10, only while a rotation is pending; master.dek.enc.next names it. Removed with that file when the rotation finishes or is discarded |
| Consent to unsealed secrets | secrets/allow-unprotected |
No version. Its presence is the answer | From core 1.10. Written when the user agrees to keep secrets in files only their account can read, on a device with no keyring and no protector |
| Storage settings | s3.config.json |
SILO_FILE_VERSION |
The one connection a joined or recovered device starts with. Since core 1.8.0 it holds only the kinds core 1.7.2 knows (s3, folder, web-dav, sftp): a joined device whose first target is of a newer kind keeps it in targets.more.config.json instead |
| Backup targets | targets.config.json |
SILO_FILE_VERSION |
Every copy, in order. Kept as a file as well as in the keychain whenever there is more than one, because the single slot above holds only the first. A keychain that refuses the write (Windows Credential Manager takes 2560 bytes, UTF-16, so 1280 characters) has its entry deleted once the file is written, because reads prefer the entry and a stale one is a target silently gone |
| More backup targets | targets.more.config.json |
SILO_FILE_VERSION |
Since core 1.8.0. Every target of a kind core 1.7.2 does not know, with its position in the whole list. The kinds in 1.8.0 are onedrive, dropbox and google-drive, each {account_id, account_label, folder} (account_label reads as empty when absent); the target id is derived from the kind, the account id and the folder, so a reconnect to the same account keeps it. Core 1.10.0 adds icloud, {folder}: no account, since it is the iCloud account of each device, and the target id is derived from the kind and the folder, so the same folder is the same target on every device. A file only, since none of it is secret (the tokens of these kinds are stored apart). Core 1.7.2 never opens it, so the list and the single slot stay readable to it and a save there leaves this file alone; proven by older_release_tests in s3_store.rs, which decode what this build writes with the 1.7.2 shapes. An entry this build cannot read, from a newer release, is kept as it was and put back at its position on every save, never dropped |
| Cloud sign-in | secrets/cloud/<target id>.token |
No version. The raw refresh token, sealed like the files above | Since core 1.8.0. One per OneDrive, Dropbox or Google Drive target, keyed by target id, in the keychain as cloud-token:<target id> when it fits there (Microsoft's can pass Credential Manager's 1280 characters). Nothing else is in it, so there is nothing a version could describe; an unreadable one reads as "sign in again" |
| Silo list | silos.json |
SILO_FILE_VERSION |
Where the silos are. Losing it costs the list, not the data |
| Cache settings | cache_settings.json |
No version. Plain JSON | One number, the local cache ceiling. Unreadable reads as the default, so there is nothing a version could save |
| Protected folders | protected.enc |
SILO_FILE_VERSION, inside a sealed payload under the content KEK |
Which folders on this computer the silo copies from. Absolute local paths, so they describe what someone keeps and where; kept beside the ledger of what has already been imported, and out of a folder that travels. Sealed since core 1.6.0; a plaintext protected.json from an earlier release is read once, written back sealed and removed |
The secret files (credentials, storage settings, targets, cloud tokens) are
sealed where the device can seal: SSDPAPI1 and the bytes DPAPI returns on
Windows, the same prefix and the client's LocalProtector elsewhere. A file
without the prefix is JSON in the clear, as releases before 1.10 wrote on a
device with neither. From core 1.10 a protector that fails refuses the
write, and with no protector a secret is written in the clear only after
the user's consent (above), so this release never writes a shape an earlier
one cannot read. targets.more.config.json and silos.json hold no secret
and are written in the clear where there is no protector, as before.
In the keychain only, no file: on an iPhone every entry above is written
AfterFirstUnlockThisDeviceOnly from core 1.10, and one an earlier release
wrote is moved to that class on its first read (same service, account and
value, so every release reads it). On a Mac and an iPhone, each Touch ID or
Face ID key has a binding beside it from core 1.10: service
com.silentsilo.enclave-point, account the credential id's tag in hex, value
the SHA-256 of the ephemeral point (CRYPTO.md, "Touch ID unlock"). An
earlier release never reads it; a key it enrolled is bound on first use.
Not formats, and deliberately so: the import ledger of the protected folders
(protected.sqlcipher, ciphered by SQLCipher 4 under protected.key, which
is sealed under the content KEK rather than the DEK so a rotation cannot
strand it; a plaintext protected.db from before core 1.6.0 is moved into it
row by row and removed), and the working copy of a silo
(vault.sqlcipher, ciphered by SQLCipher 4) with its page key (vault.key,
vault.key.next, sealed under the DEK in the envelope above). They are
machine-local and kept across locks, and a copy that does not open, or no
longer stands for vault.db.enc as it is on disk, is rebuilt from it. The
copy's own table, working_copy_state, holds the BLAKE3 fingerprints that
decide that, and is dropped from every snapshot image. What vault.db.enc
seals is still a whole plain SQLite file image. A release before core 1.5.0 looks only for a plaintext vault.db,
which this build adopts once and removes; see CRYPTO.md, "Local metadata".
Rotation writes the index under the new key beside the old one, commits the
keys, then renames. Those last two are separate filesystem operations, so a
machine that dies between them wakes with the new key in force and
vault.db.enc still under the old one. The shadow backup is under the old
key too, so both of the usual fallbacks fail and the silo does not open.
The staged file is the way out, which is why unlock knows about it: when the
primary snapshot will not decrypt, open_database tries vault.db.enc.next
before the shadow backup, and promotes it if it opens. It is tried under the
key in force and nothing else, so an abandoned rotation's leftovers under a
different key are not mistaken for the answer.
From core 1.7.0 a rotation writes the re-wrapped keys and the new recovery
envelope beside their files before it moves the KEK, and renames them after.
Same content as the files they replace, so nothing new to read. They exist
only between those steps; the next read of fido.json (or the next check for
a pending rotation) finishes the renames with no key needed.
What an older client does with them: ignores them. It can only meet them
after a crash inside that window on this machine, and then reads the old
fido.json, whose keys open the old KEK, so the unlock fails. Updating
finishes the renames. Before 1.7.0 the same crash left nothing to finish.
Until core 1.9 this was the new DEK sealed under the old one, in the silo
folder, so any credential could carry an interrupted rotation on. It also
handed the new key to whoever still had the old one, through the folder or
any synced history of it: the removed key or old recovery code the rotation
was for. From core 1.10 the sealed key goes to staged/<id>.dek in the work
folder on this machine, written before the marker, and the folder's file is
{"version": 1, "data": {"local": "<id>"}}. A rotation can then be finished
only on the computer that started it; elsewhere the marker says so
(err.rotation_other_computer) and stays, so nothing is staged over it.
What each release does with the other's file, proven by
silentsilo-fixture/tests/rotation_staging.rs against core 1.6.1's own code:
- A file 1.9 or earlier staged is read in its old form and the rotation finishes here.
- Core 1.9 or earlier meeting the new marker (a downgrade in the middle of a rotation, on the same machine) sees a rotation pending, so it refuses to stage another over it, and refuses to read a key from it ("cannot be read with this silo's current key"). The silo still opens under the old key and nothing is lost; updating finishes the rotation.
Every enrolled key carries a kind, in keys/fido.json and in the envelope
published to keys/….env. There are three values:
fido2, with derivationhmac-secret-v1: a FIDO2 credential whosehmac-secretoutput derives the key that unwrapswrapped_dek. Windows Hello is one of these. An envelope written before the field existed reads asfido2, which is what it is.secure-enclave, with derivationecdh-p256-hkdf-sha256-v1, for the macOS and iOS builds: a P-256 key in the device's Secure Enclave, gated on Touch ID or Face ID. The wrap key is the HKDF-SHA256 of a Diffie-Hellman agreement between that key and an ephemeral key made at enrolment, salted by the same per-vault string the FIDO2 path uses. The ephemeral public key has to reach unlock, socredential_idcarries it: a 16-byte tag naming the key on that device, then the 65-byte uncompressed point, all hex.public_keyis the device key's own uncompressed point.platformis true, as for Hello, and means the same thing: the key does not survive the device.android-keystore, with derivationkeystore-aes-256-gcm-v1, for the Android build: an AES-256 key in the phone's Keystore (StrongBox where the phone has one) that allows one operation per strong biometric. The wrap key is 32 random bytes chosen at enrolment and encrypted under it with AES-256-GCM, associated datasilentsilo-dek-v1:{vault_id}.credential_idis a 16-byte tag naming the Keystore key, the 12-byte nonce, then the 48-byte ciphertext and tag, all hex;public_keyis empty. Not the enclave's key agreement, because Android binds a single use of a key to a biometric only for ciphers, signatures and MACs, and every StrongBox chip does AES.
Core 1.1.0 wrote the ephemeral point into public_key of a secure-enclave
envelope. No release of any client carried that code, so no such envelope
exists in a user's bucket; nothing reads public_key to unlock either way.
Adding kinds changed nothing a 1.0.0 client does. It carries the envelope
verbatim, because credential_id and wrapped_dek are strings it never
looks inside, and it does not offer the key to its authenticator, because
kind is not one it knows. The byte vectors hold every envelope, and one
test runs the 1.0.0 vault code itself against them and against a
keys/fido.json it loads and saves back.
The field is not for this build. It is for the one that meets a key it has never heard of, and that is not a hypothetical: a macOS build would enrol Touch ID keys through the Secure Enclave, and a Linux one may use a TPM. Neither derives anything a FIDO2 backend can produce, and every device on a silo reads every other device's envelopes.
So the rule for anything reading these:
- Publishing, listing and revoking go over every enrolled key. A key this build cannot use is still a key on the silo. Retiring another platform's key because it looks unusable is how a household ends up with one device locked out.
- Every device learns the others' keys and revocations (core 1.3.0). Each
sync pass, before it publishes, reads
keys/: an envelope for a key this device has never had is added to itskeys/fido.json, and a revocation markerkeys/revoked/<credential_id>.sealed(JSONversion,credential_id,revoked_at, sealed under the content KEK) turns a key this device still has into a tombstone, so it is not published again. A device that revokes writes the marker before it deletes the envelope. From core 1.10.0 it keeps the tombstone for good: dropped once the marker was in storage, the key came back when anyone with write access put its envelope back and deleted the marker. Three limits, each deliberate: an envelope is not authenticated, so a key added this way carries no organisation policy on a silo that has none; a silo with no keys file is never given one, because it opens with its device secret; and an organisation's key is not tombstoned on another device's say-so, since retiring one needs its proof. 1.0.0 skips the markers, so a 1.0.0 client neither sees keys enrolled after it joined nor honours a marker. From core 1.9.0 a pass reads the markers on every copy before it takes in any envelope: a copy unplugged when a key was removed holds its envelope and no marker, and judged on its own markers it put the key back. - A key taken in from storage is unconfirmed (core 1.10.0) until this
device unlocks with it:
"unconfirmed": trueinkeys/fido.json, absent when false and never in a published envelope. Markers that would leave this device no confirmed key it can use are not followed, so a former holder of the content key cannot plant one envelope, mark every real key and lock every device out. A release that does not know the field drops it on save, which only makes the key count again. A recovery marker dated more than a day ahead of the device's clock is ignored for the same reason: it would kill every code made from then on. - A rotation leaves a proof (core 1.10.0) before it re-seals anything:
keys/rotated/<fingerprint>.sealed, JSONversionandrotated_at, sealed under the retired vault key and named by a key derived from it (BLAKE3derive_key, contextsilentsilo rotation proof v1, first 16 bytes in hex). A device whose key no longer opens the KEK envelope or the newest records reads a plain "rotated" only when the proof under its key opens. Without it the answer isRotatedUnproven: still a rejoin, since a release before 1.10.0 rotates without leaving a proof, and the report saysrotation_unprovenso the screen can also name the other cause, garbage written over the envelope and the newest record, which needs no key and on which a rejoin fails. - A joining device keeps
policyonly where it was proven (after core 1.5.0). Joining with the recovery code clearspolicyon every envelope it saves; joining with a key keepsorgon that key alone, since its holder just unwrapped the DEK with it. A 1.5.0 client saved the envelopes verbatim, so storage could plant anorgkey nobody holds. - Turning the recovery code off leaves a marker too (core 1.4.0), the
same sealed JSON under
keys/revoked/recovery.sealed, withcredential_id"recovery"andrevoked_atthe time it was turned off. No key id can berecovery, since key ids are hex. Each pass drops a local or stored envelope created at or before it, so a device that still held the code does not publish it again; a code generated later is dated after the marker. Clients before 1.4.0 read it as a marker for a key they do not have and ignore it, so they can still put the old envelope back. - Unlocking goes over the keys of a kind this build knows. That is
StoredFidoKeys::usable, and it is what the authenticator allow-list, the "do you have a spare key" nudge and the last-key removal guard are all counted from. A credential id only has to be hex because a FIDO2 one is: built from every key instead, a single id in another platform's shape would fail the whole unlock on a machine whose own key is plugged in. - When nothing is left that this build can use, refuse by name. Joining a silo whose only keys are of another kind says so, rather than asking for a security key that cannot exist.
Adding the field is only possible before the first public release. The clients that have to skip an unknown kind are the ones already installed, and they cannot be taught after the fact.
kind is also the version, and there is deliberately no second field beside
it. A key envelope is the one thing in the bucket with no file version, and
the fix for that is not to add one now: a version added today is a number
shipped clients do not check, so it would buy nothing that kind does not
already buy. If the shape of a fido2 envelope ever has to change
incompatibly, ship it as a new kind. Every client from the first release
onward skips what it does not recognise and says so when nothing is left,
which is the behaviour a version field would have been for.
recovery.env in the bucket and keys/recovery.json in the silo folder hold
the same JSON: the version, the Argon2id parameters, the salt, the wrapped
DEK, created_at, and since core 1.6.0 an optional auth.
auth is a tag over every other field, hex, 32 bytes: BLAKE3 keyed by
blake3::derive_key("silentsilo recovery envelope auth v1", content_kek)
over the same context string, a zero byte, then version, m_cost, t_cost
and p_cost big-endian, created_at as a signed 64-bit big-endian integer,
and algorithm, salt and wrapped_dek each as a big-endian 64-bit length
followed by its bytes. The content KEK is what keys it because the KEK never
rotates and never leaves a device: whoever can write to the bucket cannot
make one.
Why it exists: created_at decides which envelope a device adopts and which
one a disable marker retires, and until 1.6.0 nothing stopped a writer in
the bucket from choosing that number. An old envelope with a new date brought
back a code that had been turned off; an envelope full of nonsense replaced
the local copy on every device and was discovered at recovery, which is the
worst possible moment.
The rules, all in silentsilo_sync::settle_recovery_envelope:
- A device adopts a stored envelope only when its tag verifies. Not when it is merely newer.
- Unless its own has no tag either, which means no device on this silo has run 1.6.0 yet. Nothing is lost by behaving as 1.5.0 did there, and refusing would strand a fleet that has not updated.
- A device tags the envelope it already holds. The local copy is as trustworthy as the disk it sits on, so a silo set up before 1.6.0 becomes authenticated on the next pass without anyone writing a new code down. Only a missing tag is filled in: one that does not verify is left alone and reported, because this device is not the one to bless it.
- A newer stored envelope that is not tagged is left where it is, and reported. The client says so rather than silently keeping the older code.
What an older client does: it parses the envelope, ignores auth, and opens
the DEK with the code exactly as before, which is the first of the two
acceptable answers. When it rewrites the object it drops the field, because
it serializes the fields it knows. So while a 1.0.0 device is still syncing a
silo, the tag in the bucket comes and goes and the two devices rewrite the
object past each other once per pass. Harmless, and it stops when the older
device updates. The consequence worth knowing: a recovery code regenerated on
a 1.0.0 device is not adopted by a 1.6.0 device, which keeps the older code it
holds and says so. crates/silentsilo-fixture/tests/byte_vectors.rs runs
1.0.0's own code over an envelope written today, both directions.
From core 1.10.0 the envelope also carries auth_dek: the same message,
keyed by BLAKE3 derive_key with the context silentsilo recovery envelope auth v2 over the content KEK followed by the vault DEK. The KEK never
rotates, so a former holder of it can still make auth after a rotation
retired them; auth_dek also takes the current DEK. Once a device's own
envelope carries a valid auth_dek, it adopts a newer stored envelope only
if that one does too. A device fills in auth_dek on its own envelope when
auth verifies, and a rotation remakes the envelope under the new key.
Absent from the JSON when there is none, so a release before 1.10.0 reads
the envelope as before and drops the field when it rewrites it; a code
regenerated there is not adopted by an updated device, which keeps its own
and says so, as for auth above.
Beside kind and derivation, an enrolled key carries policy, in
keys/fido.json and in the published envelope. Empty is the ordinary case and
means the person holding the machine decides the key's fate. The one other
value is org: a key an organisation provisioned, which the machine's user
cannot retire and without which they cannot change the recovery code.
It is not a permission on the vault key. An org key unwraps the same DEK as
any other and unlocks exactly like it; the field constrains who may administer
the silo, never who may open it. That is why it needs no version of its own and
no discriminator: a build that ignored the field entirely would still open every
silo correctly, and would only be more permissive about administration.
The rules, all enforced in the backend rather than in the UI, because the commands are reachable from anything that can call it:
- Set only at the first enrolment.
fido_enroll_primarytakes it; nothing else can turn an ordinary silo into an administered one. A key its holder cannot remove has to be part of what the silo was set up as, or the same feature becomes a way to take somebody's vault away from them. - Retiring one takes another in hand.
fido_remove_keyruns a fresh assertion restricted to the silo'sorgkeys and verifies the derived key actually unwraps one of their envelopes. The key being retired counts as proof of itself, which is what lets an organisation rotate its key and hand a silo over by retiring the last one. - The recovery code counts as a second door. Regenerating or disabling it on an administered silo asks for the same proof. Guarding the key while leaving that open would protect nothing.
- So does rotating the encryption key, starting one and finishing an interrupted one alike. Rotation is retirement plus a fresh recovery code in a single operation, so without the same proof it would be a one-screen way around both rules above.
- Adding another needs an existing one, and only on a silo already administered.
- Never hidden. Every device lists the key with an Organisation badge.
The rules above are not enforced by each command remembering to ask. They are
enforced by save_fido_keys, which every write to the enrolled keys goes
through: it compares what is being written against what is on disk, and refuses
any write that leaves an administered silo with fewer organisation keys unless
the caller carries an OrgProof. That token exists only if its holder derived
the wrap key of an enrolled organisation key, checked by unwrapping the
envelope, so it cannot be fabricated by a caller that merely believes it is
entitled.
The shape is deliberate, and it is worth keeping. Rotation shipped without a
guard because the guard was something each command had to remember, and a
command that never asks is invisible to review: nothing in the rotation code
mentioned organisations at all. Now a new command cannot reach the file without
naming its authority, and naming Machine does not get it past the check. The
cost is that the rule lives away from the commands it constrains, which is why
it is written here.
Three writes are deliberately not proofs of anything, because they take nothing away: a join or a recovery-code restore creating the file from what the bucket holds, sync dropping tombstones storage has already confirmed, and enrolling or renaming a key. The check lets them through on its own, by asking what changed rather than who is calling.
policy defaults when absent, like its two neighbours and for the same reason:
an envelope assembled by something that does not write the field, the extraction
tool included, must read as an ordinary key rather than be refused. A silo that
will not load is the worst outcome this format has.
A device that cannot open the silo still adds to it: a phone backing up photos in the background holds no key that opens anything. It seals each item to the silo's inbox key and signs it, and an unlocked device imports it later as an ordinary file. Added in core 1.2.0.
inbox/keys/<key_id>.sealed the inbox secret
inbox/senders/<sender_id>.sealed a device allowed to send
inbox/items/<item_id>.sslo the content, an ordinary blob
inbox/items/<item_id>.env the signed envelope naming it
Keys and senders are sealed payloads under the content KEK, not the DEK.
The KEK never rotates, so a rotation by a client that has never heard of the
inbox cannot strand items waiting in it; 1.0.0's rotation re-seals ops/,
snapshots/ and keys/content.kek and nothing else. Inside, JSON:
{"version":1,"key_id":"…","secret_key":"<32 bytes hex>","created_at":1789000000}
{"version":1,"sender_id":"…","public_key":"<65 bytes hex>","credential_id":"…","label":"Galaxy S23 Ultra","created_at":1789000000}key_id and sender_id must match the object's name. Several inbox keys may
exist; every item names the one it is sealed to. A sender's public_key is an
ECDSA P-256 key, and credential_id names the device key the sender belongs
to: the sender is accepted only while keys/<credential_id>.env is in
storage, so removing a device's key from the silo stops its sending too.
An item is the content as an ordinary .sslo blob, with the item id as
its file id and a fresh content key, and an envelope:
{"version":1,"item_id":"…","blob_id":"…","key_id":"…","sent_at":1789000000,
"blob_size":103,"ephemeral":"<65 bytes hex>","sealed":"<hex>","signature":"<64 bytes hex>"}The envelope does not name its sender. The storage provider reads it, and which device sent which photo is not the provider's to know; the importer finds the sender by trying each registered sender's key.
The header both the seal and the signature cover is, at fixed widths and
big-endian: silentsilo inbox item v1 and a zero byte, item_id, blob_id,
key_id and sender_id as 16 bytes each, sent_at as a signed 64-bit
integer, blob_size as an unsigned one, then the 65-byte ephemeral point.
sealedisnonce(12) || ciphertext || tag(16): AES-256-GCM with the header as associated data, under HKDF-SHA256 of the P-256 agreement between the ephemeral key and the inbox key, saltsilentsilo-inbox-v1:{vault_id}, infosilentsilo inbox item key v1.signatureis rawr || swithslow, ECDSA P-256 over SHA-256 of the header followed by the sealed bytes.
The plaintext of sealed is JSON: content_key (hex), name, mime_type,
size_bytes, content_hash (BLAKE3 of the plaintext, hex), taken_at,
folder (one folder name per element, below the root) and source
(photos or contacts).
Import runs on an unlocked device, in an order that loses nothing when it stops anywhere:
- Check the envelope: its version, that its name matches
item_id, a registered sender whose signature it carries, that sender's device key still in storage, and that the inbox key opens it. An item failing any of these stays where it is and is reported. - If the silo already has a file with id
item_id, go to 5. - Check the
.ssloisblob_sizebytes and copy it toblobs/<blob_id>.sslo. A copy, not a move: stopping here leaves an unreferenced blob the sweep will collect, and the original still in the inbox. - Record an ordinary
AddFilewithid = item_id, its content key wrapped under the content KEK, and push it. - Delete the
.sslo, then the envelope.
Nothing here is a new operation. What reaches ops/ and blobs/ is exactly
what an upload writes, so every client since 1.0.0 reads an imported photo
like any other file. Two devices importing the same item converge, because a
second AddFile with an id already present is obsolete in every version.
What 1.0.0 does with a store holding items, its orphan sweep and its key rotation included, is checked by a test that runs 1.0.0's own code, and the byte vectors hold one real item.
A passkey lives in a password entry, as the optional passkey field of the
entry JSON that UpsertPassword carries whole (silentsilo-fido/passkey.rs):
"passkey": {
"version": 1,
"rp_id": "example.com",
"credential_id": "<base64url, no padding>",
"user_handle": "<base64url>",
"user_name": "alice",
"user_display_name": "Alice",
"algorithm": -7,
"private_key": "<base64url of the 32-byte P-256 scalar>",
"created_at": 1789000000000
}The entry around it is a login whose url is https://<rp_id>; its
password may be empty. The signature counter is not stored: synced
passkeys answer 0, and a stored counter would make every sign-in a write
that conflicts across devices.
A field rather than an operation of its own, and the reason is 1.0.0's compaction. A record type 1.0.0 does not know is stored and skipped, but its snapshot leaves it out and its prune deletes it from the bucket, so a passkey kept that way would be gone from storage the first time a 1.0.0 desktop compacted. A field it does not know rides inside the entry through replay, snapshots and edits: both frontends save an entry by spreading the loaded object, so fields they do not name are written back.
A reader offers only passkeys whose version it knows and leaves the others
in their entries. An older client shows the entry as a login.
Two more optional fields of the same entry JSON, from desktop 1.4 and Android 1.3, for the same reason as the passkey: a field rides through replay, snapshots and edits on every version, and a record type would not.
"fields": [
{ "name": "Customer number", "value": "40021", "hidden": false },
{ "name": "Card PIN", "value": "1234", "hidden": true }
],
"history": [
{ "saved_at": 1789000000000, "password": "old-pass", "username": "ana", "...": "..." }
]A hidden field is masked and copied like a password. history is the
entry's earlier versions, newest first, each the entry's content as it was
with saved_at its updated_at then. A version leaves out id,
history, attachments, passkey, favorite, category, created_at,
updated_at and require_reauth: attachments are counted only from the
entry itself (attachment_blobs, on 1.0.0 too), so a blob referenced from
history alone would be swept. How many versions a client keeps is its own
setting; the whole history stays under 256 KB.
From core 1.10.0 a version may carry "conflict": true: an edit another
device made at the same moment (the same Lamport value) as the one the order
kept, which no later edit says or holds in its history. Core puts it back
into the history of the entry the order kept, as one new change, the same on
every device (Vfs::keep_lost_edits). It is not part of what a version
says, so restoring one drops it.
An older client shows neither, and keeps both when it saves the entry, but adds no version of its own. That is client code, not core: desktop before 1.4 and Android before 1.3 edit a copy of the whole entry, so a field they do not name is written back.
From desktop 1.4, an ssh_key entry may carry "ssh_agent": true: the
desktop's SSH agent offers that key and signs with it, after the person
confirms. Absent means no. An older client ignores it and keeps it when it
saves the entry, like the fields above, and the same test carries it.
upsert_password refuses an entry over
MAX_ENTRY_BYTES (512 KB), so the record stays under the 1 MiB readers
before core 1.4.0 accept. silentsilo-fixture/tests/entry_fields_on_1_0_0.rs
replays, snapshots, compacts and restores such an entry with 1.0.0's code.
From core 1.9.0, a silo can keep a log of what was done in it: unlocks,
entries shown or copied, logins filled, files opened or saved outside,
imports and exports, key changes, and the changes the operation log also
records. On a silo an organisation administers it is always on; on any
other it is on by default and a setting. A silo nobody ever set it for
starts it at its first sync pass, once every copy has answered that none
holds a policy, or when opened if it has no copies; a policy that says off,
here or on any copy, is followed. silentsilo-audit owns the format.
"Every copy" means every copy the silo has, those resting after a failure included, and "none holds a policy" includes one whose key file did not read: a device that has not seen a copy's "off" must not override it with a newer "on". Off chosen before the log ever started is kept as a policy saying off, under a key of its own, and spread like any other.
Why its own place. A record type 1.0.0 does not know is skipped, then
dropped from its snapshot and pruned from storage. So the log is not in
ops/, and no release before it lists audit/: they prune ops/ and sweep
blobs/, and nothing else. silentsilo-fixture/tests/audit_untouched_by_old.rs
runs 1.0.0's and 1.6.1's pruning and sweep over storage holding a log, and
the log comes through byte for byte.
An event is JSON with short names, sealed alone:
{ "i": 41, "t": 1789000000000, "c": 11, "n": 1, "o": "<entry id>", "l": "Bank", "x": { "field": "password" } }i is the device's own count of events, from 0 with no gaps, so a record
missing from the log leaves a hole. t is the device's clock, in
milliseconds. c is the event code: a number, stable for good, never reused
or renumbered (events.rs, pinned by a test); a reader names a code it does
not know "Unknown event N". n counts repeats folded together and is left
out when 1. o and l are what the event was about, by id and by the name
it had then, so it stays recognisable once it is gone. x carries the rest
by name; a reader shows what it does not know as it is. No event carries a
password, a field's value or a file's content.
A record is one event sealed with HPKE (RFC 9180), base mode, to the
log's public key: SSAR, RECORD_VERSION (1), the suite as three RFC 9180
identifiers (KEM, KDF, AEAD; this build writes X25519-HKDF-SHA256,
HKDF-SHA256, AES-256-GCM), the key id (8 bytes: BLAKE3 of the public key),
the encapsulated key and the ciphertext, each length-prefixed. The info
string is silentsilo audit v1; the associated data binds the writing
device's id and the key id, so a record moved under another device does not
open. A device seals each event the moment it happens and queues it; it
never needs to read one back. A suite this build does not know is refused
by name, which is what lets a later build move to a post-quantum KEM for
logs kept for years.
A segment is a batch of one device's records: SSAS, SEGMENT_VERSION,
the device id, seq (from 0 per device), prev (the BLAKE3 of the
previous segment's bytes, zeros for the first), the time it was closed, and
the records, each length-prefixed. From core 1.10 the version is 2 and a
32-byte tag follows the records: keyed BLAKE3 of every byte before it,
under derive_key("silentsilo audit segment v2", content KEK). Version 1,
written by 1.9, is the same without the tag. Its storage key is
audit/<device>/<seq, 12 digits>.seg. A reader checks the run per device:
a missing number or a prev that does not match is named. At most 10,000
missing numbers are named one by one; a wider gap is counted, so a number
written by storage cannot decide how much memory a reader takes. Segments
are written once and never rewritten; a seed copies one only where the
destination has none. Nothing larger than 8 MiB is read.
Who wrote a segment. HPKE base mode does not say who sealed a record, and the public key is in clear in storage, so before the tag anyone who could write to storage could add records under any device's name, or replace a device's segments with a chain of their own. The tag is under a key every device of the silo and every reader holds, and storage does not. The content KEK and not the DEK: a segment is never rewritten and has to check for years, across key changes. A reader:
- refuses a version 2 segment whose tag does not check, and names it
(
refused_segments), not missing; - refuses a version 1 segment numbered after the device's first version 2 one: a device that tags never writes an untagged one again. A device put back on a release before 1.10 after this one is the exception, and what it writes then is refused by name too;
- reads every other version 1 segment, and names those no tagged segment
vouches for (
unproven_segments). A tagged segment names the hash of the one before it, so an unbroken run down from a tagged one is as sure as the tag.
What 1.9.0 does with a version 2 segment, by its own code
(silentsilo-fixture/tests/audit_tags_on_1_9_0.rs): it refuses it as written
by a newer version, leaves it in storage, never expires it, and reports its
number missing. In its own outbox, after a downgrade, it renames one to
.damaged; a build from 1.10 sends a .damaged segment whose tag checks
again.
The key is a random HPKE key pair, not one derived from any security
key, so retiring or losing a key never makes old logs unreadable.
audit/keys/<key id>.json holds the public half, the suite, the scope
(org or silo) and the private half wrapped once per way in: under each
organisation key's wrap key on an administered silo, so devices write the
log and cannot read it, or under the content KEK (by: "silo") on a
personal silo, so whoever opens the silo reads its log. Each wrapping uses a
key derived for this purpose alone (BLAKE3 derive_key, context
silentsilo audit private key v1). A key file whose public key does not
match its id is refused.
The policy, audit/policy.sealed, sealed under the content KEK: whether
the log is on, the id of the key events are sealed to, the retention in days
(absent means kept), the scope and when it changed. A device pins an
organisation's key for good and does not follow a policy that names
another in silence. A personal log's key it replaces with the one a newer
policy names: whoever could name it also holds the content key that reads
the personal log, so pinning it would protect nothing, and an organisation
starting its log on a silo that kept a personal one has to be followed. A policy naming another key changes nothing
on the device: not whether the log is on, not its scope, not its retention.
The scope a device pins is the policy's, which is sealed, and a key file
whose scope the policy does not confirm is refused. On an administered silo
the log is on whatever the policy says; only the retention is read from it.
The app deletes a segment, whole, only for the holder of an organisation
key and only once it is past the retention, never below 90 days whatever
the policy says, and never one whose tag does not check. A retention under
90 days is refused when it is set.
From core 1.10 the policy carries auth: keyed BLAKE3, hex, under
derive_key("silentsilo audit policy auth v1", vault DEK), over its other
fields in a fixed order (the context silentsilo audit policy and a zero
byte, version as u32, enabled as a byte, key_id length-prefixed,
retention_days as a presence byte and u32, the scope as 1 for org and 2
for silo, changed_at as i64, all big-endian). The content KEK never
rotates; the DEK does when a key is removed, so whoever held the silo
before that can still seal a policy but cannot tag one. A device takes in
a policy from storage only when:
- its
changed_atis at most a day past the device's clock (the newest wins, so one dated far ahead would win over every later choice); - it is an organisation's only on a silo an organisation administers
(
StoredFidoKeys::is_org_controlled), and its key file names one of that silo's organisation keys among its ways in; - once the device holds a policy it made or read with a tag that checks
(
pinned.proven), its tag checks under the current DEK.
A device that trusts its policy writes it tagged, and writes it again to a copy holding a policy it refused, or the same policy with a tag that does not check (written before 1.10, or before a key change). A refused policy newer than its own is written over by its own dated after it, so a release before 1.10, which follows the newest without checking tags, follows that one too. A device that took in an untagged policy on first sight spreads it as it came and never tags it. A queue 1.9 left is trusted as it stands, so a device that updates tags the policy it already had. A change made on a release before 1.10 is not followed by a device that holds a tagged policy.
An organisation's log is started with one of its keys touched, which core
checks against the silo's enrolled organisation keys (OrgProof): the device
makes the key, wraps the private half for that key alone and pins it. A
second organisation key is added with the first touched as well, since the
private half has to be unwrapped to be wrapped again, and must be one of the
silo's organisation keys too. Copies of the key
file are merged by by, kept in that order, so a key added on one device
reaches every copy and none is written again for nothing. A way in is never
removed by a merge: a retired organisation key still reads the log until a
new one is started.
A personal log is turned on from one device, with or without copies: that
device makes the key, wraps it under the content KEK and pins it. Turned
back on later it uses the same key. Two devices that start it at the same
time each make one, and both follow the newer policy; the records sealed to
the other key still open, since a reader of a personal log tries every key
in audit/keys/ the content KEK unwraps, by the id each record names. The newest policy wins, by changed_at:
each pass that reads the policy writes it, with its key, to every copy that
has none or an older one, so a log turned on before a copy existed reaches
it. A policy naming a key other than the one the device pinned is never
spread.
The queue on the device, <silo>/audit-queue/, beside the silo and not
synced: pending (records not yet in a segment, each prefixed with its event
number and length), state.json (the counters, the hash of the last segment
and the pinned key), outbox/<seq>.seg (closed segments, in the storage
format, waiting for every copy), key.json and policy.json (the pinned
log's key file as storage holds it, and its policy in clear) and lock
(held while the queue is open). New in core 1.9.0; a build that reads it
takes absent fields as their defaults. A record cut short by a crash is cut
off the end of pending when the queue opens. A close writes as many
segments as it takes to stay under 8 MiB and 100,000 records each. A
segment in the outbox that does not read, or whose tag does not check, is
renamed .damaged and no longer sent, and the reader names the hole it
leaves. A copy that already holds a segment under the same number with
other bytes (a queue put back from an old backup) keeps it; this device
keeps its own and says so.
The folder travels with the silo and may sit in a synced directory, so
from core 1.10 whoever can write there does not choose the key events are
sealed to. state.json carries pin_check, keyed BLAKE3 hex under
derive_key("silentsilo audit queue check v1", content KEK) over the pin's
fields (key id, public key, scope, on or off, retention, checked_at,
proven); policy.json carries check beside the policy's fields, under
the same key over the same fixed order as auth. key.json needs none:
its id is the hash of its public key, and the id is in what is checked. A
pin whose check does not hold is made again from policy.json and
key.json when the policy's check holds, with checked_at 0 so the next
pass reads storage, and dropped otherwise; a policy.json whose check does
not hold is removed. The first time a build from 1.10 opens a silo's queue
it takes in what 1.9 left without checks, and checks it from then on; it
notes that in the silo's index (vault_meta, audit_queue_checked),
which is under the DEK, so a check taken away later is not mistaken for a
queue 1.9 left. 1.9.0, run on the folder after, opens the queue, seals to
the same key and saves state.json without pin_check; the next 1.10 open
makes the pin again from the policy (audit_tags_on_1_9_0.rs).
What the log does not protect against. Whoever holds the content key
can tag a segment under any device's name and seal a policy; whoever holds
the current DEK can tag a policy. So a member of a silo can write
another device's records, and a member of an organisation can write a
policy; the device does not follow that policy to another key, and expiry
never goes below 90 days. Whoever can delete in storage can delete
segments, and the newest segments of a device leave no hole when they go.
pending in the device's queue is not checked: whoever can write to the
silo folder can add records there, and this device closes them into a
tagged segment. Segments written before 1.10 are read and named as
unproven. The log tells an honest device's story to its readers; it is not
proof against someone who holds the silo's keys or its storage.
Segments read before, <silo>/audit-cache/<device>/<seq>.seg, in the
storage format and layout, not synced: a segment never changes, so a reader
fetches each one once. One that does not parse, names another device or
number than its path, or carries a tag that does not check, is removed and
fetched again; a forged one fetched from a copy is never kept.
Every table in vault.db except vault_meta and oplog is a cache of the
operation log. A version mismatch is resolved by dropping the derived tables
and replaying, never by migrating in place, which is what makes changing the
schema free.
That only holds while the log is complete on disk, and "complete" now means
one of two things. Either oplog holds the whole payload of every record this
device has seen, which is the state of every silo that has not been compacted,
or it holds every record above a horizon and the vault_base table holds the
state at it. A rebuild restores that snapshot first and replays what is left
on top.
The snapshot is therefore the one row in vault.db that a replay cannot
produce, since it stands in for the records a replay no longer has. It is a
real format, versioned, and it is why snapshot::prune_to never drops a
record the bucket has not confirmed: until a record is pushed, the log row is
its only copy.
Since core 1.7.0 a snapshot also carries purged: the purge bookkeeping a
replay reads (purged_ids, purges, purged_names, kept_edits,
conflict_copies, pending_touches since 1.7.1, and copy_origins and content_versions of files edited
or purged). Without it a device rebuilt from the snapshot forgot every purge
below the horizon, and dropped a file added offline to a purged folder that
every other device kept at the top. Without the edit history, a later purge
of a file edited below the horizon kept an edit its author had not seen on
every device but the rebuilt one. The field is left out when empty, so a
snapshot of a silo with no edit and no purge is byte for byte what 1.6.1
wrote, and SNAPSHOT_VERSION stays 1: 1.0.0
and 1.6.1 read the snapshot and ignore the field, which
silentsilo-fixture/tests/snapshot_purge_memory.rs checks with their own
code. What they lose by ignoring it is what they already lacked.
Two consequences worth stating:
- Adding a
VaultOpvariant must bumpSCHEMA_VERSION. The rebuild is what applies records an earlier build stored but could not act on. - Adding a
VaultOpvariant must also decideVaultOp::is_skippable. The match is exhaustive, so it will not compile until someone does.
crates/silentsilo-fixture/fixtures/<version>/ holds a silo and its storage
as written by that release, plus the exact contents they should produce.
cargo test -p silentsilo-fixture rebuilds each one from storage using only a
recovery code and compares.
Each era has two fixtures, not one. v<version> holds the full operation
log; v<version>-compacted holds the same silo after compaction, a snapshot
in the store and the records below its horizon pruned. They must produce the
same tree, and they prove different halves of recovery: the plain one that a
whole log still replays, the compacted one that a snapshot still restores and
the tail still lands on top of it. Without the second, the snapshot format
has no golden bytes and a break in it passes every test.
A change that alters a fixture's output is a break. The fixture is the released format; updating it to match new behaviour only hides the problem from the people who will hit it.
v1.9.0 and its twin are the first whose storage holds an activity log (a
personal key, its sealed policy and two chained segments) and an entry with
custom fields, history and ssh_agent. Their expected.txt lists the log
as read back, one activity line each; the silo-folder half of the test
skips those lines, since the log lives in storage. Their segments are
version 1, untagged, and read the same by a build that tags: the lines
are unchanged. The release that ships tagged segments and the policy's
auth gets a fixture pair of its own; create already writes them.
v1.10.0 and its twin are that pair: version 2 segments, a policy with
auth and a recovery envelope with auth_dek. Their expected.txt reads
the same as v1.9.0's.
No rotation runs in them, so keys/rotated/ has no golden bytes here; its
older-release behaviour is covered by the rotation tests named above.
The one exception, and it expires with this release: while nothing had shipped, the formats iterated freely and the fixture was regenerated to match, because no silo written by an older iteration existed outside this repository. From 1.0.0 onward that reasoning is gone and the rule above is the whole rule.
Content is not encrypted under the vault DEK. Each blob gets a key of its own, and that key travels wrapped under the DEK inside the operation record that names the blob, and inside the snapshot once compaction has replaced those records.
The reason is rotation. Rotating the vault key has to make a revoked security key stop reading the silo, and with content under the DEK that would mean re-encrypting every byte: hours or days for a large archive, and impossible on a target that refuses deletes. With a key per blob the ciphertext never moves. Rotation re-wraps a few hundred bytes per file and re-seals the records, and someone holding the old DEK can no longer open the current records, so they cannot reach the content key, so the unchanged ciphertext is closed to them.
Where each key lives, and where it deliberately does not:
encrypt_fileanddecrypt_blobtake aContentKey, never theMasterDek. The key is not in the.sslofile: a key inside the object it protects would have to be rewritten to rotate, which is the cost being avoided.AddFileandReplaceFileContentcarryblob_key, the wrapping.filescarries ablob_keycolumn, rebuilt from the log like the rest.FileRowin a snapshot carriesblob_key. A snapshot stands in for the records it replaced, so without this the keys would be lost at the first compaction.- A password attachment's key lives in the sealed entry, its only reference.
Content keys are wrapped under a key of their own, content.kek.enc in the
silo folder and keys/content.kek in storage, itself sealed under the vault
DEK. Password entries are sealed under it too.
The reason is the log's hash chain. OpRecord::fingerprint() is a hash of the
whole record, so anything inside a record can never be rewritten without
changing its fingerprint, breaking the prev chain, and leaving this device
disagreeing with every other about the bytes of the same operation. Both the
wrapped content key and a sealed password entry live inside records.
So the values in records never change, and rotation touches one key instead. It re-wraps the KEK under the new DEK and re-seals the objects in storage, whose bodies stay byte for byte identical. Someone holding the old DEK can no longer open the current records, so the wrapping inside is out of reach even though it has not moved.
Every device shares one KEK, fetched from storage when joining. A device that minted its own would write files none of the others could open, so a join with no KEK in storage is refused rather than made to work.
A silo written before this does not open. That was acceptable exactly once, for the reason above, and the rule at the top of this page is the whole rule from here on.
Add a fixture when a release changes anything on this page:
cargo run -p silentsilo-fixture -- create crates/silentsilo-fixture/fixtures/v<version>
One per format era rather than one per tag. Ten releases that change nothing here share one fixture, named for the release where that format first shipped; ten copies of the same bytes would prove nothing and cost a review every time.
Old fixtures are never removed.
v1.0.0 opens the era this page describes: per-blob content keys, the
content KEK, and password entries sealed under it. It is the first era with a
public release behind it, so it is also the first that can never be
regenerated.