using System.Security.Cryptography; using System.Text.Json; using DodoSSH.Api.Setup; using DodoSSH.Contracts; using DodoSSH.Crypto; using DodoSSH.Domain; using DodoSSH.Infrastructure; using Microsoft.EntityFrameworkCore; using Npgsql; namespace DodoSSH.Api.Features.Identity; /// /// Publishes a user's first identity key and creates their personal vault. /// /// /// /// One transaction produces the key, its wraps, the device, the key log entry, the vault and the /// vault key grant. Nothing here is separable: a key with no vault leaves a user unable to store /// anything, and a vault with no grant is a container nobody can ever open — including its owner, /// since only the client can wrap the key and it has already moved on. /// /// /// Enrollment is idempotent on retry. A client whose request timed out after the server committed /// re-sends the identical body and receives the identical response, because the vault id and the /// keys are all client-chosen. Without that, a lost response would leave the user permanently /// enrolled against a vault they never learned the id of. /// /// internal sealed class EnrollmentService( DodoDbContext database, IIdentityBindingVerifier bindingVerifier, TimeProvider clock, ILogger logger) { /// /// Lock key serialising key log appends across the deployment. /// /// /// A constant string rather than a per-user key, because the chain is global: two concurrent /// enrollments reading the same head would produce two entries claiming the same predecessor, /// which is indistinguishable from the fork the chain exists to detect. Enrollment happens once /// per user, so serialising it costs nothing. /// private const string KeyLogLockName = "dodossh:key_log"; /// Enrolls the caller's first identity key. /// The request is structurally unacceptable. /// The provider token did not bind the keys. /// A different current key already exists. internal async Task EnrollAsync( UserAccount user, EnrollmentRequest request, CancellationToken cancellationToken) { EnrollmentValidation.Validate(request, user); var statement = request.Statement; var canonical = KeyStatementCodec.Encode(ToFields(statement)); // Order matters only for cost: the self-signature is local and cheap, the binding check may // hit the provider's JWKS endpoint. Both are mandatory and neither substitutes for the // other — the self-signature proves possession of the private key, the binding proves whose // key it is. if (!DshSignatures.VerifyKeyStatement( statement.SigningPublicKey, canonical, request.StatementSignature)) { IdentityLog.StatementSignatureRejected(logger, user.Id); throw new EnrollmentInvalidException( "The key statement self-signature did not verify against the statement's own signing key."); } var verification = await bindingVerifier.VerifyAsync( request.IdentityProviderToken, statement.Issuer, statement.Subject, KeyStatementCodec.ToNonce(KeyStatementCodec.ComputeBinding(canonical)), cancellationToken) .ConfigureAwait(false); if (!verification.Verified) { IdentityLog.BindingRejected(logger, user.Id); throw new IdentityBindingInvalidException(verification.Failure!); } var fingerprint = DshCrypto.ComputeFingerprint( statement.EncryptionPublicKey, statement.SigningPublicKey); // Cheap path for the common retry. The authoritative check runs inside the transaction. var current = await FindCurrentKeyAsync(user.Id, cancellationToken).ConfigureAwait(false); if (current is not null) { return await ResolveExistingAsync(user, request, current, fingerprint, cancellationToken) .ConfigureAwait(false); } return await CommitAsync(user, request, verification, fingerprint, cancellationToken) .ConfigureAwait(false); } private async Task CommitAsync( UserAccount user, EnrollmentRequest request, BindingVerification verification, byte[] fingerprint, CancellationToken cancellationToken) { return await database.ExecuteFreshAsync([user], async () => { var transaction = await database.Database .BeginTransactionAsync(cancellationToken) .ConfigureAwait(false); await using var _ = transaction.ConfigureAwait(false); // First statement in the transaction, as with a sync push. The key log's sequence is an // identity column, so it is assigned before commit: without serialising appends, two // transactions can interleave their sequences and the hash chain no longer matches the // order a reader sees. See ADR 0003 for the same hazard in the vault change log. await AcquireKeyLogLockAsync(cancellationToken).ConfigureAwait(false); var current = await FindCurrentKeyAsync(user.Id, cancellationToken).ConfigureAwait(false); if (current is not null) { // Two concurrent first enrollments. Fall back to the same resolution the fast path // uses, so a duplicate submission still ends in an idempotent success. return await ResolveExistingAsync(user, request, current, fingerprint, cancellationToken) .ConfigureAwait(false); } await RequireVaultIdIsFreeAsync(request.PersonalVault.VaultId, cancellationToken) .ConfigureAwait(false); var written = await WriteAsync(user, request, verification, fingerprint, cancellationToken) .ConfigureAwait(false); await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); IdentityLog.Enrolled(logger, user.Id, written.Generation, written.KeyLogSequence); return new EnrollmentResponse( UserId: user.Id, KeyGeneration: written.Generation, Fingerprint: fingerprint, PersonalVaultId: request.PersonalVault.VaultId, DeviceId: written.DeviceId, KeyLogSequence: written.KeyLogSequence); }).ConfigureAwait(false); } /// What the write produced, for the response. [System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Auto)] private readonly record struct WrittenEnrollment(int Generation, Guid? DeviceId, long KeyLogSequence); /// Writes every row enrollment produces, inside the caller's transaction. private async Task WriteAsync( UserAccount user, EnrollmentRequest request, BindingVerification verification, byte[] fingerprint, CancellationToken cancellationToken) { // Truncated to the precision the key log hashes, so the stored row can reproduce its own // hash after a round trip through PostgreSQL's microsecond timestamps. var now = KeyLogChain.TruncateTimestamp(clock.GetUtcNow()); var key = BuildKey(user, request, verification, fingerprint, now); database.UserKeys.Add(key); AddWraps(user, request, now); var deviceId = AddDevice(user, request, now); var entry = await AppendKeyLogAsync(user, request, now, cancellationToken).ConfigureAwait(false); AddPersonalVault(user, request, fingerprint, now); user.EnrolledAtUtc = now; user.UpdatedAtUtc = now; user.LastSeenAtUtc = now; try { await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false); } catch (DbUpdateException exception) when (IsUniqueViolation(exception)) { // The advisory lock covers the key log, and the pre-checks cover the expected races. // Anything left is a genuine collision — most plausibly the global unique index on key // fingerprints — and must not be reported as success. throw new AlreadyEnrolledException( "Enrollment collided with existing identity data. Retry with freshly generated keys."); } return new WrittenEnrollment(key.Generation, deviceId, entry.Sequence); } /// /// Decides whether an enrollment against an already-enrolled user is a retry or a conflict. /// /// /// Compared by fingerprint, which covers both public keys, rather than by any single field. The /// vault id must match too: the same keys with a different vault id is a client that has lost /// track of its own state, and creating a second vault for it would strand the first. /// private async Task ResolveExistingAsync( UserAccount user, EnrollmentRequest request, UserKey current, byte[] fingerprint, CancellationToken cancellationToken) { if (!CryptographicOperations.FixedTimeEquals(current.FingerprintSha256, fingerprint)) { throw new AlreadyEnrolledException( "This account already holds a different identity key. Rotate the existing key " + "rather than enrolling a second one."); } var vaultExists = await database.Vaults .AnyAsync( v => v.Id == request.PersonalVault.VaultId && v.OwnerUserId == user.Id && v.DeletedAtUtc == null, cancellationToken) .ConfigureAwait(false); if (!vaultExists) { throw new AlreadyEnrolledException( "This account is already enrolled with these keys, but not against the supplied " + "vault id. Read /api/v1/me to recover the current state."); } var sequence = await database.KeyLog .Where(e => e.UserId == user.Id && e.Generation == current.Generation) .Select(e => e.Sequence) .FirstOrDefaultAsync(cancellationToken) .ConfigureAwait(false); var deviceId = request.DevicePublicKey is null ? (Guid?)null : await database.Devices .Where(d => d.UserId == user.Id && d.PublicKey == request.DevicePublicKey) .Select(d => (Guid?)d.Id) .FirstOrDefaultAsync(cancellationToken) .ConfigureAwait(false); IdentityLog.EnrollmentReplayed(logger, user.Id); return new EnrollmentResponse( UserId: user.Id, KeyGeneration: current.Generation, Fingerprint: current.FingerprintSha256, PersonalVaultId: request.PersonalVault.VaultId, DeviceId: deviceId, KeyLogSequence: sequence); } private static UserKey BuildKey( UserAccount user, EnrollmentRequest request, BindingVerification verification, byte[] fingerprint, DateTimeOffset now) => new() { Id = Guid.CreateVersion7(), UserId = user.Id, Generation = request.Statement.KeyGeneration, EncryptionPublicKey = request.Statement.EncryptionPublicKey, SigningPublicKey = request.Statement.SigningPublicKey, FingerprintSha256 = fingerprint, // Stored verbatim so a client can recompute the canonical encoding and check the // binding for itself. The hash is over the canonical encoding, not over this JSON, so // the serialiser's formatting choices cannot invalidate anything. Statement = JsonSerializer.Serialize( request.Statement, DodoSshJsonContext.ResponseOptions), StatementSignature = request.StatementSignature, IdentityProviderBinding = verification.Evidence, IsCurrent = true, CreatedAtUtc = now, }; private void AddWraps(UserAccount user, EnrollmentRequest request, DateTimeOffset now) { database.UserKeyWraps.Add(BuildKdfWrap( user.Id, UserKeyWrapKind.Passphrase, request.WrappedPrivateKey, request.KdfParameters, now)); if (request.RecoveryWrappedPrivateKey is not null && request.RecoveryKdfParameters is not null) { database.UserKeyWraps.Add(BuildKdfWrap( user.Id, UserKeyWrapKind.Recovery, request.RecoveryWrappedPrivateKey, request.RecoveryKdfParameters, now)); } } private static UserKeyWrap BuildKdfWrap( Guid userId, UserKeyWrapKind kind, byte[] wrap, KdfParameters parameters, DateTimeOffset now) => new() { Id = Guid.CreateVersion7(), UserId = userId, Kind = kind, Wrap = wrap, WrapVersion = 1, KdfAlgorithm = parameters.Algorithm, KdfSalt = parameters.Salt, KdfMemoryKibibytes = parameters.MemoryKibibytes, KdfPasses = parameters.Passes, KdfParallelism = parameters.Parallelism, CreatedAtUtc = now, }; /// /// The platform is left unreported: the request carries a device name but no platform, and /// guessing one from a user agent would be a display-only field populated with a lie. The /// devices endpoint sets it properly when it lands. /// private Guid? AddDevice(UserAccount user, EnrollmentRequest request, DateTimeOffset now) { if (request.DevicePublicKey is null || request.DeviceWrappedPrivateKey is null) { return null; } var device = new Device { Id = Guid.CreateVersion7(), UserId = user.Id, Name = request.Statement.DeviceName, Platform = DevicePlatform.Unspecified, PublicKey = request.DevicePublicKey, EnrolledAtUtc = now, LastSeenAtUtc = now, }; database.Devices.Add(device); database.UserKeyWraps.Add(new UserKeyWrap { Id = Guid.CreateVersion7(), UserId = user.Id, Kind = UserKeyWrapKind.Device, DeviceId = device.Id, Wrap = request.DeviceWrappedPrivateKey, WrapVersion = 1, CreatedAtUtc = now, }); return device.Id; } private async Task AppendKeyLogAsync( UserAccount user, EnrollmentRequest request, DateTimeOffset now, CancellationToken cancellationToken) { // Ordered by sequence rather than by timestamp: the sequence is what the chain follows, and // two entries can share a millisecond. var previousHash = await database.KeyLog .OrderByDescending(e => e.Sequence) .Select(e => e.Hash) .FirstOrDefaultAsync(cancellationToken) .ConfigureAwait(false) ?? KeyLogChain.CreateGenesisPreviousHash(); var entry = new KeyLogEntry { UserId = user.Id, Generation = request.Statement.KeyGeneration, EncryptionPublicKey = request.Statement.EncryptionPublicKey, SigningPublicKey = request.Statement.SigningPublicKey, StatementSignature = request.StatementSignature, PreviousHash = previousHash, CreatedAtUtc = now, }; entry.Hash = KeyLogChain.ComputeEntryHash( previousHash, entry.UserId, entry.Generation, entry.EncryptionPublicKey, entry.SigningPublicKey, entry.StatementSignature, entry.CreatedAtUtc); database.KeyLog.Add(entry); return entry; } /// /// The grant carries no key log head. A self-grant has no third party whose key could have been /// substituted, and the entry that would supply the head is being written in this very /// transaction — so the client could not have signed over it. Recording the server's own /// observation instead would produce a row whose signature verifies against nothing. /// private void AddPersonalVault( UserAccount user, EnrollmentRequest request, byte[] fingerprint, DateTimeOffset now) { var personal = request.PersonalVault; database.Vaults.Add(new Vault { Id = personal.VaultId, Name = personal.Name, OwnerKind = VaultOwnerKind.Personal, OwnerUserId = user.Id, KeyGeneration = 1, CreatedAtUtc = now, UpdatedAtUtc = now, }); database.VaultKeyGrants.Add(new VaultKeyGrant { Id = Guid.CreateVersion7(), VaultId = personal.VaultId, KeyGeneration = 1, Kind = GrantKind.Member, RecipientUserId = user.Id, RecipientKeyFingerprint = fingerprint, WrappedKey = personal.WrappedVaultKey, GranterUserId = user.Id, GranterKeyFingerprint = fingerprint, KeyLogHead = null, Signature = personal.GrantSignature, State = GrantState.Active, CreatedAtUtc = now, }); } /// /// Checked rather than left to the primary key, so the failure is a clear 400 about the /// caller's own request instead of a constraint violation surfacing as a 500. The residual /// disclosure — that some vault with this id exists — is accepted: vault ids are unguessable /// random values, so confirming one exists tells a caller nothing they did not already supply. /// private async Task RequireVaultIdIsFreeAsync(Guid vaultId, CancellationToken cancellationToken) { var taken = await database.Vaults .AnyAsync(v => v.Id == vaultId, cancellationToken) .ConfigureAwait(false); if (taken) { throw new EnrollmentInvalidException( "The supplied personal vault id is already in use. Generate a new UUIDv7 and retry."); } } private Task FindCurrentKeyAsync(Guid userId, CancellationToken cancellationToken) => database.UserKeys.SingleOrDefaultAsync( k => k.UserId == userId && k.IsCurrent, cancellationToken); private Task AcquireKeyLogLockAsync(CancellationToken cancellationToken) => database.Database.ExecuteSqlAsync( $"SELECT pg_advisory_xact_lock(hashtextextended({KeyLogLockName}, 0))", cancellationToken); private static KeyStatementFields ToFields(KeyStatement statement) => new( statement.Version, statement.Issuer, statement.Subject, statement.Email, statement.EncryptionPublicKey, statement.SigningPublicKey, statement.KeyGeneration, statement.CreatedAt, statement.DeviceName); private static bool IsUniqueViolation(DbUpdateException exception) => string.Equals( (exception.InnerException as PostgresException)?.SqlState, PostgresErrorCodes.UniqueViolation, StringComparison.Ordinal); }