Public Access
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.
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.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);
|
|
}
|