using System.Diagnostics.CodeAnalysis; using System.Security.Cryptography; using DodoSSH.Client.Storage; using DodoSSH.Crypto; namespace DodoSSH.Client.Sync; /// /// The vault keys held for the duration of an unlocked session. /// /// /// /// One place that holds plaintext vault keys, so there is one place that clears them. Every store and /// every cipher call borrows a key from here rather than keeping a copy, which is what makes /// "the keys exist only while unlocked" a property of the code and not of everyone's discipline. /// /// /// A vault has a key per generation, and this holds every one it was granted. A rotation does not /// re-encrypt what is already stored — each item keeps the generation it was sealed under — so reading a /// rotated vault means opening items under two or three different keys, chosen per item rather than per /// vault. Writing uses the newest, which is what answers; reading an item asks for /// the generation that item names, which is . /// /// /// A grant that will not open is not an error: it means the vault has been rekeyed and this client's /// grant has not been re-wrapped yet, or the grant was fabricated. Both leave the vault temporarily /// unreadable and both are reported rather than thrown, so one bad grant does not take the other vaults /// down with it. /// /// public sealed class VaultKeyring : IDisposable { private readonly Dictionary> keys = []; private readonly Dictionary generations = []; private bool disposed; private VaultKeyring() { } /// Vaults whose grant could not be opened, and which are therefore unreadable. public IReadOnlyList Unopened { get; private set; } = []; /// /// Opens every grant the bundle can. /// /// The unlocked identity keys. /// The cached vault list, each with its wrapped key. public static VaultKeyring Open(UserSecretBundle bundle, IReadOnlyList vaults) { ArgumentNullException.ThrowIfNull(bundle); ArgumentNullException.ThrowIfNull(vaults); var keyring = new VaultKeyring(); var unopened = new List(); try { foreach (var vault in vaults) { // The history first, and never conditional on the current generation opening. A member // who has been rotated past but not yet re-wrapped can still read everything written // before the rotation, and dropping those keys because the newest grant is missing // would turn "you cannot see the last hour's changes" into "the vault is empty". keyring.OpenPriorWraps(bundle, vault); if (vault.WrappedVaultKey is null) { // The server said so itself: a grant awaiting re-wrap after a rekey. unopened.Add(vault.VaultId); continue; } var key = VaultKeys.TryUnwrap( bundle.EncryptionKey, vault.WrappedVaultKey, vault.VaultId, vault.KeyGeneration); if (key is null) { unopened.Add(vault.VaultId); continue; } keyring.Adopt(vault.VaultId, key, vault.KeyGeneration); } keyring.Unopened = unopened; return keyring; } catch { keyring.Dispose(); throw; } } /// /// Takes a vault key this session has just generated. /// /// The vault. /// /// The plaintext key. The keyring takes ownership and zeroes it on disposal; the caller must /// not keep a reference or zero it itself. /// /// The generation this key is for. /// /// /// Two cases, and they are the same operation: creating a team vault, and rotating one. Both /// generate the key here, wrap it to this user and send the wrap, so the plaintext is already in /// this process and unwrapping the server's copy back would be a round trip to learn something it /// just chose. Adopting it also means the vault is usable immediately rather than at the next /// unlock, which is what somebody who has just pressed a button expects. /// /// /// This generation becomes the one writes are sealed under. A key for a generation the vault has /// moved past goes in through instead, which is not the same /// operation: it makes old items readable and must not walk the write target backwards. /// /// public void Adopt(Guid vaultId, byte[] vaultKey, uint keyGeneration) { ObjectDisposedException.ThrowIf(disposed, this); ArgumentNullException.ThrowIfNull(vaultKey); Store(vaultId, vaultKey, keyGeneration); Promote(vaultId, keyGeneration); } /// /// Takes a vault key for a generation the vault has already moved past. /// /// The vault. /// /// The plaintext key. The keyring takes ownership, exactly as does. /// /// The superseded generation this key opens. /// /// Holding one of these is what lets a rotated vault be read at all: items are not re-encrypted by a /// rotation, so everything written before it is still sealed under the key it was written with. /// Nothing is ever written under one, which is why this does not touch the current /// generation and does not make an otherwise unreadable vault readable. /// public void AdoptPrior(Guid vaultId, byte[] vaultKey, uint keyGeneration) { ObjectDisposedException.ThrowIf(disposed, this); ArgumentNullException.ThrowIfNull(vaultKey); Store(vaultId, vaultKey, keyGeneration); } /// /// Tries to open a vault that has become readable since the session was unlocked. /// /// Whether the grant opened. /// /// What a share looks like from the receiving end: the vault was in the list all along, listed and /// unreadable, and a member holding Share has now wrapped its key. Re-opening it here rather than /// waiting for a relock is the difference between "someone shared a vault with you" arriving and /// arriving tomorrow. /// public bool TryAdmit(UserSecretBundle bundle, StoredVault vault) { ObjectDisposedException.ThrowIf(disposed, this); ArgumentNullException.ThrowIfNull(bundle); ArgumentNullException.ThrowIfNull(vault); // Attempted whatever happens to the current generation, and before it. A share of a vault that // has been rotated since it was created arrives as a current wrap plus its history, and the // history is not a consolation prize — without it the recipient sees a vault full of items that // will not decrypt. OpenPriorWraps(bundle, vault); if (vault.WrappedVaultKey is null) { return false; } if (Held(vault.VaultId, vault.KeyGeneration) is not null) { // Already open at this generation. Promoted rather than returned early, because a vault // that was rotated and re-granted arrives here with a generation this keyring has been // treating as historic, and it is now the one writes belong under. Promote(vault.VaultId, vault.KeyGeneration); return true; } var key = VaultKeys.TryUnwrap( bundle.EncryptionKey, vault.WrappedVaultKey, vault.VaultId, vault.KeyGeneration); if (key is null) { return false; } Adopt(vault.VaultId, key, vault.KeyGeneration); return true; } /// Records that a vault cannot be read, so the interface can say so. /// /// /// The counterpart of for the case where the grant did not open. Kept /// explicit rather than inferred from the absence of a key, because "no key" is also what a vault /// this session has never heard of looks like. /// /// /// It also gives up the write target, and that is the load-bearing half. The usual way to /// reach here is another client having rotated the vault: this session still holds the previous /// generation's key and it is no longer the current one. Going on treating it as current would seal /// new items under a superseded key — readable here, unreadable to everybody else, and with nothing /// to show the author that anything was wrong. The keys themselves are kept, because the items /// already written under them are still readable through . /// /// public void MarkUnreadable(Guid vaultId) { ObjectDisposedException.ThrowIf(disposed, this); generations.Remove(vaultId); if (!Unopened.Contains(vaultId)) { Unopened = [.. Unopened, vaultId]; } } /// /// Borrows a vault's current key: the one new items are sealed under. /// /// /// The returned memory is the keyring's own buffer, not a copy, and is zeroed when the keyring is /// disposed. Callers must not retain it past the operation they borrowed it for. /// /// False for a vault this session holds only the history of — one rotated past a grant that has not /// been re-wrapped yet. That is deliberate: writing under a superseded key would produce an item /// nobody else could read, and the honest answer is that the vault is not writable until the new /// key arrives. /// /// public bool TryGet(Guid vaultId, out ReadOnlyMemory vaultKey, out uint keyGeneration) { ObjectDisposedException.ThrowIf(disposed, this); if (generations.TryGetValue(vaultId, out var current) && Held(vaultId, current) is { } key) { vaultKey = key; keyGeneration = current; return true; } vaultKey = default; keyGeneration = 0; return false; } /// /// Borrows the key one particular generation of a vault was sealed under. /// /// The vault. /// The generation the item names. /// The key, borrowed on the same terms as . /// Whether this session holds that generation. /// /// What every read goes through, because an item names the generation it was sealed under and a /// rotated vault holds items from more than one. False means that item is unreadable here and says /// nothing about the rest of the vault — which is why a caller counts it rather than failing. /// public bool TryGetAt(Guid vaultId, uint keyGeneration, out ReadOnlyMemory vaultKey) { ObjectDisposedException.ThrowIf(disposed, this); if (Held(vaultId, keyGeneration) is { } key) { vaultKey = key; return true; } vaultKey = default; return false; } /// /// Every generation of one vault's key that this session holds, oldest first. /// /// /// Read when sharing: a recipient given only the newest key would find the vault's history /// undecryptable, so the sharing client wraps each of these in turn. It is the only party that can /// — the server holds ciphertext, and the recipient holds nothing yet. /// public IReadOnlyList GenerationsHeld(Guid vaultId) { ObjectDisposedException.ThrowIf(disposed, this); return keys.TryGetValue(vaultId, out var held) ? [.. held.Keys.Order()] : []; } /// Whether this vault can be read and written at its current generation. public bool CanRead(Guid vaultId) => !disposed && generations.TryGetValue(vaultId, out var current) && Held(vaultId, current) is not null; /// public void Dispose() { if (disposed) { return; } disposed = true; foreach (var held in keys.Values) { foreach (var key in held.Values) { CryptographicOperations.ZeroMemory(key); } } keys.Clear(); generations.Clear(); } /// Opens whatever superseded generations this vault came with. /// /// A wrap that will not open is skipped rather than reported. It means one historic grant is /// unusable — the items under that generation stay unreadable and are counted as such where they /// are listed — and it is not a reason to refuse the generations that did open. /// private void OpenPriorWraps(UserSecretBundle bundle, StoredVault vault) { foreach (var wrap in vault.PriorKeyWraps ?? []) { if (Held(vault.VaultId, wrap.KeyGeneration) is not null) { continue; } var key = VaultKeys.TryUnwrap( bundle.EncryptionKey, wrap.WrappedKey, vault.VaultId, wrap.KeyGeneration); if (key is not null) { AdoptPrior(vault.VaultId, key, wrap.KeyGeneration); } } } private byte[]? Held(Guid vaultId, uint keyGeneration) => keys.TryGetValue(vaultId, out var held) && held.TryGetValue(keyGeneration, out var key) ? key : null; private void Store(Guid vaultId, byte[] vaultKey, uint keyGeneration) { if (!keys.TryGetValue(vaultId, out var held)) { held = []; keys[vaultId] = held; } if (held.TryGetValue(keyGeneration, out var previous)) { CryptographicOperations.ZeroMemory(previous); } held[keyGeneration] = vaultKey; } private void Promote(Guid vaultId, uint keyGeneration) { generations[vaultId] = keyGeneration; Unopened = [.. Unopened.Where(id => id != vaultId)]; } } /// Thrown when an operation needs a vault key the keyring does not hold. /// /// An exception rather than a silent no-op, because every caller that reaches this point has already /// been given the chance to check . Continuing without the key would /// mean writing an item nobody can open. /// [SuppressMessage( "Design", "CA1032:Implement standard exception constructors", Justification = "The vault id is required context; a message-only constructor would lose it.")] public sealed class VaultUnreadableException(Guid vaultId) : InvalidOperationException( $"Vault {vaultId} has no usable key. Its grant is missing or awaiting re-wrap after a rekey.") { /// The vault that cannot be read. public Guid VaultId { get; } = vaultId; }