diff --git a/src/DodoSSH.Api/Features/Identity/DeviceService.cs b/src/DodoSSH.Api/Features/Identity/DeviceService.cs new file mode 100644 index 0000000..a50f847 --- /dev/null +++ b/src/DodoSSH.Api/Features/Identity/DeviceService.cs @@ -0,0 +1,137 @@ +using DodoSSH.Contracts; +using DodoSSH.Crypto; +using DodoSSH.Domain; +using DodoSSH.Infrastructure; +using Microsoft.EntityFrameworkCore; + +namespace DodoSSH.Api.Features.Identity; + +/// +/// Registers a device key against an account that is already enrolled. +/// +/// +/// +/// The counterpart to 's device handling, which only ever runs during +/// enrollment. Every account that existed before a keystore was wired up needs this, which is all of +/// them. +/// +/// +/// There is nothing to verify beyond shape. The wrap is the bundle sealed to the supplied public key, and +/// only somebody who has opened that bundle can produce it — so possession is proved by construction and +/// no challenge is needed. A caller who seals the wrong bytes registers a device that cannot unlock, +/// which is their own problem and nobody else's. The server cannot tell the difference and must not +/// pretend to: it holds no key that opens either. +/// +/// +internal sealed class DeviceService(DodoDbContext database, TimeProvider clock) +{ + /// Registers a device, or returns the one already registered for this public key. + /// The request is malformed. + internal async Task RegisterAsync( + UserAccount user, + RegisterDeviceRequest request, + CancellationToken cancellationToken) + { + Validate(request); + + var existing = await database.Devices + .Where(device => device.UserId == user.Id && device.PublicKey == request.PublicKey) + .Select(device => new { device.Id, device.EnrolledAtUtc }) + .FirstOrDefaultAsync(cancellationToken) + .ConfigureAwait(false); + + if (existing is not null) + { + // Idempotent, as enrollment is. A dropped response must not leave the caller unable to retry, + // and a second row for the same key would be a device list with a duplicate in it and two + // wraps to revoke instead of one. + return new RegisterDeviceResponse(existing.Id, existing.EnrolledAtUtc); + } + + var now = ToStorablePrecision(clock.GetUtcNow()); + + var registered = new Device + { + Id = Guid.CreateVersion7(), + UserId = user.Id, + Name = request.Name, + // Unreported, deliberately: the contract does not carry a platform. See RegisterDeviceRequest + // for why a wire enum mirroring this one was not worth the hazard. + Platform = DevicePlatform.Unspecified, + PublicKey = request.PublicKey, + EnrolledAtUtc = now, + LastSeenAtUtc = now, + }; + + database.Devices.Add(registered); + + database.UserKeyWraps.Add(new UserKeyWrap + { + Id = Guid.CreateVersion7(), + UserId = user.Id, + Kind = UserKeyWrapKind.Device, + DeviceId = registered.Id, + Wrap = request.WrappedPrivateKey, + WrapVersion = 1, + CreatedAtUtc = now, + }); + + await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + return new RegisterDeviceResponse(registered.Id, now); + } + + /// Rounds a timestamp down to what the database can actually hold. + /// + /// reports 100-nanosecond ticks and PostgreSQL's timestamp with time + /// zone keeps microseconds, so storing the raw value means the row holds something slightly + /// different from what was returned. Nothing breaks, and that is the problem: the first call would + /// report a timestamp that no later read of the same row ever produces, and the idempotent retry would + /// answer with a different one for the same device. Truncating here makes the response the same value + /// every time it is asked for. + /// + private static DateTimeOffset ToStorablePrecision(DateTimeOffset value) => + new(value.Ticks - (value.Ticks % TimeSpan.TicksPerMicrosecond), value.Offset); + + /// + /// The same rules applies to a device supplied at enrollment, and + /// the limits are the shared constants rather than repeated numbers. The four-line guards are not + /// shared: they would have to be parameterised over which exception to throw, which is more + /// indirection than four lines of if is worth. + /// + private static void Validate(RegisterDeviceRequest request) + { + if (request is null) + { + throw new DeviceRegistrationInvalidException("A device registration is required."); + } + + if (string.IsNullOrWhiteSpace(request.Name)) + { + throw new DeviceRegistrationInvalidException("The device name is required."); + } + + if (request.Name.Length > EnrollmentLimits.MaximumDeviceNameLength) + { + throw new DeviceRegistrationInvalidException( + $"The device name exceeds {EnrollmentLimits.MaximumDeviceNameLength} characters."); + } + + if (request.PublicKey is null || request.PublicKey.Length != CryptoSpec.PublicKeySize) + { + throw new DeviceRegistrationInvalidException( + $"The device public key must be exactly {CryptoSpec.PublicKeySize} bytes."); + } + + if (request.WrappedPrivateKey is null or { Length: 0 }) + { + throw new DeviceRegistrationInvalidException("The device wrap is required."); + } + + if (request.WrappedPrivateKey.Length > EnrollmentLimits.MaximumWrapBytes) + { + throw new DeviceRegistrationInvalidException( + $"The device wrap exceeds {EnrollmentLimits.MaximumWrapBytes} bytes."); + } + } +} diff --git a/src/DodoSSH.Api/Features/Identity/EnrollmentExceptions.cs b/src/DodoSSH.Api/Features/Identity/EnrollmentExceptions.cs index 16f7a5e..1230e42 100644 --- a/src/DodoSSH.Api/Features/Identity/EnrollmentExceptions.cs +++ b/src/DodoSSH.Api/Features/Identity/EnrollmentExceptions.cs @@ -12,6 +12,16 @@ internal sealed class EnrollmentInvalidException(string message) : Exception(mes /// The identity-provider token did not bind the supplied keys. internal sealed class IdentityBindingInvalidException(string message) : Exception(message); +/// +/// The device registration request was structurally unacceptable. +/// +/// +/// Its own type rather than , whose rules it largely shares. +/// Registering a device is not enrolling, and the problem code a client switches on should not claim it +/// was. As with enrollment, the message reaches the caller: keep it about the shape of their request. +/// +internal sealed class DeviceRegistrationInvalidException(string message) : Exception(message); + /// /// The caller already holds a current identity key that is not the one being enrolled. /// diff --git a/src/DodoSSH.Api/Features/Identity/IdentityEndpoints.cs b/src/DodoSSH.Api/Features/Identity/IdentityEndpoints.cs index 60f3a24..0b35adc 100644 --- a/src/DodoSSH.Api/Features/Identity/IdentityEndpoints.cs +++ b/src/DodoSSH.Api/Features/Identity/IdentityEndpoints.cs @@ -9,9 +9,10 @@ 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. +/// The group runs under rather than +/// , and must: GET / and POST /enrollment are how a client +/// discovers that it needs to enroll and then does so, so gating them on enrollment would make enrollment +/// unreachable. POST /devices is the exception and adds the stricter policy itself. /// internal static class IdentityEndpoints { @@ -29,6 +30,15 @@ internal static class IdentityEndpoints .WithName("Enroll") .WithSummary("Publishes the caller's first identity key and creates their personal vault."); + // The one endpoint in this group that does require enrollment, and it says so itself rather than + // relying on the group. You cannot wrap a bundle to a device before you have a bundle, and the + // authorization handler turns the unmet requirement into "enrollment-required" — a better answer + // than a 400 from validation about state the caller could not have known. + group.MapPost("/devices", RegisterDeviceAsync) + .RequireAuthorization(Auth.EnrolledPolicy) + .WithName("RegisterDevice") + .WithSummary("Registers a device key so this machine can unlock without the passphrase."); + return app; } @@ -87,6 +97,34 @@ internal static class IdentityEndpoints } } + /// + /// 200 rather than 201, for the reason enrollment gives: re-registering the same public key returns the + /// existing device, so there is no single moment of creation to point a Location header at. + /// + private static async Task, ProblemHttpResult>> RegisterDeviceAsync( + RegisterDeviceRequest request, + ICurrentUserContext currentUser, + DeviceService devices, + CancellationToken cancellationToken) + { + var user = await currentUser.GetOrProvisionAsync(cancellationToken).ConfigureAwait(false); + + try + { + var response = await devices.RegisterAsync(user, request, cancellationToken) + .ConfigureAwait(false); + + return TypedResults.Ok(response); + } + catch (DeviceRegistrationInvalidException exception) + { + return Problem( + StatusCodes.Status400BadRequest, + ProblemCodes.InvalidDeviceRegistration, + exception.Message); + } + } + private static ProblemHttpResult Problem(int statusCode, string code, string detail) => TypedResults.Problem( detail: detail, diff --git a/src/DodoSSH.Api/Program.cs b/src/DodoSSH.Api/Program.cs index fed8d34..8ff20b1 100644 --- a/src/DodoSSH.Api/Program.cs +++ b/src/DodoSSH.Api/Program.cs @@ -28,6 +28,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); diff --git a/src/DodoSSH.Client.Api/DodoSshApiClient.cs b/src/DodoSSH.Client.Api/DodoSshApiClient.cs index ca7f5aa..953a9a5 100644 --- a/src/DodoSSH.Client.Api/DodoSshApiClient.cs +++ b/src/DodoSSH.Client.Api/DodoSshApiClient.cs @@ -34,6 +34,17 @@ public interface IAccountApi /// Publishes the caller's first identity key and creates their personal vault. Task EnrollAsync(EnrollmentRequest request, CancellationToken cancellationToken); + + /// + /// Registers a device key against an account that is already enrolled. + /// + /// + /// On the interface rather than only on the client, because the session layer decides when to + /// offer this — after an unlock, never before — and that decision is worth testing without HTTP. + /// + Task RegisterDeviceAsync( + RegisterDeviceRequest request, + CancellationToken cancellationToken); } /// @@ -83,6 +94,7 @@ public sealed class DodoSshApiClient(HttpClient http, IAccessTokenProvider token private const string ConfigurationPath = "/.well-known/dodossh-configuration"; private const string MePath = "/api/v1/me"; private const string EnrollmentPath = "/api/v1/me/enrollment"; + private const string DevicesPath = "/api/v1/me/devices"; /// /// Reads the server's capabilities, versions and limits. @@ -130,6 +142,21 @@ public sealed class DodoSshApiClient(HttpClient http, IAccessTokenProvider token DodoSshJsonContext.Default.EnrollmentResponse, cancellationToken); + /// Registers a device key against an already-enrolled account. + /// + /// Requires an unlocked vault, because the wrap can only be produced by something holding the secret + /// bundle. That is also what proves possession to the server, which is why there is no challenge here. + /// + public Task RegisterDeviceAsync( + RegisterDeviceRequest request, + CancellationToken cancellationToken) => + SendAsync( + HttpMethod.Post, + DevicesPath, + JsonContent.Create(request, DodoSshJsonContext.Default.RegisterDeviceRequest), + DodoSshJsonContext.Default.RegisterDeviceResponse, + cancellationToken); + /// Reads vault changes after a cursor. /// /// A POST despite being a read: the filters live in the body, cursors are opaque, and no caching is diff --git a/src/DodoSSH.Contracts/Devices.cs b/src/DodoSSH.Contracts/Devices.cs new file mode 100644 index 0000000..9db9a7a --- /dev/null +++ b/src/DodoSSH.Contracts/Devices.cs @@ -0,0 +1,45 @@ +namespace DodoSSH.Contracts; + +/// +/// Registers a device key against an already-enrolled account, so that machine can unlock without the +/// passphrase. +/// +/// +/// +/// Separate from enrollment, and it has to be: enrollment happens once, and the machines that want this +/// were mostly enrolled long before anyone wired a keystore up. Without this endpoint the feature would +/// only ever reach accounts created after it shipped. +/// +/// +/// Possession of the bundle is proved by construction. Only a caller who has opened the secret +/// bundle can produce — it is that bundle sealed to +/// — so the server needs no separate challenge. A caller who sent a wrap of +/// anything else would register a device that cannot unlock, which harms nobody but themselves. +/// +/// +/// The platform is deliberately not carried. It would be display-only, and a wire enum mirroring the +/// server's DevicePlatform is exactly the shape of duplication that has produced three +/// self-consistent bugs in this repository already. A device list that wants it can add it with a +/// mapping table and a test that pins the two together. +/// +/// +/// Human-readable name for this machine, shown in the account's device list. +/// X25519 public key of this device, 32 bytes. +/// +/// The caller's secret bundle sealed to . Opaque to the server. +/// +public sealed record RegisterDeviceRequest( + string Name, + byte[] PublicKey, + byte[] WrappedPrivateKey); + +/// The registered device. +/// +/// Re-registering the same public key returns the existing device rather than creating a second one, so +/// a retry after a dropped response is safe and may predate the call. +/// +/// The device's identifier, for later revocation. +/// When this device was first registered. +public sealed record RegisterDeviceResponse( + Guid DeviceId, + DateTimeOffset EnrolledAt); diff --git a/src/DodoSSH.Contracts/DodoSshJsonContext.cs b/src/DodoSSH.Contracts/DodoSshJsonContext.cs index 8b6d092..773f154 100644 --- a/src/DodoSSH.Contracts/DodoSshJsonContext.cs +++ b/src/DodoSSH.Contracts/DodoSshJsonContext.cs @@ -35,6 +35,8 @@ namespace DodoSSH.Contracts; // 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(RegisterDeviceRequest))] +[JsonSerializable(typeof(RegisterDeviceResponse))] [JsonSerializable(typeof(DirectoryEntry))] [JsonSerializable(typeof(IReadOnlyList))] [JsonSerializable(typeof(VaultSummary))] diff --git a/src/DodoSSH.Contracts/ProblemCodes.cs b/src/DodoSSH.Contracts/ProblemCodes.cs index fdb2f18..ede158f 100644 --- a/src/DodoSSH.Contracts/ProblemCodes.cs +++ b/src/DodoSSH.Contracts/ProblemCodes.cs @@ -43,6 +43,17 @@ public static class ProblemCodes /// public const string IdentityBindingInvalid = "identity-binding-invalid"; + /// + /// A device registration was structurally invalid: a public key of the wrong length, a missing or + /// oversized wrap, or a blank name. + /// + /// + /// Distinct from even though the rules overlap, because the two are + /// different requests and a client showing "your enrollment was rejected" when somebody added a + /// fingerprint reader would be describing the wrong thing entirely. + /// + public const string InvalidDeviceRegistration = "invalid-device-registration"; + /// 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 42b87e5..a701a52 100644 --- a/src/DodoSSH.Contracts/PublicAPI.Unshipped.txt +++ b/src/DodoSSH.Contracts/PublicAPI.Unshipped.txt @@ -1,4 +1,19 @@ #nullable enable +const DodoSSH.Contracts.ProblemCodes.AlreadyEnrolled = "already-enrolled" -> string! +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.InvalidDeviceRegistration = "invalid-device-registration" -> 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! +const DodoSSH.Contracts.ProblemCodes.RelayTicketInvalid = "relay-ticket-invalid" -> string! +const DodoSSH.Contracts.ProblemCodes.TypeBaseUri = "https://dodossh.dev/problems/" -> string! +const DodoSSH.Contracts.ProblemCodes.VaultConflict = "vault-conflict" -> string! DodoSSH.Contracts.DirectoryEntry DodoSSH.Contracts.DirectoryEntry.$() -> DodoSSH.Contracts.DirectoryEntry! DodoSSH.Contracts.DirectoryEntry.Deconstruct(out System.Guid UserId, out string? Email, out string? DisplayName, out byte[]! EncryptionPublicKey, out byte[]! SigningPublicKey, out byte[]! Fingerprint, out int KeyGeneration, out long KeyLogSequence) -> void @@ -192,10 +207,10 @@ 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.GrantSignature.get -> byte[]! +DodoSSH.Contracts.PersonalVaultRequest.GrantSignature.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 @@ -204,6 +219,26 @@ DodoSSH.Contracts.PersonalVaultRequest.VaultId.init -> void DodoSSH.Contracts.PersonalVaultRequest.WrappedVaultKey.get -> byte[]! DodoSSH.Contracts.PersonalVaultRequest.WrappedVaultKey.init -> void DodoSSH.Contracts.ProblemCodes +DodoSSH.Contracts.RegisterDeviceRequest +DodoSSH.Contracts.RegisterDeviceRequest.$() -> DodoSSH.Contracts.RegisterDeviceRequest! +DodoSSH.Contracts.RegisterDeviceRequest.Deconstruct(out string! Name, out byte[]! PublicKey, out byte[]! WrappedPrivateKey) -> void +DodoSSH.Contracts.RegisterDeviceRequest.Equals(DodoSSH.Contracts.RegisterDeviceRequest? other) -> bool +DodoSSH.Contracts.RegisterDeviceRequest.Name.get -> string! +DodoSSH.Contracts.RegisterDeviceRequest.Name.init -> void +DodoSSH.Contracts.RegisterDeviceRequest.PublicKey.get -> byte[]! +DodoSSH.Contracts.RegisterDeviceRequest.PublicKey.init -> void +DodoSSH.Contracts.RegisterDeviceRequest.RegisterDeviceRequest(string! Name, byte[]! PublicKey, byte[]! WrappedPrivateKey) -> void +DodoSSH.Contracts.RegisterDeviceRequest.WrappedPrivateKey.get -> byte[]! +DodoSSH.Contracts.RegisterDeviceRequest.WrappedPrivateKey.init -> void +DodoSSH.Contracts.RegisterDeviceResponse +DodoSSH.Contracts.RegisterDeviceResponse.$() -> DodoSSH.Contracts.RegisterDeviceResponse! +DodoSSH.Contracts.RegisterDeviceResponse.Deconstruct(out System.Guid DeviceId, out System.DateTimeOffset EnrolledAt) -> void +DodoSSH.Contracts.RegisterDeviceResponse.DeviceId.get -> System.Guid +DodoSSH.Contracts.RegisterDeviceResponse.DeviceId.init -> void +DodoSSH.Contracts.RegisterDeviceResponse.EnrolledAt.get -> System.DateTimeOffset +DodoSSH.Contracts.RegisterDeviceResponse.EnrolledAt.init -> void +DodoSSH.Contracts.RegisterDeviceResponse.Equals(DodoSSH.Contracts.RegisterDeviceResponse? other) -> bool +DodoSSH.Contracts.RegisterDeviceResponse.RegisterDeviceResponse(System.Guid DeviceId, System.DateTimeOffset EnrolledAt) -> void DodoSSH.Contracts.RelayConfiguration DodoSSH.Contracts.RelayConfiguration.$() -> DodoSSH.Contracts.RelayConfiguration! DodoSSH.Contracts.RelayConfiguration.Deconstruct(out bool Enabled, out System.Uri? WebSocketUrl) -> void @@ -433,20 +468,6 @@ DodoSSH.Contracts.VaultSummary.VaultId.init -> void DodoSSH.Contracts.VaultSummary.VaultSummary(System.Guid VaultId, string! Name, bool IsPersonal, System.Guid? TeamId, uint KeyGeneration, int Permissions, byte[]? WrappedVaultKey, bool RekeyRequired) -> void DodoSSH.Contracts.VaultSummary.WrappedVaultKey.get -> byte[]? DodoSSH.Contracts.VaultSummary.WrappedVaultKey.init -> void -const DodoSSH.Contracts.ProblemCodes.AlreadyEnrolled = "already-enrolled" -> string! -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! -const DodoSSH.Contracts.ProblemCodes.RelayTicketInvalid = "relay-ticket-invalid" -> string! -const DodoSSH.Contracts.ProblemCodes.TypeBaseUri = "https://dodossh.dev/problems/" -> string! -const DodoSSH.Contracts.ProblemCodes.VaultConflict = "vault-conflict" -> string! override DodoSSH.Contracts.DirectoryEntry.Equals(object? obj) -> bool override DodoSSH.Contracts.DirectoryEntry.GetHashCode() -> int override DodoSSH.Contracts.DirectoryEntry.ToString() -> string! @@ -480,6 +501,12 @@ 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.RegisterDeviceRequest.Equals(object? obj) -> bool +override DodoSSH.Contracts.RegisterDeviceRequest.GetHashCode() -> int +override DodoSSH.Contracts.RegisterDeviceRequest.ToString() -> string! +override DodoSSH.Contracts.RegisterDeviceResponse.Equals(object? obj) -> bool +override DodoSSH.Contracts.RegisterDeviceResponse.GetHashCode() -> int +override DodoSSH.Contracts.RegisterDeviceResponse.ToString() -> string! override DodoSSH.Contracts.RelayConfiguration.Equals(object? obj) -> bool override DodoSSH.Contracts.RelayConfiguration.GetHashCode() -> int override DodoSSH.Contracts.RelayConfiguration.ToString() -> string! @@ -544,6 +571,10 @@ static DodoSSH.Contracts.OidcConfiguration.operator !=(DodoSSH.Contracts.OidcCon 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.RegisterDeviceRequest.operator !=(DodoSSH.Contracts.RegisterDeviceRequest? left, DodoSSH.Contracts.RegisterDeviceRequest? right) -> bool +static DodoSSH.Contracts.RegisterDeviceRequest.operator ==(DodoSSH.Contracts.RegisterDeviceRequest? left, DodoSSH.Contracts.RegisterDeviceRequest? right) -> bool +static DodoSSH.Contracts.RegisterDeviceResponse.operator !=(DodoSSH.Contracts.RegisterDeviceResponse? left, DodoSSH.Contracts.RegisterDeviceResponse? right) -> bool +static DodoSSH.Contracts.RegisterDeviceResponse.operator ==(DodoSSH.Contracts.RegisterDeviceResponse? left, DodoSSH.Contracts.RegisterDeviceResponse? 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 index 3d6c5e4..f85b749 100644 --- a/tests/DodoSSH.Api.Tests/IdentityEndpointTests.cs +++ b/tests/DodoSSH.Api.Tests/IdentityEndpointTests.cs @@ -22,6 +22,7 @@ public sealed class IdentityEndpointTests(ApiFixture fixture) { private const string MeUrl = "/api/v1/me"; private const string EnrollUrl = "/api/v1/me/enrollment"; + private const string DevicesUrl = "/api/v1/me/devices"; // ---- Provisioning and /me ---- @@ -839,6 +840,150 @@ public sealed class IdentityEndpointTests(ApiFixture fixture) response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); } + // ---- Registering a device after enrollment ---- + + [Fact] + public async Task RegisterDevice_AddsADeviceAndAWrapThatOnlyThatDeviceCanOpen() + { + // The gap this endpoint closes. EnrollmentService.AddDevice only ever runs during enrollment, so + // without this every account that existed before a keystore was wired up could never turn the + // feature on — which is all of them. + using var enrollment = NewEnrollment(); + var client = enrollment.CreateClient(fixture); + var enrolled = await EnrollAsync(client, enrollment.Build()); + + var publicKey = DeviceKey(); + + var registered = await RegisterDeviceAsync( + client, + new RegisterDeviceRequest("a second laptop", publicKey, Wrap())); + + registered.DeviceId.ShouldNotBe(Guid.Empty); + + await using var scope = fixture.CreateScope(); + var database = scope.ServiceProvider.GetRequiredService(); + + var device = await database.Devices.SingleAsync(d => d.Id == registered.DeviceId); + device.UserId.ShouldBe(enrolled.UserId); + device.Name.ShouldBe("a second laptop"); + device.PublicKey.ShouldBe(publicKey); + + var wrap = await database.UserKeyWraps.SingleAsync(w => w.DeviceId == registered.DeviceId); + wrap.Kind.ShouldBe(UserKeyWrapKind.Device); + wrap.UserId.ShouldBe(enrolled.UserId); + } + + [Fact] + public async Task RegisterDevice_BeforeEnrolling_AsksForEnrollmentRatherThanRejectingTheShape() + { + // You cannot wrap a bundle to a device before you have a bundle. The distinction that matters is + // which answer the client gets: "enroll first" is actionable, and a 400 about the request's shape + // would send somebody looking at their key length. + var subject = NewSubject(); + var client = fixture.CreateClientFor(subject, $"{subject}@example.com"); + + var response = await client.PostContractAsync( + DevicesUrl, + new RegisterDeviceRequest("a laptop", DeviceKey(), Wrap())); + + await ShouldBeProblemAsync( + response, + HttpStatusCode.Forbidden, + ProblemCodes.EnrollmentRequired); + } + + [Fact] + public async Task RegisterDevice_TwiceWithTheSameKey_ReturnsTheSameDeviceAndAddsNoSecondWrap() + { + // Idempotent, as enrollment is. A dropped response must leave a retry safe, and a second row for + // one key would mean a device list with a duplicate and two wraps to revoke instead of one. + using var enrollment = NewEnrollment(); + var client = enrollment.CreateClient(fixture); + await EnrollAsync(client, enrollment.Build()); + + var publicKey = DeviceKey(); + var request = new RegisterDeviceRequest("a laptop", publicKey, Wrap()); + + var first = await RegisterDeviceAsync(client, request); + var second = await RegisterDeviceAsync(client, request); + + second.DeviceId.ShouldBe(first.DeviceId); + second.EnrolledAt.ShouldBe(first.EnrolledAt); + + await using var scope = fixture.CreateScope(); + var database = scope.ServiceProvider.GetRequiredService(); + + (await database.Devices.CountAsync(d => d.PublicKey == publicKey)).ShouldBe(1); + (await database.UserKeyWraps.CountAsync(w => w.DeviceId == first.DeviceId)).ShouldBe(1); + } + + [Theory] + [InlineData(31)] + [InlineData(33)] + public async Task RegisterDevice_WithAPublicKeyOfTheWrongLength_IsRejected(int length) + { + // The same rule enrollment applies. A key that is not an X25519 public key is one nothing can ever + // seal to, so the row would be permanently useless. + using var enrollment = NewEnrollment(); + var client = enrollment.CreateClient(fixture); + await EnrollAsync(client, enrollment.Build()); + + var response = await client.PostContractAsync( + DevicesUrl, + new RegisterDeviceRequest("a laptop", new byte[length], Wrap())); + + await ShouldBeProblemAsync( + response, + HttpStatusCode.BadRequest, + ProblemCodes.InvalidDeviceRegistration); + } + + [Fact] + public async Task RegisterDevice_WithNoWrap_IsRejected() + { + // A device key with no wrap registers a device that can never unlock anything, and the server + // cannot fill the gap in — only the holder of the bundle can seal it. + using var enrollment = NewEnrollment(); + var client = enrollment.CreateClient(fixture); + await EnrollAsync(client, enrollment.Build()); + + var response = await client.PostContractAsync( + DevicesUrl, + new RegisterDeviceRequest("a laptop", DeviceKey(), [])); + + await ShouldBeProblemAsync( + response, + HttpStatusCode.BadRequest, + ProblemCodes.InvalidDeviceRegistration); + } + + [Fact] + public async Task RegisterDevice_WithABlankName_IsRejected() + { + using var enrollment = NewEnrollment(); + var client = enrollment.CreateClient(fixture); + await EnrollAsync(client, enrollment.Build()); + + var response = await client.PostContractAsync( + DevicesUrl, + new RegisterDeviceRequest(" ", DeviceKey(), Wrap())); + + await ShouldBeProblemAsync( + response, + HttpStatusCode.BadRequest, + ProblemCodes.InvalidDeviceRegistration); + } + + [Fact] + public async Task RegisterDevice_WithoutAToken_Is401() + { + var response = await fixture.CreateClient().PostContractAsync( + DevicesUrl, + new RegisterDeviceRequest("a laptop", DeviceKey(), Wrap())); + + response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); + } + // ---- Helpers ---- private static string NewSubject() => $"user-{Guid.CreateVersion7():N}"; @@ -871,6 +1016,31 @@ public sealed class IdentityEndpointTests(ApiFixture fixture) return body; } + private static async Task RegisterDeviceAsync( + HttpClient client, + RegisterDeviceRequest request) + { + var response = await client.PostContractAsync(DevicesUrl, request); + response.EnsureSuccessStatusCode(); + + var body = await response.Content.ReadContractAsync(); + body.ShouldNotBeNull(); + return body; + } + + /// + /// A distinct 32 bytes per call, because the endpoint is idempotent on the public key and two tests + /// sharing one would silently be asserting about each other's device. + /// + private static byte[] DeviceKey() => Guid.CreateVersion7().ToByteArray().Concat( + Guid.CreateVersion7().ToByteArray()).ToArray(); + + /// + /// Not a real envelope. The server treats a wrap as opaque bytes and validates only that it is present + /// and bounded, which is the whole of what it is allowed to know. + /// + private static byte[] Wrap() => [1, 2, 3, 4]; + private static Task ShouldBeBindingRejectedAsync(HttpResponseMessage response) => ShouldBeProblemAsync( response, diff --git a/tests/DodoSSH.Client.App.Tests/FakeVaultServer.cs b/tests/DodoSSH.Client.App.Tests/FakeVaultServer.cs index dbb7f3b..6efb97a 100644 --- a/tests/DodoSSH.Client.App.Tests/FakeVaultServer.cs +++ b/tests/DodoSSH.Client.App.Tests/FakeVaultServer.cs @@ -43,6 +43,9 @@ internal sealed class FakeVaultServer : IVaultServer, IAccountApi, ISyncApi, IKe internal int LiveRowCount => rows.Values.Count(row => row.Operation != SyncOperation.Delete); + /// Device wraps registered after enrollment, keyed on the device public key. + internal Dictionary RegisteredDevices { get; } = new(StringComparer.Ordinal); + /// When set, the next sign-in throws — how an unreachable server is exercised. internal Exception? SignInFailure { get; set; } @@ -130,6 +133,30 @@ internal sealed class FakeVaultServer : IVaultServer, IAccountApi, ISyncApi, IKe KeyLogSequence: 1)); } + /// + /// + /// Records the wrap so a test can assert it reached the server, and refuses before enrollment as the + /// real endpoint's Auth.EnrolledPolicy does. + /// + public Task RegisterDeviceAsync( + RegisterDeviceRequest request, + CancellationToken cancellationToken) + { + if (!IsEnrolled) + { + throw new DodoSshApiException( + System.Net.HttpStatusCode.Forbidden, + ProblemCodes.EnrollmentRequired, + "This account has no identity key yet."); + } + + RegisteredDevices[Convert.ToHexString(request.PublicKey)] = request.WrappedPrivateKey; + + return Task.FromResult(new RegisterDeviceResponse( + DeviceId: Guid.CreateVersion7(), + EnrolledAt: DateTimeOffset.UnixEpoch)); + } + // ---- Sync ---- /// diff --git a/tests/DodoSSH.Client.Session.Tests/FakeAccountServer.cs b/tests/DodoSSH.Client.Session.Tests/FakeAccountServer.cs index dae112c..849c3c8 100644 --- a/tests/DodoSSH.Client.Session.Tests/FakeAccountServer.cs +++ b/tests/DodoSSH.Client.Session.Tests/FakeAccountServer.cs @@ -40,6 +40,16 @@ internal sealed class FakeAccountServer : IAccountApi /// Whether an identity key has been published. internal bool IsEnrolled => statement is not null; + /// + /// Device wraps registered after enrollment, keyed on the device public key. + /// + /// + /// Kept so a test can assert that the wrap the server received is the one the client claimed to send. + /// The server cannot open it and neither does this, which is the point: possession is proved by + /// producing it, not by anything either side checks. + /// + internal Dictionary RegisteredDevices { get; } = new(StringComparer.Ordinal); + /// public Task GetMeAsync(CancellationToken cancellationToken) { @@ -100,6 +110,36 @@ internal sealed class FakeAccountServer : IAccountApi KeyLogSequence: 1)); } + /// + /// + /// Refuses before enrollment, as the real endpoint does through Auth.EnrolledPolicy: there is no + /// bundle to have wrapped yet, so a wrap arriving here would be a wrap of something else. Idempotent on + /// the public key, again matching the real one. + /// + public Task RegisterDeviceAsync( + RegisterDeviceRequest request, + CancellationToken cancellationToken) + { + if (!IsEnrolled) + { + throw new DodoSshApiException( + System.Net.HttpStatusCode.Forbidden, + ProblemCodes.EnrollmentRequired, + "This account has no identity key yet."); + } + + var key = Convert.ToHexString(request.PublicKey); + + if (!RegisteredDevices.TryAdd(key, request.WrappedPrivateKey)) + { + RegisteredDevices[key] = request.WrappedPrivateKey; + } + + return Task.FromResult(new RegisterDeviceResponse( + DeviceId: Guid.CreateVersion7(), + EnrolledAt: DateTimeOffset.UnixEpoch)); + } + /// Drops the vault grant, as a rekey does until it is re-issued. internal void RevokeVaultGrant() => personalVault = personalVault is null