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
+111
View File
@@ -0,0 +1,111 @@
# ADR 0007 — What protects the device key on Windows
**Status:** accepted, 2026-07-30
**Supersedes nothing. Constrains** the device-unlock work described in the client roadmap.
## Context
Unlock asks for the vault passphrase on every launch, because no device key is registered. The
mechanism for one already exists: enrollment can generate an X25519 key pair, seal the
`UserSecretBundle` to it (`kind=device` in [crypto.md §3](../crypto.md)) and register the public half
with the server. What was never decided is **where the private half lives on this machine**, and that
decision is the whole security content of the feature.
The device key is not a convenience token. It opens the same 92-byte bundle the passphrase opens — the
Ed25519 identity key plus the X25519 key that unwraps every vault key the user holds. It is
passphrase-equivalent, and recovery from its compromise is expensive: a passphrase change re-wraps one
row, but rotating the bundle means re-sealing every `VaultKey` to a new member key.
Three candidates were considered: DPAPI, Windows Hello, and a TPM-resident key.
### The constraint that reshapes the choice
DSH1 fixes the device wrap as `SealTo(device_x25519_pk)`. Neither of the two hardware options can hold
that key:
- **Windows Hello** (`KeyCredentialManager`) produces an RSA key that only *signs*. No key agreement,
no decryption.
- **The TPM**, through CNG's Platform Crypto Provider, does RSA and the NIST curves. Not X25519.
So none of the three can *be* the device key. All three are ways to protect a stored 32-byte X25519
key that still has to be reassembled in process memory to open the wrap. Any claim that "the key never
leaves hardware" would be false under all of them.
## Decision
**A Windows Hello gesture gating a protected blob, with the passphrase kept as a permanent fallback.**
The gesture is what carries the security value: it requires **user presence** per unlock. Hello cannot
decrypt, so it is used to gate release of the wrapping key, and the passphrase path remains available
unconditionally.
### Why not DPAPI alone
DPAPI would be a **regression against the status quo**, which is worth stating plainly because it is
the option that looks like the obvious default.
Today the root key exists only in the user's head and enters memory only while unlocked. Malware
running as the user must keylog the passphrase or scrape memory during a session. With DPAPI alone it
reads a file and calls `CryptUnprotectData` — no user present, no keylogging, at any moment. This is
the same reason browser cookie theft is trivial. Convenience would have been bought precisely against
the attacker most likely to turn up.
DPAPI and a raw TPM key both defend the *stolen disk* case, which BitLocker already largely covers.
Neither defends the *local malware* case. The gesture does.
### Why not extend the spec (yet)
The only option that delivers what the TPM is usually credited with is to add a `SealTo` algorithm over
a curve the TPM can do — `alg_id = 4` over P-256 — so the device private key never exists in process
memory at all. That is **the recorded target**, not this decision.
It is cheaper than "change a frozen spec" sounds, because a device wrap row is read only by the device
that created it: not by another client, and not by the server. The envelope already carries `alg_id`
and §5 requires readers to fail closed on what they do not understand, so the interop surface is
almost nil. Two things to check when it is taken up: `EnrollmentValidation` pins
`DevicePublicKey` to `CryptoSpec.PublicKeySize` (32 bytes; a P-256 public key is 33 or 65), and the
envelope's minimum-length rule.
## Consequences
### The cache key had to move, and the spec changed
`LocalCacheProtector` derived its key from the passphrase master key. A device unlock produces the
bundle and never computes a master key, so it could have opened the identity and still not read the
cache it had itself written. The derivation now hangs off the bundle — `dsh1/localcache/v1`
`v2`, [crypto.md §3.2](../crypto.md) — so every door reaches the same cache.
Two consequences fell out of that, both improvements, neither planned:
- **A passphrase change no longer discards the local cache.** The bundle is unchanged by a re-wrap.
- **Recovery-code unlock is fixed before it ships.** It derives a different master key from a different
secret and salt, so under v1 it would have silently orphaned every cached row.
Existing caches become unreadable on upgrade and are discarded and re-pulled, which is the behaviour
already specified for a stale cache.
### A stated guarantee weakened
[crypto.md §10](../crypto.md) said 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 wording now points here. The honest statement is that whatever guards
the device key on a machine is as strong as the passphrase for reading that machine's cache.
This is why the enrollment screen's sentence — that the passphrase "is the only thing standing between
a stolen copy of the database and every credential in your vault" — stays true under this decision and
would have become false under DPAPI alone. A gesture is still something the attacker must produce.
### Operational
- **Hello is not always available.** No biometric hardware falls back to a Hello PIN, which is
TPM-bound and rate-limited and still satisfies the presence requirement. Some machines have no Hello
at all. The passphrase path is therefore required, not a nicety.
- **Hello keys are invalidated when the PIN is reset**, so the blob must be treated as losable at any
time; losing it degrades to a passphrase prompt and never to a locked-out vault.
- **Registering a device is a separate act from enrolling one.** `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. Producing the wrap requires the bundle, so the client proves possession
by construction.
- **Revocation must delete the server row**, and un-enrolling the machine in front of the user must not
be able to lock them out: [ADR 0001](0001-e2ee-trust-model.md) makes an enrolled device a recovery
path, so it is now load-bearing for more than convenience.
+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
+4 -4
View File
@@ -131,9 +131,9 @@ public sealed class SessionOpener(
}
/// <remarks>
/// The master key lives only inside this method. Both things derived from it — the cache subkey and
/// the identity bundle — outlive it, which is why they are produced together here rather than by two
/// calls that would each need the master key again.
/// The master key lives only inside this method — it opens the bundle and is then done with. The cache
/// protector derives from the bundle rather than from the master key, which is what lets a device or
/// recovery unlock reach the same cache; see <see cref="UserSecretBundle.DeriveLocalCacheKey"/>.
/// </remarks>
private static UserSecretBundle? OpenBundle(
StoredUnlockMaterial profile,
@@ -155,7 +155,7 @@ public sealed class SessionOpener(
try
{
protector = LocalCacheProtector.From(master);
protector = LocalCacheProtector.From(bundle);
return bundle;
}
catch
@@ -15,8 +15,8 @@ namespace DodoSSH.Client.Storage;
/// definition. Both go through here.
/// </para>
/// <para>
/// <b>What this is and is not worth.</b> The key derives from the master key, so it exists only while
/// the vault is unlocked and is never written anywhere. That makes a stolen laptop, a stray backup or
/// <b>What this is and is not worth.</b> The key derives from the identity bundle, so it exists only
/// while the vault is unlocked and is never written anywhere. That makes a stolen laptop, a stray backup or
/// a synced-to-cloud application folder yield nothing — which is the threat this addresses. It does
/// <em>not</em> defend against a process running as the same user: that process can read this
/// process's memory, and no on-disk measure changes it. docs/crypto.md §10 says the same about a
@@ -33,21 +33,22 @@ public sealed class LocalCacheProtector : IDisposable
private readonly byte[] key = new byte[CryptoSpec.SymmetricKeySize];
private bool disposed;
private LocalCacheProtector(MasterKey master) => master.DeriveLocalCacheKey(key);
private LocalCacheProtector(UserSecretBundle bundle) => bundle.DeriveLocalCacheKey(key);
/// <summary>
/// Derives the cache key from an unlocked master key.
/// Derives the cache key from an opened identity bundle.
/// </summary>
/// <remarks>
/// The master key is not retained. Only the subkey is, and it is domain-separated by its HKDF
/// label from the key that wraps the secret bundle — the two live in very different threat models
/// and must not be the same bytes.
/// From the bundle rather than the passphrase master key, so that every way of opening the vault
/// reaches the same cache — see <see cref="UserSecretBundle.DeriveLocalCacheKey"/>. The bundle is not
/// retained. Only the subkey is, and it is domain-separated by its HKDF label from the key that wraps
/// the bundle: the two live in very different threat models and must not be the same bytes.
/// </remarks>
public static LocalCacheProtector From(MasterKey master)
public static LocalCacheProtector From(UserSecretBundle bundle)
{
ArgumentNullException.ThrowIfNull(master);
ArgumentNullException.ThrowIfNull(bundle);
return new LocalCacheProtector(master);
return new LocalCacheProtector(bundle);
}
/// <summary>Seals a cache record, binding it to the row that will hold it.</summary>
+12 -2
View File
@@ -166,8 +166,18 @@ public static class CryptoSpec
/// <summary>Derives the key-encryption key that wraps the secret bundle.</summary>
public static ReadOnlySpan<byte> PassphraseKek => "dsh1/kek/passphrase/v1"u8;
/// <summary>Derives the key that encrypts the client's on-disk cache.</summary>
public static ReadOnlySpan<byte> LocalCache => "dsh1/localcache/v1"u8;
/// <summary>
/// Derives the key that encrypts the client's on-disk cache.
/// </summary>
/// <remarks>
/// <b>v2 derives from the secret bundle, where v1 derived from the passphrase master key.</b> The
/// cache key has to be reachable through every door that opens the vault — passphrase, device,
/// recovery code, escrow — and only the bundle is common to all of them. Under v1 a device unlock
/// could open the identity and still not read the cache it had written, and a recovery-code unlock
/// would silently derive a different key and orphan the whole cache. Bumping the label rather than
/// reusing it is what makes an old cache fail to open instead of decrypting to nonsense.
/// </remarks>
public static ReadOnlySpan<byte> LocalCache => "dsh1/localcache/v2"u8;
/// <summary>Prefix of the SealTo key-derivation info, concatenated with the AAD.</summary>
public static ReadOnlySpan<byte> SealTo => "dsh1/sealto/v1|"u8;
+3 -10
View File
@@ -62,16 +62,9 @@ public sealed class MasterKey : IDisposable
}
}
/// <summary>
/// Derives the key that encrypts the client's on-disk cache.
/// </summary>
/// <remarks>
/// Domain-separated from the bundle's key-encryption key by its HKDF info label, so a cache
/// record can never be opened with the wrap key or the reverse — the two live in very different
/// threat models and must not share a key.
/// </remarks>
public void DeriveLocalCacheKey(Span<byte> destination) =>
DeriveSubkey(CryptoSpec.DerivationLabels.LocalCache, destination);
// The local cache key used to be derived here. It now derives from the secret bundle — see
// UserSecretBundle.DeriveLocalCacheKey — because a passphrase is only one of four ways to open a
// vault, and the cache key has to be the same one whichever was used.
/// <summary>Wraps a bundle under the passphrase-derived key-encryption key.</summary>
/// <remarks>
+56 -1
View File
@@ -115,6 +115,56 @@ public sealed class UserSecretBundle : IDisposable
KeyLogChain.TruncateTimestamp(createdAt));
}
/// <summary>
/// Derives the key that encrypts this client's on-disk cache. See docs/crypto.md §3.2.
/// </summary>
/// <remarks>
/// <para>
/// Derived from the bundle rather than from the passphrase master key, because the bundle is the one
/// thing every unlock path ends up holding. A device unlock opens a <see cref="SealTo"/> wrap and never
/// sees a master key at all, so under the old derivation it could open the identity and still not read
/// the cache it had itself written; a recovery-code unlock would derive a different key from a different
/// master and silently orphan every cached row. Keying on the bundle makes the cache readable through
/// whichever door was used, and unreadable exactly when the identity itself has been rotated — which is
/// the correct time to discard it.
/// </para>
/// <para>
/// <b>Extract-and-expand, not expand alone.</b> <see cref="MasterKey"/> can expand directly because an
/// Argon2id output is already 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 it
/// reaches any key material. HKDF's extract step is what turns that into a pseudorandom key, and
/// skipping it would be feeding structured input to a function that assumes it has none.
/// </para>
/// </remarks>
public void DeriveLocalCacheKey(Span<byte> destination)
{
Alive();
if (destination.Length != CryptoSpec.SymmetricKeySize)
{
throw new ArgumentException(
$"The cache key is {CryptoSpec.SymmetricKeySize} bytes, got {destination.Length}.",
nameof(destination));
}
Span<byte> encoded = stackalloc byte[EncodedLength];
try
{
Encode(encoded);
HKDF.DeriveKey(
HashAlgorithmName.SHA512,
encoded,
destination,
salt: default,
info: CryptoSpec.DerivationLabels.LocalCache);
}
finally
{
CryptographicOperations.ZeroMemory(encoded);
}
}
/// <summary>
/// Wraps the bundle under a symmetric key-encryption key.
/// </summary>
@@ -261,7 +311,12 @@ public sealed class UserSecretBundle : IDisposable
&& BinaryPrimitives.ReadUInt16BigEndian(encoded[OffsetVersion..]) == CurrentVersion
&& BinaryPrimitives.ReadUInt32BigEndian(encoded[OffsetKeyGeneration..]) >= 1;
private static UserSecretBundle? TryDecode(ReadOnlySpan<byte> encoded)
/// <remarks>
/// Internal rather than private so the golden-vector generator can build a bundle from fixed bytes.
/// The cache key derives from this encoding, and a vector for it is worth nothing unless the input is
/// pinned — <see cref="Create"/> draws fresh randomness, so it cannot produce a reproducible one.
/// </remarks>
internal static UserSecretBundle? TryDecode(ReadOnlySpan<byte> encoded)
{
if (!IsWellFormed(encoded))
{
@@ -15,15 +15,16 @@ namespace DodoSSH.Client.Storage.Tests;
/// </remarks>
internal sealed class CacheHarness : IDisposable
{
private static readonly Argon2Profile CheapProfile =
Argon2Profile.FromStoredParameters(memoryKibibytes: 8 * 1024, passes: 1, parallelism: 1);
/// <remarks>
/// Fixed rather than "now", because a bundle's encoding includes its creation timestamp and the cache
/// key derives from that encoding. Nothing here depends on the value; it only has to be a constant.
/// </remarks>
private static readonly DateTimeOffset IdentityCreatedAt =
new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
private readonly MasterKey master;
private CacheHarness(ClientCacheFactory factory, MasterKey master, LocalCacheProtector protector)
private CacheHarness(ClientCacheFactory factory, LocalCacheProtector protector)
{
Factory = factory;
this.master = master;
Protector = protector;
Items = new ItemStore(factory, protector);
@@ -54,8 +55,12 @@ internal sealed class CacheHarness : IDisposable
internal ConflictStore Conflicts { get; }
internal static async Task<CacheHarness> CreateAsync(
string passphrase = "correct horse battery staple")
/// <remarks>
/// Each harness gets a freshly generated identity, so two of them are two different users and their
/// cache keys differ. There is no passphrase here at all any more: the cache key derives from the
/// bundle, so a passphrase is not part of the question this harness sets up.
/// </remarks>
internal static async Task<CacheHarness> CreateAsync()
{
var factory = ClientCacheFactory.ForMemory($"cache-{Guid.CreateVersion7():N}");
@@ -63,10 +68,11 @@ internal sealed class CacheHarness : IDisposable
{
await factory.MigrateAsync(TestContext.Current.CancellationToken);
var salt = new byte[CryptoSpec.SaltSize];
var derived = MasterKey.Derive(passphrase, salt, CheapProfile);
// Disposed immediately: the protector copies the derived key, so the identity itself is not
// needed once it has answered.
using var identity = UserSecretBundle.Create(IdentityCreatedAt);
return new CacheHarness(factory, derived, LocalCacheProtector.From(derived));
return new CacheHarness(factory, LocalCacheProtector.From(identity));
}
catch
{
@@ -79,7 +85,6 @@ internal sealed class CacheHarness : IDisposable
public void Dispose()
{
Protector.Dispose();
master.Dispose();
Factory.Dispose();
}
@@ -97,11 +97,15 @@ public sealed class CacheStoreTests : IAsyncLifetime
}
[Fact]
public async Task ARecordSealedUnderAnotherPassphrase_DoesNotOpen()
public async Task ARecordSealedByAnotherIdentity_DoesNotOpen()
{
// Another *identity*, not another passphrase, and the distinction is the point. The cache key now
// derives from the secret bundle, so changing a passphrase deliberately keeps the cache readable —
// UserSecretBundleTests.TheLocalCacheKey_SurvivesAPassphraseChange pins that. What must still be
// unreadable is another user's cache, and that is what a second harness is.
var entityId = Guid.CreateVersion7();
using var stranger = await CreateAsync(passphrase: "a completely different passphrase");
using var stranger = await CreateAsync();
var sealedFields = stranger.Protector.Protect(
CryptoSpec.AadResourceType.Host, entityId, [1, 2, 3]);
@@ -14,13 +14,11 @@ namespace DodoSSH.Client.Sync.Tests;
internal sealed class SyncDevice : IDisposable
{
private readonly ClientCacheFactory factory;
private readonly MasterKey master;
private readonly LocalCacheProtector protector;
private SyncDevice(
string name,
ClientCacheFactory factory,
MasterKey master,
LocalCacheProtector protector,
VaultKeyring keyring,
FakeVaultServer server,
@@ -28,7 +26,6 @@ internal sealed class SyncDevice : IDisposable
{
Name = name;
this.factory = factory;
this.master = master;
this.protector = protector;
Keyring = keyring;
@@ -80,14 +77,14 @@ internal sealed class SyncDevice : IDisposable
{
await cache.MigrateAsync(TestContext.Current.CancellationToken);
var derived = MasterKey.Derive(
$"passphrase-{name}", new byte[CryptoSpec.SaltSize], SyncHarness.CheapProfile);
// Opened through the real grant, so the keyring, the wrap and the AAD are all exercised.
var keyring = VaultKeyring.Open(bundle, [vault]);
// Both simulated machines derive the same cache key, because they are the same user holding the
// same identity — which is what keying the cache on the bundle means. They still have separate
// cache databases, so nothing is shared between them but the key that would open either.
return new SyncDevice(
name, cache, derived, LocalCacheProtector.From(derived), keyring, server, options);
name, cache, LocalCacheProtector.From(bundle), keyring, server, options);
}
catch
{
@@ -199,7 +196,6 @@ internal sealed class SyncDevice : IDisposable
{
Keyring.Dispose();
protector.Dispose();
master.Dispose();
factory.Dispose();
}
}
@@ -104,8 +104,11 @@ public sealed class CryptoSpecTests
// These are HKDF info strings; changing one silently derives a different key.
CryptoSpec.DerivationLabels.PassphraseKek.ToArray()
.ShouldBe("dsh1/kek/passphrase/v1"u8.ToArray());
// v2 since 2026-07-30: the cache key derives from the bundle rather than the master key, so that a
// device or recovery unlock reaches the same cache. docs/crypto.md §3.2. Bumping the label is what
// makes a v1 cache fail to open rather than decrypt to nonsense.
CryptoSpec.DerivationLabels.LocalCache.ToArray()
.ShouldBe("dsh1/localcache/v1"u8.ToArray());
.ShouldBe("dsh1/localcache/v2"u8.ToArray());
CryptoSpec.DerivationLabels.SealTo.ToArray()
.ShouldBe("dsh1/sealto/v1|"u8.ToArray());
CryptoSpec.DerivationLabels.Fingerprint.ToArray()
+67 -1
View File
@@ -1,3 +1,4 @@
using System.Buffers.Binary;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
@@ -32,6 +33,7 @@ internal static class GoldenVectors
["envelope"] = BuildEnvelopeVectors(),
["aead"] = BuildAeadVectors(),
["hkdf"] = BuildHkdfVectors(),
["localCacheKey"] = BuildLocalCacheKeyVectors(),
["argon2id"] = BuildArgon2Vectors(),
["fingerprint"] = BuildFingerprintVectors(),
["keyStatement"] = BuildKeyStatementVectors(),
@@ -186,6 +188,12 @@ internal static class GoldenVectors
];
}
/// <remarks>
/// Only the labels that really are HKDF-Expand over a master key belong here. The local cache key used
/// to be one of them and is not any more — it extracts and expands over the bundle's encoding
/// instead — so it has its own section rather than an entry here that would describe a derivation this
/// implementation no longer performs. See <see cref="BuildLocalCacheKeyVectors"/>.
/// </remarks>
private static JsonArray BuildHkdfVectors()
{
var prk = Enumerable.Range(0, 64).Select(i => (byte)i).ToArray();
@@ -193,7 +201,6 @@ internal static class GoldenVectors
(string Name, byte[] Info)[] cases =
[
("passphrase-kek", CryptoSpec.DerivationLabels.PassphraseKek.ToArray()),
("local-cache", CryptoSpec.DerivationLabels.LocalCache.ToArray()),
];
var array = new JsonArray();
@@ -214,6 +221,65 @@ internal static class GoldenVectors
return array;
}
/// <summary>
/// The local cache key, over a bundle whose every byte is pinned. docs/crypto.md §3.2.
/// </summary>
/// <remarks>
/// Built from a fixed encoding rather than from <c>UserSecretBundle.Create</c>, which draws fresh
/// randomness and so could never produce a reproducible vector. This is the one that matters for a
/// second implementation: it pins the extract-and-expand construction, the info label, and the fact
/// that the input is the bundle's canonical encoding rather than any key inside it.
/// </remarks>
private static JsonArray BuildLocalCacheKeyVectors()
{
var encoded = FixedBundleEncoding();
using var bundle = UserSecretBundle.TryDecode(encoded)
?? throw new InvalidOperationException("The fixed bundle encoding is not well-formed.");
var cacheKey = new byte[CryptoSpec.SymmetricKeySize];
bundle.DeriveLocalCacheKey(cacheKey);
return
[
new JsonObject
{
["name"] = "local-cache-key-from-bundle",
["algorithm"] = "HKDF-SHA512 extract-and-expand, no salt",
["bundle"] = Hex(encoded),
["info"] = Encoding.UTF8.GetString(CryptoSpec.DerivationLabels.LocalCache),
["outputLength"] = CryptoSpec.SymmetricKeySize,
["output"] = Hex(cacheKey),
},
];
}
/// <remarks>
/// The §3.1 layout with every field a constant: label, version 1, generation 1, a fixed timestamp, and
/// two key scalars of recognisable byte patterns. Any 32 bytes is a valid X25519 scalar and a valid
/// Ed25519 seed, so nothing here needs to be a real generated key.
/// </remarks>
private static byte[] FixedBundleEncoding()
{
var encoded = new byte[UserSecretBundle.EncodedLength];
var span = encoded.AsSpan();
UserSecretBundle.Label.CopyTo(span);
BinaryPrimitives.WriteUInt16BigEndian(span[14..], UserSecretBundle.CurrentVersion);
BinaryPrimitives.WriteUInt32BigEndian(span[16..], 1u);
BinaryPrimitives.WriteInt64BigEndian(
span[20..],
new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero).ToUnixTimeMilliseconds());
for (var i = 0; i < CryptoSpec.SymmetricKeySize; i++)
{
span[28 + i] = (byte)(0x40 + i);
span[60 + i] = (byte)(0x60 + i);
}
return encoded;
}
private static JsonArray BuildArgon2Vectors()
{
var salt = Enumerable.Range(0, CryptoSpec.SaltSize).Select(i => (byte)(0x20 + i)).ToArray();
@@ -473,30 +473,82 @@ public sealed class MasterKeyTests
var wrap = master.WrapBundle(bundle, descriptor);
Span<byte> cacheKey = stackalloc byte[CryptoSpec.SymmetricKeySize];
master.DeriveLocalCacheKey(cacheKey);
bundle.DeriveLocalCacheKey(cacheKey);
UserSecretBundle.TryOpenUnder(cacheKey, wrap, descriptor).ShouldBeNull();
}
[Fact]
public void TheLocalCacheKey_IsStableForTheSamePassphraseAndSalt()
public void TheLocalCacheKey_IsStableAcrossAWrapAndUnwrap()
{
// The same identity has to produce the same cache key after a round trip through a wrap, or every
// unlock would derive a different key and find its own cache unreadable.
var descriptor = DshAad.UserSecretBundle(Alice);
using var master = Derive();
using var original = UserSecretBundle.Create(CreatedAt);
using var reopened = master.TryOpenBundle(master.WrapBundle(original, descriptor), descriptor)!;
Span<byte> first = stackalloc byte[CryptoSpec.SymmetricKeySize];
Span<byte> second = stackalloc byte[CryptoSpec.SymmetricKeySize];
using (var master = Derive())
{
master.DeriveLocalCacheKey(first);
}
using (var master = Derive())
{
master.DeriveLocalCacheKey(second);
}
original.DeriveLocalCacheKey(first);
reopened.DeriveLocalCacheKey(second);
first.SequenceEqual(second).ShouldBeTrue();
}
[Fact]
public void TheLocalCacheKey_SurvivesAPassphraseChange()
{
// What keying on the bundle rather than the master key actually buys, and the reason the derivation
// label went to v2. Under v1 this was false: a new passphrase derived a new master key, so changing
// it silently orphaned every cached row and the next launch re-pulled the whole vault.
var descriptor = DshAad.UserSecretBundle(Alice);
using var bundle = UserSecretBundle.Create(CreatedAt);
Span<byte> before = stackalloc byte[CryptoSpec.SymmetricKeySize];
bundle.DeriveLocalCacheKey(before);
using var changed = MasterKey.Derive(
"an entirely different passphrase", Salt, Argon2Profile.RandomSecret);
using var reopened = changed.TryOpenBundle(changed.WrapBundle(bundle, descriptor), descriptor)!;
Span<byte> after = stackalloc byte[CryptoSpec.SymmetricKeySize];
reopened.DeriveLocalCacheKey(after);
before.SequenceEqual(after).ShouldBeTrue();
}
[Fact]
public void TheLocalCacheKey_DiffersForADifferentIdentity()
{
// The other half of the property: the cache follows the identity, so a rotated one cannot read the
// cache the previous one wrote. That is the correct moment to discard it.
using var first = UserSecretBundle.Create(CreatedAt);
using var second = UserSecretBundle.Create(CreatedAt);
Span<byte> one = stackalloc byte[CryptoSpec.SymmetricKeySize];
Span<byte> two = stackalloc byte[CryptoSpec.SymmetricKeySize];
first.DeriveLocalCacheKey(one);
second.DeriveLocalCacheKey(two);
one.SequenceEqual(two).ShouldBeFalse();
}
[Fact]
public void DerivingACacheKeyFromADisposedBundle_Throws()
{
var bundle = UserSecretBundle.Create(CreatedAt);
bundle.Dispose();
Should.Throw<ObjectDisposedException>(() =>
bundle.DeriveLocalCacheKey(new byte[CryptoSpec.SymmetricKeySize]));
}
[Fact]
public void ASaltShorterThanTheSpecifiedMinimum_IsRejected()
{
@@ -519,19 +571,18 @@ public sealed class MasterKeyTests
var master = Derive();
master.Dispose();
using var bundle = UserSecretBundle.Create(CreatedAt);
Should.Throw<ObjectDisposedException>(() =>
{
var buffer = new byte[CryptoSpec.SymmetricKeySize];
master.DeriveLocalCacheKey(buffer);
});
master.WrapBundle(bundle, DshAad.UserSecretBundle(Alice)));
}
[Fact]
public void ASubkeyBufferOfTheWrongSize_IsRejected()
public void ACacheKeyBufferOfTheWrongSize_IsRejected()
{
using var master = Derive();
using var bundle = UserSecretBundle.Create(CreatedAt);
Should.Throw<ArgumentException>(() => master.DeriveLocalCacheKey(new byte[16]));
Should.Throw<ArgumentException>(() => bundle.DeriveLocalCacheKey(new byte[16]));
}
[Fact]
+8 -6
View File
@@ -128,14 +128,16 @@
"info": "dsh1/kek/passphrase/v1",
"outputLength": 32,
"output": "652b3a4a3ce03b235095ad32f1eed2cfdae915b5b0a98cc9f96face30853f4c7"
},
}
],
"localCacheKey": [
{
"name": "local-cache",
"algorithm": "HKDF-SHA512-Expand",
"prk": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f",
"info": "dsh1/localcache/v1",
"name": "local-cache-key-from-bundle",
"algorithm": "HKDF-SHA512 extract-and-expand, no salt",
"bundle": "647368312f62756e646c652f76310001000000010000019b76daa800404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f",
"info": "dsh1/localcache/v2",
"outputLength": 32,
"output": "5b69ed9266ff5f297f11667ca693b0049b805365ee34d54d6e60b843e414b1f5"
"output": "65bcac61e28f526f6aabfa91dd4ff3e519eaa0724a5729e890a1ef71d69062a0"
}
],
"argon2id": [