Files
DodoSSH/tests/DodoSSH.Api.Tests/IdentityEndpointTests.cs
T
jaap-jan 8d2416a602 Add the encrypted local cache and the sync client
Three new client projects, and the wire-contract fix they needed.

DodoSSH.Client.Domain holds the decrypted item model and the three-way
merge, with no I/O at all — so the suite that decides whether a
credential can be lost runs in milliseconds with nothing to mock.
Scalars defer to the server on a genuine clash so every replica resolves
the same triple identically and two clients cannot ping-pong; directives
merge per name so two people each adding one both keep theirs; the jump
chain merges as a whole value because its order is the route. Whatever
loses is returned rather than dropped.

DodoSSH.Client.Storage is EF Core on SQLite, no SQLCipher: the rows are
already ciphertext, so an encrypted file would protect protected bytes
at the cost of a native dependency. It keeps the server's state and the
outbox in separate tables, which is what preserves the common ancestor a
merge needs. One pending operation per item, enforced by a unique index.

DodoSSH.Client.Sync is the pull/apply/push loop. Pulling never decrypts
— a change with no local work pending is plumbed as ciphertext — so a
first sync of thousands of items does not run twice as many AEAD
operations for nothing.

Contracts: EncryptedPayload gains WrappedDataKey and DataKeyId. The
specification has required a per-item data key since crypto.md §3, the
columns have existed since the first migration and DshAad.ItemPayload
binds the id, but this record had nowhere to put either — so a
spec-compliant item could not be transmitted at all. Found by writing
the client that has to produce one. Also closes a hole in
AadResourceType, which had no value for the HostTag and HostCredential
that SyncEntityType has always listed.

Four bugs the tests found, not review:

- SQLite refuses to order or compare its own DateTimeOffset mapping, and
  throws at execution rather than model build. Collecting tombstones and
  listing conflicts are both that shape, so this was a crash waiting for
  the first user with a deleted host. Timestamps are integers now, by
  convention so a later field cannot be the one left unconverted.
- SQLitePCLRaw 2.1.11, which EF resolves, is covered by
  GHSA-2m69-gcr7-jv3q. Pinned forward as a family.
- Resurrecting content from a remote deletion cleared the original
  before queueing the copy. Two transactions, so a crash between them
  lost the work; reversed, and the rescued id is derived from the
  tombstone so a replay coalesces instead of duplicating.
- Several equality assertions went through Shouldly's ShouldBe, which
  compares IEnumerable element-wise and so tested nothing about the
  Equals these types exist to provide. Corrected; the falsification that
  caught it went from 2 failures to 6.

The push response's cursor is deliberately ignored. It sits after this
client's own writes, so adopting it skips anything another client
committed at a lower sequence in the window between a pull and a push —
permanently. Re-reading one's own writes is idempotent and costs a page.
The Contracts doc that invited the shortcut now says so.

593 tests, up from 448. The delete-versus-edit rules, the ancestor
retention, the fresh operation id on coalesce and the cursor safeguard
were each verified by breaking them and watching the right test fail.
2026-07-29 10:27:37 +02:00

904 lines
34 KiB
C#

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;
/// <summary>
/// Just-in-time provisioning, <c>/me</c>, and enrollment end to end over HTTP.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[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<DodoDbContext>();
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<DodoDbContext>();
(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<DodoDbContext>();
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<DodoDbContext>();
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<DodoDbContext>();
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<DodoDbContext>();
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], [5, 6], Guid.CreateVersion7(), 1, 1),
new SyncPlaintextFields()),
]));
push.EnsureSuccessStatusCode();
var body = await push.Content.ReadFromJsonAsync<SyncPushResponse>();
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<DodoDbContext>();
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<DodoDbContext>();
(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<DodoDbContext>();
// 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<EnrollmentResponse>(responses.Length);
foreach (var response in responses)
{
response.EnsureSuccessStatusCode();
var body = await response.Content.ReadFromJsonAsync<EnrollmentResponse>();
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<DodoDbContext>();
(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<DodoDbContext>();
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<MeResponse> ReadMeAsync(HttpClient client)
{
var response = await client.GetAsync(new Uri(MeUrl, UriKind.Relative));
response.EnsureSuccessStatusCode();
var me = await response.Content.ReadFromJsonAsync<MeResponse>();
me.ShouldNotBeNull();
return me;
}
private static async Task<EnrollmentResponse> EnrollAsync(
HttpClient client,
EnrollmentRequest request)
{
var response = await client.PostAsJsonAsync(EnrollUrl, request);
response.EnsureSuccessStatusCode();
var body = await response.Content.ReadFromJsonAsync<EnrollmentResponse>();
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<JsonProblem>();
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);
}