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.PostContractAsync( $"/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], [5, 6], Guid.CreateVersion7(), 1, 1), new SyncPlaintextFields()), ])); push.EnsureSuccessStatusCode(); var body = await push.Content.ReadContractAsync(); 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).PostContractAsync(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).PostContractAsync(EnrollUrl, request))); var bodies = new List(responses.Length); foreach (var response in responses) { response.EnsureSuccessStatusCode(); var body = await response.Content.ReadContractAsync(); 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.PostContractAsync(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.PostContractAsync( 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).PostContractAsync( 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).PostContractAsync( 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).PostContractAsync( 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).PostContractAsync( 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).PostContractAsync( 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).PostContractAsync( 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).PostContractAsync( 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).PostContractAsync( 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.PostContractAsync( 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).PostContractAsync( 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).PostContractAsync( 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).PostContractAsync( 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).PostContractAsync( 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).PostContractAsync( 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).PostContractAsync( 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).PostContractAsync( 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).PostContractAsync( 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).PostContractAsync( 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).PostContractAsync( 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().PostContractAsync(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.ReadContractAsync(); me.ShouldNotBeNull(); return me; } private static async Task EnrollAsync( HttpClient client, EnrollmentRequest request) { var response = await client.PostContractAsync(EnrollUrl, request); response.EnsureSuccessStatusCode(); var body = await response.Content.ReadContractAsync(); 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.ReadProblemAsync(); 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); }