zudo-text

検索したい単語を入力

いつでも検索バーを開ける

Account Store (My Snippets)

Overview

The account store is a per-user, end-to-end-encrypted content store that spans every workspace a user owns — it is what backs My snippets (epic #6780). Unlike a workspace's content, which is encrypted with a key derived from that workspace's password, the account store is encrypted with one random key per user. The server keeps that key wrapped once per workspace so unlocking any workspace that holds a wrap yields the same account key everywhere.

Implemented across @takazudo/cloud-crypto (key material, wrapping), workers/sync-server (D1 tables, R2 blobs, HTTP routes — see Account API), @takazudo/backend-bridge's ./account-core subpath (client, engine, outbox), and the renderer-side account- modules in tauri-app/renderer/lib/ that own the enrollment state machine, the lifecycle singletons, and the offline mirror.

Key material

generateAccountKeyMaterial() (packages/cloud-crypto/src/account-key.ts) draws 96 random bytes and imports them with the same importSubKeys() split every workspace key uses: bytes 0–31 become the AES-256-GCM content encryptionKey, 32–63 the HMAC-SHA256 hmacKey, 64–95 the AES-256-GCM pathKey. Because the split is identical, every content/path/HMAC helper that already works on workspace DerivedKeys (encryptContent, encryptPath, computeHmac, …) accepts AccountKeys unchanged. importAccountKeys(raw) re-imports previously-unwrapped bytes as the same non-extractable key set.

The raw 96 bytes exist in memory only long enough to wrap them for a workspace or hold them for the current session (AccountKeyMaterial.raw in account-key-enrollment.ts) — they are never sent to the server and never written to disk in the clear.

The wrap key: PBKDF2 extended to 128 bytes

The server never sees the account key in the clear. Each workspace instead stores an AES-256-GCM wrap of it, sealed with a wrapKey derived from that workspace's own password. deriveKeysWithWrap() (packages/cloud-crypto/src/key-derivation.ts) derives this by extending the same PBKDF2 call deriveKeys() uses from 96 to 128 bytes:

  • Bytes 0–95 are bit-for-bit identical to deriveKeys()'s output for the same password + salt — PBKDF2 output blocks are computed independently, so the three workspace sub-keys are unaffected by asking for 32 more bytes.

  • Bytes 96–127 become the wrapKey (AES-256-GCM, non-extractable).

Deriving the wrap key through the full PBKDF2 cost (600,000 iterations, SHA-256 — PBKDF2_ITERATIONS) rather than a cheaper HKDF step keeps a stolen wrap as expensive to brute-force offline as the workspace keys themselves.

The wrap envelope

wrapAccountKey(raw, wrapKey, context) / unwrapAccountKey(blob, wrapKey, context) (account-key.ts) seal/open the 96 raw bytes under a workspace's wrapKey:

  • AAD: utf8(userId|workspaceId|keyId|keyVersion). Any field containing | is rejected, and keyVersion must be a non-negative safe integer — this keeps two different (user, workspace, key, version) contexts from ever serializing to the same AAD, so a wrap can't be replayed into a different slot.

  • Wire format: base64url([1 byte version=0x01][12-byte IV][ciphertext including the 16-byte GCM tag]).

  • Fail-closed: a wrong wrapKey, a mismatched AAD context, a tampered envelope, an unrecognized version byte, or a wrong plaintext length all throw. There is no partial or "best effort" unwrap.

Server-side key registry

Two D1 tables (migration 0036_account_keys.sql) back the key lifecycle:

  • account_keys — one row per user_id (key_id, key_version). Created first-writer-wins: POST /keys uses ON CONFLICT(user_id) DO NOTHING, so if two devices race to create the account key, only the first insert wins and the loser re-reads the wraps instead.

  • account_key_wraps — primary key (user_id, workspace_id), one wrap per workspace. An upsert only succeeds while (key_id, key_version) still matches the row currently in account_keys (the WHERE clause of the upsert re-checks it), so a wrap can never land for a key a concurrent reset has already replaced.

See Account API for the full route list.

Enrollment branches

enrollAccountKey() (packages/backend-bridge/src/account-core/account-key-enrollment.ts, re-exported by the renderer's lib/account-key-enrollment.ts) runs once per verified workspace arm (boot, unlock, switch, genesis, and a popout's own arm) and decides how this device gets (or creates) the account key for the workspace that was just armed:

  1. GET key-wraps — the current key id/version plus every wrap of it.

  2. A wrap for this workspace under the current key exists → unwrap it with the workspace's wrapKey → armed (via: "unwrapped").

  3. No account key exists at all → generate one and POST keys together with this workspace's first wrap in one server batch, so a crash between "key created" and "wrap stored" can never leave a key nobody can unwrap. Losing the first-writer race discards the local key and re-lists the wraps (the winner may already have wrapped this workspace; otherwise falls through to step 5).

  4. This session already holds the current key in memory, but this workspace has no wrap of it → PUT one, and opportunistically wrap every other workspace this session has armed that still lacks a wrap (rewrapOthers).

  5. Nothing held and no wrap for this workspace → unavailable (reason: "no-wrap").

Fail-closed throughout: a wrap that does not open under this workspace's wrapKey/AAD (reason: "unwrap-failed") ends in unavailable without a single write — it is never "repaired" by overwriting the server's wrap. A readOnly caller (a popout window) only ever attempts step 2; it never creates a key or writes a wrap.

Last-wrap guard and reset

Removing a workspace's own wrap of the account key would strand "My snippets" if that workspace holds the only wrap. isLastAccountKeyWrap(workspaceId) (the renderer wrapper over account-core's checkLastAccountKeyWrap) calls GET key-wraps/last-wrap-check/:workspaceId, which counts wraps of the account's current key and reports whether this workspace's wrap is the sole one. This guard lives in the workspace-removal dialog.

"Reset My snippets" (resetAccountStore()) replaces the account key and purges everything encrypted under the old one via POST /reset, a compare-and-swap keyed on expectedKeyId — two devices racing a reset cannot wipe each other's fresh data, because only the first swap succeeds and the loser's request 409s. A reset is always atomic with the new key's first wrap: it seals the new key for a verified-unlocked workspace other than the excluded one (the open workspace, else any other workspace unlocked this session) and sends that wrap in the same POST /reset, which the server applies all-or-nothing with the key swap (see Account API). With no such workspace — the boot picker, or removing the only unlocked workspace — the reset is refused before any request (canResetAccountStore() is false), and the removal dialog tells the user to open another workspace first instead of offering the button. The server therefore never holds an account key with zero wraps.

Offline cached wrap

enrollAccountKey() always starts with GET key-wraps, which needs the network. A fully offline cold boot has already unlocked its workspace from the workspace's own local mirror (real wrapKey, no network), but the account key's wrap for that workspace can only come from somewhere local:

  • account-key-wrap-cache.ts caches each workspace's wrapped-key blob (ciphertext only — safe in plaintext localStorage, since it only opens under that workspace's own wrap key) on every successful online enrollment.

  • account-offline-unwrap.ts's unwrapAccountKeyOffline() is tried only when the online enrollAccountKey() call throws; it reproduces a previously-proven wrap against the cached blob. It never creates a key or writes a wrap — an offline unwrap can only replay what an earlier online session already proved.

Both caches are cleared on sign-out (clearAccountKeyWrapCache()), directly from teardownAccountLifecycle().

Renderer lifecycle

tauri-app/renderer/lib/account-lifecycle.ts is the one module-scoped owner, per JS realm, of the held account key and the AccountSyncEngine behind bridge.accountFiles:

  • After every verified workspace arm, the caller invokes enrollAccountForCurrentWorkspace(), which runs the enrollment state machine above against the just-armed workspace's wrapKey and arms the engine with the result.

  • The engine is not tied to the workspace singletons: a workspace switch tears down cloud-sync-bridge but leaves the account engine, its outbox, and its decrypted store running — the next workspace's enrollment only adds a wrap; the held key already matches, so the engine is not re-armed and its store is not cleared.

  • Sign-out (teardownAccountLifecycle()) is the one path that drops everything: the engine, the held key, every session wrap key, the account mirror, the cached key wrap, and any clearer registered via registerAccountLocalStateClearer().

  • A popout enrolls read-only (step 2 of the enrollment branches only) and never creates keys or writes wraps.

The engine is built on the one AccountSyncClient returned by ensureAccountSyncClient() — the same instance the account WS bridge uses to mint its ticket — so both speak with the same device identity.

Local mirror and offline hydrate

The account store has no workspace id of its own, so it shares workspace-mirror-store.ts's IndexedDB tables under the pseudo-id account:<userId> (account-mirror-store.ts). This reuses the workspace mirror's atomicity contract (row + cursor in one transaction, schema drift means "no mirror") for free; a personal snippet set is small enough that sharing the database costs nothing. clearAllWorkspaceMirrors() never touches it — only clearAccountMirror(userId) does.

The engine's own persisted sync cursor (zudotext.accountSyncCursor.v1.<userId>) must never run ahead of the mirror, or an app quit between the two writes would make the next boot skip the gap. The renderer builds its engine with deferCursorPersistence: true, and account-mirror-writer.ts reads the store and the cursor together, writes the mirror, and only then calls engine.commitCursor(cursor). A failed mirror write leaves the persisted cursor behind, which only costs an idempotent re-pull.

account-mirror-writer.ts trades the workspace mirror's incremental-batch precision for a simpler rule: on every store change it re-encrypts and writes the whole current store as one snapshot (replaceAccountMirror). On a fresh arm (new engine, or the same engine re-armed under a different key), hydrateAccountMirrorStore() pre-populates the engine's in-memory store from this mirror before the first sync() — on a fully offline cold boot, sync()'s network calls fail silently and this hydrate is the only content "My snippets" gets that session. A workspace switch that keeps the same account key skips both steps: the live store already holds the session's content.

Realtime channel

The account store's push/pull is backed by the same SyncRoom Durable Object class the workspace sync uses, named user:<userId> instead of a workspace id (accountRoomName(), epic #6780 sub #6789) — no new Durable Object class or wrangler migration needed. POST /api/v1/account/ws-ticket mints a short-TTL, single-use ticket (a browser WebSocket client cannot send an Authorization header); GET /api/v1/account/ws?token=<ticket> consumes it and upgrades into that room. notifyAccountRoom() broadcasts on every file upload, delete, and reset.

On the client, account-ws-bridge.ts debounces a room's changes push into AccountSyncEngine.pull() (150 ms) rather than trusting any payload in the push, and reports coarse connectivity through AccountFilesAPI.state()'s "offline" value. The account WS connection is opened only while an account engine is armed for the session — an unarmed session (signed out, or enrollment not yet resolved) does not open it or mint a ticket.

Note

The mock backend adapter's accountFiles is a fixed in-memory fixture (seeded with two sample "My snippets"), independent of the enrollment state machine above — a mock workspace switch does not swap it, and it never reports keyMismatch.

Switch / sign-out / popout summary

EventEngineHeld keyMirror / cached wrap
Workspace switch (same account)Kept, re-enrolledKeptUntouched
Sign-outDisposedDropped, zeroedCleared
Popout window armOwn read-only enrollmentNot created/writtenRead-only hydrate; never written
Reset ("Reset My snippets")Re-armed with the new keyReplacedOld-key rows purged server-side; mirror rebuilt