Public Access
496 lines
20 KiB
C#
496 lines
20 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Publishes a user's first identity key and creates their personal vault.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// 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.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal sealed class EnrollmentService(
|
|
DodoDbContext database,
|
|
IIdentityBindingVerifier bindingVerifier,
|
|
TimeProvider clock,
|
|
ILogger<EnrollmentService> logger)
|
|
{
|
|
/// <summary>
|
|
/// Lock key serialising key log appends across the deployment.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
private const string KeyLogLockName = "dodossh:key_log";
|
|
|
|
/// <summary>Enrolls the caller's first identity key.</summary>
|
|
/// <exception cref="EnrollmentInvalidException">The request is structurally unacceptable.</exception>
|
|
/// <exception cref="IdentityBindingInvalidException">The provider token did not bind the keys.</exception>
|
|
/// <exception cref="AlreadyEnrolledException">A different current key already exists.</exception>
|
|
internal async Task<EnrollmentResponse> 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<EnrollmentResponse> 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);
|
|
}
|
|
|
|
/// <summary>What the write produced, for the response.</summary>
|
|
[System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Auto)]
|
|
private readonly record struct WrittenEnrollment(int Generation, Guid? DeviceId, long KeyLogSequence);
|
|
|
|
/// <summary>Writes every row enrollment produces, inside the caller's transaction.</summary>
|
|
private async Task<WrittenEnrollment> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Decides whether an enrollment against an already-enrolled user is a retry or a conflict.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
private async Task<EnrollmentResponse> 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,
|
|
};
|
|
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
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<KeyLogEntry> 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;
|
|
}
|
|
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
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,
|
|
});
|
|
}
|
|
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
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<UserKey?> FindCurrentKeyAsync(Guid userId, CancellationToken cancellationToken) =>
|
|
database.UserKeys.SingleOrDefaultAsync(
|
|
k => k.UserId == userId && k.IsCurrent,
|
|
cancellationToken);
|
|
|
|
private Task<int> 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);
|
|
}
|