Files
DodoSSH/tests/DodoSSH.Client.Api.Tests/ClientEnrollmentTests.cs
T
jaap-jan a878c2b6bb 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.
2026-07-28 22:42:56 +02:00

357 lines
13 KiB
C#

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");
}
}
}