Files
jaap-jan 5d447da532 Take a rotated vault's contents onto the new key as well
Rotating a vault re-keyed the vault and not its contents, which was the deal
struck last time: everything already stored stayed sealed under the generation it
was written with, every remaining member kept the older keys, and the guarantee
was narrowed to "nothing written from now on". That left one gap worth closing —
somebody who walked off with the old key could still open old ciphertext they
later got hold of — and the reason it was safe to defer is the reason it was
cheap to add. A vault at mixed generations reads perfectly well, so the pass that
moves items across can stop half way and be run again.

VaultResealer walks the vault and rewrites each item as an ordinary upsert
against the version the server holds. It never decodes the plaintext: an item is
opened and the same bytes are sealed again under a fresh data key, so an item
written by a newer client crosses a rotation untouched rather than being
re-encoded through this build's codec and quietly losing the fields this build
has no concept of. It also means nothing in the pass knows what an item is, which
is why one loop covers every type including the ones added after it. A conflict
is counted and skipped rather than merged — there is nothing to merge, since no
content changes — and the next pass picks the item up at the version the other
client left.

The half that a pass over stored items cannot see is a change queued before the
rotation and pushed after it, which would put a brand-new item into the vault
under the key the person who just left still holds. So the push path re-seals a
stale payload as it dispatches it, writing the revision back to the outbox first
so that a retry sends the same bytes rather than a fresh envelope. Between the
two, nothing reaches the server under a superseded generation at all. Queued
items are therefore deliberately left alone by the pass: rewriting one there
would overwrite the user's unpushed work with the version the server holds, which
is the one thing a re-keying pass must never do.

Removal runs it last, after a sync — a mirror that is behind produces a batch of
conflicts instead of a re-sealed vault — and the status line distinguishes the two
guarantees, because they are not the same: a vault fully re-sealed is closed to
the person who left, and one with items outstanding is closed only to what
happens next.

Six tests, and three mutations run against them: making the re-seal return the
payload unchanged fails five of the six, making the push path skip re-sealing
fails the queued-edit test and only that one, and counting conflicts as applied
fails the write-elsewhere test. One of the six was wrong before it was right — it
modelled a third-party write by re-pushing an existing payload at a bumped
version, which no real client would do, and it took reading the AAD to see that
the test was lying rather than the code.
2026-08-04 10:18:14 +02:00

425 lines
16 KiB
C#

using System.Security.Cryptography;
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 readonly FakeVaultServer server;
private readonly SyncOptions options;
private SyncDevice(
string name,
ClientCacheFactory factory,
LocalCacheProtector protector,
VaultKeyring keyring,
FakeVaultServer server,
SyncOptions options)
{
Name = name;
this.factory = factory;
this.protector = protector;
this.server = server;
this.options = options;
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);
/// <summary>Moves everything this machine can see onto the vault's current key.</summary>
/// <remarks>
/// Built per call rather than held, as the engine is: it carries no state between passes, and one
/// per call is what the session does.
/// </remarks>
internal Task<ResealReport> ResealAsync() =>
new VaultResealer(server, Items, Outbox, Keyring, TimeProvider.System, options)
.ResealAsync(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>
/// Rotates the vault: a new key, taken by both machines, and a server that says so.
/// </summary>
/// <returns>The key the vault has just moved off, so a test can prove it no longer opens anything.</returns>
/// <remarks>
/// <para>
/// Stands in for the server call the real rotation makes. What matters here is the state it leaves —
/// a vault whose current generation is one past everything stored in it — and the grant round trip
/// that produces that state is <c>DodoSSH.Api.Tests</c>'s subject, not this suite's.
/// </para>
/// <para>
/// Each keyring gets its own copy of the bytes, because a keyring owns what it is handed and zeroes
/// it on disposal; sharing one array would leave the second machine holding a zeroed key at the end
/// of a test and produce failures that look like a decryption bug.
/// </para>
/// </remarks>
internal byte[] Rotate()
{
var generation = Server.KeyGeneration + 1;
First.Keyring.TryGetAt(VaultId, Server.KeyGeneration, out var previous).ShouldBeTrue();
var superseded = previous.ToArray();
var key = VaultKeys.Create();
First.Keyring.Adopt(VaultId, [.. key], generation);
Second.Keyring.Adopt(VaultId, [.. key], generation);
CryptographicOperations.ZeroMemory(key);
Server.KeyGeneration = generation;
return superseded;
}
/// <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,
};
}