One vault. One chain per scope.
The client uses plain append-only CBOR files; the server uses SQLite. Both stores hold byte-identical events — there is no client/server format divergence.
Client layout — ~/.fd0/
~/.fd0/ ├── vault.enc # sealed super_priv + per-scope OEKs + chain tips ├── config.toml # client config (server URL, sync interval) ├── chains/ │ ├── user.cbor # per-user event chain (auth.set events) │ └── scope_<id>.cbor # one per scope, append-only └── recovery/ # optional, user-managed exports
vault.enc layout
The header is unauthenticated metadata; the body is AEAD-sealed under a payload key that is itself wrapped under each active auth method. Auth methods are independent — adding a YubiKey to an existing passphrase-only vault doesn't re-encrypt the body, only adds a new wrap.
00000000 46 44 30 56 01 01 ba 92 1a 98 c9 6c 82 74 52 f6 |FD0V......l.tR.|
00000010 1a e4 75 dd bb e4 e5 93 8b d4 b5 60 fa a0 b1 41 |..u........`...A|
00000020 ad b8 73 0a 8b 20 a1 04 20 50 41 53 53 50 48 52 |..s.. .. PASSPHR|
00000030 41 53 45 18 4b 2f 1c d8 3a e9 17 c0 5b 99 9d 4e |ASE.K/..:...[..N|
[00..03] magic "FD0V"
[04] version 0x01
[05..24] super_pub 32B Ed25519
[25..27] wraps.count uvarint
[28..] wraps[]: method_id (ULID) · method_type · public_params
· wrap_nonce(12) · AEAD-ctAtomic writes
Vault re-seal goes through vault.enc.tmp → fsync → rename → fsync(parent). Chain appends go through the same pattern at file granularity. An advisory exclusive lock at ~/.fd0/.lock serialises appends, compaction, tail-truncation, vault re-seal, scope unlink, and config writes within a single client.
Server layout — SQLite
One database file with tables for users (super_pub → short_id), user events, scope events, and STHs. The server stores byte-identical CBOR blobs and per-event metadata (seq, prev_hash). No plaintext, no derived keys, no decryption capability anywhere in the server's code path.