From a628762cd189b1c583149e261c14bdfa698158a3 Mon Sep 17 00:00:00 2001 From: Jaap-Jan de Wit | DodoTech Date: Tue, 28 Jul 2026 16:06:35 +0200 Subject: [PATCH] Add /me and enrollment with the identity-provider key binding (M1) The last backend piece of M1. A client can now log in, discover it must enroll, publish its identity key, and get a usable personal vault. Enrollment is one indivisible act. One transaction writes the key, its wraps, the device, the key log entry, the vault and the vault key grant, because none of them is useful alone: 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. Two independent checks run, and neither substitutes for the other. The Ed25519 self-signature proves possession of the private key. The identity-provider binding proves whose key it is: the client hashed its statement, used the hash as an OIDC nonce, and the resulting ID token is the provider's signature over exactly those public keys. This server cannot mint that 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. The binding token is stored verbatim, not just summarised. Clients must repeat the check against the provider's JWKS fetched directly, and storing only our conclusion would ask them to trust the server about the one question the design exists to avoid trusting it about. Key log appends take a deployment-wide advisory lock. The falsification matters more than the passing test: with the lock removed, Enroll_ConcurrentEnrollmentsByDifferentUsers_LeaveAnUnbrokenChain fails with entry 11 linked to the wrong predecessor. Different users trip no unique index, so without serialising they all read the same head and the chain forks -- indistinguishable from the key substitution the log exists to make detectable, and permanent, because the log is append-only. Enrollment is idempotent. Vault ids and keys are client-chosen, so a client whose response was lost re-sends the identical body and gets the identical result. Without that, a lost response leaves a user enrolled against a vault they never learned the id of. Contract change, breaking the v0.1 freeze deliberately. EnrollmentRequest had DevicePublicKey but no wrap to go with it, which is unsatisfiable: only the holder of the secret bundle can seal it, so the server could never fill the gap. Added DeviceWrappedPrivateKey, and PersonalVault so enrollment can be atomic rather than leaving an unopenable vault behind two endpoints that do not exist yet. No client exists and no package is published, which is exactly when PublicAPI.Unshipped.txt expects this. Sync now requires the Enrolled policy, which until now was a stub whose name promised a check it never made. The sync denial tests use enrolled intruders instead of unenrolled ones -- an unenrolled caller is stopped before the vault check runs, which would have left those tests passing without exercising the thing they exist to prove. Also fixed: omitting kdfParameters from the JSON body was a 500. A record's non-nullable parameters are a compile-time promise, not a runtime one. 268 tests pass, zero warnings on a clean rebuild, format clean. --- README.md | 8 +- .../Authorization/EnrolledRequirement.cs | 104 ++ .../Authorization/VaultAccessService.cs | 28 + .../Features/Identity/EnrollmentExceptions.cs | 23 + .../Features/Identity/EnrollmentLimits.cs | 74 ++ .../Features/Identity/EnrollmentService.cs | 496 ++++++++++ .../Features/Identity/EnrollmentValidation.cs | 260 +++++ .../Identity/IdentityBindingVerifier.cs | 210 ++++ .../Features/Identity/IdentityEndpoints.cs | 96 ++ .../Features/Identity/IdentityLog.cs | 36 + .../Features/Identity/IdentityService.cs | 134 +++ .../Features/Sync/SyncEndpoints.cs | 5 +- src/DodoSSH.Api/Program.cs | 14 + src/DodoSSH.Api/Setup/Auth.cs | 13 +- src/DodoSSH.Api/Setup/EndpointRegistration.cs | 4 +- src/DodoSSH.Contracts/DodoSshJsonContext.cs | 4 + src/DodoSSH.Contracts/Enrollment.cs | 46 +- src/DodoSSH.Contracts/ProblemCodes.cs | 14 +- src/DodoSSH.Contracts/PublicAPI.Unshipped.txt | 30 +- .../IdentityEndpointTests.cs | 903 ++++++++++++++++++ tests/DodoSSH.Api.Tests/JsonProblem.cs | 11 + tests/DodoSSH.Api.Tests/Seed.cs | 35 + .../DodoSSH.Api.Tests/StubIdentityProvider.cs | 96 +- tests/DodoSSH.Api.Tests/SyncEndpointTests.cs | 148 +-- tests/DodoSSH.Api.Tests/TestEnrollment.cs | 153 +++ 25 files changed, 2863 insertions(+), 82 deletions(-) create mode 100644 src/DodoSSH.Api/Authorization/EnrolledRequirement.cs create mode 100644 src/DodoSSH.Api/Features/Identity/EnrollmentExceptions.cs create mode 100644 src/DodoSSH.Api/Features/Identity/EnrollmentLimits.cs create mode 100644 src/DodoSSH.Api/Features/Identity/EnrollmentService.cs create mode 100644 src/DodoSSH.Api/Features/Identity/EnrollmentValidation.cs create mode 100644 src/DodoSSH.Api/Features/Identity/IdentityBindingVerifier.cs create mode 100644 src/DodoSSH.Api/Features/Identity/IdentityEndpoints.cs create mode 100644 src/DodoSSH.Api/Features/Identity/IdentityLog.cs create mode 100644 src/DodoSSH.Api/Features/Identity/IdentityService.cs create mode 100644 tests/DodoSSH.Api.Tests/IdentityEndpointTests.cs create mode 100644 tests/DodoSSH.Api.Tests/JsonProblem.cs create mode 100644 tests/DodoSSH.Api.Tests/Seed.cs create mode 100644 tests/DodoSSH.Api.Tests/TestEnrollment.cs 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); +}