Files
DodoSSH/tests/DodoSSH.Client.Storage.Tests/CacheStoreTests.cs
T
jaap-jan 7016ce36f1 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.
2026-07-30 12:46:55 +02:00

406 lines
16 KiB
C#

using DodoSSH.Contracts;
using DodoSSH.Crypto;
using Microsoft.EntityFrameworkCore;
using static DodoSSH.Client.Storage.Tests.CacheHarness;
namespace DodoSSH.Client.Storage.Tests;
/// <summary>
/// The item mirror, the vault list, the sync cursor, and the conflict log.
/// </summary>
public sealed class CacheStoreTests : IAsyncLifetime
{
private CacheHarness harness = null!;
/// <inheritdoc />
public async ValueTask InitializeAsync() => harness = await CreateAsync();
/// <inheritdoc />
public ValueTask DisposeAsync()
{
harness.Dispose();
return ValueTask.CompletedTask;
}
// ---- Items ----
[Fact]
public async Task AnItem_RoundTripsItsCiphertextByteForByte()
{
// Not "equivalent" — identical. The AAD binds the row, so re-encrypting locally would work but
// would throw away the ability to notice the server handing back bytes it should not have.
var entityId = Guid.CreateVersion7();
var item = Item(entityId, version: 4, changeSequence: 17, seed: 3);
await harness.Items.SaveAsync(item, Token);
var read = await harness.Items.FindAsync(VaultId, SyncEntityType.Host, entityId, Token);
read.ShouldNotBeNull();
read.Version.ShouldBe(4);
read.ChangeSequence.ShouldBe(17);
read.Payload.ShouldNotBeNull();
read.Payload.Envelope.ShouldBe(item.Payload!.Envelope);
read.Payload.WrappedDataKey.ShouldBe(item.Payload.WrappedDataKey);
read.Payload.DataKeyId.ShouldBe(item.Payload.DataKeyId);
read.Payload.KeyGeneration.ShouldBe(item.Payload.KeyGeneration);
read.Payload.AadVersion.ShouldBe(item.Payload.AadVersion);
}
[Fact]
public async Task ThePlaintextFields_RoundTripAndAreNotReadableInTheDatabase()
{
// The server has to hold a relay-enabled host's address in the clear because it resolves it.
// This machine already holds the key that opens the payload, so leaving the address readable in
// a file that ends up in a backup buys nothing.
var entityId = Guid.CreateVersion7();
var fields = new SyncPlaintextFields(
RelayEnabled: true, Hostname: "bastion.internal", Port: 2222);
await harness.Items.SaveAsync(Item(entityId, fields: fields), Token);
var read = await harness.Items.FindAsync(VaultId, SyncEntityType.Host, entityId, Token);
read!.Fields.ShouldBe(fields);
var stored = await ReadRawFieldsAsync(entityId);
stored.ShouldNotBeNull();
System.Text.Encoding.UTF8.GetString(stored)
.Contains("bastion.internal", StringComparison.Ordinal)
.ShouldBeFalse("the hostname is stored in the clear");
}
[Fact]
public async Task ACacheRecord_CannotBeMovedToAnotherRow()
{
// Why the record is bound to its own row rather than only to the user. Swapping two rows would
// otherwise point one host's connection at another host's address.
var mine = Guid.CreateVersion7();
var other = Guid.CreateVersion7();
var sealedFields = harness.Protector.Protect(
CryptoSpec.AadResourceType.Host,
mine,
PlaintextFieldsCodec.Encode(new SyncPlaintextFields(true, "mine.internal", 22)));
harness.Protector
.TryUnprotect(CryptoSpec.AadResourceType.Host, other, sealedFields)
.ShouldBeNull();
harness.Protector
.TryUnprotect(CryptoSpec.AadResourceType.Credential, mine, sealedFields)
.ShouldBeNull();
harness.Protector
.TryUnprotect(CryptoSpec.AadResourceType.Host, mine, sealedFields)
.ShouldNotBeNull();
}
[Fact]
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();
var sealedFields = stranger.Protector.Protect(
CryptoSpec.AadResourceType.Host, entityId, [1, 2, 3]);
harness.Protector
.TryUnprotect(CryptoSpec.AadResourceType.Host, entityId, sealedFields)
.ShouldBeNull();
}
[Fact]
public async Task SavingTwice_ReplacesRatherThanDuplicates()
{
var entityId = Guid.CreateVersion7();
await harness.Items.SaveAsync(Item(entityId, version: 1, seed: 1), Token);
await harness.Items.SaveAsync(Item(entityId, version: 2, seed: 9), Token);
var items = await harness.Items.ListAsync(VaultId, SyncEntityType.Host, false, Token);
items.ShouldHaveSingleItem().Version.ShouldBe(2);
}
[Fact]
public async Task ATombstone_IsHiddenFromTheListButStillFindable()
{
// The interface must not show a deleted host. The sync engine must still be able to tell a
// deleted item from one it has never seen — a row that simply vanished is indistinguishable
// from the latter, and would silently reappear.
var entityId = Guid.CreateVersion7();
await harness.Items.SaveAsync(Item(entityId, version: 1), Token);
await harness.Items.SaveAsync(Item(entityId, version: 2, deleted: true), Token);
(await harness.Items.ListAsync(VaultId, SyncEntityType.Host, false, Token)).ShouldBeEmpty();
var withDeleted = await harness.Items.ListAsync(VaultId, SyncEntityType.Host, true, Token);
withDeleted.ShouldHaveSingleItem().IsDeleted.ShouldBeTrue();
var found = await harness.Items.FindAsync(VaultId, SyncEntityType.Host, entityId, Token);
found.ShouldNotBeNull();
found.IsDeleted.ShouldBeTrue();
found.Payload.ShouldBeNull();
}
[Fact]
public async Task CollectingTombstones_LeavesLiveItemsAlone()
{
var live = Guid.CreateVersion7();
var dead = Guid.CreateVersion7();
await harness.Items.SaveAsync(Item(live, changeSequence: 1), Token);
await harness.Items.SaveAsync(Item(dead, changeSequence: 2, deleted: true), Token);
var cutoff = DateTimeOffset.FromUnixTimeSeconds(1_750_000_100);
var collected = await harness.Items.CollectTombstonesAsync(VaultId, cutoff, Token);
collected.ShouldBe(1);
var remaining = await harness.Items.ListAsync(VaultId, SyncEntityType.Host, true, Token);
remaining.ShouldHaveSingleItem().EntityId.ShouldBe(live);
}
// ---- Vaults ----
[Fact]
public async Task ReplacingTheVaultList_AddsUpdatesAndRemoves()
{
var keep = Guid.CreateVersion7();
var drop = Guid.CreateVersion7();
await harness.Vaults.ReplaceAllAsync(
[Vault(keep, "Personal", 1), Vault(drop, "Old team", 1)], Token);
await harness.Vaults.ReplaceAllAsync([Vault(keep, "Renamed", 2)], Token);
var vaults = await harness.Vaults.ListAsync(Token);
var only = vaults.ShouldHaveSingleItem();
only.VaultId.ShouldBe(keep);
only.Name.ShouldBe("Renamed");
only.KeyGeneration.ShouldBe(2u);
(await harness.Vaults.FindAsync(drop, Token)).ShouldBeNull();
}
[Fact]
public async Task AVaultsWrappedKey_IsCachedSoAnOfflineLaunchCanDecrypt()
{
// Without this, an offline start could unlock the identity bundle and still not open a single
// item.
var vaultId = Guid.CreateVersion7();
byte[] wrapped = [9, 9, 9, 9];
await harness.Vaults.ReplaceAllAsync(
[Vault(vaultId, "Personal", 1) with { WrappedVaultKey = wrapped }], Token);
var read = await harness.Vaults.FindAsync(vaultId, Token);
read!.WrappedVaultKey.ShouldBe(wrapped);
}
// ---- Unlock material ----
[Fact]
public async Task TheUnlockMaterial_SurvivesTheContextThatWroteIt()
{
// The offline unlock story, asserted rather than assumed: a fresh store over the same database
// reads back the salt and the wrapped bundle with no network involved.
var material = Material();
await harness.Unlock.SaveAsync(material, Token);
var reader = new UnlockStore(harness.Factory, TimeProvider.System);
var read = await reader.ReadAsync(Token);
read.ShouldNotBeNull();
read.UserId.ShouldBe(material.UserId);
read.WrappedPrivateKey.ShouldBe(material.WrappedPrivateKey);
read.KdfParameters.Salt.ShouldBe(material.KdfParameters.Salt);
read.KdfParameters.MemoryKibibytes.ShouldBe(material.KdfParameters.MemoryKibibytes);
read.KdfParameters.Passes.ShouldBe(material.KdfParameters.Passes);
}
[Fact]
public async Task SavingTheUnlockMaterialTwice_UpdatesTheSingleRow()
{
await harness.Unlock.SaveAsync(Material(), Token);
await harness.Unlock.SaveAsync(Material() with { Email = "changed@example.com" }, Token);
var read = await harness.Unlock.ReadAsync(Token);
read!.Email.ShouldBe("changed@example.com");
(await CountUnlockRowsAsync()).ShouldBe(1);
}
[Fact]
public async Task AnotherUsersMaterial_IsRefusedRatherThanMixedIn()
{
// Adopting it would offer an unlock prompt whose passphrase can never work, and would mix one
// user's items into another's vault list.
await harness.Unlock.SaveAsync(Material(), Token);
var other = Material() with { UserId = Guid.CreateVersion7() };
await Should.ThrowAsync<CacheIdentityMismatchException>(
async () => await harness.Unlock.SaveAsync(other, Token));
}
// ---- Sync state ----
[Fact]
public async Task AnUnknownVault_ReadsAsStartingFromTheBeginning()
{
// Not an error. A null cursor is exactly right for a vault this client has not synced, and is
// also the recovery path for a cache that had to be discarded.
var state = await harness.SyncState.ReadAsync(Guid.CreateVersion7(), Token);
state.Cursor.ShouldBeNull();
state.KeyGeneration.ShouldBe(0u);
}
[Fact]
public async Task TheCursor_IsStoredVerbatim()
{
// Opaque and integrity-tagged. A client that adjusted one could ask to resume from a position
// the server never granted; storing it untouched is the only correct handling.
const string Cursor = "v1.aGVsbG8gd29ybGQ.c2lnbmF0dXJl";
await harness.SyncState.SaveAsync(new StoredSyncState(VaultId, Cursor, 3), Token);
var read = await harness.SyncState.ReadAsync(VaultId, Token);
read.Cursor.ShouldBe(Cursor);
read.KeyGeneration.ShouldBe(3u);
}
[Fact]
public async Task ResettingAVault_DropsItsItemsButKeepsTheOutbox()
{
// Local unpushed changes are the only copy of the user's work. Clearing them along with the
// cache would turn a recoverable cache problem into lost work.
var entityId = Guid.CreateVersion7();
await harness.Items.SaveAsync(Item(entityId), Token);
await harness.Outbox.QueueAsync(Change(Guid.CreateVersion7()), Token);
await harness.SyncState.SaveAsync(new StoredSyncState(VaultId, "cursor", 1), Token);
await harness.SyncState.ResetAsync(VaultId, Token);
(await harness.Items.ListAsync(VaultId, SyncEntityType.Host, true, Token)).ShouldBeEmpty();
(await harness.SyncState.ReadAsync(VaultId, Token)).Cursor.ShouldBeNull();
(await harness.Outbox.TakeAsync(VaultId, 10, Token)).ShouldHaveSingleItem();
}
// ---- Conflicts ----
[Fact]
public async Task AConflictDetail_RoundTripsAndIsSealedAtRest()
{
// This is the one place the cache holds decrypted vault content on purpose: the value a merge
// displaced. It has to be readable to be useful, and it is as sensitive as the item it came
// from.
var entityId = Guid.CreateVersion7();
var detail = System.Text.Encoding.UTF8.GetBytes("""{"field":"Notes","discarded":"my secret"}""");
var id = await harness.Conflicts.RecordAsync(
VaultId, SyncEntityType.Host, entityId, ConflictKind.FieldOverridden, detail, Token);
var listed = (await harness.Conflicts.ListAsync(VaultId, false, Token)).ShouldHaveSingleItem();
listed.Id.ShouldBe(id);
listed.Kind.ShouldBe(ConflictKind.FieldOverridden);
listed.Detail.ShouldBe(detail);
var raw = await ReadRawConflictDetailAsync(id);
System.Text.Encoding.UTF8.GetString(raw!)
.Contains("my secret", StringComparison.Ordinal)
.ShouldBeFalse("the discarded value is stored in the clear");
}
[Fact]
public async Task AnAcknowledgedConflict_LeavesTheListButKeepsTheValue()
{
// Someone who dismisses a warning and realises a minute later that they wanted the other value
// should still be able to get it.
var id = await harness.Conflicts.RecordAsync(
VaultId, SyncEntityType.Host, Guid.CreateVersion7(), ConflictKind.FieldOverridden,
new byte[] { 1, 2, 3 }, Token);
(await harness.Conflicts.AcknowledgeAsync(id, Token)).ShouldBeTrue();
(await harness.Conflicts.ListAsync(VaultId, false, Token)).ShouldBeEmpty();
var all = (await harness.Conflicts.ListAsync(VaultId, true, Token)).ShouldHaveSingleItem();
all.Detail.ShouldBe(new byte[] { 1, 2, 3 });
}
[Fact]
public async Task AnUnacknowledgedConflict_CannotBeDiscarded()
{
var id = await harness.Conflicts.RecordAsync(
VaultId, SyncEntityType.Host, Guid.CreateVersion7(), ConflictKind.Undecryptable,
new byte[] { 1 }, Token);
(await harness.Conflicts.DiscardAsync(id, Token)).ShouldBeFalse();
await harness.Conflicts.AcknowledgeAsync(id, Token);
(await harness.Conflicts.DiscardAsync(id, Token)).ShouldBeTrue();
}
// ---- Helpers ----
private static CancellationToken Token => TestContext.Current.CancellationToken;
private static StoredVault Vault(Guid vaultId, string name, uint keyGeneration) =>
new(vaultId, name, IsPersonal: true, TeamId: null, keyGeneration, Permissions: 31,
WrappedVaultKey: [1, 2, 3], RekeyRequired: false);
private static StoredUnlockMaterial Material() =>
new(
"https://dodossh.example",
UserId,
"https://idp.example",
"alice",
"alice@example.com",
"Alice",
KeyGeneration: 1,
WrappedPrivateKey: [4, 5, 6, 7],
new KdfParameters("argon2id", [8, 9, 10, 11], 262144, 4, 1),
DateTimeOffset.FromUnixTimeSeconds(1_750_000_000));
private async Task<byte[]?> ReadRawFieldsAsync(Guid entityId)
{
var context = harness.Factory.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
return await context.Set<CachedItemRow>()
.Where(row => row.EntityId == entityId)
.Select(row => row.ProtectedFields)
.SingleAsync(Token);
}
private async Task<byte[]?> ReadRawConflictDetailAsync(Guid id)
{
var context = harness.Factory.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
return await context.Set<ConflictRow>()
.Where(row => row.Id == id)
.Select(row => row.Detail)
.SingleAsync(Token);
}
private async Task<int> CountUnlockRowsAsync()
{
var context = harness.Factory.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
return await context.Set<UnlockMaterialRow>().CountAsync(Token);
}
}