using System.Security.Cryptography;
using DodoSSH.Client.Api;
using DodoSSH.Client.Storage;
using DodoSSH.Client.Sync;
using DodoSSH.Contracts;
using DodoSSH.Crypto;
namespace DodoSSH.Client.Session;
/// What a share attempt did.
/// Whether a grant was recorded.
///
/// How the recipient's key was checked. Present whether or not the share went ahead, because a refusal
/// is the interesting outcome and the reason for it is the whole of what a user needs to see.
///
/// One line for a person. Never contains key material.
///
/// How many generations of the vault key were wrapped. One for a vault that has never been rotated;
/// more for one that has, because its older items are still sealed under the keys they were written
/// with and a recipient given only the newest would find them unreadable.
///
public sealed record ShareOutcome(
bool Shared,
RecipientVerification Verification,
string Message,
int Generations = 0);
/// What sharing or rotating one vault did, named so a message can say which vault.
/// The vault.
/// Its display name.
/// What happened, when the attempt was made.
///
/// Why it was not, when it failed. Carried rather than thrown for the reason a per-vault sync report
/// carries its own: one unreachable vault must not stop the others, and a vault that silently did not
/// get the key is the outcome this whole design exists to make visible.
///
public sealed record VaultShareReport(
Guid VaultId,
string Name,
ShareOutcome? Outcome,
Exception? Failure)
{
/// Whether a grant was recorded for this vault.
public bool Succeeded => Outcome is { Shared: true };
}
/// What rotating one vault did.
/// The vault.
/// Its display name.
/// The generation it now holds, or zero if it was not rotated.
/// The members the new key was wrapped to.
///
/// The members it was not, with the reason. A rotation that re-wrapped to nobody has locked the
/// remaining members out of everything written from now on, which they must be told rather than left
/// to discover.
///
/// Why the rotation itself did not happen, when it did not.
///
/// What moving the vault's stored items onto the new key achieved, or null when the rotation did not
/// get that far. A rotation without this has re-keyed the vault and not its contents, which is a
/// different guarantee — see .
///
public sealed record VaultRekeyReport(
Guid VaultId,
string Name,
uint KeyGeneration,
IReadOnlyList Shared,
IReadOnlyList<(Guid UserId, string Reason)> NotShared,
Exception? Failure,
ResealReport? Reseal = null)
{
/// Whether the vault moved to a new key.
public bool Rotated => Failure is null && KeyGeneration > 0;
/// Whether everything in the vault is now sealed under that new key.
public bool Sealed => Reseal is { Complete: true };
}
///
/// Sharing, from the side that holds the keys.
///
///
///
/// These live on rather than in a service above it for the reason
/// registering a device does: wrapping a vault key is the one step only an unlocked session can
/// perform, and this type is the keyring's custodian. Everything else — the calls, the directory —
/// arrives as a parameter, so the session still knows nothing about how either is implemented.
///
///
/// Nothing here trusts the server's answer about somebody else's key. Every share reads the
/// whole key log, verifies its hash chain, and refuses unless the directory's answer appears in it
/// unchanged. That check is the difference between end-to-end encryption and a server that can read
/// everything by handing out a key of its own; see and ADR 0001.
///
///
public sealed partial class VaultSession
{
///
/// Creates a vault owned by a team, generating its key here.
///
/// The team calls.
/// The owning team.
/// Display name. Plaintext, as all vault names are.
/// Cancellation token.
/// The new vault, already readable by this session.
///
/// The key never leaves this process in the clear: it is generated here, sealed to this user's own
/// encryption key, and the seal is what the server stores. The creator's grant carries no key log
/// head, exactly as a personal vault's does not — there is no third party whose key could have been
/// substituted when you wrap something to yourself.
///
public async Task CreateTeamVaultAsync(
ITeamApi api,
Guid teamId,
string name,
CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(disposed, this);
ArgumentNullException.ThrowIfNull(api);
ArgumentException.ThrowIfNullOrWhiteSpace(name);
var vaultId = Guid.CreateVersion7();
var vaultKey = VaultKeys.Create();
var now = clock.GetUtcNow();
try
{
var request = BuildCreateRequest(vaultId, vaultKey, name, now);
var summary = await api.CreateTeamVaultAsync(teamId, request, cancellationToken)
.ConfigureAwait(false);
var stored = ToStored(summary);
await Vault.UpsertAsync(stored, cancellationToken).ConfigureAwait(false);
// Adopted rather than unwrapped from the response: this process generated the key, so
// unwrapping the server's copy of our own seal would be a round trip to learn something we
// already know. The keyring takes ownership from here.
keyring.Adopt(vaultId, vaultKey, summary.KeyGeneration);
Vaults = await Vault.ListAsync(cancellationToken).ConfigureAwait(false);
return stored;
}
catch
{
// Never reached the keyring, so this is the only thing that can release it.
CryptographicOperations.ZeroMemory(vaultKey);
throw;
}
}
///
/// Renames a vault, here and on the server.
///
/// The vault calls.
/// The vault to rename.
/// What to call it. Plaintext, as all vault names are.
/// Cancellation token.
/// The vault as this machine now holds it.
///
///
/// Nothing is re-encrypted. A vault's name is the one thing about it the server stores in the clear —
/// a person has to be able to choose a vault before anything is decrypted — so a rename is a plain
/// column write at both ends and touches no key.
///
///
/// The cached row is edited rather than replaced with the response. The server answers with a
/// summary written for a caller who is not this one: no wrapped key and no permissions, because it
/// has nothing to say about either that this session does not already hold. Replacing the cached row
/// with it would take this machine's own grant away and leave the vault unreadable until the next
/// refresh.
///
///
public async Task RenameVaultAsync(
IVaultGrantApi api,
Guid vaultId,
string name,
CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(disposed, this);
ArgumentNullException.ThrowIfNull(api);
ArgumentException.ThrowIfNullOrWhiteSpace(name);
var summary = await api
.RenameVaultAsync(vaultId, new UpdateVaultRequest(name), cancellationToken)
.ConfigureAwait(false);
var stored = Vaults.FirstOrDefault(vault => vault.VaultId == vaultId) is { } known
? known with { Name = summary.Name }
: ToStored(summary);
await Vault.UpsertAsync(stored, cancellationToken).ConfigureAwait(false);
Vaults = await Vault.ListAsync(cancellationToken).ConfigureAwait(false);
return stored;
}
///
/// Deletes a vault, here and on the server.
///
/// The vault calls.
/// The vault to delete.
/// Cancellation token.
/// Whether there was a vault to delete.
///
///
/// The server first, and this machine only if it agreed. The other order would take a vault off this
/// screen and leave it on everybody else's, which is the one outcome worse than the deletion failing:
/// the person who pressed it is then the only one who believes it is gone.
///
///
/// The key is dropped from the keyring, and the items are not. Dropping the key is what makes
/// this machine unable to read what is left, which is the honest end state — the ciphertext is still
/// in this cache, as it is in every other member's, and pretending otherwise by deleting the rows
/// would be claiming a reach this product does not have. See ADR 0001. The rows go with the next
/// wipe of the cache; nothing reads them meanwhile, because every list is built from the vaults the
/// keyring can open.
///
///
public async Task DeleteVaultAsync(
IVaultGrantApi api,
Guid vaultId,
CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(disposed, this);
ArgumentNullException.ThrowIfNull(api);
if (!await api.DeleteVaultAsync(vaultId, cancellationToken).ConfigureAwait(false))
{
return false;
}
keyring.Forget(vaultId);
await Vault.RemoveAsync(vaultId, cancellationToken).ConfigureAwait(false);
Vaults = await Vault.ListAsync(cancellationToken).ConfigureAwait(false);
return true;
}
///
/// Wraps a vault's key to another member, after verifying their published key.
///
/// The grant calls.
/// The directory and the key log that makes it checkable.
/// The vault to share.
/// Who to share it with.
/// Cancellation token.
///
///
/// The verification is not optional and is not a parameter. A caller that could pass
/// skipChecks: true is a caller that will, on the day the log is briefly unreachable, and the
/// resulting grant is indistinguishable from a correct one afterwards.
///
///
/// What this still cannot promise is that the key belongs to the person you meant. Compare
/// with them over a channel this server does not carry;
/// that is the only step that closes the gap, and the outcome message says so.
///
///
/// Every generation this session holds is wrapped, not only the newest. A rotation does not
/// re-encrypt what is already stored, so a vault that has been rotated twice holds items under three
/// keys — and a recipient handed only the current one would open the vault to find most of it
/// unreadable. This is also the only party that can do it: the server holds ciphertext it cannot
/// read, and the recipient holds nothing yet.
///
///
public async Task ShareVaultAsync(
IVaultGrantApi grants,
IDirectoryApi directory,
Guid vaultId,
Guid recipientUserId,
CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(disposed, this);
ArgumentNullException.ThrowIfNull(grants);
ArgumentNullException.ThrowIfNull(directory);
if (!keyring.TryGet(vaultId, out _, out _))
{
throw new VaultUnreadableException(vaultId);
}
var entry = await directory.LookupByIdAsync(recipientUserId, cancellationToken)
.ConfigureAwait(false);
var log = await KeyLogAudit.ReadAsync(directory, cancellationToken).ConfigureAwait(false);
var verification = KeyLogAudit.Verify(log, entry);
if (!verification.IsVerified)
{
return new ShareOutcome(false, verification, verification.Message);
}
var recipient = verification.Recipient!;
var generations = keyring.GenerationsHeld(vaultId);
// Oldest first, so an interruption leaves the recipient holding history without the present
// rather than the reverse. Both are incomplete; only one of them looks like a working vault
// that is quietly missing its recent items.
foreach (var generation in generations)
{
if (!keyring.TryGetAt(vaultId, generation, out var vaultKey))
{
continue;
}
await IssueAsync(grants, vaultId, vaultKey, generation, recipient, cancellationToken)
.ConfigureAwait(false);
}
return new ShareOutcome(
true,
verification,
"Shared. Check the fingerprint with them out of band — everything the client can verify on "
+ "its own only proves this server has been consistent with itself.",
generations.Count);
}
///
/// Moves a vault to a fresh key and hands it to the members who are left.
///
/// The grant calls.
/// The directory and the key log that makes it checkable.
///
/// The synchronisation calls, for the last step: moving what is already stored onto the new key.
///
/// The vault to rotate.
///
/// Who should hold the new key. The caller's own id may be in here and is ignored: this session
/// wrapped the new key to itself as part of the rotation.
///
/// Cancellation token.
///
///
/// Three acts, and only the first is atomic. The generation advances in one server
/// transaction, so there is no moment at which two clients disagree about which key is current.
/// Wrapping it to each remaining member is a separate call per member, each verified against the key
/// log the same way an ordinary share is — and any of them can fail. A member who was missed holds
/// the vault's history and cannot read anything written since, which the report says so the
/// interface can too.
///
///
/// The third act is re-sealing what is already there, and it is what makes the rotation worth
/// the name: until it has run, the vault's stored items are still sealed under keys the departed
/// member may have kept. It runs last for a reason — it needs the new key, and it is the only step
/// that can be interrupted without leaving anything broken, because a vault at mixed generations
/// stays readable to everybody holding the grants. A pass that stops half way is re-run.
///
///
/// What none of it can do is take back what the departed member already pulled onto their own
/// machine. Retroactive revocation is not achievable; rotate the credentials themselves. See
/// ADR 0001.
///
///
public async Task RekeyVaultAsync(
IVaultGrantApi grants,
IDirectoryApi directory,
ISyncApi sync,
Guid vaultId,
IReadOnlyList recipients,
CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(disposed, this);
ArgumentNullException.ThrowIfNull(grants);
ArgumentNullException.ThrowIfNull(directory);
ArgumentNullException.ThrowIfNull(sync);
ArgumentNullException.ThrowIfNull(recipients);
if (!keyring.TryGet(vaultId, out _, out var keyGeneration))
{
throw new VaultUnreadableException(vaultId);
}
var name = Vaults.FirstOrDefault(vault => vault.VaultId == vaultId)?.Name ?? "this vault";
var summary = await RotateAsync(grants, vaultId, keyGeneration, cancellationToken)
.ConfigureAwait(false);
var shared = new List();
var missed = new List<(Guid UserId, string Reason)>();
foreach (var recipient in recipients.Distinct().Where(id => id != Profile.UserId))
{
try
{
var outcome = await ShareVaultAsync(
grants, directory, vaultId, recipient, cancellationToken)
.ConfigureAwait(false);
if (outcome.Shared)
{
shared.Add(recipient);
}
else
{
missed.Add((recipient, outcome.Message));
}
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
// One member's key being unusable — never enrolled, rotated their identity key mid-call
// — is not a reason to leave the rest of the team without the new one.
missed.Add((recipient, exception.Message));
}
}
// Synced before re-sealing, and it is not tidiness. The pass rewrites each item against the
// version the server holds, so a mirror that is behind produces a batch of conflicts instead of
// a re-sealed vault — and the sync also carries out anything queued here, which the push path
// re-seals on the way rather than leaving to be found later.
await SyncAsync(sync, vaultId, cancellationToken).ConfigureAwait(false);
var resealed = await ResealVaultAsync(sync, vaultId, cancellationToken).ConfigureAwait(false);
return new VaultRekeyReport(
vaultId, name, summary.KeyGeneration, shared, missed, Failure: null, resealed);
}
/// Generates the next vault key, records it, and takes it into the keyring.
///
/// The key is adopted only after the server has accepted the rotation. The other order would leave
/// this session sealing items under a generation the vault never reached, and every one of them
/// would be unreadable to everybody including its author at the next unlock.
///
private async Task RotateAsync(
IVaultGrantApi grants,
Guid vaultId,
uint keyGeneration,
CancellationToken cancellationToken)
{
var generation = keyGeneration + 1;
var vaultKey = VaultKeys.Create();
var now = clock.GetUtcNow();
try
{
var wrapped = VaultKeys.WrapTo(
vaultKey, bundle.EncryptionPublicKey, vaultId, generation);
var fingerprint = DshCrypto.ComputeFingerprint(
bundle.EncryptionPublicKey, bundle.SigningPublicKey);
var canonical = GrantStatementCodec.Encode(
vaultId,
generation,
GrantPurpose.Member,
granteeUserId: Profile.UserId,
granteeKeyFingerprint: fingerprint,
wrappedKey: wrapped,
granterUserId: Profile.UserId,
granterKeyFingerprint: fingerprint,
// Absent, as in every self-grant: there is no third party whose key could have been
// substituted when you wrap something to yourself.
keyLogHead: default,
grantedAt: now);
var summary = await grants.RekeyVaultAsync(
vaultId,
new RekeyVaultRequest(
KeyGeneration: generation,
WrappedVaultKey: wrapped,
GrantSignature: GrantStatementCodec.Sign(bundle.SigningKey, canonical),
GrantedAt: now),
cancellationToken)
.ConfigureAwait(false);
var stored = ToStored(summary);
await Vault.UpsertAsync(stored, cancellationToken).ConfigureAwait(false);
keyring.Adopt(vaultId, vaultKey, summary.KeyGeneration);
Vaults = await Vault.ListAsync(cancellationToken).ConfigureAwait(false);
return summary;
}
catch
{
// Never reached the keyring, so this is the only thing that can release it.
CryptographicOperations.ZeroMemory(vaultKey);
throw;
}
}
///
/// Hands every team vault this session can open to one member.
///
/// One report per vault, in the order they were attempted.
///
/// What "adding somebody to a team" means in full. Membership is a server-side authorization change
/// and takes effect at once; a key is a cryptographic act only a machine holding one can perform, so
/// this is the half that has to happen here. A vault this session cannot open is skipped rather than
/// failed — somebody else holds its key, and this client has nothing to wrap.
///
public async Task> ShareTeamVaultsAsync(
IVaultGrantApi grants,
IDirectoryApi directory,
Guid teamId,
Guid recipientUserId,
CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(disposed, this);
ArgumentNullException.ThrowIfNull(grants);
ArgumentNullException.ThrowIfNull(directory);
var reports = new List();
foreach (var vault in TeamVaults(teamId))
{
try
{
var outcome = await ShareVaultAsync(
grants, directory, vault.VaultId, recipientUserId, cancellationToken)
.ConfigureAwait(false);
reports.Add(new VaultShareReport(vault.VaultId, vault.Name, outcome, null));
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
reports.Add(new VaultShareReport(vault.VaultId, vault.Name, null, exception));
}
}
return reports;
}
///
/// Rotates every team vault this session can open, handing each new key to the members who remain.
///
/// One report per vault, in the order they were attempted.
///
/// What "removing somebody from a team" means in full, and the reason it is per vault rather than
/// per team: a key belongs to a vault, and a client can only rotate the ones it can currently open.
/// A vault it cannot is left alone and stays flagged for rekey, which is the honest state — somebody
/// who holds its key has to finish the job.
///
public async Task> RekeyTeamVaultsAsync(
IVaultGrantApi grants,
IDirectoryApi directory,
ISyncApi sync,
Guid teamId,
IReadOnlyList recipients,
CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(disposed, this);
ArgumentNullException.ThrowIfNull(grants);
ArgumentNullException.ThrowIfNull(directory);
ArgumentNullException.ThrowIfNull(sync);
ArgumentNullException.ThrowIfNull(recipients);
var reports = new List();
foreach (var vault in TeamVaults(teamId))
{
try
{
reports.Add(
await RekeyVaultAsync(
grants, directory, sync, vault.VaultId, recipients, cancellationToken)
.ConfigureAwait(false));
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
reports.Add(new VaultRekeyReport(
vault.VaultId, vault.Name, KeyGeneration: 0, [], [], exception));
}
}
return reports;
}
/// The team's vaults this session actually holds a current key for.
///
/// Materialised before the loops above use it, because both of them write to
/// through the vault store — and a rotation part-way through a lazily evaluated sequence would be
/// enumerating a list that has been replaced underneath it.
///
private List TeamVaults(Guid teamId) =>
[.. Vaults.Where(vault => vault.TeamId == teamId && keyring.CanRead(vault.VaultId))];
///
/// Re-reads which vaults the server says are reachable, and opens any that have become readable.
///
/// How many vaults this call made readable that were not before.
///
/// Called after a share and on a periodic pass. A vault somebody shared a minute ago arrives as a
/// new entry with a wrapped key attached; one whose grant was revoked arrives without one, and is
/// marked unreadable rather than quietly dropped so the interface can say what happened. Items
/// already pulled are deliberately left alone — see .
///
public async Task RefreshVaultsAsync(IAccountApi api, CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(disposed, this);
ArgumentNullException.ThrowIfNull(api);
var me = await api.GetMeAsync(cancellationToken).ConfigureAwait(false);
await Vault.ReplaceAllAsync([.. me.Vaults.Select(ToStored)], cancellationToken)
.ConfigureAwait(false);
Vaults = await Vault.ListAsync(cancellationToken).ConfigureAwait(false);
var admitted = 0;
foreach (var vault in Vaults)
{
// Attempted even for a vault that already opens, because the answer can have grown: a
// rotated vault arrives with a new current generation, and a vault shared by somebody who
// holds more of its history arrives with wraps this session did not have. Admitting is
// idempotent, so the only thing an unconditional call costs is the unwrap it skips.
var readable = keyring.CanRead(vault.VaultId);
if (keyring.TryAdmit(bundle, vault))
{
if (!readable)
{
admitted++;
}
}
else
{
keyring.MarkUnreadable(vault.VaultId);
}
}
return admitted;
}
/// Signs and posts one grant.
private async Task IssueAsync(
IVaultGrantApi grants,
Guid vaultId,
ReadOnlyMemory vaultKey,
uint keyGeneration,
VerifiedRecipient recipient,
CancellationToken cancellationToken)
{
var now = clock.GetUtcNow();
var entry = recipient.Entry;
var wrapped = VaultKeys.WrapTo(
vaultKey.Span, entry.EncryptionPublicKey, vaultId, keyGeneration);
var ownFingerprint = DshCrypto.ComputeFingerprint(
bundle.EncryptionPublicKey, bundle.SigningPublicKey);
var canonical = GrantStatementCodec.Encode(
vaultId,
keyGeneration,
GrantPurpose.Member,
granteeUserId: entry.UserId,
granteeKeyFingerprint: recipient.Fingerprint,
wrappedKey: wrapped,
granterUserId: Profile.UserId,
granterKeyFingerprint: ownFingerprint,
// Present, unlike a self-grant's. This is the third-party case the head exists for: it
// records which view of the key log this client held while wrapping, so a server showing
// two clients different logs has to keep both stories straight for ever after.
keyLogHead: recipient.KeyLogHead,
grantedAt: now);
await grants.IssueVaultGrantAsync(
vaultId,
new IssueVaultGrantRequest(
RecipientUserId: entry.UserId,
RecipientKeyFingerprint: recipient.Fingerprint,
KeyGeneration: keyGeneration,
WrappedVaultKey: wrapped,
KeyLogHead: recipient.KeyLogHead,
GrantSignature: GrantStatementCodec.Sign(bundle.SigningKey, canonical),
GrantedAt: now),
cancellationToken)
.ConfigureAwait(false);
}
///
/// The signature covers the vault id, so the id has to be chosen before anything is wrapped — which
/// is also what makes a create whose response was lost safe to send again.
///
private CreateTeamVaultRequest BuildCreateRequest(
Guid vaultId,
byte[] vaultKey,
string name,
DateTimeOffset now)
{
var wrapped = VaultKeys.WrapTo(vaultKey, bundle.EncryptionPublicKey, vaultId, 1);
var fingerprint = DshCrypto.ComputeFingerprint(
bundle.EncryptionPublicKey, bundle.SigningPublicKey);
var canonical = GrantStatementCodec.Encode(
vaultId,
keyGeneration: 1,
GrantPurpose.Member,
granteeUserId: Profile.UserId,
granteeKeyFingerprint: fingerprint,
wrappedKey: wrapped,
granterUserId: Profile.UserId,
granterKeyFingerprint: fingerprint,
keyLogHead: default,
grantedAt: now);
return new CreateTeamVaultRequest(
VaultId: vaultId,
Name: name,
WrappedVaultKey: wrapped,
GrantSignature: GrantStatementCodec.Sign(bundle.SigningKey, canonical),
GrantedAt: now);
}
private static StoredVault ToStored(VaultSummary summary) =>
new(
summary.VaultId,
summary.Name,
summary.IsPersonal,
summary.TeamId,
summary.KeyGeneration,
summary.Permissions,
summary.WrappedVaultKey,
summary.RekeyRequired,
summary.PriorKeyWraps);
}