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