Public Access
The first of the three pieces ADR 0007 needs, and the one that was a discovery rather than a plan. EnrollmentService.AddDevice runs only during enrollment, so without an endpoint the device-unlock feature would have reached accounts created after it shipped and no others — which is to say none of the ones that exist. The code even said so: "the devices endpoint sets it properly when it lands." POST /api/v1/me/devices takes a name, an X25519 public key and the bundle sealed to it, and writes a device row plus a UserKeyWrapKind.Device wrap. Possession is proved by construction, so there is no challenge. The wrap is the secret bundle sealed to the supplied public key, and only something that has opened that bundle can produce it. A caller who seals the wrong bytes registers a device that cannot unlock, which harms nobody else; the server cannot tell the difference and must not pretend to, because it holds no key that opens either. That is also why the client must be unlocked to call this at all. It is the one endpoint in the /me group that requires enrollment, and it says so itself rather than relying on the group. The group deliberately does not: GET / and POST /enrollment are how a client discovers it needs to enroll and then does so, and gating those on enrollment would make enrollment unreachable. Adding the stricter policy to this route alone means an unenrolled caller is told "enrollment-required" by the authorization handler rather than getting a 400 about the shape of a request that was fine. Idempotent on the public key, and 200 rather than 201 for the reason enrollment gives: a retry of an identical request returns the same body, so there is no single moment of creation to point a Location header at. A second row for one key would mean a device list with a duplicate in it and two wraps to revoke instead of one. Mutation tested — removing the lookup fails RegisterDevice_TwiceWithTheSameKey_ReturnsTheSameDeviceAndAddsNoSecondWrap and nothing else. That test also found a real defect, in the way these usually surface: two timestamps that print identically and are not equal. TimeProvider reports 100-nanosecond ticks and PostgreSQL's timestamp with time zone keeps microseconds, so the first call returned a value that no later read of the row would ever produce, and the idempotent retry answered with a different timestamp for the same device. Nothing breaks, which is what makes it worth fixing: the service now truncates to the precision the column actually holds, so the response is the same value every time it is asked for. The repo already had a precedent for this class of thing in KeyLogChain.TruncateTimestamp; it just had not been applied here. The platform is deliberately not carried on the wire, which leaves Device.Platform unreported and the stale comment corrected rather than fulfilled. It would be a display-only field, and a Contracts enum mirroring the domain's DevicePlatform is exactly the shape of duplication that has produced three self-consistent bugs in this repository. A device list that wants it can add a mapping table and a test pinning the two together, which is what the sync entity types already do. Its own problem code and exception rather than reusing enrollment's, whose rules it largely shares. Registering a device is not enrolling, and a client showing "your enrollment was rejected" because somebody set up a fingerprint reader would be describing the wrong thing. The validation shares the limit constants — MaximumWrapBytes, MaximumDeviceNameLength, PublicKeySize — and not the four-line guards, which would have had to be parameterised over which exception to throw for less than they cost. Both in-memory fakes implement it properly rather than throwing: they record the wrap so a test can assert it arrived, and refuse before enrollment as the real endpoint's policy does. A fake that answered where the server refuses is a fake that can make a real bug pass. 866 tests green, 8 of them new. Zero warnings, dotnet format clean. Still to come: the protector seam with the wrap cached locally so device unlock works offline, then the Windows Hello implementation and the unlock-screen UI — which is where the Windows target framework lands and where automated testing stops.
1074 lines
41 KiB
C#
1074 lines
41 KiB
C#
using System.Net;
|
|
using System.Net.Http.Json;
|
|
using DodoSSH.Contracts;
|
|
using DodoSSH.Crypto;
|
|
using DodoSSH.Domain;
|
|
using DodoSSH.Infrastructure;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
|
|
namespace DodoSSH.Api.Tests;
|
|
|
|
/// <summary>
|
|
/// Just-in-time provisioning, <c>/me</c>, and enrollment end to end over HTTP.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The rejection tests are the important ones. Enrollment is where a user's public keys enter the
|
|
/// system, and every confidentiality guarantee in the product is downstream of those keys being
|
|
/// genuinely theirs. A hole here is not a bug in one endpoint; it is the whole vault.
|
|
/// </remarks>
|
|
[Collection(ApiCollection.Name)]
|
|
public sealed class IdentityEndpointTests(ApiFixture fixture)
|
|
{
|
|
private const string MeUrl = "/api/v1/me";
|
|
private const string EnrollUrl = "/api/v1/me/enrollment";
|
|
private const string DevicesUrl = "/api/v1/me/devices";
|
|
|
|
// ---- Provisioning and /me ----
|
|
|
|
[Fact]
|
|
public async Task GetMe_WithoutAToken_Is401()
|
|
{
|
|
var response = await fixture.CreateClient().GetAsync(new Uri(MeUrl, UriKind.Relative));
|
|
|
|
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetMe_OnAFirstRequest_ProvisionsTheUserAndAsksForEnrollment()
|
|
{
|
|
var subject = NewSubject();
|
|
var email = $"{subject}@example.com";
|
|
var client = fixture.CreateClientFor(subject, email);
|
|
|
|
var me = await ReadMeAsync(client);
|
|
|
|
me.Issuer.ShouldBe(fixture.IdentityProvider.Authority);
|
|
me.Subject.ShouldBe(subject);
|
|
me.Email.ShouldBe(email);
|
|
|
|
// The single flag a client branches on at startup.
|
|
me.EnrollmentRequired.ShouldBeTrue();
|
|
me.KeyGeneration.ShouldBeNull();
|
|
me.WrappedPrivateKey.ShouldBeNull();
|
|
me.KdfParameters.ShouldBeNull();
|
|
me.Vaults.ShouldBeEmpty();
|
|
|
|
await using var scope = fixture.CreateScope();
|
|
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
|
|
|
|
var user = await database.Users.SingleOrDefaultAsync(u => u.Subject == subject);
|
|
user.ShouldNotBeNull();
|
|
user.Status.ShouldBe(UserStatus.Active);
|
|
user.EnrolledAtUtc.ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetMe_RepeatedRequests_ProvisionOnlyOnce()
|
|
{
|
|
var subject = NewSubject();
|
|
var client = fixture.CreateClientFor(subject);
|
|
|
|
for (var i = 0; i < 3; i++)
|
|
{
|
|
(await client.GetAsync(new Uri(MeUrl, UriKind.Relative))).EnsureSuccessStatusCode();
|
|
}
|
|
|
|
await using var scope = fixture.CreateScope();
|
|
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
|
|
|
|
(await database.Users.CountAsync(u => u.Subject == subject)).ShouldBe(1);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetMe_AfterEnrolling_ReturnsEverythingNeededToUnlockOffline()
|
|
{
|
|
using var enrollment = NewEnrollment();
|
|
var client = enrollment.CreateClient(fixture);
|
|
|
|
var enrolled = await EnrollAsync(client, enrollment.Build());
|
|
|
|
var me = await ReadMeAsync(client);
|
|
|
|
me.EnrollmentRequired.ShouldBeFalse();
|
|
me.KeyGeneration.ShouldBe(1);
|
|
|
|
// Wrap and KDF parameters together, because unlock has to work with no network at all. If
|
|
// the salt were fetched at unlock time, an offline launch could not open the vault.
|
|
me.WrappedPrivateKey.ShouldNotBeNull();
|
|
me.KdfParameters.ShouldNotBeNull();
|
|
me.KdfParameters.Algorithm.ShouldBe("argon2id");
|
|
me.KdfParameters.MemoryKibibytes.ShouldBe(256 * 1024);
|
|
me.KdfParameters.Passes.ShouldBe(4);
|
|
me.KdfParameters.Parallelism.ShouldBe(1);
|
|
|
|
var vault = me.Vaults.ShouldHaveSingleItem();
|
|
vault.VaultId.ShouldBe(enrolled.PersonalVaultId);
|
|
vault.IsPersonal.ShouldBeTrue();
|
|
vault.TeamId.ShouldBeNull();
|
|
vault.KeyGeneration.ShouldBe(1u);
|
|
vault.RekeyRequired.ShouldBeFalse();
|
|
|
|
// Without the wrapped vault key the vault is unopenable, so this being present is the
|
|
// difference between enrollment having worked and having half worked.
|
|
vault.WrappedVaultKey.ShouldNotBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetMe_DoesNotListAnotherUsersVault()
|
|
{
|
|
using var owner = NewEnrollment();
|
|
await EnrollAsync(owner.CreateClient(fixture), owner.Build());
|
|
|
|
using var other = NewEnrollment();
|
|
var otherClient = other.CreateClient(fixture);
|
|
await EnrollAsync(otherClient, other.Build());
|
|
|
|
var me = await ReadMeAsync(otherClient);
|
|
|
|
me.Vaults.ShouldHaveSingleItem().VaultId.ShouldBe(other.VaultId);
|
|
}
|
|
|
|
// ---- Enrollment: the happy path ----
|
|
|
|
[Fact]
|
|
public async Task Enroll_WritesTheKeyItsWrapsTheDeviceTheLogEntryAndTheVault()
|
|
{
|
|
using var enrollment = NewEnrollment();
|
|
var client = enrollment.CreateClient(fixture);
|
|
|
|
var response = await EnrollAsync(client, enrollment.Build());
|
|
|
|
response.KeyGeneration.ShouldBe(1);
|
|
response.PersonalVaultId.ShouldBe(enrollment.VaultId);
|
|
response.DeviceId.ShouldNotBeNull();
|
|
response.KeyLogSequence.ShouldBeGreaterThan(0);
|
|
|
|
// The fingerprint is computed by the server from the statement, never taken from the client.
|
|
response.Fingerprint.ShouldBe(enrollment.Fingerprint);
|
|
|
|
await using var scope = fixture.CreateScope();
|
|
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
|
|
|
|
var key = await database.UserKeys.SingleAsync(k => k.UserId == response.UserId);
|
|
key.IsCurrent.ShouldBeTrue();
|
|
key.Generation.ShouldBe(1);
|
|
key.FingerprintSha256.ShouldBe(enrollment.Fingerprint);
|
|
key.EncryptionPublicKey.ShouldBe(enrollment.Statement.EncryptionPublicKey);
|
|
key.SigningPublicKey.ShouldBe(enrollment.Statement.SigningPublicKey);
|
|
|
|
var wraps = await database.UserKeyWraps
|
|
.Where(w => w.UserId == response.UserId)
|
|
.ToListAsync();
|
|
|
|
// One bundle, three wraps of it. That shape is what makes a passphrase change a single-row
|
|
// update rather than a re-encryption of the vault.
|
|
wraps.Count.ShouldBe(3);
|
|
wraps.Select(w => w.Kind).Order().ShouldBe(
|
|
[UserKeyWrapKind.Passphrase, UserKeyWrapKind.Device, UserKeyWrapKind.Recovery]);
|
|
|
|
var device = wraps.Single(w => w.Kind == UserKeyWrapKind.Device);
|
|
device.DeviceId.ShouldBe(response.DeviceId);
|
|
device.KdfAlgorithm.ShouldBeNull();
|
|
device.KdfSalt.ShouldBeNull();
|
|
|
|
var vault = await database.Vaults.SingleAsync(v => v.Id == enrollment.VaultId);
|
|
vault.OwnerKind.ShouldBe(VaultOwnerKind.Personal);
|
|
vault.OwnerUserId.ShouldBe(response.UserId);
|
|
vault.KeyGeneration.ShouldBe(1);
|
|
|
|
var grant = await database.VaultKeyGrants.SingleAsync(g => g.VaultId == enrollment.VaultId);
|
|
grant.Kind.ShouldBe(GrantKind.Member);
|
|
grant.State.ShouldBe(GrantState.Active);
|
|
grant.RecipientUserId.ShouldBe(response.UserId);
|
|
grant.RecipientKeyFingerprint.ShouldBe(enrollment.Fingerprint);
|
|
grant.GranterKeyFingerprint.ShouldBe(enrollment.Fingerprint);
|
|
|
|
// A self-grant records no key log head: there is no third party whose key could have been
|
|
// substituted, and the client could not have signed over an entry written alongside it.
|
|
grant.KeyLogHead.ShouldBeNull();
|
|
|
|
var user = await database.Users.SingleAsync(u => u.Id == response.UserId);
|
|
user.EnrolledAtUtc.ShouldNotBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Enroll_AppendsToTheKeyLogAndTheEntryReproducesItsOwnHash()
|
|
{
|
|
using var enrollment = NewEnrollment();
|
|
var response = await EnrollAsync(enrollment.CreateClient(fixture), enrollment.Build());
|
|
|
|
await using var scope = fixture.CreateScope();
|
|
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
|
|
|
|
var entry = await database.KeyLog.SingleAsync(e => e.UserId == response.UserId);
|
|
|
|
// The stored row must reproduce its own hash after a round trip. It only does so if the
|
|
// timestamp was truncated to the precision the chain hashes before being written — the
|
|
// column holds microseconds and the hash covers milliseconds.
|
|
var recomputed = KeyLogChain.ComputeEntryHash(
|
|
entry.PreviousHash,
|
|
entry.UserId,
|
|
entry.Generation,
|
|
entry.EncryptionPublicKey,
|
|
entry.SigningPublicKey,
|
|
entry.StatementSignature,
|
|
entry.CreatedAtUtc);
|
|
|
|
entry.Hash.ShouldBe(recomputed);
|
|
entry.Sequence.ShouldBe(response.KeyLogSequence);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Enroll_LinksEachLogEntryToItsPredecessor()
|
|
{
|
|
// The chain is what turns key substitution from undetectable into detectable: a server
|
|
// serving divergent views has to keep both forks consistent forever.
|
|
using var first = NewEnrollment();
|
|
var firstResponse = await EnrollAsync(first.CreateClient(fixture), first.Build());
|
|
|
|
using var second = NewEnrollment();
|
|
var secondResponse = await EnrollAsync(second.CreateClient(fixture), second.Build());
|
|
|
|
await using var scope = fixture.CreateScope();
|
|
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
|
|
|
|
var firstEntry = await database.KeyLog.SingleAsync(e => e.UserId == firstResponse.UserId);
|
|
var secondEntry = await database.KeyLog.SingleAsync(e => e.UserId == secondResponse.UserId);
|
|
|
|
secondEntry.Sequence.ShouldBeGreaterThan(firstEntry.Sequence);
|
|
|
|
// Not necessarily the immediate successor — the shared database has other tests' entries —
|
|
// so walk back rather than assuming adjacency.
|
|
var predecessor = await database.KeyLog
|
|
.Where(e => e.Sequence < secondEntry.Sequence)
|
|
.OrderByDescending(e => e.Sequence)
|
|
.FirstAsync();
|
|
|
|
secondEntry.PreviousHash.ShouldBe(predecessor.Hash);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Enroll_RetainsTheProviderTokenSoClientsNeedNotTrustTheServer()
|
|
{
|
|
using var enrollment = NewEnrollment();
|
|
var request = enrollment.Build();
|
|
|
|
var response = await EnrollAsync(enrollment.CreateClient(fixture), request);
|
|
|
|
await using var scope = fixture.CreateScope();
|
|
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
|
|
|
|
var key = await database.UserKeys.SingleAsync(k => k.UserId == response.UserId);
|
|
|
|
// Storing only our own summary would ask clients to trust the server about the single
|
|
// question the design exists to avoid trusting it about.
|
|
key.IdentityProviderBinding.ShouldNotBeNull();
|
|
key.IdentityProviderBinding.ShouldContain(request.IdentityProviderToken);
|
|
key.IdentityProviderBinding.ShouldContain(
|
|
KeyStatementCodec.ComputeNonce(Fields(enrollment.Statement)));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Enroll_ThenPush_CompletesTheVerticalSlice()
|
|
{
|
|
// Login, enroll, store an item. The whole point of M1's backend.
|
|
using var enrollment = NewEnrollment();
|
|
var client = enrollment.CreateClient(fixture);
|
|
|
|
await EnrollAsync(client, enrollment.Build());
|
|
|
|
var push = await client.PostContractAsync(
|
|
$"/api/v1/vaults/{enrollment.VaultId}/sync/push",
|
|
new SyncPushRequest(
|
|
[
|
|
new SyncPushOperation(
|
|
Guid.CreateVersion7(),
|
|
SyncEntityType.Host,
|
|
Guid.CreateVersion7(),
|
|
SyncOperation.Upsert,
|
|
null,
|
|
new EncryptedPayload([1, 2, 3, 4], [5, 6], Guid.CreateVersion7(), 1, 1),
|
|
new SyncPlaintextFields()),
|
|
]));
|
|
|
|
push.EnsureSuccessStatusCode();
|
|
|
|
var body = await push.Content.ReadContractAsync<SyncPushResponse>();
|
|
body.ShouldNotBeNull();
|
|
body.Results[0].Status.ShouldBe(SyncOperationStatus.Applied);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Enroll_WithoutADeviceOrRecoveryWrap_StillSucceeds()
|
|
{
|
|
using var enrollment = NewEnrollment();
|
|
|
|
var response = await EnrollAsync(
|
|
enrollment.CreateClient(fixture),
|
|
enrollment.Build(includeDevice: false, includeRecovery: false));
|
|
|
|
response.DeviceId.ShouldBeNull();
|
|
|
|
await using var scope = fixture.CreateScope();
|
|
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
|
|
|
|
var wraps = await database.UserKeyWraps.Where(w => w.UserId == response.UserId).ToListAsync();
|
|
wraps.ShouldHaveSingleItem().Kind.ShouldBe(UserKeyWrapKind.Passphrase);
|
|
}
|
|
|
|
// ---- Enrollment: retries and conflicts ----
|
|
|
|
[Fact]
|
|
public async Task Enroll_ReplayingTheIdenticalRequest_IsIdempotent()
|
|
{
|
|
// A client whose response was lost re-sends the same body. Creating a second vault instead
|
|
// would strand the first one with no way for the client to discover it.
|
|
using var enrollment = NewEnrollment();
|
|
var client = enrollment.CreateClient(fixture);
|
|
var request = enrollment.Build();
|
|
|
|
var first = await EnrollAsync(client, request);
|
|
var replay = await EnrollAsync(client, request);
|
|
|
|
replay.UserId.ShouldBe(first.UserId);
|
|
replay.KeyGeneration.ShouldBe(first.KeyGeneration);
|
|
replay.PersonalVaultId.ShouldBe(first.PersonalVaultId);
|
|
replay.KeyLogSequence.ShouldBe(first.KeyLogSequence);
|
|
replay.Fingerprint.ShouldBe(first.Fingerprint);
|
|
replay.DeviceId.ShouldBe(first.DeviceId);
|
|
|
|
await using var scope = fixture.CreateScope();
|
|
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
|
|
|
|
(await database.UserKeys.CountAsync(k => k.UserId == first.UserId)).ShouldBe(1);
|
|
(await database.KeyLog.CountAsync(e => e.UserId == first.UserId)).ShouldBe(1);
|
|
(await database.Vaults.CountAsync(v => v.OwnerUserId == first.UserId)).ShouldBe(1);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Enroll_ConcurrentEnrollmentsByDifferentUsers_LeaveAnUnbrokenChain()
|
|
{
|
|
// The real hazard the deployment-wide advisory lock exists for. Different users trip no
|
|
// unique index, so without serialised appends they all read the same head and write entries
|
|
// that each claim the same predecessor. That is a forked chain — indistinguishable from the
|
|
// key-substitution attack the log exists to make detectable — and it is permanent, because
|
|
// the log is append-only and no server-side repair is possible.
|
|
const int Concurrency = 6;
|
|
|
|
var enrollments = Enumerable.Range(0, Concurrency).Select(_ => NewEnrollment()).ToArray();
|
|
|
|
try
|
|
{
|
|
var responses = await Task.WhenAll(enrollments.Select(e =>
|
|
e.CreateClient(fixture).PostContractAsync(EnrollUrl, e.Build())));
|
|
|
|
foreach (var response in responses)
|
|
{
|
|
response.EnsureSuccessStatusCode();
|
|
}
|
|
|
|
await using var scope = fixture.CreateScope();
|
|
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
|
|
|
|
// The whole log, including entries earlier tests appended. Every link and every hash.
|
|
var entries = await database.KeyLog.OrderBy(e => e.Sequence).ToListAsync();
|
|
|
|
entries.Count.ShouldBeGreaterThanOrEqualTo(Concurrency);
|
|
|
|
var expectedPrevious = KeyLogChain.CreateGenesisPreviousHash();
|
|
|
|
foreach (var entry in entries)
|
|
{
|
|
entry.PreviousHash.ShouldBe(
|
|
expectedPrevious,
|
|
$"Key log entry {entry.Sequence} does not link to its predecessor. The chain "
|
|
+ "has forked, which is exactly what an undetectable key substitution looks like.");
|
|
|
|
entry.Hash.ShouldBe(KeyLogChain.ComputeEntryHash(
|
|
entry.PreviousHash,
|
|
entry.UserId,
|
|
entry.Generation,
|
|
entry.EncryptionPublicKey,
|
|
entry.SigningPublicKey,
|
|
entry.StatementSignature,
|
|
entry.CreatedAtUtc));
|
|
|
|
expectedPrevious = entry.Hash;
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
foreach (var enrollment in enrollments)
|
|
{
|
|
enrollment.Dispose();
|
|
}
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Enroll_ConcurrentIdenticalRequests_ProduceExactlyOneEnrollment()
|
|
{
|
|
// A client on a flaky connection genuinely does this. Four requests race just-in-time
|
|
// provisioning, the unique index on the current key, and the deployment-wide key log lock at
|
|
// once. Every one must end in the same answer, and the append-only log must gain one entry —
|
|
// two entries claiming the same predecessor would look exactly like the fork the chain
|
|
// exists to detect.
|
|
using var enrollment = NewEnrollment();
|
|
var request = enrollment.Build();
|
|
|
|
var responses = await Task.WhenAll(
|
|
Enumerable.Range(0, 4).Select(_ =>
|
|
enrollment.CreateClient(fixture).PostContractAsync(EnrollUrl, request)));
|
|
|
|
var bodies = new List<EnrollmentResponse>(responses.Length);
|
|
foreach (var response in responses)
|
|
{
|
|
response.EnsureSuccessStatusCode();
|
|
|
|
var body = await response.Content.ReadContractAsync<EnrollmentResponse>();
|
|
body.ShouldNotBeNull();
|
|
bodies.Add(body);
|
|
}
|
|
|
|
bodies.Select(b => b.UserId).Distinct().ShouldHaveSingleItem();
|
|
bodies.Select(b => b.KeyLogSequence).Distinct().ShouldHaveSingleItem();
|
|
bodies.Select(b => b.DeviceId).Distinct().ShouldHaveSingleItem();
|
|
|
|
var userId = bodies[0].UserId;
|
|
|
|
await using var scope = fixture.CreateScope();
|
|
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
|
|
|
|
(await database.Users.CountAsync(u => u.Subject == enrollment.Subject)).ShouldBe(1);
|
|
(await database.UserKeys.CountAsync(k => k.UserId == userId)).ShouldBe(1);
|
|
(await database.KeyLog.CountAsync(e => e.UserId == userId)).ShouldBe(1);
|
|
(await database.Vaults.CountAsync(v => v.OwnerUserId == userId)).ShouldBe(1);
|
|
(await database.Devices.CountAsync(d => d.UserId == userId)).ShouldBe(1);
|
|
(await database.UserKeyWraps.CountAsync(w => w.UserId == userId)).ShouldBe(3);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Enroll_ASecondDifferentKey_Is409()
|
|
{
|
|
// Accepting it would orphan every vault key already wrapped to the first one.
|
|
using var first = NewEnrollment();
|
|
var client = first.CreateClient(fixture);
|
|
await EnrollAsync(client, first.Build());
|
|
|
|
using var second = new TestEnrollment(fixture.IdentityProvider, first.Subject);
|
|
|
|
var response = await client.PostContractAsync(EnrollUrl, second.Build());
|
|
|
|
await ShouldBeProblemAsync(response, HttpStatusCode.Conflict, ProblemCodes.AlreadyEnrolled);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Enroll_TheSameKeysAgainstADifferentVaultId_Is409()
|
|
{
|
|
using var enrollment = NewEnrollment();
|
|
var client = enrollment.CreateClient(fixture);
|
|
await EnrollAsync(client, enrollment.Build());
|
|
|
|
var response = await client.PostContractAsync(
|
|
EnrollUrl,
|
|
enrollment.Build(personalVault: enrollment.DefaultVault(vaultId: Guid.CreateVersion7())));
|
|
|
|
await ShouldBeProblemAsync(response, HttpStatusCode.Conflict, ProblemCodes.AlreadyEnrolled);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Enroll_WithAVaultIdAnotherUserAlreadyHas_Is400()
|
|
{
|
|
using var owner = NewEnrollment();
|
|
await EnrollAsync(owner.CreateClient(fixture), owner.Build());
|
|
|
|
using var second = NewEnrollment();
|
|
|
|
var response = await second.CreateClient(fixture).PostContractAsync(
|
|
EnrollUrl,
|
|
second.Build(personalVault: second.DefaultVault(vaultId: owner.VaultId)));
|
|
|
|
await ShouldBeProblemAsync(
|
|
response,
|
|
HttpStatusCode.BadRequest,
|
|
ProblemCodes.InvalidEnrollment);
|
|
}
|
|
|
|
// ---- Enrollment: the identity-provider binding ----
|
|
|
|
[Fact]
|
|
public async Task Enroll_WithANonceForADifferentStatement_IsRejected()
|
|
{
|
|
// The heart of it. A token that does not hash to *this* statement binds nothing, so
|
|
// accepting it would let anyone publish keys under any account the provider knows.
|
|
using var enrollment = NewEnrollment();
|
|
var other = enrollment.Statement with { DeviceName = "some-other-device" };
|
|
|
|
var response = await enrollment.CreateClient(fixture).PostContractAsync(
|
|
EnrollUrl,
|
|
enrollment.Build(idToken: enrollment.MintIdToken(other)));
|
|
|
|
await ShouldBeBindingRejectedAsync(response);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Enroll_WithATokenForAnotherSubject_IsRejected()
|
|
{
|
|
using var enrollment = NewEnrollment();
|
|
|
|
var response = await enrollment.CreateClient(fixture).PostContractAsync(
|
|
EnrollUrl,
|
|
enrollment.Build(
|
|
idToken: enrollment.MintIdToken(enrollment.Statement, subject: NewSubject())));
|
|
|
|
await ShouldBeBindingRejectedAsync(response);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Enroll_WithATokenAudiencedToTheApiRatherThanTheClient_IsRejected()
|
|
{
|
|
// An ID token is audienced to the client. An access token presented here would be a
|
|
// different kind of assertion entirely, and must not be interchangeable with one.
|
|
using var enrollment = NewEnrollment();
|
|
|
|
var response = await enrollment.CreateClient(fixture).PostContractAsync(
|
|
EnrollUrl,
|
|
enrollment.Build(
|
|
idToken: enrollment.MintIdToken(
|
|
enrollment.Statement,
|
|
audience: StubIdentityProvider.Audience)));
|
|
|
|
await ShouldBeBindingRejectedAsync(response);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Enroll_WithATokenSignedByAKeyTheProviderDoesNotPublish_IsRejected()
|
|
{
|
|
// Proves the JWKS check genuinely runs rather than the token being parsed and trusted.
|
|
using var enrollment = NewEnrollment();
|
|
|
|
var foreign = fixture.IdentityProvider.MintIdTokenWithForeignKey(
|
|
enrollment.Subject,
|
|
KeyStatementCodec.ComputeNonce(Fields(enrollment.Statement)));
|
|
|
|
var response = await enrollment.CreateClient(fixture).PostContractAsync(
|
|
EnrollUrl,
|
|
enrollment.Build(idToken: foreign));
|
|
|
|
await ShouldBeBindingRejectedAsync(response);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Enroll_WithAnExpiredToken_IsRejected()
|
|
{
|
|
using var enrollment = NewEnrollment();
|
|
|
|
var response = await enrollment.CreateClient(fixture).PostContractAsync(
|
|
EnrollUrl,
|
|
enrollment.Build(
|
|
idToken: enrollment.MintIdToken(
|
|
enrollment.Statement,
|
|
expires: TimeProvider.System.GetUtcNow().UtcDateTime.AddMinutes(-10))));
|
|
|
|
await ShouldBeBindingRejectedAsync(response);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Enroll_WithATokenFromAnotherIssuer_IsRejected()
|
|
{
|
|
using var enrollment = NewEnrollment();
|
|
|
|
var response = await enrollment.CreateClient(fixture).PostContractAsync(
|
|
EnrollUrl,
|
|
enrollment.Build(
|
|
idToken: enrollment.MintIdToken(enrollment.Statement, issuer: "https://evil.example")));
|
|
|
|
await ShouldBeBindingRejectedAsync(response);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Enroll_WithNoNonceAtAll_IsRejected()
|
|
{
|
|
using var enrollment = NewEnrollment();
|
|
|
|
var response = await enrollment.CreateClient(fixture).PostContractAsync(
|
|
EnrollUrl,
|
|
enrollment.Build(
|
|
idToken: enrollment.MintIdToken(enrollment.Statement, omitNonce: true)));
|
|
|
|
await ShouldBeBindingRejectedAsync(response);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Enroll_WhenTheBindingFails_WritesNothing()
|
|
{
|
|
// A rejection that still wrote a key log entry would poison the chain permanently, because
|
|
// the log is append-only.
|
|
using var enrollment = NewEnrollment();
|
|
var client = enrollment.CreateClient(fixture);
|
|
|
|
var response = await client.PostContractAsync(
|
|
EnrollUrl,
|
|
enrollment.Build(idToken: enrollment.MintIdToken(enrollment.Statement, omitNonce: true)));
|
|
|
|
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
|
|
|
|
await using var scope = fixture.CreateScope();
|
|
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
|
|
|
|
var user = await database.Users.SingleAsync(u => u.Subject == enrollment.Subject);
|
|
|
|
(await database.UserKeys.AnyAsync(k => k.UserId == user.Id)).ShouldBeFalse();
|
|
(await database.UserKeyWraps.AnyAsync(w => w.UserId == user.Id)).ShouldBeFalse();
|
|
(await database.KeyLog.AnyAsync(e => e.UserId == user.Id)).ShouldBeFalse();
|
|
(await database.Vaults.AnyAsync(v => v.Id == enrollment.VaultId)).ShouldBeFalse();
|
|
user.EnrolledAtUtc.ShouldBeNull();
|
|
}
|
|
|
|
// ---- Enrollment: statement and shape validation ----
|
|
|
|
[Fact]
|
|
public async Task Enroll_WithASignatureFromAnotherKey_IsRejected()
|
|
{
|
|
using var enrollment = NewEnrollment();
|
|
using var other = NewEnrollment();
|
|
|
|
var response = await enrollment.CreateClient(fixture).PostContractAsync(
|
|
EnrollUrl,
|
|
enrollment.Build(statementSignature: other.Sign(enrollment.Statement)));
|
|
|
|
await ShouldBeProblemAsync(
|
|
response,
|
|
HttpStatusCode.BadRequest,
|
|
ProblemCodes.InvalidEnrollment);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Enroll_WithAStatementNamingAnotherSubject_IsRejected()
|
|
{
|
|
// Caught by shape validation before any cryptography, so the code says "invalid" rather
|
|
// than blaming the binding.
|
|
using var enrollment = NewEnrollment();
|
|
var impersonating = enrollment.Statement with { Subject = NewSubject() };
|
|
|
|
var response = await enrollment.CreateClient(fixture).PostContractAsync(
|
|
EnrollUrl,
|
|
enrollment.Build(statement: impersonating));
|
|
|
|
await ShouldBeProblemAsync(
|
|
response,
|
|
HttpStatusCode.BadRequest,
|
|
ProblemCodes.InvalidEnrollment);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(1024, 4, "memory far below the floor")]
|
|
[InlineData(256 * 1024, 1, "too few passes")]
|
|
[InlineData(8 * 1024 * 1024, 4, "memory above the ceiling")]
|
|
public async Task Enroll_WithUnacceptableKdfParameters_IsRejected(
|
|
int memoryKibibytes,
|
|
int passes,
|
|
string reason)
|
|
{
|
|
// A weak wrap here is a permanent liability: it is exactly what an attacker who ever
|
|
// obtains a database dump grinds against offline. An absurd one locks the user out instead.
|
|
reason.ShouldNotBeEmpty();
|
|
|
|
using var enrollment = NewEnrollment();
|
|
|
|
var response = await enrollment.CreateClient(fixture).PostContractAsync(
|
|
EnrollUrl,
|
|
enrollment.Build(
|
|
kdfParameters: new KdfParameters("argon2id", new byte[16], memoryKibibytes, passes, 1)));
|
|
|
|
await ShouldBeProblemAsync(
|
|
response,
|
|
HttpStatusCode.BadRequest,
|
|
ProblemCodes.InvalidEnrollment);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Enroll_WithParallelismOtherThanOne_IsRejected()
|
|
{
|
|
using var enrollment = NewEnrollment();
|
|
|
|
var response = await enrollment.CreateClient(fixture).PostContractAsync(
|
|
EnrollUrl,
|
|
enrollment.Build(
|
|
kdfParameters: new KdfParameters("argon2id", new byte[16], 256 * 1024, 4, 4)));
|
|
|
|
await ShouldBeProblemAsync(
|
|
response,
|
|
HttpStatusCode.BadRequest,
|
|
ProblemCodes.InvalidEnrollment);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Enroll_WithAWrongLengthPublicKey_IsRejected()
|
|
{
|
|
using var enrollment = NewEnrollment();
|
|
var truncated = enrollment.Statement with { EncryptionPublicKey = new byte[31] };
|
|
|
|
// The signature and token are supplied ready-made: a malformed statement cannot be
|
|
// canonically encoded at all, so neither can be derived from it. Shape validation runs
|
|
// before any cryptography, so the server never gets that far either.
|
|
var response = await enrollment.CreateClient(fixture).PostContractAsync(
|
|
EnrollUrl,
|
|
enrollment.Build(
|
|
statement: truncated,
|
|
statementSignature: new byte[64],
|
|
idToken: enrollment.MintIdToken(enrollment.Statement)));
|
|
|
|
await ShouldBeProblemAsync(
|
|
response,
|
|
HttpStatusCode.BadRequest,
|
|
ProblemCodes.InvalidEnrollment);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Enroll_ReusingOneKeyForBothRoles_IsRejected()
|
|
{
|
|
// Using one key for agreement and signatures is a standing cryptographic mistake, and is
|
|
// also the shape a client bug takes when it exports the wrong key twice.
|
|
using var enrollment = NewEnrollment();
|
|
var reused = enrollment.Statement with
|
|
{
|
|
EncryptionPublicKey = enrollment.Statement.SigningPublicKey,
|
|
};
|
|
|
|
var response = await enrollment.CreateClient(fixture).PostContractAsync(
|
|
EnrollUrl,
|
|
enrollment.Build(statement: reused));
|
|
|
|
await ShouldBeProblemAsync(
|
|
response,
|
|
HttpStatusCode.BadRequest,
|
|
ProblemCodes.InvalidEnrollment);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Enroll_WithAGenerationOtherThanOne_IsRejected()
|
|
{
|
|
using var enrollment = NewEnrollment();
|
|
var later = enrollment.Statement with { KeyGeneration = 2 };
|
|
|
|
var response = await enrollment.CreateClient(fixture).PostContractAsync(
|
|
EnrollUrl,
|
|
enrollment.Build(statement: later));
|
|
|
|
await ShouldBeProblemAsync(
|
|
response,
|
|
HttpStatusCode.BadRequest,
|
|
ProblemCodes.InvalidEnrollment);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Enroll_WithADeviceKeyButNoDeviceWrap_IsRejected()
|
|
{
|
|
// Only the holder of the bundle can seal it, so a device key with no wrap would register a
|
|
// device that can never unlock anything and that the server could never fix.
|
|
using var enrollment = NewEnrollment();
|
|
|
|
var response = await enrollment.CreateClient(fixture).PostContractAsync(
|
|
EnrollUrl,
|
|
enrollment.Build() with { DeviceWrappedPrivateKey = null });
|
|
|
|
await ShouldBeProblemAsync(
|
|
response,
|
|
HttpStatusCode.BadRequest,
|
|
ProblemCodes.InvalidEnrollment);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Enroll_WithARecoveryWrapButNoKdfParameters_IsRejected()
|
|
{
|
|
using var enrollment = NewEnrollment();
|
|
|
|
var response = await enrollment.CreateClient(fixture).PostContractAsync(
|
|
EnrollUrl,
|
|
enrollment.Build() with { RecoveryKdfParameters = null });
|
|
|
|
await ShouldBeProblemAsync(
|
|
response,
|
|
HttpStatusCode.BadRequest,
|
|
ProblemCodes.InvalidEnrollment);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Enroll_WithAJsonBodyMissingRequiredFields_Is400NotAServerError()
|
|
{
|
|
// A record's non-nullable parameters are a compile-time promise only — JSON that omits a
|
|
// property deserialises to null regardless. The distinction that matters is 400 versus 500:
|
|
// one tells a client its request was wrong, the other looks like the server broke.
|
|
using var enrollment = NewEnrollment();
|
|
|
|
using var content = new StringContent(
|
|
"""{"statement":null,"statementSignature":null,"identityProviderToken":null}""",
|
|
System.Text.Encoding.UTF8,
|
|
"application/json");
|
|
|
|
var response = await enrollment.CreateClient(fixture).PostAsync(
|
|
new Uri(EnrollUrl, UriKind.Relative),
|
|
content);
|
|
|
|
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Enroll_WithoutKdfParameters_Is400NotAServerError()
|
|
{
|
|
using var enrollment = NewEnrollment();
|
|
|
|
var response = await enrollment.CreateClient(fixture).PostContractAsync(
|
|
EnrollUrl,
|
|
enrollment.Build() with { KdfParameters = null! });
|
|
|
|
await ShouldBeProblemAsync(
|
|
response,
|
|
HttpStatusCode.BadRequest,
|
|
ProblemCodes.InvalidEnrollment);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Enroll_WithoutAToken_Is401()
|
|
{
|
|
using var enrollment = NewEnrollment();
|
|
|
|
var response = await fixture.CreateClient().PostContractAsync(EnrollUrl, enrollment.Build());
|
|
|
|
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
|
|
}
|
|
|
|
// ---- Registering a device after enrollment ----
|
|
|
|
[Fact]
|
|
public async Task RegisterDevice_AddsADeviceAndAWrapThatOnlyThatDeviceCanOpen()
|
|
{
|
|
// The gap this endpoint closes. EnrollmentService.AddDevice only ever runs during enrollment, so
|
|
// without this every account that existed before a keystore was wired up could never turn the
|
|
// feature on — which is all of them.
|
|
using var enrollment = NewEnrollment();
|
|
var client = enrollment.CreateClient(fixture);
|
|
var enrolled = await EnrollAsync(client, enrollment.Build());
|
|
|
|
var publicKey = DeviceKey();
|
|
|
|
var registered = await RegisterDeviceAsync(
|
|
client,
|
|
new RegisterDeviceRequest("a second laptop", publicKey, Wrap()));
|
|
|
|
registered.DeviceId.ShouldNotBe(Guid.Empty);
|
|
|
|
await using var scope = fixture.CreateScope();
|
|
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
|
|
|
|
var device = await database.Devices.SingleAsync(d => d.Id == registered.DeviceId);
|
|
device.UserId.ShouldBe(enrolled.UserId);
|
|
device.Name.ShouldBe("a second laptop");
|
|
device.PublicKey.ShouldBe(publicKey);
|
|
|
|
var wrap = await database.UserKeyWraps.SingleAsync(w => w.DeviceId == registered.DeviceId);
|
|
wrap.Kind.ShouldBe(UserKeyWrapKind.Device);
|
|
wrap.UserId.ShouldBe(enrolled.UserId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RegisterDevice_BeforeEnrolling_AsksForEnrollmentRatherThanRejectingTheShape()
|
|
{
|
|
// You cannot wrap a bundle to a device before you have a bundle. The distinction that matters is
|
|
// which answer the client gets: "enroll first" is actionable, and a 400 about the request's shape
|
|
// would send somebody looking at their key length.
|
|
var subject = NewSubject();
|
|
var client = fixture.CreateClientFor(subject, $"{subject}@example.com");
|
|
|
|
var response = await client.PostContractAsync(
|
|
DevicesUrl,
|
|
new RegisterDeviceRequest("a laptop", DeviceKey(), Wrap()));
|
|
|
|
await ShouldBeProblemAsync(
|
|
response,
|
|
HttpStatusCode.Forbidden,
|
|
ProblemCodes.EnrollmentRequired);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RegisterDevice_TwiceWithTheSameKey_ReturnsTheSameDeviceAndAddsNoSecondWrap()
|
|
{
|
|
// Idempotent, as enrollment is. A dropped response must leave a retry safe, and a second row for
|
|
// one key would mean a device list with a duplicate and two wraps to revoke instead of one.
|
|
using var enrollment = NewEnrollment();
|
|
var client = enrollment.CreateClient(fixture);
|
|
await EnrollAsync(client, enrollment.Build());
|
|
|
|
var publicKey = DeviceKey();
|
|
var request = new RegisterDeviceRequest("a laptop", publicKey, Wrap());
|
|
|
|
var first = await RegisterDeviceAsync(client, request);
|
|
var second = await RegisterDeviceAsync(client, request);
|
|
|
|
second.DeviceId.ShouldBe(first.DeviceId);
|
|
second.EnrolledAt.ShouldBe(first.EnrolledAt);
|
|
|
|
await using var scope = fixture.CreateScope();
|
|
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
|
|
|
|
(await database.Devices.CountAsync(d => d.PublicKey == publicKey)).ShouldBe(1);
|
|
(await database.UserKeyWraps.CountAsync(w => w.DeviceId == first.DeviceId)).ShouldBe(1);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(31)]
|
|
[InlineData(33)]
|
|
public async Task RegisterDevice_WithAPublicKeyOfTheWrongLength_IsRejected(int length)
|
|
{
|
|
// The same rule enrollment applies. A key that is not an X25519 public key is one nothing can ever
|
|
// seal to, so the row would be permanently useless.
|
|
using var enrollment = NewEnrollment();
|
|
var client = enrollment.CreateClient(fixture);
|
|
await EnrollAsync(client, enrollment.Build());
|
|
|
|
var response = await client.PostContractAsync(
|
|
DevicesUrl,
|
|
new RegisterDeviceRequest("a laptop", new byte[length], Wrap()));
|
|
|
|
await ShouldBeProblemAsync(
|
|
response,
|
|
HttpStatusCode.BadRequest,
|
|
ProblemCodes.InvalidDeviceRegistration);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RegisterDevice_WithNoWrap_IsRejected()
|
|
{
|
|
// A device key with no wrap registers a device that can never unlock anything, and the server
|
|
// cannot fill the gap in — only the holder of the bundle can seal it.
|
|
using var enrollment = NewEnrollment();
|
|
var client = enrollment.CreateClient(fixture);
|
|
await EnrollAsync(client, enrollment.Build());
|
|
|
|
var response = await client.PostContractAsync(
|
|
DevicesUrl,
|
|
new RegisterDeviceRequest("a laptop", DeviceKey(), []));
|
|
|
|
await ShouldBeProblemAsync(
|
|
response,
|
|
HttpStatusCode.BadRequest,
|
|
ProblemCodes.InvalidDeviceRegistration);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RegisterDevice_WithABlankName_IsRejected()
|
|
{
|
|
using var enrollment = NewEnrollment();
|
|
var client = enrollment.CreateClient(fixture);
|
|
await EnrollAsync(client, enrollment.Build());
|
|
|
|
var response = await client.PostContractAsync(
|
|
DevicesUrl,
|
|
new RegisterDeviceRequest(" ", DeviceKey(), Wrap()));
|
|
|
|
await ShouldBeProblemAsync(
|
|
response,
|
|
HttpStatusCode.BadRequest,
|
|
ProblemCodes.InvalidDeviceRegistration);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RegisterDevice_WithoutAToken_Is401()
|
|
{
|
|
var response = await fixture.CreateClient().PostContractAsync(
|
|
DevicesUrl,
|
|
new RegisterDeviceRequest("a laptop", DeviceKey(), Wrap()));
|
|
|
|
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
|
|
}
|
|
|
|
// ---- Helpers ----
|
|
|
|
private static string NewSubject() => $"user-{Guid.CreateVersion7():N}";
|
|
|
|
private TestEnrollment NewEnrollment()
|
|
{
|
|
var subject = NewSubject();
|
|
return new TestEnrollment(fixture.IdentityProvider, subject, $"{subject}@example.com");
|
|
}
|
|
|
|
private static async Task<MeResponse> ReadMeAsync(HttpClient client)
|
|
{
|
|
var response = await client.GetAsync(new Uri(MeUrl, UriKind.Relative));
|
|
response.EnsureSuccessStatusCode();
|
|
|
|
var me = await response.Content.ReadContractAsync<MeResponse>();
|
|
me.ShouldNotBeNull();
|
|
return me;
|
|
}
|
|
|
|
private static async Task<EnrollmentResponse> EnrollAsync(
|
|
HttpClient client,
|
|
EnrollmentRequest request)
|
|
{
|
|
var response = await client.PostContractAsync(EnrollUrl, request);
|
|
response.EnsureSuccessStatusCode();
|
|
|
|
var body = await response.Content.ReadContractAsync<EnrollmentResponse>();
|
|
body.ShouldNotBeNull();
|
|
return body;
|
|
}
|
|
|
|
private static async Task<RegisterDeviceResponse> RegisterDeviceAsync(
|
|
HttpClient client,
|
|
RegisterDeviceRequest request)
|
|
{
|
|
var response = await client.PostContractAsync(DevicesUrl, request);
|
|
response.EnsureSuccessStatusCode();
|
|
|
|
var body = await response.Content.ReadContractAsync<RegisterDeviceResponse>();
|
|
body.ShouldNotBeNull();
|
|
return body;
|
|
}
|
|
|
|
/// <remarks>
|
|
/// A distinct 32 bytes per call, because the endpoint is idempotent on the public key and two tests
|
|
/// sharing one would silently be asserting about each other's device.
|
|
/// </remarks>
|
|
private static byte[] DeviceKey() => Guid.CreateVersion7().ToByteArray().Concat(
|
|
Guid.CreateVersion7().ToByteArray()).ToArray();
|
|
|
|
/// <remarks>
|
|
/// Not a real envelope. The server treats a wrap as opaque bytes and validates only that it is present
|
|
/// and bounded, which is the whole of what it is allowed to know.
|
|
/// </remarks>
|
|
private static byte[] Wrap() => [1, 2, 3, 4];
|
|
|
|
private static Task ShouldBeBindingRejectedAsync(HttpResponseMessage response) =>
|
|
ShouldBeProblemAsync(
|
|
response,
|
|
HttpStatusCode.BadRequest,
|
|
ProblemCodes.IdentityBindingInvalid);
|
|
|
|
private static async Task ShouldBeProblemAsync(
|
|
HttpResponseMessage response,
|
|
HttpStatusCode expectedStatus,
|
|
string expectedCode)
|
|
{
|
|
response.StatusCode.ShouldBe(expectedStatus);
|
|
|
|
var problem = await response.Content.ReadProblemAsync();
|
|
problem.ShouldNotBeNull();
|
|
problem.Code.ShouldBe(expectedCode);
|
|
}
|
|
|
|
private static KeyStatementFields Fields(KeyStatement statement) =>
|
|
new(
|
|
statement.Version,
|
|
statement.Issuer,
|
|
statement.Subject,
|
|
statement.Email,
|
|
statement.EncryptionPublicKey,
|
|
statement.SigningPublicKey,
|
|
statement.KeyGeneration,
|
|
statement.CreatedAt,
|
|
statement.DeviceName);
|
|
}
|