Add /me and enrollment with the identity-provider key binding (M1)

The last backend piece of M1. A client can now log in, discover it must
enroll, publish its identity key, and get a usable personal vault.

Enrollment is one indivisible act. One transaction writes the key, its
wraps, the device, the key log entry, the vault and the vault key grant,
because none of them is useful alone: a key with no vault leaves a user
unable to store anything, and a vault with no grant is a container nobody
can ever open -- including its owner, since only the client can wrap the
key and it has already moved on.

Two independent checks run, and neither substitutes for the other. The
Ed25519 self-signature proves possession of the private key. The
identity-provider binding proves whose key it is: the client hashed its
statement, used the hash as an OIDC nonce, and the resulting ID token is
the provider's signature over exactly those public keys. This server
cannot mint that signature, so it cannot invent a key for a user who never
enrolled -- which is the attack that would otherwise let an operator read
every vault by publishing its own key as yours.

The binding token is stored verbatim, not just summarised. Clients must
repeat the check against the provider's JWKS fetched directly, and storing
only our conclusion would ask them to trust the server about the one
question the design exists to avoid trusting it about.

Key log appends take a deployment-wide advisory lock. The falsification
matters more than the passing test: with the lock removed,
Enroll_ConcurrentEnrollmentsByDifferentUsers_LeaveAnUnbrokenChain fails
with entry 11 linked to the wrong predecessor. Different users trip no
unique index, so without serialising they all read the same head and the
chain forks -- indistinguishable from the key substitution the log exists
to make detectable, and permanent, because the log is append-only.

Enrollment is idempotent. Vault ids and keys are client-chosen, so a
client whose response was lost re-sends the identical body and gets the
identical result. Without that, a lost response leaves a user enrolled
against a vault they never learned the id of.

Contract change, breaking the v0.1 freeze deliberately. EnrollmentRequest
had DevicePublicKey but no wrap to go with it, which is unsatisfiable:
only the holder of the secret bundle can seal it, so the server could
never fill the gap. Added DeviceWrappedPrivateKey, and PersonalVault so
enrollment can be atomic rather than leaving an unopenable vault behind
two endpoints that do not exist yet. No client exists and no package is
published, which is exactly when PublicAPI.Unshipped.txt expects this.

Sync now requires the Enrolled policy, which until now was a stub whose
name promised a check it never made. The sync denial tests use enrolled
intruders instead of unenrolled ones -- an unenrolled caller is stopped
before the vault check runs, which would have left those tests passing
without exercising the thing they exist to prove.

Also fixed: omitting kdfParameters from the JSON body was a 500. A
record's non-nullable parameters are a compile-time promise, not a runtime
one.

268 tests pass, zero warnings on a clean rebuild, format clean.
This commit is contained in:
2026-07-28 16:06:35 +02:00
parent d2a2ed8a29
commit a628762cd1
25 changed files with 2863 additions and 82 deletions
@@ -0,0 +1,903 @@
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], 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);
}
+11
View File
@@ -0,0 +1,11 @@
namespace DodoSSH.Api.Tests;
/// <summary>
/// Minimal RFC 9457 shape, for asserting on the stable <c>code</c> extension.
/// </summary>
/// <remarks>
/// Tests assert on <see cref="Code"/> rather than on <see cref="Detail"/>. The code is part of the
/// contract and the prose is not, so matching prose would make every wording improvement a test
/// failure — and would tempt someone to keep a bad message because a test depends on it.
/// </remarks>
internal sealed record JsonProblem(string? Type, string? Detail, string? Code);
+35
View File
@@ -0,0 +1,35 @@
using System.Security.Cryptography;
using DodoSSH.Domain;
namespace DodoSSH.Api.Tests;
/// <summary>Directly-inserted rows, for tests whose subject is not enrollment itself.</summary>
/// <remarks>
/// Sync tests seed a current key rather than driving the enrollment endpoint. Going through the
/// endpoint would make every sync failure ambiguous between the two features, and would make the
/// sync suite fail whenever enrollment changed.
/// </remarks>
internal static class Seed
{
/// <summary>
/// A minimal current identity key, enough to satisfy the enrolled policy.
/// </summary>
/// <remarks>
/// The keys and fingerprint are random because <c>user_key</c> has a global unique index on the
/// fingerprint: fixed bytes would make the second seeded user in the shared database collide.
/// </remarks>
internal static UserKey CurrentKey(Guid userId, DateTimeOffset now) =>
new()
{
Id = Guid.CreateVersion7(),
UserId = userId,
Generation = 1,
EncryptionPublicKey = RandomNumberGenerator.GetBytes(32),
SigningPublicKey = RandomNumberGenerator.GetBytes(32),
FingerprintSha256 = RandomNumberGenerator.GetBytes(32),
Statement = "{}",
StatementSignature = RandomNumberGenerator.GetBytes(64),
IsCurrent = true,
CreatedAtUtc = now,
};
}
@@ -1,3 +1,4 @@
using System.Globalization;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Cryptography;
using System.Text.Json;
@@ -39,9 +40,19 @@ public sealed class StubIdentityProvider : IDisposable
/// <summary>Issuer URL, matching what the tokens claim.</summary>
public string Authority { get; }
/// <summary>Audience the API is configured to expect.</summary>
/// <summary>Audience the API is configured to expect on access tokens.</summary>
public static string Audience => "dodossh-api";
/// <summary>
/// The public client identifier, and therefore the audience of an ID token.
/// </summary>
/// <remarks>
/// Distinct from <see cref="Audience"/> on purpose. An ID token is audienced to the client that
/// requested it, never to the API — validating a binding token against the API audience would
/// reject every genuine enrollment, and a test that used one audience for both would not notice.
/// </remarks>
public static string ClientId => "dodossh-desktop";
/// <summary>
/// Mints a signed access token.
/// </summary>
@@ -94,6 +105,89 @@ public sealed class StubIdentityProvider : IDisposable
return new JwtSecurityTokenHandler().WriteToken(token);
}
/// <summary>
/// Mints an ID token carrying a key-binding nonce.
/// </summary>
/// <remarks>
/// This is the artefact the whole public-key trust model rests on: an identity-provider
/// signature over the hash of a key statement. Signed with the same real RSA key the JWKS
/// endpoint publishes, so the server's verification genuinely runs.
/// </remarks>
/// <param name="subject">The <c>sub</c> claim.</param>
/// <param name="nonce">The key statement binding, from <c>KeyStatementCodec.ComputeNonce</c>.</param>
/// <param name="email">Optional email claim.</param>
/// <param name="audience">Override the audience, to test rejection. Defaults to the client id.</param>
/// <param name="issuer">Override the issuer, to test rejection.</param>
/// <param name="expires">Override expiry, to test rejection.</param>
/// <param name="omitNonce">Omit the nonce entirely, to test rejection.</param>
public string MintIdToken(
string subject,
string nonce,
string? email = null,
string? audience = null,
string? issuer = null,
DateTime? expires = null,
bool omitNonce = false)
{
var now = TimeProvider.System.GetUtcNow().UtcDateTime;
// Integer64, so the handler writes a JSON number rather than a string. A provider that
// emitted a string here would be unusual, and the server reads auth_time as a number.
var claims = new List<System.Security.Claims.Claim>
{
new("sub", subject),
new(
"auth_time",
new DateTimeOffset(now, TimeSpan.Zero).ToUnixTimeSeconds()
.ToString(CultureInfo.InvariantCulture),
System.Security.Claims.ClaimValueTypes.Integer64),
};
if (!omitNonce)
{
claims.Add(new System.Security.Claims.Claim("nonce", nonce));
}
if (email is not null)
{
claims.Add(new System.Security.Claims.Claim("email", email));
}
var expiry = expires ?? now.AddMinutes(5);
var notBefore = expiry < now ? expiry.AddMinutes(-1) : now.AddMinutes(-1);
var token = new JwtSecurityToken(
issuer: issuer ?? Authority,
audience: audience ?? ClientId,
claims: claims,
notBefore: notBefore,
expires: expiry,
signingCredentials: new SigningCredentials(signingKey, SecurityAlgorithms.RsaSha256));
return new JwtSecurityTokenHandler().WriteToken(token);
}
/// <summary>Mints an ID token signed by a key the provider does not publish.</summary>
public string MintIdTokenWithForeignKey(string subject, string nonce)
{
var foreign = new RsaSecurityKey(RSA.Create(2048)) { KeyId = KeyId };
var now = TimeProvider.System.GetUtcNow().UtcDateTime;
var token = new JwtSecurityToken(
issuer: Authority,
audience: ClientId,
claims:
[
new System.Security.Claims.Claim("sub", subject),
new System.Security.Claims.Claim("nonce", nonce),
],
notBefore: now.AddMinutes(-1),
expires: now.AddMinutes(5),
signingCredentials: new SigningCredentials(foreign, SecurityAlgorithms.RsaSha256));
return new JwtSecurityTokenHandler().WriteToken(token);
}
/// <summary>Mints a token signed by a different key, which must be rejected.</summary>
public string MintTokenWithForeignKey(string subject)
{
+80 -68
View File
@@ -101,6 +101,42 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
}
// ---- Authorization: the caller must have enrolled ----
[Fact]
public async Task Pull_BeforeEnrolling_Is403WithAnActionableCode()
{
// Not a confidentiality boundary — an unenrolled user owns no vault anyway. The value is
// that the client is told what to do instead of receiving an empty 403 or, worse,
// ciphertext it has no key for.
var client = fixture.CreateClientFor(NewSubject());
var response = await client.PostAsJsonAsync(
PullUrl(Guid.CreateVersion7()),
new SyncPullRequest(null, null, null));
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
var problem = await response.Content.ReadFromJsonAsync<JsonProblem>();
problem.ShouldNotBeNull();
problem.Code.ShouldBe(ProblemCodes.EnrollmentRequired);
}
[Fact]
public async Task Push_BeforeEnrolling_Is403AndWritesNothing()
{
var (_, vaultId) = await SeedUserWithVaultAsync();
var client = fixture.CreateClientFor(NewSubject());
var response = await client.PostAsJsonAsync(PushUrl(vaultId), NewCreateBatch());
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
await using var scope = fixture.CreateScope();
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
(await database.VaultChanges.AnyAsync(c => c.VaultId == vaultId)).ShouldBeFalse();
}
// ---- Authorization: the wrong user must be denied ----
[Fact]
@@ -109,7 +145,7 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
// 404 rather than 403: a distinct "exists but forbidden" answer would let a caller
// enumerate other tenants' vault ids.
var (_, vaultId) = await SeedUserWithVaultAsync();
var intruder = fixture.CreateClientFor(NewSubject());
var intruder = fixture.CreateClientFor(await SeedEnrolledUserAsync());
var response = await intruder.PostAsJsonAsync(
PullUrl(vaultId),
@@ -122,7 +158,7 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
public async Task Push_AnotherUsersVault_Is404()
{
var (_, vaultId) = await SeedUserWithVaultAsync();
var intruder = fixture.CreateClientFor(NewSubject());
var intruder = fixture.CreateClientFor(await SeedEnrolledUserAsync());
var response = await intruder.PostAsJsonAsync(PushUrl(vaultId), NewCreateBatch());
@@ -134,7 +170,7 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
{
// A denial that still mutated state would be worse than no check at all.
var (_, vaultId) = await SeedUserWithVaultAsync();
var intruder = fixture.CreateClientFor(NewSubject());
var intruder = fixture.CreateClientFor(await SeedEnrolledUserAsync());
var batch = NewCreateBatch();
await intruder.PostAsJsonAsync(PushUrl(vaultId), batch);
@@ -149,7 +185,7 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
[Fact]
public async Task Pull_ANonexistentVault_Is404()
{
var client = fixture.CreateClientFor(NewSubject());
var client = fixture.CreateClientFor(await SeedEnrolledUserAsync());
var response = await client.PostAsJsonAsync(
PullUrl(Guid.CreateVersion7()),
@@ -163,7 +199,7 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
{
// Failing closed on an unimplemented path, rather than falling through to a default.
var vaultId = await SeedTeamVaultAsync();
var client = fixture.CreateClientFor(NewSubject());
var client = fixture.CreateClientFor(await SeedEnrolledUserAsync());
var response = await client.PostAsJsonAsync(
PullUrl(vaultId),
@@ -569,48 +605,8 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
body!.Results[0].Status.ShouldBe(SyncOperationStatus.Invalid);
}
// ---- JIT provisioning ----
[Fact]
public async Task AFirstRequest_ProvisionsTheUser()
{
var subject = NewSubject();
var email = $"{subject}@example.com";
var client = fixture.CreateClientFor(subject, email);
// Any authenticated call is enough to trigger provisioning.
await client.PostAsJsonAsync(
PullUrl(Guid.CreateVersion7()),
new SyncPullRequest(null, null, null));
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.Issuer.ShouldBe(fixture.IdentityProvider.Authority);
user.Email.ShouldBe(email);
user.Status.ShouldBe(UserStatus.Active);
}
[Fact]
public async Task RepeatedRequests_ProvisionOnlyOnce()
{
var subject = NewSubject();
var client = fixture.CreateClientFor(subject);
for (var i = 0; i < 3; i++)
{
await client.PostAsJsonAsync(
PullUrl(Guid.CreateVersion7()),
new SyncPullRequest(null, null, null));
}
await using var scope = fixture.CreateScope();
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
(await database.Users.CountAsync(u => u.Subject == subject)).ShouldBe(1);
}
// Just-in-time provisioning is covered by IdentityEndpointTests, against /me — the endpoint a
// client actually calls first, and the only one reachable before enrollment.
// ---- Helpers ----
@@ -640,15 +636,7 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
await using var scope = fixture.CreateScope();
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
var user = new UserAccount
{
Id = Guid.CreateVersion7(),
Issuer = fixture.IdentityProvider.Authority,
Subject = subject,
Status = UserStatus.Active,
CreatedAtUtc = Now,
UpdatedAtUtc = Now,
};
var user = NewUser(subject);
var vault = new Vault
{
@@ -662,26 +650,53 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
};
database.Users.Add(user);
database.UserKeys.Add(Seed.CurrentKey(user.Id, Now));
database.Vaults.Add(vault);
await database.SaveChangesAsync();
return (subject, vault.Id);
}
/// <summary>
/// An enrolled user with no vault of their own: the realistic intruder.
/// </summary>
/// <remarks>
/// The denial tests use one of these rather than an unenrolled caller. An unenrolled caller is
/// stopped by the enrolled policy before the vault check runs at all, which would leave the
/// authorization tests passing without ever exercising the thing they exist to prove.
/// </remarks>
private async Task<string> SeedEnrolledUserAsync()
{
var subject = NewSubject();
await using var scope = fixture.CreateScope();
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
var user = NewUser(subject);
database.Users.Add(user);
database.UserKeys.Add(Seed.CurrentKey(user.Id, Now));
await database.SaveChangesAsync();
return subject;
}
private UserAccount NewUser(string subject) =>
new()
{
Id = Guid.CreateVersion7(),
Issuer = fixture.IdentityProvider.Authority,
Subject = subject,
Status = UserStatus.Active,
CreatedAtUtc = Now,
UpdatedAtUtc = Now,
};
private async Task<Guid> SeedTeamVaultAsync()
{
await using var scope = fixture.CreateScope();
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
var owner = new UserAccount
{
Id = Guid.CreateVersion7(),
Issuer = fixture.IdentityProvider.Authority,
Subject = NewSubject(),
Status = UserStatus.Active,
CreatedAtUtc = Now,
UpdatedAtUtc = Now,
};
var owner = NewUser(NewSubject());
var team = new Team
{
@@ -710,7 +725,4 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
return vault.Id;
}
/// <summary>Minimal ProblemDetails shape, for asserting on the code extension.</summary>
private sealed record JsonProblem(string? Type, string? Detail, string? Code);
}
+153
View File
@@ -0,0 +1,153 @@
using DodoSSH.Contracts;
using DodoSSH.Crypto;
using NSec.Cryptography;
namespace DodoSSH.Api.Tests;
/// <summary>
/// Builds a genuine enrollment: real X25519 and Ed25519 keys, a real Ed25519 statement signature,
/// and an ID token whose nonce is the statement's actual canonical hash.
/// </summary>
/// <remarks>
/// Nothing here is stubbed. The point is that the server's two independent checks — the statement
/// self-signature and the identity-provider binding — both run for real, so a change that breaks
/// either shows up here rather than against a live Keycloak.
/// </remarks>
internal sealed class TestEnrollment : IDisposable
{
private static readonly DateTimeOffset CreatedAt =
DateTimeOffset.FromUnixTimeMilliseconds(1_750_000_000_123);
private readonly StubIdentityProvider identityProvider;
private readonly Key encryptionKey;
private readonly Key signingKey;
internal TestEnrollment(StubIdentityProvider identityProvider, string subject, string? email = null)
{
this.identityProvider = identityProvider;
Subject = subject;
encryptionKey = Key.Create(KeyAgreementAlgorithm.X25519);
signingKey = Key.Create(SignatureAlgorithm.Ed25519);
Statement = new KeyStatement(
Version: 1,
Issuer: identityProvider.Authority,
Subject: subject,
Email: email,
EncryptionPublicKey: encryptionKey.PublicKey.Export(KeyBlobFormat.RawPublicKey),
SigningPublicKey: signingKey.PublicKey.Export(KeyBlobFormat.RawPublicKey),
KeyGeneration: 1,
CreatedAt: CreatedAt,
DeviceName: "test-device");
}
/// <summary>The OIDC subject this enrollment is for.</summary>
internal string Subject { get; }
/// <summary>The client-chosen personal vault id.</summary>
internal Guid VaultId { get; } = Guid.CreateVersion7();
/// <summary>The default, well-formed statement.</summary>
internal KeyStatement Statement { get; }
/// <summary>The identity fingerprint the server should compute.</summary>
internal byte[] Fingerprint => DshCrypto.ComputeFingerprint(
Statement.EncryptionPublicKey,
Statement.SigningPublicKey);
/// <summary>Signs a statement with this enrollment's Ed25519 key.</summary>
internal byte[] Sign(KeyStatement statement) =>
DshSignatures.SignKeyStatement(signingKey, KeyStatementCodec.Encode(ToFields(statement)));
/// <summary>Mints an ID token whose nonce is the given statement's binding.</summary>
internal string MintIdToken(
KeyStatement statement,
string? subject = null,
string? audience = null,
string? issuer = null,
DateTime? expires = null,
bool omitNonce = false) =>
identityProvider.MintIdToken(
subject ?? Subject,
KeyStatementCodec.ComputeNonce(ToFields(statement)),
audience: audience,
issuer: issuer,
expires: expires,
omitNonce: omitNonce);
/// <summary>Builds a complete, valid request, with every part overridable for negative tests.</summary>
internal EnrollmentRequest Build(
KeyStatement? statement = null,
byte[]? statementSignature = null,
string? idToken = null,
KdfParameters? kdfParameters = null,
PersonalVaultRequest? personalVault = null,
bool includeDevice = true,
bool includeRecovery = true)
{
var effective = statement ?? Statement;
return new EnrollmentRequest(
Statement: effective,
StatementSignature: statementSignature ?? Sign(effective),
IdentityProviderToken: idToken ?? MintIdToken(effective),
WrappedPrivateKey: Bytes(220, 0x11),
KdfParameters: kdfParameters ?? DefaultKdf(),
DevicePublicKey: includeDevice ? Bytes(32, 0x22) : null,
DeviceWrappedPrivateKey: includeDevice ? Bytes(240, 0x33) : null,
RecoveryWrappedPrivateKey: includeRecovery ? Bytes(220, 0x44) : null,
RecoveryKdfParameters: includeRecovery ? RecoveryKdf() : null,
PersonalVault: personalVault ?? DefaultVault());
}
/// <summary>The passphrase KDF profile, matching <c>Argon2Profile.PassphraseDefault</c>.</summary>
internal static KdfParameters DefaultKdf() =>
new("argon2id", Bytes(16, 0x55), MemoryKibibytes: 256 * 1024, Passes: 4, Parallelism: 1);
/// <summary>The recovery KDF profile. Cheaper, because a recovery code carries real entropy.</summary>
internal static KdfParameters RecoveryKdf() =>
new("argon2id", Bytes(16, 0x66), MemoryKibibytes: 64 * 1024, Passes: 3, Parallelism: 1);
/// <remarks>
/// The grant signature is a real Ed25519 signature of the right length, but not over the §7
/// grant tuple: that canonical encoding lands with team sharing in M3, and the server stores
/// grant signatures opaquely rather than verifying them. Shape is what is under test here.
/// </remarks>
internal PersonalVaultRequest DefaultVault(Guid? vaultId = null, string name = "Personal") =>
new(
VaultId: vaultId ?? VaultId,
Name: name,
WrappedVaultKey: Bytes(80, 0x77),
GrantSignature: SignatureAlgorithm.Ed25519.Sign(signingKey, Bytes(32, 0x88)),
GrantedAt: CreatedAt);
/// <summary>Bearer client for this enrollment's subject.</summary>
internal HttpClient CreateClient(ApiFixture fixture) => fixture.CreateClientFor(Subject);
/// <inheritdoc />
public void Dispose()
{
encryptionKey.Dispose();
signingKey.Dispose();
}
private static byte[] Bytes(int length, byte seed) =>
[.. Enumerable.Range(0, length).Select(i => (byte)(seed + i))];
/// <remarks>
/// Mapped here rather than reusing the server's mapper, so a change to either side's field list
/// shows up as a failing enrollment instead of two copies of the same mistake agreeing.
/// </remarks>
private static KeyStatementFields ToFields(KeyStatement statement) =>
new(
statement.Version,
statement.Issuer,
statement.Subject,
statement.Email,
statement.EncryptionPublicKey,
statement.SigningPublicKey,
statement.KeyGeneration,
statement.CreatedAt,
statement.DeviceName);
}