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