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. public sealed record ShareOutcome( bool Shared, RecipientVerification Verification, string Message); /// /// 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. /// /// 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 var vaultKey, out var keyGeneration)) { 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!; await IssueAsync(grants, vaultId, vaultKey, keyGeneration, 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."); } /// /// 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) { if (keyring.CanRead(vault.VaultId)) { continue; } if (keyring.TryAdmit(bundle, vault)) { 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); }