using System.Net;
using System.Text.Json;
using DodoSSH.Client.Auth;
using DodoSSH.Contracts;
using DodoSSH.Crypto;
namespace DodoSSH.Client.Api.Tests;
///
/// The client half of enrollment: what it sends, and what it keeps to itself.
///
///
/// 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.
///
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;
///
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(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(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 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);
/// Records the nonce it was asked to bind, and returns a token carrying it.
private sealed class CapturingKeyBinding : IKeyBindingAuthorizer
{
internal string? RequestedNonce { get; private set; }
public Task 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");
}
}
}