Add the server client and client-side enrollment

A typed client over DodoSSH.Contracts, and the orchestration that turns a
passphrase into an enrolled identity: generate keys, have the identity
provider sign over them, wrap the bundle three ways, create the personal
vault, publish.

Ordering here is forced, not chosen. The secret bundle's AAD binds to the
server-assigned user id, so /me has to be read before anything can be
wrapped -- which is exactly why /me provisions the account and returns its id
even while reporting that enrollment is required. That constraint was
designed into the server earlier; this is the first code that depends on it.

The grant tuple now has a real canonical encoding (crypto.md 7.3) rather
than the placeholder signature I would otherwise have had to invent and then
keep. §7 named the tuple without specifying how to encode it; this fills that
in with the same conventions as 7.1, and the self-grant at enrollment is
already in its final format. The signature covers SHA-256(wrappedKey) rather
than the key, so a verifier can check attribution without holding the vault
key at all.

The most valuable tests are the negative ones about the request body: the
server is meant to be unable to read what it stores, and a refactor that put
a passphrase or a private key into the enrollment request would be invisible
to every other test in the repository. So one asserts the body contains
neither the passphrase, the recovery code, nor any private key in base64 or
hex. Another opens the same bundle three ways -- passphrase, recovery code and
device key -- which is what makes a passphrase change a one-row update.

ClientEnrollment depends on IKeyBindingAuthorizer rather than the whole
OidcClient. It needs exactly one capability, and depending on the full client
would drag discovery and token exchange into every test of key binding.

Two things fixed while building it. The recovery code buffer was sized one
separator short, so every enrollment threw IndexOutOfRange -- caught
immediately because nine of ten tests failed identically. And the crypto
enum collided with Domain.GrantKind in the server, so it is GrantPurpose
there; the numeric values still have to match, which the doc and a test both
say.

448 tests pass, zero warnings on a clean rebuild, format clean.
This commit is contained in:
2026-07-28 22:42:56 +02:00
parent 5fccd53824
commit a878c2b6bb
15 changed files with 3301 additions and 1 deletions
+2
View File
@@ -16,6 +16,7 @@
<Project Path="src/DodoSSH.Domain/DodoSSH.Domain.csproj" />
<Project Path="src/DodoSSH.Infrastructure/DodoSSH.Infrastructure.csproj" />
<Project Path="src/DodoSSH.Api/DodoSSH.Api.csproj" />
<Project Path="src/DodoSSH.Client.Api/DodoSSH.Client.Api.csproj" />
<Project Path="src/DodoSSH.Client.App/DodoSSH.Client.App.csproj" />
<Project Path="src/DodoSSH.Client.Auth/DodoSSH.Client.Auth.csproj" />
<Project Path="src/DodoSSH.Client.Ssh/DodoSSH.Client.Ssh.csproj" />
@@ -24,6 +25,7 @@
<Folder Name="/tests/">
<Project Path="tests/DodoSSH.Api.Tests/DodoSSH.Api.Tests.csproj" />
<Project Path="tests/DodoSSH.Client.Api.Tests/DodoSSH.Client.Api.Tests.csproj" />
<Project Path="tests/DodoSSH.Client.Auth.Tests/DodoSSH.Client.Auth.Tests.csproj" />
<Project Path="tests/DodoSSH.Client.Ssh.Tests/DodoSSH.Client.Ssh.Tests.csproj" />
<Project Path="tests/DodoSSH.Client.Terminal.Tests/DodoSSH.Client.Terminal.Tests.csproj" />
+32
View File
@@ -387,6 +387,38 @@ Appends must be serialised (the server takes a deployment-wide advisory lock). T
appends reading the same head would produce two entries claiming the same predecessor, which is
indistinguishable from the fork the chain exists to detect.
### 7.3 Vault key grant — canonical encoding
> **Added 2026-07-28.** §7 named the grant tuple without specifying its encoding. This fills that in,
> using the same conventions as §7.1. Pinned by `GrantStatementCodecTests`.
```
grant = "dsh1/grant/v1" 13 bytes, literal
|| u32 keyGeneration big-endian
|| u8 grantKind 1 = Member, 2 = Recovery, 3 = Escrow
|| vaultId 16 bytes, RFC 4122 big-endian
|| granteeUserId 16 bytes, all-zero for a non-member grant
|| granteeKeyFingerprint 32 bytes
|| SHA-256(wrappedKey) 32 bytes
|| granterUserId 16 bytes
|| granterKeyFingerprint 32 bytes
|| keyLogHead 0x00, or 0x01 followed by 32 bytes
|| i64 grantedAt big-endian, Unix milliseconds, UTC
```
Signed with context `dsh1/sig/grant/v1`.
- **The digest of the wrapped key, not the key.** A verifier must be able to check who issued a grant
without holding the vault key, which is the whole point of separating attribution from access.
- **`grantKind` values are load-bearing.** They must match `DodoSSH.Domain.GrantKind` exactly; the
crypto-layer enum is named `GrantPurpose` only to avoid a name collision in the server, where both
are visible. Renumbering either would make every grant of the changed kind fail verification
permanently.
- **The key log head is optional, with a presence byte.** Absent for a self-grant: there is no third
party whose key could have been substituted, and the log entry that would supply a head is written
by the server in the same transaction, so a client cannot have signed over it. Without the presence
byte, "no head" and "a head of 32 zero bytes" would be indistinguishable.
## 8. Fingerprints and versioning
```
+315
View File
@@ -0,0 +1,315 @@
using System.Security.Cryptography;
using DodoSSH.Client.Auth;
using DodoSSH.Contracts;
using DodoSSH.Crypto;
using NSec.Cryptography;
namespace DodoSSH.Client.Api;
/// <summary>What enrolling produced, for the caller to hold and persist.</summary>
/// <remarks>
/// The bundle and the vault key are live secrets. The caller owns their lifetime and must dispose the
/// bundle; neither is ever written anywhere but the OS keystore and the encrypted local cache.
/// </remarks>
/// <param name="Response">The server's answer, including the vault and key log position.</param>
/// <param name="Bundle">The identity key pair, unlocked for this session.</param>
/// <param name="PersonalVaultKey">The personal vault's key, in plaintext for this session.</param>
/// <param name="DevicePrivateKey">
/// The enrolled device's X25519 private key. Belongs in the OS keystore — it is what lets a later
/// launch unlock without the passphrase.
/// </param>
/// <param name="RecoveryCode">
/// The generated recovery code, which must be shown to the user once and never stored. Losing this
/// along with the passphrase and every device means the vault is unrecoverable, and no server-side
/// reset is possible by design.
/// </param>
public sealed record EnrollmentOutcome(
EnrollmentResponse Response,
UserSecretBundle Bundle,
byte[] PersonalVaultKey,
byte[] DevicePrivateKey,
string RecoveryCode);
/// <summary>
/// Runs enrollment: generate keys, have the identity provider sign over them, and publish.
/// </summary>
/// <remarks>
/// <para>
/// Ordering here is not a matter of taste. The secret bundle's AAD binds to the server-assigned user
/// id, so <c>/me</c> must be read before anything can be wrapped — which is why <c>/me</c> provisions
/// the account and returns its id even when it reports that enrollment is required.
/// </para>
/// <para>
/// Everything the server receives is opaque to it. It gets public keys, wrapped blobs it cannot open,
/// and signatures it does not verify beyond the statement's own. That is the whole point: the server
/// stores the vault and cannot read it.
/// </para>
/// </remarks>
public sealed class ClientEnrollment(
DodoSshApiClient api,
IKeyBindingAuthorizer keyBinding,
TimeProvider clock)
{
/// <summary>Bytes of entropy behind a recovery code.</summary>
private const int RecoveryEntropyBytes = 20;
/// <summary>
/// Enrolls the caller.
/// </summary>
/// <param name="me">
/// The result of <see cref="DodoSshApiClient.GetMeAsync"/>, which supplies the user id the wraps
/// bind to.
/// </param>
/// <param name="passphrase">The vault passphrase. Never transmitted or stored.</param>
/// <param name="deviceName">Human-readable name for this machine.</param>
/// <param name="vaultName">Display name for the personal vault. Plaintext, as vault names are.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task<EnrollmentOutcome> EnrollAsync(
MeResponse me,
string passphrase,
string deviceName,
string vaultName,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(me);
ArgumentException.ThrowIfNullOrWhiteSpace(passphrase);
ArgumentException.ThrowIfNullOrWhiteSpace(deviceName);
var now = clock.GetUtcNow();
var bundle = UserSecretBundle.Create(now);
try
{
var statement = BuildStatement(me, bundle, now, deviceName);
// The provider signs over the statement's hash, which is what stops the DodoSSH server
// fabricating a key for a user who never enrolled. See ADR 0001.
var idToken = await keyBinding
.AuthorizeKeyBindingAsync(
KeyStatementCodec.ComputeNonce(ToFields(statement)),
cancellationToken)
.ConfigureAwait(false);
var request = BuildRequest(
me, bundle, statement, idToken, passphrase, vaultName, now, out var material);
var response = await api.EnrollAsync(request, cancellationToken).ConfigureAwait(false);
return new EnrollmentOutcome(
response,
bundle,
material.VaultKey,
material.DevicePrivateKey,
material.RecoveryCode);
}
catch
{
// The caller never receives the bundle on failure, so this is the only place that can
// release its guarded memory.
bundle.Dispose();
throw;
}
}
private static KeyStatement BuildStatement(
MeResponse me,
UserSecretBundle bundle,
DateTimeOffset now,
string deviceName) =>
new(
Version: KeyStatementCodec.CurrentVersion,
Issuer: me.Issuer,
Subject: me.Subject,
Email: me.Email,
EncryptionPublicKey: bundle.EncryptionPublicKey,
SigningPublicKey: bundle.SigningPublicKey,
KeyGeneration: 1,
CreatedAt: now,
DeviceName: deviceName);
/// <summary>Secrets the caller keeps after a successful enrollment.</summary>
private readonly record struct SessionMaterial(
byte[] VaultKey,
byte[] DevicePrivateKey,
string RecoveryCode);
/// <remarks>
/// The passphrase is a parameter rather than a field, so it lives only for the duration of this call
/// and never becomes state on a long-lived object that a heap dump would find.
/// </remarks>
private static EnrollmentRequest BuildRequest(
MeResponse me,
UserSecretBundle bundle,
KeyStatement statement,
string idToken,
string passphrase,
string vaultName,
DateTimeOffset now,
out SessionMaterial material)
{
var descriptor = DshAad.UserSecretBundle(me.UserId);
// A fresh salt per wrap, and the parameters travel with it — so raising the cost later is a
// per-user migration at next unlock rather than a breaking change.
var passphraseSalt = RandomNumberGenerator.GetBytes(CryptoSpec.SaltSize);
var recoverySalt = RandomNumberGenerator.GetBytes(CryptoSpec.SaltSize);
var recoveryCode = GenerateRecoveryCode();
byte[] passphraseWrap;
byte[] recoveryWrap;
using (var master = MasterKey.Derive(
passphrase, passphraseSalt, Argon2Profile.PassphraseDefault))
{
passphraseWrap = master.WrapBundle(bundle, descriptor);
}
// The recovery code carries real entropy, so it needs far less stretching than a passphrase.
using (var recoveryMaster = MasterKey.Derive(
recoveryCode, recoverySalt, Argon2Profile.RandomSecret))
{
recoveryWrap = recoveryMaster.WrapBundle(bundle, descriptor);
}
using var deviceKey = Key.Create(
KeyAgreementAlgorithm.X25519,
new KeyCreationParameters { ExportPolicy = KeyExportPolicies.AllowPlaintextExport });
var devicePublicKey = deviceKey.PublicKey.Export(KeyBlobFormat.RawPublicKey);
var deviceWrap = bundle.SealTo(devicePublicKey, descriptor);
var vault = BuildPersonalVault(me, bundle, vaultName, now, out var vaultKey);
material = new SessionMaterial(
vaultKey,
deviceKey.Export(KeyBlobFormat.RawPrivateKey),
recoveryCode);
return new EnrollmentRequest(
Statement: statement,
StatementSignature: DshSignatures.SignKeyStatement(
bundle.SigningKey,
KeyStatementCodec.Encode(ToFields(statement))),
IdentityProviderToken: idToken,
WrappedPrivateKey: passphraseWrap,
KdfParameters: ToContract(passphraseSalt, Argon2Profile.PassphraseDefault),
DevicePublicKey: devicePublicKey,
DeviceWrappedPrivateKey: deviceWrap,
RecoveryWrappedPrivateKey: recoveryWrap,
RecoveryKdfParameters: ToContract(recoverySalt, Argon2Profile.RandomSecret),
PersonalVault: vault);
}
/// <remarks>
/// The vault id is chosen here rather than by the server, which is what makes enrollment safely
/// retryable and is required by the grant signature — the signed tuple covers the vault id.
/// <para>
/// A self-grant carries no key log head: there is no third party whose key could have been
/// substituted, and the log entry that would supply one is written by the server in the same
/// transaction, so it cannot be signed over here.
/// </para>
/// </remarks>
private static PersonalVaultRequest BuildPersonalVault(
MeResponse me,
UserSecretBundle bundle,
string vaultName,
DateTimeOffset now,
out byte[] vaultKey)
{
var vaultId = Guid.CreateVersion7();
vaultKey = VaultKeys.Create();
var wrappedVaultKey = VaultKeys.WrapTo(vaultKey, bundle.EncryptionPublicKey, vaultId, 1);
var fingerprint = DshCrypto.ComputeFingerprint(
bundle.EncryptionPublicKey,
bundle.SigningPublicKey);
var grant = GrantStatementCodec.Encode(
vaultId,
keyGeneration: 1,
GrantPurpose.Member,
granteeUserId: me.UserId,
granteeKeyFingerprint: fingerprint,
wrappedKey: wrappedVaultKey,
granterUserId: me.UserId,
granterKeyFingerprint: fingerprint,
keyLogHead: default,
grantedAt: now);
return new PersonalVaultRequest(
VaultId: vaultId,
Name: vaultName,
WrappedVaultKey: wrappedVaultKey,
GrantSignature: GrantStatementCodec.Sign(bundle.SigningKey, grant),
GrantedAt: now);
}
/// <summary>
/// Generates a printable recovery code.
/// </summary>
/// <remarks>
/// Base32 over Crockford's alphabet, which omits I, L, O and U — so a code read aloud or copied off
/// a screen cannot be mistranscribed into a different valid code, and cannot spell anything
/// unfortunate. Grouped for legibility, and the groups are not part of the secret: the derivation
/// uses the string exactly as shown, dashes included, because that is what the user will type back.
/// </remarks>
private static string GenerateRecoveryCode()
{
const string Alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
const int BitsPerCharacter = 5;
const int CharactersPerGroup = 5;
// 20 bytes is 160 bits, which divides evenly into 32 five-bit characters — so no bits are
// discarded and no padding is needed. Separators go between groups, hence one fewer than the
// number of groups.
var totalCharacters = RecoveryEntropyBytes * 8 / BitsPerCharacter;
var separators = (totalCharacters - 1) / CharactersPerGroup;
var entropy = RandomNumberGenerator.GetBytes(RecoveryEntropyBytes);
var characters = new char[totalCharacters + separators];
var index = 0;
for (var position = 0; position < totalCharacters; position++)
{
if (position > 0 && position % CharactersPerGroup == 0)
{
characters[index++] = '-';
}
var value = 0;
for (var offset = 0; offset < BitsPerCharacter; offset++)
{
var bit = (position * BitsPerCharacter) + offset;
value = (value << 1) | ((entropy[bit / 8] >> (7 - (bit % 8))) & 1);
}
characters[index++] = Alphabet[value];
}
CryptographicOperations.ZeroMemory(entropy);
return new string(characters);
}
private static KdfParameters ToContract(byte[] salt, Argon2Profile profile) =>
new(
Algorithm: "argon2id",
Salt: salt,
MemoryKibibytes: profile.MemoryKibibytes,
Passes: profile.Passes,
Parallelism: Argon2Profile.Parallelism);
private static KeyStatementFields ToFields(KeyStatement statement) =>
new(
statement.Version,
statement.Issuer,
statement.Subject,
statement.Email,
statement.EncryptionPublicKey,
statement.SigningPublicKey,
statement.KeyGeneration,
statement.CreatedAt,
statement.DeviceName);
}
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
The typed client for a DodoSSH server. Avalonia-free, so the whole request/response surface
and the enrollment orchestration are testable against a stubbed server.
-->
<ItemGroup>
<ProjectReference Include="../DodoSSH.Contracts/DodoSSH.Contracts.csproj" />
<ProjectReference Include="../DodoSSH.Crypto/DodoSSH.Crypto.csproj" />
<ProjectReference Include="../DodoSSH.Client.Auth/DodoSSH.Client.Auth.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="DodoSSH.Client.Api.Tests" />
</ItemGroup>
</Project>
+175
View File
@@ -0,0 +1,175 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using DodoSSH.Contracts;
namespace DodoSSH.Client.Api;
/// <summary>Supplies the bearer token for API calls, refreshing it when needed.</summary>
/// <remarks>
/// An abstraction because token lifetime is the auth layer's problem, not the API client's. The
/// client asks for a token per request and never caches one, so a refresh that happens mid-session is
/// invisible here rather than something every call site has to remember to handle.
/// </remarks>
public interface IAccessTokenProvider
{
/// <summary>Returns a currently-valid access token.</summary>
ValueTask<string> GetAccessTokenAsync(CancellationToken cancellationToken);
}
/// <summary>
/// The typed client for one DodoSSH server.
/// </summary>
/// <remarks>
/// <para>
/// Everything goes through <c>DodoSSH.Contracts</c> and its source-generated serialiser, which is the
/// actual contract between the two sides — not the OpenAPI document. Requests are written with
/// <c>StrictRequestOptions</c> on the server and read here with <c>ResponseOptions</c>, so an older
/// client tolerates a newer server's extra fields instead of failing on them.
/// </para>
/// <para>
/// Discovery is unauthenticated by necessity: a client has to learn how to authenticate before it can.
/// Everything else carries a bearer token.
/// </para>
/// </remarks>
public sealed class DodoSshApiClient(HttpClient http, IAccessTokenProvider tokens)
{
private const string MetaPath = "/api/v1/meta";
private const string ConfigurationPath = "/.well-known/dodossh-configuration";
private const string MePath = "/api/v1/me";
private const string EnrollmentPath = "/api/v1/me/enrollment";
/// <summary>
/// Reads the server's capabilities, versions and limits.
/// </summary>
/// <remarks>
/// Unauthenticated, and the replacement for URL-based API versioning: when client and server
/// upgrade independently — normal for self-hosted software — a client has to ask what this
/// particular server supports rather than assume. See ADR 0002.
/// </remarks>
public Task<MetaResponse> GetMetaAsync(CancellationToken cancellationToken) =>
GetAnonymousAsync(MetaPath, DodoSshJsonContext.Default.MetaResponse, cancellationToken);
/// <summary>
/// Reads everything needed to begin authenticating.
/// </summary>
/// <remarks>
/// This is the onboarding story: the user types one server URL and the client discovers the OIDC
/// authority, the client id, the scopes and the relay from it.
/// </remarks>
public Task<DodoSshConfiguration> GetConfigurationAsync(CancellationToken cancellationToken) =>
GetAnonymousAsync(
ConfigurationPath,
DodoSshJsonContext.Default.DodoSshConfiguration,
cancellationToken);
/// <summary>
/// Reads the caller's profile, unlock material and reachable vaults.
/// </summary>
/// <remarks>
/// The first authenticated call a client makes, and the only one that works before enrollment. It
/// also provisions the account, so its <c>UserId</c> is available before enrolling — which matters,
/// because the secret bundle's AAD binds to that id and therefore cannot be built any earlier.
/// </remarks>
public Task<MeResponse> GetMeAsync(CancellationToken cancellationToken) =>
SendAsync(HttpMethod.Get, MePath, null, DodoSshJsonContext.Default.MeResponse, cancellationToken);
/// <summary>Publishes the caller's first identity key and creates their personal vault.</summary>
public Task<EnrollmentResponse> EnrollAsync(
EnrollmentRequest request,
CancellationToken cancellationToken) =>
SendAsync(
HttpMethod.Post,
EnrollmentPath,
JsonContent.Create(request, DodoSshJsonContext.Default.EnrollmentRequest),
DodoSshJsonContext.Default.EnrollmentResponse,
cancellationToken);
/// <summary>Reads vault changes after a cursor.</summary>
/// <remarks>
/// A POST despite being a read: the filters live in the body, cursors are opaque, and no caching is
/// wanted.
/// </remarks>
public Task<SyncPullResponse> SyncPullAsync(
Guid vaultId,
SyncPullRequest request,
CancellationToken cancellationToken) =>
SendAsync(
HttpMethod.Post,
$"/api/v1/vaults/{vaultId}/sync/pull",
JsonContent.Create(request, DodoSshJsonContext.Default.SyncPullRequest),
DodoSshJsonContext.Default.SyncPullResponse,
cancellationToken);
/// <summary>
/// Applies a batch of vault changes.
/// </summary>
/// <remarks>
/// Succeeds with per-operation status even when individual operations failed, so one stale item
/// cannot block everything else a client queued while offline. Callers must inspect
/// <c>SyncPushResult.Status</c> rather than treating a 200 as everything having applied.
/// </remarks>
public Task<SyncPushResponse> SyncPushAsync(
Guid vaultId,
SyncPushRequest request,
CancellationToken cancellationToken) =>
SendAsync(
HttpMethod.Post,
$"/api/v1/vaults/{vaultId}/sync/push",
JsonContent.Create(request, DodoSshJsonContext.Default.SyncPushRequest),
DodoSshJsonContext.Default.SyncPushResponse,
cancellationToken);
private async Task<T> GetAnonymousAsync<T>(
string path,
System.Text.Json.Serialization.Metadata.JsonTypeInfo<T> typeInfo,
CancellationToken cancellationToken)
{
using var request = new HttpRequestMessage(HttpMethod.Get, path);
return await SendCoreAsync(request, typeInfo, cancellationToken).ConfigureAwait(false);
}
private async Task<T> SendAsync<T>(
HttpMethod method,
string path,
HttpContent? content,
System.Text.Json.Serialization.Metadata.JsonTypeInfo<T> typeInfo,
CancellationToken cancellationToken)
{
using var request = new HttpRequestMessage(method, path) { Content = content };
var token = await tokens.GetAccessTokenAsync(cancellationToken).ConfigureAwait(false);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
return await SendCoreAsync(request, typeInfo, cancellationToken).ConfigureAwait(false);
}
private async Task<T> SendCoreAsync<T>(
HttpRequestMessage request,
System.Text.Json.Serialization.Metadata.JsonTypeInfo<T> typeInfo,
CancellationToken cancellationToken)
{
using var response = await http.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var body = await response.Content
.ReadAsStringAsync(cancellationToken)
.ConfigureAwait(false);
throw DodoSshApiException.FromResponse(response.StatusCode, body);
}
var value = await response.Content
.ReadFromJsonAsync(typeInfo, cancellationToken)
.ConfigureAwait(false);
// A 200 with a null body is a server bug, but it must not surface as a NullReferenceException
// three frames further up where the cause is invisible.
return value ?? throw new DodoSshApiException(
HttpStatusCode.OK,
null,
$"The server returned an empty body where a {typeof(T).Name} was expected.");
}
}
@@ -0,0 +1,64 @@
using System.Net;
using System.Text.Json;
namespace DodoSSH.Client.Api;
/// <summary>
/// A DodoSSH server returned an error.
/// </summary>
/// <remarks>
/// Carries the RFC 9457 <c>code</c> extension rather than only the status and prose. The codes are
/// constants in <c>DodoSSH.Contracts.ProblemCodes</c>, so the client can branch on
/// <see cref="Code"/> — <c>enrollment-required</c> means run enrollment, <c>vault-conflict</c> means
/// merge and retry — instead of matching on a message that is free to change.
/// </remarks>
public sealed class DodoSshApiException(HttpStatusCode statusCode, string? code, string message)
: Exception(message)
{
/// <summary>The HTTP status.</summary>
public HttpStatusCode StatusCode { get; } = statusCode;
/// <summary>The stable machine-readable code, when the server supplied one.</summary>
public string? Code { get; } = code;
/// <summary>
/// Builds an exception from a response body.
/// </summary>
/// <remarks>
/// Tolerates a body that is not ProblemDetails, or not JSON at all. A reverse proxy returning its
/// own HTML 502 is a normal thing to meet, and losing the status code while trying to parse it
/// would replace a diagnosable failure with a parse error.
/// </remarks>
internal static DodoSshApiException FromResponse(HttpStatusCode statusCode, string body)
{
string? code = null;
var detail = body;
try
{
using var document = JsonDocument.Parse(body);
var root = document.RootElement;
if (root.ValueKind == JsonValueKind.Object)
{
code = ReadString(root, "code");
detail = ReadString(root, "detail") ?? ReadString(root, "title") ?? body;
}
}
catch (JsonException)
{
// Not JSON. The status code still tells the caller what happened.
}
var summary = string.IsNullOrWhiteSpace(detail)
? $"The server returned {(int)statusCode}."
: $"The server returned {(int)statusCode}: {detail}";
return new DodoSshApiException(statusCode, code, summary);
}
private static string? ReadString(JsonElement root, string name) =>
root.TryGetProperty(name, out var property) && property.ValueKind == JsonValueKind.String
? property.GetString()
: null;
}
+46
View File
@@ -0,0 +1,46 @@
{
"version": 2,
"dependencies": {
"net10.0": {
"Meziantou.Analyzer": {
"type": "Direct",
"requested": "[3.0.134, )",
"resolved": "3.0.134",
"contentHash": "tTYCcYKyOko3TMNxmxmA9nakbcHVUgglENmCMIhzIjl9y9FBZO/0tWSxTGC74Sp198FmWih5S5KkjQRBg5ePkQ=="
},
"Microsoft.CodeAnalysis.BannedApiAnalyzers": {
"type": "Direct",
"requested": "[5.6.0, )",
"resolved": "5.6.0",
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
},
"dodossh.client.auth": {
"type": "Project"
},
"dodossh.contracts": {
"type": "Project"
},
"dodossh.crypto": {
"type": "Project",
"dependencies": {
"NSec.Cryptography": "[26.4.0, )"
}
},
"libsodium": {
"type": "CentralTransitive",
"requested": "[1.0.22, )",
"resolved": "1.0.22",
"contentHash": "KPD9SloJFclrsjnhABu7dzWrcyYkwPbvx5l1gRSPAX/0n+OBtSiVCKtGFv4n+ecWUHU0tCG9LSSwoZZx673zBQ=="
},
"NSec.Cryptography": {
"type": "CentralTransitive",
"requested": "[26.4.0, )",
"resolved": "26.4.0",
"contentHash": "0vsCtY5f+YgQROiWNqzgWp+l2pddfk9FkWoGV/bEo0MuEYPKlJWuoA8aOfO6qp3f+EnObKE3zSJhn1PspJeJVg==",
"dependencies": {
"libsodium": "[1.0.22, 1.0.23)"
}
}
}
}
}
+20 -1
View File
@@ -6,6 +6,25 @@ using System.Text.Json;
namespace DodoSSH.Client.Auth;
/// <summary>
/// Obtains an identity-provider signature over a set of public keys.
/// </summary>
/// <remarks>
/// Narrower than the whole OIDC client on purpose. Enrollment needs exactly this one capability, and
/// depending on the full client would drag discovery, token exchange and refresh into every test of
/// it — which is how a test for key binding ends up needing a stubbed token endpoint.
/// </remarks>
public interface IKeyBindingAuthorizer
{
/// <summary>
/// Runs an authorization whose <c>nonce</c> is a key statement's hash.
/// </summary>
/// <param name="bindingNonce">From <c>KeyStatementCodec.ComputeNonce</c>.</param>
/// <param name="cancellationToken">Cancels the wait for the browser.</param>
/// <returns>The ID token to hand to the enrollment endpoint.</returns>
Task<string> AuthorizeKeyBindingAsync(string bindingNonce, CancellationToken cancellationToken);
}
/// <summary>
/// Authorization Code with PKCE on a loopback redirect, for a native public client.
/// </summary>
@@ -26,7 +45,7 @@ public sealed class OidcClient(
HttpClient http,
IBrowserLauncher browser,
TimeProvider clock,
OidcClientOptions options)
OidcClientOptions options) : IKeyBindingAuthorizer
{
private readonly OidcDiscoveryClient discovery = new(http);
+260
View File
@@ -0,0 +1,260 @@
using System.Buffers.Binary;
using System.Security.Cryptography;
using NSec.Cryptography;
namespace DodoSSH.Crypto;
/// <summary>
/// Why a vault key grant exists.
/// </summary>
/// <remarks>
/// <para>
/// Named <c>GrantPurpose</c> rather than <c>GrantKind</c> only to avoid colliding with
/// <c>DodoSSH.Domain.GrantKind</c>, which the server uses for the same concept. Both are visible in the
/// server, and an ambiguous name there would need qualifying at every use.
/// </para>
/// <para>
/// The <b>numeric values must match</b> that enum exactly. They are covered by a grant signature, so a
/// renumbering would make every grant of the changed kind fail verification for good. A test pins them.
/// </para>
/// </remarks>
public enum GrantPurpose : byte
{
/// <summary>Not a legal value.</summary>
Unspecified = 0,
/// <summary>Wrapped to a member's identity key.</summary>
Member = 1,
/// <summary>Wrapped to a recovery key held by the vault owner.</summary>
Recovery = 2,
/// <summary>Wrapped to a team break-glass key.</summary>
Escrow = 3,
}
/// <summary>
/// The canonical encoding of a vault key grant, as signed by the granter. See docs/crypto.md §7.3.
/// </summary>
/// <remarks>
/// <para>
/// Sealing a vault key is anonymous-sender by construction, so a grant proves nothing about who
/// created it. Without a signature over this tuple a server could fabricate a grant containing a key
/// of its own choosing, and the recipient would unwrap it successfully and be none the wiser. The
/// signature makes that detectable and attributable — it cannot make it impossible, since verifying
/// the contents would require the server to hold the key.
/// </para>
/// <para>
/// The signature covers <c>SHA-256(wrappedKey)</c> rather than the wrapped key itself, so a verifier
/// does not need the vault key to check who issued the grant.
/// </para>
/// </remarks>
public static class GrantStatementCodec
{
/// <summary>Domain-separating prefix.</summary>
public static ReadOnlySpan<byte> Label => "dsh1/grant/v1"u8;
private const int LabelLength = 13;
/// <summary>Length without a key log head.</summary>
private const int BaseLength =
LabelLength
+ sizeof(uint) // key generation
+ 1 // grant kind
+ 16 // vault id
+ 16 // grantee user id
+ CryptoSpec.DigestSize // grantee key fingerprint
+ CryptoSpec.DigestSize // SHA-256 of the wrapped key
+ 16 // granter user id
+ CryptoSpec.DigestSize // granter key fingerprint
+ 1 // key log head presence
+ sizeof(long); // timestamp, Unix milliseconds
/// <summary>
/// Writes the canonical encoding.
/// </summary>
/// <param name="vaultId">The vault the key belongs to.</param>
/// <param name="keyGeneration">Generation the grant is for, so a superseded one cannot be replayed.</param>
/// <param name="kind">Why the grant exists.</param>
/// <param name="granteeUserId">Who may open it. <see cref="Guid.Empty"/> for a non-member grant.</param>
/// <param name="granteeKeyFingerprint">The exact identity key it was wrapped to.</param>
/// <param name="wrappedKey">The sealed vault key; only its digest is covered.</param>
/// <param name="granterUserId">Who issued it.</param>
/// <param name="granterKeyFingerprint">The granter's identity key.</param>
/// <param name="keyLogHead">
/// The key log head the granter observed, or null. Null for a self-grant, where there is no third
/// party whose key could have been substituted — and where the log entry that would supply the head
/// is written in the same transaction, so the client could not have signed over it.
/// </param>
/// <param name="grantedAt">Signing time; truncated to milliseconds.</param>
public static byte[] Encode(
Guid vaultId,
uint keyGeneration,
GrantPurpose kind,
Guid granteeUserId,
ReadOnlySpan<byte> granteeKeyFingerprint,
ReadOnlySpan<byte> wrappedKey,
Guid granterUserId,
ReadOnlySpan<byte> granterKeyFingerprint,
ReadOnlySpan<byte> keyLogHead,
DateTimeOffset grantedAt)
{
Validate(kind, granteeKeyFingerprint, wrappedKey, granterKeyFingerprint, keyLogHead);
var buffer = new byte[BaseLength + keyLogHead.Length];
var span = buffer.AsSpan();
Label.CopyTo(span);
var offset = LabelLength;
BinaryPrimitives.WriteUInt32BigEndian(span[offset..], keyGeneration);
offset += sizeof(uint);
span[offset++] = (byte)kind;
offset = WriteGuid(span, offset, vaultId);
offset = WriteGuid(span, offset, granteeUserId);
granteeKeyFingerprint.CopyTo(span[offset..]);
offset += CryptoSpec.DigestSize;
// The digest, not the key. A verifier must be able to check attribution without holding the
// vault key.
SHA256.HashData(wrappedKey, span.Slice(offset, CryptoSpec.DigestSize));
offset += CryptoSpec.DigestSize;
offset = WriteGuid(span, offset, granterUserId);
granterKeyFingerprint.CopyTo(span[offset..]);
offset += CryptoSpec.DigestSize;
if (keyLogHead.IsEmpty)
{
span[offset++] = 0;
}
else
{
span[offset++] = 1;
keyLogHead.CopyTo(span[offset..]);
offset += CryptoSpec.DigestSize;
}
BinaryPrimitives.WriteInt64BigEndian(span[offset..], grantedAt.ToUnixTimeMilliseconds());
offset += sizeof(long);
if (offset != buffer.Length)
{
throw new InvalidOperationException(
$"Grant encoding wrote {offset} bytes but reserved {buffer.Length}.");
}
return buffer;
}
private static void Validate(
GrantPurpose kind,
ReadOnlySpan<byte> granteeKeyFingerprint,
ReadOnlySpan<byte> wrappedKey,
ReadOnlySpan<byte> granterKeyFingerprint,
ReadOnlySpan<byte> keyLogHead)
{
if (kind == GrantPurpose.Unspecified)
{
throw new ArgumentOutOfRangeException(nameof(kind), kind, "A grant kind is required.");
}
RequireDigest(granteeKeyFingerprint, nameof(granteeKeyFingerprint));
RequireDigest(granterKeyFingerprint, nameof(granterKeyFingerprint));
if (wrappedKey.IsEmpty)
{
throw new ArgumentException("A wrapped key is required.", nameof(wrappedKey));
}
if (!keyLogHead.IsEmpty && keyLogHead.Length != CryptoSpec.DigestSize)
{
throw new ArgumentException(
$"A key log head is {CryptoSpec.DigestSize} bytes or absent.",
nameof(keyLogHead));
}
}
/// <summary>Signs a grant encoding.</summary>
public static byte[] Sign(Key signingKey, ReadOnlySpan<byte> canonicalGrant)
{
ArgumentNullException.ThrowIfNull(signingKey);
return SignatureAlgorithm.Ed25519.Sign(signingKey, BuildMessage(canonicalGrant));
}
/// <summary>
/// Verifies a grant signature against the granter's published signing key.
/// </summary>
/// <remarks>
/// Clients verify these; the server stores them opaquely. Server-side verification would be a
/// convenience and never the boundary, and would put an asymmetric implementation on a machine
/// that is supposed to hold no keys.
/// </remarks>
public static bool Verify(
ReadOnlySpan<byte> granterSigningPublicKey,
ReadOnlySpan<byte> canonicalGrant,
ReadOnlySpan<byte> signature)
{
if (granterSigningPublicKey.Length != CryptoSpec.PublicKeySize
|| signature.Length != CryptoSpec.SignatureSize)
{
return false;
}
PublicKey publicKey;
try
{
publicKey = PublicKey.Import(
SignatureAlgorithm.Ed25519,
granterSigningPublicKey,
KeyBlobFormat.RawPublicKey);
}
catch (FormatException)
{
return false;
}
return SignatureAlgorithm.Ed25519.Verify(publicKey, BuildMessage(canonicalGrant), signature);
}
/// <remarks>
/// Context-prefixed, so a grant signature can never be replayed as a key statement signature or an
/// attestation.
/// </remarks>
private static byte[] BuildMessage(ReadOnlySpan<byte> canonicalGrant)
{
var context = CryptoSpec.SigningContexts.Grant;
var message = new byte[context.Length + canonicalGrant.Length];
context.CopyTo(message);
canonicalGrant.CopyTo(message.AsSpan(context.Length));
return message;
}
private static int WriteGuid(Span<byte> destination, int offset, Guid value)
{
// RFC 4122 big-endian, as everywhere else in this specification.
if (!value.TryWriteBytes(destination[offset..], bigEndian: true, out _))
{
throw new InvalidOperationException("Failed to write a grant identifier.");
}
return offset + 16;
}
private static void RequireDigest(ReadOnlySpan<byte> value, string parameterName)
{
if (value.Length != CryptoSpec.DigestSize)
{
throw new ArgumentException(
$"Expected a {CryptoSpec.DigestSize}-byte fingerprint, got {value.Length}.",
parameterName);
}
}
}
@@ -0,0 +1,356 @@
using System.Net;
using System.Text.Json;
using DodoSSH.Client.Auth;
using DodoSSH.Contracts;
using DodoSSH.Crypto;
namespace DodoSSH.Client.Api.Tests;
/// <summary>
/// The client half of enrollment: what it sends, and what it keeps to itself.
/// </summary>
/// <remarks>
/// The most valuable assertions here are the negative ones about the request body. The server is
/// supposed to be unable to read anything it stores, and this is where that either holds or quietly
/// stops holding — a refactor that put a passphrase or a private key into the request would be
/// invisible to every other test in the repository.
/// </remarks>
public sealed class ClientEnrollmentTests : IDisposable
{
private const string Passphrase = "correct horse battery staple";
private const string EnrollmentPath = "/api/v1/me/enrollment";
private static readonly Guid UserId = Guid.Parse("0192f0c8-1a2b-7c3d-8e4f-5a6b7c8d9e0f");
private readonly StubServer server = new();
private readonly HttpClient http = new();
public ClientEnrollmentTests() => http.BaseAddress = server.BaseUrl;
/// <inheritdoc />
public void Dispose()
{
http.Dispose();
server.Dispose();
}
[Fact]
public async Task Enroll_SendsAStatementTheServerCanVerify()
{
var binding = new CapturingKeyBinding();
var outcome = await EnrollAsync(binding);
using var bundle = outcome.Bundle;
var request = ReadRequest();
// The statement must describe the caller, or the server rejects it — and the signature must
// verify against the statement's own signing key, which is what proves possession.
request.Statement.Issuer.ShouldBe("https://idp.example/realms/dodossh");
request.Statement.Subject.ShouldBe("alice");
request.Statement.KeyGeneration.ShouldBe(1);
request.Statement.EncryptionPublicKey.ShouldBe(bundle.EncryptionPublicKey);
request.Statement.SigningPublicKey.ShouldBe(bundle.SigningPublicKey);
DshSignatures.VerifyKeyStatement(
request.Statement.SigningPublicKey,
KeyStatementCodec.Encode(ToFields(request.Statement)),
request.StatementSignature)
.ShouldBeTrue();
}
[Fact]
public async Task Enroll_BindsTheKeysWithTheStatementsOwnHash()
{
// The nonce handed to the identity provider must be this statement's hash and no other,
// otherwise the token binds keys nobody is publishing.
var binding = new CapturingKeyBinding();
var outcome = await EnrollAsync(binding);
outcome.Bundle.Dispose();
var request = ReadRequest();
binding.RequestedNonce.ShouldBe(
KeyStatementCodec.ComputeNonce(ToFields(request.Statement)));
}
[Fact]
public async Task Enroll_SendsNothingTheServerCouldUseToOpenTheVault()
{
var outcome = await EnrollAsync(new CapturingKeyBinding());
using var bundle = outcome.Bundle;
var body = server.LastBody(EnrollmentPath);
// The passphrase and the recovery code exist only on this machine.
body.ShouldNotContain(Passphrase);
body.ShouldNotContain(outcome.RecoveryCode);
// Nor may any private key appear, in any encoding the serialiser might have chosen.
body.ShouldNotContain(Convert.ToBase64String(outcome.PersonalVaultKey));
body.ShouldNotContain(Convert.ToBase64String(outcome.DevicePrivateKey));
body.ShouldNotContain(Convert.ToHexString(outcome.PersonalVaultKey));
}
[Fact]
public async Task Enroll_WrapsTheSameBundleThreeWays()
{
// One bundle, several wraps, which is what makes a passphrase change a single-row update
// rather than a re-encryption of the vault.
var outcome = await EnrollAsync(new CapturingKeyBinding());
using var bundle = outcome.Bundle;
var request = ReadRequest();
var descriptor = DshAad.UserSecretBundle(UserId);
using var viaPassphrase = OpenWithPassphrase(request, Passphrase, descriptor);
viaPassphrase.ShouldNotBeNull();
viaPassphrase.SigningPublicKey.ShouldBe(bundle.SigningPublicKey);
request.RecoveryWrappedPrivateKey.ShouldNotBeNull();
request.RecoveryKdfParameters.ShouldNotBeNull();
using var viaRecovery = OpenWithRecovery(request, outcome.RecoveryCode, descriptor);
viaRecovery.ShouldNotBeNull();
viaRecovery.SigningPublicKey.ShouldBe(bundle.SigningPublicKey);
// The device wrap is sealed to the device key rather than derived, so it opens with the
// private half the caller was handed to put in the OS keystore.
request.DevicePublicKey.ShouldNotBeNull();
request.DeviceWrappedPrivateKey.ShouldNotBeNull();
using var deviceKey = NSec.Cryptography.Key.Import(
NSec.Cryptography.KeyAgreementAlgorithm.X25519,
outcome.DevicePrivateKey,
NSec.Cryptography.KeyBlobFormat.RawPrivateKey);
using var viaDevice = UserSecretBundle.TryOpenSealed(
deviceKey, request.DeviceWrappedPrivateKey, descriptor);
viaDevice.ShouldNotBeNull();
viaDevice.SigningPublicKey.ShouldBe(bundle.SigningPublicKey);
}
[Fact]
public async Task Enroll_SendsKdfParametersStrongEnoughForTheServerToAccept()
{
// The server enforces a floor. Sending anything below it fails enrollment against a real
// server while passing every stub, so the values are asserted here rather than discovered
// later.
var outcome = await EnrollAsync(new CapturingKeyBinding());
outcome.Bundle.Dispose();
var request = ReadRequest();
request.KdfParameters.Algorithm.ShouldBe("argon2id");
request.KdfParameters.MemoryKibibytes.ShouldBe(Argon2Profile.PassphraseDefault.MemoryKibibytes);
request.KdfParameters.Passes.ShouldBe(Argon2Profile.PassphraseDefault.Passes);
request.KdfParameters.Parallelism.ShouldBe(1);
request.KdfParameters.Salt.Length.ShouldBe(CryptoSpec.SaltSize);
// A distinct salt per wrap. Reusing one would let a single cracking effort cover both.
request.RecoveryKdfParameters!.Salt.ShouldNotBe(request.KdfParameters.Salt);
}
[Fact]
public async Task Enroll_SignsThePersonalVaultGrantOverTheCanonicalTuple()
{
var outcome = await EnrollAsync(new CapturingKeyBinding());
using var bundle = outcome.Bundle;
var request = ReadRequest();
var vault = request.PersonalVault;
var fingerprint = DshCrypto.ComputeFingerprint(
bundle.EncryptionPublicKey, bundle.SigningPublicKey);
var grant = GrantStatementCodec.Encode(
vault.VaultId,
keyGeneration: 1,
GrantPurpose.Member,
granteeUserId: UserId,
granteeKeyFingerprint: fingerprint,
wrappedKey: vault.WrappedVaultKey,
granterUserId: UserId,
granterKeyFingerprint: fingerprint,
keyLogHead: default,
grantedAt: vault.GrantedAt);
GrantStatementCodec.Verify(bundle.SigningPublicKey, grant, vault.GrantSignature)
.ShouldBeTrue("A grant the granter's own key cannot verify is one no client will accept.");
}
[Fact]
public async Task Enroll_SealsThePersonalVaultKeyToTheEnrollingIdentity()
{
var outcome = await EnrollAsync(new CapturingKeyBinding());
using var bundle = outcome.Bundle;
var vault = ReadRequest().PersonalVault;
VaultKeys.TryUnwrap(bundle.EncryptionKey, vault.WrappedVaultKey, vault.VaultId, 1)
.ShouldBe(outcome.PersonalVaultKey);
}
[Fact]
public async Task Enroll_ProducesAReadableRecoveryCode()
{
// Read aloud or copied off a screen, so the alphabet omits the characters that get
// mistranscribed.
var outcome = await EnrollAsync(new CapturingKeyBinding());
outcome.Bundle.Dispose();
outcome.RecoveryCode.ShouldNotBeNullOrWhiteSpace();
outcome.RecoveryCode.ShouldContain("-");
foreach (var character in outcome.RecoveryCode.Replace("-", string.Empty, StringComparison.Ordinal))
{
"0123456789ABCDEFGHJKMNPQRSTVWXYZ".ShouldContain(character);
}
}
[Fact]
public async Task Enroll_DisposesTheBundleWhenTheServerRefuses()
{
// The caller never receives the bundle on failure, so nothing else could release its guarded
// memory.
server.StubProblem(
EnrollmentPath, "POST", 409, ProblemCodes.AlreadyEnrolled, "Already enrolled.");
var enrollment = new ClientEnrollment(
new DodoSshApiClient(http, new StubTokenProvider()),
new CapturingKeyBinding(),
TimeProvider.System);
var exception = await Should.ThrowAsync<DodoSshApiException>(async () =>
await enrollment.EnrollAsync(
Me(), Passphrase, "laptop", "Personal", TestContext.Current.CancellationToken));
exception.Code.ShouldBe(ProblemCodes.AlreadyEnrolled);
exception.StatusCode.ShouldBe(HttpStatusCode.Conflict);
}
[Fact]
public async Task Enroll_RejectsAnEmptyPassphraseBeforeTouchingTheNetwork()
{
var binding = new CapturingKeyBinding();
var enrollment = new ClientEnrollment(
new DodoSshApiClient(http, new StubTokenProvider()),
binding,
TimeProvider.System);
await Should.ThrowAsync<ArgumentException>(async () =>
await enrollment.EnrollAsync(
Me(), string.Empty, "laptop", "Personal", TestContext.Current.CancellationToken));
binding.RequestedNonce.ShouldBeNull("Nothing should reach the identity provider.");
}
// ---- Helpers ----
private async Task<EnrollmentOutcome> EnrollAsync(CapturingKeyBinding binding)
{
server.StubEnrollment(new EnrollmentResponse(
UserId: UserId,
KeyGeneration: 1,
Fingerprint: new byte[32],
PersonalVaultId: Guid.CreateVersion7(),
DeviceId: Guid.CreateVersion7(),
KeyLogSequence: 1));
var enrollment = new ClientEnrollment(
new DodoSshApiClient(http, new StubTokenProvider()),
binding,
TimeProvider.System);
return await enrollment.EnrollAsync(
Me(), Passphrase, "laptop", "Personal", TestContext.Current.CancellationToken);
}
private EnrollmentRequest ReadRequest()
{
var request = JsonSerializer.Deserialize(
server.LastBody(EnrollmentPath),
DodoSshJsonContext.Default.EnrollmentRequest);
request.ShouldNotBeNull();
return request;
}
private static MeResponse Me() =>
new(
UserId: UserId,
Issuer: "https://idp.example/realms/dodossh",
Subject: "alice",
Email: "alice@example.com",
DisplayName: "Alice",
EnrollmentRequired: true,
KeyGeneration: null,
WrappedPrivateKey: null,
KdfParameters: null,
Vaults: []);
private static UserSecretBundle? OpenWithPassphrase(
EnrollmentRequest request,
string passphrase,
AadDescriptor descriptor)
{
// Reconstructed from the stored parameters, exactly as a client unlocking on another device
// would do after reading them from /me.
using var master = MasterKey.Derive(
passphrase,
request.KdfParameters.Salt,
Argon2Profile.FromStoredParameters(
request.KdfParameters.MemoryKibibytes,
request.KdfParameters.Passes,
request.KdfParameters.Parallelism));
return master.TryOpenBundle(request.WrappedPrivateKey, descriptor);
}
private static UserSecretBundle? OpenWithRecovery(
EnrollmentRequest request,
string recoveryCode,
AadDescriptor descriptor)
{
var parameters = request.RecoveryKdfParameters!;
using var master = MasterKey.Derive(
recoveryCode,
parameters.Salt,
Argon2Profile.FromStoredParameters(
parameters.MemoryKibibytes, parameters.Passes, parameters.Parallelism));
return master.TryOpenBundle(request.RecoveryWrappedPrivateKey!, descriptor);
}
private static KeyStatementFields ToFields(KeyStatement statement) =>
new(
statement.Version,
statement.Issuer,
statement.Subject,
statement.Email,
statement.EncryptionPublicKey,
statement.SigningPublicKey,
statement.KeyGeneration,
statement.CreatedAt,
statement.DeviceName);
/// <summary>Records the nonce it was asked to bind, and returns a token carrying it.</summary>
private sealed class CapturingKeyBinding : IKeyBindingAuthorizer
{
internal string? RequestedNonce { get; private set; }
public Task<string> AuthorizeKeyBindingAsync(
string bindingNonce,
CancellationToken cancellationToken)
{
RequestedNonce = bindingNonce;
// Shape only. The real token's signature and nonce are the server's to verify, and doing
// it here would just be testing the stub.
return Task.FromResult($"header.{bindingNonce}.signature");
}
}
}
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
The client against a stubbed DodoSSH server. Its job is the wire contract and the enrollment
orchestration: that requests carry what the server expects, that problem codes survive, and
that nothing secret leaves the process.
-->
<ItemGroup>
<ProjectReference Include="../../src/DodoSSH.Client.Api/DodoSSH.Client.Api.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="WireMock.Net" />
</ItemGroup>
</Project>
@@ -0,0 +1,239 @@
using System.Net;
using DodoSSH.Contracts;
namespace DodoSSH.Client.Api.Tests;
/// <summary>
/// The wire contract: what goes out, what comes back, and how failures surface.
/// </summary>
/// <remarks>
/// The problem-code assertions matter most. Those codes are how a client decides what to do next —
/// <c>enrollment-required</c> means enroll, <c>vault-conflict</c> means merge and retry — so losing one
/// while parsing an error turns an actionable failure into an opaque one.
/// </remarks>
public sealed class DodoSshApiClientTests : IDisposable
{
private static readonly Guid VaultId = Guid.Parse("0192f0c8-1a2b-7c3d-8e4f-5a6b7c8d9e0f");
private readonly StubServer server = new();
private readonly HttpClient http = new();
public DodoSshApiClientTests() => http.BaseAddress = server.BaseUrl;
/// <inheritdoc />
public void Dispose()
{
http.Dispose();
server.Dispose();
}
[Fact]
public async Task Meta_IsFetchedWithoutAToken()
{
// A client has to be able to ask what a server supports before it can authenticate, so this
// one must not require a bearer token.
server.StubMeta(new MetaResponse(
ServerVersion: "1.2.3",
ApiVersions: [1],
SyncProtocolVersion: 1,
CryptoSpecVersion: 1,
Features: ["teams"],
MinClientVersion: null,
MaxOperationsPerPush: 500,
MaxPayloadBytes: 8 * 1024 * 1024,
MaxItemPayloadBytes: 256 * 1024));
var meta = await Client().GetMetaAsync(TestContext.Current.CancellationToken);
meta.ServerVersion.ShouldBe("1.2.3");
meta.MaxOperationsPerPush.ShouldBe(500);
server.LastAuthorization("/api/v1/meta").ShouldBeNull();
}
[Fact]
public async Task Me_CarriesTheBearerToken()
{
server.StubMe(UnenrolledMe());
var me = await Client().GetMeAsync(TestContext.Current.CancellationToken);
me.EnrollmentRequired.ShouldBeTrue();
server.LastAuthorization("/api/v1/me").ShouldBe("Bearer test-access-token");
}
[Fact]
public async Task Me_RoundTripsVaultSummaries()
{
// Byte arrays through the source-generated serialiser, which is the one thing most likely to
// go wrong silently between the two sides.
var wrappedKey = new byte[] { 1, 2, 3, 4, 5 };
server.StubMe(UnenrolledMe() with
{
EnrollmentRequired = false,
KeyGeneration = 1,
WrappedPrivateKey = [9, 8, 7],
KdfParameters = new KdfParameters("argon2id", [1, 2, 3], 262144, 4, 1),
Vaults =
[
new VaultSummary(
VaultId: VaultId,
Name: "Personal",
IsPersonal: true,
TeamId: null,
KeyGeneration: 1,
Permissions: 31,
WrappedVaultKey: wrappedKey,
RekeyRequired: false),
],
});
var me = await Client().GetMeAsync(TestContext.Current.CancellationToken);
var vault = me.Vaults.ShouldHaveSingleItem();
vault.WrappedVaultKey.ShouldBe(wrappedKey);
vault.KeyGeneration.ShouldBe(1u);
me.WrappedPrivateKey.ShouldBe([9, 8, 7]);
me.KdfParameters!.MemoryKibibytes.ShouldBe(262144);
}
[Fact]
public async Task AProblemResponse_KeepsItsCode()
{
server.StubProblem(
"/api/v1/me", "GET", 403, ProblemCodes.EnrollmentRequired, "Publish an identity key first.");
var exception = await Should.ThrowAsync<DodoSshApiException>(async () =>
await Client().GetMeAsync(TestContext.Current.CancellationToken));
exception.Code.ShouldBe(ProblemCodes.EnrollmentRequired);
exception.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
exception.Message.ShouldContain("Publish an identity key first.");
}
[Fact]
public async Task ANonJsonError_StillReportsItsStatus()
{
// A reverse proxy in front of a dead server returns HTML. Losing the status code while trying
// to parse that as ProblemDetails would replace a diagnosable failure with a parse error.
server.StubGatewayError("/api/v1/me", "GET");
var exception = await Should.ThrowAsync<DodoSshApiException>(async () =>
await Client().GetMeAsync(TestContext.Current.CancellationToken));
exception.StatusCode.ShouldBe(HttpStatusCode.BadGateway);
exception.Code.ShouldBeNull();
}
[Fact]
public async Task Push_SendsTheBatchAndReturnsPerOperationStatus()
{
// A push succeeds with mixed results on purpose, so one stale item cannot block everything
// else a client queued while offline. Callers must read the statuses rather than trusting
// the 200.
var applied = Guid.CreateVersion7();
var conflicted = Guid.CreateVersion7();
server.StubPush(VaultId, new SyncPushResponse(
Results:
[
new SyncPushResult(applied, SyncOperationStatus.Applied, 1, 10, null, null),
new SyncPushResult(conflicted, SyncOperationStatus.Conflict, 2, 11, null, null),
],
Cursor: "next-cursor"));
var request = new SyncPushRequest(
[
new SyncPushOperation(
applied,
SyncEntityType.Host,
Guid.CreateVersion7(),
SyncOperation.Upsert,
null,
new EncryptedPayload([1, 2, 3], 1, 1),
new SyncPlaintextFields()),
]);
var response = await Client().SyncPushAsync(
VaultId, request, TestContext.Current.CancellationToken);
response.Results.Count.ShouldBe(2);
response.Results[0].Status.ShouldBe(SyncOperationStatus.Applied);
response.Results[1].Status.ShouldBe(SyncOperationStatus.Conflict);
response.Cursor.ShouldBe("next-cursor");
var body = server.LastBody($"/api/v1/vaults/{VaultId}/sync/push");
body.ShouldContain("operations");
body.ShouldContain("Upsert");
}
[Fact]
public async Task Pull_RoundTripsChangesAndTheCursor()
{
var entityId = Guid.CreateVersion7();
server.StubPull(VaultId, new SyncPullResponse(
Changes:
[
new SyncChange(
SyncEntityType.Host,
entityId,
SyncOperation.Upsert,
Version: 1,
ChangeSequence: 5,
Payload: new EncryptedPayload([4, 5, 6], 1, 1),
PlaintextFields: new SyncPlaintextFields(RelayEnabled: false),
UpdatedAt: DateTimeOffset.FromUnixTimeSeconds(1_750_000_000)),
],
NextCursor: "cursor-2",
HasMore: false,
ServerTime: DateTimeOffset.FromUnixTimeSeconds(1_750_000_001),
CurrentKeyGeneration: 1));
var response = await Client().SyncPullAsync(
VaultId,
new SyncPullRequest("cursor-1", null, null),
TestContext.Current.CancellationToken);
var change = response.Changes.ShouldHaveSingleItem();
change.EntityId.ShouldBe(entityId);
change.Payload!.Envelope.ShouldBe([4, 5, 6]);
response.NextCursor.ShouldBe("cursor-2");
response.HasMore.ShouldBeFalse();
server.LastBody($"/api/v1/vaults/{VaultId}/sync/pull").ShouldContain("cursor-1");
}
[Fact]
public async Task AConflictOnPush_SurfacesItsCode()
{
server.StubProblem(
$"/api/v1/vaults/{VaultId}/sync/push",
"POST",
409,
ProblemCodes.VaultConflict,
"Stale version.");
var exception = await Should.ThrowAsync<DodoSshApiException>(async () =>
await Client().SyncPushAsync(
VaultId, new SyncPushRequest([]), TestContext.Current.CancellationToken));
exception.Code.ShouldBe(ProblemCodes.VaultConflict);
}
private DodoSshApiClient Client() => new(http, new StubTokenProvider());
private static MeResponse UnenrolledMe() =>
new(
UserId: Guid.Parse("0192f0c8-9999-7aaa-8bbb-cccccccccccc"),
Issuer: "https://idp.example",
Subject: "alice",
Email: "alice@example.com",
DisplayName: "Alice",
EnrollmentRequired: true,
KeyGeneration: null,
WrappedPrivateKey: null,
KdfParameters: null,
Vaults: []);
}
@@ -0,0 +1,125 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using DodoSSH.Contracts;
using WireMock.RequestBuilders;
using WireMock.ResponseBuilders;
using WireMock.Server;
namespace DodoSSH.Client.Api.Tests;
/// <summary>A stand-in DodoSSH server.</summary>
/// <remarks>
/// Responses are built from the real contract types and the real source-generated serialiser, so the
/// client is reading exactly the shape a live server produces rather than hand-written JSON that
/// happens to satisfy it.
/// </remarks>
internal sealed class StubServer : IDisposable
{
private readonly WireMockServer server = WireMockServer.Start();
internal Uri BaseUrl => new(server.Url!, UriKind.Absolute);
/// <summary>Requests received, so tests can assert on what was sent.</summary>
internal IReadOnlyList<WireMock.Logging.ILogEntry> Requests => server.LogEntries.ToList();
internal void StubMe(MeResponse response) =>
StubJson("/api/v1/me", "GET", 200, JsonSerializer.Serialize(
response, DodoSshJsonContext.Default.MeResponse));
internal void StubEnrollment(EnrollmentResponse response) =>
StubJson("/api/v1/me/enrollment", "POST", 200, JsonSerializer.Serialize(
response, DodoSshJsonContext.Default.EnrollmentResponse));
internal void StubMeta(MetaResponse response) =>
StubJson("/api/v1/meta", "GET", 200, JsonSerializer.Serialize(
response, DodoSshJsonContext.Default.MetaResponse));
internal void StubPush(Guid vaultId, SyncPushResponse response) =>
StubJson($"/api/v1/vaults/{vaultId}/sync/push", "POST", 200, JsonSerializer.Serialize(
response, DodoSshJsonContext.Default.SyncPushResponse));
internal void StubPull(Guid vaultId, SyncPullResponse response) =>
StubJson($"/api/v1/vaults/{vaultId}/sync/pull", "POST", 200, JsonSerializer.Serialize(
response, DodoSshJsonContext.Default.SyncPullResponse));
/// <summary>Stubs an RFC 9457 problem response.</summary>
internal void StubProblem(string path, string method, int statusCode, string code, string detail)
{
var problem = new JsonObject
{
["type"] = ProblemCodes.TypeBaseUri + code,
["title"] = "Request failed",
["status"] = statusCode,
["detail"] = detail,
["code"] = code,
};
StubJson(path, method, statusCode, problem.ToJsonString());
}
/// <summary>Stubs a non-JSON error, as a reverse proxy in front of a dead server would return.</summary>
internal void StubGatewayError(string path, string method) =>
server
.Given(Request.Create().WithPath(path).UsingMethod(method))
.RespondWith(Response.Create()
.WithStatusCode(502)
.WithHeader("Content-Type", "text/html")
.WithBody("<html><body><h1>502 Bad Gateway</h1></body></html>"));
/// <summary>The body of the last request to a path.</summary>
internal string LastBody(string path)
{
var entries = server.LogEntries
.Where(e => e.RequestMessage?.Path?.EndsWith(path, StringComparison.Ordinal) == true)
.ToList();
if (entries.Count == 0)
{
throw new InvalidOperationException($"Nothing was sent to {path}.");
}
return entries[^1].RequestMessage?.Body ?? string.Empty;
}
/// <summary>The Authorization header of the last request to a path.</summary>
internal string? LastAuthorization(string path)
{
var entries = server.LogEntries
.Where(e => e.RequestMessage?.Path?.EndsWith(path, StringComparison.Ordinal) == true)
.ToList();
if (entries.Count == 0)
{
return null;
}
var headers = entries[^1].RequestMessage?.Headers;
return headers is not null && headers.TryGetValue("Authorization", out var values)
? values.FirstOrDefault()
: null;
}
/// <inheritdoc />
public void Dispose()
{
server.Stop();
server.Dispose();
}
private void StubJson(string path, string method, int statusCode, string body) =>
server
.Given(Request.Create().WithPath(path).UsingMethod(method))
.RespondWith(Response.Create()
.WithStatusCode(statusCode)
.WithHeader("Content-Type", "application/json")
.WithBody(body));
}
/// <summary>Hands out a fixed token, so tests can assert it reached the wire.</summary>
internal sealed class StubTokenProvider(string token = "test-access-token") : IAccessTokenProvider
{
/// <inheritdoc />
public ValueTask<string> GetAccessTokenAsync(CancellationToken cancellationToken) =>
ValueTask.FromResult(token);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,223 @@
using NSec.Cryptography;
namespace DodoSSH.Crypto.Tests;
/// <summary>
/// The vault key grant tuple and its signature. See docs/crypto.md §7.3.
/// </summary>
/// <remarks>
/// Sealing a vault key is anonymous-sender, so without this signature a server could fabricate a grant
/// containing a key of its own choosing and the recipient would unwrap it happily. Most of these tests
/// are therefore about a signature failing to transfer between contexts it should not.
/// </remarks>
public sealed class GrantStatementCodecTests
{
private static readonly Guid VaultA = Guid.Parse("0192f0c8-1111-7c3d-8e4f-5a6b7c8d9e0f");
private static readonly Guid VaultB = Guid.Parse("0192f0c8-2222-7c3d-8e4f-5a6b7c8d9e0f");
private static readonly Guid Alice = Guid.Parse("0192f0c8-3333-7c3d-8e4f-5a6b7c8d9e0f");
private static readonly Guid Bob = Guid.Parse("0192f0c8-4444-7c3d-8e4f-5a6b7c8d9e0f");
private static readonly DateTimeOffset GrantedAt = DateTimeOffset.FromUnixTimeMilliseconds(1_750_000_000_123);
[Fact]
public void Encode_IsDeterministic()
{
Encode().ShouldBe(Encode());
}
[Fact]
public void Encode_BeginsWithTheDomainLabel()
{
var encoding = Encode();
encoding.AsSpan(0, GrantStatementCodec.Label.Length)
.SequenceEqual(GrantStatementCodec.Label)
.ShouldBeTrue();
}
[Theory]
[InlineData("vault")]
[InlineData("generation")]
[InlineData("kind")]
[InlineData("grantee")]
[InlineData("granteeFingerprint")]
[InlineData("wrappedKey")]
[InlineData("granter")]
[InlineData("granterFingerprint")]
[InlineData("keyLogHead")]
[InlineData("grantedAt")]
public void ChangingAnyField_ChangesTheEncoding(string field)
{
var baseline = Encode();
var altered = field switch
{
"vault" => Encode(vaultId: VaultB),
"generation" => Encode(keyGeneration: 2),
"kind" => Encode(kind: GrantPurpose.Recovery),
"grantee" => Encode(granteeUserId: Bob),
"granteeFingerprint" => Encode(granteeFingerprint: Fingerprint(0xB0)),
"wrappedKey" => Encode(wrappedKey: Bytes(80, 0x99)),
"granter" => Encode(granterUserId: Bob),
"granterFingerprint" => Encode(granterFingerprint: Fingerprint(0xC0)),
"keyLogHead" => Encode(keyLogHead: Fingerprint(0xD0)),
"grantedAt" => Encode(grantedAt: GrantedAt.AddMilliseconds(1)),
_ => throw new ArgumentOutOfRangeException(nameof(field), field, "Unknown field."),
};
altered.ShouldNotBe(baseline);
}
[Fact]
public void AbsentAndPresentKeyLogHeads_EncodeDifferently()
{
// The presence byte, without which a grant carrying no head and one carrying 32 zero bytes
// would be indistinguishable.
Encode(keyLogHead: default).ShouldNotBe(Encode(keyLogHead: new byte[32]));
}
[Fact]
public void TheSignatureCoversOnlyTheWrappedKeysDigest()
{
// So a verifier can check who issued a grant without holding the vault key. The encoding is
// 32 bytes of digest regardless of how large the wrapped key is.
var small = Encode(wrappedKey: Bytes(48, 0x11));
var large = Encode(wrappedKey: Bytes(4096, 0x11));
small.Length.ShouldBe(large.Length);
small.ShouldNotBe(large);
}
[Fact]
public void AFreshSignature_Verifies()
{
using var key = CreateSigningKey();
var grant = Encode();
var signature = GrantStatementCodec.Sign(key, grant);
signature.Length.ShouldBe(CryptoSpec.SignatureSize);
GrantStatementCodec.Verify(PublicKeyBytes(key), grant, signature).ShouldBeTrue();
}
[Fact]
public void ASignatureOverAnotherGrant_DoesNotVerify()
{
// The property that matters: a grant for one vault cannot be replayed onto another.
using var key = CreateSigningKey();
var signature = GrantStatementCodec.Sign(key, Encode(vaultId: VaultA));
GrantStatementCodec.Verify(PublicKeyBytes(key), Encode(vaultId: VaultB), signature)
.ShouldBeFalse();
}
[Fact]
public void ASignatureFromAnotherGranter_DoesNotVerify()
{
using var key = CreateSigningKey();
using var other = CreateSigningKey();
var grant = Encode();
var signature = GrantStatementCodec.Sign(other, grant);
GrantStatementCodec.Verify(PublicKeyBytes(key), grant, signature).ShouldBeFalse();
}
[Fact]
public void AKeyStatementSignature_DoesNotVerifyAsAGrant()
{
// Context separation. Without it a signature produced in one role could be presented in
// another, which is the whole reason each context string exists.
using var key = CreateSigningKey();
var grant = Encode();
var wrongContext = DshSignatures.SignKeyStatement(key, grant);
GrantStatementCodec.Verify(PublicKeyBytes(key), grant, wrongContext).ShouldBeFalse();
}
[Theory]
[InlineData(0)]
[InlineData(31)]
[InlineData(33)]
public void AMalformedPublicKey_ReturnsFalseRatherThanThrowing(int length)
{
using var key = CreateSigningKey();
var grant = Encode();
var signature = GrantStatementCodec.Sign(key, grant);
GrantStatementCodec.Verify(new byte[length], grant, signature).ShouldBeFalse();
}
[Fact]
public void Encode_RejectsAnUnspecifiedKind()
{
Should.Throw<ArgumentOutOfRangeException>(() => Encode(kind: GrantPurpose.Unspecified));
}
[Fact]
public void Encode_RejectsAnEmptyWrappedKey()
{
Should.Throw<ArgumentException>(() => Encode(wrappedKey: []));
}
[Fact]
public void Encode_RejectsAWrongLengthFingerprint()
{
Should.Throw<ArgumentException>(() => Encode(granteeFingerprint: new byte[16]));
}
[Fact]
public void Encode_RejectsAWrongLengthKeyLogHead()
{
Should.Throw<ArgumentException>(() => Encode(keyLogHead: new byte[16]));
}
[Fact]
public void ThePurposeValues_MatchTheDomainEnum()
{
// Covered by the signature, so a renumbering would make every grant of the changed kind fail
// verification. Domain cannot be referenced from here, so the values are asserted literally
// against docs/crypto.md §7.3.
((byte)GrantPurpose.Member).ShouldBe((byte)1);
((byte)GrantPurpose.Recovery).ShouldBe((byte)2);
((byte)GrantPurpose.Escrow).ShouldBe((byte)3);
}
// ---- Helpers ----
private static byte[] Encode(
Guid? vaultId = null,
uint keyGeneration = 1,
GrantPurpose kind = GrantPurpose.Member,
Guid? granteeUserId = null,
byte[]? granteeFingerprint = null,
byte[]? wrappedKey = null,
Guid? granterUserId = null,
byte[]? granterFingerprint = null,
byte[]? keyLogHead = null,
DateTimeOffset? grantedAt = null) =>
GrantStatementCodec.Encode(
vaultId ?? VaultA,
keyGeneration,
kind,
granteeUserId ?? Alice,
granteeFingerprint ?? Fingerprint(0x40),
wrappedKey ?? Bytes(80, 0x77),
granterUserId ?? Alice,
granterFingerprint ?? Fingerprint(0x60),
keyLogHead ?? [],
grantedAt ?? GrantedAt);
private static byte[] Fingerprint(byte seed) => Bytes(CryptoSpec.DigestSize, seed);
private static byte[] Bytes(int length, byte seed) =>
[.. Enumerable.Range(0, length).Select(i => (byte)(seed + i))];
private static Key CreateSigningKey() =>
Key.Create(
SignatureAlgorithm.Ed25519,
new KeyCreationParameters { ExportPolicy = KeyExportPolicies.AllowPlaintextExport });
private static byte[] PublicKeyBytes(Key key) => key.PublicKey.Export(KeyBlobFormat.RawPublicKey);
}