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 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) { 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.keys[vault.VaultId] = key; keyring.generations[vault.VaultId] = 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. /// /// Creating a team vault is the only case: the client generates the key, wraps it to itself and /// sends the wrap, so the plaintext is already here and unwrapping the server's copy back would be /// a round trip to learn something this process just chose. Adopting it also means the new vault is /// usable immediately rather than at the next unlock, which is what somebody who just pressed /// "create" expects. /// public void Adopt(Guid vaultId, byte[] vaultKey, uint keyGeneration) { ObjectDisposedException.ThrowIf(disposed, this); ArgumentNullException.ThrowIfNull(vaultKey); if (keys.TryGetValue(vaultId, out var previous)) { CryptographicOperations.ZeroMemory(previous); } keys[vaultId] = vaultKey; generations[vaultId] = keyGeneration; Unopened = [.. Unopened.Where(id => id != vaultId)]; } /// /// 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); if (vault.WrappedVaultKey is null) { return false; } if (keys.ContainsKey(vault.VaultId) && generations[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. /// public void MarkUnreadable(Guid vaultId) { ObjectDisposedException.ThrowIf(disposed, this); if (!Unopened.Contains(vaultId)) { Unopened = [.. Unopened, vaultId]; } } /// /// Borrows a vault's key. /// /// /// 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. /// public bool TryGet(Guid vaultId, out ReadOnlyMemory vaultKey, out uint keyGeneration) { ObjectDisposedException.ThrowIf(disposed, this); if (keys.TryGetValue(vaultId, out var key)) { vaultKey = key; keyGeneration = generations[vaultId]; return true; } vaultKey = default; keyGeneration = 0; return false; } /// Whether this vault can be read at all. public bool CanRead(Guid vaultId) => !disposed && keys.ContainsKey(vaultId); /// public void Dispose() { if (disposed) { return; } disposed = true; foreach (var key in keys.Values) { CryptographicOperations.ZeroMemory(key); } keys.Clear(); generations.Clear(); } } /// 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; }