Public Access
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.
406 lines
16 KiB
C#
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);
|
|
}
|
|
}
|