using DodoSSH.Client.Domain; using DodoSSH.Client.Storage; using DodoSSH.Crypto; namespace DodoSSH.Client.Sync.Tests; /// /// One machine: its own cache, its own outbox, its own view of the vault. /// /// /// 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. /// 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, SyncOptions options) { Name = name; this.factory = factory; this.master = master; 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 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); 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]); return new SyncDevice( name, cache, derived, LocalCacheProtector.From(derived), keyring, server, options); } catch { cache.Dispose(); throw; } } internal Task SyncAsync() => Engine.SyncAsync(SyncHarness.VaultId, TestContext.Current.CancellationToken); internal Task> ListAsync() => Hosts.ListAsync(SyncHarness.VaultId, TestContext.Current.CancellationToken); internal async Task> HostsSortedAsync() { var listing = await ListAsync(); return [.. listing.Items.Select(h => h.Secret).OrderBy(h => h.Label, StringComparer.Ordinal)]; } internal async Task> 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 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> ListKeysAsync() => SshKeys.ListAsync(SyncHarness.VaultId, TestContext.Current.CancellationToken); internal async Task> 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 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> ListCredentialsAsync() => Credentials.ListAsync(SyncHarness.VaultId, TestContext.Current.CancellationToken); internal async Task> 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 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> ListKnownHostsAsync() => KnownHosts.ListAsync(SyncHarness.VaultId, TestContext.Current.CancellationToken); internal async Task> 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 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> ConflictsAsync() => Conflicts.ListAsync(SyncHarness.VaultId, false, TestContext.Current.CancellationToken); /// public void Dispose() { Keyring.Dispose(); protector.Dispose(); master.Dispose(); factory.Dispose(); } } /// /// One user, one vault, two machines and a server. /// /// /// 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. /// 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; } /// The laptop. internal SyncDevice First { get; } /// The desktop. internal SyncDevice Second { get; } internal static async Task 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; } } /// Brings both devices up to date, twice, so the result is a settled state. /// /// 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. /// internal async Task SettleAsync() { for (var round = 0; round < 2; round++) { await First.SyncAsync(); await Second.SyncAsync(); } } /// 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, }; /// /// An SSH key whose material is a plausible shape but not a real key. /// /// /// 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. SshKeySecret.TryValidate only requires the armour, and the tests that /// need a key SSH.NET can actually load live in DodoSSH.Client.Ssh.Tests where one is generated. /// /// A credential for the suites, varying only what a test is about. internal static CredentialSecret Credential( string label, string password = "hunter2", string? username = null, string? notes = null) => new() { Label = label, Password = password, Username = username, Notes = notes }; /// A pinned host key, varying only what a test is about. /// /// The fingerprint is a plausible shape rather than a real digest. Nothing in the sync path hashes /// anything or checks the encoding — SshHostKeyFingerprint 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. /// 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, }; }