diff --git a/README.md b/README.md
index 4a5301c..f1f86f5 100644
--- a/README.md
+++ b/README.md
@@ -83,9 +83,11 @@ Development — `/openapi/v1.json`.
## Milestones
- **M0 — foundation.** Repo structure, build conventions, CI, ADRs. *Done.*
-- **M1 — vertical slice.** OIDC login → enroll → create a host → open a shell. Gated on
- freezing `DodoSSH.Contracts` and the crypto AAD, plus two client spikes (Linux WebView,
- SSH.NET window-change).
+- **M1 — vertical slice.** OIDC login → enroll → create a host → open a shell.
+ *Backend done:* the DSH1 crypto core, the data model, sync push/pull for hosts, `/me`, and
+ enrollment with the identity-provider key binding. *Remaining:* the desktop client, and the two
+ spikes that gate it — the Linux WebView and SSH.NET's `window-change`. Both need a Linux and a
+ macOS machine, so neither has run yet.
- **M2 — full personal vault**, robust sync, relay.
- **M3 — teams**, sharing, ACLs.
- **M4 — hardening and ops**, packaging, self-hosting guide.
diff --git a/src/DodoSSH.Api/Authorization/EnrolledRequirement.cs b/src/DodoSSH.Api/Authorization/EnrolledRequirement.cs
new file mode 100644
index 0000000..d7818a2
--- /dev/null
+++ b/src/DodoSSH.Api/Authorization/EnrolledRequirement.cs
@@ -0,0 +1,104 @@
+using DodoSSH.Contracts;
+using DodoSSH.Infrastructure;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Authorization.Policy;
+using Microsoft.EntityFrameworkCore;
+
+namespace DodoSSH.Api.Authorization;
+
+///
+/// Requires that the caller has published an identity key.
+///
+///
+/// Not a confidentiality boundary — vault access is decided by ownership and grants, and an
+/// unenrolled user holds neither. What this prevents is a client with no key material writing
+/// ciphertext nobody can ever decrypt, and reading vault contents it has no way to open. Both
+/// present to a user as data corruption, so the server refuses early and says why.
+///
+internal sealed class EnrolledRequirement : IAuthorizationRequirement;
+
+/// Checks enrollment state against the database.
+///
+/// Scoped, not singleton, because it uses the request's . Registering it
+/// with the default singleton lifetime would capture one context for the process.
+///
+internal sealed class EnrolledHandler(
+ ICurrentUserContext currentUser,
+ DodoDbContext database,
+ IHttpContextAccessor accessor)
+ : AuthorizationHandler
+{
+ ///
+ protected override async Task HandleRequirementAsync(
+ AuthorizationHandlerContext context,
+ EnrolledRequirement requirement)
+ {
+ ArgumentNullException.ThrowIfNull(context);
+
+ // Anonymous callers are already refused by the policy's authentication requirement, which
+ // produces a 401. Leaving this one unmet as well would turn that into a 403.
+ if (context.User.Identity?.IsAuthenticated != true)
+ {
+ return;
+ }
+
+ var cancellationToken = accessor.HttpContext?.RequestAborted ?? CancellationToken.None;
+
+ var user = await currentUser.GetOrProvisionAsync(cancellationToken).ConfigureAwait(false);
+
+ var enrolled = await database.UserKeys
+ .AnyAsync(k => k.UserId == user.Id && k.IsCurrent, cancellationToken)
+ .ConfigureAwait(false);
+
+ if (enrolled)
+ {
+ context.Succeed(requirement);
+ }
+ }
+}
+
+///
+/// Turns an unmet into a ProblemDetails response carrying
+/// .
+///
+///
+/// A bare 403 has an empty body, so a client cannot tell "you have not enrolled" — which it can
+/// fix on its own — from "you may not touch this vault", which it cannot. Every other failure falls
+/// through to the framework's default handling unchanged.
+///
+internal sealed class DodoAuthorizationResultHandler : IAuthorizationMiddlewareResultHandler
+{
+ private readonly AuthorizationMiddlewareResultHandler defaultHandler = new();
+
+ ///
+ public async Task HandleAsync(
+ RequestDelegate next,
+ HttpContext context,
+ AuthorizationPolicy policy,
+ PolicyAuthorizationResult authorizeResult)
+ {
+ ArgumentNullException.ThrowIfNull(context);
+ ArgumentNullException.ThrowIfNull(authorizeResult);
+
+ var enrollmentMissing = authorizeResult.Forbidden
+ && authorizeResult.AuthorizationFailure is { } failure
+ && failure.FailedRequirements.OfType().Any();
+
+ if (!enrollmentMissing)
+ {
+ await defaultHandler.HandleAsync(next, context, policy, authorizeResult).ConfigureAwait(false);
+ return;
+ }
+
+ await TypedResults.Problem(
+ detail: "Publish an identity key at POST /api/v1/me/enrollment before using vaults.",
+ statusCode: StatusCodes.Status403Forbidden,
+ type: ProblemCodes.TypeBaseUri + ProblemCodes.EnrollmentRequired,
+ extensions: new Dictionary(StringComparer.Ordinal)
+ {
+ ["code"] = ProblemCodes.EnrollmentRequired,
+ })
+ .ExecuteAsync(context)
+ .ConfigureAwait(false);
+ }
+}
diff --git a/src/DodoSSH.Api/Authorization/VaultAccessService.cs b/src/DodoSSH.Api/Authorization/VaultAccessService.cs
index 9893161..b461d91 100644
--- a/src/DodoSSH.Api/Authorization/VaultAccessService.cs
+++ b/src/DodoSSH.Api/Authorization/VaultAccessService.cs
@@ -29,6 +29,16 @@ public interface IVaultAccessService
/// answer is an existence oracle that lets a caller enumerate other tenants' vault ids.
///
Task ResolveAsync(Guid userId, Guid vaultId, CancellationToken cancellationToken);
+
+ ///
+ /// Lists every vault the caller can reach, with their effective permissions.
+ ///
+ ///
+ /// Shares the permission rules with rather than reimplementing them
+ /// for the list case. A listing that disagreed with the per-vault check would show a user a
+ /// vault they then could not open, or worse, hide one they can.
+ ///
+ Task> ListAsync(Guid userId, CancellationToken cancellationToken);
}
///
@@ -79,4 +89,22 @@ internal sealed class VaultAccessService(DodoDbContext database) : IVaultAccessS
// correct behaviour in the meantime.
return VaultAccess.Denied;
}
+
+ ///
+ public async Task> ListAsync(
+ Guid userId,
+ CancellationToken cancellationToken)
+ {
+ // Personal ownership only, matching ResolveAsync. When M3 adds the
+ // v_user_vault_permission view, both methods change together and neither can drift.
+ var vaults = await database.Vaults
+ .Where(v => v.OwnerKind == VaultOwnerKind.Personal
+ && v.OwnerUserId == userId
+ && v.DeletedAtUtc == null)
+ .OrderBy(v => v.CreatedAtUtc)
+ .ToListAsync(cancellationToken)
+ .ConfigureAwait(false);
+
+ return [.. vaults.Select(v => new VaultAccess(v, OwnerPermissions))];
+ }
}
diff --git a/src/DodoSSH.Api/Features/Identity/EnrollmentExceptions.cs b/src/DodoSSH.Api/Features/Identity/EnrollmentExceptions.cs
new file mode 100644
index 0000000..16f7a5e
--- /dev/null
+++ b/src/DodoSSH.Api/Features/Identity/EnrollmentExceptions.cs
@@ -0,0 +1,23 @@
+namespace DodoSSH.Api.Features.Identity;
+
+///
+/// The enrollment request was structurally unacceptable.
+///
+///
+/// The message is returned to the caller. Keep it about the shape of their own request and never
+/// about other accounts or stored state.
+///
+internal sealed class EnrollmentInvalidException(string message) : Exception(message);
+
+/// The identity-provider token did not bind the supplied keys.
+internal sealed class IdentityBindingInvalidException(string message) : Exception(message);
+
+///
+/// The caller already holds a current identity key that is not the one being enrolled.
+///
+///
+/// Re-sending an identical enrollment is not this: that is a retry, and it succeeds
+/// idempotently. This is a second, different key, which would silently orphan every vault key
+/// wrapped to the first one.
+///
+internal sealed class AlreadyEnrolledException(string message) : Exception(message);
diff --git a/src/DodoSSH.Api/Features/Identity/EnrollmentLimits.cs b/src/DodoSSH.Api/Features/Identity/EnrollmentLimits.cs
new file mode 100644
index 0000000..c545467
--- /dev/null
+++ b/src/DodoSSH.Api/Features/Identity/EnrollmentLimits.cs
@@ -0,0 +1,74 @@
+namespace DodoSSH.Api.Features.Identity;
+
+///
+/// Bounds the server enforces on an enrollment request.
+///
+///
+/// These are sanity limits, not policy. The server cannot read any of the material they describe,
+/// so their job is to stop a buggy or hostile client writing something that is permanently
+/// unusable — or permanently weak, which matters because a wrap stored here is exactly what an
+/// attacker who ever obtains a database dump gets to grind against offline.
+///
+internal static class EnrollmentLimits
+{
+ /// The only KDF this version accepts.
+ internal const string KdfAlgorithm = "argon2id";
+
+ /// Smallest acceptable salt.
+ internal const int MinimumSaltBytes = 16;
+
+ /// Largest sensible salt.
+ internal const int MaximumSaltBytes = 64;
+
+ ///
+ /// Hard floor on Argon2id memory cost, in kibibytes: 64 MiB.
+ ///
+ ///
+ /// Below the recommended 256 MiB, because a low-memory machine must still be able to enroll,
+ /// and because the recovery-code wrap legitimately uses a cheaper profile — a printable
+ /// recovery code carries far more entropy than a passphrase, so it needs less stretching.
+ /// See docs/crypto.md §2.
+ ///
+ internal const int MinimumKdfMemoryKibibytes = 64 * 1024;
+
+ /// Ceiling on Argon2id memory cost, in kibibytes: 4 GiB.
+ ///
+ /// A client that stores an absurd cost locks itself out of its own vault on every future
+ /// device, and the server is the only place that can refuse it.
+ ///
+ internal const int MaximumKdfMemoryKibibytes = 4 * 1024 * 1024;
+
+ /// Fewest acceptable Argon2id passes.
+ internal const int MinimumKdfPasses = 2;
+
+ /// Most acceptable Argon2id passes.
+ internal const int MaximumKdfPasses = 16;
+
+ /// Lanes. libsodium supports one value and one only.
+ internal const int RequiredKdfParallelism = 1;
+
+ ///
+ /// Largest accepted wrap or sealed vault key.
+ ///
+ ///
+ /// The secret bundle is about 200 bytes and a sealed vault key is 32, both inside a DSH1
+ /// envelope. 4 KiB is generous room for growth while still refusing a client trying to use the
+ /// identity tables as storage.
+ ///
+ internal const int MaximumWrapBytes = 4 * 1024;
+
+ /// Longest vault name, matching the column.
+ internal const int MaximumVaultNameLength = 256;
+
+ /// Longest device name, matching the column.
+ internal const int MaximumDeviceNameLength = 256;
+
+ /// Longest issuer, matching the column.
+ internal const int MaximumIssuerLength = 512;
+
+ /// Longest subject, matching the column.
+ internal const int MaximumSubjectLength = 256;
+
+ /// Longest email, matching the column.
+ internal const int MaximumEmailLength = 320;
+}
diff --git a/src/DodoSSH.Api/Features/Identity/EnrollmentService.cs b/src/DodoSSH.Api/Features/Identity/EnrollmentService.cs
new file mode 100644
index 0000000..8e61083
--- /dev/null
+++ b/src/DodoSSH.Api/Features/Identity/EnrollmentService.cs
@@ -0,0 +1,496 @@
+using System.Security.Cryptography;
+using System.Text.Json;
+using DodoSSH.Contracts;
+using DodoSSH.Crypto;
+using DodoSSH.Domain;
+using DodoSSH.Infrastructure;
+using Microsoft.EntityFrameworkCore;
+using Npgsql;
+
+namespace DodoSSH.Api.Features.Identity;
+
+///
+/// Publishes a user's first identity key and creates their personal vault.
+///
+///
+///
+/// One transaction produces the key, its wraps, the device, the key log entry, the vault and the
+/// vault key grant. Nothing here is separable: 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.
+///
+///
+/// Enrollment is idempotent on retry. A client whose request timed out after the server committed
+/// re-sends the identical body and receives the identical response, because the vault id and the
+/// keys are all client-chosen. Without that, a lost response would leave the user permanently
+/// enrolled against a vault they never learned the id of.
+///
+///
+internal sealed class EnrollmentService(
+ DodoDbContext database,
+ IIdentityBindingVerifier bindingVerifier,
+ TimeProvider clock,
+ ILogger logger)
+{
+ ///
+ /// Lock key serialising key log appends across the deployment.
+ ///
+ ///
+ /// A constant string rather than a per-user key, because the chain is global: two concurrent
+ /// enrollments reading the same head would produce two entries claiming the same predecessor,
+ /// which is indistinguishable from the fork the chain exists to detect. Enrollment happens once
+ /// per user, so serialising it costs nothing.
+ ///
+ private const string KeyLogLockName = "dodossh:key_log";
+
+ /// Enrolls the caller's first identity key.
+ /// The request is structurally unacceptable.
+ /// The provider token did not bind the keys.
+ /// A different current key already exists.
+ internal async Task EnrollAsync(
+ UserAccount user,
+ EnrollmentRequest request,
+ CancellationToken cancellationToken)
+ {
+ EnrollmentValidation.Validate(request, user);
+
+ var statement = request.Statement;
+ var canonical = KeyStatementCodec.Encode(ToFields(statement));
+
+ // Order matters only for cost: the self-signature is local and cheap, the binding check may
+ // hit the provider's JWKS endpoint. Both are mandatory and neither substitutes for the
+ // other — the self-signature proves possession of the private key, the binding proves whose
+ // key it is.
+ if (!DshSignatures.VerifyKeyStatement(
+ statement.SigningPublicKey, canonical, request.StatementSignature))
+ {
+ IdentityLog.StatementSignatureRejected(logger, user.Id);
+ throw new EnrollmentInvalidException(
+ "The key statement self-signature did not verify against the statement's own signing key.");
+ }
+
+ var verification = await bindingVerifier.VerifyAsync(
+ request.IdentityProviderToken,
+ statement.Issuer,
+ statement.Subject,
+ KeyStatementCodec.ToNonce(KeyStatementCodec.ComputeBinding(canonical)),
+ cancellationToken)
+ .ConfigureAwait(false);
+
+ if (!verification.Verified)
+ {
+ IdentityLog.BindingRejected(logger, user.Id);
+ throw new IdentityBindingInvalidException(verification.Failure!);
+ }
+
+ var fingerprint = DshCrypto.ComputeFingerprint(
+ statement.EncryptionPublicKey,
+ statement.SigningPublicKey);
+
+ // Cheap path for the common retry. The authoritative check runs inside the transaction.
+ var current = await FindCurrentKeyAsync(user.Id, cancellationToken).ConfigureAwait(false);
+ if (current is not null)
+ {
+ return await ResolveExistingAsync(user, request, current, fingerprint, cancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ return await CommitAsync(user, request, verification, fingerprint, cancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ private async Task CommitAsync(
+ UserAccount user,
+ EnrollmentRequest request,
+ BindingVerification verification,
+ byte[] fingerprint,
+ CancellationToken cancellationToken)
+ {
+ var strategy = database.Database.CreateExecutionStrategy();
+
+ return await strategy.ExecuteAsync(async () =>
+ {
+ var transaction = await database.Database
+ .BeginTransactionAsync(cancellationToken)
+ .ConfigureAwait(false);
+ await using var _ = transaction.ConfigureAwait(false);
+
+ // First statement in the transaction, as with a sync push. The key log's sequence is an
+ // identity column, so it is assigned before commit: without serialising appends, two
+ // transactions can interleave their sequences and the hash chain no longer matches the
+ // order a reader sees. See ADR 0003 for the same hazard in the vault change log.
+ await AcquireKeyLogLockAsync(cancellationToken).ConfigureAwait(false);
+
+ var current = await FindCurrentKeyAsync(user.Id, cancellationToken).ConfigureAwait(false);
+ if (current is not null)
+ {
+ // Two concurrent first enrollments. Fall back to the same resolution the fast path
+ // uses, so a duplicate submission still ends in an idempotent success.
+ return await ResolveExistingAsync(user, request, current, fingerprint, cancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ await RequireVaultIdIsFreeAsync(request.PersonalVault.VaultId, cancellationToken)
+ .ConfigureAwait(false);
+
+ var written = await WriteAsync(user, request, verification, fingerprint, cancellationToken)
+ .ConfigureAwait(false);
+
+ await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
+
+ IdentityLog.Enrolled(logger, user.Id, written.Generation, written.KeyLogSequence);
+
+ return new EnrollmentResponse(
+ UserId: user.Id,
+ KeyGeneration: written.Generation,
+ Fingerprint: fingerprint,
+ PersonalVaultId: request.PersonalVault.VaultId,
+ DeviceId: written.DeviceId,
+ KeyLogSequence: written.KeyLogSequence);
+ }).ConfigureAwait(false);
+ }
+
+ /// What the write produced, for the response.
+ [System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Auto)]
+ private readonly record struct WrittenEnrollment(int Generation, Guid? DeviceId, long KeyLogSequence);
+
+ /// Writes every row enrollment produces, inside the caller's transaction.
+ private async Task WriteAsync(
+ UserAccount user,
+ EnrollmentRequest request,
+ BindingVerification verification,
+ byte[] fingerprint,
+ CancellationToken cancellationToken)
+ {
+ // Truncated to the precision the key log hashes, so the stored row can reproduce its own
+ // hash after a round trip through PostgreSQL's microsecond timestamps.
+ var now = KeyLogChain.TruncateTimestamp(clock.GetUtcNow());
+
+ var key = BuildKey(user, request, verification, fingerprint, now);
+ database.UserKeys.Add(key);
+
+ AddWraps(user, request, now);
+ var deviceId = AddDevice(user, request, now);
+ var entry = await AppendKeyLogAsync(user, request, now, cancellationToken).ConfigureAwait(false);
+ AddPersonalVault(user, request, fingerprint, now);
+
+ user.EnrolledAtUtc = now;
+ user.UpdatedAtUtc = now;
+ user.LastSeenAtUtc = now;
+
+ try
+ {
+ await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ }
+ catch (DbUpdateException exception) when (IsUniqueViolation(exception))
+ {
+ // The advisory lock covers the key log, and the pre-checks cover the expected races.
+ // Anything left is a genuine collision — most plausibly the global unique index on key
+ // fingerprints — and must not be reported as success.
+ throw new AlreadyEnrolledException(
+ "Enrollment collided with existing identity data. Retry with freshly generated keys.");
+ }
+
+ return new WrittenEnrollment(key.Generation, deviceId, entry.Sequence);
+ }
+
+ ///
+ /// Decides whether an enrollment against an already-enrolled user is a retry or a conflict.
+ ///
+ ///
+ /// Compared by fingerprint, which covers both public keys, rather than by any single field. The
+ /// vault id must match too: the same keys with a different vault id is a client that has lost
+ /// track of its own state, and creating a second vault for it would strand the first.
+ ///
+ private async Task ResolveExistingAsync(
+ UserAccount user,
+ EnrollmentRequest request,
+ UserKey current,
+ byte[] fingerprint,
+ CancellationToken cancellationToken)
+ {
+ if (!CryptographicOperations.FixedTimeEquals(current.FingerprintSha256, fingerprint))
+ {
+ throw new AlreadyEnrolledException(
+ "This account already holds a different identity key. Rotate the existing key "
+ + "rather than enrolling a second one.");
+ }
+
+ var vaultExists = await database.Vaults
+ .AnyAsync(
+ v => v.Id == request.PersonalVault.VaultId
+ && v.OwnerUserId == user.Id
+ && v.DeletedAtUtc == null,
+ cancellationToken)
+ .ConfigureAwait(false);
+
+ if (!vaultExists)
+ {
+ throw new AlreadyEnrolledException(
+ "This account is already enrolled with these keys, but not against the supplied "
+ + "vault id. Read /api/v1/me to recover the current state.");
+ }
+
+ var sequence = await database.KeyLog
+ .Where(e => e.UserId == user.Id && e.Generation == current.Generation)
+ .Select(e => e.Sequence)
+ .FirstOrDefaultAsync(cancellationToken)
+ .ConfigureAwait(false);
+
+ var deviceId = request.DevicePublicKey is null
+ ? (Guid?)null
+ : await database.Devices
+ .Where(d => d.UserId == user.Id && d.PublicKey == request.DevicePublicKey)
+ .Select(d => (Guid?)d.Id)
+ .FirstOrDefaultAsync(cancellationToken)
+ .ConfigureAwait(false);
+
+ IdentityLog.EnrollmentReplayed(logger, user.Id);
+
+ return new EnrollmentResponse(
+ UserId: user.Id,
+ KeyGeneration: current.Generation,
+ Fingerprint: current.FingerprintSha256,
+ PersonalVaultId: request.PersonalVault.VaultId,
+ DeviceId: deviceId,
+ KeyLogSequence: sequence);
+ }
+
+ private static UserKey BuildKey(
+ UserAccount user,
+ EnrollmentRequest request,
+ BindingVerification verification,
+ byte[] fingerprint,
+ DateTimeOffset now) =>
+ new()
+ {
+ Id = Guid.CreateVersion7(),
+ UserId = user.Id,
+ Generation = request.Statement.KeyGeneration,
+ EncryptionPublicKey = request.Statement.EncryptionPublicKey,
+ SigningPublicKey = request.Statement.SigningPublicKey,
+ FingerprintSha256 = fingerprint,
+
+ // Stored verbatim so a client can recompute the canonical encoding and check the
+ // binding for itself. The hash is over the canonical encoding, not over this JSON, so
+ // the serialiser's formatting choices cannot invalidate anything.
+ Statement = JsonSerializer.Serialize(
+ request.Statement,
+ DodoSshJsonContext.ResponseOptions),
+
+ StatementSignature = request.StatementSignature,
+ IdentityProviderBinding = verification.Evidence,
+ IsCurrent = true,
+ CreatedAtUtc = now,
+ };
+
+ private void AddWraps(UserAccount user, EnrollmentRequest request, DateTimeOffset now)
+ {
+ database.UserKeyWraps.Add(BuildKdfWrap(
+ user.Id,
+ UserKeyWrapKind.Passphrase,
+ request.WrappedPrivateKey,
+ request.KdfParameters,
+ now));
+
+ if (request.RecoveryWrappedPrivateKey is not null && request.RecoveryKdfParameters is not null)
+ {
+ database.UserKeyWraps.Add(BuildKdfWrap(
+ user.Id,
+ UserKeyWrapKind.Recovery,
+ request.RecoveryWrappedPrivateKey,
+ request.RecoveryKdfParameters,
+ now));
+ }
+ }
+
+ private static UserKeyWrap BuildKdfWrap(
+ Guid userId,
+ UserKeyWrapKind kind,
+ byte[] wrap,
+ KdfParameters parameters,
+ DateTimeOffset now) =>
+ new()
+ {
+ Id = Guid.CreateVersion7(),
+ UserId = userId,
+ Kind = kind,
+ Wrap = wrap,
+ WrapVersion = 1,
+ KdfAlgorithm = parameters.Algorithm,
+ KdfSalt = parameters.Salt,
+ KdfMemoryKibibytes = parameters.MemoryKibibytes,
+ KdfPasses = parameters.Passes,
+ KdfParallelism = parameters.Parallelism,
+ CreatedAtUtc = now,
+ };
+
+ ///
+ /// The platform is left unreported: the request carries a device name but no platform, and
+ /// guessing one from a user agent would be a display-only field populated with a lie. The
+ /// devices endpoint sets it properly when it lands.
+ ///
+ private Guid? AddDevice(UserAccount user, EnrollmentRequest request, DateTimeOffset now)
+ {
+ if (request.DevicePublicKey is null || request.DeviceWrappedPrivateKey is null)
+ {
+ return null;
+ }
+
+ var device = new Device
+ {
+ Id = Guid.CreateVersion7(),
+ UserId = user.Id,
+ Name = request.Statement.DeviceName,
+ Platform = DevicePlatform.Unspecified,
+ PublicKey = request.DevicePublicKey,
+ EnrolledAtUtc = now,
+ LastSeenAtUtc = now,
+ };
+
+ database.Devices.Add(device);
+
+ database.UserKeyWraps.Add(new UserKeyWrap
+ {
+ Id = Guid.CreateVersion7(),
+ UserId = user.Id,
+ Kind = UserKeyWrapKind.Device,
+ DeviceId = device.Id,
+ Wrap = request.DeviceWrappedPrivateKey,
+ WrapVersion = 1,
+ CreatedAtUtc = now,
+ });
+
+ return device.Id;
+ }
+
+ private async Task AppendKeyLogAsync(
+ UserAccount user,
+ EnrollmentRequest request,
+ DateTimeOffset now,
+ CancellationToken cancellationToken)
+ {
+ // Ordered by sequence rather than by timestamp: the sequence is what the chain follows, and
+ // two entries can share a millisecond.
+ var previousHash = await database.KeyLog
+ .OrderByDescending(e => e.Sequence)
+ .Select(e => e.Hash)
+ .FirstOrDefaultAsync(cancellationToken)
+ .ConfigureAwait(false)
+ ?? KeyLogChain.CreateGenesisPreviousHash();
+
+ var entry = new KeyLogEntry
+ {
+ UserId = user.Id,
+ Generation = request.Statement.KeyGeneration,
+ EncryptionPublicKey = request.Statement.EncryptionPublicKey,
+ SigningPublicKey = request.Statement.SigningPublicKey,
+ StatementSignature = request.StatementSignature,
+ PreviousHash = previousHash,
+ CreatedAtUtc = now,
+ };
+
+ entry.Hash = KeyLogChain.ComputeEntryHash(
+ previousHash,
+ entry.UserId,
+ entry.Generation,
+ entry.EncryptionPublicKey,
+ entry.SigningPublicKey,
+ entry.StatementSignature,
+ entry.CreatedAtUtc);
+
+ database.KeyLog.Add(entry);
+
+ return entry;
+ }
+
+ ///
+ /// The grant carries no key log head. A self-grant has no third party whose key could have been
+ /// substituted, and the entry that would supply the head is being written in this very
+ /// transaction — so the client could not have signed over it. Recording the server's own
+ /// observation instead would produce a row whose signature verifies against nothing.
+ ///
+ private void AddPersonalVault(
+ UserAccount user,
+ EnrollmentRequest request,
+ byte[] fingerprint,
+ DateTimeOffset now)
+ {
+ var personal = request.PersonalVault;
+
+ database.Vaults.Add(new Vault
+ {
+ Id = personal.VaultId,
+ Name = personal.Name,
+ OwnerKind = VaultOwnerKind.Personal,
+ OwnerUserId = user.Id,
+ KeyGeneration = 1,
+ CreatedAtUtc = now,
+ UpdatedAtUtc = now,
+ });
+
+ database.VaultKeyGrants.Add(new VaultKeyGrant
+ {
+ Id = Guid.CreateVersion7(),
+ VaultId = personal.VaultId,
+ KeyGeneration = 1,
+ Kind = GrantKind.Member,
+ RecipientUserId = user.Id,
+ RecipientKeyFingerprint = fingerprint,
+ WrappedKey = personal.WrappedVaultKey,
+ GranterUserId = user.Id,
+ GranterKeyFingerprint = fingerprint,
+ KeyLogHead = null,
+ Signature = personal.GrantSignature,
+ State = GrantState.Active,
+ CreatedAtUtc = now,
+ });
+ }
+
+ ///
+ /// Checked rather than left to the primary key, so the failure is a clear 400 about the
+ /// caller's own request instead of a constraint violation surfacing as a 500. The residual
+ /// disclosure — that some vault with this id exists — is accepted: vault ids are unguessable
+ /// random values, so confirming one exists tells a caller nothing they did not already supply.
+ ///
+ private async Task RequireVaultIdIsFreeAsync(Guid vaultId, CancellationToken cancellationToken)
+ {
+ var taken = await database.Vaults
+ .AnyAsync(v => v.Id == vaultId, cancellationToken)
+ .ConfigureAwait(false);
+
+ if (taken)
+ {
+ throw new EnrollmentInvalidException(
+ "The supplied personal vault id is already in use. Generate a new UUIDv7 and retry.");
+ }
+ }
+
+ private Task FindCurrentKeyAsync(Guid userId, CancellationToken cancellationToken) =>
+ database.UserKeys.SingleOrDefaultAsync(
+ k => k.UserId == userId && k.IsCurrent,
+ cancellationToken);
+
+ private Task AcquireKeyLogLockAsync(CancellationToken cancellationToken) =>
+ database.Database.ExecuteSqlAsync(
+ $"SELECT pg_advisory_xact_lock(hashtextextended({KeyLogLockName}, 0))",
+ cancellationToken);
+
+ 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);
+
+ private static bool IsUniqueViolation(DbUpdateException exception) =>
+ string.Equals(
+ (exception.InnerException as PostgresException)?.SqlState,
+ PostgresErrorCodes.UniqueViolation,
+ StringComparison.Ordinal);
+}
diff --git a/src/DodoSSH.Api/Features/Identity/EnrollmentValidation.cs b/src/DodoSSH.Api/Features/Identity/EnrollmentValidation.cs
new file mode 100644
index 0000000..fb9c493
--- /dev/null
+++ b/src/DodoSSH.Api/Features/Identity/EnrollmentValidation.cs
@@ -0,0 +1,260 @@
+using DodoSSH.Contracts;
+using DodoSSH.Crypto;
+using DodoSSH.Domain;
+
+namespace DodoSSH.Api.Features.Identity;
+
+///
+/// Structural validation of an enrollment request, before any cryptography or database work.
+///
+///
+/// Everything here fails the whole request rather than being recorded and skipped. Enrollment is
+/// one indivisible act: a user with a key but no vault, or a vault whose key was never wrapped, is
+/// worse than a user who has to try again.
+///
+internal static class EnrollmentValidation
+{
+ /// Validates the request against the caller it claims to describe.
+ /// Any part of the request is unacceptable.
+ internal static void Validate(EnrollmentRequest request, UserAccount caller)
+ {
+ ValidateStatement(request.Statement, caller);
+ ValidateSignature(request.StatementSignature);
+ ValidatePassphraseWrap(request);
+ ValidateDevice(request);
+ ValidateRecovery(request);
+ ValidatePersonalVault(request.PersonalVault);
+ }
+
+ private static void ValidateStatement(KeyStatement statement, UserAccount caller)
+ {
+ if (statement is null)
+ {
+ throw new EnrollmentInvalidException("A key statement is required.");
+ }
+
+ ValidateStatementIdentity(statement, caller);
+ ValidateStatementKeys(statement);
+ }
+
+ private static void ValidateStatementIdentity(KeyStatement statement, UserAccount caller)
+ {
+ if (statement.Version != KeyStatementCodec.CurrentVersion)
+ {
+ throw new EnrollmentInvalidException(
+ $"Key statement version {statement.Version} is not supported; this server accepts "
+ + $"version {KeyStatementCodec.CurrentVersion}.");
+ }
+
+ // The first generation only. Rotation is a separate operation with its own predecessor
+ // signature, and accepting an arbitrary generation here would let a client skip straight to
+ // generation 9 and leave gaps the key log could never explain.
+ if (statement.KeyGeneration != 1)
+ {
+ throw new EnrollmentInvalidException(
+ "Enrollment publishes key generation 1. Use key rotation for later generations.");
+ }
+
+ // The statement is what the identity provider signs over, so it must describe the caller
+ // and not merely be well-formed. Without this a user could enroll keys naming someone else.
+ if (!string.Equals(statement.Issuer, caller.Issuer, StringComparison.Ordinal))
+ {
+ throw new EnrollmentInvalidException("The statement issuer is not the caller's issuer.");
+ }
+
+ if (!string.Equals(statement.Subject, caller.Subject, StringComparison.Ordinal))
+ {
+ throw new EnrollmentInvalidException("The statement subject is not the caller's subject.");
+ }
+
+ RequireText(statement.Issuer, EnrollmentLimits.MaximumIssuerLength, "The statement issuer");
+ RequireText(statement.Subject, EnrollmentLimits.MaximumSubjectLength, "The statement subject");
+ RequireText(statement.DeviceName, EnrollmentLimits.MaximumDeviceNameLength, "The device name");
+
+ if (statement.Email is { Length: > EnrollmentLimits.MaximumEmailLength })
+ {
+ throw new EnrollmentInvalidException("The statement email is too long.");
+ }
+ }
+
+ private static void ValidateStatementKeys(KeyStatement statement)
+ {
+ RequireLength(
+ statement.EncryptionPublicKey,
+ CryptoSpec.PublicKeySize,
+ "The encryption public key");
+
+ RequireLength(
+ statement.SigningPublicKey,
+ CryptoSpec.PublicKeySize,
+ "The signing public key");
+
+ // Distinct key pairs for agreement and signatures. Equal raw bytes here would mean one key
+ // used for both roles, which is a standing cryptographic mistake and is also the shape a
+ // client bug takes when it exports the wrong key twice.
+ if (statement.EncryptionPublicKey.AsSpan().SequenceEqual(statement.SigningPublicKey))
+ {
+ throw new EnrollmentInvalidException(
+ "The encryption and signing public keys must differ.");
+ }
+ }
+
+ private static void ValidateSignature(byte[] signature) =>
+ RequireLength(signature, CryptoSpec.SignatureSize, "The statement signature");
+
+ private static void ValidatePassphraseWrap(EnrollmentRequest request)
+ {
+ RequireWrap(request.WrappedPrivateKey, "The passphrase wrap");
+ ValidateKdf(request.KdfParameters, "passphrase");
+ }
+
+ ///
+ /// Both or neither. A device public key without its wrap registers a device that can never
+ /// unlock anything — only the holder of the secret bundle can seal it, so the server could
+ /// never fill the gap in.
+ ///
+ private static void ValidateDevice(EnrollmentRequest request)
+ {
+ if (request.DevicePublicKey is null && request.DeviceWrappedPrivateKey is null)
+ {
+ return;
+ }
+
+ if (request.DevicePublicKey is null || request.DeviceWrappedPrivateKey is null)
+ {
+ throw new EnrollmentInvalidException(
+ "A device key and its wrap must be supplied together.");
+ }
+
+ RequireLength(request.DevicePublicKey, CryptoSpec.PublicKeySize, "The device public key");
+ RequireWrap(request.DeviceWrappedPrivateKey, "The device wrap");
+ }
+
+ ///
+ /// Recovery is optional at the protocol level and the client should make it very hard to skip.
+ /// A user who loses their passphrase and every device has no other path back, and a
+ /// zero-knowledge server genuinely cannot help. See ADR 0001.
+ ///
+ private static void ValidateRecovery(EnrollmentRequest request)
+ {
+ if (request.RecoveryWrappedPrivateKey is null && request.RecoveryKdfParameters is null)
+ {
+ return;
+ }
+
+ if (request.RecoveryWrappedPrivateKey is null || request.RecoveryKdfParameters is null)
+ {
+ throw new EnrollmentInvalidException(
+ "A recovery wrap and its KDF parameters must be supplied together.");
+ }
+
+ RequireWrap(request.RecoveryWrappedPrivateKey, "The recovery wrap");
+ ValidateKdf(request.RecoveryKdfParameters, "recovery");
+ }
+
+ private static void ValidatePersonalVault(PersonalVaultRequest vault)
+ {
+ if (vault is null)
+ {
+ throw new EnrollmentInvalidException("A personal vault is required.");
+ }
+
+ if (vault.VaultId == Guid.Empty)
+ {
+ throw new EnrollmentInvalidException("The personal vault id must not be empty.");
+ }
+
+ RequireText(vault.Name, EnrollmentLimits.MaximumVaultNameLength, "The vault name");
+ RequireWrap(vault.WrappedVaultKey, "The wrapped vault key");
+ RequireLength(vault.GrantSignature, CryptoSpec.SignatureSize, "The grant signature");
+ }
+
+ private static void ValidateKdf(KdfParameters? parameters, string role)
+ {
+ // A record's non-nullable parameter is a compile-time promise, not a runtime one: a JSON
+ // body that simply omits the property deserialises to null. Without this the next line is a
+ // NullReferenceException, so a malformed request would be a 500 instead of a 400.
+ if (parameters is null)
+ {
+ throw new EnrollmentInvalidException($"The {role} KDF parameters are required.");
+ }
+
+ if (!string.Equals(parameters.Algorithm, EnrollmentLimits.KdfAlgorithm, StringComparison.Ordinal))
+ {
+ throw new EnrollmentInvalidException(
+ $"The {role} KDF must be {EnrollmentLimits.KdfAlgorithm}.");
+ }
+
+ if (parameters.Salt is null
+ or { Length: < EnrollmentLimits.MinimumSaltBytes }
+ or { Length: > EnrollmentLimits.MaximumSaltBytes })
+ {
+ throw new EnrollmentInvalidException(
+ $"The {role} salt must be between {EnrollmentLimits.MinimumSaltBytes} and "
+ + $"{EnrollmentLimits.MaximumSaltBytes} bytes.");
+ }
+
+ if (parameters.MemoryKibibytes is < EnrollmentLimits.MinimumKdfMemoryKibibytes
+ or > EnrollmentLimits.MaximumKdfMemoryKibibytes)
+ {
+ // Kibibytes, not bytes. Confusing the two is a factor of 1024 in either direction:
+ // unusably slow one way, trivially crackable the other. See docs/crypto.md §2.
+ throw new EnrollmentInvalidException(
+ $"The {role} KDF memory cost must be between "
+ + $"{EnrollmentLimits.MinimumKdfMemoryKibibytes} and "
+ + $"{EnrollmentLimits.MaximumKdfMemoryKibibytes} kibibytes.");
+ }
+
+ if (parameters.Passes is < EnrollmentLimits.MinimumKdfPasses
+ or > EnrollmentLimits.MaximumKdfPasses)
+ {
+ throw new EnrollmentInvalidException(
+ $"The {role} KDF pass count must be between {EnrollmentLimits.MinimumKdfPasses} "
+ + $"and {EnrollmentLimits.MaximumKdfPasses}.");
+ }
+
+ if (parameters.Parallelism != EnrollmentLimits.RequiredKdfParallelism)
+ {
+ throw new EnrollmentInvalidException(
+ $"The {role} KDF parallelism must be {EnrollmentLimits.RequiredKdfParallelism}; "
+ + "libsodium supports no other value.");
+ }
+ }
+
+ private static void RequireLength(byte[]? value, int expected, string description)
+ {
+ if (value is null || value.Length != expected)
+ {
+ throw new EnrollmentInvalidException(
+ $"{description} must be exactly {expected} bytes.");
+ }
+ }
+
+ private static void RequireWrap(byte[]? value, string description)
+ {
+ if (value is null or { Length: 0 })
+ {
+ throw new EnrollmentInvalidException($"{description} is required.");
+ }
+
+ if (value.Length > EnrollmentLimits.MaximumWrapBytes)
+ {
+ throw new EnrollmentInvalidException(
+ $"{description} exceeds {EnrollmentLimits.MaximumWrapBytes} bytes.");
+ }
+ }
+
+ private static void RequireText(string? value, int maximumLength, string description)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ throw new EnrollmentInvalidException($"{description} is required.");
+ }
+
+ if (value.Length > maximumLength)
+ {
+ throw new EnrollmentInvalidException(
+ $"{description} exceeds {maximumLength} characters.");
+ }
+ }
+}
diff --git a/src/DodoSSH.Api/Features/Identity/IdentityBindingVerifier.cs b/src/DodoSSH.Api/Features/Identity/IdentityBindingVerifier.cs
new file mode 100644
index 0000000..d4903cf
--- /dev/null
+++ b/src/DodoSSH.Api/Features/Identity/IdentityBindingVerifier.cs
@@ -0,0 +1,210 @@
+using System.Text.Json.Nodes;
+using DodoSSH.Api.Setup;
+using DodoSSH.Crypto;
+using Microsoft.AspNetCore.Authentication.JwtBearer;
+using Microsoft.Extensions.Options;
+using Microsoft.IdentityModel.JsonWebTokens;
+using Microsoft.IdentityModel.Tokens;
+
+namespace DodoSSH.Api.Features.Identity;
+
+/// The outcome of checking an identity-provider key binding.
+/// Whether the token bound the supplied keys to the caller.
+/// Why it did not, when it did not.
+///
+/// The binding evidence to persist, as JSON. Includes the token verbatim so a client can repeat
+/// the verification against the provider's own JWKS rather than trusting this server's word.
+///
+internal sealed record BindingVerification(bool Verified, string? Failure, string? Evidence)
+{
+ internal static BindingVerification Failed(string failure) => new(false, failure, null);
+}
+
+/// Verifies that an identity provider signed an assertion over a set of public keys.
+internal interface IIdentityBindingVerifier
+{
+ ///
+ /// Verifies a binding ID token.
+ ///
+ /// The ID token from the binding authorization.
+ /// Issuer named by the key statement.
+ /// Subject the access token authenticated as.
+ ///
+ /// The statement's binding value, from .
+ ///
+ /// Cancellation token.
+ Task VerifyAsync(
+ string idToken,
+ string expectedIssuer,
+ string expectedSubject,
+ string expectedNonce,
+ CancellationToken cancellationToken);
+}
+
+///
+/// Identity-provider binding verification.
+///
+///
+///
+/// This is the primary public-key trust anchor, and everything else in the design is downstream of
+/// it. At enrollment the client hashes its key statement and runs a fresh OIDC authorization with
+/// that hash as the nonce, so the resulting ID token is an identity-provider signature over
+/// exactly those keys. This server cannot mint such a 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. See ADR 0001.
+///
+///
+/// Be clear about the residual risk: this makes the identity provider a key-distribution trust
+/// root, and in a self-hosted deployment the person running Keycloak is usually the person running
+/// DodoSSH. It raises the bar from one compromised service to one compromised service plus a
+/// detectable artefact — the key log — not to zero.
+///
+///
+/// Verifying here does not make this the boundary. Clients must repeat the check against
+/// the provider's JWKS fetched directly, which is why the token is retained verbatim. A check only
+/// the server performs is a claim, not evidence.
+///
+///
+internal sealed class IdentityBindingVerifier(
+ IOptionsMonitor jwtBearerOptions,
+ IOptions oidcOptions,
+ TimeProvider clock)
+ : IIdentityBindingVerifier
+{
+ /// Stateless and documented as thread-safe, so one instance serves every request.
+ private static readonly JsonWebTokenHandler TokenHandler = new();
+
+ /// Version of the stored evidence document.
+ private const int EvidenceVersion = 1;
+
+ ///
+ public async Task VerifyAsync(
+ string idToken,
+ string expectedIssuer,
+ string expectedSubject,
+ string expectedNonce,
+ CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrWhiteSpace(idToken))
+ {
+ return BindingVerification.Failed("No identity provider token was supplied.");
+ }
+
+ var parameters = await BuildValidationParametersAsync(cancellationToken).ConfigureAwait(false);
+
+ var result = await TokenHandler.ValidateTokenAsync(idToken, parameters).ConfigureAwait(false);
+ if (!result.IsValid || result.SecurityToken is not JsonWebToken token)
+ {
+ // Deliberately not echoing the library's reason. It is the caller's own token, so
+ // there is no leak, but the messages are long, version-dependent, and end up in
+ // client-side string matching if we make them part of the contract.
+ return BindingVerification.Failed(
+ "The identity provider token failed signature, issuer, audience or lifetime validation.");
+ }
+
+ if (!string.Equals(token.Issuer, expectedIssuer, StringComparison.Ordinal))
+ {
+ // The statement names the issuer that vouches for it. If that disagrees with the token,
+ // the statement is describing a different identity than the one being proved.
+ return BindingVerification.Failed("The token issuer does not match the key statement.");
+ }
+
+ if (!string.Equals(token.Subject, expectedSubject, StringComparison.Ordinal))
+ {
+ // Without this, any user of this provider could bind keys to another user's account.
+ return BindingVerification.Failed("The token subject is not the authenticated caller.");
+ }
+
+ if (!token.TryGetPayloadValue("nonce", out var nonce) || nonce is null)
+ {
+ return BindingVerification.Failed("The token carries no nonce, so it binds no keys.");
+ }
+
+ if (!string.Equals(nonce, expectedNonce, StringComparison.Ordinal))
+ {
+ return BindingVerification.Failed("The token nonce is not this key statement's hash.");
+ }
+
+ return new BindingVerification(true, null, BuildEvidence(idToken, token, nonce));
+ }
+
+ ///
+ /// The signing keys come from the same the bearer
+ /// handler uses, so there is one metadata cache, one refresh schedule and one backchannel
+ /// rather than a second set that could drift after a provider key rotation.
+ ///
+ private async Task BuildValidationParametersAsync(
+ CancellationToken cancellationToken)
+ {
+ var bearer = jwtBearerOptions.Get(JwtBearerDefaults.AuthenticationScheme);
+
+ var configurationManager = bearer.ConfigurationManager
+ ?? throw new InvalidOperationException(
+ "The bearer scheme has no configuration manager, so provider metadata is unavailable.");
+
+ var configuration = await configurationManager
+ .GetConfigurationAsync(cancellationToken)
+ .ConfigureAwait(false);
+
+ return new TokenValidationParameters
+ {
+ ValidateIssuer = true,
+
+ // From discovery rather than from the configured authority, which may differ by a
+ // trailing slash or be an alias. The document is authoritative about its own issuer.
+ ValidIssuer = configuration.Issuer,
+
+ ValidateAudience = true,
+
+ // An ID token is audienced to the client that requested it, never to this API.
+ // Validating it against the API audience would reject every genuine binding.
+ ValidAudience = oidcOptions.Value.ClientId,
+
+ ValidateLifetime = true,
+ RequireExpirationTime = true,
+ ClockSkew = TimeSpan.FromSeconds(30),
+
+ ValidateIssuerSigningKey = true,
+ RequireSignedTokens = true,
+ IssuerSigningKeys = configuration.SigningKeys,
+
+ // Algorithms are deliberately not restricted: providers legitimately use RS256, PS256,
+ // ES256 and now EdDSA, and a fixed list breaks deployments for no gain. The classic
+ // algorithm-confusion attack is already closed, because the discovery document only
+ // publishes asymmetric keys and a symmetric algorithm cannot consume one.
+ };
+ }
+
+ /// Builds the evidence document stored alongside the key.
+ ///
+ /// The token is stored verbatim so that clients — and later the public-key directory — can
+ /// verify the binding themselves. It has already expired by the time any third party reads it,
+ /// and its audience is the desktop client rather than any API, so it is not a usable credential
+ /// elsewhere. Storing only our summary would mean asking clients to trust the server on the one
+ /// question the whole design exists to avoid trusting it about.
+ ///
+ private string BuildEvidence(string idToken, JsonWebToken token, string nonce)
+ {
+ var evidence = new JsonObject
+ {
+ ["v"] = EvidenceVersion,
+ ["idToken"] = idToken,
+ ["issuer"] = token.Issuer,
+ ["subject"] = token.Subject,
+ ["audiences"] = new JsonArray([.. token.Audiences.Select(a => JsonValue.Create(a))]),
+ ["nonce"] = nonce,
+ ["issuedAtUtc"] = token.IssuedAt.ToString("O", System.Globalization.CultureInfo.InvariantCulture),
+ ["expiresAtUtc"] = token.ValidTo.ToString("O", System.Globalization.CultureInfo.InvariantCulture),
+ ["verifiedAtUtc"] = clock.GetUtcNow().ToString("O", System.Globalization.CultureInfo.InvariantCulture),
+ };
+
+ if (token.TryGetPayloadValue("auth_time", out var authTime))
+ {
+ evidence["authTimeUtc"] = DateTimeOffset
+ .FromUnixTimeSeconds(authTime)
+ .ToString("O", System.Globalization.CultureInfo.InvariantCulture);
+ }
+
+ return evidence.ToJsonString();
+ }
+}
diff --git a/src/DodoSSH.Api/Features/Identity/IdentityEndpoints.cs b/src/DodoSSH.Api/Features/Identity/IdentityEndpoints.cs
new file mode 100644
index 0000000..60f3a24
--- /dev/null
+++ b/src/DodoSSH.Api/Features/Identity/IdentityEndpoints.cs
@@ -0,0 +1,96 @@
+using DodoSSH.Api.Authorization;
+using DodoSSH.Api.Setup;
+using DodoSSH.Contracts;
+using Microsoft.AspNetCore.Http.HttpResults;
+
+namespace DodoSSH.Api.Features.Identity;
+
+///
+/// The caller's own identity: profile, unlock state and enrollment.
+///
+///
+/// Both endpoints run under rather than
+/// , and must. They are how a client discovers that it needs to
+/// enroll and then does so; gating them on enrollment would make enrollment unreachable.
+///
+internal static class IdentityEndpoints
+{
+ internal static IEndpointRouteBuilder MapIdentityEndpoints(this IEndpointRouteBuilder app)
+ {
+ var group = app.MapGroup("/api/v1/me")
+ .RequireAuthorization(Auth.AuthenticatedPolicy)
+ .WithTags("Identity");
+
+ group.MapGet("/", GetMeAsync)
+ .WithName("GetMe")
+ .WithSummary("The caller's profile, enrollment state and reachable vaults.");
+
+ group.MapPost("/enrollment", EnrollAsync)
+ .WithName("Enroll")
+ .WithSummary("Publishes the caller's first identity key and creates their personal vault.");
+
+ return app;
+ }
+
+ private static async Task> GetMeAsync(
+ ICurrentUserContext currentUser,
+ IdentityService identity,
+ CancellationToken cancellationToken)
+ {
+ // Provisioning happens here, on the first authenticated request, keyed on (issuer, subject).
+ var user = await currentUser.GetOrProvisionAsync(cancellationToken).ConfigureAwait(false);
+
+ return TypedResults.Ok(
+ await identity.GetMeAsync(user, cancellationToken).ConfigureAwait(false));
+ }
+
+ private static async Task, ProblemHttpResult>> EnrollAsync(
+ EnrollmentRequest request,
+ ICurrentUserContext currentUser,
+ EnrollmentService enrollment,
+ CancellationToken cancellationToken)
+ {
+ var user = await currentUser.GetOrProvisionAsync(cancellationToken).ConfigureAwait(false);
+
+ try
+ {
+ // 200 rather than 201. A retry of an identical request returns the same body, so there
+ // is no single moment of creation to point a Location header at, and the client already
+ // knows the vault id — it chose it.
+ var response = await enrollment.EnrollAsync(user, request, cancellationToken)
+ .ConfigureAwait(false);
+
+ return TypedResults.Ok(response);
+ }
+ catch (EnrollmentInvalidException exception)
+ {
+ return Problem(
+ StatusCodes.Status400BadRequest,
+ ProblemCodes.InvalidEnrollment,
+ exception.Message);
+ }
+ catch (IdentityBindingInvalidException exception)
+ {
+ // 400, not 401: the access token authenticated the caller perfectly well. What failed is
+ // the separate assertion they supplied about their own keys, which is request content.
+ return Problem(
+ StatusCodes.Status400BadRequest,
+ ProblemCodes.IdentityBindingInvalid,
+ exception.Message);
+ }
+ catch (AlreadyEnrolledException exception)
+ {
+ return Problem(
+ StatusCodes.Status409Conflict,
+ ProblemCodes.AlreadyEnrolled,
+ exception.Message);
+ }
+ }
+
+ private static ProblemHttpResult Problem(int statusCode, string code, string detail) =>
+ TypedResults.Problem(
+ detail: detail,
+ statusCode: statusCode,
+ type: ProblemCodes.TypeBaseUri + code,
+ extensions: new Dictionary(StringComparer.Ordinal) { ["code"] = code });
+}
diff --git a/src/DodoSSH.Api/Features/Identity/IdentityLog.cs b/src/DodoSSH.Api/Features/Identity/IdentityLog.cs
new file mode 100644
index 0000000..3aceb5f
--- /dev/null
+++ b/src/DodoSSH.Api/Features/Identity/IdentityLog.cs
@@ -0,0 +1,36 @@
+namespace DodoSSH.Api.Features.Identity;
+
+///
+/// Source-generated log events for identity and enrollment.
+///
+///
+/// Ids, counts and outcomes only. Never a wrap, a token, a nonce or a public key: an enrollment log
+/// line that carried key material would put in the log exactly what the vault design exists to keep
+/// out of the server's reach.
+///
+internal static partial class IdentityLog
+{
+ [LoggerMessage(
+ EventId = 2001,
+ Level = LogLevel.Information,
+ Message = "Enrolled identity key generation {Generation} for user {UserId} at key log sequence {Sequence}.")]
+ internal static partial void Enrolled(ILogger logger, Guid userId, int generation, long sequence);
+
+ [LoggerMessage(
+ EventId = 2002,
+ Level = LogLevel.Information,
+ Message = "Enrollment retry for user {UserId} matched the stored key; returning the original result.")]
+ internal static partial void EnrollmentReplayed(ILogger logger, Guid userId);
+
+ [LoggerMessage(
+ EventId = 2003,
+ Level = LogLevel.Warning,
+ Message = "Rejected enrollment for user {UserId}: the identity provider binding did not verify.")]
+ internal static partial void BindingRejected(ILogger logger, Guid userId);
+
+ [LoggerMessage(
+ EventId = 2004,
+ Level = LogLevel.Warning,
+ Message = "Rejected enrollment for user {UserId}: the key statement self-signature did not verify.")]
+ internal static partial void StatementSignatureRejected(ILogger logger, Guid userId);
+}
diff --git a/src/DodoSSH.Api/Features/Identity/IdentityService.cs b/src/DodoSSH.Api/Features/Identity/IdentityService.cs
new file mode 100644
index 0000000..b6951ea
--- /dev/null
+++ b/src/DodoSSH.Api/Features/Identity/IdentityService.cs
@@ -0,0 +1,134 @@
+using DodoSSH.Api.Authorization;
+using DodoSSH.Contracts;
+using DodoSSH.Domain;
+using DodoSSH.Infrastructure;
+using Microsoft.EntityFrameworkCore;
+
+namespace DodoSSH.Api.Features.Identity;
+
+///
+/// Assembles the caller's own profile and unlock state.
+///
+///
+/// This is the first authenticated call a client makes and the only one that works before
+/// enrollment, so it has to answer "what do I do next" unambiguously: either enroll, or unlock with
+/// these parameters and open these vaults.
+///
+internal sealed class IdentityService(DodoDbContext database, IVaultAccessService vaultAccess)
+{
+ /// Builds the caller's profile.
+ internal async Task GetMeAsync(UserAccount user, CancellationToken cancellationToken)
+ {
+ var key = await database.UserKeys
+ .SingleOrDefaultAsync(k => k.UserId == user.Id && k.IsCurrent, cancellationToken)
+ .ConfigureAwait(false);
+
+ var wrap = key is null
+ ? null
+ : await database.UserKeyWraps
+ .SingleOrDefaultAsync(
+ w => w.UserId == user.Id && w.Kind == UserKeyWrapKind.Passphrase,
+ cancellationToken)
+ .ConfigureAwait(false);
+
+ var vaults = await BuildVaultsAsync(user, key, cancellationToken).ConfigureAwait(false);
+
+ return new MeResponse(
+ UserId: user.Id,
+ Issuer: user.Issuer,
+ Subject: user.Subject,
+ Email: user.Email,
+ DisplayName: user.DisplayName,
+
+ // The single flag the client branches on at startup.
+ EnrollmentRequired: key is null,
+
+ KeyGeneration: key?.Generation,
+
+ // Both are needed to unlock, and both are useless to the server. Returning them
+ // together is what lets a client cache them and unlock offline later: the KDF salt must
+ // never be something it has to fetch at unlock time.
+ WrappedPrivateKey: wrap?.Wrap,
+ KdfParameters: ToContract(wrap),
+
+ Vaults: vaults);
+ }
+
+ private async Task> BuildVaultsAsync(
+ UserAccount user,
+ UserKey? key,
+ CancellationToken cancellationToken)
+ {
+ var accessible = await vaultAccess.ListAsync(user.Id, cancellationToken).ConfigureAwait(false);
+ if (accessible.Count == 0)
+ {
+ return [];
+ }
+
+ var vaultIds = accessible.Select(a => a.Vault!.Id).ToArray();
+
+ var grants = await database.VaultKeyGrants
+ .Where(g => vaultIds.Contains(g.VaultId)
+ && g.RecipientUserId == user.Id
+ && g.State == GrantState.Active
+ && g.RevokedAtUtc == null)
+ .ToListAsync(cancellationToken)
+ .ConfigureAwait(false);
+
+ var summaries = new List(accessible.Count);
+
+ foreach (var access in accessible)
+ {
+ var vault = access.Vault!;
+
+ // The grant must match both the current key generation and the exact identity key it
+ // was wrapped to. A grant left over from a superseded key is not merely stale — the
+ // client's current private key cannot open it, so offering it would produce a tag
+ // failure the user reads as data corruption.
+ var grant = grants.Find(g =>
+ g.VaultId == vault.Id
+ && g.KeyGeneration == vault.KeyGeneration
+ && key is not null
+ && g.RecipientKeyFingerprint.AsSpan().SequenceEqual(key.FingerprintSha256));
+
+ summaries.Add(new VaultSummary(
+ VaultId: vault.Id,
+ Name: vault.Name,
+ IsPersonal: vault.OwnerKind == VaultOwnerKind.Personal,
+ TeamId: vault.TeamId,
+ KeyGeneration: (uint)vault.KeyGeneration,
+ Permissions: (int)access.Permissions,
+
+ // Null means temporarily unreadable, not forbidden. A member holding Share must
+ // re-wrap it; the client has to say so rather than showing an empty vault.
+ WrappedVaultKey: grant?.WrappedKey,
+
+ RekeyRequired: vault.RekeyRequired));
+ }
+
+ return summaries;
+ }
+
+ private static KdfParameters? ToContract(UserKeyWrap? wrap)
+ {
+ // The CHECK constraint on user_key_wrap guarantees a passphrase wrap carries every
+ // parameter, so a partial row cannot exist. Pattern-matched anyway rather than asserted,
+ // because a null here would become an unopenable vault rather than an exception.
+ if (wrap is null
+ || wrap.KdfAlgorithm is null
+ || wrap.KdfSalt is null
+ || wrap.KdfMemoryKibibytes is null
+ || wrap.KdfPasses is null
+ || wrap.KdfParallelism is null)
+ {
+ return null;
+ }
+
+ return new KdfParameters(
+ wrap.KdfAlgorithm,
+ wrap.KdfSalt,
+ wrap.KdfMemoryKibibytes.Value,
+ wrap.KdfPasses.Value,
+ wrap.KdfParallelism.Value);
+ }
+}
diff --git a/src/DodoSSH.Api/Features/Sync/SyncEndpoints.cs b/src/DodoSSH.Api/Features/Sync/SyncEndpoints.cs
index 878bfc7..837a2f3 100644
--- a/src/DodoSSH.Api/Features/Sync/SyncEndpoints.cs
+++ b/src/DodoSSH.Api/Features/Sync/SyncEndpoints.cs
@@ -18,8 +18,11 @@ internal static class SyncEndpoints
{
internal static IEndpointRouteBuilder MapSyncEndpoints(this IEndpointRouteBuilder app)
{
+ // Enrolled, not merely authenticated. A caller with no identity key holds no vault key
+ // either, so it can neither produce ciphertext anyone can read nor read what is there.
+ // Serving it would look like corruption; refusing with a code it can act on does not.
var group = app.MapGroup("/api/v1/vaults/{vaultId:guid}/sync")
- .RequireAuthorization(Auth.AuthenticatedPolicy)
+ .RequireAuthorization(Auth.EnrolledPolicy)
.WithTags("Sync");
// POST rather than GET: the filters live in the body, cursors are opaque, and no caching is
diff --git a/src/DodoSSH.Api/Program.cs b/src/DodoSSH.Api/Program.cs
index f877ce9..4466112 100644
--- a/src/DodoSSH.Api/Program.cs
+++ b/src/DodoSSH.Api/Program.cs
@@ -1,6 +1,9 @@
using DodoSSH.Api.Authorization;
+using DodoSSH.Api.Features.Identity;
using DodoSSH.Api.Features.Sync;
using DodoSSH.Api.Setup;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Authorization.Policy;
var builder = WebApplication.CreateBuilder(args);
@@ -22,7 +25,18 @@ builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
+builder.Services.AddScoped();
+builder.Services.AddScoped();
+builder.Services.AddScoped();
builder.Services.AddSingleton();
+
+// Scoped rather than the AddAuthorization default of singleton: the handler reads the request's
+// DbContext, and a singleton would capture one for the lifetime of the process.
+builder.Services.AddScoped();
+
+// Replaces the framework default, which is also registered as a singleton. Last registration wins.
+builder.Services.AddSingleton();
+
builder.Services.AddProblemDetails();
var app = builder.Build();
diff --git a/src/DodoSSH.Api/Setup/Auth.cs b/src/DodoSSH.Api/Setup/Auth.cs
index 1c63bb5..2bbc978 100644
--- a/src/DodoSSH.Api/Setup/Auth.cs
+++ b/src/DodoSSH.Api/Setup/Auth.cs
@@ -58,10 +58,15 @@ internal static class Auth
{
options.AddPolicy(AuthenticatedPolicy, policy => policy.RequireAuthenticatedUser());
- // Enrollment state lives in the database, so the real handler arrives with the
- // enrollment feature. Registered now so endpoint groups can reference the policy name
- // and the endpoint-inventory test has something to assert against.
- options.AddPolicy(EnrolledPolicy, policy => policy.RequireAuthenticatedUser());
+ // Enrollment state lives in the database, so this is satisfied by EnrolledHandler.
+ // An unmet EnrolledRequirement is rewritten into a ProblemDetails carrying
+ // "enrollment-required" by DodoAuthorizationResultHandler, because an empty 403 cannot
+ // tell a client whether the problem is theirs to fix.
+ options.AddPolicy(
+ EnrolledPolicy,
+ policy => policy
+ .RequireAuthenticatedUser()
+ .AddRequirements(new Authorization.EnrolledRequirement()));
// Deny by default: an endpoint without an explicit policy still requires a caller.
options.FallbackPolicy = options.GetPolicy(AuthenticatedPolicy);
diff --git a/src/DodoSSH.Api/Setup/EndpointRegistration.cs b/src/DodoSSH.Api/Setup/EndpointRegistration.cs
index 3d2fa4e..39101d0 100644
--- a/src/DodoSSH.Api/Setup/EndpointRegistration.cs
+++ b/src/DodoSSH.Api/Setup/EndpointRegistration.cs
@@ -1,3 +1,4 @@
+using DodoSSH.Api.Features.Identity;
using DodoSSH.Api.Features.Meta;
using DodoSSH.Api.Features.Sync;
@@ -17,10 +18,11 @@ internal static class EndpointRegistration
internal static WebApplication MapDodoEndpoints(this WebApplication app)
{
app.MapMetaEndpoints();
+ app.MapIdentityEndpoints();
app.MapSyncEndpoints();
// Registered as each feature lands:
- // Identity — /me, enrollment, key rotation, devices
+ // Identity — key rotation, devices, passphrase change
// Directory — public-key lookup
// Vaults — grants, rekey, ACL
// Relay — tickets and the WebSocket
diff --git a/src/DodoSSH.Contracts/DodoSshJsonContext.cs b/src/DodoSSH.Contracts/DodoSshJsonContext.cs
index 8624b39..01fb835 100644
--- a/src/DodoSSH.Contracts/DodoSshJsonContext.cs
+++ b/src/DodoSSH.Contracts/DodoSshJsonContext.cs
@@ -30,6 +30,10 @@ namespace DodoSSH.Contracts;
[JsonSerializable(typeof(DodoSshConfiguration))]
[JsonSerializable(typeof(MeResponse))]
[JsonSerializable(typeof(EnrollmentRequest))]
+
+// Explicit, although it is reachable through EnrollmentRequest. The server serialises a statement
+// on its own when persisting it, and a resolver that had only inferred it would fail at runtime.
+[JsonSerializable(typeof(KeyStatement))]
[JsonSerializable(typeof(EnrollmentResponse))]
[JsonSerializable(typeof(DirectoryEntry))]
[JsonSerializable(typeof(IReadOnlyList))]
diff --git a/src/DodoSSH.Contracts/Enrollment.cs b/src/DodoSSH.Contracts/Enrollment.cs
index fecf5bc..659e921 100644
--- a/src/DodoSSH.Contracts/Enrollment.cs
+++ b/src/DodoSSH.Contracts/Enrollment.cs
@@ -53,6 +53,40 @@ public sealed record KeyStatement(
DateTimeOffset CreatedAt,
string DeviceName);
+///
+/// The personal vault a client creates as part of enrolling.
+///
+///
+///
+/// The vault key is generated on the client and sealed to the user's own X25519 key, so the
+/// server cannot produce this and cannot verify that contains
+/// anything in particular. It stores the bytes and the signature that attributes them.
+///
+///
+/// is chosen by the client, not the server. That is what makes enrollment
+/// safely retryable: a client whose request timed out re-sends the identical body and gets the
+/// identical result, instead of accumulating a second vault it never learns about. It is also
+/// required by the grant signature, which covers the vault id — see docs/crypto.md §7.
+///
+///
+/// Client-generated UUIDv7 for the new vault.
+/// Display name. Plaintext, as all vault names are.
+///
+/// The vault key sealed to the enrolling user's own encryption key. Opaque to the server.
+///
+///
+/// Ed25519 signature over the canonical grant tuple, by the key being enrolled. A self-grant
+/// carries no key log head, because there is no third party whose key could have been
+/// substituted.
+///
+/// Signing timestamp, part of the signed tuple.
+public sealed record PersonalVaultRequest(
+ Guid VaultId,
+ string Name,
+ byte[] WrappedVaultKey,
+ byte[] GrantSignature,
+ DateTimeOffset GrantedAt);
+
/// A request to enroll a user's first identity key pair.
/// The key statement.
/// Ed25519 self-signature over the statement.
@@ -68,10 +102,18 @@ public sealed record KeyStatement(
/// X25519 public key of this device, so the bundle can also be wrapped to the device and
/// unlocked without re-entering the passphrase.
///
+///
+/// The same bundle sealed to . Required whenever a device key
+/// is supplied: only the holder of the bundle can produce this, so a device key without its wrap
+/// registers a device that can never unlock anything.
+///
///
/// The same bundle sealed under a recovery-code-derived key.
///
/// Parameters for the recovery wrap.
+///
+/// The personal vault to create, with its key already wrapped to the enrolling user.
+///
public sealed record EnrollmentRequest(
KeyStatement Statement,
byte[] StatementSignature,
@@ -79,8 +121,10 @@ public sealed record EnrollmentRequest(
byte[] WrappedPrivateKey,
KdfParameters KdfParameters,
byte[]? DevicePublicKey,
+ byte[]? DeviceWrappedPrivateKey,
byte[]? RecoveryWrappedPrivateKey,
- KdfParameters? RecoveryKdfParameters);
+ KdfParameters? RecoveryKdfParameters,
+ PersonalVaultRequest PersonalVault);
/// Result of a successful enrollment.
/// The user's identifier.
diff --git a/src/DodoSSH.Contracts/ProblemCodes.cs b/src/DodoSSH.Contracts/ProblemCodes.cs
index 35d580f..fdb2f18 100644
--- a/src/DodoSSH.Contracts/ProblemCodes.cs
+++ b/src/DodoSSH.Contracts/ProblemCodes.cs
@@ -28,9 +28,21 @@ public static class ProblemCodes
/// The caller has not yet enrolled a public key, so no vault is reachable.
public const string EnrollmentRequired = "enrollment-required";
- /// Enrollment was attempted for a user who already holds a current key.
+ /// Enrollment was attempted for a user who already holds a different current key.
public const string AlreadyEnrolled = "already-enrolled";
+ ///
+ /// The enrollment request was structurally invalid: a bad key length, mismatched KDF
+ /// parameters, a statement that does not describe the caller, or a vault id already in use.
+ ///
+ public const string InvalidEnrollment = "invalid-enrollment";
+
+ ///
+ /// The identity-provider token did not bind the supplied keys: a bad signature, the wrong
+ /// subject or audience, an expired token, or a nonce that is not the statement's hash.
+ ///
+ public const string IdentityBindingInvalid = "identity-binding-invalid";
+
/// The relay refused the requested target. Never states why, to avoid a probe oracle.
public const string RelayTargetRejected = "relay-target-rejected";
diff --git a/src/DodoSSH.Contracts/PublicAPI.Unshipped.txt b/src/DodoSSH.Contracts/PublicAPI.Unshipped.txt
index 695cce1..cd09859 100644
--- a/src/DodoSSH.Contracts/PublicAPI.Unshipped.txt
+++ b/src/DodoSSH.Contracts/PublicAPI.Unshipped.txt
@@ -45,15 +45,19 @@ DodoSSH.Contracts.EncryptedPayload.KeyGeneration.get -> uint
DodoSSH.Contracts.EncryptedPayload.KeyGeneration.init -> void
DodoSSH.Contracts.EnrollmentRequest
DodoSSH.Contracts.EnrollmentRequest.$() -> DodoSSH.Contracts.EnrollmentRequest!
-DodoSSH.Contracts.EnrollmentRequest.Deconstruct(out DodoSSH.Contracts.KeyStatement! Statement, out byte[]! StatementSignature, out string! IdentityProviderToken, out byte[]! WrappedPrivateKey, out DodoSSH.Contracts.KdfParameters! KdfParameters, out byte[]? DevicePublicKey, out byte[]? RecoveryWrappedPrivateKey, out DodoSSH.Contracts.KdfParameters? RecoveryKdfParameters) -> void
+DodoSSH.Contracts.EnrollmentRequest.Deconstruct(out DodoSSH.Contracts.KeyStatement! Statement, out byte[]! StatementSignature, out string! IdentityProviderToken, out byte[]! WrappedPrivateKey, out DodoSSH.Contracts.KdfParameters! KdfParameters, out byte[]? DevicePublicKey, out byte[]? DeviceWrappedPrivateKey, out byte[]? RecoveryWrappedPrivateKey, out DodoSSH.Contracts.KdfParameters? RecoveryKdfParameters, out DodoSSH.Contracts.PersonalVaultRequest! PersonalVault) -> void
DodoSSH.Contracts.EnrollmentRequest.DevicePublicKey.get -> byte[]?
DodoSSH.Contracts.EnrollmentRequest.DevicePublicKey.init -> void
-DodoSSH.Contracts.EnrollmentRequest.EnrollmentRequest(DodoSSH.Contracts.KeyStatement! Statement, byte[]! StatementSignature, string! IdentityProviderToken, byte[]! WrappedPrivateKey, DodoSSH.Contracts.KdfParameters! KdfParameters, byte[]? DevicePublicKey, byte[]? RecoveryWrappedPrivateKey, DodoSSH.Contracts.KdfParameters? RecoveryKdfParameters) -> void
+DodoSSH.Contracts.EnrollmentRequest.DeviceWrappedPrivateKey.get -> byte[]?
+DodoSSH.Contracts.EnrollmentRequest.DeviceWrappedPrivateKey.init -> void
+DodoSSH.Contracts.EnrollmentRequest.EnrollmentRequest(DodoSSH.Contracts.KeyStatement! Statement, byte[]! StatementSignature, string! IdentityProviderToken, byte[]! WrappedPrivateKey, DodoSSH.Contracts.KdfParameters! KdfParameters, byte[]? DevicePublicKey, byte[]? DeviceWrappedPrivateKey, byte[]? RecoveryWrappedPrivateKey, DodoSSH.Contracts.KdfParameters? RecoveryKdfParameters, DodoSSH.Contracts.PersonalVaultRequest! PersonalVault) -> void
DodoSSH.Contracts.EnrollmentRequest.Equals(DodoSSH.Contracts.EnrollmentRequest? other) -> bool
DodoSSH.Contracts.EnrollmentRequest.IdentityProviderToken.get -> string!
DodoSSH.Contracts.EnrollmentRequest.IdentityProviderToken.init -> void
DodoSSH.Contracts.EnrollmentRequest.KdfParameters.get -> DodoSSH.Contracts.KdfParameters!
DodoSSH.Contracts.EnrollmentRequest.KdfParameters.init -> void
+DodoSSH.Contracts.EnrollmentRequest.PersonalVault.get -> DodoSSH.Contracts.PersonalVaultRequest!
+DodoSSH.Contracts.EnrollmentRequest.PersonalVault.init -> void
DodoSSH.Contracts.EnrollmentRequest.RecoveryKdfParameters.get -> DodoSSH.Contracts.KdfParameters?
DodoSSH.Contracts.EnrollmentRequest.RecoveryKdfParameters.init -> void
DodoSSH.Contracts.EnrollmentRequest.RecoveryWrappedPrivateKey.get -> byte[]?
@@ -180,6 +184,21 @@ DodoSSH.Contracts.OidcConfiguration.LoopbackRedirectPattern.init -> void
DodoSSH.Contracts.OidcConfiguration.OidcConfiguration(System.Uri! Authority, string! ClientId, System.Collections.Generic.IReadOnlyList! Scopes, string! LoopbackRedirectPattern) -> void
DodoSSH.Contracts.OidcConfiguration.Scopes.get -> System.Collections.Generic.IReadOnlyList!
DodoSSH.Contracts.OidcConfiguration.Scopes.init -> void
+DodoSSH.Contracts.PersonalVaultRequest
+DodoSSH.Contracts.PersonalVaultRequest.$() -> DodoSSH.Contracts.PersonalVaultRequest!
+DodoSSH.Contracts.PersonalVaultRequest.Deconstruct(out System.Guid VaultId, out string! Name, out byte[]! WrappedVaultKey, out byte[]! GrantSignature, out System.DateTimeOffset GrantedAt) -> void
+DodoSSH.Contracts.PersonalVaultRequest.Equals(DodoSSH.Contracts.PersonalVaultRequest? other) -> bool
+DodoSSH.Contracts.PersonalVaultRequest.GrantSignature.get -> byte[]!
+DodoSSH.Contracts.PersonalVaultRequest.GrantSignature.init -> void
+DodoSSH.Contracts.PersonalVaultRequest.GrantedAt.get -> System.DateTimeOffset
+DodoSSH.Contracts.PersonalVaultRequest.GrantedAt.init -> void
+DodoSSH.Contracts.PersonalVaultRequest.Name.get -> string!
+DodoSSH.Contracts.PersonalVaultRequest.Name.init -> void
+DodoSSH.Contracts.PersonalVaultRequest.PersonalVaultRequest(System.Guid VaultId, string! Name, byte[]! WrappedVaultKey, byte[]! GrantSignature, System.DateTimeOffset GrantedAt) -> void
+DodoSSH.Contracts.PersonalVaultRequest.VaultId.get -> System.Guid
+DodoSSH.Contracts.PersonalVaultRequest.VaultId.init -> void
+DodoSSH.Contracts.PersonalVaultRequest.WrappedVaultKey.get -> byte[]!
+DodoSSH.Contracts.PersonalVaultRequest.WrappedVaultKey.init -> void
DodoSSH.Contracts.ProblemCodes
DodoSSH.Contracts.RelayConfiguration
DodoSSH.Contracts.RelayConfiguration.$() -> DodoSSH.Contracts.RelayConfiguration!
@@ -415,7 +434,9 @@ const DodoSSH.Contracts.ProblemCodes.ClientTooOld = "client-too-old" -> string!
const DodoSSH.Contracts.ProblemCodes.EnrollmentRequired = "enrollment-required" -> string!
const DodoSSH.Contracts.ProblemCodes.Forbidden = "forbidden" -> string!
const DodoSSH.Contracts.ProblemCodes.IdempotencyKeyReuse = "idempotency-key-reuse" -> string!
+const DodoSSH.Contracts.ProblemCodes.IdentityBindingInvalid = "identity-binding-invalid" -> string!
const DodoSSH.Contracts.ProblemCodes.InvalidCursor = "invalid-cursor" -> string!
+const DodoSSH.Contracts.ProblemCodes.InvalidEnrollment = "invalid-enrollment" -> string!
const DodoSSH.Contracts.ProblemCodes.PushBatchTooLarge = "push-batch-too-large" -> string!
const DodoSSH.Contracts.ProblemCodes.RelayLimitReached = "relay-limit-reached" -> string!
const DodoSSH.Contracts.ProblemCodes.RelayTargetRejected = "relay-target-rejected" -> string!
@@ -452,6 +473,9 @@ override DodoSSH.Contracts.MetaResponse.ToString() -> string!
override DodoSSH.Contracts.OidcConfiguration.Equals(object? obj) -> bool
override DodoSSH.Contracts.OidcConfiguration.GetHashCode() -> int
override DodoSSH.Contracts.OidcConfiguration.ToString() -> string!
+override DodoSSH.Contracts.PersonalVaultRequest.Equals(object? obj) -> bool
+override DodoSSH.Contracts.PersonalVaultRequest.GetHashCode() -> int
+override DodoSSH.Contracts.PersonalVaultRequest.ToString() -> string!
override DodoSSH.Contracts.RelayConfiguration.Equals(object? obj) -> bool
override DodoSSH.Contracts.RelayConfiguration.GetHashCode() -> int
override DodoSSH.Contracts.RelayConfiguration.ToString() -> string!
@@ -513,6 +537,8 @@ static DodoSSH.Contracts.MetaResponse.operator !=(DodoSSH.Contracts.MetaResponse
static DodoSSH.Contracts.MetaResponse.operator ==(DodoSSH.Contracts.MetaResponse? left, DodoSSH.Contracts.MetaResponse? right) -> bool
static DodoSSH.Contracts.OidcConfiguration.operator !=(DodoSSH.Contracts.OidcConfiguration? left, DodoSSH.Contracts.OidcConfiguration? right) -> bool
static DodoSSH.Contracts.OidcConfiguration.operator ==(DodoSSH.Contracts.OidcConfiguration? left, DodoSSH.Contracts.OidcConfiguration? right) -> bool
+static DodoSSH.Contracts.PersonalVaultRequest.operator !=(DodoSSH.Contracts.PersonalVaultRequest? left, DodoSSH.Contracts.PersonalVaultRequest? right) -> bool
+static DodoSSH.Contracts.PersonalVaultRequest.operator ==(DodoSSH.Contracts.PersonalVaultRequest? left, DodoSSH.Contracts.PersonalVaultRequest? right) -> bool
static DodoSSH.Contracts.RelayConfiguration.operator !=(DodoSSH.Contracts.RelayConfiguration? left, DodoSSH.Contracts.RelayConfiguration? right) -> bool
static DodoSSH.Contracts.RelayConfiguration.operator ==(DodoSSH.Contracts.RelayConfiguration? left, DodoSSH.Contracts.RelayConfiguration? right) -> bool
static DodoSSH.Contracts.RelaySessionSummary.operator !=(DodoSSH.Contracts.RelaySessionSummary? left, DodoSSH.Contracts.RelaySessionSummary? right) -> bool
diff --git a/tests/DodoSSH.Api.Tests/IdentityEndpointTests.cs b/tests/DodoSSH.Api.Tests/IdentityEndpointTests.cs
new file mode 100644
index 0000000..181b6eb
--- /dev/null
+++ b/tests/DodoSSH.Api.Tests/IdentityEndpointTests.cs
@@ -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;
+
+///
+/// Just-in-time provisioning, /me, and enrollment end to end over HTTP.
+///
+///
+/// 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.
+///
+[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();
+
+ 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();
+
+ (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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+ 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();
+
+ 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();
+
+ (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();
+
+ // 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(responses.Length);
+ foreach (var response in responses)
+ {
+ response.EnsureSuccessStatusCode();
+
+ var body = await response.Content.ReadFromJsonAsync();
+ 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();
+
+ (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();
+
+ 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 ReadMeAsync(HttpClient client)
+ {
+ var response = await client.GetAsync(new Uri(MeUrl, UriKind.Relative));
+ response.EnsureSuccessStatusCode();
+
+ var me = await response.Content.ReadFromJsonAsync();
+ me.ShouldNotBeNull();
+ return me;
+ }
+
+ private static async Task EnrollAsync(
+ HttpClient client,
+ EnrollmentRequest request)
+ {
+ var response = await client.PostAsJsonAsync(EnrollUrl, request);
+ response.EnsureSuccessStatusCode();
+
+ var body = await response.Content.ReadFromJsonAsync();
+ 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();
+ 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);
+}
diff --git a/tests/DodoSSH.Api.Tests/JsonProblem.cs b/tests/DodoSSH.Api.Tests/JsonProblem.cs
new file mode 100644
index 0000000..9b99c11
--- /dev/null
+++ b/tests/DodoSSH.Api.Tests/JsonProblem.cs
@@ -0,0 +1,11 @@
+namespace DodoSSH.Api.Tests;
+
+///
+/// Minimal RFC 9457 shape, for asserting on the stable code extension.
+///
+///
+/// Tests assert on rather than on . 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.
+///
+internal sealed record JsonProblem(string? Type, string? Detail, string? Code);
diff --git a/tests/DodoSSH.Api.Tests/Seed.cs b/tests/DodoSSH.Api.Tests/Seed.cs
new file mode 100644
index 0000000..0803197
--- /dev/null
+++ b/tests/DodoSSH.Api.Tests/Seed.cs
@@ -0,0 +1,35 @@
+using System.Security.Cryptography;
+using DodoSSH.Domain;
+
+namespace DodoSSH.Api.Tests;
+
+/// Directly-inserted rows, for tests whose subject is not enrollment itself.
+///
+/// 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.
+///
+internal static class Seed
+{
+ ///
+ /// A minimal current identity key, enough to satisfy the enrolled policy.
+ ///
+ ///
+ /// The keys and fingerprint are random because user_key has a global unique index on the
+ /// fingerprint: fixed bytes would make the second seeded user in the shared database collide.
+ ///
+ 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,
+ };
+}
diff --git a/tests/DodoSSH.Api.Tests/StubIdentityProvider.cs b/tests/DodoSSH.Api.Tests/StubIdentityProvider.cs
index 04ef1b6..b81edce 100644
--- a/tests/DodoSSH.Api.Tests/StubIdentityProvider.cs
+++ b/tests/DodoSSH.Api.Tests/StubIdentityProvider.cs
@@ -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
/// Issuer URL, matching what the tokens claim.
public string Authority { get; }
- /// Audience the API is configured to expect.
+ /// Audience the API is configured to expect on access tokens.
public static string Audience => "dodossh-api";
+ ///
+ /// The public client identifier, and therefore the audience of an ID token.
+ ///
+ ///
+ /// Distinct from 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.
+ ///
+ public static string ClientId => "dodossh-desktop";
+
///
/// Mints a signed access token.
///
@@ -94,6 +105,89 @@ public sealed class StubIdentityProvider : IDisposable
return new JwtSecurityTokenHandler().WriteToken(token);
}
+ ///
+ /// Mints an ID token carrying a key-binding nonce.
+ ///
+ ///
+ /// 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.
+ ///
+ /// The sub claim.
+ /// The key statement binding, from KeyStatementCodec.ComputeNonce.
+ /// Optional email claim.
+ /// Override the audience, to test rejection. Defaults to the client id.
+ /// Override the issuer, to test rejection.
+ /// Override expiry, to test rejection.
+ /// Omit the nonce entirely, to test rejection.
+ 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
+ {
+ 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);
+ }
+
+ /// Mints an ID token signed by a key the provider does not publish.
+ 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);
+ }
+
/// Mints a token signed by a different key, which must be rejected.
public string MintTokenWithForeignKey(string subject)
{
diff --git a/tests/DodoSSH.Api.Tests/SyncEndpointTests.cs b/tests/DodoSSH.Api.Tests/SyncEndpointTests.cs
index 837b221..645eeb0 100644
--- a/tests/DodoSSH.Api.Tests/SyncEndpointTests.cs
+++ b/tests/DodoSSH.Api.Tests/SyncEndpointTests.cs
@@ -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();
+ 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();
+ (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();
-
- 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();
-
- (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();
- 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);
}
+ ///
+ /// An enrolled user with no vault of their own: the realistic intruder.
+ ///
+ ///
+ /// 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.
+ ///
+ private async Task SeedEnrolledUserAsync()
+ {
+ var subject = NewSubject();
+
+ await using var scope = fixture.CreateScope();
+ var database = scope.ServiceProvider.GetRequiredService();
+
+ 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 SeedTeamVaultAsync()
{
await using var scope = fixture.CreateScope();
var database = scope.ServiceProvider.GetRequiredService();
- 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;
}
-
- /// Minimal ProblemDetails shape, for asserting on the code extension.
- private sealed record JsonProblem(string? Type, string? Detail, string? Code);
}
diff --git a/tests/DodoSSH.Api.Tests/TestEnrollment.cs b/tests/DodoSSH.Api.Tests/TestEnrollment.cs
new file mode 100644
index 0000000..7f25eb8
--- /dev/null
+++ b/tests/DodoSSH.Api.Tests/TestEnrollment.cs
@@ -0,0 +1,153 @@
+using DodoSSH.Contracts;
+using DodoSSH.Crypto;
+using NSec.Cryptography;
+
+namespace DodoSSH.Api.Tests;
+
+///
+/// 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.
+///
+///
+/// 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.
+///
+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");
+ }
+
+ /// The OIDC subject this enrollment is for.
+ internal string Subject { get; }
+
+ /// The client-chosen personal vault id.
+ internal Guid VaultId { get; } = Guid.CreateVersion7();
+
+ /// The default, well-formed statement.
+ internal KeyStatement Statement { get; }
+
+ /// The identity fingerprint the server should compute.
+ internal byte[] Fingerprint => DshCrypto.ComputeFingerprint(
+ Statement.EncryptionPublicKey,
+ Statement.SigningPublicKey);
+
+ /// Signs a statement with this enrollment's Ed25519 key.
+ internal byte[] Sign(KeyStatement statement) =>
+ DshSignatures.SignKeyStatement(signingKey, KeyStatementCodec.Encode(ToFields(statement)));
+
+ /// Mints an ID token whose nonce is the given statement's binding.
+ 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);
+
+ /// Builds a complete, valid request, with every part overridable for negative tests.
+ 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());
+ }
+
+ /// The passphrase KDF profile, matching Argon2Profile.PassphraseDefault.
+ internal static KdfParameters DefaultKdf() =>
+ new("argon2id", Bytes(16, 0x55), MemoryKibibytes: 256 * 1024, Passes: 4, Parallelism: 1);
+
+ /// The recovery KDF profile. Cheaper, because a recovery code carries real entropy.
+ internal static KdfParameters RecoveryKdf() =>
+ new("argon2id", Bytes(16, 0x66), MemoryKibibytes: 64 * 1024, Passes: 3, Parallelism: 1);
+
+ ///
+ /// 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.
+ ///
+ 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);
+
+ /// Bearer client for this enrollment's subject.
+ internal HttpClient CreateClient(ApiFixture fixture) => fixture.CreateClientFor(Subject);
+
+ ///
+ public void Dispose()
+ {
+ encryptionKey.Dispose();
+ signingKey.Dispose();
+ }
+
+ private static byte[] Bytes(int length, byte seed) =>
+ [.. Enumerable.Range(0, length).Select(i => (byte)(seed + i))];
+
+ ///
+ /// 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.
+ ///
+ 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);
+}