using System.Security.Cryptography; using DodoSSH.Contracts; using DodoSSH.Domain; using DodoSSH.Infrastructure; using Microsoft.EntityFrameworkCore; using Npgsql; namespace DodoSSH.Api.Features.Teams; /// /// Team vaults and the key grants that make them readable. /// /// /// /// Everything here stores bytes it cannot interpret. A wrapped vault key is sealed to a recipient's /// X25519 key, and a grant signature is Ed25519 over a tuple this server never verifies — the two /// together are what let a client detect a fabricated grant, and moving either check onto the server /// would make it a convenience rather than the boundary. See docs/crypto.md §6 and §7. /// /// /// What the server can check is that a grant is not obviously useless: that the recipient is /// enrolled, that the fingerprint names their current key, and that the generation is the vault's /// current one. Each of those would otherwise surface at the far end as a tag failure the recipient /// reads as data corruption, days later, with nothing pointing at the grant that caused it. /// /// internal sealed class VaultGrantService( DodoDbContext database, TimeProvider clock, ILogger logger) { /// /// Largest wrapped vault key accepted. /// /// /// A SealTo envelope over a 32-byte key is 6 + 32 + 24 + 48 = 110 bytes. The cap is loose /// enough to survive a future envelope — the reserved hybrid seal in docs/crypto.md §8 is far /// larger — and tight enough that this column cannot be used as free storage on a server that /// stores it without being able to read it. /// private const int MaxWrappedKeyBytes = 4096; /// Creates a vault owned by a team, with the creator's own grant. /// /// The vault and its first grant are written together, for the reason enrollment gives about a /// personal vault: a vault with no grant is a container nobody can ever open, including whoever /// created it, because only a client can wrap the key and it has already moved on. /// internal async Task CreateTeamVaultAsync( UserAccount user, Team team, CreateTeamVaultRequest request, CancellationToken cancellationToken) { var name = RequireVaultName(request.Name); if (request.VaultId == Guid.Empty) { throw new TeamInvalidException("A vault id is required. Generate a UUIDv7 on the client."); } RequireWrappedKey(request.WrappedVaultKey); RequireSignature(request.GrantSignature); var key = await RequireCurrentKeyAsync(user.Id, cancellationToken).ConfigureAwait(false); var taken = await database.Vaults .AnyAsync(v => v.Id == request.VaultId, cancellationToken) .ConfigureAwait(false); if (taken) { throw new TeamInvalidException( "That vault id is already in use. Generate a new UUIDv7 and retry."); } AddVaultWithSelfGrant(user, team, request, name, key, clock.GetUtcNow()); try { await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false); } catch (DbUpdateException exception) when (IsUniqueViolation(exception)) { // The pre-check covers the ordinary case; this is the race between two creates choosing // the same id, which must not surface as a 500 about a constraint. throw new TeamInvalidException( "That vault id is already in use. Generate a new UUIDv7 and retry."); } TeamLog.TeamVaultCreated(logger, request.VaultId, team.Id, user.Id); return new VaultSummary( VaultId: request.VaultId, Name: name, IsPersonal: false, TeamId: team.Id, KeyGeneration: 1, Permissions: 0, WrappedVaultKey: request.WrappedVaultKey, RekeyRequired: false); } /// Adds the vault row and the creator's own grant, in one unit of work. private void AddVaultWithSelfGrant( UserAccount user, Team team, CreateTeamVaultRequest request, string name, UserKey key, DateTimeOffset now) { database.Vaults.Add(new Vault { Id = request.VaultId, Name = name, OwnerKind = VaultOwnerKind.Team, TeamId = team.Id, KeyGeneration = 1, CreatedAtUtc = now, UpdatedAtUtc = now, }); database.VaultKeyGrants.Add(new VaultKeyGrant { Id = Guid.CreateVersion7(), VaultId = request.VaultId, KeyGeneration = 1, Kind = GrantKind.Member, RecipientUserId = user.Id, RecipientKeyFingerprint = key.FingerprintSha256, WrappedKey = request.WrappedVaultKey, GranterUserId = user.Id, GranterKeyFingerprint = key.FingerprintSha256, // No key log head, exactly as a personal vault's self-grant carries none: there is no // third party whose key could have been substituted here. KeyLogHead = null, Signature = request.GrantSignature, State = GrantState.Active, CreatedAtUtc = now, }); } private static string RequireVaultName(string? value) { var name = (value ?? string.Empty).Trim(); return name.Length is 0 or > 256 ? throw new TeamInvalidException("A vault name of 1 to 256 characters is required.") : name; } /// /// Renames a vault, and the team behind it where that team exists to carry this vault alone. /// /// /// /// The team is renamed with it, and only when it owns nothing else. A vault made from the /// vaults screen gets a team of its own named after it, and that team is not a thing the person who /// made it was ever shown — so a rename that moved the vault's name and left the team's would leave /// the operator, the logs and the database naming it something nobody uses. A team owning several /// vaults is a different situation: it has a name of its own that somebody chose, and renaming one of /// its vaults must not take it. /// /// /// The slug never moves, exactly as UpdateTeamRequest records: it is unique only among live /// teams, so a rename that changed it could take one an archived team is still holding. /// /// internal async Task RenameVaultAsync( Vault vault, UpdateVaultRequest request, CancellationToken cancellationToken) { var name = RequireVaultName(request.Name); vault.Name = name; vault.UpdatedAtUtc = clock.GetUtcNow(); if (vault.TeamId is { } teamId) { var alone = !await database.Vaults .AnyAsync( other => other.TeamId == teamId && other.Id != vault.Id && other.DeletedAtUtc == null, cancellationToken) .ConfigureAwait(false); if (alone) { var team = await database.Teams .SingleOrDefaultAsync(t => t.Id == teamId && t.DeletedAtUtc == null, cancellationToken) .ConfigureAwait(false); if (team is not null) { team.Name = name; } } } await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false); TeamLog.VaultRenamed(logger, vault.Id, vault.TeamId); return new VaultSummary( VaultId: vault.Id, Name: name, IsPersonal: vault.OwnerKind == VaultOwnerKind.Personal, TeamId: vault.TeamId, KeyGeneration: (uint)vault.KeyGeneration, Permissions: 0, WrappedVaultKey: null, RekeyRequired: vault.RekeyRequired); } /// /// Deletes a vault, and the team behind it where that team exists to carry this vault alone. /// /// /// The team that has been left owning nothing by this, or null where there is none. The caller /// archives it; see for why that is a second step. /// /// /// /// Soft, like every other removal here. The row is stamped rather than dropped, so an operator /// can see that a vault existed and what became of it — and so the foreign keys from its items and its /// grants stay valid. VaultAccessService filters on the stamp at both ends, so from the moment /// this commits the vault is absent from every member's /me and every call naming it answers 404. /// /// /// The items are deliberately left alone. They are ciphertext behind a vault nothing will now /// resolve, so deleting them buys no confidentiality — and it would destroy the one thing an operator /// restoring a vault deleted by mistake would need. What this is not is a promise about other people's /// machines: a member who synced yesterday still holds their copy, exactly as ADR 0001 says about /// revocation, and the deletion message says so. /// /// /// Every grant is withdrawn in the same write. Nothing reads them once the vault is gone, but a /// live grant on a deleted vault is a row that says somebody holds a key to something that no longer /// exists — and the grant list is the thing an operator reads to answer "who could open this". /// /// internal async Task DeleteVaultAsync( UserAccount actor, Vault vault, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(vault); // A personal vault is where everything filed nowhere else lives, and it is created by enrollment // rather than by anybody choosing to make it. Deleting one would leave an enrolled account with a // key, no vault, and no way to make the vault it is supposed to have. if (vault.OwnerKind != VaultOwnerKind.Team || vault.TeamId is not { } teamId) { throw new VaultGrantInvalidException( "A personal vault cannot be deleted. It is where everything filed nowhere else lives, and " + "the account has no way to make another. Move what you want to keep into a shared vault " + "and delete that instead."); } var now = clock.GetUtcNow(); var grants = await database.VaultKeyGrants .Where(g => g.VaultId == vault.Id && g.RevokedAtUtc == null) .ToListAsync(cancellationToken) .ConfigureAwait(false); foreach (var grant in grants) { grant.State = GrantState.Revoked; grant.RevokedAtUtc = now; } vault.DeletedAtUtc = now; vault.UpdatedAtUtc = now; await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false); TeamLog.VaultDeleted(logger, vault.Id, teamId, actor.Id, grants.Count); // Read after the save, so "owns nothing else" is asked of a database that already knows this one // is gone. The team is returned rather than archived here for the reason the rename gives about // renaming it: a team carrying several vaults has a name and a membership list somebody chose, and // one made to carry this vault alone is a thing its creator was never shown. var alone = !await database.Vaults .AnyAsync(other => other.TeamId == teamId && other.DeletedAtUtc == null, cancellationToken) .ConfigureAwait(false); return alone ? await database.Teams .SingleOrDefaultAsync(t => t.Id == teamId && t.DeletedAtUtc == null, cancellationToken) .ConfigureAwait(false) : null; } /// Lists who can open a vault. /// /// One row per holder, not one per grant. A rotated vault holds several grants per member — one per /// generation, which is what lets them read its history — and a listing that showed each of them /// would answer "who can open this" with the same person three times. The row carries the best key /// they hold: the live grant at the highest generation, or, for somebody whose access has been /// withdrawn, the most recent grant they had, so the withdrawal is still visible. /// internal async Task ListGrantsAsync( Vault vault, CancellationToken cancellationToken) { var grants = await database.VaultKeyGrants .Where(g => g.VaultId == vault.Id && g.Kind == GrantKind.Member) .Include(g => g.RecipientUser) .OrderBy(g => g.CreatedAtUtc) .ToListAsync(cancellationToken) .ConfigureAwait(false); var holders = grants .GroupBy(g => g.RecipientUserId!.Value) .Select(group => group .OrderByDescending(g => g.RevokedAtUtc is null) .ThenByDescending(g => g.KeyGeneration) .First()) // The order the first grant of each holder was made in, so the list reads as the vault was // shared rather than reshuffling itself every time somebody is re-wrapped. .OrderBy(g => grants.Find(first => first.RecipientUserId == g.RecipientUserId)!.CreatedAtUtc) .ToList(); return new VaultGrantsResponse( VaultId: vault.Id, KeyGeneration: (uint)vault.KeyGeneration, RekeyRequired: vault.RekeyRequired, Grants: [ .. holders.Select(g => new VaultGrantSummary( g.RecipientUserId!.Value, g.RecipientUser?.Email, g.RecipientUser?.DisplayName, (uint)g.KeyGeneration, ToContract(g.State), g.GranterUserId, g.CreatedAtUtc, g.RevokedAtUtc)), ]); } /// Wraps a vault key to another member. /// /// /// Re-issuing to a recipient who already holds a live grant for that generation replaces it /// in place rather than inserting a second row, because the unique index permits exactly one live /// grant per recipient per generation — and because the operation somebody is actually performing /// when they do this is "wrap it again", after a botched first attempt. /// /// /// A recipient may hold one grant per generation at once, and after a rotation they need to: an item /// is sealed under whatever generation was current when it was written, so somebody given only the /// newest key would find everything older unreadable. Which generations get wrapped is the sharing /// client's decision — it is the only party that can tell which ones it holds. /// /// internal async Task IssueGrantAsync( UserAccount actor, Vault vault, IssueVaultGrantRequest request, CancellationToken cancellationToken) { await RequireIssuableAsync(vault, request, cancellationToken).ConfigureAwait(false); var granterKey = await RequireCurrentKeyAsync(actor.Id, cancellationToken) .ConfigureAwait(false); var generation = (int)request.KeyGeneration; var existing = await database.VaultKeyGrants .SingleOrDefaultAsync( g => g.VaultId == vault.Id && g.KeyGeneration == generation && g.RecipientUserId == request.RecipientUserId && g.RevokedAtUtc == null, cancellationToken) .ConfigureAwait(false); var grant = existing ?? new VaultKeyGrant { Id = Guid.CreateVersion7(), VaultId = vault.Id, KeyGeneration = generation, Kind = GrantKind.Member, RecipientUserId = request.RecipientUserId, CreatedAtUtc = clock.GetUtcNow(), }; grant.RecipientKeyFingerprint = request.RecipientKeyFingerprint; grant.WrappedKey = request.WrappedVaultKey; grant.GranterUserId = actor.Id; // Taken from the server's own view of the caller's key rather than from the request. The // client signed over the same value, so an honest client is unaffected; a field that could // disagree with reality is one a reader would have to decide which copy to believe. grant.GranterKeyFingerprint = granterKey.FingerprintSha256; grant.KeyLogHead = request.KeyLogHead; grant.Signature = request.GrantSignature; grant.State = GrantState.Active; if (existing is null) { database.VaultKeyGrants.Add(grant); } await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false); TeamLog.GrantIssued( logger, vault.Id, generation, request.RecipientUserId, actor.Id); } /// /// Everything that can be checked about a grant without holding the vault key. /// /// /// None of this verifies that the wrap contains the right key — nothing on this machine can. Each /// check exists because failing it would otherwise surface at the recipient as a tag failure they /// read as data corruption, long after the request that caused it. /// private async Task RequireIssuableAsync( Vault vault, IssueVaultGrantRequest request, CancellationToken cancellationToken) { // Team vaults only. A personal vault has exactly one subject who may reach it, so a grant on // one would seal a key to somebody the access check will go on refusing — a row that looks // like sharing and is not. Moving the items into a team vault is the operation that shares. if (vault.OwnerKind != VaultOwnerKind.Team || vault.TeamId is not { } teamId) { throw new VaultGrantInvalidException( "Only a team vault can be shared. A personal vault is reachable by its owner alone, " + "so a grant on one would seal a key to somebody who still could not fetch it."); } RequireWrappedKey(request.WrappedVaultKey); RequireSignature(request.GrantSignature); RequireDigest(request.RecipientKeyFingerprint, "recipient key fingerprint"); RequireDigest(request.KeyLogHead, "key log head"); // Any generation the vault has actually reached, not only the current one — sharing a rotated // vault means handing over its history as well as its present. A generation ahead of the // current one is refused: nothing is sealed under it, so the grant would open nothing, and // accepting it would let a client move the vault forward without the transaction that does so. if (request.KeyGeneration is 0 || request.KeyGeneration > (uint)vault.KeyGeneration) { throw new VaultGrantInvalidException( $"This vault is at key generation {vault.KeyGeneration}. A grant for generation " + $"{request.KeyGeneration} would open nothing."); } var member = await database.TeamMemberships .AnyAsync( m => m.TeamId == teamId && m.UserId == request.RecipientUserId && m.Status == MembershipStatus.Active && m.DeletedAtUtc == null, cancellationToken) .ConfigureAwait(false); if (!member) { throw new VaultGrantInvalidException( "That account is not an active member of the team that owns this vault. Add them to " + "the team first — a key wrapped to somebody the server will refuse to serve is a " + "grant that does nothing."); } var recipientKey = await RequireCurrentKeyAsync(request.RecipientUserId, cancellationToken) .ConfigureAwait(false); // The fingerprint the client signed over must be the key the recipient actually holds. // Otherwise the grant is sealed to a superseded key, opens nothing, and surfaces at the far // end as an unexplained decryption failure rather than as the mistake it is. if (!CryptographicOperations.FixedTimeEquals( recipientKey.FingerprintSha256, request.RecipientKeyFingerprint)) { throw new VaultGrantInvalidException( "The fingerprint does not name the recipient's current identity key. Re-read the " + "directory and wrap the key again — theirs has been rotated since you fetched it."); } } /// /// Moves a vault to a fresh key generation, wrapped to the caller. /// /// The vault as the caller now sees it, at the generation this call created. /// /// /// What the server contributes is the moment, not the key. It cannot generate a vault key, tell /// that the one it is handed differs from the old one, or check that the caller held the old one at /// all. What it can do — and what nothing else can — is advance the generation exactly once, so two /// admins rotating the same vault at the same time do not both walk away believing they succeeded. /// The stale one's generation is no longer one past the current, and it is refused. /// /// /// Earlier grants are left standing. They are what the remaining members read the vault's /// history with: an item carries the generation it was sealed under, and nothing here re-encrypts /// items — only a client holding both keys could. The departed member is cut off by the revocation /// that removal already performed, which takes every generation they held. /// /// /// The rekey flag is cleared here rather than when the last member is re-wrapped, because it records /// that a membership change left the vault owing a rotation, and the rotation is this. Who still /// needs the new key is a different question, and the grant list answers it by generation. /// /// internal async Task RekeyAsync( UserAccount actor, Vault vault, int permissions, RekeyVaultRequest request, CancellationToken cancellationToken) { var key = await RequireRotatableAsync(actor, vault, request, cancellationToken) .ConfigureAwait(false); var now = clock.GetUtcNow(); var generation = (int)request.KeyGeneration; AddSelfGrant(actor, vault, key, generation, request, now); vault.KeyGeneration = generation; vault.RekeyRequired = false; vault.RekeyReason = RekeyReason.None; vault.UpdatedAtUtc = now; try { // One SaveChanges, so the row and the grant land together. The vault's xmin concurrency // token is what makes the generation check above binding rather than advisory: a second // rotation that read the same generation fails here instead of overwriting this one. await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false); } catch (DbUpdateConcurrencyException) { // Reported as the same refusal the pre-check gives, because it is the same situation seen a // moment later — and a 500 about a concurrency token would tell the user nothing they could // act on. Retrying is safe: the caller generates a fresh key and reads the generation again. throw new VaultGrantInvalidException( "Somebody else rotated this vault while this rotation was being recorded. Read it again " + "and rotate from the generation they left behind."); } TeamLog.VaultRekeyed(logger, vault.Id, generation, actor.Id); var prior = await database.VaultKeyGrants .Where(g => g.VaultId == vault.Id && g.RecipientUserId == actor.Id && g.KeyGeneration < generation && g.State == GrantState.Active && g.RevokedAtUtc == null) .OrderBy(g => g.KeyGeneration) .Select(g => new VaultKeyWrap((uint)g.KeyGeneration, g.WrappedKey)) .ToListAsync(cancellationToken) .ConfigureAwait(false); return new VaultSummary( VaultId: vault.Id, Name: vault.Name, IsPersonal: false, TeamId: vault.TeamId, KeyGeneration: request.KeyGeneration, Permissions: permissions, WrappedVaultKey: request.WrappedVaultKey, RekeyRequired: false, PriorKeyWraps: prior); } /// Records the rotating client's grant for the generation it has just created. private void AddSelfGrant( UserAccount actor, Vault vault, UserKey key, int generation, RekeyVaultRequest request, DateTimeOffset now) => database.VaultKeyGrants.Add(new VaultKeyGrant { Id = Guid.CreateVersion7(), VaultId = vault.Id, KeyGeneration = generation, Kind = GrantKind.Member, RecipientUserId = actor.Id, RecipientKeyFingerprint = key.FingerprintSha256, WrappedKey = request.WrappedVaultKey, GranterUserId = actor.Id, GranterKeyFingerprint = key.FingerprintSha256, // No key log head, as every self-grant carries none: there is no third party whose key // could have been substituted when you wrap something to yourself. KeyLogHead = null, Signature = request.GrantSignature, State = GrantState.Active, CreatedAtUtc = now, }); /// /// Everything that can be checked about a rotation before it is recorded. /// /// The caller's current identity key, which the new grant is filed against. private async Task RequireRotatableAsync( UserAccount actor, Vault vault, RekeyVaultRequest request, CancellationToken cancellationToken) { if (vault.OwnerKind != VaultOwnerKind.Team || vault.TeamId is null) { throw new VaultGrantInvalidException( "Only a team vault can be rotated. A personal vault has one reader, so a rotation " + "would re-wrap a key to the same person and change nothing about who can read it."); } RequireWrappedKey(request.WrappedVaultKey); RequireSignature(request.GrantSignature); if (request.KeyGeneration != (uint)vault.KeyGeneration + 1) { throw new VaultGrantInvalidException( $"This vault is at key generation {vault.KeyGeneration}, so the next one is " + $"{vault.KeyGeneration + 1} and not {request.KeyGeneration}. Read the vault again — " + "somebody else has rotated it since you last looked."); } var key = await RequireCurrentKeyAsync(actor.Id, cancellationToken).ConfigureAwait(false); // Held now, not merely permitted. The new key has to be wrapped from the old one, and an // account that cannot open the current generation cannot have done that — so a request from // one is either a mistake or an attempt to strand every other member behind a key nobody has. var holdsCurrent = await database.VaultKeyGrants .AnyAsync( g => g.VaultId == vault.Id && g.KeyGeneration == vault.KeyGeneration && g.RecipientUserId == actor.Id && g.State == GrantState.Active && g.RevokedAtUtc == null, cancellationToken) .ConfigureAwait(false); return holdsCurrent ? key : throw new VaultGrantInvalidException( "You hold no key to this vault at its current generation, so you cannot rotate it. Ask " + "a member who does."); } /// /// Withdraws a member's key grant. /// /// Whether there was a live grant to withdraw. /// /// Blocks future reads and nothing else. Anything the recipient has already pulled is on their /// machine and stays there, which is why the vault is flagged for rekey and why the honest /// remediation for a departure is rotating the SSH credential itself. See ADR 0001. /// internal async Task RevokeGrantAsync( UserAccount actor, Vault vault, Guid recipientUserId, CancellationToken cancellationToken) { if (recipientUserId == actor.Id) { throw new VaultGrantInvalidException( "You cannot withdraw your own key. It would leave you unable to read a vault you can " + "still write to, and nothing here can hand it back."); } var grants = await database.VaultKeyGrants .Where(g => g.VaultId == vault.Id && g.RecipientUserId == recipientUserId && g.RevokedAtUtc == null) .ToListAsync(cancellationToken) .ConfigureAwait(false); if (grants.Count == 0) { return false; } var now = clock.GetUtcNow(); foreach (var grant in grants) { grant.State = GrantState.Revoked; grant.RevokedAtUtc = now; } vault.RekeyRequired = true; vault.RekeyReason = RekeyReason.Requested; vault.UpdatedAtUtc = now; await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false); TeamLog.GrantRevoked(logger, vault.Id, recipientUserId, actor.Id); return true; } private async Task RequireCurrentKeyAsync(Guid userId, CancellationToken cancellationToken) { var key = await database.UserKeys .SingleOrDefaultAsync(k => k.UserId == userId && k.IsCurrent, cancellationToken) .ConfigureAwait(false); return key ?? throw new VaultGrantInvalidException( "That account has not published an identity key yet, so there is nothing to wrap a " + "vault key to. They have to sign in and set up their vault first."); } private static void RequireWrappedKey(byte[]? value) { if (value is null || value.Length == 0 || value.Length > MaxWrappedKeyBytes) { throw new VaultGrantInvalidException( $"A wrapped vault key of 1 to {MaxWrappedKeyBytes} bytes is required."); } } private static void RequireSignature(byte[]? value) { if (value is null || value.Length != 64) { throw new VaultGrantInvalidException("An Ed25519 grant signature is 64 bytes."); } } private static void RequireDigest(byte[]? value, string field) { if (value is null || value.Length != 32) { throw new VaultGrantInvalidException($"A {field} is 32 bytes."); } } private static VaultGrantState ToContract(GrantState state) => state switch { GrantState.Active => VaultGrantState.Active, GrantState.AwaitingRewrap => VaultGrantState.AwaitingRewrap, GrantState.Revoked => VaultGrantState.Revoked, _ => VaultGrantState.Unspecified, }; private static bool IsUniqueViolation(DbUpdateException exception) => string.Equals( (exception.InnerException as PostgresException)?.SqlState, PostgresErrorCodes.UniqueViolation, StringComparison.Ordinal); }