Public Access
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.
72 lines
3.4 KiB
C#
72 lines
3.4 KiB
C#
namespace DodoSSH.Contracts;
|
|
|
|
/// <summary>
|
|
/// Stable machine-readable error codes returned in the <c>code</c> extension of an
|
|
/// RFC 9457 ProblemDetails response.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// These live in Contracts so the client switches on constants rather than parsing prose.
|
|
/// The values are part of the public contract: add freely, never rename or repurpose.
|
|
/// </remarks>
|
|
public static class ProblemCodes
|
|
{
|
|
/// <summary>The base URI that every problem <c>type</c> is formed under.</summary>
|
|
public const string TypeBaseUri = "https://dodossh.dev/problems/";
|
|
|
|
/// <summary>A push operation's <c>expectedVersion</c> did not match the stored row.</summary>
|
|
public const string VaultConflict = "vault-conflict";
|
|
|
|
/// <summary>The caller is authenticated but lacks the required permission.</summary>
|
|
public const string Forbidden = "forbidden";
|
|
|
|
/// <summary>The sync cursor was malformed, or failed its integrity tag.</summary>
|
|
public const string InvalidCursor = "invalid-cursor";
|
|
|
|
/// <summary>An <c>Idempotency-Key</c> was reused with a different request body.</summary>
|
|
public const string IdempotencyKeyReuse = "idempotency-key-reuse";
|
|
|
|
/// <summary>The caller has not yet enrolled a public key, so no vault is reachable.</summary>
|
|
public const string EnrollmentRequired = "enrollment-required";
|
|
|
|
/// <summary>Enrollment was attempted for a user who already holds a different current key.</summary>
|
|
public const string AlreadyEnrolled = "already-enrolled";
|
|
|
|
/// <summary>
|
|
/// The enrollment request was structurally invalid: a bad key length, mismatched KDF
|
|
/// parameters, a statement that does not describe the caller, or a vault id already in use.
|
|
/// </summary>
|
|
public const string InvalidEnrollment = "invalid-enrollment";
|
|
|
|
/// <summary>
|
|
/// The identity-provider token did not bind the supplied keys: a bad signature, the wrong
|
|
/// subject or audience, an expired token, or a <c>nonce</c> that is not the statement's hash.
|
|
/// </summary>
|
|
public const string IdentityBindingInvalid = "identity-binding-invalid";
|
|
|
|
/// <summary>
|
|
/// 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>
|
|
public const string RelayTargetRejected = "relay-target-rejected";
|
|
|
|
/// <summary>The relay ticket is expired, already used, or not valid for this node.</summary>
|
|
public const string RelayTicketInvalid = "relay-ticket-invalid";
|
|
|
|
/// <summary>A per-user or per-node relay session limit was reached.</summary>
|
|
public const string RelayLimitReached = "relay-limit-reached";
|
|
|
|
/// <summary>The client is older than the server's <c>minClientVersion</c>.</summary>
|
|
public const string ClientTooOld = "client-too-old";
|
|
|
|
/// <summary>A push batch exceeded the operation count or payload size cap.</summary>
|
|
public const string PushBatchTooLarge = "push-batch-too-large";
|
|
}
|