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