Public Access
Add /me and enrollment with the identity-provider key binding (M1)
The last backend piece of M1. A client can now log in, discover it must enroll, publish its identity key, and get a usable personal vault. Enrollment is one indivisible act. One transaction writes the key, its wraps, the device, the key log entry, the vault and the vault key grant, because none of them is useful alone: a key with no vault leaves a user unable to store anything, and a vault with no grant is a container nobody can ever open -- including its owner, since only the client can wrap the key and it has already moved on. Two independent checks run, and neither substitutes for the other. The Ed25519 self-signature proves possession of the private key. The identity-provider binding proves whose key it is: the client hashed its statement, used the hash as an OIDC nonce, and the resulting ID token is the provider's signature over exactly those public keys. This server cannot mint that signature, so it cannot invent a key for a user who never enrolled -- which is the attack that would otherwise let an operator read every vault by publishing its own key as yours. The binding token is stored verbatim, not just summarised. Clients must repeat the check against the provider's JWKS fetched directly, and storing only our conclusion would ask them to trust the server about the one question the design exists to avoid trusting it about. Key log appends take a deployment-wide advisory lock. The falsification matters more than the passing test: with the lock removed, Enroll_ConcurrentEnrollmentsByDifferentUsers_LeaveAnUnbrokenChain fails with entry 11 linked to the wrong predecessor. Different users trip no unique index, so without serialising they all read the same head and the chain forks -- indistinguishable from the key substitution the log exists to make detectable, and permanent, because the log is append-only. Enrollment is idempotent. Vault ids and keys are client-chosen, so a client whose response was lost re-sends the identical body and gets the identical result. Without that, a lost response leaves a user enrolled against a vault they never learned the id of. Contract change, breaking the v0.1 freeze deliberately. EnrollmentRequest had DevicePublicKey but no wrap to go with it, which is unsatisfiable: only the holder of the secret bundle can seal it, so the server could never fill the gap. Added DeviceWrappedPrivateKey, and PersonalVault so enrollment can be atomic rather than leaving an unopenable vault behind two endpoints that do not exist yet. No client exists and no package is published, which is exactly when PublicAPI.Unshipped.txt expects this. Sync now requires the Enrolled policy, which until now was a stub whose name promised a check it never made. The sync denial tests use enrolled intruders instead of unenrolled ones -- an unenrolled caller is stopped before the vault check runs, which would have left those tests passing without exercising the thing they exist to prove. Also fixed: omitting kdfParameters from the JSON body was a 500. A record's non-nullable parameters are a compile-time promise, not a runtime one. 268 tests pass, zero warnings on a clean rebuild, format clean.
This commit is contained in:
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Requires that the caller has published an identity key.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
internal sealed class EnrolledRequirement : IAuthorizationRequirement;
|
||||
|
||||
/// <summary>Checks enrollment state against the database.</summary>
|
||||
/// <remarks>
|
||||
/// Scoped, not singleton, because it uses the request's <see cref="DodoDbContext"/>. Registering it
|
||||
/// with the default singleton lifetime would capture one context for the process.
|
||||
/// </remarks>
|
||||
internal sealed class EnrolledHandler(
|
||||
ICurrentUserContext currentUser,
|
||||
DodoDbContext database,
|
||||
IHttpContextAccessor accessor)
|
||||
: AuthorizationHandler<EnrolledRequirement>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Turns an unmet <see cref="EnrolledRequirement"/> into a ProblemDetails response carrying
|
||||
/// <see cref="ProblemCodes.EnrollmentRequired"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
internal sealed class DodoAuthorizationResultHandler : IAuthorizationMiddlewareResultHandler
|
||||
{
|
||||
private readonly AuthorizationMiddlewareResultHandler defaultHandler = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
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<EnrolledRequirement>().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<string, object?>(StringComparer.Ordinal)
|
||||
{
|
||||
["code"] = ProblemCodes.EnrollmentRequired,
|
||||
})
|
||||
.ExecuteAsync(context)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,16 @@ public interface IVaultAccessService
|
||||
/// answer is an existence oracle that lets a caller enumerate other tenants' vault ids.
|
||||
/// </remarks>
|
||||
Task<VaultAccess> ResolveAsync(Guid userId, Guid vaultId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Lists every vault the caller can reach, with their effective permissions.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Shares the permission rules with <see cref="ResolveAsync"/> 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.
|
||||
/// </remarks>
|
||||
Task<IReadOnlyList<VaultAccess>> ListAsync(Guid userId, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -79,4 +89,22 @@ internal sealed class VaultAccessService(DodoDbContext database) : IVaultAccessS
|
||||
// correct behaviour in the meantime.
|
||||
return VaultAccess.Denied;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<VaultAccess>> 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))];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace DodoSSH.Api.Features.Identity;
|
||||
|
||||
/// <summary>
|
||||
/// The enrollment request was structurally unacceptable.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The message is returned to the caller. Keep it about the shape of their own request and never
|
||||
/// about other accounts or stored state.
|
||||
/// </remarks>
|
||||
internal sealed class EnrollmentInvalidException(string message) : Exception(message);
|
||||
|
||||
/// <summary>The identity-provider token did not bind the supplied keys.</summary>
|
||||
internal sealed class IdentityBindingInvalidException(string message) : Exception(message);
|
||||
|
||||
/// <summary>
|
||||
/// The caller already holds a current identity key that is not the one being enrolled.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Re-sending an identical enrollment is <em>not</em> 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.
|
||||
/// </remarks>
|
||||
internal sealed class AlreadyEnrolledException(string message) : Exception(message);
|
||||
@@ -0,0 +1,74 @@
|
||||
namespace DodoSSH.Api.Features.Identity;
|
||||
|
||||
/// <summary>
|
||||
/// Bounds the server enforces on an enrollment request.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
internal static class EnrollmentLimits
|
||||
{
|
||||
/// <summary>The only KDF this version accepts.</summary>
|
||||
internal const string KdfAlgorithm = "argon2id";
|
||||
|
||||
/// <summary>Smallest acceptable salt.</summary>
|
||||
internal const int MinimumSaltBytes = 16;
|
||||
|
||||
/// <summary>Largest sensible salt.</summary>
|
||||
internal const int MaximumSaltBytes = 64;
|
||||
|
||||
/// <summary>
|
||||
/// Hard floor on Argon2id memory cost, in kibibytes: 64 MiB.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
internal const int MinimumKdfMemoryKibibytes = 64 * 1024;
|
||||
|
||||
/// <summary>Ceiling on Argon2id memory cost, in kibibytes: 4 GiB.</summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
internal const int MaximumKdfMemoryKibibytes = 4 * 1024 * 1024;
|
||||
|
||||
/// <summary>Fewest acceptable Argon2id passes.</summary>
|
||||
internal const int MinimumKdfPasses = 2;
|
||||
|
||||
/// <summary>Most acceptable Argon2id passes.</summary>
|
||||
internal const int MaximumKdfPasses = 16;
|
||||
|
||||
/// <summary>Lanes. libsodium supports one value and one only.</summary>
|
||||
internal const int RequiredKdfParallelism = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Largest accepted wrap or sealed vault key.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
internal const int MaximumWrapBytes = 4 * 1024;
|
||||
|
||||
/// <summary>Longest vault name, matching the column.</summary>
|
||||
internal const int MaximumVaultNameLength = 256;
|
||||
|
||||
/// <summary>Longest device name, matching the column.</summary>
|
||||
internal const int MaximumDeviceNameLength = 256;
|
||||
|
||||
/// <summary>Longest issuer, matching the column.</summary>
|
||||
internal const int MaximumIssuerLength = 512;
|
||||
|
||||
/// <summary>Longest subject, matching the column.</summary>
|
||||
internal const int MaximumSubjectLength = 256;
|
||||
|
||||
/// <summary>Longest email, matching the column.</summary>
|
||||
internal const int MaximumEmailLength = 320;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Publishes a user's first identity key and creates their personal vault.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class EnrollmentService(
|
||||
DodoDbContext database,
|
||||
IIdentityBindingVerifier bindingVerifier,
|
||||
TimeProvider clock,
|
||||
ILogger<EnrollmentService> logger)
|
||||
{
|
||||
/// <summary>
|
||||
/// Lock key serialising key log appends across the deployment.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
private const string KeyLogLockName = "dodossh:key_log";
|
||||
|
||||
/// <summary>Enrolls the caller's first identity key.</summary>
|
||||
/// <exception cref="EnrollmentInvalidException">The request is structurally unacceptable.</exception>
|
||||
/// <exception cref="IdentityBindingInvalidException">The provider token did not bind the keys.</exception>
|
||||
/// <exception cref="AlreadyEnrolledException">A different current key already exists.</exception>
|
||||
internal async Task<EnrollmentResponse> 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<EnrollmentResponse> 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);
|
||||
}
|
||||
|
||||
/// <summary>What the write produced, for the response.</summary>
|
||||
[System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Auto)]
|
||||
private readonly record struct WrittenEnrollment(int Generation, Guid? DeviceId, long KeyLogSequence);
|
||||
|
||||
/// <summary>Writes every row enrollment produces, inside the caller's transaction.</summary>
|
||||
private async Task<WrittenEnrollment> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decides whether an enrollment against an already-enrolled user is a retry or a conflict.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
private async Task<EnrollmentResponse> 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,
|
||||
};
|
||||
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
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<KeyLogEntry> 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;
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
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<UserKey?> FindCurrentKeyAsync(Guid userId, CancellationToken cancellationToken) =>
|
||||
database.UserKeys.SingleOrDefaultAsync(
|
||||
k => k.UserId == userId && k.IsCurrent,
|
||||
cancellationToken);
|
||||
|
||||
private Task<int> 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);
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
using DodoSSH.Contracts;
|
||||
using DodoSSH.Crypto;
|
||||
using DodoSSH.Domain;
|
||||
|
||||
namespace DodoSSH.Api.Features.Identity;
|
||||
|
||||
/// <summary>
|
||||
/// Structural validation of an enrollment request, before any cryptography or database work.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
internal static class EnrollmentValidation
|
||||
{
|
||||
/// <summary>Validates the request against the caller it claims to describe.</summary>
|
||||
/// <exception cref="EnrollmentInvalidException">Any part of the request is unacceptable.</exception>
|
||||
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");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
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");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>The outcome of checking an identity-provider key binding.</summary>
|
||||
/// <param name="Verified">Whether the token bound the supplied keys to the caller.</param>
|
||||
/// <param name="Failure">Why it did not, when it did not.</param>
|
||||
/// <param name="Evidence">
|
||||
/// 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.
|
||||
/// </param>
|
||||
internal sealed record BindingVerification(bool Verified, string? Failure, string? Evidence)
|
||||
{
|
||||
internal static BindingVerification Failed(string failure) => new(false, failure, null);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an identity provider signed an assertion over a set of public keys.</summary>
|
||||
internal interface IIdentityBindingVerifier
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies a binding ID token.
|
||||
/// </summary>
|
||||
/// <param name="idToken">The ID token from the binding authorization.</param>
|
||||
/// <param name="expectedIssuer">Issuer named by the key statement.</param>
|
||||
/// <param name="expectedSubject">Subject the access token authenticated as.</param>
|
||||
/// <param name="expectedNonce">
|
||||
/// The statement's binding value, from <see cref="KeyStatementCodec.ComputeNonce"/>.
|
||||
/// </param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task<BindingVerification> VerifyAsync(
|
||||
string idToken,
|
||||
string expectedIssuer,
|
||||
string expectedSubject,
|
||||
string expectedNonce,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Identity-provider binding verification.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 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 <c>nonce</c>, 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Verifying here does <em>not</em> 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class IdentityBindingVerifier(
|
||||
IOptionsMonitor<JwtBearerOptions> jwtBearerOptions,
|
||||
IOptions<OidcOptions> oidcOptions,
|
||||
TimeProvider clock)
|
||||
: IIdentityBindingVerifier
|
||||
{
|
||||
/// <summary>Stateless and documented as thread-safe, so one instance serves every request.</summary>
|
||||
private static readonly JsonWebTokenHandler TokenHandler = new();
|
||||
|
||||
/// <summary>Version of the stored evidence document.</summary>
|
||||
private const int EvidenceVersion = 1;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<BindingVerification> 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<string>("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));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The signing keys come from the same <see cref="IConfigurationManager{T}"/> 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.
|
||||
/// </remarks>
|
||||
private async Task<TokenValidationParameters> 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.
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Builds the evidence document stored alongside the key.</summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
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<long>("auth_time", out var authTime))
|
||||
{
|
||||
evidence["authTimeUtc"] = DateTimeOffset
|
||||
.FromUnixTimeSeconds(authTime)
|
||||
.ToString("O", System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
return evidence.ToJsonString();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// The caller's own identity: profile, unlock state and enrollment.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Both endpoints run under <see cref="Auth.AuthenticatedPolicy"/> rather than
|
||||
/// <see cref="Auth.EnrolledPolicy"/>, 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.
|
||||
/// </remarks>
|
||||
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<Ok<MeResponse>> 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<Results<Ok<EnrollmentResponse>, 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<string, object?>(StringComparer.Ordinal) { ["code"] = code });
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
namespace DodoSSH.Api.Features.Identity;
|
||||
|
||||
/// <summary>
|
||||
/// Source-generated log events for identity and enrollment.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Assembles the caller's own profile and unlock state.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
internal sealed class IdentityService(DodoDbContext database, IVaultAccessService vaultAccess)
|
||||
{
|
||||
/// <summary>Builds the caller's profile.</summary>
|
||||
internal async Task<MeResponse> 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<IReadOnlyList<VaultSummary>> 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<VaultSummary>(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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<ICurrentUserContext, CurrentUserContext>();
|
||||
builder.Services.AddScoped<IVaultAccessService, VaultAccessService>();
|
||||
builder.Services.AddScoped<SyncService>();
|
||||
builder.Services.AddScoped<IdentityService>();
|
||||
builder.Services.AddScoped<EnrollmentService>();
|
||||
builder.Services.AddScoped<IIdentityBindingVerifier, IdentityBindingVerifier>();
|
||||
builder.Services.AddSingleton<ICursorKeyProvider, CursorKeyProvider>();
|
||||
|
||||
// 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<IAuthorizationHandler, EnrolledHandler>();
|
||||
|
||||
// Replaces the framework default, which is also registered as a singleton. Last registration wins.
|
||||
builder.Services.AddSingleton<IAuthorizationMiddlewareResultHandler, DodoAuthorizationResultHandler>();
|
||||
|
||||
builder.Services.AddProblemDetails();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<DirectoryEntry>))]
|
||||
|
||||
@@ -53,6 +53,40 @@ public sealed record KeyStatement(
|
||||
DateTimeOffset CreatedAt,
|
||||
string DeviceName);
|
||||
|
||||
/// <summary>
|
||||
/// The personal vault a client creates as part of enrolling.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 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 <see cref="WrappedVaultKey"/> contains
|
||||
/// anything in particular. It stores the bytes and the signature that attributes them.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="VaultId"/> 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="VaultId">Client-generated UUIDv7 for the new vault.</param>
|
||||
/// <param name="Name">Display name. Plaintext, as all vault names are.</param>
|
||||
/// <param name="WrappedVaultKey">
|
||||
/// The vault key sealed to the enrolling user's own encryption key. Opaque to the server.
|
||||
/// </param>
|
||||
/// <param name="GrantSignature">
|
||||
/// 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.
|
||||
/// </param>
|
||||
/// <param name="GrantedAt">Signing timestamp, part of the signed tuple.</param>
|
||||
public sealed record PersonalVaultRequest(
|
||||
Guid VaultId,
|
||||
string Name,
|
||||
byte[] WrappedVaultKey,
|
||||
byte[] GrantSignature,
|
||||
DateTimeOffset GrantedAt);
|
||||
|
||||
/// <summary>A request to enroll a user's first identity key pair.</summary>
|
||||
/// <param name="Statement">The key statement.</param>
|
||||
/// <param name="StatementSignature">Ed25519 self-signature over the statement.</param>
|
||||
@@ -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.
|
||||
/// </param>
|
||||
/// <param name="DeviceWrappedPrivateKey">
|
||||
/// The same bundle sealed to <paramref name="DevicePublicKey"/>. 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.
|
||||
/// </param>
|
||||
/// <param name="RecoveryWrappedPrivateKey">
|
||||
/// The same bundle sealed under a recovery-code-derived key.
|
||||
/// </param>
|
||||
/// <param name="RecoveryKdfParameters">Parameters for the recovery wrap.</param>
|
||||
/// <param name="PersonalVault">
|
||||
/// The personal vault to create, with its key already wrapped to the enrolling user.
|
||||
/// </param>
|
||||
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);
|
||||
|
||||
/// <summary>Result of a successful enrollment.</summary>
|
||||
/// <param name="UserId">The user's identifier.</param>
|
||||
|
||||
@@ -28,9 +28,21 @@ public static class ProblemCodes
|
||||
/// <summary>The caller has not yet enrolled a public key, so no vault is reachable.</summary>
|
||||
public const string EnrollmentRequired = "enrollment-required";
|
||||
|
||||
/// <summary>Enrollment was attempted for a user who already holds a current key.</summary>
|
||||
/// <summary>Enrollment was attempted for a user who already holds a different current key.</summary>
|
||||
public const string AlreadyEnrolled = "already-enrolled";
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public const string InvalidEnrollment = "invalid-enrollment";
|
||||
|
||||
/// <summary>
|
||||
/// The identity-provider token did not bind the supplied keys: a bad signature, the wrong
|
||||
/// subject or audience, an expired token, or a <c>nonce</c> that is not the statement's hash.
|
||||
/// </summary>
|
||||
public const string IdentityBindingInvalid = "identity-binding-invalid";
|
||||
|
||||
/// <summary>The relay refused the requested target. Never states why, to avoid a probe oracle.</summary>
|
||||
public const string RelayTargetRejected = "relay-target-rejected";
|
||||
|
||||
|
||||
@@ -45,15 +45,19 @@ DodoSSH.Contracts.EncryptedPayload.KeyGeneration.get -> uint
|
||||
DodoSSH.Contracts.EncryptedPayload.KeyGeneration.init -> void
|
||||
DodoSSH.Contracts.EnrollmentRequest
|
||||
DodoSSH.Contracts.EnrollmentRequest.<Clone>$() -> 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<string!>! Scopes, string! LoopbackRedirectPattern) -> void
|
||||
DodoSSH.Contracts.OidcConfiguration.Scopes.get -> System.Collections.Generic.IReadOnlyList<string!>!
|
||||
DodoSSH.Contracts.OidcConfiguration.Scopes.init -> void
|
||||
DodoSSH.Contracts.PersonalVaultRequest
|
||||
DodoSSH.Contracts.PersonalVaultRequest.<Clone>$() -> 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.<Clone>$() -> 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
|
||||
|
||||
Reference in New Issue
Block a user