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