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