Files
DodoSSH/tests/DodoSSH.Api.Tests/IdentityEndpointTests.cs
T
jaap-jan f86791e817 Finish revoking a device, instead of half of it
ForgetDeviceAsync stopped this machine unlocking without a passphrase and left
the server's row exactly where it was, so the account went on listing a device
nobody could account for. ADR 0007 recorded that as a deliberate gap needing an
endpoint. This is the endpoint, and the two things that turned up behind it.

DELETE /api/v1/me/devices/{id}. The device row is not the dangerous half: a
kind=device wrap is the user's identity bundle sealed to a key somebody may be
holding, and that is what has to go. It goes on the foreign key's cascade rather
than a second statement, and RevokeDevice_TakesItsWrapWithIt asserts the cascade
rather than trusting the configuration to keep saying so.

Scoped to the caller's own account, which is the only authorisation check there
is. The id is an unguessable v7 GUID, but unguessable is not a permission —
without the scope one user could withdraw another's device key by pasting an id
they saw once, and the victim's next launch would ask for a passphrase with no
explanation. 404 rather than 403 for somebody else's device, so a stranger does
not learn the id exists.

Never refused for being the last device. ADR 0001 makes an enrolled device a
recovery path, so removing the last one does cost the user something — but the
machine being revoked is most likely the one they have just lost, and a server
that argued about it would be refusing the one request that has to work
immediately. The passphrase wrap is untouched either way, which
RevokeDevice_LeavesThePassphraseWrapAlone pins.

--- Two things found on the way ---

Registering twice from one machine left two devices on the account. The server
is idempotent on the public key, but the client generates a fresh key pair every
call and the keystore holds one — so the second registration orphaned a wrap
whose private half had just been overwritten, which is precisely the leftover
this change exists to remove. Registering now withdraws the previous device.
Found by a test that asserted the property and failed.

And the fakes were lying about it. FakeAccountServer's comment claimed the real
service's idempotence while handing back a fresh Guid on every call, which is
invisible until something revokes by id — at which point a test would be
revoking an id the server never issued, and passing. Both fakes now issue one id
per public key and drop the wrap with the device, as the cascade does.

--- Reachable at all ---

ForgetDeviceAsync had exactly one caller and it was a test, so "Stop unlocking
here" now sits in the account bar where "Use Windows Hello here" was. Its own
flag rather than the negation of that one: a machine with no TPM and a machine
that is already registered are both "cannot register", and only the second has
anything to take back.

No confirmation prompt, deliberately. The cost of pressing it by accident is one
passphrase and one re-registration; the cost of a dialog is a moment's
hesitation at the point somebody has realised a machine is in the wrong hands.

Offline it does the local half and says so rather than refusing. Whether this
machine may unlock itself is decided entirely by the local cache and the local
keystore — the unlock path never asks the server — so forgetting here is what
actually revokes, and "you are offline, so this machine will go on unlocking
itself" would be the worst available answer. DeviceRevocation.LocalOnly is what
the interface reports and the status line explains what is left to do.

The local half runs first for the same reason, and the keystore call is the
first thing in the method that can yield: on Windows it raises a consent dialog,
and a dialog wants the thread it was called from. That ordering is currently
load-bearing and shakier than it looks — see the open device-unlock hang.

Four mutations, all caught: dropping the user scope from the server query
(1 test), skipping the stale-device revoke on re-registration (2), skipping the
server call in ForgetDeviceAsync (2), and the earlier version of the client that
never called it at all.

930 tests green across 16 projects, 13 of them new. Zero warnings, format clean.
2026-07-30 17:33:31 +02:00

1182 lines
46 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";
private const string DevicesUrl = "/api/v1/me/devices";
// ---- 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.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<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).PostContractAsync(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).PostContractAsync(EnrollUrl, request)));
var bodies = new List<EnrollmentResponse>(responses.Length);
foreach (var response in responses)
{
response.EnsureSuccessStatusCode();
var body = await response.Content.ReadContractAsync<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.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<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).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);
}
// ---- Registering a device after enrollment ----
[Fact]
public async Task RegisterDevice_AddsADeviceAndAWrapThatOnlyThatDeviceCanOpen()
{
// The gap this endpoint closes. EnrollmentService.AddDevice only ever runs during enrollment, so
// without this every account that existed before a keystore was wired up could never turn the
// feature on — which is all of them.
using var enrollment = NewEnrollment();
var client = enrollment.CreateClient(fixture);
var enrolled = await EnrollAsync(client, enrollment.Build());
var publicKey = DeviceKey();
var registered = await RegisterDeviceAsync(
client,
new RegisterDeviceRequest("a second laptop", publicKey, Wrap()));
registered.DeviceId.ShouldNotBe(Guid.Empty);
await using var scope = fixture.CreateScope();
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
var device = await database.Devices.SingleAsync(d => d.Id == registered.DeviceId);
device.UserId.ShouldBe(enrolled.UserId);
device.Name.ShouldBe("a second laptop");
device.PublicKey.ShouldBe(publicKey);
var wrap = await database.UserKeyWraps.SingleAsync(w => w.DeviceId == registered.DeviceId);
wrap.Kind.ShouldBe(UserKeyWrapKind.Device);
wrap.UserId.ShouldBe(enrolled.UserId);
}
[Fact]
public async Task RegisterDevice_BeforeEnrolling_AsksForEnrollmentRatherThanRejectingTheShape()
{
// You cannot wrap a bundle to a device before you have a bundle. The distinction that matters is
// which answer the client gets: "enroll first" is actionable, and a 400 about the request's shape
// would send somebody looking at their key length.
var subject = NewSubject();
var client = fixture.CreateClientFor(subject, $"{subject}@example.com");
var response = await client.PostContractAsync(
DevicesUrl,
new RegisterDeviceRequest("a laptop", DeviceKey(), Wrap()));
await ShouldBeProblemAsync(
response,
HttpStatusCode.Forbidden,
ProblemCodes.EnrollmentRequired);
}
[Fact]
public async Task RegisterDevice_TwiceWithTheSameKey_ReturnsTheSameDeviceAndAddsNoSecondWrap()
{
// Idempotent, as enrollment is. A dropped response must leave a retry safe, and a second row for
// one key would mean a device list with a duplicate and two wraps to revoke instead of one.
using var enrollment = NewEnrollment();
var client = enrollment.CreateClient(fixture);
await EnrollAsync(client, enrollment.Build());
var publicKey = DeviceKey();
var request = new RegisterDeviceRequest("a laptop", publicKey, Wrap());
var first = await RegisterDeviceAsync(client, request);
var second = await RegisterDeviceAsync(client, request);
second.DeviceId.ShouldBe(first.DeviceId);
second.EnrolledAt.ShouldBe(first.EnrolledAt);
await using var scope = fixture.CreateScope();
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
(await database.Devices.CountAsync(d => d.PublicKey == publicKey)).ShouldBe(1);
(await database.UserKeyWraps.CountAsync(w => w.DeviceId == first.DeviceId)).ShouldBe(1);
}
[Theory]
[InlineData(31)]
[InlineData(33)]
public async Task RegisterDevice_WithAPublicKeyOfTheWrongLength_IsRejected(int length)
{
// The same rule enrollment applies. A key that is not an X25519 public key is one nothing can ever
// seal to, so the row would be permanently useless.
using var enrollment = NewEnrollment();
var client = enrollment.CreateClient(fixture);
await EnrollAsync(client, enrollment.Build());
var response = await client.PostContractAsync(
DevicesUrl,
new RegisterDeviceRequest("a laptop", new byte[length], Wrap()));
await ShouldBeProblemAsync(
response,
HttpStatusCode.BadRequest,
ProblemCodes.InvalidDeviceRegistration);
}
[Fact]
public async Task RegisterDevice_WithNoWrap_IsRejected()
{
// A device key with no wrap registers a device that can never unlock anything, and the server
// cannot fill the gap in — only the holder of the bundle can seal it.
using var enrollment = NewEnrollment();
var client = enrollment.CreateClient(fixture);
await EnrollAsync(client, enrollment.Build());
var response = await client.PostContractAsync(
DevicesUrl,
new RegisterDeviceRequest("a laptop", DeviceKey(), []));
await ShouldBeProblemAsync(
response,
HttpStatusCode.BadRequest,
ProblemCodes.InvalidDeviceRegistration);
}
/// <remarks>
/// The wrap is the half that matters. A device row nobody can use is untidy; a <c>kind=device</c> wrap
/// left behind is the user's identity bundle still sealed to a key somebody may be holding. This asserts
/// the foreign key's cascade rather than trusting the configuration to go on saying so.
/// </remarks>
[Fact]
public async Task RevokeDevice_TakesItsWrapWithIt()
{
using var enrollment = NewEnrollment();
var client = enrollment.CreateClient(fixture);
await EnrollAsync(client, enrollment.Build());
var registered = await RegisterDeviceAsync(
client, new RegisterDeviceRequest("a laptop", DeviceKey(), Wrap()));
var response = await client.DeleteAsync(
new Uri($"{DevicesUrl}/{registered.DeviceId}", UriKind.Relative));
response.StatusCode.ShouldBe(HttpStatusCode.NoContent);
await using var scope = fixture.CreateScope();
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
(await database.Devices.CountAsync(d => d.Id == registered.DeviceId)).ShouldBe(0);
(await database.UserKeyWraps.CountAsync(w => w.DeviceId == registered.DeviceId)).ShouldBe(0);
}
/// <remarks>
/// The passphrase wrap is what makes revocation safe to offer at all: withdrawing every device must
/// never be able to lock somebody out of their own vault, so this checks the one row that guarantees it
/// is still there afterwards.
/// </remarks>
[Fact]
public async Task RevokeDevice_LeavesThePassphraseWrapAlone()
{
using var enrollment = NewEnrollment();
var client = enrollment.CreateClient(fixture);
await EnrollAsync(client, enrollment.Build());
var registered = await RegisterDeviceAsync(
client, new RegisterDeviceRequest("a laptop", DeviceKey(), Wrap()));
await client.DeleteAsync(new Uri($"{DevicesUrl}/{registered.DeviceId}", UriKind.Relative));
var me = await ReadMeAsync(client);
me.EnrollmentRequired.ShouldBeFalse();
me.WrappedPrivateKey.ShouldNotBeNull();
}
[Fact]
public async Task RevokeDevice_ThatIsNotThere_Is404()
{
// Rather than a bland 204. "Revoked" is what the user reads, and reading it about the wrong id is
// worse than being told to look again — a client driving towards "this machine cannot unlock" can
// treat 404 as having arrived, and the desktop one does.
using var enrollment = NewEnrollment();
var client = enrollment.CreateClient(fixture);
await EnrollAsync(client, enrollment.Build());
var response = await client.DeleteAsync(
new Uri($"{DevicesUrl}/{Guid.CreateVersion7()}", UriKind.Relative));
response.StatusCode.ShouldBe(HttpStatusCode.NotFound);
}
/// <remarks>
/// The authorisation check, and the only one there is: the id is an unguessable v7 GUID, but unguessable
/// is not a permission. Without the user scope on the query, one account could withdraw another's device
/// key by pasting an id it saw once — and the victim's next launch would ask for a passphrase with no
/// explanation.
/// </remarks>
[Fact]
public async Task RevokeDevice_BelongingToSomebodyElse_Is404AndChangesNothing()
{
using var mine = NewEnrollment();
var myClient = mine.CreateClient(fixture);
await EnrollAsync(myClient, mine.Build());
var registered = await RegisterDeviceAsync(
myClient, new RegisterDeviceRequest("my laptop", DeviceKey(), Wrap()));
using var theirs = NewEnrollment();
var theirClient = theirs.CreateClient(fixture);
await EnrollAsync(theirClient, theirs.Build());
var response = await theirClient.DeleteAsync(
new Uri($"{DevicesUrl}/{registered.DeviceId}", UriKind.Relative));
// 404 rather than 403, because a stranger must not learn that the id exists.
response.StatusCode.ShouldBe(HttpStatusCode.NotFound);
await using var scope = fixture.CreateScope();
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
(await database.Devices.CountAsync(d => d.Id == registered.DeviceId)).ShouldBe(1);
(await database.UserKeyWraps.CountAsync(w => w.DeviceId == registered.DeviceId)).ShouldBe(1);
}
[Fact]
public async Task RevokeDevice_WithoutAToken_Is401()
{
var response = await fixture.CreateClient().DeleteAsync(
new Uri($"{DevicesUrl}/{Guid.CreateVersion7()}", UriKind.Relative));
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
}
[Fact]
public async Task RegisterDevice_WithABlankName_IsRejected()
{
using var enrollment = NewEnrollment();
var client = enrollment.CreateClient(fixture);
await EnrollAsync(client, enrollment.Build());
var response = await client.PostContractAsync(
DevicesUrl,
new RegisterDeviceRequest(" ", DeviceKey(), Wrap()));
await ShouldBeProblemAsync(
response,
HttpStatusCode.BadRequest,
ProblemCodes.InvalidDeviceRegistration);
}
[Fact]
public async Task RegisterDevice_WithoutAToken_Is401()
{
var response = await fixture.CreateClient().PostContractAsync(
DevicesUrl,
new RegisterDeviceRequest("a laptop", DeviceKey(), Wrap()));
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.ReadContractAsync<MeResponse>();
me.ShouldNotBeNull();
return me;
}
private static async Task<EnrollmentResponse> EnrollAsync(
HttpClient client,
EnrollmentRequest request)
{
var response = await client.PostContractAsync(EnrollUrl, request);
response.EnsureSuccessStatusCode();
var body = await response.Content.ReadContractAsync<EnrollmentResponse>();
body.ShouldNotBeNull();
return body;
}
private static async Task<RegisterDeviceResponse> RegisterDeviceAsync(
HttpClient client,
RegisterDeviceRequest request)
{
var response = await client.PostContractAsync(DevicesUrl, request);
response.EnsureSuccessStatusCode();
var body = await response.Content.ReadContractAsync<RegisterDeviceResponse>();
body.ShouldNotBeNull();
return body;
}
/// <remarks>
/// A distinct 32 bytes per call, because the endpoint is idempotent on the public key and two tests
/// sharing one would silently be asserting about each other's device.
/// </remarks>
private static byte[] DeviceKey() => Guid.CreateVersion7().ToByteArray().Concat(
Guid.CreateVersion7().ToByteArray()).ToArray();
/// <remarks>
/// Not a real envelope. The server treats a wrap as opaque bytes and validates only that it is present
/// and bounded, which is the whole of what it is allowed to know.
/// </remarks>
private static byte[] Wrap() => [1, 2, 3, 4];
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);
}