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);
}
}