Public Access
- Pull hydrated every historical change entry with the item's current ciphertext under that entry's old revision. The AAD binds the revision, so a client opened it at the wrong version, failed, and parked its own pending edit as undecryptable. Entries an item has since moved past are left out; the later entry carries the same payload under the right number, and a fresh device no longer downloads an item once per edit it ever had. - Deleting a team's last vault archived the team whoever deleted it, so an admin could archive it out from under its owner. Only the owner's delete archives it now, and TeamService.ArchiveAsync checks. - UserStatus was checked nowhere on the request path. ActiveAccountRequirement on both policies answers 403 account-not-active for a suspended or deprovisioned account. - The hourly last-seen update moves xmin under the tracked user, and enrolment then saved that user against the stale token: a 500 for anybody enrolling more than an hour after first signing in. The entity is reloaded after the update.
1245 lines
48 KiB
C#
1245 lines
48 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();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Suspended means "sign-in blocked", and nothing used to block it: the identity provider still vouches
|
|
/// for the person, and every endpoint resolved them by issuer and subject alone. The status is this
|
|
/// server's decision, so this server has to be the one refusing — on its own endpoints and on vaults.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ASuspendedAccount_IsRefusedEverywhereWithACodeSayingWhy()
|
|
{
|
|
using var enrollment = NewEnrollment();
|
|
var client = enrollment.CreateClient(fixture);
|
|
|
|
await EnrollAsync(client, enrollment.Build());
|
|
|
|
await using (var scope = fixture.CreateScope())
|
|
{
|
|
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
|
|
|
|
await database.Users
|
|
.Where(u => u.Subject == enrollment.Subject)
|
|
.ExecuteUpdateAsync(setters => setters.SetProperty(u => u.Status, UserStatus.Suspended));
|
|
}
|
|
|
|
await ShouldBeProblemAsync(
|
|
await client.GetAsync(new Uri(MeUrl, UriKind.Relative)),
|
|
HttpStatusCode.Forbidden,
|
|
ProblemCodes.AccountNotActive);
|
|
|
|
await ShouldBeProblemAsync(
|
|
await client.PostContractAsync(
|
|
$"/api/v1/vaults/{enrollment.VaultId}/sync/pull", new SyncPullRequest(null, null, null)),
|
|
HttpStatusCode.Forbidden,
|
|
ProblemCodes.AccountNotActive);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The last-seen stamp is written past the change tracker once an hour, which moves the row's
|
|
/// concurrency token under the entity the request is holding. Enrolment then saved that same user and
|
|
/// failed on the stale token — so anyone who signed in, left, and came back an hour later to finish
|
|
/// setting up got a 500 on their first try.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task Enroll_MoreThanAnHourAfterFirstSigningIn_Succeeds()
|
|
{
|
|
using var enrollment = NewEnrollment();
|
|
var client = enrollment.CreateClient(fixture);
|
|
|
|
(await client.GetAsync(new Uri(MeUrl, UriKind.Relative))).EnsureSuccessStatusCode();
|
|
|
|
await using (var scope = fixture.CreateScope())
|
|
{
|
|
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
|
|
|
|
await database.Users
|
|
.Where(u => u.Subject == enrollment.Subject)
|
|
.ExecuteUpdateAsync(setters => setters.SetProperty(
|
|
u => u.LastSeenAtUtc, TimeProvider.System.GetUtcNow().AddHours(-2)));
|
|
}
|
|
|
|
var response = await EnrollAsync(client, enrollment.Build());
|
|
|
|
response.KeyGeneration.ShouldBe(1);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Enroll_AppendsToTheKeyLogAndTheEntryReproducesItsOwnHash()
|
|
{
|
|
using var enrollment = NewEnrollment();
|
|
var response = await EnrollAsync(enrollment.CreateClient(fixture), enrollment.Build());
|
|
|
|
await using var scope = fixture.CreateScope();
|
|
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
|
|
|
|
var entry = await database.KeyLog.SingleAsync(e => e.UserId == response.UserId);
|
|
|
|
// The stored row must reproduce its own hash after a round trip. It only does so if the
|
|
// timestamp was truncated to the precision the chain hashes before being written — the
|
|
// column holds microseconds and the hash covers milliseconds.
|
|
var recomputed = KeyLogChain.ComputeEntryHash(
|
|
entry.PreviousHash,
|
|
entry.UserId,
|
|
entry.Generation,
|
|
entry.EncryptionPublicKey,
|
|
entry.SigningPublicKey,
|
|
entry.StatementSignature,
|
|
entry.CreatedAtUtc);
|
|
|
|
entry.Hash.ShouldBe(recomputed);
|
|
entry.Sequence.ShouldBe(response.KeyLogSequence);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Enroll_LinksEachLogEntryToItsPredecessor()
|
|
{
|
|
// The chain is what turns key substitution from undetectable into detectable: a server
|
|
// serving divergent views has to keep both forks consistent forever.
|
|
using var first = NewEnrollment();
|
|
var firstResponse = await EnrollAsync(first.CreateClient(fixture), first.Build());
|
|
|
|
using var second = NewEnrollment();
|
|
var secondResponse = await EnrollAsync(second.CreateClient(fixture), second.Build());
|
|
|
|
await using var scope = fixture.CreateScope();
|
|
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
|
|
|
|
var firstEntry = await database.KeyLog.SingleAsync(e => e.UserId == firstResponse.UserId);
|
|
var secondEntry = await database.KeyLog.SingleAsync(e => e.UserId == secondResponse.UserId);
|
|
|
|
secondEntry.Sequence.ShouldBeGreaterThan(firstEntry.Sequence);
|
|
|
|
// Not necessarily the immediate successor — the shared database has other tests' entries —
|
|
// so walk back rather than assuming adjacency.
|
|
var predecessor = await database.KeyLog
|
|
.Where(e => e.Sequence < secondEntry.Sequence)
|
|
.OrderByDescending(e => e.Sequence)
|
|
.FirstAsync();
|
|
|
|
secondEntry.PreviousHash.ShouldBe(predecessor.Hash);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Enroll_RetainsTheProviderTokenSoClientsNeedNotTrustTheServer()
|
|
{
|
|
using var enrollment = NewEnrollment();
|
|
var request = enrollment.Build();
|
|
|
|
var response = await EnrollAsync(enrollment.CreateClient(fixture), request);
|
|
|
|
await using var scope = fixture.CreateScope();
|
|
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
|
|
|
|
var key = await database.UserKeys.SingleAsync(k => k.UserId == response.UserId);
|
|
|
|
// Storing only our own summary would ask clients to trust the server about the single
|
|
// question the design exists to avoid trusting it about.
|
|
key.IdentityProviderBinding.ShouldNotBeNull();
|
|
key.IdentityProviderBinding.ShouldContain(request.IdentityProviderToken);
|
|
key.IdentityProviderBinding.ShouldContain(
|
|
KeyStatementCodec.ComputeNonce(Fields(enrollment.Statement)));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Enroll_ThenPush_CompletesTheVerticalSlice()
|
|
{
|
|
// Login, enroll, store an item. The whole point of M1's backend.
|
|
using var enrollment = NewEnrollment();
|
|
var client = enrollment.CreateClient(fixture);
|
|
|
|
await EnrollAsync(client, enrollment.Build());
|
|
|
|
var push = await client.PostContractAsync(
|
|
$"/api/v1/vaults/{enrollment.VaultId}/sync/push",
|
|
new SyncPushRequest(
|
|
[
|
|
new SyncPushOperation(
|
|
Guid.CreateVersion7(),
|
|
SyncEntityType.Host,
|
|
Guid.CreateVersion7(),
|
|
SyncOperation.Upsert,
|
|
null,
|
|
new EncryptedPayload([1, 2, 3, 4], [5, 6], Guid.CreateVersion7(), 1, 1),
|
|
new SyncPlaintextFields()),
|
|
]));
|
|
|
|
push.EnsureSuccessStatusCode();
|
|
|
|
var body = await push.Content.ReadContractAsync<SyncPushResponse>();
|
|
body.ShouldNotBeNull();
|
|
body.Results[0].Status.ShouldBe(SyncOperationStatus.Applied);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Enroll_WithoutADeviceOrRecoveryWrap_StillSucceeds()
|
|
{
|
|
using var enrollment = NewEnrollment();
|
|
|
|
var response = await EnrollAsync(
|
|
enrollment.CreateClient(fixture),
|
|
enrollment.Build(includeDevice: false, includeRecovery: false));
|
|
|
|
response.DeviceId.ShouldBeNull();
|
|
|
|
await using var scope = fixture.CreateScope();
|
|
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
|
|
|
|
var wraps = await database.UserKeyWraps.Where(w => w.UserId == response.UserId).ToListAsync();
|
|
wraps.ShouldHaveSingleItem().Kind.ShouldBe(UserKeyWrapKind.Passphrase);
|
|
}
|
|
|
|
// ---- Enrollment: retries and conflicts ----
|
|
|
|
[Fact]
|
|
public async Task Enroll_ReplayingTheIdenticalRequest_IsIdempotent()
|
|
{
|
|
// A client whose response was lost re-sends the same body. Creating a second vault instead
|
|
// would strand the first one with no way for the client to discover it.
|
|
using var enrollment = NewEnrollment();
|
|
var client = enrollment.CreateClient(fixture);
|
|
var request = enrollment.Build();
|
|
|
|
var first = await EnrollAsync(client, request);
|
|
var replay = await EnrollAsync(client, request);
|
|
|
|
replay.UserId.ShouldBe(first.UserId);
|
|
replay.KeyGeneration.ShouldBe(first.KeyGeneration);
|
|
replay.PersonalVaultId.ShouldBe(first.PersonalVaultId);
|
|
replay.KeyLogSequence.ShouldBe(first.KeyLogSequence);
|
|
replay.Fingerprint.ShouldBe(first.Fingerprint);
|
|
replay.DeviceId.ShouldBe(first.DeviceId);
|
|
|
|
await using var scope = fixture.CreateScope();
|
|
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
|
|
|
|
(await database.UserKeys.CountAsync(k => k.UserId == first.UserId)).ShouldBe(1);
|
|
(await database.KeyLog.CountAsync(e => e.UserId == first.UserId)).ShouldBe(1);
|
|
(await database.Vaults.CountAsync(v => v.OwnerUserId == first.UserId)).ShouldBe(1);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Enroll_ConcurrentEnrollmentsByDifferentUsers_LeaveAnUnbrokenChain()
|
|
{
|
|
// The real hazard the deployment-wide advisory lock exists for. Different users trip no
|
|
// unique index, so without serialised appends they all read the same head and write entries
|
|
// that each claim the same predecessor. That is a forked chain — indistinguishable from the
|
|
// key-substitution attack the log exists to make detectable — and it is permanent, because
|
|
// the log is append-only and no server-side repair is possible.
|
|
const int Concurrency = 6;
|
|
|
|
var enrollments = Enumerable.Range(0, Concurrency).Select(_ => NewEnrollment()).ToArray();
|
|
|
|
try
|
|
{
|
|
var responses = await Task.WhenAll(enrollments.Select(e =>
|
|
e.CreateClient(fixture).PostContractAsync(EnrollUrl, e.Build())));
|
|
|
|
foreach (var response in responses)
|
|
{
|
|
response.EnsureSuccessStatusCode();
|
|
}
|
|
|
|
await using var scope = fixture.CreateScope();
|
|
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
|
|
|
|
// The whole log, including entries earlier tests appended. Every link and every hash.
|
|
var entries = await database.KeyLog.OrderBy(e => e.Sequence).ToListAsync();
|
|
|
|
entries.Count.ShouldBeGreaterThanOrEqualTo(Concurrency);
|
|
|
|
var expectedPrevious = KeyLogChain.CreateGenesisPreviousHash();
|
|
|
|
foreach (var entry in entries)
|
|
{
|
|
entry.PreviousHash.ShouldBe(
|
|
expectedPrevious,
|
|
$"Key log entry {entry.Sequence} does not link to its predecessor. The chain "
|
|
+ "has forked, which is exactly what an undetectable key substitution looks like.");
|
|
|
|
entry.Hash.ShouldBe(KeyLogChain.ComputeEntryHash(
|
|
entry.PreviousHash,
|
|
entry.UserId,
|
|
entry.Generation,
|
|
entry.EncryptionPublicKey,
|
|
entry.SigningPublicKey,
|
|
entry.StatementSignature,
|
|
entry.CreatedAtUtc));
|
|
|
|
expectedPrevious = entry.Hash;
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
foreach (var enrollment in enrollments)
|
|
{
|
|
enrollment.Dispose();
|
|
}
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Enroll_ConcurrentIdenticalRequests_ProduceExactlyOneEnrollment()
|
|
{
|
|
// A client on a flaky connection genuinely does this. Four requests race just-in-time
|
|
// provisioning, the unique index on the current key, and the deployment-wide key log lock at
|
|
// once. Every one must end in the same answer, and the append-only log must gain one entry —
|
|
// two entries claiming the same predecessor would look exactly like the fork the chain
|
|
// exists to detect.
|
|
using var enrollment = NewEnrollment();
|
|
var request = enrollment.Build();
|
|
|
|
var responses = await Task.WhenAll(
|
|
Enumerable.Range(0, 4).Select(_ =>
|
|
enrollment.CreateClient(fixture).PostContractAsync(EnrollUrl, request)));
|
|
|
|
var bodies = new List<EnrollmentResponse>(responses.Length);
|
|
foreach (var response in responses)
|
|
{
|
|
response.EnsureSuccessStatusCode();
|
|
|
|
var body = await response.Content.ReadContractAsync<EnrollmentResponse>();
|
|
body.ShouldNotBeNull();
|
|
bodies.Add(body);
|
|
}
|
|
|
|
bodies.Select(b => b.UserId).Distinct().ShouldHaveSingleItem();
|
|
bodies.Select(b => b.KeyLogSequence).Distinct().ShouldHaveSingleItem();
|
|
bodies.Select(b => b.DeviceId).Distinct().ShouldHaveSingleItem();
|
|
|
|
var userId = bodies[0].UserId;
|
|
|
|
await using var scope = fixture.CreateScope();
|
|
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
|
|
|
|
(await database.Users.CountAsync(u => u.Subject == enrollment.Subject)).ShouldBe(1);
|
|
(await database.UserKeys.CountAsync(k => k.UserId == userId)).ShouldBe(1);
|
|
(await database.KeyLog.CountAsync(e => e.UserId == userId)).ShouldBe(1);
|
|
(await database.Vaults.CountAsync(v => v.OwnerUserId == userId)).ShouldBe(1);
|
|
(await database.Devices.CountAsync(d => d.UserId == userId)).ShouldBe(1);
|
|
(await database.UserKeyWraps.CountAsync(w => w.UserId == userId)).ShouldBe(3);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Enroll_ASecondDifferentKey_Is409()
|
|
{
|
|
// Accepting it would orphan every vault key already wrapped to the first one.
|
|
using var first = NewEnrollment();
|
|
var client = first.CreateClient(fixture);
|
|
await EnrollAsync(client, first.Build());
|
|
|
|
using var second = new TestEnrollment(fixture.IdentityProvider, first.Subject);
|
|
|
|
var response = await client.PostContractAsync(EnrollUrl, second.Build());
|
|
|
|
await ShouldBeProblemAsync(response, HttpStatusCode.Conflict, ProblemCodes.AlreadyEnrolled);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Enroll_TheSameKeysAgainstADifferentVaultId_Is409()
|
|
{
|
|
using var enrollment = NewEnrollment();
|
|
var client = enrollment.CreateClient(fixture);
|
|
await EnrollAsync(client, enrollment.Build());
|
|
|
|
var response = await client.PostContractAsync(
|
|
EnrollUrl,
|
|
enrollment.Build(personalVault: enrollment.DefaultVault(vaultId: Guid.CreateVersion7())));
|
|
|
|
await ShouldBeProblemAsync(response, HttpStatusCode.Conflict, ProblemCodes.AlreadyEnrolled);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Enroll_WithAVaultIdAnotherUserAlreadyHas_Is400()
|
|
{
|
|
using var owner = NewEnrollment();
|
|
await EnrollAsync(owner.CreateClient(fixture), owner.Build());
|
|
|
|
using var second = NewEnrollment();
|
|
|
|
var response = await second.CreateClient(fixture).PostContractAsync(
|
|
EnrollUrl,
|
|
second.Build(personalVault: second.DefaultVault(vaultId: owner.VaultId)));
|
|
|
|
await ShouldBeProblemAsync(
|
|
response,
|
|
HttpStatusCode.BadRequest,
|
|
ProblemCodes.InvalidEnrollment);
|
|
}
|
|
|
|
// ---- Enrollment: the identity-provider binding ----
|
|
|
|
[Fact]
|
|
public async Task Enroll_WithANonceForADifferentStatement_IsRejected()
|
|
{
|
|
// The heart of it. A token that does not hash to *this* statement binds nothing, so
|
|
// accepting it would let anyone publish keys under any account the provider knows.
|
|
using var enrollment = NewEnrollment();
|
|
var other = enrollment.Statement with { DeviceName = "some-other-device" };
|
|
|
|
var response = await enrollment.CreateClient(fixture).PostContractAsync(
|
|
EnrollUrl,
|
|
enrollment.Build(idToken: enrollment.MintIdToken(other)));
|
|
|
|
await ShouldBeBindingRejectedAsync(response);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Enroll_WithATokenForAnotherSubject_IsRejected()
|
|
{
|
|
using var enrollment = NewEnrollment();
|
|
|
|
var response = await enrollment.CreateClient(fixture).PostContractAsync(
|
|
EnrollUrl,
|
|
enrollment.Build(
|
|
idToken: enrollment.MintIdToken(enrollment.Statement, subject: NewSubject())));
|
|
|
|
await ShouldBeBindingRejectedAsync(response);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Enroll_WithATokenAudiencedToTheApiRatherThanTheClient_IsRejected()
|
|
{
|
|
// An ID token is audienced to the client. An access token presented here would be a
|
|
// different kind of assertion entirely, and must not be interchangeable with one.
|
|
using var enrollment = NewEnrollment();
|
|
|
|
var response = await enrollment.CreateClient(fixture).PostContractAsync(
|
|
EnrollUrl,
|
|
enrollment.Build(
|
|
idToken: enrollment.MintIdToken(
|
|
enrollment.Statement,
|
|
audience: StubIdentityProvider.Audience)));
|
|
|
|
await ShouldBeBindingRejectedAsync(response);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Enroll_WithATokenSignedByAKeyTheProviderDoesNotPublish_IsRejected()
|
|
{
|
|
// Proves the JWKS check genuinely runs rather than the token being parsed and trusted.
|
|
using var enrollment = NewEnrollment();
|
|
|
|
var foreign = fixture.IdentityProvider.MintIdTokenWithForeignKey(
|
|
enrollment.Subject,
|
|
KeyStatementCodec.ComputeNonce(Fields(enrollment.Statement)));
|
|
|
|
var response = await enrollment.CreateClient(fixture).PostContractAsync(
|
|
EnrollUrl,
|
|
enrollment.Build(idToken: foreign));
|
|
|
|
await ShouldBeBindingRejectedAsync(response);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Enroll_WithAnExpiredToken_IsRejected()
|
|
{
|
|
using var enrollment = NewEnrollment();
|
|
|
|
var response = await enrollment.CreateClient(fixture).PostContractAsync(
|
|
EnrollUrl,
|
|
enrollment.Build(
|
|
idToken: enrollment.MintIdToken(
|
|
enrollment.Statement,
|
|
expires: TimeProvider.System.GetUtcNow().UtcDateTime.AddMinutes(-10))));
|
|
|
|
await ShouldBeBindingRejectedAsync(response);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Enroll_WithATokenFromAnotherIssuer_IsRejected()
|
|
{
|
|
using var enrollment = NewEnrollment();
|
|
|
|
var response = await enrollment.CreateClient(fixture).PostContractAsync(
|
|
EnrollUrl,
|
|
enrollment.Build(
|
|
idToken: enrollment.MintIdToken(enrollment.Statement, issuer: "https://evil.example")));
|
|
|
|
await ShouldBeBindingRejectedAsync(response);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Enroll_WithNoNonceAtAll_IsRejected()
|
|
{
|
|
using var enrollment = NewEnrollment();
|
|
|
|
var response = await enrollment.CreateClient(fixture).PostContractAsync(
|
|
EnrollUrl,
|
|
enrollment.Build(
|
|
idToken: enrollment.MintIdToken(enrollment.Statement, omitNonce: true)));
|
|
|
|
await ShouldBeBindingRejectedAsync(response);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Enroll_WhenTheBindingFails_WritesNothing()
|
|
{
|
|
// A rejection that still wrote a key log entry would poison the chain permanently, because
|
|
// the log is append-only.
|
|
using var enrollment = NewEnrollment();
|
|
var client = enrollment.CreateClient(fixture);
|
|
|
|
var response = await client.PostContractAsync(
|
|
EnrollUrl,
|
|
enrollment.Build(idToken: enrollment.MintIdToken(enrollment.Statement, omitNonce: true)));
|
|
|
|
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
|
|
|
|
await using var scope = fixture.CreateScope();
|
|
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
|
|
|
|
var user = await database.Users.SingleAsync(u => u.Subject == enrollment.Subject);
|
|
|
|
(await database.UserKeys.AnyAsync(k => k.UserId == user.Id)).ShouldBeFalse();
|
|
(await database.UserKeyWraps.AnyAsync(w => w.UserId == user.Id)).ShouldBeFalse();
|
|
(await database.KeyLog.AnyAsync(e => e.UserId == user.Id)).ShouldBeFalse();
|
|
(await database.Vaults.AnyAsync(v => v.Id == enrollment.VaultId)).ShouldBeFalse();
|
|
user.EnrolledAtUtc.ShouldBeNull();
|
|
}
|
|
|
|
// ---- Enrollment: statement and shape validation ----
|
|
|
|
[Fact]
|
|
public async Task Enroll_WithASignatureFromAnotherKey_IsRejected()
|
|
{
|
|
using var enrollment = NewEnrollment();
|
|
using var other = NewEnrollment();
|
|
|
|
var response = await enrollment.CreateClient(fixture).PostContractAsync(
|
|
EnrollUrl,
|
|
enrollment.Build(statementSignature: other.Sign(enrollment.Statement)));
|
|
|
|
await ShouldBeProblemAsync(
|
|
response,
|
|
HttpStatusCode.BadRequest,
|
|
ProblemCodes.InvalidEnrollment);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Enroll_WithAStatementNamingAnotherSubject_IsRejected()
|
|
{
|
|
// Caught by shape validation before any cryptography, so the code says "invalid" rather
|
|
// than blaming the binding.
|
|
using var enrollment = NewEnrollment();
|
|
var impersonating = enrollment.Statement with { Subject = NewSubject() };
|
|
|
|
var response = await enrollment.CreateClient(fixture).PostContractAsync(
|
|
EnrollUrl,
|
|
enrollment.Build(statement: impersonating));
|
|
|
|
await ShouldBeProblemAsync(
|
|
response,
|
|
HttpStatusCode.BadRequest,
|
|
ProblemCodes.InvalidEnrollment);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(1024, 4, "memory far below the floor")]
|
|
[InlineData(256 * 1024, 1, "too few passes")]
|
|
[InlineData(8 * 1024 * 1024, 4, "memory above the ceiling")]
|
|
public async Task Enroll_WithUnacceptableKdfParameters_IsRejected(
|
|
int memoryKibibytes,
|
|
int passes,
|
|
string reason)
|
|
{
|
|
// A weak wrap here is a permanent liability: it is exactly what an attacker who ever
|
|
// obtains a database dump grinds against offline. An absurd one locks the user out instead.
|
|
reason.ShouldNotBeEmpty();
|
|
|
|
using var enrollment = NewEnrollment();
|
|
|
|
var response = await enrollment.CreateClient(fixture).PostContractAsync(
|
|
EnrollUrl,
|
|
enrollment.Build(
|
|
kdfParameters: new KdfParameters("argon2id", new byte[16], memoryKibibytes, passes, 1)));
|
|
|
|
await ShouldBeProblemAsync(
|
|
response,
|
|
HttpStatusCode.BadRequest,
|
|
ProblemCodes.InvalidEnrollment);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Enroll_WithParallelismOtherThanOne_IsRejected()
|
|
{
|
|
using var enrollment = NewEnrollment();
|
|
|
|
var response = await enrollment.CreateClient(fixture).PostContractAsync(
|
|
EnrollUrl,
|
|
enrollment.Build(
|
|
kdfParameters: new KdfParameters("argon2id", new byte[16], 256 * 1024, 4, 4)));
|
|
|
|
await ShouldBeProblemAsync(
|
|
response,
|
|
HttpStatusCode.BadRequest,
|
|
ProblemCodes.InvalidEnrollment);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Enroll_WithAWrongLengthPublicKey_IsRejected()
|
|
{
|
|
using var enrollment = NewEnrollment();
|
|
var truncated = enrollment.Statement with { EncryptionPublicKey = new byte[31] };
|
|
|
|
// The signature and token are supplied ready-made: a malformed statement cannot be
|
|
// canonically encoded at all, so neither can be derived from it. Shape validation runs
|
|
// before any cryptography, so the server never gets that far either.
|
|
var response = await enrollment.CreateClient(fixture).PostContractAsync(
|
|
EnrollUrl,
|
|
enrollment.Build(
|
|
statement: truncated,
|
|
statementSignature: new byte[64],
|
|
idToken: enrollment.MintIdToken(enrollment.Statement)));
|
|
|
|
await ShouldBeProblemAsync(
|
|
response,
|
|
HttpStatusCode.BadRequest,
|
|
ProblemCodes.InvalidEnrollment);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Enroll_ReusingOneKeyForBothRoles_IsRejected()
|
|
{
|
|
// Using one key for agreement and signatures is a standing cryptographic mistake, and is
|
|
// also the shape a client bug takes when it exports the wrong key twice.
|
|
using var enrollment = NewEnrollment();
|
|
var reused = enrollment.Statement with
|
|
{
|
|
EncryptionPublicKey = enrollment.Statement.SigningPublicKey,
|
|
};
|
|
|
|
var response = await enrollment.CreateClient(fixture).PostContractAsync(
|
|
EnrollUrl,
|
|
enrollment.Build(statement: reused));
|
|
|
|
await ShouldBeProblemAsync(
|
|
response,
|
|
HttpStatusCode.BadRequest,
|
|
ProblemCodes.InvalidEnrollment);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Enroll_WithAGenerationOtherThanOne_IsRejected()
|
|
{
|
|
using var enrollment = NewEnrollment();
|
|
var later = enrollment.Statement with { KeyGeneration = 2 };
|
|
|
|
var response = await enrollment.CreateClient(fixture).PostContractAsync(
|
|
EnrollUrl,
|
|
enrollment.Build(statement: later));
|
|
|
|
await ShouldBeProblemAsync(
|
|
response,
|
|
HttpStatusCode.BadRequest,
|
|
ProblemCodes.InvalidEnrollment);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Enroll_WithADeviceKeyButNoDeviceWrap_IsRejected()
|
|
{
|
|
// Only the holder of the bundle can seal it, so a device key with no wrap would register a
|
|
// device that can never unlock anything and that the server could never fix.
|
|
using var enrollment = NewEnrollment();
|
|
|
|
var response = await enrollment.CreateClient(fixture).PostContractAsync(
|
|
EnrollUrl,
|
|
enrollment.Build() with { DeviceWrappedPrivateKey = null });
|
|
|
|
await ShouldBeProblemAsync(
|
|
response,
|
|
HttpStatusCode.BadRequest,
|
|
ProblemCodes.InvalidEnrollment);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Enroll_WithARecoveryWrapButNoKdfParameters_IsRejected()
|
|
{
|
|
using var enrollment = NewEnrollment();
|
|
|
|
var response = await enrollment.CreateClient(fixture).PostContractAsync(
|
|
EnrollUrl,
|
|
enrollment.Build() with { RecoveryKdfParameters = null });
|
|
|
|
await ShouldBeProblemAsync(
|
|
response,
|
|
HttpStatusCode.BadRequest,
|
|
ProblemCodes.InvalidEnrollment);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Enroll_WithAJsonBodyMissingRequiredFields_Is400NotAServerError()
|
|
{
|
|
// A record's non-nullable parameters are a compile-time promise only — JSON that omits a
|
|
// property deserialises to null regardless. The distinction that matters is 400 versus 500:
|
|
// one tells a client its request was wrong, the other looks like the server broke.
|
|
using var enrollment = NewEnrollment();
|
|
|
|
using var content = new StringContent(
|
|
"""{"statement":null,"statementSignature":null,"identityProviderToken":null}""",
|
|
System.Text.Encoding.UTF8,
|
|
"application/json");
|
|
|
|
var response = await enrollment.CreateClient(fixture).PostAsync(
|
|
new Uri(EnrollUrl, UriKind.Relative),
|
|
content);
|
|
|
|
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Enroll_WithoutKdfParameters_Is400NotAServerError()
|
|
{
|
|
using var enrollment = NewEnrollment();
|
|
|
|
var response = await enrollment.CreateClient(fixture).PostContractAsync(
|
|
EnrollUrl,
|
|
enrollment.Build() with { KdfParameters = null! });
|
|
|
|
await ShouldBeProblemAsync(
|
|
response,
|
|
HttpStatusCode.BadRequest,
|
|
ProblemCodes.InvalidEnrollment);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Enroll_WithoutAToken_Is401()
|
|
{
|
|
using var enrollment = NewEnrollment();
|
|
|
|
var response = await fixture.CreateClient().PostContractAsync(EnrollUrl, enrollment.Build());
|
|
|
|
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
|
|
}
|
|
|
|
// ---- Registering a device after enrollment ----
|
|
|
|
[Fact]
|
|
public async Task RegisterDevice_AddsADeviceAndAWrapThatOnlyThatDeviceCanOpen()
|
|
{
|
|
// The gap this endpoint closes. EnrollmentService.AddDevice only ever runs during enrollment, so
|
|
// without this every account that existed before a keystore was wired up could never turn the
|
|
// feature on — which is all of them.
|
|
using var enrollment = NewEnrollment();
|
|
var client = enrollment.CreateClient(fixture);
|
|
var enrolled = await EnrollAsync(client, enrollment.Build());
|
|
|
|
var publicKey = DeviceKey();
|
|
|
|
var registered = await RegisterDeviceAsync(
|
|
client,
|
|
new RegisterDeviceRequest("a second laptop", publicKey, Wrap()));
|
|
|
|
registered.DeviceId.ShouldNotBe(Guid.Empty);
|
|
|
|
await using var scope = fixture.CreateScope();
|
|
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
|
|
|
|
var device = await database.Devices.SingleAsync(d => d.Id == registered.DeviceId);
|
|
device.UserId.ShouldBe(enrolled.UserId);
|
|
device.Name.ShouldBe("a second laptop");
|
|
device.PublicKey.ShouldBe(publicKey);
|
|
|
|
var wrap = await database.UserKeyWraps.SingleAsync(w => w.DeviceId == registered.DeviceId);
|
|
wrap.Kind.ShouldBe(UserKeyWrapKind.Device);
|
|
wrap.UserId.ShouldBe(enrolled.UserId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RegisterDevice_BeforeEnrolling_AsksForEnrollmentRatherThanRejectingTheShape()
|
|
{
|
|
// You cannot wrap a bundle to a device before you have a bundle. The distinction that matters is
|
|
// which answer the client gets: "enroll first" is actionable, and a 400 about the request's shape
|
|
// would send somebody looking at their key length.
|
|
var subject = NewSubject();
|
|
var client = fixture.CreateClientFor(subject, $"{subject}@example.com");
|
|
|
|
var response = await client.PostContractAsync(
|
|
DevicesUrl,
|
|
new RegisterDeviceRequest("a laptop", DeviceKey(), Wrap()));
|
|
|
|
await ShouldBeProblemAsync(
|
|
response,
|
|
HttpStatusCode.Forbidden,
|
|
ProblemCodes.EnrollmentRequired);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RegisterDevice_TwiceWithTheSameKey_ReturnsTheSameDeviceAndAddsNoSecondWrap()
|
|
{
|
|
// Idempotent, as enrollment is. A dropped response must leave a retry safe, and a second row for
|
|
// one key would mean a device list with a duplicate and two wraps to revoke instead of one.
|
|
using var enrollment = NewEnrollment();
|
|
var client = enrollment.CreateClient(fixture);
|
|
await EnrollAsync(client, enrollment.Build());
|
|
|
|
var publicKey = DeviceKey();
|
|
var request = new RegisterDeviceRequest("a laptop", publicKey, Wrap());
|
|
|
|
var first = await RegisterDeviceAsync(client, request);
|
|
var second = await RegisterDeviceAsync(client, request);
|
|
|
|
second.DeviceId.ShouldBe(first.DeviceId);
|
|
second.EnrolledAt.ShouldBe(first.EnrolledAt);
|
|
|
|
await using var scope = fixture.CreateScope();
|
|
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
|
|
|
|
(await database.Devices.CountAsync(d => d.PublicKey == publicKey)).ShouldBe(1);
|
|
(await database.UserKeyWraps.CountAsync(w => w.DeviceId == first.DeviceId)).ShouldBe(1);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(31)]
|
|
[InlineData(33)]
|
|
public async Task RegisterDevice_WithAPublicKeyOfTheWrongLength_IsRejected(int length)
|
|
{
|
|
// The same rule enrollment applies. A key that is not an X25519 public key is one nothing can ever
|
|
// seal to, so the row would be permanently useless.
|
|
using var enrollment = NewEnrollment();
|
|
var client = enrollment.CreateClient(fixture);
|
|
await EnrollAsync(client, enrollment.Build());
|
|
|
|
var response = await client.PostContractAsync(
|
|
DevicesUrl,
|
|
new RegisterDeviceRequest("a laptop", new byte[length], Wrap()));
|
|
|
|
await ShouldBeProblemAsync(
|
|
response,
|
|
HttpStatusCode.BadRequest,
|
|
ProblemCodes.InvalidDeviceRegistration);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RegisterDevice_WithNoWrap_IsRejected()
|
|
{
|
|
// A device key with no wrap registers a device that can never unlock anything, and the server
|
|
// cannot fill the gap in — only the holder of the bundle can seal it.
|
|
using var enrollment = NewEnrollment();
|
|
var client = enrollment.CreateClient(fixture);
|
|
await EnrollAsync(client, enrollment.Build());
|
|
|
|
var response = await client.PostContractAsync(
|
|
DevicesUrl,
|
|
new RegisterDeviceRequest("a laptop", DeviceKey(), []));
|
|
|
|
await ShouldBeProblemAsync(
|
|
response,
|
|
HttpStatusCode.BadRequest,
|
|
ProblemCodes.InvalidDeviceRegistration);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The wrap is the half that matters. A device row nobody can use is untidy; a <c>kind=device</c> wrap
|
|
/// left behind is the user's identity bundle still sealed to a key somebody may be holding. This asserts
|
|
/// the foreign key's cascade rather than trusting the configuration to go on saying so.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task RevokeDevice_TakesItsWrapWithIt()
|
|
{
|
|
using var enrollment = NewEnrollment();
|
|
var client = enrollment.CreateClient(fixture);
|
|
await EnrollAsync(client, enrollment.Build());
|
|
|
|
var registered = await RegisterDeviceAsync(
|
|
client, new RegisterDeviceRequest("a laptop", DeviceKey(), Wrap()));
|
|
|
|
var response = await client.DeleteAsync(
|
|
new Uri($"{DevicesUrl}/{registered.DeviceId}", UriKind.Relative));
|
|
|
|
response.StatusCode.ShouldBe(HttpStatusCode.NoContent);
|
|
|
|
await using var scope = fixture.CreateScope();
|
|
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
|
|
|
|
(await database.Devices.CountAsync(d => d.Id == registered.DeviceId)).ShouldBe(0);
|
|
(await database.UserKeyWraps.CountAsync(w => w.DeviceId == registered.DeviceId)).ShouldBe(0);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The passphrase wrap is what makes revocation safe to offer at all: withdrawing every device must
|
|
/// never be able to lock somebody out of their own vault, so this checks the one row that guarantees it
|
|
/// is still there afterwards.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task RevokeDevice_LeavesThePassphraseWrapAlone()
|
|
{
|
|
using var enrollment = NewEnrollment();
|
|
var client = enrollment.CreateClient(fixture);
|
|
await EnrollAsync(client, enrollment.Build());
|
|
|
|
var registered = await RegisterDeviceAsync(
|
|
client, new RegisterDeviceRequest("a laptop", DeviceKey(), Wrap()));
|
|
|
|
await client.DeleteAsync(new Uri($"{DevicesUrl}/{registered.DeviceId}", UriKind.Relative));
|
|
|
|
var me = await ReadMeAsync(client);
|
|
|
|
me.EnrollmentRequired.ShouldBeFalse();
|
|
me.WrappedPrivateKey.ShouldNotBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RevokeDevice_ThatIsNotThere_Is404()
|
|
{
|
|
// Rather than a bland 204. "Revoked" is what the user reads, and reading it about the wrong id is
|
|
// worse than being told to look again — a client driving towards "this machine cannot unlock" can
|
|
// treat 404 as having arrived, and the desktop one does.
|
|
using var enrollment = NewEnrollment();
|
|
var client = enrollment.CreateClient(fixture);
|
|
await EnrollAsync(client, enrollment.Build());
|
|
|
|
var response = await client.DeleteAsync(
|
|
new Uri($"{DevicesUrl}/{Guid.CreateVersion7()}", UriKind.Relative));
|
|
|
|
response.StatusCode.ShouldBe(HttpStatusCode.NotFound);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The authorisation check, and the only one there is: the id is an unguessable v7 GUID, but unguessable
|
|
/// is not a permission. Without the user scope on the query, one account could withdraw another's device
|
|
/// key by pasting an id it saw once — and the victim's next launch would ask for a passphrase with no
|
|
/// explanation.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task RevokeDevice_BelongingToSomebodyElse_Is404AndChangesNothing()
|
|
{
|
|
using var mine = NewEnrollment();
|
|
var myClient = mine.CreateClient(fixture);
|
|
await EnrollAsync(myClient, mine.Build());
|
|
|
|
var registered = await RegisterDeviceAsync(
|
|
myClient, new RegisterDeviceRequest("my laptop", DeviceKey(), Wrap()));
|
|
|
|
using var theirs = NewEnrollment();
|
|
var theirClient = theirs.CreateClient(fixture);
|
|
await EnrollAsync(theirClient, theirs.Build());
|
|
|
|
var response = await theirClient.DeleteAsync(
|
|
new Uri($"{DevicesUrl}/{registered.DeviceId}", UriKind.Relative));
|
|
|
|
// 404 rather than 403, because a stranger must not learn that the id exists.
|
|
response.StatusCode.ShouldBe(HttpStatusCode.NotFound);
|
|
|
|
await using var scope = fixture.CreateScope();
|
|
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
|
|
|
|
(await database.Devices.CountAsync(d => d.Id == registered.DeviceId)).ShouldBe(1);
|
|
(await database.UserKeyWraps.CountAsync(w => w.DeviceId == registered.DeviceId)).ShouldBe(1);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RevokeDevice_WithoutAToken_Is401()
|
|
{
|
|
var response = await fixture.CreateClient().DeleteAsync(
|
|
new Uri($"{DevicesUrl}/{Guid.CreateVersion7()}", UriKind.Relative));
|
|
|
|
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RegisterDevice_WithABlankName_IsRejected()
|
|
{
|
|
using var enrollment = NewEnrollment();
|
|
var client = enrollment.CreateClient(fixture);
|
|
await EnrollAsync(client, enrollment.Build());
|
|
|
|
var response = await client.PostContractAsync(
|
|
DevicesUrl,
|
|
new RegisterDeviceRequest(" ", DeviceKey(), Wrap()));
|
|
|
|
await ShouldBeProblemAsync(
|
|
response,
|
|
HttpStatusCode.BadRequest,
|
|
ProblemCodes.InvalidDeviceRegistration);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RegisterDevice_WithoutAToken_Is401()
|
|
{
|
|
var response = await fixture.CreateClient().PostContractAsync(
|
|
DevicesUrl,
|
|
new RegisterDeviceRequest("a laptop", DeviceKey(), Wrap()));
|
|
|
|
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
|
|
}
|
|
|
|
// ---- Helpers ----
|
|
|
|
private static string NewSubject() => $"user-{Guid.CreateVersion7():N}";
|
|
|
|
private TestEnrollment NewEnrollment()
|
|
{
|
|
var subject = NewSubject();
|
|
return new TestEnrollment(fixture.IdentityProvider, subject, $"{subject}@example.com");
|
|
}
|
|
|
|
private static async Task<MeResponse> ReadMeAsync(HttpClient client)
|
|
{
|
|
var response = await client.GetAsync(new Uri(MeUrl, UriKind.Relative));
|
|
response.EnsureSuccessStatusCode();
|
|
|
|
var me = await response.Content.ReadContractAsync<MeResponse>();
|
|
me.ShouldNotBeNull();
|
|
return me;
|
|
}
|
|
|
|
private static async Task<EnrollmentResponse> EnrollAsync(
|
|
HttpClient client,
|
|
EnrollmentRequest request)
|
|
{
|
|
var response = await client.PostContractAsync(EnrollUrl, request);
|
|
response.EnsureSuccessStatusCode();
|
|
|
|
var body = await response.Content.ReadContractAsync<EnrollmentResponse>();
|
|
body.ShouldNotBeNull();
|
|
return body;
|
|
}
|
|
|
|
private static async Task<RegisterDeviceResponse> RegisterDeviceAsync(
|
|
HttpClient client,
|
|
RegisterDeviceRequest request)
|
|
{
|
|
var response = await client.PostContractAsync(DevicesUrl, request);
|
|
response.EnsureSuccessStatusCode();
|
|
|
|
var body = await response.Content.ReadContractAsync<RegisterDeviceResponse>();
|
|
body.ShouldNotBeNull();
|
|
return body;
|
|
}
|
|
|
|
/// <remarks>
|
|
/// A distinct 32 bytes per call, because the endpoint is idempotent on the public key and two tests
|
|
/// sharing one would silently be asserting about each other's device.
|
|
/// </remarks>
|
|
private static byte[] DeviceKey() => Guid.CreateVersion7().ToByteArray().Concat(
|
|
Guid.CreateVersion7().ToByteArray()).ToArray();
|
|
|
|
/// <remarks>
|
|
/// Not a real envelope. The server treats a wrap as opaque bytes and validates only that it is present
|
|
/// and bounded, which is the whole of what it is allowed to know.
|
|
/// </remarks>
|
|
private static byte[] Wrap() => [1, 2, 3, 4];
|
|
|
|
private static Task ShouldBeBindingRejectedAsync(HttpResponseMessage response) =>
|
|
ShouldBeProblemAsync(
|
|
response,
|
|
HttpStatusCode.BadRequest,
|
|
ProblemCodes.IdentityBindingInvalid);
|
|
|
|
private static async Task ShouldBeProblemAsync(
|
|
HttpResponseMessage response,
|
|
HttpStatusCode expectedStatus,
|
|
string expectedCode)
|
|
{
|
|
response.StatusCode.ShouldBe(expectedStatus);
|
|
|
|
var problem = await response.Content.ReadProblemAsync();
|
|
problem.ShouldNotBeNull();
|
|
problem.Code.ShouldBe(expectedCode);
|
|
}
|
|
|
|
private static KeyStatementFields Fields(KeyStatement statement) =>
|
|
new(
|
|
statement.Version,
|
|
statement.Issuer,
|
|
statement.Subject,
|
|
statement.Email,
|
|
statement.EncryptionPublicKey,
|
|
statement.SigningPublicKey,
|
|
statement.KeyGeneration,
|
|
statement.CreatedAt,
|
|
statement.DeviceName);
|
|
}
|