Key the local cache to the identity, not to the door it was opened through

Groundwork for a device key, and a spec change rather than a feature. ADR 0007
records the decision it clears the way for: a Windows Hello gesture guarding a
protected blob, with the passphrase kept as a permanent fallback.

The reason that decision needed this first is that a device key cannot open a
session on its own. SessionOpener derived two things from the passphrase master
key — the bundle, and the local cache key — and a device wrap is
SealTo(device_x25519_pk), which yields the bundle and never computes a master key
at all. A device unlock could therefore have opened the identity and still not
read the cache it had itself written.

So LocalCacheKey now derives from the bundle: dsh1/localcache/v1 → v2, specified
in crypto.md §3.2. Every wrap that opens a vault ends up holding the bundle, so
every door reaches the same cache.

Extract-and-expand, not expand alone. Everything derived from the master key uses
HKDF-Expand directly, which is sound because an Argon2id output is uniformly
random over its whole length. The bundle's encoding is not — it opens with a
fixed 14-byte label and carries a version, a generation and a timestamp before
reaching any key material — so it needs the extract step to become a pseudorandom
key first.

Two consequences fell out, both improvements and neither the point:

- A passphrase change no longer discards the local cache. The bundle is unchanged
  by a re-wrap, so the cache key is too. Under v1 changing a passphrase silently
  orphaned every cached row and the next launch re-pulled the whole vault.
- Recovery-code unlock is fixed before it ships. It derives a different master key
  from a different secret and a different salt, so under v1 it would have had the
  same defect as the device path, and nobody would have noticed until it landed.

The cache becomes unreadable exactly when the identity is rotated, which is the
correct moment to discard it. Existing caches are discarded and re-pulled on
upgrade — already the specified behaviour for a stale cache, and the reason the
label is versioned rather than reused: a v1 cache must fail to open rather than
decrypt to nonsense.

One stated guarantee got weaker and now says so. crypto.md §10 claimed locking
meant "nothing on disk can be read again without the passphrase." Where a device
wrap exists that is no longer true, and it would have been untrue under either
candidate design — the alternative was storing a copy of the cache key in the
device blob, which is the same door with an extra key lying next to it. The
wording now points at ADR 0007, because what guards the device key is a platform
decision and not a property of this specification.

A golden vector was quietly lying, which is the part worth reading twice. The
"local-cache" entry pinned HKDF-SHA512-Expand over a fixed PRK — a construction
the cache key no longer uses. Regenerating it would have produced a green suite
describing a derivation this code does not perform. It is replaced by a vector
over a bundle whose every byte is pinned: the label, version 1, generation 1, a
fixed timestamp and two recognisable key scalars, all visible in the fixture so a
second implementation can check itself against it. UserSecretBundle.TryDecode is
internal for this, because Create draws fresh randomness and so can never produce
a reproducible input.

Mutation tested, and this one earns its keep: dropping the extract step now fails
CommittedVectors_MatchCurrentImplementation. The vector it replaced could not
have caught that, because it never touched the bundle at all.

One test became false and says so. ARecordSealedUnderAnotherPassphrase is now
ARecordSealedByAnotherIdentity: a different passphrase deliberately no longer
changes the cache key, and TheLocalCacheKey_SurvivesAPassphraseChange pins that.
What must still be unreadable is another user's cache. CacheHarness therefore
generates an identity rather than deriving from a passphrase, and has no
passphrase parameter left — the cache key is not a question about passphrases any
more.

SyncHarness's two simulated machines now derive the same cache key, which is what
keying on the bundle means: they are the same user holding the same identity. They
still have separate cache databases, so nothing is shared between them but the key
that would open either. Both harnesses lost a MasterKey field that existed only to
make a protector.

858 tests green. Zero warnings, dotnet format clean.

Not done: the device key itself. Three pieces remain, and the middle one was a
discovery rather than a plan — EnrollmentService.AddDevice runs only during
enrollment, so every already-enrolled account, which is all of them, needs an
endpoint to add a device wrap while unlocked. The client proves possession by
producing the wrap, so that shape falls out of the crypto. After that: the
protector seam with the wrap cached locally for offline unlock, then the Hello
implementation and the unlock-screen UI, which is where the Windows TFM lands and
where automated testing stops.
This commit is contained in:
2026-07-30 12:46:55 +02:00
parent c5dec2d68e
commit 7016ce36f1
14 changed files with 412 additions and 78 deletions
+40 -3
View File
@@ -98,8 +98,7 @@ vault passphrase
MK — master key, RAM only, never persisted, never transmitted
│ HKDF-SHA512-Expand with domain-separated info labels
── KEK_pp info = "dsh1/kek/passphrase/v1" 32 B
└── LocalCacheKey info = "dsh1/localcache/v1" 32 B
── KEK_pp info = "dsh1/kek/passphrase/v1" 32 B
UserSecretBundle — fixed binary, 92 B (see 3.1)
stored server-side as N independent wraps of the SAME bundle:
@@ -107,6 +106,8 @@ UserSecretBundle — fixed binary, 92 B (see 3.1)
kind=device → SealTo(device_x25519_pk) one row per enrolled device
kind=recovery → symmetric AEAD under KEK_rc = Argon2id(recovery code)
kind=escrow → SealTo(team_breakglass_pk) opt-in, M5
│ HKDF-SHA512 extract-and-expand over encode(bundle) — see 3.2
├── LocalCacheKey info = "dsh1/localcache/v2" 32 B
VaultKey — 32 B CSPRNG, per vault, per key generation
wrapped per member: SealTo(member_x25519_pk, VaultKey, aad)
@@ -160,6 +161,38 @@ so a new field means bumping `version` — which a fixed layout handles as well
Readers **must** reject a bundle whose length, label or version does not match exactly. This is the
root of everything a user can read; there is no safe way to guess at a malformed one.
### 3.2 LocalCacheKey
> **Changed 2026-07-30**, from deriving under `MK` with info `"dsh1/localcache/v1"` to deriving
> under the bundle with `"dsh1/localcache/v2"`. Recorded here because it reverses a stated choice.
```
LocalCacheKey = HKDF-SHA512(ikm = encode(bundle), salt = none,
info = "dsh1/localcache/v2", L = 32)
```
**Extract-and-expand, not expand alone.** Everything derived from `MK` uses HKDF-Expand directly,
which is sound because an Argon2id output is uniformly random over its whole length. `encode(bundle)`
is not: it opens with a fixed 14-byte label and carries a version, a generation and a timestamp before
reaching any key material. The extract step is what turns that into a pseudorandom key.
Derived from the bundle rather than from `MK` because a passphrase is only one of four ways to open a
vault, and the cache has to be readable through all of them. Under v1:
- a **device** unlock opens a `SealTo` wrap and never computes `MK`, so it could open the identity and
still not read the cache it had itself written;
- a **recovery-code** unlock derives a *different* `MK` — different secret, different salt — and so
would silently derive a different cache key and orphan every cached row;
- an **escrow** unlock (M5) has the same problem as device.
Keying on the bundle also means a **passphrase change no longer discards the cache**, which is a
consequence worth stating rather than discovering: the bundle is unchanged by a re-wrap, so the cache
key is too. The cache becomes unreadable exactly when the *identity* is rotated, which is the correct
moment to discard it.
The label is versioned, so a client holding a v1 cache fails to open it and re-pulls rather than
decrypting to nonsense. That is the whole reason for bumping rather than reusing the label.
### Why the bundle is wrapped many ways
This is the load-bearing structural choice. Because every wrap protects the *same* bundle:
@@ -500,7 +533,11 @@ server, its operators, its backups and the network. It does **not** address:
visible, as are host addresses for relay-enabled hosts;
- a weak passphrase — §2 parameters and passphrase entropy are the whole defence;
- **a locked vault on a machine with open sessions** — locking zeroes the identity keys, the vault
keys and the cache key, so nothing on disk can be read again without the passphrase. It does not
keys and the cache key, so nothing on disk can be read again without re-opening the bundle: a
passphrase, or any other wrap the user has registered. Where a device wrap exists, whatever guards
it on that machine is therefore as strong as the passphrase for reading the cache — which is the
decision recorded in [ADR 0007](adr/0007-device-key-protection.md), not a property of this spec. It
does not
touch an SSH channel that is already open: that channel was authorised at connect time by a
credential the remote host verified itself, and no vault key participates in keeping it alive.
Sessions therefore survive lock **by design** (the client says so on its unlock screen, and the