Skip to content

perf(sync): overlap the PBKDF2 derive with the pull on keychain unlock (#103, Tier 2) - #110

Merged
arimxyer merged 3 commits into
mainfrom
perf/103-tier2-kdf-overlap
Jun 26, 2026
Merged

perf(sync): overlap the PBKDF2 derive with the pull on keychain unlock (#103, Tier 2)#110
arimxyer merged 3 commits into
mainfrom
perf/103-tier2-kdf-overlap

Conversation

@arimxyer

Copy link
Copy Markdown
Collaborator

Completes #103. Tier 1 (#109) hid the pre-unlock probe latency behind the password prompt; this Tier 2 hides it for keychain users — the default/common case, who have no prompt to overlap — behind the PBKDF2 key derivation (600k iterations, ≈ tens-to-hundreds of ms; measured ~63ms on a fast desktop, more on a laptop). No staleness tradeoff.

Note: the KDF is PBKDF2-SHA256-600k, not Argon2 (the issue text said "Argon2" — corrected here and in code comments).

How it works

Unlock couples file-read + derive + decrypt, and a pull replaces the file, so the decrypt must run after the pull. But the expensive derivation only needs the password + the key params (salt/iterations/wrappedDEK) — not the ciphertext — so it can overlap the pull:

  1. PrepareUnlock() reads the key params (cheap, pre-pull).
  2. SyncPull runs in a goroutine while DeriveDataKey (PBKDF2) runs on the main goroutine — neither touches vault.enc.
  3. Join, then UnlockWithPreparedKey decrypts the now-current file with the prepared key.

A single goroutine (the pull); the derive is on the main thread — same shape as Tier 1.

The correctness crux: param-comparison, not decrypt-failure

If the pull brings a re-keyed vault, the prepared key is stale. The fallback gate compares the current on-disk key params against the captured ones — mismatch ⇒ discard the prepared key, fall back to a full password unlock.

This is deliberately not "try the key, fall back on decrypt failure". For a v2 vault a remote password-only change preserves the DEK, so a stale prepared DEK would still decrypt — and we'd unlock with a stale masterPassword (reads work, saves break). Param-comparison makes Tier 2 behave identically to a sequential unlock on every re-key: a stale keychain password fails cleanly at unlock. (A decrypt failure is kept as a belt-and-suspenders second fallback.)

Staged for safety

  1. refactor(vault): extract handleIncompleteMigration + finishUnlock from Unlockpure no-behavior-change, verified green by the existing Unlock tests, so the new path reuses proven state-setup (retains masterPassword, not recoveryDEK).
  2. feat(vault): storage ReadKeyParams/DeriveDataKey/Equal + vault PrepareUnlock/UnlockWithPreparedKey.
  3. perf(sync): wire the cmd keychain branch.

Tests (all -race)

  • vault: happy path (params match → prepared key used, save works, not a recovery unlock); re-key → fallback to current password succeeds; stale password after re-key → fails cleanly, vault stays locked.
  • cmd: drives the keychain overlap end-to-end against a real sync-enabled, initialized vault (no keychain backend needed) — correct unlock + working save.
  • Full unit + integration green; cmd/vault/storage pass go test -race; golangci-lint v2.5 clean.

Closes #103.

🤖 Generated with Claude Code

arimxyer and others added 3 commits June 26, 2026 19:17
…m Unlock

Pure no-behavior-change refactor, staged ahead of #103 Tier 2. Splits Unlock into
three single-purpose pieces so the upcoming prepared-key unlock path can reuse the
exact same state setup instead of duplicating it:

- handleIncompleteMigration() — the vault.tmp auto-rollback block.
- finishUnlock(data, masterPassword) — everything after decryption: unmarshal,
  set in-memory state (incl. the masterPassword copy, NOT recoveryDEK), restore
  audit logging, sync metadata, remove the post-migration backup, log success.
- Unlock now calls handleIncompleteMigration → LoadVault → finishUnlock.

The existing Unlock test coverage (unit + integration, all green) is what proves
finishUnlock is correct, so the Tier 2 commit that follows only adds new surface.

Refs #103.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019sxsM218vNzDbuMZ2nhMzx
…ync pull (#103, Tier 2)

Adds the primitives for keychain users to hide the pre-unlock probe latency behind
the key derivation (PBKDF2-600k ≈ tens-to-hundreds of ms), the win Tier 1 couldn't
give them (no prompt to overlap). The cmd wiring lands in a follow-up commit.

storage:
- PreparedKeyParams + ReadKeyParams(): read the vault header's key-derivation
  params (version, salt, iterations, wrappedDEK[,nonce]) WITHOUT decrypting —
  cheap, runs before the pull.
- DeriveDataKey(password, params): the expensive PBKDF2 step, no file access, so
  it can run concurrently with the pull. v1 → password-derived key; v2 → derive
  KEK then unwrap the DEK. Returns the key LoadVaultWithKey consumes.
- PreparedKeyParams.Equal: compare captured vs current params to detect a re-key.

vault:
- PrepareUnlock() → PreparedUnlock{DeriveDataKey()} → UnlockWithPreparedKey().
- UnlockWithPreparedKey decrypts with the prepared key ONLY if the current on-disk
  params still match what we derived against; otherwise it falls back to a full
  password unlock. This is deliberately param-comparison, not decrypt-failure: for
  v2 a remote password-only change keeps the DEK, so a stale prepared DEK would
  still decrypt and we'd unlock with a stale masterPassword (reads work, saves
  break). Param-comparison makes Tier 2 behave identically to a sequential unlock
  on every re-key — a stale keychain password fails cleanly at unlock. A decrypt
  failure is a belt-and-suspenders second fallback.
- Reuses finishUnlock (retains masterPassword for saves, not recoveryDEK).

Tests (-race): happy path (params match → prepared key used, save works, not a
recovery unlock); re-key → fallback to current password succeeds; stale password
after re-key → fails cleanly, vault stays locked.

Refs #103 (Tier 2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019sxsM218vNzDbuMZ2nhMzx
#103, Tier 2)

Wires the Tier 2 primitives into the command path so keychain users — the common
case, who have no password prompt to hide behind (Tier 1) — also get the
pre-unlock probe latency hidden, here behind the PBKDF2 key derivation
(600k iterations ≈ tens-to-hundreds of ms).

cmd/unlockKeychainOverlappingPull (the keychain branch of unlockVaultWithSync):
- PrepareUnlock() reads key params (cheap, pre-pull);
- SyncPull runs in a goroutine while DeriveDataKey (PBKDF2) runs on the main
  goroutine — neither touches vault.enc, so they overlap safely;
- join, then UnlockWithPreparedKey decrypts the now-current file, falling back to
  a full password unlock if the pull brought a re-keyed vault.
- A transient "Checking remote..." indicator is shown here (no prompt to garble),
  and a sync conflict is surfaced cleanly after the join. Falls back to a
  sequential keychain unlock if the header can't be read.

Tier 1 (password branch) is unchanged. Test (-race): drives the keychain overlap
end-to-end against a real sync-enabled, initialized vault by calling the helper
with the password directly (no keychain backend needed), asserting a correct
unlock and a working save (masterPassword retained).

Closes #103.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019sxsM218vNzDbuMZ2nhMzx
@arimxyer
arimxyer merged commit 81b8117 into main Jun 26, 2026
7 checks passed
@arimxyer
arimxyer deleted the perf/103-tier2-kdf-overlap branch June 26, 2026 23:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

perf(sync): overlap pre-unlock remote probe with unlock to hide latency (split from #96)

1 participant