using DodoSSH.Client.Domain; using DodoSSH.Client.Storage; using DodoSSH.Contracts; using DodoSSH.Crypto; namespace DodoSSH.Client.Sync.Tests; /// /// Holding more than one generation of a vault's key at once. /// /// /// A rotation does not re-encrypt what is already stored, so a rotated vault holds items sealed under /// two or three different keys and every read has to choose the one the item names. These are the tests /// that say so: the alternative — one key per vault — reads a rotated vault's whole history as corrupt, /// which is a data-loss bug that looks exactly like a decryption failure. /// public sealed class VaultKeyringTests : IDisposable { private static readonly Guid VaultId = Guid.Parse("0192f0c8-1111-7c3d-8e4f-5a6b7c8d9e0f"); private static readonly Guid HostId = Guid.Parse("0192f0c8-2222-7c3d-8e4f-5a6b7c8d9e0f"); private readonly UserSecretBundle bundle = UserSecretBundle.Create(DateTimeOffset.FromUnixTimeSeconds(1_700_000_000)); /// public void Dispose() => bundle.Dispose(); [Fact] public void AVaultWithNoHistory_HoldsExactlyOneGeneration() { var (vault, _) = Rotated(currentGeneration: 1); using var keyring = VaultKeyring.Open(bundle, [vault]); keyring.GenerationsHeld(VaultId).ShouldBe([1u]); keyring.CanRead(VaultId).ShouldBeTrue(); keyring.Unopened.ShouldBeEmpty(); } [Fact] public void ARotatedVault_OpensEveryGenerationItWasGranted() { var (vault, keys) = Rotated(currentGeneration: 3); using var keyring = VaultKeyring.Open(bundle, [vault]); keyring.GenerationsHeld(VaultId).ShouldBe([1u, 2u, 3u]); foreach (var (generation, key) in keys) { keyring.TryGetAt(VaultId, generation, out var held).ShouldBeTrue(); held.ToArray().ShouldBe(key); } } /// /// Writes go under the newest key, always. Sealing a new item under a superseded one would produce /// an item that nobody who joined after the rotation can read, and the author would have no way to /// tell — their own keyring still holds the old key. /// [Fact] public void TheCurrentGeneration_IsTheNewestOneAndNotTheOldest() { var (vault, keys) = Rotated(currentGeneration: 3); using var keyring = VaultKeyring.Open(bundle, [vault]); keyring.TryGet(VaultId, out var current, out var generation).ShouldBeTrue(); generation.ShouldBe(3u); current.ToArray().ShouldBe(keys[3u]); } /// /// The state a member is left in between somebody rotating a vault and somebody wrapping the new key /// to them. They can still read what was there — their old grants stand — and they must not be able /// to write, because anything they wrote would be sealed under a key the vault has moved past. /// [Fact] public void AMemberAwaitingTheNewKey_ReadsTheHistoryAndCannotWrite() { var (vault, keys) = Rotated(currentGeneration: 2); var awaiting = vault with { WrappedVaultKey = null }; using var keyring = VaultKeyring.Open(bundle, [awaiting]); keyring.CanRead(VaultId).ShouldBeFalse(); keyring.TryGet(VaultId, out _, out _).ShouldBeFalse(); keyring.Unopened.ShouldBe([VaultId]); keyring.TryGetAt(VaultId, 1, out var first).ShouldBeTrue(); first.ToArray().ShouldBe(keys[1u]); } /// /// What the rotating client itself does: it generates the next key, the server accepts it, and the /// keyring takes it without losing the one the vault's existing items are sealed under. /// [Fact] public void AdoptingANewGeneration_KeepsTheOneBeforeIt() { var (vault, keys) = Rotated(currentGeneration: 1); using var keyring = VaultKeyring.Open(bundle, [vault]); var next = VaultKeys.Create(); keyring.Adopt(VaultId, next, keyGeneration: 2); keyring.TryGet(VaultId, out _, out var generation).ShouldBeTrue(); generation.ShouldBe(2u); keyring.GenerationsHeld(VaultId).ShouldBe([1u, 2u]); keyring.TryGetAt(VaultId, 1, out var first).ShouldBeTrue(); first.ToArray().ShouldBe(keys[1u]); } /// /// The whole point, at the layer that pays for it: an item written before a rotation still opens /// after one. Sealed and opened through the real cipher, so the AAD's generation binding is /// exercised rather than assumed. /// [Fact] public void AnItemSealedBeforeARotation_StillOpensAfterIt() { var (vault, _) = Rotated(currentGeneration: 1); using var keyring = VaultKeyring.Open(bundle, [vault]); keyring.TryGet(VaultId, out var vaultKey, out var generation).ShouldBeTrue(); var host = new HostSecret { Label = "web-01", Hostname = "web-01.example", Username = "ops" }; var payload = HostCipher.Seal(host, vaultKey.Span, HostId, generation, itemVersion: 1); keyring.Adopt(VaultId, VaultKeys.Create(), keyGeneration: 2); // Chosen by the payload's own generation, which is what every read path does. keyring.TryGetAt(VaultId, payload.KeyGeneration, out var itemKey).ShouldBeTrue(); HostCipher.TryOpen(payload, itemKey.Span, HostId, itemVersion: 1) .ShouldNotBeNull() .Host.Label.ShouldBe("web-01"); // And the current key does not open it, which is why holding only that one would be a loss. keyring.TryGet(VaultId, out var newest, out _).ShouldBeTrue(); HostCipher.TryOpen(payload, newest.Span, HostId, itemVersion: 1).ShouldBeNull(); } /// /// What another client rotating the vault looks like from here: the key this session holds is /// suddenly the previous generation. It goes on opening what it wrote, and it must stop being the /// one new items are sealed under — an item written under a superseded key is readable to its /// author and to nobody else, with nothing to show that anything went wrong. /// [Fact] public void AVaultRotatedElsewhere_StopsBeingWritableAndStaysReadable() { var (vault, keys) = Rotated(currentGeneration: 1); using var keyring = VaultKeyring.Open(bundle, [vault]); keyring.CanRead(VaultId).ShouldBeTrue(); // What RefreshVaultsAsync does when the server reports a generation this session has no grant // for: the admit fails, and the vault is marked unreadable. keyring.MarkUnreadable(VaultId); keyring.CanRead(VaultId).ShouldBeFalse(); keyring.TryGet(VaultId, out _, out _).ShouldBeFalse(); keyring.TryGetAt(VaultId, 1, out var first).ShouldBeTrue(); first.ToArray().ShouldBe(keys[1u]); } /// /// A wrap that will not open is one unusable grant, not a broken vault. Skipping it leaves the /// generations that did open readable; refusing them all would take the whole vault down over one /// bad row. /// [Fact] public void AnUnopenableHistoricWrap_IsSkippedRatherThanFatal() { var (vault, _) = Rotated(currentGeneration: 2); var corrupted = vault with { PriorKeyWraps = [new VaultKeyWrap(1, new byte[110])], }; using var keyring = VaultKeyring.Open(bundle, [corrupted]); keyring.CanRead(VaultId).ShouldBeTrue(); keyring.GenerationsHeld(VaultId).ShouldBe([2u]); keyring.TryGetAt(VaultId, 1, out _).ShouldBeFalse(); } /// /// A vault at , with a distinct key wrapped for every generation /// up to it. /// private (StoredVault Vault, Dictionary Keys) Rotated(uint currentGeneration) { var keys = new Dictionary(); var prior = new List(); byte[]? current = null; for (var generation = 1u; generation <= currentGeneration; generation++) { var key = VaultKeys.Create(); var wrapped = VaultKeys.WrapTo(key, bundle.EncryptionPublicKey, VaultId, generation); keys[generation] = key; if (generation == currentGeneration) { current = wrapped; } else { prior.Add(new VaultKeyWrap(generation, wrapped)); } } var vault = new StoredVault( VaultId, "Platform secrets", IsPersonal: false, TeamId: Guid.CreateVersion7(), currentGeneration, Permissions: 31, current, RekeyRequired: false, prior); return (vault, keys); } }