diff --git a/README.md b/README.md index 4a5301c..f1f86f5 100644 --- a/README.md +++ b/README.md @@ -83,9 +83,11 @@ Development — `/openapi/v1.json`. ## Milestones - **M0 — foundation.** Repo structure, build conventions, CI, ADRs. *Done.* -- **M1 — vertical slice.** OIDC login → enroll → create a host → open a shell. Gated on - freezing `DodoSSH.Contracts` and the crypto AAD, plus two client spikes (Linux WebView, - SSH.NET window-change). +- **M1 — vertical slice.** OIDC login → enroll → create a host → open a shell. + *Backend done:* the DSH1 crypto core, the data model, sync push/pull for hosts, `/me`, and + enrollment with the identity-provider key binding. *Remaining:* the desktop client, and the two + spikes that gate it — the Linux WebView and SSH.NET's `window-change`. Both need a Linux and a + macOS machine, so neither has run yet. - **M2 — full personal vault**, robust sync, relay. - **M3 — teams**, sharing, ACLs. - **M4 — hardening and ops**, packaging, self-hosting guide. diff --git a/src/DodoSSH.Api/Authorization/EnrolledRequirement.cs b/src/DodoSSH.Api/Authorization/EnrolledRequirement.cs new file mode 100644 index 0000000..d7818a2 --- /dev/null +++ b/src/DodoSSH.Api/Authorization/EnrolledRequirement.cs @@ -0,0 +1,104 @@ +using DodoSSH.Contracts; +using DodoSSH.Infrastructure; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Authorization.Policy; +using Microsoft.EntityFrameworkCore; + +namespace DodoSSH.Api.Authorization; + +/// +/// Requires that the caller has published an identity key. +/// +/// +/// Not a confidentiality boundary — vault access is decided by ownership and grants, and an +/// unenrolled user holds neither. What this prevents is a client with no key material writing +/// ciphertext nobody can ever decrypt, and reading vault contents it has no way to open. Both +/// present to a user as data corruption, so the server refuses early and says why. +/// +internal sealed class EnrolledRequirement : IAuthorizationRequirement; + +/// Checks enrollment state against the database. +/// +/// Scoped, not singleton, because it uses the request's . Registering it +/// with the default singleton lifetime would capture one context for the process. +/// +internal sealed class EnrolledHandler( + ICurrentUserContext currentUser, + DodoDbContext database, + IHttpContextAccessor accessor) + : AuthorizationHandler +{ + /// + protected override async Task HandleRequirementAsync( + AuthorizationHandlerContext context, + EnrolledRequirement requirement) + { + ArgumentNullException.ThrowIfNull(context); + + // Anonymous callers are already refused by the policy's authentication requirement, which + // produces a 401. Leaving this one unmet as well would turn that into a 403. + if (context.User.Identity?.IsAuthenticated != true) + { + return; + } + + var cancellationToken = accessor.HttpContext?.RequestAborted ?? CancellationToken.None; + + var user = await currentUser.GetOrProvisionAsync(cancellationToken).ConfigureAwait(false); + + var enrolled = await database.UserKeys + .AnyAsync(k => k.UserId == user.Id && k.IsCurrent, cancellationToken) + .ConfigureAwait(false); + + if (enrolled) + { + context.Succeed(requirement); + } + } +} + +/// +/// Turns an unmet into a ProblemDetails response carrying +/// . +/// +/// +/// A bare 403 has an empty body, so a client cannot tell "you have not enrolled" — which it can +/// fix on its own — from "you may not touch this vault", which it cannot. Every other failure falls +/// through to the framework's default handling unchanged. +/// +internal sealed class DodoAuthorizationResultHandler : IAuthorizationMiddlewareResultHandler +{ + private readonly AuthorizationMiddlewareResultHandler defaultHandler = new(); + + /// + public async Task HandleAsync( + RequestDelegate next, + HttpContext context, + AuthorizationPolicy policy, + PolicyAuthorizationResult authorizeResult) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(authorizeResult); + + var enrollmentMissing = authorizeResult.Forbidden + && authorizeResult.AuthorizationFailure is { } failure + && failure.FailedRequirements.OfType().Any(); + + if (!enrollmentMissing) + { + await defaultHandler.HandleAsync(next, context, policy, authorizeResult).ConfigureAwait(false); + return; + } + + await TypedResults.Problem( + detail: "Publish an identity key at POST /api/v1/me/enrollment before using vaults.", + statusCode: StatusCodes.Status403Forbidden, + type: ProblemCodes.TypeBaseUri + ProblemCodes.EnrollmentRequired, + extensions: new Dictionary(StringComparer.Ordinal) + { + ["code"] = ProblemCodes.EnrollmentRequired, + }) + .ExecuteAsync(context) + .ConfigureAwait(false); + } +} diff --git a/src/DodoSSH.Api/Authorization/VaultAccessService.cs b/src/DodoSSH.Api/Authorization/VaultAccessService.cs index 9893161..b461d91 100644 --- a/src/DodoSSH.Api/Authorization/VaultAccessService.cs +++ b/src/DodoSSH.Api/Authorization/VaultAccessService.cs @@ -29,6 +29,16 @@ public interface IVaultAccessService /// answer is an existence oracle that lets a caller enumerate other tenants' vault ids. /// Task ResolveAsync(Guid userId, Guid vaultId, CancellationToken cancellationToken); + + /// + /// Lists every vault the caller can reach, with their effective permissions. + /// + /// + /// Shares the permission rules with rather than reimplementing them + /// for the list case. A listing that disagreed with the per-vault check would show a user a + /// vault they then could not open, or worse, hide one they can. + /// + Task> ListAsync(Guid userId, CancellationToken cancellationToken); } /// @@ -79,4 +89,22 @@ internal sealed class VaultAccessService(DodoDbContext database) : IVaultAccessS // correct behaviour in the meantime. return VaultAccess.Denied; } + + /// + public async Task> ListAsync( + Guid userId, + CancellationToken cancellationToken) + { + // Personal ownership only, matching ResolveAsync. When M3 adds the + // v_user_vault_permission view, both methods change together and neither can drift. + var vaults = await database.Vaults + .Where(v => v.OwnerKind == VaultOwnerKind.Personal + && v.OwnerUserId == userId + && v.DeletedAtUtc == null) + .OrderBy(v => v.CreatedAtUtc) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + return [.. vaults.Select(v => new VaultAccess(v, OwnerPermissions))]; + } } diff --git a/src/DodoSSH.Api/Features/Identity/EnrollmentExceptions.cs b/src/DodoSSH.Api/Features/Identity/EnrollmentExceptions.cs new file mode 100644 index 0000000..16f7a5e --- /dev/null +++ b/src/DodoSSH.Api/Features/Identity/EnrollmentExceptions.cs @@ -0,0 +1,23 @@ +namespace DodoSSH.Api.Features.Identity; + +/// +/// The enrollment request was structurally unacceptable. +/// +/// +/// The message is returned to the caller. Keep it about the shape of their own request and never +/// about other accounts or stored state. +/// +internal sealed class EnrollmentInvalidException(string message) : Exception(message); + +/// The identity-provider token did not bind the supplied keys. +internal sealed class IdentityBindingInvalidException(string message) : Exception(message); + +/// +/// The caller already holds a current identity key that is not the one being enrolled. +/// +/// +/// Re-sending an identical enrollment is not this: that is a retry, and it succeeds +/// idempotently. This is a second, different key, which would silently orphan every vault key +/// wrapped to the first one. +/// +internal sealed class AlreadyEnrolledException(string message) : Exception(message); diff --git a/src/DodoSSH.Api/Features/Identity/EnrollmentLimits.cs b/src/DodoSSH.Api/Features/Identity/EnrollmentLimits.cs new file mode 100644 index 0000000..c545467 --- /dev/null +++ b/src/DodoSSH.Api/Features/Identity/EnrollmentLimits.cs @@ -0,0 +1,74 @@ +namespace DodoSSH.Api.Features.Identity; + +/// +/// Bounds the server enforces on an enrollment request. +/// +/// +/// These are sanity limits, not policy. The server cannot read any of the material they describe, +/// so their job is to stop a buggy or hostile client writing something that is permanently +/// unusable — or permanently weak, which matters because a wrap stored here is exactly what an +/// attacker who ever obtains a database dump gets to grind against offline. +/// +internal static class EnrollmentLimits +{ + /// The only KDF this version accepts. + internal const string KdfAlgorithm = "argon2id"; + + /// Smallest acceptable salt. + internal const int MinimumSaltBytes = 16; + + /// Largest sensible salt. + internal const int MaximumSaltBytes = 64; + + /// + /// Hard floor on Argon2id memory cost, in kibibytes: 64 MiB. + /// + /// + /// Below the recommended 256 MiB, because a low-memory machine must still be able to enroll, + /// and because the recovery-code wrap legitimately uses a cheaper profile — a printable + /// recovery code carries far more entropy than a passphrase, so it needs less stretching. + /// See docs/crypto.md §2. + /// + internal const int MinimumKdfMemoryKibibytes = 64 * 1024; + + /// Ceiling on Argon2id memory cost, in kibibytes: 4 GiB. + /// + /// A client that stores an absurd cost locks itself out of its own vault on every future + /// device, and the server is the only place that can refuse it. + /// + internal const int MaximumKdfMemoryKibibytes = 4 * 1024 * 1024; + + /// Fewest acceptable Argon2id passes. + internal const int MinimumKdfPasses = 2; + + /// Most acceptable Argon2id passes. + internal const int MaximumKdfPasses = 16; + + /// Lanes. libsodium supports one value and one only. + internal const int RequiredKdfParallelism = 1; + + /// + /// Largest accepted wrap or sealed vault key. + /// + /// + /// The secret bundle is about 200 bytes and a sealed vault key is 32, both inside a DSH1 + /// envelope. 4 KiB is generous room for growth while still refusing a client trying to use the + /// identity tables as storage. + /// + internal const int MaximumWrapBytes = 4 * 1024; + + /// Longest vault name, matching the column. + internal const int MaximumVaultNameLength = 256; + + /// Longest device name, matching the column. + internal const int MaximumDeviceNameLength = 256; + + /// Longest issuer, matching the column. + internal const int MaximumIssuerLength = 512; + + /// Longest subject, matching the column. + internal const int MaximumSubjectLength = 256; + + /// Longest email, matching the column. + internal const int MaximumEmailLength = 320; +} diff --git a/src/DodoSSH.Api/Features/Identity/EnrollmentService.cs b/src/DodoSSH.Api/Features/Identity/EnrollmentService.cs new file mode 100644 index 0000000..8e61083 --- /dev/null +++ b/src/DodoSSH.Api/Features/Identity/EnrollmentService.cs @@ -0,0 +1,496 @@ +using System.Security.Cryptography; +using System.Text.Json; +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) + { + var strategy = database.Database.CreateExecutionStrategy(); + + return await strategy.ExecuteAsync(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); +} diff --git a/src/DodoSSH.Api/Features/Identity/EnrollmentValidation.cs b/src/DodoSSH.Api/Features/Identity/EnrollmentValidation.cs new file mode 100644 index 0000000..fb9c493 --- /dev/null +++ b/src/DodoSSH.Api/Features/Identity/EnrollmentValidation.cs @@ -0,0 +1,260 @@ +using DodoSSH.Contracts; +using DodoSSH.Crypto; +using DodoSSH.Domain; + +namespace DodoSSH.Api.Features.Identity; + +/// +/// Structural validation of an enrollment request, before any cryptography or database work. +/// +/// +/// Everything here fails the whole request rather than being recorded and skipped. Enrollment is +/// one indivisible act: a user with a key but no vault, or a vault whose key was never wrapped, is +/// worse than a user who has to try again. +/// +internal static class EnrollmentValidation +{ + /// Validates the request against the caller it claims to describe. + /// Any part of the request is unacceptable. + internal static void Validate(EnrollmentRequest request, UserAccount caller) + { + ValidateStatement(request.Statement, caller); + ValidateSignature(request.StatementSignature); + ValidatePassphraseWrap(request); + ValidateDevice(request); + ValidateRecovery(request); + ValidatePersonalVault(request.PersonalVault); + } + + private static void ValidateStatement(KeyStatement statement, UserAccount caller) + { + if (statement is null) + { + throw new EnrollmentInvalidException("A key statement is required."); + } + + ValidateStatementIdentity(statement, caller); + ValidateStatementKeys(statement); + } + + private static void ValidateStatementIdentity(KeyStatement statement, UserAccount caller) + { + if (statement.Version != KeyStatementCodec.CurrentVersion) + { + throw new EnrollmentInvalidException( + $"Key statement version {statement.Version} is not supported; this server accepts " + + $"version {KeyStatementCodec.CurrentVersion}."); + } + + // The first generation only. Rotation is a separate operation with its own predecessor + // signature, and accepting an arbitrary generation here would let a client skip straight to + // generation 9 and leave gaps the key log could never explain. + if (statement.KeyGeneration != 1) + { + throw new EnrollmentInvalidException( + "Enrollment publishes key generation 1. Use key rotation for later generations."); + } + + // The statement is what the identity provider signs over, so it must describe the caller + // and not merely be well-formed. Without this a user could enroll keys naming someone else. + if (!string.Equals(statement.Issuer, caller.Issuer, StringComparison.Ordinal)) + { + throw new EnrollmentInvalidException("The statement issuer is not the caller's issuer."); + } + + if (!string.Equals(statement.Subject, caller.Subject, StringComparison.Ordinal)) + { + throw new EnrollmentInvalidException("The statement subject is not the caller's subject."); + } + + RequireText(statement.Issuer, EnrollmentLimits.MaximumIssuerLength, "The statement issuer"); + RequireText(statement.Subject, EnrollmentLimits.MaximumSubjectLength, "The statement subject"); + RequireText(statement.DeviceName, EnrollmentLimits.MaximumDeviceNameLength, "The device name"); + + if (statement.Email is { Length: > EnrollmentLimits.MaximumEmailLength }) + { + throw new EnrollmentInvalidException("The statement email is too long."); + } + } + + private static void ValidateStatementKeys(KeyStatement statement) + { + RequireLength( + statement.EncryptionPublicKey, + CryptoSpec.PublicKeySize, + "The encryption public key"); + + RequireLength( + statement.SigningPublicKey, + CryptoSpec.PublicKeySize, + "The signing public key"); + + // Distinct key pairs for agreement and signatures. Equal raw bytes here would mean one key + // used for both roles, which is a standing cryptographic mistake and is also the shape a + // client bug takes when it exports the wrong key twice. + if (statement.EncryptionPublicKey.AsSpan().SequenceEqual(statement.SigningPublicKey)) + { + throw new EnrollmentInvalidException( + "The encryption and signing public keys must differ."); + } + } + + private static void ValidateSignature(byte[] signature) => + RequireLength(signature, CryptoSpec.SignatureSize, "The statement signature"); + + private static void ValidatePassphraseWrap(EnrollmentRequest request) + { + RequireWrap(request.WrappedPrivateKey, "The passphrase wrap"); + ValidateKdf(request.KdfParameters, "passphrase"); + } + + /// + /// Both or neither. A device public key without its wrap registers a device that can never + /// unlock anything — only the holder of the secret bundle can seal it, so the server could + /// never fill the gap in. + /// + private static void ValidateDevice(EnrollmentRequest request) + { + if (request.DevicePublicKey is null && request.DeviceWrappedPrivateKey is null) + { + return; + } + + if (request.DevicePublicKey is null || request.DeviceWrappedPrivateKey is null) + { + throw new EnrollmentInvalidException( + "A device key and its wrap must be supplied together."); + } + + RequireLength(request.DevicePublicKey, CryptoSpec.PublicKeySize, "The device public key"); + RequireWrap(request.DeviceWrappedPrivateKey, "The device wrap"); + } + + /// + /// Recovery is optional at the protocol level and the client should make it very hard to skip. + /// A user who loses their passphrase and every device has no other path back, and a + /// zero-knowledge server genuinely cannot help. See ADR 0001. + /// + private static void ValidateRecovery(EnrollmentRequest request) + { + if (request.RecoveryWrappedPrivateKey is null && request.RecoveryKdfParameters is null) + { + return; + } + + if (request.RecoveryWrappedPrivateKey is null || request.RecoveryKdfParameters is null) + { + throw new EnrollmentInvalidException( + "A recovery wrap and its KDF parameters must be supplied together."); + } + + RequireWrap(request.RecoveryWrappedPrivateKey, "The recovery wrap"); + ValidateKdf(request.RecoveryKdfParameters, "recovery"); + } + + private static void ValidatePersonalVault(PersonalVaultRequest vault) + { + if (vault is null) + { + throw new EnrollmentInvalidException("A personal vault is required."); + } + + if (vault.VaultId == Guid.Empty) + { + throw new EnrollmentInvalidException("The personal vault id must not be empty."); + } + + RequireText(vault.Name, EnrollmentLimits.MaximumVaultNameLength, "The vault name"); + RequireWrap(vault.WrappedVaultKey, "The wrapped vault key"); + RequireLength(vault.GrantSignature, CryptoSpec.SignatureSize, "The grant signature"); + } + + private static void ValidateKdf(KdfParameters? parameters, string role) + { + // A record's non-nullable parameter is a compile-time promise, not a runtime one: a JSON + // body that simply omits the property deserialises to null. Without this the next line is a + // NullReferenceException, so a malformed request would be a 500 instead of a 400. + if (parameters is null) + { + throw new EnrollmentInvalidException($"The {role} KDF parameters are required."); + } + + if (!string.Equals(parameters.Algorithm, EnrollmentLimits.KdfAlgorithm, StringComparison.Ordinal)) + { + throw new EnrollmentInvalidException( + $"The {role} KDF must be {EnrollmentLimits.KdfAlgorithm}."); + } + + if (parameters.Salt is null + or { Length: < EnrollmentLimits.MinimumSaltBytes } + or { Length: > EnrollmentLimits.MaximumSaltBytes }) + { + throw new EnrollmentInvalidException( + $"The {role} salt must be between {EnrollmentLimits.MinimumSaltBytes} and " + + $"{EnrollmentLimits.MaximumSaltBytes} bytes."); + } + + if (parameters.MemoryKibibytes is < EnrollmentLimits.MinimumKdfMemoryKibibytes + or > EnrollmentLimits.MaximumKdfMemoryKibibytes) + { + // Kibibytes, not bytes. Confusing the two is a factor of 1024 in either direction: + // unusably slow one way, trivially crackable the other. See docs/crypto.md §2. + throw new EnrollmentInvalidException( + $"The {role} KDF memory cost must be between " + + $"{EnrollmentLimits.MinimumKdfMemoryKibibytes} and " + + $"{EnrollmentLimits.MaximumKdfMemoryKibibytes} kibibytes."); + } + + if (parameters.Passes is < EnrollmentLimits.MinimumKdfPasses + or > EnrollmentLimits.MaximumKdfPasses) + { + throw new EnrollmentInvalidException( + $"The {role} KDF pass count must be between {EnrollmentLimits.MinimumKdfPasses} " + + $"and {EnrollmentLimits.MaximumKdfPasses}."); + } + + if (parameters.Parallelism != EnrollmentLimits.RequiredKdfParallelism) + { + throw new EnrollmentInvalidException( + $"The {role} KDF parallelism must be {EnrollmentLimits.RequiredKdfParallelism}; " + + "libsodium supports no other value."); + } + } + + private static void RequireLength(byte[]? value, int expected, string description) + { + if (value is null || value.Length != expected) + { + throw new EnrollmentInvalidException( + $"{description} must be exactly {expected} bytes."); + } + } + + private static void RequireWrap(byte[]? value, string description) + { + if (value is null or { Length: 0 }) + { + throw new EnrollmentInvalidException($"{description} is required."); + } + + if (value.Length > EnrollmentLimits.MaximumWrapBytes) + { + throw new EnrollmentInvalidException( + $"{description} exceeds {EnrollmentLimits.MaximumWrapBytes} bytes."); + } + } + + private static void RequireText(string? value, int maximumLength, string description) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new EnrollmentInvalidException($"{description} is required."); + } + + if (value.Length > maximumLength) + { + throw new EnrollmentInvalidException( + $"{description} exceeds {maximumLength} characters."); + } + } +} diff --git a/src/DodoSSH.Api/Features/Identity/IdentityBindingVerifier.cs b/src/DodoSSH.Api/Features/Identity/IdentityBindingVerifier.cs new file mode 100644 index 0000000..d4903cf --- /dev/null +++ b/src/DodoSSH.Api/Features/Identity/IdentityBindingVerifier.cs @@ -0,0 +1,210 @@ +using System.Text.Json.Nodes; +using DodoSSH.Api.Setup; +using DodoSSH.Crypto; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; + +namespace DodoSSH.Api.Features.Identity; + +/// The outcome of checking an identity-provider key binding. +/// Whether the token bound the supplied keys to the caller. +/// Why it did not, when it did not. +/// +/// The binding evidence to persist, as JSON. Includes the token verbatim so a client can repeat +/// the verification against the provider's own JWKS rather than trusting this server's word. +/// +internal sealed record BindingVerification(bool Verified, string? Failure, string? Evidence) +{ + internal static BindingVerification Failed(string failure) => new(false, failure, null); +} + +/// Verifies that an identity provider signed an assertion over a set of public keys. +internal interface IIdentityBindingVerifier +{ + /// + /// Verifies a binding ID token. + /// + /// The ID token from the binding authorization. + /// Issuer named by the key statement. + /// Subject the access token authenticated as. + /// + /// The statement's binding value, from . + /// + /// Cancellation token. + Task VerifyAsync( + string idToken, + string expectedIssuer, + string expectedSubject, + string expectedNonce, + CancellationToken cancellationToken); +} + +/// +/// Identity-provider binding verification. +/// +/// +/// +/// This is the primary public-key trust anchor, and everything else in the design is downstream of +/// it. At enrollment the client hashes its key statement and runs a fresh OIDC authorization with +/// that hash as the nonce, so the resulting ID token is an identity-provider signature over +/// exactly those keys. This server cannot mint such a signature, so it cannot invent a key for a +/// user who never enrolled — which is the attack that would otherwise let an operator read every +/// vault by publishing its own key as yours. See ADR 0001. +/// +/// +/// Be clear about the residual risk: this makes the identity provider a key-distribution trust +/// root, and in a self-hosted deployment the person running Keycloak is usually the person running +/// DodoSSH. It raises the bar from one compromised service to one compromised service plus a +/// detectable artefact — the key log — not to zero. +/// +/// +/// Verifying here does not make this the boundary. Clients must repeat the check against +/// the provider's JWKS fetched directly, which is why the token is retained verbatim. A check only +/// the server performs is a claim, not evidence. +/// +/// +internal sealed class IdentityBindingVerifier( + IOptionsMonitor jwtBearerOptions, + IOptions oidcOptions, + TimeProvider clock) + : IIdentityBindingVerifier +{ + /// Stateless and documented as thread-safe, so one instance serves every request. + private static readonly JsonWebTokenHandler TokenHandler = new(); + + /// Version of the stored evidence document. + private const int EvidenceVersion = 1; + + /// + public async Task VerifyAsync( + string idToken, + string expectedIssuer, + string expectedSubject, + string expectedNonce, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(idToken)) + { + return BindingVerification.Failed("No identity provider token was supplied."); + } + + var parameters = await BuildValidationParametersAsync(cancellationToken).ConfigureAwait(false); + + var result = await TokenHandler.ValidateTokenAsync(idToken, parameters).ConfigureAwait(false); + if (!result.IsValid || result.SecurityToken is not JsonWebToken token) + { + // Deliberately not echoing the library's reason. It is the caller's own token, so + // there is no leak, but the messages are long, version-dependent, and end up in + // client-side string matching if we make them part of the contract. + return BindingVerification.Failed( + "The identity provider token failed signature, issuer, audience or lifetime validation."); + } + + if (!string.Equals(token.Issuer, expectedIssuer, StringComparison.Ordinal)) + { + // The statement names the issuer that vouches for it. If that disagrees with the token, + // the statement is describing a different identity than the one being proved. + return BindingVerification.Failed("The token issuer does not match the key statement."); + } + + if (!string.Equals(token.Subject, expectedSubject, StringComparison.Ordinal)) + { + // Without this, any user of this provider could bind keys to another user's account. + return BindingVerification.Failed("The token subject is not the authenticated caller."); + } + + if (!token.TryGetPayloadValue("nonce", out var nonce) || nonce is null) + { + return BindingVerification.Failed("The token carries no nonce, so it binds no keys."); + } + + if (!string.Equals(nonce, expectedNonce, StringComparison.Ordinal)) + { + return BindingVerification.Failed("The token nonce is not this key statement's hash."); + } + + return new BindingVerification(true, null, BuildEvidence(idToken, token, nonce)); + } + + /// + /// The signing keys come from the same the bearer + /// handler uses, so there is one metadata cache, one refresh schedule and one backchannel + /// rather than a second set that could drift after a provider key rotation. + /// + private async Task BuildValidationParametersAsync( + CancellationToken cancellationToken) + { + var bearer = jwtBearerOptions.Get(JwtBearerDefaults.AuthenticationScheme); + + var configurationManager = bearer.ConfigurationManager + ?? throw new InvalidOperationException( + "The bearer scheme has no configuration manager, so provider metadata is unavailable."); + + var configuration = await configurationManager + .GetConfigurationAsync(cancellationToken) + .ConfigureAwait(false); + + return new TokenValidationParameters + { + ValidateIssuer = true, + + // From discovery rather than from the configured authority, which may differ by a + // trailing slash or be an alias. The document is authoritative about its own issuer. + ValidIssuer = configuration.Issuer, + + ValidateAudience = true, + + // An ID token is audienced to the client that requested it, never to this API. + // Validating it against the API audience would reject every genuine binding. + ValidAudience = oidcOptions.Value.ClientId, + + ValidateLifetime = true, + RequireExpirationTime = true, + ClockSkew = TimeSpan.FromSeconds(30), + + ValidateIssuerSigningKey = true, + RequireSignedTokens = true, + IssuerSigningKeys = configuration.SigningKeys, + + // Algorithms are deliberately not restricted: providers legitimately use RS256, PS256, + // ES256 and now EdDSA, and a fixed list breaks deployments for no gain. The classic + // algorithm-confusion attack is already closed, because the discovery document only + // publishes asymmetric keys and a symmetric algorithm cannot consume one. + }; + } + + /// Builds the evidence document stored alongside the key. + /// + /// The token is stored verbatim so that clients — and later the public-key directory — can + /// verify the binding themselves. It has already expired by the time any third party reads it, + /// and its audience is the desktop client rather than any API, so it is not a usable credential + /// elsewhere. Storing only our summary would mean asking clients to trust the server on the one + /// question the whole design exists to avoid trusting it about. + /// + private string BuildEvidence(string idToken, JsonWebToken token, string nonce) + { + var evidence = new JsonObject + { + ["v"] = EvidenceVersion, + ["idToken"] = idToken, + ["issuer"] = token.Issuer, + ["subject"] = token.Subject, + ["audiences"] = new JsonArray([.. token.Audiences.Select(a => JsonValue.Create(a))]), + ["nonce"] = nonce, + ["issuedAtUtc"] = token.IssuedAt.ToString("O", System.Globalization.CultureInfo.InvariantCulture), + ["expiresAtUtc"] = token.ValidTo.ToString("O", System.Globalization.CultureInfo.InvariantCulture), + ["verifiedAtUtc"] = clock.GetUtcNow().ToString("O", System.Globalization.CultureInfo.InvariantCulture), + }; + + if (token.TryGetPayloadValue("auth_time", out var authTime)) + { + evidence["authTimeUtc"] = DateTimeOffset + .FromUnixTimeSeconds(authTime) + .ToString("O", System.Globalization.CultureInfo.InvariantCulture); + } + + return evidence.ToJsonString(); + } +} diff --git a/src/DodoSSH.Api/Features/Identity/IdentityEndpoints.cs b/src/DodoSSH.Api/Features/Identity/IdentityEndpoints.cs new file mode 100644 index 0000000..60f3a24 --- /dev/null +++ b/src/DodoSSH.Api/Features/Identity/IdentityEndpoints.cs @@ -0,0 +1,96 @@ +using DodoSSH.Api.Authorization; +using DodoSSH.Api.Setup; +using DodoSSH.Contracts; +using Microsoft.AspNetCore.Http.HttpResults; + +namespace DodoSSH.Api.Features.Identity; + +/// +/// The caller's own identity: profile, unlock state and enrollment. +/// +/// +/// Both endpoints run under rather than +/// , and must. They are how a client discovers that it needs to +/// enroll and then does so; gating them on enrollment would make enrollment unreachable. +/// +internal static class IdentityEndpoints +{ + internal static IEndpointRouteBuilder MapIdentityEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/api/v1/me") + .RequireAuthorization(Auth.AuthenticatedPolicy) + .WithTags("Identity"); + + group.MapGet("/", GetMeAsync) + .WithName("GetMe") + .WithSummary("The caller's profile, enrollment state and reachable vaults."); + + group.MapPost("/enrollment", EnrollAsync) + .WithName("Enroll") + .WithSummary("Publishes the caller's first identity key and creates their personal vault."); + + return app; + } + + private static async Task> GetMeAsync( + ICurrentUserContext currentUser, + IdentityService identity, + CancellationToken cancellationToken) + { + // Provisioning happens here, on the first authenticated request, keyed on (issuer, subject). + var user = await currentUser.GetOrProvisionAsync(cancellationToken).ConfigureAwait(false); + + return TypedResults.Ok( + await identity.GetMeAsync(user, cancellationToken).ConfigureAwait(false)); + } + + private static async Task, ProblemHttpResult>> EnrollAsync( + EnrollmentRequest request, + ICurrentUserContext currentUser, + EnrollmentService enrollment, + CancellationToken cancellationToken) + { + var user = await currentUser.GetOrProvisionAsync(cancellationToken).ConfigureAwait(false); + + try + { + // 200 rather than 201. A retry of an identical request returns the same body, so there + // is no single moment of creation to point a Location header at, and the client already + // knows the vault id — it chose it. + var response = await enrollment.EnrollAsync(user, request, cancellationToken) + .ConfigureAwait(false); + + return TypedResults.Ok(response); + } + catch (EnrollmentInvalidException exception) + { + return Problem( + StatusCodes.Status400BadRequest, + ProblemCodes.InvalidEnrollment, + exception.Message); + } + catch (IdentityBindingInvalidException exception) + { + // 400, not 401: the access token authenticated the caller perfectly well. What failed is + // the separate assertion they supplied about their own keys, which is request content. + return Problem( + StatusCodes.Status400BadRequest, + ProblemCodes.IdentityBindingInvalid, + exception.Message); + } + catch (AlreadyEnrolledException exception) + { + return Problem( + StatusCodes.Status409Conflict, + ProblemCodes.AlreadyEnrolled, + exception.Message); + } + } + + private static ProblemHttpResult Problem(int statusCode, string code, string detail) => + TypedResults.Problem( + detail: detail, + statusCode: statusCode, + type: ProblemCodes.TypeBaseUri + code, + extensions: new Dictionary(StringComparer.Ordinal) { ["code"] = code }); +} diff --git a/src/DodoSSH.Api/Features/Identity/IdentityLog.cs b/src/DodoSSH.Api/Features/Identity/IdentityLog.cs new file mode 100644 index 0000000..3aceb5f --- /dev/null +++ b/src/DodoSSH.Api/Features/Identity/IdentityLog.cs @@ -0,0 +1,36 @@ +namespace DodoSSH.Api.Features.Identity; + +/// +/// Source-generated log events for identity and enrollment. +/// +/// +/// Ids, counts and outcomes only. Never a wrap, a token, a nonce or a public key: an enrollment log +/// line that carried key material would put in the log exactly what the vault design exists to keep +/// out of the server's reach. +/// +internal static partial class IdentityLog +{ + [LoggerMessage( + EventId = 2001, + Level = LogLevel.Information, + Message = "Enrolled identity key generation {Generation} for user {UserId} at key log sequence {Sequence}.")] + internal static partial void Enrolled(ILogger logger, Guid userId, int generation, long sequence); + + [LoggerMessage( + EventId = 2002, + Level = LogLevel.Information, + Message = "Enrollment retry for user {UserId} matched the stored key; returning the original result.")] + internal static partial void EnrollmentReplayed(ILogger logger, Guid userId); + + [LoggerMessage( + EventId = 2003, + Level = LogLevel.Warning, + Message = "Rejected enrollment for user {UserId}: the identity provider binding did not verify.")] + internal static partial void BindingRejected(ILogger logger, Guid userId); + + [LoggerMessage( + EventId = 2004, + Level = LogLevel.Warning, + Message = "Rejected enrollment for user {UserId}: the key statement self-signature did not verify.")] + internal static partial void StatementSignatureRejected(ILogger logger, Guid userId); +} diff --git a/src/DodoSSH.Api/Features/Identity/IdentityService.cs b/src/DodoSSH.Api/Features/Identity/IdentityService.cs new file mode 100644 index 0000000..b6951ea --- /dev/null +++ b/src/DodoSSH.Api/Features/Identity/IdentityService.cs @@ -0,0 +1,134 @@ +using DodoSSH.Api.Authorization; +using DodoSSH.Contracts; +using DodoSSH.Domain; +using DodoSSH.Infrastructure; +using Microsoft.EntityFrameworkCore; + +namespace DodoSSH.Api.Features.Identity; + +/// +/// Assembles the caller's own profile and unlock state. +/// +/// +/// This is the first authenticated call a client makes and the only one that works before +/// enrollment, so it has to answer "what do I do next" unambiguously: either enroll, or unlock with +/// these parameters and open these vaults. +/// +internal sealed class IdentityService(DodoDbContext database, IVaultAccessService vaultAccess) +{ + /// Builds the caller's profile. + internal async Task GetMeAsync(UserAccount user, CancellationToken cancellationToken) + { + var key = await database.UserKeys + .SingleOrDefaultAsync(k => k.UserId == user.Id && k.IsCurrent, cancellationToken) + .ConfigureAwait(false); + + var wrap = key is null + ? null + : await database.UserKeyWraps + .SingleOrDefaultAsync( + w => w.UserId == user.Id && w.Kind == UserKeyWrapKind.Passphrase, + cancellationToken) + .ConfigureAwait(false); + + var vaults = await BuildVaultsAsync(user, key, cancellationToken).ConfigureAwait(false); + + return new MeResponse( + UserId: user.Id, + Issuer: user.Issuer, + Subject: user.Subject, + Email: user.Email, + DisplayName: user.DisplayName, + + // The single flag the client branches on at startup. + EnrollmentRequired: key is null, + + KeyGeneration: key?.Generation, + + // Both are needed to unlock, and both are useless to the server. Returning them + // together is what lets a client cache them and unlock offline later: the KDF salt must + // never be something it has to fetch at unlock time. + WrappedPrivateKey: wrap?.Wrap, + KdfParameters: ToContract(wrap), + + Vaults: vaults); + } + + private async Task> BuildVaultsAsync( + UserAccount user, + UserKey? key, + CancellationToken cancellationToken) + { + var accessible = await vaultAccess.ListAsync(user.Id, cancellationToken).ConfigureAwait(false); + if (accessible.Count == 0) + { + return []; + } + + var vaultIds = accessible.Select(a => a.Vault!.Id).ToArray(); + + var grants = await database.VaultKeyGrants + .Where(g => vaultIds.Contains(g.VaultId) + && g.RecipientUserId == user.Id + && g.State == GrantState.Active + && g.RevokedAtUtc == null) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + var summaries = new List(accessible.Count); + + foreach (var access in accessible) + { + var vault = access.Vault!; + + // The grant must match both the current key generation and the exact identity key it + // was wrapped to. A grant left over from a superseded key is not merely stale — the + // client's current private key cannot open it, so offering it would produce a tag + // failure the user reads as data corruption. + var grant = grants.Find(g => + g.VaultId == vault.Id + && g.KeyGeneration == vault.KeyGeneration + && key is not null + && g.RecipientKeyFingerprint.AsSpan().SequenceEqual(key.FingerprintSha256)); + + summaries.Add(new VaultSummary( + VaultId: vault.Id, + Name: vault.Name, + IsPersonal: vault.OwnerKind == VaultOwnerKind.Personal, + TeamId: vault.TeamId, + KeyGeneration: (uint)vault.KeyGeneration, + Permissions: (int)access.Permissions, + + // Null means temporarily unreadable, not forbidden. A member holding Share must + // re-wrap it; the client has to say so rather than showing an empty vault. + WrappedVaultKey: grant?.WrappedKey, + + RekeyRequired: vault.RekeyRequired)); + } + + return summaries; + } + + private static KdfParameters? ToContract(UserKeyWrap? wrap) + { + // The CHECK constraint on user_key_wrap guarantees a passphrase wrap carries every + // parameter, so a partial row cannot exist. Pattern-matched anyway rather than asserted, + // because a null here would become an unopenable vault rather than an exception. + if (wrap is null + || wrap.KdfAlgorithm is null + || wrap.KdfSalt is null + || wrap.KdfMemoryKibibytes is null + || wrap.KdfPasses is null + || wrap.KdfParallelism is null) + { + return null; + } + + return new KdfParameters( + wrap.KdfAlgorithm, + wrap.KdfSalt, + wrap.KdfMemoryKibibytes.Value, + wrap.KdfPasses.Value, + wrap.KdfParallelism.Value); + } +} diff --git a/src/DodoSSH.Api/Features/Sync/SyncEndpoints.cs b/src/DodoSSH.Api/Features/Sync/SyncEndpoints.cs index 878bfc7..837a2f3 100644 --- a/src/DodoSSH.Api/Features/Sync/SyncEndpoints.cs +++ b/src/DodoSSH.Api/Features/Sync/SyncEndpoints.cs @@ -18,8 +18,11 @@ internal static class SyncEndpoints { internal static IEndpointRouteBuilder MapSyncEndpoints(this IEndpointRouteBuilder app) { + // Enrolled, not merely authenticated. A caller with no identity key holds no vault key + // either, so it can neither produce ciphertext anyone can read nor read what is there. + // Serving it would look like corruption; refusing with a code it can act on does not. var group = app.MapGroup("/api/v1/vaults/{vaultId:guid}/sync") - .RequireAuthorization(Auth.AuthenticatedPolicy) + .RequireAuthorization(Auth.EnrolledPolicy) .WithTags("Sync"); // POST rather than GET: the filters live in the body, cursors are opaque, and no caching is diff --git a/src/DodoSSH.Api/Program.cs b/src/DodoSSH.Api/Program.cs index f877ce9..4466112 100644 --- a/src/DodoSSH.Api/Program.cs +++ b/src/DodoSSH.Api/Program.cs @@ -1,6 +1,9 @@ using DodoSSH.Api.Authorization; +using DodoSSH.Api.Features.Identity; using DodoSSH.Api.Features.Sync; using DodoSSH.Api.Setup; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Authorization.Policy; var builder = WebApplication.CreateBuilder(args); @@ -22,7 +25,18 @@ builder.Services.AddHttpContextAccessor(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddSingleton(); + +// Scoped rather than the AddAuthorization default of singleton: the handler reads the request's +// DbContext, and a singleton would capture one for the lifetime of the process. +builder.Services.AddScoped(); + +// Replaces the framework default, which is also registered as a singleton. Last registration wins. +builder.Services.AddSingleton(); + builder.Services.AddProblemDetails(); var app = builder.Build(); diff --git a/src/DodoSSH.Api/Setup/Auth.cs b/src/DodoSSH.Api/Setup/Auth.cs index 1c63bb5..2bbc978 100644 --- a/src/DodoSSH.Api/Setup/Auth.cs +++ b/src/DodoSSH.Api/Setup/Auth.cs @@ -58,10 +58,15 @@ internal static class Auth { options.AddPolicy(AuthenticatedPolicy, policy => policy.RequireAuthenticatedUser()); - // Enrollment state lives in the database, so the real handler arrives with the - // enrollment feature. Registered now so endpoint groups can reference the policy name - // and the endpoint-inventory test has something to assert against. - options.AddPolicy(EnrolledPolicy, policy => policy.RequireAuthenticatedUser()); + // Enrollment state lives in the database, so this is satisfied by EnrolledHandler. + // An unmet EnrolledRequirement is rewritten into a ProblemDetails carrying + // "enrollment-required" by DodoAuthorizationResultHandler, because an empty 403 cannot + // tell a client whether the problem is theirs to fix. + options.AddPolicy( + EnrolledPolicy, + policy => policy + .RequireAuthenticatedUser() + .AddRequirements(new Authorization.EnrolledRequirement())); // Deny by default: an endpoint without an explicit policy still requires a caller. options.FallbackPolicy = options.GetPolicy(AuthenticatedPolicy); diff --git a/src/DodoSSH.Api/Setup/EndpointRegistration.cs b/src/DodoSSH.Api/Setup/EndpointRegistration.cs index 3d2fa4e..39101d0 100644 --- a/src/DodoSSH.Api/Setup/EndpointRegistration.cs +++ b/src/DodoSSH.Api/Setup/EndpointRegistration.cs @@ -1,3 +1,4 @@ +using DodoSSH.Api.Features.Identity; using DodoSSH.Api.Features.Meta; using DodoSSH.Api.Features.Sync; @@ -17,10 +18,11 @@ internal static class EndpointRegistration internal static WebApplication MapDodoEndpoints(this WebApplication app) { app.MapMetaEndpoints(); + app.MapIdentityEndpoints(); app.MapSyncEndpoints(); // Registered as each feature lands: - // Identity — /me, enrollment, key rotation, devices + // Identity — key rotation, devices, passphrase change // Directory — public-key lookup // Vaults — grants, rekey, ACL // Relay — tickets and the WebSocket diff --git a/src/DodoSSH.Contracts/DodoSshJsonContext.cs b/src/DodoSSH.Contracts/DodoSshJsonContext.cs index 8624b39..01fb835 100644 --- a/src/DodoSSH.Contracts/DodoSshJsonContext.cs +++ b/src/DodoSSH.Contracts/DodoSshJsonContext.cs @@ -30,6 +30,10 @@ namespace DodoSSH.Contracts; [JsonSerializable(typeof(DodoSshConfiguration))] [JsonSerializable(typeof(MeResponse))] [JsonSerializable(typeof(EnrollmentRequest))] + +// Explicit, although it is reachable through EnrollmentRequest. The server serialises a statement +// on its own when persisting it, and a resolver that had only inferred it would fail at runtime. +[JsonSerializable(typeof(KeyStatement))] [JsonSerializable(typeof(EnrollmentResponse))] [JsonSerializable(typeof(DirectoryEntry))] [JsonSerializable(typeof(IReadOnlyList))] diff --git a/src/DodoSSH.Contracts/Enrollment.cs b/src/DodoSSH.Contracts/Enrollment.cs index fecf5bc..659e921 100644 --- a/src/DodoSSH.Contracts/Enrollment.cs +++ b/src/DodoSSH.Contracts/Enrollment.cs @@ -53,6 +53,40 @@ public sealed record KeyStatement( DateTimeOffset CreatedAt, string DeviceName); +/// +/// The personal vault a client creates as part of enrolling. +/// +/// +/// +/// The vault key is generated on the client and sealed to the user's own X25519 key, so the +/// server cannot produce this and cannot verify that contains +/// anything in particular. It stores the bytes and the signature that attributes them. +/// +/// +/// is chosen by the client, not the server. That is what makes enrollment +/// safely retryable: a client whose request timed out re-sends the identical body and gets the +/// identical result, instead of accumulating a second vault it never learns about. It is also +/// required by the grant signature, which covers the vault id — see docs/crypto.md §7. +/// +/// +/// Client-generated UUIDv7 for the new vault. +/// Display name. Plaintext, as all vault names are. +/// +/// The vault key sealed to the enrolling user's own encryption key. Opaque to the server. +/// +/// +/// Ed25519 signature over the canonical grant tuple, by the key being enrolled. A self-grant +/// carries no key log head, because there is no third party whose key could have been +/// substituted. +/// +/// Signing timestamp, part of the signed tuple. +public sealed record PersonalVaultRequest( + Guid VaultId, + string Name, + byte[] WrappedVaultKey, + byte[] GrantSignature, + DateTimeOffset GrantedAt); + /// A request to enroll a user's first identity key pair. /// The key statement. /// Ed25519 self-signature over the statement. @@ -68,10 +102,18 @@ public sealed record KeyStatement( /// X25519 public key of this device, so the bundle can also be wrapped to the device and /// unlocked without re-entering the passphrase. /// +/// +/// The same bundle sealed to . Required whenever a device key +/// is supplied: only the holder of the bundle can produce this, so a device key without its wrap +/// registers a device that can never unlock anything. +/// /// /// The same bundle sealed under a recovery-code-derived key. /// /// Parameters for the recovery wrap. +/// +/// The personal vault to create, with its key already wrapped to the enrolling user. +/// public sealed record EnrollmentRequest( KeyStatement Statement, byte[] StatementSignature, @@ -79,8 +121,10 @@ public sealed record EnrollmentRequest( byte[] WrappedPrivateKey, KdfParameters KdfParameters, byte[]? DevicePublicKey, + byte[]? DeviceWrappedPrivateKey, byte[]? RecoveryWrappedPrivateKey, - KdfParameters? RecoveryKdfParameters); + KdfParameters? RecoveryKdfParameters, + PersonalVaultRequest PersonalVault); /// Result of a successful enrollment. /// The user's identifier. diff --git a/src/DodoSSH.Contracts/ProblemCodes.cs b/src/DodoSSH.Contracts/ProblemCodes.cs index 35d580f..fdb2f18 100644 --- a/src/DodoSSH.Contracts/ProblemCodes.cs +++ b/src/DodoSSH.Contracts/ProblemCodes.cs @@ -28,9 +28,21 @@ public static class ProblemCodes /// The caller has not yet enrolled a public key, so no vault is reachable. public const string EnrollmentRequired = "enrollment-required"; - /// Enrollment was attempted for a user who already holds a current key. + /// Enrollment was attempted for a user who already holds a different current key. public const string AlreadyEnrolled = "already-enrolled"; + /// + /// The enrollment request was structurally invalid: a bad key length, mismatched KDF + /// parameters, a statement that does not describe the caller, or a vault id already in use. + /// + public const string InvalidEnrollment = "invalid-enrollment"; + + /// + /// The identity-provider token did not bind the supplied keys: a bad signature, the wrong + /// subject or audience, an expired token, or a nonce that is not the statement's hash. + /// + public const string IdentityBindingInvalid = "identity-binding-invalid"; + /// The relay refused the requested target. Never states why, to avoid a probe oracle. public const string RelayTargetRejected = "relay-target-rejected"; diff --git a/src/DodoSSH.Contracts/PublicAPI.Unshipped.txt b/src/DodoSSH.Contracts/PublicAPI.Unshipped.txt index 695cce1..cd09859 100644 --- a/src/DodoSSH.Contracts/PublicAPI.Unshipped.txt +++ b/src/DodoSSH.Contracts/PublicAPI.Unshipped.txt @@ -45,15 +45,19 @@ DodoSSH.Contracts.EncryptedPayload.KeyGeneration.get -> uint DodoSSH.Contracts.EncryptedPayload.KeyGeneration.init -> void DodoSSH.Contracts.EnrollmentRequest DodoSSH.Contracts.EnrollmentRequest.$() -> DodoSSH.Contracts.EnrollmentRequest! -DodoSSH.Contracts.EnrollmentRequest.Deconstruct(out DodoSSH.Contracts.KeyStatement! Statement, out byte[]! StatementSignature, out string! IdentityProviderToken, out byte[]! WrappedPrivateKey, out DodoSSH.Contracts.KdfParameters! KdfParameters, out byte[]? DevicePublicKey, out byte[]? RecoveryWrappedPrivateKey, out DodoSSH.Contracts.KdfParameters? RecoveryKdfParameters) -> void +DodoSSH.Contracts.EnrollmentRequest.Deconstruct(out DodoSSH.Contracts.KeyStatement! Statement, out byte[]! StatementSignature, out string! IdentityProviderToken, out byte[]! WrappedPrivateKey, out DodoSSH.Contracts.KdfParameters! KdfParameters, out byte[]? DevicePublicKey, out byte[]? DeviceWrappedPrivateKey, out byte[]? RecoveryWrappedPrivateKey, out DodoSSH.Contracts.KdfParameters? RecoveryKdfParameters, out DodoSSH.Contracts.PersonalVaultRequest! PersonalVault) -> void DodoSSH.Contracts.EnrollmentRequest.DevicePublicKey.get -> byte[]? DodoSSH.Contracts.EnrollmentRequest.DevicePublicKey.init -> void -DodoSSH.Contracts.EnrollmentRequest.EnrollmentRequest(DodoSSH.Contracts.KeyStatement! Statement, byte[]! StatementSignature, string! IdentityProviderToken, byte[]! WrappedPrivateKey, DodoSSH.Contracts.KdfParameters! KdfParameters, byte[]? DevicePublicKey, byte[]? RecoveryWrappedPrivateKey, DodoSSH.Contracts.KdfParameters? RecoveryKdfParameters) -> void +DodoSSH.Contracts.EnrollmentRequest.DeviceWrappedPrivateKey.get -> byte[]? +DodoSSH.Contracts.EnrollmentRequest.DeviceWrappedPrivateKey.init -> void +DodoSSH.Contracts.EnrollmentRequest.EnrollmentRequest(DodoSSH.Contracts.KeyStatement! Statement, byte[]! StatementSignature, string! IdentityProviderToken, byte[]! WrappedPrivateKey, DodoSSH.Contracts.KdfParameters! KdfParameters, byte[]? DevicePublicKey, byte[]? DeviceWrappedPrivateKey, byte[]? RecoveryWrappedPrivateKey, DodoSSH.Contracts.KdfParameters? RecoveryKdfParameters, DodoSSH.Contracts.PersonalVaultRequest! PersonalVault) -> void DodoSSH.Contracts.EnrollmentRequest.Equals(DodoSSH.Contracts.EnrollmentRequest? other) -> bool DodoSSH.Contracts.EnrollmentRequest.IdentityProviderToken.get -> string! DodoSSH.Contracts.EnrollmentRequest.IdentityProviderToken.init -> void DodoSSH.Contracts.EnrollmentRequest.KdfParameters.get -> DodoSSH.Contracts.KdfParameters! DodoSSH.Contracts.EnrollmentRequest.KdfParameters.init -> void +DodoSSH.Contracts.EnrollmentRequest.PersonalVault.get -> DodoSSH.Contracts.PersonalVaultRequest! +DodoSSH.Contracts.EnrollmentRequest.PersonalVault.init -> void DodoSSH.Contracts.EnrollmentRequest.RecoveryKdfParameters.get -> DodoSSH.Contracts.KdfParameters? DodoSSH.Contracts.EnrollmentRequest.RecoveryKdfParameters.init -> void DodoSSH.Contracts.EnrollmentRequest.RecoveryWrappedPrivateKey.get -> byte[]? @@ -180,6 +184,21 @@ DodoSSH.Contracts.OidcConfiguration.LoopbackRedirectPattern.init -> void DodoSSH.Contracts.OidcConfiguration.OidcConfiguration(System.Uri! Authority, string! ClientId, System.Collections.Generic.IReadOnlyList! Scopes, string! LoopbackRedirectPattern) -> void DodoSSH.Contracts.OidcConfiguration.Scopes.get -> System.Collections.Generic.IReadOnlyList! DodoSSH.Contracts.OidcConfiguration.Scopes.init -> void +DodoSSH.Contracts.PersonalVaultRequest +DodoSSH.Contracts.PersonalVaultRequest.$() -> DodoSSH.Contracts.PersonalVaultRequest! +DodoSSH.Contracts.PersonalVaultRequest.Deconstruct(out System.Guid VaultId, out string! Name, out byte[]! WrappedVaultKey, out byte[]! GrantSignature, out System.DateTimeOffset GrantedAt) -> void +DodoSSH.Contracts.PersonalVaultRequest.Equals(DodoSSH.Contracts.PersonalVaultRequest? other) -> bool +DodoSSH.Contracts.PersonalVaultRequest.GrantSignature.get -> byte[]! +DodoSSH.Contracts.PersonalVaultRequest.GrantSignature.init -> void +DodoSSH.Contracts.PersonalVaultRequest.GrantedAt.get -> System.DateTimeOffset +DodoSSH.Contracts.PersonalVaultRequest.GrantedAt.init -> void +DodoSSH.Contracts.PersonalVaultRequest.Name.get -> string! +DodoSSH.Contracts.PersonalVaultRequest.Name.init -> void +DodoSSH.Contracts.PersonalVaultRequest.PersonalVaultRequest(System.Guid VaultId, string! Name, byte[]! WrappedVaultKey, byte[]! GrantSignature, System.DateTimeOffset GrantedAt) -> void +DodoSSH.Contracts.PersonalVaultRequest.VaultId.get -> System.Guid +DodoSSH.Contracts.PersonalVaultRequest.VaultId.init -> void +DodoSSH.Contracts.PersonalVaultRequest.WrappedVaultKey.get -> byte[]! +DodoSSH.Contracts.PersonalVaultRequest.WrappedVaultKey.init -> void DodoSSH.Contracts.ProblemCodes DodoSSH.Contracts.RelayConfiguration DodoSSH.Contracts.RelayConfiguration.$() -> DodoSSH.Contracts.RelayConfiguration! @@ -415,7 +434,9 @@ const DodoSSH.Contracts.ProblemCodes.ClientTooOld = "client-too-old" -> string! const DodoSSH.Contracts.ProblemCodes.EnrollmentRequired = "enrollment-required" -> string! const DodoSSH.Contracts.ProblemCodes.Forbidden = "forbidden" -> string! const DodoSSH.Contracts.ProblemCodes.IdempotencyKeyReuse = "idempotency-key-reuse" -> string! +const DodoSSH.Contracts.ProblemCodes.IdentityBindingInvalid = "identity-binding-invalid" -> string! const DodoSSH.Contracts.ProblemCodes.InvalidCursor = "invalid-cursor" -> string! +const DodoSSH.Contracts.ProblemCodes.InvalidEnrollment = "invalid-enrollment" -> string! const DodoSSH.Contracts.ProblemCodes.PushBatchTooLarge = "push-batch-too-large" -> string! const DodoSSH.Contracts.ProblemCodes.RelayLimitReached = "relay-limit-reached" -> string! const DodoSSH.Contracts.ProblemCodes.RelayTargetRejected = "relay-target-rejected" -> string! @@ -452,6 +473,9 @@ override DodoSSH.Contracts.MetaResponse.ToString() -> string! override DodoSSH.Contracts.OidcConfiguration.Equals(object? obj) -> bool override DodoSSH.Contracts.OidcConfiguration.GetHashCode() -> int override DodoSSH.Contracts.OidcConfiguration.ToString() -> string! +override DodoSSH.Contracts.PersonalVaultRequest.Equals(object? obj) -> bool +override DodoSSH.Contracts.PersonalVaultRequest.GetHashCode() -> int +override DodoSSH.Contracts.PersonalVaultRequest.ToString() -> string! override DodoSSH.Contracts.RelayConfiguration.Equals(object? obj) -> bool override DodoSSH.Contracts.RelayConfiguration.GetHashCode() -> int override DodoSSH.Contracts.RelayConfiguration.ToString() -> string! @@ -513,6 +537,8 @@ static DodoSSH.Contracts.MetaResponse.operator !=(DodoSSH.Contracts.MetaResponse static DodoSSH.Contracts.MetaResponse.operator ==(DodoSSH.Contracts.MetaResponse? left, DodoSSH.Contracts.MetaResponse? right) -> bool static DodoSSH.Contracts.OidcConfiguration.operator !=(DodoSSH.Contracts.OidcConfiguration? left, DodoSSH.Contracts.OidcConfiguration? right) -> bool static DodoSSH.Contracts.OidcConfiguration.operator ==(DodoSSH.Contracts.OidcConfiguration? left, DodoSSH.Contracts.OidcConfiguration? right) -> bool +static DodoSSH.Contracts.PersonalVaultRequest.operator !=(DodoSSH.Contracts.PersonalVaultRequest? left, DodoSSH.Contracts.PersonalVaultRequest? right) -> bool +static DodoSSH.Contracts.PersonalVaultRequest.operator ==(DodoSSH.Contracts.PersonalVaultRequest? left, DodoSSH.Contracts.PersonalVaultRequest? right) -> bool static DodoSSH.Contracts.RelayConfiguration.operator !=(DodoSSH.Contracts.RelayConfiguration? left, DodoSSH.Contracts.RelayConfiguration? right) -> bool static DodoSSH.Contracts.RelayConfiguration.operator ==(DodoSSH.Contracts.RelayConfiguration? left, DodoSSH.Contracts.RelayConfiguration? right) -> bool static DodoSSH.Contracts.RelaySessionSummary.operator !=(DodoSSH.Contracts.RelaySessionSummary? left, DodoSSH.Contracts.RelaySessionSummary? right) -> bool diff --git a/tests/DodoSSH.Api.Tests/IdentityEndpointTests.cs b/tests/DodoSSH.Api.Tests/IdentityEndpointTests.cs new file mode 100644 index 0000000..181b6eb --- /dev/null +++ b/tests/DodoSSH.Api.Tests/IdentityEndpointTests.cs @@ -0,0 +1,903 @@ +using System.Net; +using System.Net.Http.Json; +using DodoSSH.Contracts; +using DodoSSH.Crypto; +using DodoSSH.Domain; +using DodoSSH.Infrastructure; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace DodoSSH.Api.Tests; + +/// +/// Just-in-time provisioning, /me, and enrollment end to end over HTTP. +/// +/// +/// The rejection tests are the important ones. Enrollment is where a user's public keys enter the +/// system, and every confidentiality guarantee in the product is downstream of those keys being +/// genuinely theirs. A hole here is not a bug in one endpoint; it is the whole vault. +/// +[Collection(ApiCollection.Name)] +public sealed class IdentityEndpointTests(ApiFixture fixture) +{ + private const string MeUrl = "/api/v1/me"; + private const string EnrollUrl = "/api/v1/me/enrollment"; + + // ---- Provisioning and /me ---- + + [Fact] + public async Task GetMe_WithoutAToken_Is401() + { + var response = await fixture.CreateClient().GetAsync(new Uri(MeUrl, UriKind.Relative)); + + response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task GetMe_OnAFirstRequest_ProvisionsTheUserAndAsksForEnrollment() + { + var subject = NewSubject(); + var email = $"{subject}@example.com"; + var client = fixture.CreateClientFor(subject, email); + + var me = await ReadMeAsync(client); + + me.Issuer.ShouldBe(fixture.IdentityProvider.Authority); + me.Subject.ShouldBe(subject); + me.Email.ShouldBe(email); + + // The single flag a client branches on at startup. + me.EnrollmentRequired.ShouldBeTrue(); + me.KeyGeneration.ShouldBeNull(); + me.WrappedPrivateKey.ShouldBeNull(); + me.KdfParameters.ShouldBeNull(); + me.Vaults.ShouldBeEmpty(); + + await using var scope = fixture.CreateScope(); + var database = scope.ServiceProvider.GetRequiredService(); + + var user = await database.Users.SingleOrDefaultAsync(u => u.Subject == subject); + user.ShouldNotBeNull(); + user.Status.ShouldBe(UserStatus.Active); + user.EnrolledAtUtc.ShouldBeNull(); + } + + [Fact] + public async Task GetMe_RepeatedRequests_ProvisionOnlyOnce() + { + var subject = NewSubject(); + var client = fixture.CreateClientFor(subject); + + for (var i = 0; i < 3; i++) + { + (await client.GetAsync(new Uri(MeUrl, UriKind.Relative))).EnsureSuccessStatusCode(); + } + + await using var scope = fixture.CreateScope(); + var database = scope.ServiceProvider.GetRequiredService(); + + (await database.Users.CountAsync(u => u.Subject == subject)).ShouldBe(1); + } + + [Fact] + public async Task GetMe_AfterEnrolling_ReturnsEverythingNeededToUnlockOffline() + { + using var enrollment = NewEnrollment(); + var client = enrollment.CreateClient(fixture); + + var enrolled = await EnrollAsync(client, enrollment.Build()); + + var me = await ReadMeAsync(client); + + me.EnrollmentRequired.ShouldBeFalse(); + me.KeyGeneration.ShouldBe(1); + + // Wrap and KDF parameters together, because unlock has to work with no network at all. If + // the salt were fetched at unlock time, an offline launch could not open the vault. + me.WrappedPrivateKey.ShouldNotBeNull(); + me.KdfParameters.ShouldNotBeNull(); + me.KdfParameters.Algorithm.ShouldBe("argon2id"); + me.KdfParameters.MemoryKibibytes.ShouldBe(256 * 1024); + me.KdfParameters.Passes.ShouldBe(4); + me.KdfParameters.Parallelism.ShouldBe(1); + + var vault = me.Vaults.ShouldHaveSingleItem(); + vault.VaultId.ShouldBe(enrolled.PersonalVaultId); + vault.IsPersonal.ShouldBeTrue(); + vault.TeamId.ShouldBeNull(); + vault.KeyGeneration.ShouldBe(1u); + vault.RekeyRequired.ShouldBeFalse(); + + // Without the wrapped vault key the vault is unopenable, so this being present is the + // difference between enrollment having worked and having half worked. + vault.WrappedVaultKey.ShouldNotBeNull(); + } + + [Fact] + public async Task GetMe_DoesNotListAnotherUsersVault() + { + using var owner = NewEnrollment(); + await EnrollAsync(owner.CreateClient(fixture), owner.Build()); + + using var other = NewEnrollment(); + var otherClient = other.CreateClient(fixture); + await EnrollAsync(otherClient, other.Build()); + + var me = await ReadMeAsync(otherClient); + + me.Vaults.ShouldHaveSingleItem().VaultId.ShouldBe(other.VaultId); + } + + // ---- Enrollment: the happy path ---- + + [Fact] + public async Task Enroll_WritesTheKeyItsWrapsTheDeviceTheLogEntryAndTheVault() + { + using var enrollment = NewEnrollment(); + var client = enrollment.CreateClient(fixture); + + var response = await EnrollAsync(client, enrollment.Build()); + + response.KeyGeneration.ShouldBe(1); + response.PersonalVaultId.ShouldBe(enrollment.VaultId); + response.DeviceId.ShouldNotBeNull(); + response.KeyLogSequence.ShouldBeGreaterThan(0); + + // The fingerprint is computed by the server from the statement, never taken from the client. + response.Fingerprint.ShouldBe(enrollment.Fingerprint); + + await using var scope = fixture.CreateScope(); + var database = scope.ServiceProvider.GetRequiredService(); + + var key = await database.UserKeys.SingleAsync(k => k.UserId == response.UserId); + key.IsCurrent.ShouldBeTrue(); + key.Generation.ShouldBe(1); + key.FingerprintSha256.ShouldBe(enrollment.Fingerprint); + key.EncryptionPublicKey.ShouldBe(enrollment.Statement.EncryptionPublicKey); + key.SigningPublicKey.ShouldBe(enrollment.Statement.SigningPublicKey); + + var wraps = await database.UserKeyWraps + .Where(w => w.UserId == response.UserId) + .ToListAsync(); + + // One bundle, three wraps of it. That shape is what makes a passphrase change a single-row + // update rather than a re-encryption of the vault. + wraps.Count.ShouldBe(3); + wraps.Select(w => w.Kind).Order().ShouldBe( + [UserKeyWrapKind.Passphrase, UserKeyWrapKind.Device, UserKeyWrapKind.Recovery]); + + var device = wraps.Single(w => w.Kind == UserKeyWrapKind.Device); + device.DeviceId.ShouldBe(response.DeviceId); + device.KdfAlgorithm.ShouldBeNull(); + device.KdfSalt.ShouldBeNull(); + + var vault = await database.Vaults.SingleAsync(v => v.Id == enrollment.VaultId); + vault.OwnerKind.ShouldBe(VaultOwnerKind.Personal); + vault.OwnerUserId.ShouldBe(response.UserId); + vault.KeyGeneration.ShouldBe(1); + + var grant = await database.VaultKeyGrants.SingleAsync(g => g.VaultId == enrollment.VaultId); + grant.Kind.ShouldBe(GrantKind.Member); + grant.State.ShouldBe(GrantState.Active); + grant.RecipientUserId.ShouldBe(response.UserId); + grant.RecipientKeyFingerprint.ShouldBe(enrollment.Fingerprint); + grant.GranterKeyFingerprint.ShouldBe(enrollment.Fingerprint); + + // A self-grant records no key log head: there is no third party whose key could have been + // substituted, and the client could not have signed over an entry written alongside it. + grant.KeyLogHead.ShouldBeNull(); + + var user = await database.Users.SingleAsync(u => u.Id == response.UserId); + user.EnrolledAtUtc.ShouldNotBeNull(); + } + + [Fact] + public async Task Enroll_AppendsToTheKeyLogAndTheEntryReproducesItsOwnHash() + { + using var enrollment = NewEnrollment(); + var response = await EnrollAsync(enrollment.CreateClient(fixture), enrollment.Build()); + + await using var scope = fixture.CreateScope(); + var database = scope.ServiceProvider.GetRequiredService(); + + var entry = await database.KeyLog.SingleAsync(e => e.UserId == response.UserId); + + // The stored row must reproduce its own hash after a round trip. It only does so if the + // timestamp was truncated to the precision the chain hashes before being written — the + // column holds microseconds and the hash covers milliseconds. + var recomputed = KeyLogChain.ComputeEntryHash( + entry.PreviousHash, + entry.UserId, + entry.Generation, + entry.EncryptionPublicKey, + entry.SigningPublicKey, + entry.StatementSignature, + entry.CreatedAtUtc); + + entry.Hash.ShouldBe(recomputed); + entry.Sequence.ShouldBe(response.KeyLogSequence); + } + + [Fact] + public async Task Enroll_LinksEachLogEntryToItsPredecessor() + { + // The chain is what turns key substitution from undetectable into detectable: a server + // serving divergent views has to keep both forks consistent forever. + using var first = NewEnrollment(); + var firstResponse = await EnrollAsync(first.CreateClient(fixture), first.Build()); + + using var second = NewEnrollment(); + var secondResponse = await EnrollAsync(second.CreateClient(fixture), second.Build()); + + await using var scope = fixture.CreateScope(); + var database = scope.ServiceProvider.GetRequiredService(); + + var firstEntry = await database.KeyLog.SingleAsync(e => e.UserId == firstResponse.UserId); + var secondEntry = await database.KeyLog.SingleAsync(e => e.UserId == secondResponse.UserId); + + secondEntry.Sequence.ShouldBeGreaterThan(firstEntry.Sequence); + + // Not necessarily the immediate successor — the shared database has other tests' entries — + // so walk back rather than assuming adjacency. + var predecessor = await database.KeyLog + .Where(e => e.Sequence < secondEntry.Sequence) + .OrderByDescending(e => e.Sequence) + .FirstAsync(); + + secondEntry.PreviousHash.ShouldBe(predecessor.Hash); + } + + [Fact] + public async Task Enroll_RetainsTheProviderTokenSoClientsNeedNotTrustTheServer() + { + using var enrollment = NewEnrollment(); + var request = enrollment.Build(); + + var response = await EnrollAsync(enrollment.CreateClient(fixture), request); + + await using var scope = fixture.CreateScope(); + var database = scope.ServiceProvider.GetRequiredService(); + + var key = await database.UserKeys.SingleAsync(k => k.UserId == response.UserId); + + // Storing only our own summary would ask clients to trust the server about the single + // question the design exists to avoid trusting it about. + key.IdentityProviderBinding.ShouldNotBeNull(); + key.IdentityProviderBinding.ShouldContain(request.IdentityProviderToken); + key.IdentityProviderBinding.ShouldContain( + KeyStatementCodec.ComputeNonce(Fields(enrollment.Statement))); + } + + [Fact] + public async Task Enroll_ThenPush_CompletesTheVerticalSlice() + { + // Login, enroll, store an item. The whole point of M1's backend. + using var enrollment = NewEnrollment(); + var client = enrollment.CreateClient(fixture); + + await EnrollAsync(client, enrollment.Build()); + + var push = await client.PostAsJsonAsync( + $"/api/v1/vaults/{enrollment.VaultId}/sync/push", + new SyncPushRequest( + [ + new SyncPushOperation( + Guid.CreateVersion7(), + SyncEntityType.Host, + Guid.CreateVersion7(), + SyncOperation.Upsert, + null, + new EncryptedPayload([1, 2, 3, 4], 1, 1), + new SyncPlaintextFields()), + ])); + + push.EnsureSuccessStatusCode(); + + var body = await push.Content.ReadFromJsonAsync(); + body.ShouldNotBeNull(); + body.Results[0].Status.ShouldBe(SyncOperationStatus.Applied); + } + + [Fact] + public async Task Enroll_WithoutADeviceOrRecoveryWrap_StillSucceeds() + { + using var enrollment = NewEnrollment(); + + var response = await EnrollAsync( + enrollment.CreateClient(fixture), + enrollment.Build(includeDevice: false, includeRecovery: false)); + + response.DeviceId.ShouldBeNull(); + + await using var scope = fixture.CreateScope(); + var database = scope.ServiceProvider.GetRequiredService(); + + var wraps = await database.UserKeyWraps.Where(w => w.UserId == response.UserId).ToListAsync(); + wraps.ShouldHaveSingleItem().Kind.ShouldBe(UserKeyWrapKind.Passphrase); + } + + // ---- Enrollment: retries and conflicts ---- + + [Fact] + public async Task Enroll_ReplayingTheIdenticalRequest_IsIdempotent() + { + // A client whose response was lost re-sends the same body. Creating a second vault instead + // would strand the first one with no way for the client to discover it. + using var enrollment = NewEnrollment(); + var client = enrollment.CreateClient(fixture); + var request = enrollment.Build(); + + var first = await EnrollAsync(client, request); + var replay = await EnrollAsync(client, request); + + replay.UserId.ShouldBe(first.UserId); + replay.KeyGeneration.ShouldBe(first.KeyGeneration); + replay.PersonalVaultId.ShouldBe(first.PersonalVaultId); + replay.KeyLogSequence.ShouldBe(first.KeyLogSequence); + replay.Fingerprint.ShouldBe(first.Fingerprint); + replay.DeviceId.ShouldBe(first.DeviceId); + + await using var scope = fixture.CreateScope(); + var database = scope.ServiceProvider.GetRequiredService(); + + (await database.UserKeys.CountAsync(k => k.UserId == first.UserId)).ShouldBe(1); + (await database.KeyLog.CountAsync(e => e.UserId == first.UserId)).ShouldBe(1); + (await database.Vaults.CountAsync(v => v.OwnerUserId == first.UserId)).ShouldBe(1); + } + + [Fact] + public async Task Enroll_ConcurrentEnrollmentsByDifferentUsers_LeaveAnUnbrokenChain() + { + // The real hazard the deployment-wide advisory lock exists for. Different users trip no + // unique index, so without serialised appends they all read the same head and write entries + // that each claim the same predecessor. That is a forked chain — indistinguishable from the + // key-substitution attack the log exists to make detectable — and it is permanent, because + // the log is append-only and no server-side repair is possible. + const int Concurrency = 6; + + var enrollments = Enumerable.Range(0, Concurrency).Select(_ => NewEnrollment()).ToArray(); + + try + { + var responses = await Task.WhenAll(enrollments.Select(e => + e.CreateClient(fixture).PostAsJsonAsync(EnrollUrl, e.Build()))); + + foreach (var response in responses) + { + response.EnsureSuccessStatusCode(); + } + + await using var scope = fixture.CreateScope(); + var database = scope.ServiceProvider.GetRequiredService(); + + // The whole log, including entries earlier tests appended. Every link and every hash. + var entries = await database.KeyLog.OrderBy(e => e.Sequence).ToListAsync(); + + entries.Count.ShouldBeGreaterThanOrEqualTo(Concurrency); + + var expectedPrevious = KeyLogChain.CreateGenesisPreviousHash(); + + foreach (var entry in entries) + { + entry.PreviousHash.ShouldBe( + expectedPrevious, + $"Key log entry {entry.Sequence} does not link to its predecessor. The chain " + + "has forked, which is exactly what an undetectable key substitution looks like."); + + entry.Hash.ShouldBe(KeyLogChain.ComputeEntryHash( + entry.PreviousHash, + entry.UserId, + entry.Generation, + entry.EncryptionPublicKey, + entry.SigningPublicKey, + entry.StatementSignature, + entry.CreatedAtUtc)); + + expectedPrevious = entry.Hash; + } + } + finally + { + foreach (var enrollment in enrollments) + { + enrollment.Dispose(); + } + } + } + + [Fact] + public async Task Enroll_ConcurrentIdenticalRequests_ProduceExactlyOneEnrollment() + { + // A client on a flaky connection genuinely does this. Four requests race just-in-time + // provisioning, the unique index on the current key, and the deployment-wide key log lock at + // once. Every one must end in the same answer, and the append-only log must gain one entry — + // two entries claiming the same predecessor would look exactly like the fork the chain + // exists to detect. + using var enrollment = NewEnrollment(); + var request = enrollment.Build(); + + var responses = await Task.WhenAll( + Enumerable.Range(0, 4).Select(_ => + enrollment.CreateClient(fixture).PostAsJsonAsync(EnrollUrl, request))); + + var bodies = new List(responses.Length); + foreach (var response in responses) + { + response.EnsureSuccessStatusCode(); + + var body = await response.Content.ReadFromJsonAsync(); + body.ShouldNotBeNull(); + bodies.Add(body); + } + + bodies.Select(b => b.UserId).Distinct().ShouldHaveSingleItem(); + bodies.Select(b => b.KeyLogSequence).Distinct().ShouldHaveSingleItem(); + bodies.Select(b => b.DeviceId).Distinct().ShouldHaveSingleItem(); + + var userId = bodies[0].UserId; + + await using var scope = fixture.CreateScope(); + var database = scope.ServiceProvider.GetRequiredService(); + + (await database.Users.CountAsync(u => u.Subject == enrollment.Subject)).ShouldBe(1); + (await database.UserKeys.CountAsync(k => k.UserId == userId)).ShouldBe(1); + (await database.KeyLog.CountAsync(e => e.UserId == userId)).ShouldBe(1); + (await database.Vaults.CountAsync(v => v.OwnerUserId == userId)).ShouldBe(1); + (await database.Devices.CountAsync(d => d.UserId == userId)).ShouldBe(1); + (await database.UserKeyWraps.CountAsync(w => w.UserId == userId)).ShouldBe(3); + } + + [Fact] + public async Task Enroll_ASecondDifferentKey_Is409() + { + // Accepting it would orphan every vault key already wrapped to the first one. + using var first = NewEnrollment(); + var client = first.CreateClient(fixture); + await EnrollAsync(client, first.Build()); + + using var second = new TestEnrollment(fixture.IdentityProvider, first.Subject); + + var response = await client.PostAsJsonAsync(EnrollUrl, second.Build()); + + await ShouldBeProblemAsync(response, HttpStatusCode.Conflict, ProblemCodes.AlreadyEnrolled); + } + + [Fact] + public async Task Enroll_TheSameKeysAgainstADifferentVaultId_Is409() + { + using var enrollment = NewEnrollment(); + var client = enrollment.CreateClient(fixture); + await EnrollAsync(client, enrollment.Build()); + + var response = await client.PostAsJsonAsync( + EnrollUrl, + enrollment.Build(personalVault: enrollment.DefaultVault(vaultId: Guid.CreateVersion7()))); + + await ShouldBeProblemAsync(response, HttpStatusCode.Conflict, ProblemCodes.AlreadyEnrolled); + } + + [Fact] + public async Task Enroll_WithAVaultIdAnotherUserAlreadyHas_Is400() + { + using var owner = NewEnrollment(); + await EnrollAsync(owner.CreateClient(fixture), owner.Build()); + + using var second = NewEnrollment(); + + var response = await second.CreateClient(fixture).PostAsJsonAsync( + EnrollUrl, + second.Build(personalVault: second.DefaultVault(vaultId: owner.VaultId))); + + await ShouldBeProblemAsync( + response, + HttpStatusCode.BadRequest, + ProblemCodes.InvalidEnrollment); + } + + // ---- Enrollment: the identity-provider binding ---- + + [Fact] + public async Task Enroll_WithANonceForADifferentStatement_IsRejected() + { + // The heart of it. A token that does not hash to *this* statement binds nothing, so + // accepting it would let anyone publish keys under any account the provider knows. + using var enrollment = NewEnrollment(); + var other = enrollment.Statement with { DeviceName = "some-other-device" }; + + var response = await enrollment.CreateClient(fixture).PostAsJsonAsync( + EnrollUrl, + enrollment.Build(idToken: enrollment.MintIdToken(other))); + + await ShouldBeBindingRejectedAsync(response); + } + + [Fact] + public async Task Enroll_WithATokenForAnotherSubject_IsRejected() + { + using var enrollment = NewEnrollment(); + + var response = await enrollment.CreateClient(fixture).PostAsJsonAsync( + EnrollUrl, + enrollment.Build( + idToken: enrollment.MintIdToken(enrollment.Statement, subject: NewSubject()))); + + await ShouldBeBindingRejectedAsync(response); + } + + [Fact] + public async Task Enroll_WithATokenAudiencedToTheApiRatherThanTheClient_IsRejected() + { + // An ID token is audienced to the client. An access token presented here would be a + // different kind of assertion entirely, and must not be interchangeable with one. + using var enrollment = NewEnrollment(); + + var response = await enrollment.CreateClient(fixture).PostAsJsonAsync( + EnrollUrl, + enrollment.Build( + idToken: enrollment.MintIdToken( + enrollment.Statement, + audience: StubIdentityProvider.Audience))); + + await ShouldBeBindingRejectedAsync(response); + } + + [Fact] + public async Task Enroll_WithATokenSignedByAKeyTheProviderDoesNotPublish_IsRejected() + { + // Proves the JWKS check genuinely runs rather than the token being parsed and trusted. + using var enrollment = NewEnrollment(); + + var foreign = fixture.IdentityProvider.MintIdTokenWithForeignKey( + enrollment.Subject, + KeyStatementCodec.ComputeNonce(Fields(enrollment.Statement))); + + var response = await enrollment.CreateClient(fixture).PostAsJsonAsync( + EnrollUrl, + enrollment.Build(idToken: foreign)); + + await ShouldBeBindingRejectedAsync(response); + } + + [Fact] + public async Task Enroll_WithAnExpiredToken_IsRejected() + { + using var enrollment = NewEnrollment(); + + var response = await enrollment.CreateClient(fixture).PostAsJsonAsync( + EnrollUrl, + enrollment.Build( + idToken: enrollment.MintIdToken( + enrollment.Statement, + expires: TimeProvider.System.GetUtcNow().UtcDateTime.AddMinutes(-10)))); + + await ShouldBeBindingRejectedAsync(response); + } + + [Fact] + public async Task Enroll_WithATokenFromAnotherIssuer_IsRejected() + { + using var enrollment = NewEnrollment(); + + var response = await enrollment.CreateClient(fixture).PostAsJsonAsync( + EnrollUrl, + enrollment.Build( + idToken: enrollment.MintIdToken(enrollment.Statement, issuer: "https://evil.example"))); + + await ShouldBeBindingRejectedAsync(response); + } + + [Fact] + public async Task Enroll_WithNoNonceAtAll_IsRejected() + { + using var enrollment = NewEnrollment(); + + var response = await enrollment.CreateClient(fixture).PostAsJsonAsync( + EnrollUrl, + enrollment.Build( + idToken: enrollment.MintIdToken(enrollment.Statement, omitNonce: true))); + + await ShouldBeBindingRejectedAsync(response); + } + + [Fact] + public async Task Enroll_WhenTheBindingFails_WritesNothing() + { + // A rejection that still wrote a key log entry would poison the chain permanently, because + // the log is append-only. + using var enrollment = NewEnrollment(); + var client = enrollment.CreateClient(fixture); + + var response = await client.PostAsJsonAsync( + EnrollUrl, + enrollment.Build(idToken: enrollment.MintIdToken(enrollment.Statement, omitNonce: true))); + + response.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + + await using var scope = fixture.CreateScope(); + var database = scope.ServiceProvider.GetRequiredService(); + + var user = await database.Users.SingleAsync(u => u.Subject == enrollment.Subject); + + (await database.UserKeys.AnyAsync(k => k.UserId == user.Id)).ShouldBeFalse(); + (await database.UserKeyWraps.AnyAsync(w => w.UserId == user.Id)).ShouldBeFalse(); + (await database.KeyLog.AnyAsync(e => e.UserId == user.Id)).ShouldBeFalse(); + (await database.Vaults.AnyAsync(v => v.Id == enrollment.VaultId)).ShouldBeFalse(); + user.EnrolledAtUtc.ShouldBeNull(); + } + + // ---- Enrollment: statement and shape validation ---- + + [Fact] + public async Task Enroll_WithASignatureFromAnotherKey_IsRejected() + { + using var enrollment = NewEnrollment(); + using var other = NewEnrollment(); + + var response = await enrollment.CreateClient(fixture).PostAsJsonAsync( + EnrollUrl, + enrollment.Build(statementSignature: other.Sign(enrollment.Statement))); + + await ShouldBeProblemAsync( + response, + HttpStatusCode.BadRequest, + ProblemCodes.InvalidEnrollment); + } + + [Fact] + public async Task Enroll_WithAStatementNamingAnotherSubject_IsRejected() + { + // Caught by shape validation before any cryptography, so the code says "invalid" rather + // than blaming the binding. + using var enrollment = NewEnrollment(); + var impersonating = enrollment.Statement with { Subject = NewSubject() }; + + var response = await enrollment.CreateClient(fixture).PostAsJsonAsync( + EnrollUrl, + enrollment.Build(statement: impersonating)); + + await ShouldBeProblemAsync( + response, + HttpStatusCode.BadRequest, + ProblemCodes.InvalidEnrollment); + } + + [Theory] + [InlineData(1024, 4, "memory far below the floor")] + [InlineData(256 * 1024, 1, "too few passes")] + [InlineData(8 * 1024 * 1024, 4, "memory above the ceiling")] + public async Task Enroll_WithUnacceptableKdfParameters_IsRejected( + int memoryKibibytes, + int passes, + string reason) + { + // A weak wrap here is a permanent liability: it is exactly what an attacker who ever + // obtains a database dump grinds against offline. An absurd one locks the user out instead. + reason.ShouldNotBeEmpty(); + + using var enrollment = NewEnrollment(); + + var response = await enrollment.CreateClient(fixture).PostAsJsonAsync( + EnrollUrl, + enrollment.Build( + kdfParameters: new KdfParameters("argon2id", new byte[16], memoryKibibytes, passes, 1))); + + await ShouldBeProblemAsync( + response, + HttpStatusCode.BadRequest, + ProblemCodes.InvalidEnrollment); + } + + [Fact] + public async Task Enroll_WithParallelismOtherThanOne_IsRejected() + { + using var enrollment = NewEnrollment(); + + var response = await enrollment.CreateClient(fixture).PostAsJsonAsync( + EnrollUrl, + enrollment.Build( + kdfParameters: new KdfParameters("argon2id", new byte[16], 256 * 1024, 4, 4))); + + await ShouldBeProblemAsync( + response, + HttpStatusCode.BadRequest, + ProblemCodes.InvalidEnrollment); + } + + [Fact] + public async Task Enroll_WithAWrongLengthPublicKey_IsRejected() + { + using var enrollment = NewEnrollment(); + var truncated = enrollment.Statement with { EncryptionPublicKey = new byte[31] }; + + // The signature and token are supplied ready-made: a malformed statement cannot be + // canonically encoded at all, so neither can be derived from it. Shape validation runs + // before any cryptography, so the server never gets that far either. + var response = await enrollment.CreateClient(fixture).PostAsJsonAsync( + EnrollUrl, + enrollment.Build( + statement: truncated, + statementSignature: new byte[64], + idToken: enrollment.MintIdToken(enrollment.Statement))); + + await ShouldBeProblemAsync( + response, + HttpStatusCode.BadRequest, + ProblemCodes.InvalidEnrollment); + } + + [Fact] + public async Task Enroll_ReusingOneKeyForBothRoles_IsRejected() + { + // Using one key for agreement and signatures is a standing cryptographic mistake, and is + // also the shape a client bug takes when it exports the wrong key twice. + using var enrollment = NewEnrollment(); + var reused = enrollment.Statement with + { + EncryptionPublicKey = enrollment.Statement.SigningPublicKey, + }; + + var response = await enrollment.CreateClient(fixture).PostAsJsonAsync( + EnrollUrl, + enrollment.Build(statement: reused)); + + await ShouldBeProblemAsync( + response, + HttpStatusCode.BadRequest, + ProblemCodes.InvalidEnrollment); + } + + [Fact] + public async Task Enroll_WithAGenerationOtherThanOne_IsRejected() + { + using var enrollment = NewEnrollment(); + var later = enrollment.Statement with { KeyGeneration = 2 }; + + var response = await enrollment.CreateClient(fixture).PostAsJsonAsync( + EnrollUrl, + enrollment.Build(statement: later)); + + await ShouldBeProblemAsync( + response, + HttpStatusCode.BadRequest, + ProblemCodes.InvalidEnrollment); + } + + [Fact] + public async Task Enroll_WithADeviceKeyButNoDeviceWrap_IsRejected() + { + // Only the holder of the bundle can seal it, so a device key with no wrap would register a + // device that can never unlock anything and that the server could never fix. + using var enrollment = NewEnrollment(); + + var response = await enrollment.CreateClient(fixture).PostAsJsonAsync( + EnrollUrl, + enrollment.Build() with { DeviceWrappedPrivateKey = null }); + + await ShouldBeProblemAsync( + response, + HttpStatusCode.BadRequest, + ProblemCodes.InvalidEnrollment); + } + + [Fact] + public async Task Enroll_WithARecoveryWrapButNoKdfParameters_IsRejected() + { + using var enrollment = NewEnrollment(); + + var response = await enrollment.CreateClient(fixture).PostAsJsonAsync( + EnrollUrl, + enrollment.Build() with { RecoveryKdfParameters = null }); + + await ShouldBeProblemAsync( + response, + HttpStatusCode.BadRequest, + ProblemCodes.InvalidEnrollment); + } + + [Fact] + public async Task Enroll_WithAJsonBodyMissingRequiredFields_Is400NotAServerError() + { + // A record's non-nullable parameters are a compile-time promise only — JSON that omits a + // property deserialises to null regardless. The distinction that matters is 400 versus 500: + // one tells a client its request was wrong, the other looks like the server broke. + using var enrollment = NewEnrollment(); + + using var content = new StringContent( + """{"statement":null,"statementSignature":null,"identityProviderToken":null}""", + System.Text.Encoding.UTF8, + "application/json"); + + var response = await enrollment.CreateClient(fixture).PostAsync( + new Uri(EnrollUrl, UriKind.Relative), + content); + + response.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task Enroll_WithoutKdfParameters_Is400NotAServerError() + { + using var enrollment = NewEnrollment(); + + var response = await enrollment.CreateClient(fixture).PostAsJsonAsync( + EnrollUrl, + enrollment.Build() with { KdfParameters = null! }); + + await ShouldBeProblemAsync( + response, + HttpStatusCode.BadRequest, + ProblemCodes.InvalidEnrollment); + } + + [Fact] + public async Task Enroll_WithoutAToken_Is401() + { + using var enrollment = NewEnrollment(); + + var response = await fixture.CreateClient().PostAsJsonAsync(EnrollUrl, enrollment.Build()); + + response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); + } + + // ---- Helpers ---- + + private static string NewSubject() => $"user-{Guid.CreateVersion7():N}"; + + private TestEnrollment NewEnrollment() + { + var subject = NewSubject(); + return new TestEnrollment(fixture.IdentityProvider, subject, $"{subject}@example.com"); + } + + private static async Task ReadMeAsync(HttpClient client) + { + var response = await client.GetAsync(new Uri(MeUrl, UriKind.Relative)); + response.EnsureSuccessStatusCode(); + + var me = await response.Content.ReadFromJsonAsync(); + me.ShouldNotBeNull(); + return me; + } + + private static async Task EnrollAsync( + HttpClient client, + EnrollmentRequest request) + { + var response = await client.PostAsJsonAsync(EnrollUrl, request); + response.EnsureSuccessStatusCode(); + + var body = await response.Content.ReadFromJsonAsync(); + body.ShouldNotBeNull(); + return body; + } + + private static Task ShouldBeBindingRejectedAsync(HttpResponseMessage response) => + ShouldBeProblemAsync( + response, + HttpStatusCode.BadRequest, + ProblemCodes.IdentityBindingInvalid); + + private static async Task ShouldBeProblemAsync( + HttpResponseMessage response, + HttpStatusCode expectedStatus, + string expectedCode) + { + response.StatusCode.ShouldBe(expectedStatus); + + var problem = await response.Content.ReadFromJsonAsync(); + problem.ShouldNotBeNull(); + problem.Code.ShouldBe(expectedCode); + } + + private static KeyStatementFields Fields(KeyStatement statement) => + new( + statement.Version, + statement.Issuer, + statement.Subject, + statement.Email, + statement.EncryptionPublicKey, + statement.SigningPublicKey, + statement.KeyGeneration, + statement.CreatedAt, + statement.DeviceName); +} diff --git a/tests/DodoSSH.Api.Tests/JsonProblem.cs b/tests/DodoSSH.Api.Tests/JsonProblem.cs new file mode 100644 index 0000000..9b99c11 --- /dev/null +++ b/tests/DodoSSH.Api.Tests/JsonProblem.cs @@ -0,0 +1,11 @@ +namespace DodoSSH.Api.Tests; + +/// +/// Minimal RFC 9457 shape, for asserting on the stable code extension. +/// +/// +/// Tests assert on rather than on . The code is part of the +/// contract and the prose is not, so matching prose would make every wording improvement a test +/// failure — and would tempt someone to keep a bad message because a test depends on it. +/// +internal sealed record JsonProblem(string? Type, string? Detail, string? Code); diff --git a/tests/DodoSSH.Api.Tests/Seed.cs b/tests/DodoSSH.Api.Tests/Seed.cs new file mode 100644 index 0000000..0803197 --- /dev/null +++ b/tests/DodoSSH.Api.Tests/Seed.cs @@ -0,0 +1,35 @@ +using System.Security.Cryptography; +using DodoSSH.Domain; + +namespace DodoSSH.Api.Tests; + +/// Directly-inserted rows, for tests whose subject is not enrollment itself. +/// +/// Sync tests seed a current key rather than driving the enrollment endpoint. Going through the +/// endpoint would make every sync failure ambiguous between the two features, and would make the +/// sync suite fail whenever enrollment changed. +/// +internal static class Seed +{ + /// + /// A minimal current identity key, enough to satisfy the enrolled policy. + /// + /// + /// The keys and fingerprint are random because user_key has a global unique index on the + /// fingerprint: fixed bytes would make the second seeded user in the shared database collide. + /// + internal static UserKey CurrentKey(Guid userId, DateTimeOffset now) => + new() + { + Id = Guid.CreateVersion7(), + UserId = userId, + Generation = 1, + EncryptionPublicKey = RandomNumberGenerator.GetBytes(32), + SigningPublicKey = RandomNumberGenerator.GetBytes(32), + FingerprintSha256 = RandomNumberGenerator.GetBytes(32), + Statement = "{}", + StatementSignature = RandomNumberGenerator.GetBytes(64), + IsCurrent = true, + CreatedAtUtc = now, + }; +} diff --git a/tests/DodoSSH.Api.Tests/StubIdentityProvider.cs b/tests/DodoSSH.Api.Tests/StubIdentityProvider.cs index 04ef1b6..b81edce 100644 --- a/tests/DodoSSH.Api.Tests/StubIdentityProvider.cs +++ b/tests/DodoSSH.Api.Tests/StubIdentityProvider.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.IdentityModel.Tokens.Jwt; using System.Security.Cryptography; using System.Text.Json; @@ -39,9 +40,19 @@ public sealed class StubIdentityProvider : IDisposable /// Issuer URL, matching what the tokens claim. public string Authority { get; } - /// Audience the API is configured to expect. + /// Audience the API is configured to expect on access tokens. public static string Audience => "dodossh-api"; + /// + /// The public client identifier, and therefore the audience of an ID token. + /// + /// + /// Distinct from on purpose. An ID token is audienced to the client that + /// requested it, never to the API — validating a binding token against the API audience would + /// reject every genuine enrollment, and a test that used one audience for both would not notice. + /// + public static string ClientId => "dodossh-desktop"; + /// /// Mints a signed access token. /// @@ -94,6 +105,89 @@ public sealed class StubIdentityProvider : IDisposable return new JwtSecurityTokenHandler().WriteToken(token); } + /// + /// Mints an ID token carrying a key-binding nonce. + /// + /// + /// This is the artefact the whole public-key trust model rests on: an identity-provider + /// signature over the hash of a key statement. Signed with the same real RSA key the JWKS + /// endpoint publishes, so the server's verification genuinely runs. + /// + /// The sub claim. + /// The key statement binding, from KeyStatementCodec.ComputeNonce. + /// Optional email claim. + /// Override the audience, to test rejection. Defaults to the client id. + /// Override the issuer, to test rejection. + /// Override expiry, to test rejection. + /// Omit the nonce entirely, to test rejection. + public string MintIdToken( + string subject, + string nonce, + string? email = null, + string? audience = null, + string? issuer = null, + DateTime? expires = null, + bool omitNonce = false) + { + var now = TimeProvider.System.GetUtcNow().UtcDateTime; + + // Integer64, so the handler writes a JSON number rather than a string. A provider that + // emitted a string here would be unusual, and the server reads auth_time as a number. + var claims = new List + { + new("sub", subject), + new( + "auth_time", + new DateTimeOffset(now, TimeSpan.Zero).ToUnixTimeSeconds() + .ToString(CultureInfo.InvariantCulture), + System.Security.Claims.ClaimValueTypes.Integer64), + }; + + if (!omitNonce) + { + claims.Add(new System.Security.Claims.Claim("nonce", nonce)); + } + + if (email is not null) + { + claims.Add(new System.Security.Claims.Claim("email", email)); + } + + var expiry = expires ?? now.AddMinutes(5); + var notBefore = expiry < now ? expiry.AddMinutes(-1) : now.AddMinutes(-1); + + var token = new JwtSecurityToken( + issuer: issuer ?? Authority, + audience: audience ?? ClientId, + claims: claims, + notBefore: notBefore, + expires: expiry, + signingCredentials: new SigningCredentials(signingKey, SecurityAlgorithms.RsaSha256)); + + return new JwtSecurityTokenHandler().WriteToken(token); + } + + /// Mints an ID token signed by a key the provider does not publish. + public string MintIdTokenWithForeignKey(string subject, string nonce) + { + var foreign = new RsaSecurityKey(RSA.Create(2048)) { KeyId = KeyId }; + var now = TimeProvider.System.GetUtcNow().UtcDateTime; + + var token = new JwtSecurityToken( + issuer: Authority, + audience: ClientId, + claims: + [ + new System.Security.Claims.Claim("sub", subject), + new System.Security.Claims.Claim("nonce", nonce), + ], + notBefore: now.AddMinutes(-1), + expires: now.AddMinutes(5), + signingCredentials: new SigningCredentials(foreign, SecurityAlgorithms.RsaSha256)); + + return new JwtSecurityTokenHandler().WriteToken(token); + } + /// Mints a token signed by a different key, which must be rejected. public string MintTokenWithForeignKey(string subject) { diff --git a/tests/DodoSSH.Api.Tests/SyncEndpointTests.cs b/tests/DodoSSH.Api.Tests/SyncEndpointTests.cs index 837b221..645eeb0 100644 --- a/tests/DodoSSH.Api.Tests/SyncEndpointTests.cs +++ b/tests/DodoSSH.Api.Tests/SyncEndpointTests.cs @@ -101,6 +101,42 @@ public sealed class SyncEndpointTests(ApiFixture fixture) response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); } + // ---- Authorization: the caller must have enrolled ---- + + [Fact] + public async Task Pull_BeforeEnrolling_Is403WithAnActionableCode() + { + // Not a confidentiality boundary — an unenrolled user owns no vault anyway. The value is + // that the client is told what to do instead of receiving an empty 403 or, worse, + // ciphertext it has no key for. + var client = fixture.CreateClientFor(NewSubject()); + + var response = await client.PostAsJsonAsync( + PullUrl(Guid.CreateVersion7()), + new SyncPullRequest(null, null, null)); + + response.StatusCode.ShouldBe(HttpStatusCode.Forbidden); + + var problem = await response.Content.ReadFromJsonAsync(); + problem.ShouldNotBeNull(); + problem.Code.ShouldBe(ProblemCodes.EnrollmentRequired); + } + + [Fact] + public async Task Push_BeforeEnrolling_Is403AndWritesNothing() + { + var (_, vaultId) = await SeedUserWithVaultAsync(); + var client = fixture.CreateClientFor(NewSubject()); + + var response = await client.PostAsJsonAsync(PushUrl(vaultId), NewCreateBatch()); + + response.StatusCode.ShouldBe(HttpStatusCode.Forbidden); + + await using var scope = fixture.CreateScope(); + var database = scope.ServiceProvider.GetRequiredService(); + (await database.VaultChanges.AnyAsync(c => c.VaultId == vaultId)).ShouldBeFalse(); + } + // ---- Authorization: the wrong user must be denied ---- [Fact] @@ -109,7 +145,7 @@ public sealed class SyncEndpointTests(ApiFixture fixture) // 404 rather than 403: a distinct "exists but forbidden" answer would let a caller // enumerate other tenants' vault ids. var (_, vaultId) = await SeedUserWithVaultAsync(); - var intruder = fixture.CreateClientFor(NewSubject()); + var intruder = fixture.CreateClientFor(await SeedEnrolledUserAsync()); var response = await intruder.PostAsJsonAsync( PullUrl(vaultId), @@ -122,7 +158,7 @@ public sealed class SyncEndpointTests(ApiFixture fixture) public async Task Push_AnotherUsersVault_Is404() { var (_, vaultId) = await SeedUserWithVaultAsync(); - var intruder = fixture.CreateClientFor(NewSubject()); + var intruder = fixture.CreateClientFor(await SeedEnrolledUserAsync()); var response = await intruder.PostAsJsonAsync(PushUrl(vaultId), NewCreateBatch()); @@ -134,7 +170,7 @@ public sealed class SyncEndpointTests(ApiFixture fixture) { // A denial that still mutated state would be worse than no check at all. var (_, vaultId) = await SeedUserWithVaultAsync(); - var intruder = fixture.CreateClientFor(NewSubject()); + var intruder = fixture.CreateClientFor(await SeedEnrolledUserAsync()); var batch = NewCreateBatch(); await intruder.PostAsJsonAsync(PushUrl(vaultId), batch); @@ -149,7 +185,7 @@ public sealed class SyncEndpointTests(ApiFixture fixture) [Fact] public async Task Pull_ANonexistentVault_Is404() { - var client = fixture.CreateClientFor(NewSubject()); + var client = fixture.CreateClientFor(await SeedEnrolledUserAsync()); var response = await client.PostAsJsonAsync( PullUrl(Guid.CreateVersion7()), @@ -163,7 +199,7 @@ public sealed class SyncEndpointTests(ApiFixture fixture) { // Failing closed on an unimplemented path, rather than falling through to a default. var vaultId = await SeedTeamVaultAsync(); - var client = fixture.CreateClientFor(NewSubject()); + var client = fixture.CreateClientFor(await SeedEnrolledUserAsync()); var response = await client.PostAsJsonAsync( PullUrl(vaultId), @@ -569,48 +605,8 @@ public sealed class SyncEndpointTests(ApiFixture fixture) body!.Results[0].Status.ShouldBe(SyncOperationStatus.Invalid); } - // ---- JIT provisioning ---- - - [Fact] - public async Task AFirstRequest_ProvisionsTheUser() - { - var subject = NewSubject(); - var email = $"{subject}@example.com"; - var client = fixture.CreateClientFor(subject, email); - - // Any authenticated call is enough to trigger provisioning. - await client.PostAsJsonAsync( - PullUrl(Guid.CreateVersion7()), - new SyncPullRequest(null, null, null)); - - await using var scope = fixture.CreateScope(); - var database = scope.ServiceProvider.GetRequiredService(); - - var user = await database.Users.SingleOrDefaultAsync(u => u.Subject == subject); - user.ShouldNotBeNull(); - user.Issuer.ShouldBe(fixture.IdentityProvider.Authority); - user.Email.ShouldBe(email); - user.Status.ShouldBe(UserStatus.Active); - } - - [Fact] - public async Task RepeatedRequests_ProvisionOnlyOnce() - { - var subject = NewSubject(); - var client = fixture.CreateClientFor(subject); - - for (var i = 0; i < 3; i++) - { - await client.PostAsJsonAsync( - PullUrl(Guid.CreateVersion7()), - new SyncPullRequest(null, null, null)); - } - - await using var scope = fixture.CreateScope(); - var database = scope.ServiceProvider.GetRequiredService(); - - (await database.Users.CountAsync(u => u.Subject == subject)).ShouldBe(1); - } + // Just-in-time provisioning is covered by IdentityEndpointTests, against /me — the endpoint a + // client actually calls first, and the only one reachable before enrollment. // ---- Helpers ---- @@ -640,15 +636,7 @@ public sealed class SyncEndpointTests(ApiFixture fixture) await using var scope = fixture.CreateScope(); var database = scope.ServiceProvider.GetRequiredService(); - var user = new UserAccount - { - Id = Guid.CreateVersion7(), - Issuer = fixture.IdentityProvider.Authority, - Subject = subject, - Status = UserStatus.Active, - CreatedAtUtc = Now, - UpdatedAtUtc = Now, - }; + var user = NewUser(subject); var vault = new Vault { @@ -662,26 +650,53 @@ public sealed class SyncEndpointTests(ApiFixture fixture) }; database.Users.Add(user); + database.UserKeys.Add(Seed.CurrentKey(user.Id, Now)); database.Vaults.Add(vault); await database.SaveChangesAsync(); return (subject, vault.Id); } + /// + /// An enrolled user with no vault of their own: the realistic intruder. + /// + /// + /// The denial tests use one of these rather than an unenrolled caller. An unenrolled caller is + /// stopped by the enrolled policy before the vault check runs at all, which would leave the + /// authorization tests passing without ever exercising the thing they exist to prove. + /// + private async Task SeedEnrolledUserAsync() + { + var subject = NewSubject(); + + await using var scope = fixture.CreateScope(); + var database = scope.ServiceProvider.GetRequiredService(); + + var user = NewUser(subject); + database.Users.Add(user); + database.UserKeys.Add(Seed.CurrentKey(user.Id, Now)); + await database.SaveChangesAsync(); + + return subject; + } + + private UserAccount NewUser(string subject) => + new() + { + Id = Guid.CreateVersion7(), + Issuer = fixture.IdentityProvider.Authority, + Subject = subject, + Status = UserStatus.Active, + CreatedAtUtc = Now, + UpdatedAtUtc = Now, + }; + private async Task SeedTeamVaultAsync() { await using var scope = fixture.CreateScope(); var database = scope.ServiceProvider.GetRequiredService(); - var owner = new UserAccount - { - Id = Guid.CreateVersion7(), - Issuer = fixture.IdentityProvider.Authority, - Subject = NewSubject(), - Status = UserStatus.Active, - CreatedAtUtc = Now, - UpdatedAtUtc = Now, - }; + var owner = NewUser(NewSubject()); var team = new Team { @@ -710,7 +725,4 @@ public sealed class SyncEndpointTests(ApiFixture fixture) return vault.Id; } - - /// Minimal ProblemDetails shape, for asserting on the code extension. - private sealed record JsonProblem(string? Type, string? Detail, string? Code); } diff --git a/tests/DodoSSH.Api.Tests/TestEnrollment.cs b/tests/DodoSSH.Api.Tests/TestEnrollment.cs new file mode 100644 index 0000000..7f25eb8 --- /dev/null +++ b/tests/DodoSSH.Api.Tests/TestEnrollment.cs @@ -0,0 +1,153 @@ +using DodoSSH.Contracts; +using DodoSSH.Crypto; +using NSec.Cryptography; + +namespace DodoSSH.Api.Tests; + +/// +/// Builds a genuine enrollment: real X25519 and Ed25519 keys, a real Ed25519 statement signature, +/// and an ID token whose nonce is the statement's actual canonical hash. +/// +/// +/// Nothing here is stubbed. The point is that the server's two independent checks — the statement +/// self-signature and the identity-provider binding — both run for real, so a change that breaks +/// either shows up here rather than against a live Keycloak. +/// +internal sealed class TestEnrollment : IDisposable +{ + private static readonly DateTimeOffset CreatedAt = + DateTimeOffset.FromUnixTimeMilliseconds(1_750_000_000_123); + + private readonly StubIdentityProvider identityProvider; + private readonly Key encryptionKey; + private readonly Key signingKey; + + internal TestEnrollment(StubIdentityProvider identityProvider, string subject, string? email = null) + { + this.identityProvider = identityProvider; + Subject = subject; + + encryptionKey = Key.Create(KeyAgreementAlgorithm.X25519); + signingKey = Key.Create(SignatureAlgorithm.Ed25519); + + Statement = new KeyStatement( + Version: 1, + Issuer: identityProvider.Authority, + Subject: subject, + Email: email, + EncryptionPublicKey: encryptionKey.PublicKey.Export(KeyBlobFormat.RawPublicKey), + SigningPublicKey: signingKey.PublicKey.Export(KeyBlobFormat.RawPublicKey), + KeyGeneration: 1, + CreatedAt: CreatedAt, + DeviceName: "test-device"); + } + + /// The OIDC subject this enrollment is for. + internal string Subject { get; } + + /// The client-chosen personal vault id. + internal Guid VaultId { get; } = Guid.CreateVersion7(); + + /// The default, well-formed statement. + internal KeyStatement Statement { get; } + + /// The identity fingerprint the server should compute. + internal byte[] Fingerprint => DshCrypto.ComputeFingerprint( + Statement.EncryptionPublicKey, + Statement.SigningPublicKey); + + /// Signs a statement with this enrollment's Ed25519 key. + internal byte[] Sign(KeyStatement statement) => + DshSignatures.SignKeyStatement(signingKey, KeyStatementCodec.Encode(ToFields(statement))); + + /// Mints an ID token whose nonce is the given statement's binding. + internal string MintIdToken( + KeyStatement statement, + string? subject = null, + string? audience = null, + string? issuer = null, + DateTime? expires = null, + bool omitNonce = false) => + identityProvider.MintIdToken( + subject ?? Subject, + KeyStatementCodec.ComputeNonce(ToFields(statement)), + audience: audience, + issuer: issuer, + expires: expires, + omitNonce: omitNonce); + + /// Builds a complete, valid request, with every part overridable for negative tests. + internal EnrollmentRequest Build( + KeyStatement? statement = null, + byte[]? statementSignature = null, + string? idToken = null, + KdfParameters? kdfParameters = null, + PersonalVaultRequest? personalVault = null, + bool includeDevice = true, + bool includeRecovery = true) + { + var effective = statement ?? Statement; + + return new EnrollmentRequest( + Statement: effective, + StatementSignature: statementSignature ?? Sign(effective), + IdentityProviderToken: idToken ?? MintIdToken(effective), + WrappedPrivateKey: Bytes(220, 0x11), + KdfParameters: kdfParameters ?? DefaultKdf(), + DevicePublicKey: includeDevice ? Bytes(32, 0x22) : null, + DeviceWrappedPrivateKey: includeDevice ? Bytes(240, 0x33) : null, + RecoveryWrappedPrivateKey: includeRecovery ? Bytes(220, 0x44) : null, + RecoveryKdfParameters: includeRecovery ? RecoveryKdf() : null, + PersonalVault: personalVault ?? DefaultVault()); + } + + /// The passphrase KDF profile, matching Argon2Profile.PassphraseDefault. + internal static KdfParameters DefaultKdf() => + new("argon2id", Bytes(16, 0x55), MemoryKibibytes: 256 * 1024, Passes: 4, Parallelism: 1); + + /// The recovery KDF profile. Cheaper, because a recovery code carries real entropy. + internal static KdfParameters RecoveryKdf() => + new("argon2id", Bytes(16, 0x66), MemoryKibibytes: 64 * 1024, Passes: 3, Parallelism: 1); + + /// + /// The grant signature is a real Ed25519 signature of the right length, but not over the §7 + /// grant tuple: that canonical encoding lands with team sharing in M3, and the server stores + /// grant signatures opaquely rather than verifying them. Shape is what is under test here. + /// + internal PersonalVaultRequest DefaultVault(Guid? vaultId = null, string name = "Personal") => + new( + VaultId: vaultId ?? VaultId, + Name: name, + WrappedVaultKey: Bytes(80, 0x77), + GrantSignature: SignatureAlgorithm.Ed25519.Sign(signingKey, Bytes(32, 0x88)), + GrantedAt: CreatedAt); + + /// Bearer client for this enrollment's subject. + internal HttpClient CreateClient(ApiFixture fixture) => fixture.CreateClientFor(Subject); + + /// + public void Dispose() + { + encryptionKey.Dispose(); + signingKey.Dispose(); + } + + private static byte[] Bytes(int length, byte seed) => + [.. Enumerable.Range(0, length).Select(i => (byte)(seed + i))]; + + /// + /// Mapped here rather than reusing the server's mapper, so a change to either side's field list + /// shows up as a failing enrollment instead of two copies of the same mistake agreeing. + /// + 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); +}