Let an already-enrolled account register a device key

The first of the three pieces ADR 0007 needs, and the one that was a discovery
rather than a plan. EnrollmentService.AddDevice runs only during enrollment, so
without an endpoint the device-unlock feature would have reached accounts created
after it shipped and no others — which is to say none of the ones that exist. The
code even said so: "the devices endpoint sets it properly when it lands."

POST /api/v1/me/devices takes a name, an X25519 public key and the bundle sealed
to it, and writes a device row plus a UserKeyWrapKind.Device wrap.

Possession is proved by construction, so there is no challenge. The wrap is the
secret bundle sealed to the supplied public key, and only something that has
opened that bundle can produce it. A caller who seals the wrong bytes registers a
device that cannot unlock, which harms nobody else; the server cannot tell the
difference and must not pretend to, because it holds no key that opens either.
That is also why the client must be unlocked to call this at all.

It is the one endpoint in the /me group that requires enrollment, and it says so
itself rather than relying on the group. The group deliberately does not: GET /
and POST /enrollment are how a client discovers it needs to enroll and then does
so, and gating those on enrollment would make enrollment unreachable. Adding the
stricter policy to this route alone means an unenrolled caller is told
"enrollment-required" by the authorization handler rather than getting a 400 about
the shape of a request that was fine.

Idempotent on the public key, and 200 rather than 201 for the reason enrollment
gives: a retry of an identical request returns the same body, so there is no
single moment of creation to point a Location header at. A second row for one key
would mean a device list with a duplicate in it and two wraps to revoke instead
of one. Mutation tested — removing the lookup fails
RegisterDevice_TwiceWithTheSameKey_ReturnsTheSameDeviceAndAddsNoSecondWrap and
nothing else.

That test also found a real defect, in the way these usually surface: two
timestamps that print identically and are not equal. TimeProvider reports
100-nanosecond ticks and PostgreSQL's timestamp with time zone keeps microseconds,
so the first call returned a value that no later read of the row would ever
produce, and the idempotent retry answered with a different timestamp for the same
device. Nothing breaks, which is what makes it worth fixing: the service now
truncates to the precision the column actually holds, so the response is the same
value every time it is asked for. The repo already had a precedent for this class
of thing in KeyLogChain.TruncateTimestamp; it just had not been applied here.

The platform is deliberately not carried on the wire, which leaves
Device.Platform unreported and the stale comment corrected rather than fulfilled.
It would be a display-only field, and a Contracts enum mirroring the domain's
DevicePlatform is exactly the shape of duplication that has produced three
self-consistent bugs in this repository. A device list that wants it can add a
mapping table and a test pinning the two together, which is what the sync entity
types already do.

Its own problem code and exception rather than reusing enrollment's, whose rules
it largely shares. Registering a device is not enrolling, and a client showing
"your enrollment was rejected" because somebody set up a fingerprint reader would
be describing the wrong thing. The validation shares the limit constants —
MaximumWrapBytes, MaximumDeviceNameLength, PublicKeySize — and not the four-line
guards, which would have had to be parameterised over which exception to throw for
less than they cost.

Both in-memory fakes implement it properly rather than throwing: they record the
wrap so a test can assert it arrived, and refuse before enrollment as the real
endpoint's policy does. A fake that answered where the server refuses is a fake
that can make a real bug pass.

866 tests green, 8 of them new. Zero warnings, dotnet format clean.

Still to come: the protector seam with the wrap cached locally so device unlock
works offline, then the Windows Hello implementation and the unlock-screen UI —
which is where the Windows target framework lands and where automated testing
stops.
This commit is contained in:
2026-07-30 13:18:09 +02:00
parent 7016ce36f1
commit db4a8ed3d3
12 changed files with 558 additions and 19 deletions
@@ -0,0 +1,137 @@
using DodoSSH.Contracts;
using DodoSSH.Crypto;
using DodoSSH.Domain;
using DodoSSH.Infrastructure;
using Microsoft.EntityFrameworkCore;
namespace DodoSSH.Api.Features.Identity;
/// <summary>
/// Registers a device key against an account that is already enrolled.
/// </summary>
/// <remarks>
/// <para>
/// The counterpart to <see cref="EnrollmentService"/>'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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
internal sealed class DeviceService(DodoDbContext database, TimeProvider clock)
{
/// <summary>Registers a device, or returns the one already registered for this public key.</summary>
/// <exception cref="DeviceRegistrationInvalidException">The request is malformed.</exception>
internal async Task<RegisterDeviceResponse> 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);
}
/// <summary>Rounds a timestamp down to what the database can actually hold.</summary>
/// <remarks>
/// <see cref="TimeProvider"/> reports 100-nanosecond ticks and PostgreSQL's <c>timestamp with time
/// zone</c> 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.
/// </remarks>
private static DateTimeOffset ToStorablePrecision(DateTimeOffset value) =>
new(value.Ticks - (value.Ticks % TimeSpan.TicksPerMicrosecond), value.Offset);
/// <remarks>
/// The same rules <see cref="EnrollmentValidation"/> 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 <c>if</c> is worth.
/// </remarks>
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.");
}
}
}
@@ -12,6 +12,16 @@ internal sealed class EnrollmentInvalidException(string message) : Exception(mes
/// <summary>The identity-provider token did not bind the supplied keys.</summary> /// <summary>The identity-provider token did not bind the supplied keys.</summary>
internal sealed class IdentityBindingInvalidException(string message) : Exception(message); internal sealed class IdentityBindingInvalidException(string message) : Exception(message);
/// <summary>
/// The device registration request was structurally unacceptable.
/// </summary>
/// <remarks>
/// Its own type rather than <see cref="EnrollmentInvalidException"/>, 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.
/// </remarks>
internal sealed class DeviceRegistrationInvalidException(string message) : Exception(message);
/// <summary> /// <summary>
/// The caller already holds a current identity key that is not the one being enrolled. /// The caller already holds a current identity key that is not the one being enrolled.
/// </summary> /// </summary>
@@ -9,9 +9,10 @@ namespace DodoSSH.Api.Features.Identity;
/// The caller's own identity: profile, unlock state and enrollment. /// The caller's own identity: profile, unlock state and enrollment.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Both endpoints run under <see cref="Auth.AuthenticatedPolicy"/> rather than /// The group runs under <see cref="Auth.AuthenticatedPolicy"/> rather than
/// <see cref="Auth.EnrolledPolicy"/>, and must. They are how a client discovers that it needs to /// <see cref="Auth.EnrolledPolicy"/>, and must: <c>GET /</c> and <c>POST /enrollment</c> are how a client
/// enroll and then does so; gating them on enrollment would make enrollment unreachable. /// discovers that it needs to enroll and then does so, so gating them on enrollment would make enrollment
/// unreachable. <c>POST /devices</c> is the exception and adds the stricter policy itself.
/// </remarks> /// </remarks>
internal static class IdentityEndpoints internal static class IdentityEndpoints
{ {
@@ -29,6 +30,15 @@ internal static class IdentityEndpoints
.WithName("Enroll") .WithName("Enroll")
.WithSummary("Publishes the caller's first identity key and creates their personal vault."); .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; return app;
} }
@@ -87,6 +97,34 @@ internal static class IdentityEndpoints
} }
} }
/// <remarks>
/// 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.
/// </remarks>
private static async Task<Results<Ok<RegisterDeviceResponse>, 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) => private static ProblemHttpResult Problem(int statusCode, string code, string detail) =>
TypedResults.Problem( TypedResults.Problem(
detail: detail, detail: detail,
+1
View File
@@ -28,6 +28,7 @@ builder.Services.AddScoped<IVaultAccessService, VaultAccessService>();
builder.Services.AddScoped<SyncService>(); builder.Services.AddScoped<SyncService>();
builder.Services.AddScoped<IdentityService>(); builder.Services.AddScoped<IdentityService>();
builder.Services.AddScoped<EnrollmentService>(); builder.Services.AddScoped<EnrollmentService>();
builder.Services.AddScoped<DeviceService>();
builder.Services.AddScoped<IIdentityBindingVerifier, IdentityBindingVerifier>(); builder.Services.AddScoped<IIdentityBindingVerifier, IdentityBindingVerifier>();
builder.Services.AddSingleton<ICursorKeyProvider, CursorKeyProvider>(); builder.Services.AddSingleton<ICursorKeyProvider, CursorKeyProvider>();
@@ -34,6 +34,17 @@ public interface IAccountApi
/// <summary>Publishes the caller's first identity key and creates their personal vault.</summary> /// <summary>Publishes the caller's first identity key and creates their personal vault.</summary>
Task<EnrollmentResponse> EnrollAsync(EnrollmentRequest request, CancellationToken cancellationToken); Task<EnrollmentResponse> EnrollAsync(EnrollmentRequest request, CancellationToken cancellationToken);
/// <summary>
/// Registers a device key against an account that is already enrolled.
/// </summary>
/// <remarks>
/// On the interface rather than only on the client, because the session layer decides <em>when</em> to
/// offer this — after an unlock, never before — and that decision is worth testing without HTTP.
/// </remarks>
Task<RegisterDeviceResponse> RegisterDeviceAsync(
RegisterDeviceRequest request,
CancellationToken cancellationToken);
} }
/// <summary> /// <summary>
@@ -83,6 +94,7 @@ public sealed class DodoSshApiClient(HttpClient http, IAccessTokenProvider token
private const string ConfigurationPath = "/.well-known/dodossh-configuration"; private const string ConfigurationPath = "/.well-known/dodossh-configuration";
private const string MePath = "/api/v1/me"; private const string MePath = "/api/v1/me";
private const string EnrollmentPath = "/api/v1/me/enrollment"; private const string EnrollmentPath = "/api/v1/me/enrollment";
private const string DevicesPath = "/api/v1/me/devices";
/// <summary> /// <summary>
/// Reads the server's capabilities, versions and limits. /// Reads the server's capabilities, versions and limits.
@@ -130,6 +142,21 @@ public sealed class DodoSshApiClient(HttpClient http, IAccessTokenProvider token
DodoSshJsonContext.Default.EnrollmentResponse, DodoSshJsonContext.Default.EnrollmentResponse,
cancellationToken); cancellationToken);
/// <summary>Registers a device key against an already-enrolled account.</summary>
/// <remarks>
/// 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.
/// </remarks>
public Task<RegisterDeviceResponse> RegisterDeviceAsync(
RegisterDeviceRequest request,
CancellationToken cancellationToken) =>
SendAsync(
HttpMethod.Post,
DevicesPath,
JsonContent.Create(request, DodoSshJsonContext.Default.RegisterDeviceRequest),
DodoSshJsonContext.Default.RegisterDeviceResponse,
cancellationToken);
/// <summary>Reads vault changes after a cursor.</summary> /// <summary>Reads vault changes after a cursor.</summary>
/// <remarks> /// <remarks>
/// A POST despite being a read: the filters live in the body, cursors are opaque, and no caching is /// A POST despite being a read: the filters live in the body, cursors are opaque, and no caching is
+45
View File
@@ -0,0 +1,45 @@
namespace DodoSSH.Contracts;
/// <summary>
/// Registers a device key against an already-enrolled account, so that machine can unlock without the
/// passphrase.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// <b>Possession of the bundle is proved by construction.</b> Only a caller who has opened the secret
/// bundle can produce <paramref name="WrappedPrivateKey"/> — it is that bundle sealed to
/// <paramref name="PublicKey"/> — 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.
/// </para>
/// <para>
/// The platform is deliberately not carried. It would be display-only, and a wire enum mirroring the
/// server's <c>DevicePlatform</c> 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.
/// </para>
/// </remarks>
/// <param name="Name">Human-readable name for this machine, shown in the account's device list.</param>
/// <param name="PublicKey">X25519 public key of this device, 32 bytes.</param>
/// <param name="WrappedPrivateKey">
/// The caller's secret bundle sealed to <paramref name="PublicKey"/>. Opaque to the server.
/// </param>
public sealed record RegisterDeviceRequest(
string Name,
byte[] PublicKey,
byte[] WrappedPrivateKey);
/// <summary>The registered device.</summary>
/// <remarks>
/// 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 <paramref name="EnrolledAt"/> may predate the call.
/// </remarks>
/// <param name="DeviceId">The device's identifier, for later revocation.</param>
/// <param name="EnrolledAt">When this device was first registered.</param>
public sealed record RegisterDeviceResponse(
Guid DeviceId,
DateTimeOffset EnrolledAt);
@@ -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. // on its own when persisting it, and a resolver that had only inferred it would fail at runtime.
[JsonSerializable(typeof(KeyStatement))] [JsonSerializable(typeof(KeyStatement))]
[JsonSerializable(typeof(EnrollmentResponse))] [JsonSerializable(typeof(EnrollmentResponse))]
[JsonSerializable(typeof(RegisterDeviceRequest))]
[JsonSerializable(typeof(RegisterDeviceResponse))]
[JsonSerializable(typeof(DirectoryEntry))] [JsonSerializable(typeof(DirectoryEntry))]
[JsonSerializable(typeof(IReadOnlyList<DirectoryEntry>))] [JsonSerializable(typeof(IReadOnlyList<DirectoryEntry>))]
[JsonSerializable(typeof(VaultSummary))] [JsonSerializable(typeof(VaultSummary))]
+11
View File
@@ -43,6 +43,17 @@ public static class ProblemCodes
/// </summary> /// </summary>
public const string IdentityBindingInvalid = "identity-binding-invalid"; public const string IdentityBindingInvalid = "identity-binding-invalid";
/// <summary>
/// A device registration was structurally invalid: a public key of the wrong length, a missing or
/// oversized wrap, or a blank name.
/// </summary>
/// <remarks>
/// Distinct from <see cref="InvalidEnrollment"/> 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.
/// </remarks>
public const string InvalidDeviceRegistration = "invalid-device-registration";
/// <summary>The relay refused the requested target. Never states why, to avoid a probe oracle.</summary> /// <summary>The relay refused the requested target. Never states why, to avoid a probe oracle.</summary>
public const string RelayTargetRejected = "relay-target-rejected"; public const string RelayTargetRejected = "relay-target-rejected";
+47 -16
View File
@@ -1,4 +1,19 @@
#nullable enable #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.<Clone>$() -> DodoSSH.Contracts.DirectoryEntry! DodoSSH.Contracts.DirectoryEntry.<Clone>$() -> 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 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.<Clone>$() -> 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.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.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.get -> System.DateTimeOffset
DodoSSH.Contracts.PersonalVaultRequest.GrantedAt.init -> void 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.get -> string!
DodoSSH.Contracts.PersonalVaultRequest.Name.init -> void 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.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.get -> byte[]!
DodoSSH.Contracts.PersonalVaultRequest.WrappedVaultKey.init -> void DodoSSH.Contracts.PersonalVaultRequest.WrappedVaultKey.init -> void
DodoSSH.Contracts.ProblemCodes DodoSSH.Contracts.ProblemCodes
DodoSSH.Contracts.RegisterDeviceRequest
DodoSSH.Contracts.RegisterDeviceRequest.<Clone>$() -> 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.<Clone>$() -> 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.<Clone>$() -> DodoSSH.Contracts.RelayConfiguration! DodoSSH.Contracts.RelayConfiguration.<Clone>$() -> DodoSSH.Contracts.RelayConfiguration!
DodoSSH.Contracts.RelayConfiguration.Deconstruct(out bool Enabled, out System.Uri? WebSocketUrl) -> void 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.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.get -> byte[]?
DodoSSH.Contracts.VaultSummary.WrappedVaultKey.init -> void 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.Equals(object? obj) -> bool
override DodoSSH.Contracts.DirectoryEntry.GetHashCode() -> int override DodoSSH.Contracts.DirectoryEntry.GetHashCode() -> int
override DodoSSH.Contracts.DirectoryEntry.ToString() -> string! 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.Equals(object? obj) -> bool
override DodoSSH.Contracts.PersonalVaultRequest.GetHashCode() -> int override DodoSSH.Contracts.PersonalVaultRequest.GetHashCode() -> int
override DodoSSH.Contracts.PersonalVaultRequest.ToString() -> string! 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.Equals(object? obj) -> bool
override DodoSSH.Contracts.RelayConfiguration.GetHashCode() -> int override DodoSSH.Contracts.RelayConfiguration.GetHashCode() -> int
override DodoSSH.Contracts.RelayConfiguration.ToString() -> string! 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.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.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.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 static DodoSSH.Contracts.RelaySessionSummary.operator !=(DodoSSH.Contracts.RelaySessionSummary? left, DodoSSH.Contracts.RelaySessionSummary? right) -> bool
@@ -22,6 +22,7 @@ public sealed class IdentityEndpointTests(ApiFixture fixture)
{ {
private const string MeUrl = "/api/v1/me"; private const string MeUrl = "/api/v1/me";
private const string EnrollUrl = "/api/v1/me/enrollment"; private const string EnrollUrl = "/api/v1/me/enrollment";
private const string DevicesUrl = "/api/v1/me/devices";
// ---- Provisioning and /me ---- // ---- Provisioning and /me ----
@@ -839,6 +840,150 @@ public sealed class IdentityEndpointTests(ApiFixture fixture)
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
} }
// ---- Registering a device after enrollment ----
[Fact]
public async Task RegisterDevice_AddsADeviceAndAWrapThatOnlyThatDeviceCanOpen()
{
// The gap this endpoint closes. EnrollmentService.AddDevice only ever runs during enrollment, so
// without this every account that existed before a keystore was wired up could never turn the
// feature on — which is all of them.
using var enrollment = NewEnrollment();
var client = enrollment.CreateClient(fixture);
var enrolled = await EnrollAsync(client, enrollment.Build());
var publicKey = DeviceKey();
var registered = await RegisterDeviceAsync(
client,
new RegisterDeviceRequest("a second laptop", publicKey, Wrap()));
registered.DeviceId.ShouldNotBe(Guid.Empty);
await using var scope = fixture.CreateScope();
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
var device = await database.Devices.SingleAsync(d => d.Id == registered.DeviceId);
device.UserId.ShouldBe(enrolled.UserId);
device.Name.ShouldBe("a second laptop");
device.PublicKey.ShouldBe(publicKey);
var wrap = await database.UserKeyWraps.SingleAsync(w => w.DeviceId == registered.DeviceId);
wrap.Kind.ShouldBe(UserKeyWrapKind.Device);
wrap.UserId.ShouldBe(enrolled.UserId);
}
[Fact]
public async Task RegisterDevice_BeforeEnrolling_AsksForEnrollmentRatherThanRejectingTheShape()
{
// You cannot wrap a bundle to a device before you have a bundle. The distinction that matters is
// which answer the client gets: "enroll first" is actionable, and a 400 about the request's shape
// would send somebody looking at their key length.
var subject = NewSubject();
var client = fixture.CreateClientFor(subject, $"{subject}@example.com");
var response = await client.PostContractAsync(
DevicesUrl,
new RegisterDeviceRequest("a laptop", DeviceKey(), Wrap()));
await ShouldBeProblemAsync(
response,
HttpStatusCode.Forbidden,
ProblemCodes.EnrollmentRequired);
}
[Fact]
public async Task RegisterDevice_TwiceWithTheSameKey_ReturnsTheSameDeviceAndAddsNoSecondWrap()
{
// Idempotent, as enrollment is. A dropped response must leave a retry safe, and a second row for
// one key would mean a device list with a duplicate and two wraps to revoke instead of one.
using var enrollment = NewEnrollment();
var client = enrollment.CreateClient(fixture);
await EnrollAsync(client, enrollment.Build());
var publicKey = DeviceKey();
var request = new RegisterDeviceRequest("a laptop", publicKey, Wrap());
var first = await RegisterDeviceAsync(client, request);
var second = await RegisterDeviceAsync(client, request);
second.DeviceId.ShouldBe(first.DeviceId);
second.EnrolledAt.ShouldBe(first.EnrolledAt);
await using var scope = fixture.CreateScope();
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
(await database.Devices.CountAsync(d => d.PublicKey == publicKey)).ShouldBe(1);
(await database.UserKeyWraps.CountAsync(w => w.DeviceId == first.DeviceId)).ShouldBe(1);
}
[Theory]
[InlineData(31)]
[InlineData(33)]
public async Task RegisterDevice_WithAPublicKeyOfTheWrongLength_IsRejected(int length)
{
// The same rule enrollment applies. A key that is not an X25519 public key is one nothing can ever
// seal to, so the row would be permanently useless.
using var enrollment = NewEnrollment();
var client = enrollment.CreateClient(fixture);
await EnrollAsync(client, enrollment.Build());
var response = await client.PostContractAsync(
DevicesUrl,
new RegisterDeviceRequest("a laptop", new byte[length], Wrap()));
await ShouldBeProblemAsync(
response,
HttpStatusCode.BadRequest,
ProblemCodes.InvalidDeviceRegistration);
}
[Fact]
public async Task RegisterDevice_WithNoWrap_IsRejected()
{
// A device key with no wrap registers a device that can never unlock anything, and the server
// cannot fill the gap in — only the holder of the bundle can seal it.
using var enrollment = NewEnrollment();
var client = enrollment.CreateClient(fixture);
await EnrollAsync(client, enrollment.Build());
var response = await client.PostContractAsync(
DevicesUrl,
new RegisterDeviceRequest("a laptop", DeviceKey(), []));
await ShouldBeProblemAsync(
response,
HttpStatusCode.BadRequest,
ProblemCodes.InvalidDeviceRegistration);
}
[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 ---- // ---- Helpers ----
private static string NewSubject() => $"user-{Guid.CreateVersion7():N}"; private static string NewSubject() => $"user-{Guid.CreateVersion7():N}";
@@ -871,6 +1016,31 @@ public sealed class IdentityEndpointTests(ApiFixture fixture)
return body; return body;
} }
private static async Task<RegisterDeviceResponse> RegisterDeviceAsync(
HttpClient client,
RegisterDeviceRequest request)
{
var response = await client.PostContractAsync(DevicesUrl, request);
response.EnsureSuccessStatusCode();
var body = await response.Content.ReadContractAsync<RegisterDeviceResponse>();
body.ShouldNotBeNull();
return body;
}
/// <remarks>
/// A distinct 32 bytes per call, because the endpoint is idempotent on the public key and two tests
/// sharing one would silently be asserting about each other's device.
/// </remarks>
private static byte[] DeviceKey() => Guid.CreateVersion7().ToByteArray().Concat(
Guid.CreateVersion7().ToByteArray()).ToArray();
/// <remarks>
/// Not a real envelope. The server treats a wrap as opaque bytes and validates only that it is present
/// and bounded, which is the whole of what it is allowed to know.
/// </remarks>
private static byte[] Wrap() => [1, 2, 3, 4];
private static Task ShouldBeBindingRejectedAsync(HttpResponseMessage response) => private static Task ShouldBeBindingRejectedAsync(HttpResponseMessage response) =>
ShouldBeProblemAsync( ShouldBeProblemAsync(
response, response,
@@ -43,6 +43,9 @@ internal sealed class FakeVaultServer : IVaultServer, IAccountApi, ISyncApi, IKe
internal int LiveRowCount => rows.Values.Count(row => row.Operation != SyncOperation.Delete); internal int LiveRowCount => rows.Values.Count(row => row.Operation != SyncOperation.Delete);
/// <summary>Device wraps registered after enrollment, keyed on the device public key.</summary>
internal Dictionary<string, byte[]> RegisteredDevices { get; } = new(StringComparer.Ordinal);
/// <summary>When set, the next sign-in throws — how an unreachable server is exercised.</summary> /// <summary>When set, the next sign-in throws — how an unreachable server is exercised.</summary>
internal Exception? SignInFailure { get; set; } internal Exception? SignInFailure { get; set; }
@@ -130,6 +133,30 @@ internal sealed class FakeVaultServer : IVaultServer, IAccountApi, ISyncApi, IKe
KeyLogSequence: 1)); KeyLogSequence: 1));
} }
/// <inheritdoc />
/// <remarks>
/// Records the wrap so a test can assert it reached the server, and refuses before enrollment as the
/// real endpoint's <c>Auth.EnrolledPolicy</c> does.
/// </remarks>
public Task<RegisterDeviceResponse> 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 ---- // ---- Sync ----
/// <inheritdoc /> /// <inheritdoc />
@@ -40,6 +40,16 @@ internal sealed class FakeAccountServer : IAccountApi
/// <summary>Whether an identity key has been published.</summary> /// <summary>Whether an identity key has been published.</summary>
internal bool IsEnrolled => statement is not null; internal bool IsEnrolled => statement is not null;
/// <summary>
/// Device wraps registered after enrollment, keyed on the device public key.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
internal Dictionary<string, byte[]> RegisteredDevices { get; } = new(StringComparer.Ordinal);
/// <inheritdoc /> /// <inheritdoc />
public Task<MeResponse> GetMeAsync(CancellationToken cancellationToken) public Task<MeResponse> GetMeAsync(CancellationToken cancellationToken)
{ {
@@ -100,6 +110,36 @@ internal sealed class FakeAccountServer : IAccountApi
KeyLogSequence: 1)); KeyLogSequence: 1));
} }
/// <inheritdoc />
/// <remarks>
/// Refuses before enrollment, as the real endpoint does through <c>Auth.EnrolledPolicy</c>: 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.
/// </remarks>
public Task<RegisterDeviceResponse> 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));
}
/// <summary>Drops the vault grant, as a rekey does until it is re-issued.</summary> /// <summary>Drops the vault grant, as a rekey does until it is re-issued.</summary>
internal void RevokeVaultGrant() => internal void RevokeVaultGrant() =>
personalVault = personalVault is null personalVault = personalVault is null