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. public sealed record VaultRekeyReport( Guid VaultId, string Name, uint KeyGeneration, IReadOnlyList Shared, IReadOnlyList<(Guid UserId, string Reason)> NotShared, Exception? Failure) { /// Whether the vault moved to a new key. public bool Rotated => Failure is null && KeyGeneration > 0; } /// /// 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; } } /// /// 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 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. /// /// /// Two 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. /// /// /// What a rotation is worth, stated honestly. Nothing already stored is re-encrypted — only a /// client holding both keys could, and that is deferred work. So this does not take back what the /// departed member already has, and it does not re-seal the vault's history against the key they may /// have kept. What it does is make everything written from now on unreadable to them. Retroactive /// revocation is not achievable; rotate the credentials themselves. See ADR 0001. /// /// public async Task RekeyVaultAsync( IVaultGrantApi grants, IDirectoryApi directory, Guid vaultId, IReadOnlyList recipients, CancellationToken cancellationToken) { ObjectDisposedException.ThrowIf(disposed, this); ArgumentNullException.ThrowIfNull(grants); ArgumentNullException.ThrowIfNull(directory); 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)); } } return new VaultRekeyReport( vaultId, name, summary.KeyGeneration, shared, missed, Failure: null); } /// 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, Guid teamId, IReadOnlyList recipients, CancellationToken cancellationToken) { ObjectDisposedException.ThrowIf(disposed, this); ArgumentNullException.ThrowIfNull(grants); ArgumentNullException.ThrowIfNull(directory); ArgumentNullException.ThrowIfNull(recipients); var reports = new List(); foreach (var vault in TeamVaults(teamId)) { try { reports.Add( await RekeyVaultAsync( grants, directory, 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); }