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