Files
DodoSSH/tests/DodoSSH.Client.Sync.Tests/SyncHarness.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

375 lines
14 KiB
C#

using DodoSSH.Client.Domain;
using DodoSSH.Client.Storage;
using DodoSSH.Crypto;
namespace DodoSSH.Client.Sync.Tests;
/// <summary>
/// One machine: its own cache, its own outbox, its own view of the vault.
/// </summary>
/// <remarks>
/// A separate SQLite database per device, because the whole subject of these tests is two caches
/// diverging and being reconciled. Sharing one would make every conflict test vacuous.
/// </remarks>
internal sealed class SyncDevice : IDisposable
{
private readonly ClientCacheFactory factory;
private readonly LocalCacheProtector protector;
private SyncDevice(
string name,
ClientCacheFactory factory,
LocalCacheProtector protector,
VaultKeyring keyring,
FakeVaultServer server,
SyncOptions options)
{
Name = name;
this.factory = factory;
this.protector = protector;
Keyring = keyring;
Items = new ItemStore(factory, protector);
Outbox = new OutboxStore(factory, protector, TimeProvider.System);
SyncState = new SyncStateStore(factory);
Conflicts = new ConflictStore(factory, protector, TimeProvider.System);
Hosts = new HostRepository(Items, Outbox, keyring);
SshKeys = new SshKeyRepository(Items, Outbox, keyring);
Credentials = new CredentialRepository(Items, Outbox, keyring);
KnownHosts = new KnownHostRepository(Items, Outbox, keyring);
Engine = new SyncEngine(
server, Items, Outbox, SyncState, Conflicts, keyring, TimeProvider.System, options);
}
internal string Name { get; }
internal VaultKeyring Keyring { get; }
internal ItemStore Items { get; }
internal OutboxStore Outbox { get; }
internal SyncStateStore SyncState { get; }
internal ConflictStore Conflicts { get; }
internal HostRepository Hosts { get; }
internal SshKeyRepository SshKeys { get; }
internal CredentialRepository Credentials { get; }
internal KnownHostRepository KnownHosts { get; }
internal SyncEngine Engine { get; }
internal static async Task<SyncDevice> CreateAsync(
string name,
UserSecretBundle bundle,
StoredVault vault,
FakeVaultServer server,
SyncOptions options)
{
var cache = ClientCacheFactory.ForMemory($"sync-{name}-{Guid.CreateVersion7():N}");
try
{
await cache.MigrateAsync(TestContext.Current.CancellationToken);
// 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, LocalCacheProtector.From(bundle), keyring, server, options);
}
catch
{
cache.Dispose();
throw;
}
}
internal Task<SyncReport> SyncAsync() =>
Engine.SyncAsync(SyncHarness.VaultId, TestContext.Current.CancellationToken);
internal Task<ItemListing<HostSecret>> ListAsync() =>
Hosts.ListAsync(SyncHarness.VaultId, TestContext.Current.CancellationToken);
internal async Task<IReadOnlyList<HostSecret>> HostsSortedAsync()
{
var listing = await ListAsync();
return [.. listing.Items.Select(h => h.Secret).OrderBy(h => h.Label, StringComparer.Ordinal)];
}
internal async Task<VaultItem<HostSecret>> FindAsync(Guid entityId)
{
var listing = await ListAsync();
return listing.Items.SingleOrDefault(host => host.EntityId == entityId)
?? throw new InvalidOperationException($"{Name} cannot see host {entityId}.");
}
internal Task<Guid> CreateAsync(HostSecret host) =>
Hosts.CreateAsync(SyncHarness.VaultId, host, TestContext.Current.CancellationToken);
internal Task UpdateAsync(Guid entityId, HostSecret host) =>
Hosts.UpdateAsync(SyncHarness.VaultId, entityId, host, TestContext.Current.CancellationToken);
internal Task DeleteAsync(Guid entityId) =>
Hosts.DeleteAsync(SyncHarness.VaultId, entityId, TestContext.Current.CancellationToken);
// ---- The same four operations, on SSH keys ----
internal Task<ItemListing<SshKeySecret>> ListKeysAsync() =>
SshKeys.ListAsync(SyncHarness.VaultId, TestContext.Current.CancellationToken);
internal async Task<VaultItem<SshKeySecret>> FindKeyAsync(Guid entityId)
{
var listing = await ListKeysAsync();
return listing.Items.SingleOrDefault(key => key.EntityId == entityId)
?? throw new InvalidOperationException($"{Name} cannot see key {entityId}.");
}
internal Task<Guid> CreateKeyAsync(SshKeySecret key) =>
SshKeys.CreateAsync(SyncHarness.VaultId, key, TestContext.Current.CancellationToken);
internal Task UpdateKeyAsync(Guid entityId, SshKeySecret key) =>
SshKeys.UpdateAsync(SyncHarness.VaultId, entityId, key, TestContext.Current.CancellationToken);
internal Task DeleteKeyAsync(Guid entityId) =>
SshKeys.DeleteAsync(SyncHarness.VaultId, entityId, TestContext.Current.CancellationToken);
// ---- And again on credentials ----
internal Task<ItemListing<CredentialSecret>> ListCredentialsAsync() =>
Credentials.ListAsync(SyncHarness.VaultId, TestContext.Current.CancellationToken);
internal async Task<VaultItem<CredentialSecret>> FindCredentialAsync(Guid entityId)
{
var listing = await ListCredentialsAsync();
return listing.Items.SingleOrDefault(credential => credential.EntityId == entityId)
?? throw new InvalidOperationException($"{Name} cannot see credential {entityId}.");
}
internal Task<Guid> CreateCredentialAsync(CredentialSecret credential) =>
Credentials.CreateAsync(SyncHarness.VaultId, credential, TestContext.Current.CancellationToken);
internal Task UpdateCredentialAsync(Guid entityId, CredentialSecret credential) =>
Credentials.UpdateAsync(
SyncHarness.VaultId, entityId, credential, TestContext.Current.CancellationToken);
// ---- And again on known host keys ----
internal Task<ItemListing<KnownHostSecret>> ListKnownHostsAsync() =>
KnownHosts.ListAsync(SyncHarness.VaultId, TestContext.Current.CancellationToken);
internal async Task<VaultItem<KnownHostSecret>> FindKnownHostAsync(Guid entityId)
{
var listing = await ListKnownHostsAsync();
return listing.Items.SingleOrDefault(pin => pin.EntityId == entityId)
?? throw new InvalidOperationException($"{Name} cannot see known host key {entityId}.");
}
internal Task<Guid> CreateKnownHostAsync(KnownHostSecret knownHost) =>
KnownHosts.CreateAsync(SyncHarness.VaultId, knownHost, TestContext.Current.CancellationToken);
internal Task UpdateKnownHostAsync(Guid entityId, KnownHostSecret knownHost) =>
KnownHosts.UpdateAsync(
SyncHarness.VaultId, entityId, knownHost, TestContext.Current.CancellationToken);
internal Task DeleteKnownHostAsync(Guid entityId) =>
KnownHosts.DeleteAsync(SyncHarness.VaultId, entityId, TestContext.Current.CancellationToken);
internal Task<IReadOnlyList<StoredConflict>> ConflictsAsync() =>
Conflicts.ListAsync(SyncHarness.VaultId, false, TestContext.Current.CancellationToken);
/// <inheritdoc />
public void Dispose()
{
Keyring.Dispose();
protector.Dispose();
factory.Dispose();
}
}
/// <summary>
/// One user, one vault, two machines and a server.
/// </summary>
/// <remarks>
/// Both devices share the identity bundle, which is what a single user on a laptop and a desktop
/// actually looks like: one enrolled key pair, one vault grant, two independent local caches. That is
/// also the cheapest realistic setup in which every conflict case can be produced.
/// </remarks>
internal sealed class SyncHarness : IDisposable
{
internal static readonly Argon2Profile CheapProfile =
Argon2Profile.FromStoredParameters(memoryKibibytes: 8 * 1024, passes: 1, parallelism: 1);
private readonly UserSecretBundle bundle;
private SyncHarness(UserSecretBundle bundle, FakeVaultServer server, SyncDevice first, SyncDevice second)
{
this.bundle = bundle;
Server = server;
First = first;
Second = second;
}
internal static Guid VaultId { get; } = Guid.Parse("0192f0c8-7777-7c3d-8e4f-5a6b7c8d9e0f");
internal FakeVaultServer Server { get; }
/// <summary>The laptop.</summary>
internal SyncDevice First { get; }
/// <summary>The desktop.</summary>
internal SyncDevice Second { get; }
internal static async Task<SyncHarness> CreateAsync(SyncOptions? options = null)
{
var effective = options ?? SyncOptions.Default;
var identity = UserSecretBundle.Create(DateTimeOffset.FromUnixTimeSeconds(1_700_000_000));
try
{
var vaultKey = VaultKeys.Create();
var wrapped = VaultKeys.WrapTo(vaultKey, identity.EncryptionPublicKey, VaultId, 1);
// The plaintext key is not retained: each device unwraps the grant itself, as it would after
// an ordinary unlock.
System.Security.Cryptography.CryptographicOperations.ZeroMemory(vaultKey);
var vault = new StoredVault(
VaultId, "Personal", IsPersonal: true, TeamId: null, KeyGeneration: 1,
Permissions: 31, wrapped, RekeyRequired: false);
var server = new FakeVaultServer(VaultId);
var first = await SyncDevice.CreateAsync("laptop", identity, vault, server, effective);
try
{
var second = await SyncDevice.CreateAsync("desktop", identity, vault, server, effective);
return new SyncHarness(identity, server, first, second);
}
catch
{
first.Dispose();
throw;
}
}
catch
{
identity.Dispose();
throw;
}
}
/// <summary>Brings both devices up to date, twice, so the result is a settled state.</summary>
/// <remarks>
/// Twice because one pass per device is not enough for a change made on one to be merged on the
/// other and then pushed back. Asserting on a settled state rather than on an intermediate one is
/// what makes "the two devices converge" a meaningful claim.
/// </remarks>
internal async Task SettleAsync()
{
for (var round = 0; round < 2; round++)
{
await First.SyncAsync();
await Second.SyncAsync();
}
}
/// <inheritdoc />
public void Dispose()
{
First.Dispose();
Second.Dispose();
bundle.Dispose();
}
// ---- Builders ----
internal static HostSecret Host(
string label,
string hostname = "db.internal",
int port = 22,
string? username = "deploy",
string? notes = null,
(string Name, string Value)[]? options = null,
bool relayEnabled = false) =>
new()
{
Label = label,
Hostname = hostname,
Port = port,
Username = username,
Notes = notes,
Options = options is null
? HostOptions.Empty
: HostOptions.Create(options.Select(o => new HostOption(o.Name, o.Value))),
RelayEnabled = relayEnabled,
};
/// <summary>
/// An SSH key whose material is a plausible shape but not a real key.
/// </summary>
/// <remarks>
/// Not a valid Ed25519 key, and deliberately so: nothing in the sync path parses the material, and a
/// real private key checked into a test repository is a real private key on the internet regardless of
/// what it was used for. <c>SshKeySecret.TryValidate</c> only requires the armour, and the tests that
/// need a key SSH.NET can actually load live in <c>DodoSSH.Client.Ssh.Tests</c> where one is generated.
/// </remarks>
/// <summary>A credential for the suites, varying only what a test is about.</summary>
internal static CredentialSecret Credential(
string label,
string password = "hunter2",
string? username = null,
string? notes = null) =>
new() { Label = label, Password = password, Username = username, Notes = notes };
/// <summary>A pinned host key, varying only what a test is about.</summary>
/// <remarks>
/// The fingerprint is a plausible shape rather than a real digest. Nothing in the sync path hashes
/// anything or checks the encoding — <c>SshHostKeyFingerprint</c> does that, one layer down and in its own
/// suite — so a value that reads as one is worth more here than a genuine one.
/// </remarks>
internal static KnownHostSecret KnownHost(
string host = "db.internal",
int port = 22,
string algorithm = "ssh-ed25519",
string fingerprint = "SHA256:AAAAtestfingerprint0123456789abcdefghijklmno") =>
new()
{
Host = host,
Port = port,
Algorithm = algorithm,
Fingerprint = fingerprint,
};
internal static SshKeySecret Key(
string label,
string material = "deploy-key-material",
string? passphrase = null,
string? publicKey = null,
string? notes = null) =>
new()
{
Label = label,
PrivateKeyPem = $"-----BEGIN OPENSSH PRIVATE KEY-----\n{material}\n"
+ "-----END OPENSSH PRIVATE KEY-----\n",
Passphrase = passphrase,
PublicKey = publicKey,
Notes = notes,
};
}