Public Access
The whole vertical slice now runs against a real Keycloak, a real API, a real PostgreSQL and a real sshd: sign in through the browser flow, enroll with the identity-provider key binding, unlock, create a host, sync it, read it back on a second machine, unlock again with no network, accept an unseen host key, and open an interactive shell. Opt-in, because it needs the development stack; skipped with a message naming the commands. It found two bugs on its first run, and both are the same class: two sides of a stub agreeing with each other about something the specification never said. **The API never applied DodoSshJsonContext to its HTTP JSON options.** Minimal APIs therefore used the framework's web defaults, which write an enum as a number. Every request DTO carrying one failed to bind against a client writing the specified string form — which is the entire sync surface, unreachable from the real client, with a 400 naming only the parameter. The documented guarantee that request bodies reject unmapped members was likewise not in effect anywhere. Nothing caught it because the API tests posted with PostAsJsonAsync's defaults, so they and the server had independently settled on integers. Those tests now serialise through the contract, which is the deeper fix: removing the new configuration fails 13 of them. Copying settings into options a host owns is itself the hazard the context warns about, so ApplyTo lives beside the settings it mirrors and ApplyToTests pins the transformation, including that inserting the resolver leaves the caller's own in place. **The realm registered a loopback redirect URI Keycloak rejects.** `http://127.0.0.1:*/callback` looks more explicit than the RFC 8252 form and is broken: Keycloak's wildcards are trailing-only, so the `*` parses as a literal port and every authorization request came back "Invalid parameter: redirect_uri". Providers ignore the port for loopback hosts, which is the whole mechanism, so the correct registration is `http://127.0.0.1/callback` — path pinned, port free. The value the server advertises through the discovery document said the same wrong thing and now says the right one. Two smaller things, both documented in docs/platform-flags.md: - --import-realm skips a realm that already exists, so editing the realm file and restarting Keycloak changes nothing and serves stale configuration. The container has to be recreated. The compose comment claimed the opposite. - Keycloak marks its session cookies Secure even over plain HTTP, because SameSite=None requires it. A spec-conformant client drops them and the login POST answers 400 with no message; browsers complete the flow only because they exempt loopback. Harmless for the product, fatal for automation, so ScriptedBrowser carries the cookies by hand and says why. Also: the server enforces a 64 MiB floor on the passphrase KDF, so this suite cannot use the 8 MiB profile the other client suites take for speed. Those only get away with it because their in-memory servers have no policy — worth knowing rather than rediscovering. 638 tests. The solution-wide run stays green with the stack down: exit code 8 means "no tests ran", which the platform reports as failure, so the opt-in project ignores exactly that code.
904 lines
34 KiB
C#
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.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);
|
|
}
|
|
|
|
// ---- 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 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);
|
|
}
|