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