using DodoSSH.Contracts; using DodoSSH.Crypto; using NSec.Cryptography; namespace DodoSSH.Api.Tests; /// /// Builds a genuine enrollment: real X25519 and Ed25519 keys, a real Ed25519 statement signature, /// and an ID token whose nonce is the statement's actual canonical hash. /// /// /// Nothing here is stubbed. The point is that the server's two independent checks — the statement /// self-signature and the identity-provider binding — both run for real, so a change that breaks /// either shows up here rather than against a live Keycloak. /// internal sealed class TestEnrollment : IDisposable { private static readonly DateTimeOffset CreatedAt = DateTimeOffset.FromUnixTimeMilliseconds(1_750_000_000_123); private readonly StubIdentityProvider identityProvider; private readonly Key encryptionKey; private readonly Key signingKey; internal TestEnrollment(StubIdentityProvider identityProvider, string subject, string? email = null) { this.identityProvider = identityProvider; Subject = subject; encryptionKey = Key.Create(KeyAgreementAlgorithm.X25519); signingKey = Key.Create(SignatureAlgorithm.Ed25519); Statement = new KeyStatement( Version: 1, Issuer: identityProvider.Authority, Subject: subject, Email: email, EncryptionPublicKey: encryptionKey.PublicKey.Export(KeyBlobFormat.RawPublicKey), SigningPublicKey: signingKey.PublicKey.Export(KeyBlobFormat.RawPublicKey), KeyGeneration: 1, CreatedAt: CreatedAt, DeviceName: "test-device"); } /// The OIDC subject this enrollment is for. internal string Subject { get; } /// The client-chosen personal vault id. internal Guid VaultId { get; } = Guid.CreateVersion7(); /// The default, well-formed statement. internal KeyStatement Statement { get; } /// The identity fingerprint the server should compute. internal byte[] Fingerprint => DshCrypto.ComputeFingerprint( Statement.EncryptionPublicKey, Statement.SigningPublicKey); /// Signs a statement with this enrollment's Ed25519 key. internal byte[] Sign(KeyStatement statement) => DshSignatures.SignKeyStatement(signingKey, KeyStatementCodec.Encode(ToFields(statement))); /// Mints an ID token whose nonce is the given statement's binding. internal string MintIdToken( KeyStatement statement, string? subject = null, string? audience = null, string? issuer = null, DateTime? expires = null, bool omitNonce = false) => identityProvider.MintIdToken( subject ?? Subject, KeyStatementCodec.ComputeNonce(ToFields(statement)), audience: audience, issuer: issuer, expires: expires, omitNonce: omitNonce); /// Builds a complete, valid request, with every part overridable for negative tests. internal EnrollmentRequest Build( KeyStatement? statement = null, byte[]? statementSignature = null, string? idToken = null, KdfParameters? kdfParameters = null, PersonalVaultRequest? personalVault = null, bool includeDevice = true, bool includeRecovery = true) { var effective = statement ?? Statement; return new EnrollmentRequest( Statement: effective, StatementSignature: statementSignature ?? Sign(effective), IdentityProviderToken: idToken ?? MintIdToken(effective), WrappedPrivateKey: Bytes(220, 0x11), KdfParameters: kdfParameters ?? DefaultKdf(), DevicePublicKey: includeDevice ? Bytes(32, 0x22) : null, DeviceWrappedPrivateKey: includeDevice ? Bytes(240, 0x33) : null, RecoveryWrappedPrivateKey: includeRecovery ? Bytes(220, 0x44) : null, RecoveryKdfParameters: includeRecovery ? RecoveryKdf() : null, PersonalVault: personalVault ?? DefaultVault()); } /// The passphrase KDF profile, matching Argon2Profile.PassphraseDefault. internal static KdfParameters DefaultKdf() => new("argon2id", Bytes(16, 0x55), MemoryKibibytes: 256 * 1024, Passes: 4, Parallelism: 1); /// The recovery KDF profile. Cheaper, because a recovery code carries real entropy. internal static KdfParameters RecoveryKdf() => new("argon2id", Bytes(16, 0x66), MemoryKibibytes: 64 * 1024, Passes: 3, Parallelism: 1); /// /// The grant signature is a real Ed25519 signature of the right length, but not over the §7 /// grant tuple: that canonical encoding lands with team sharing in M3, and the server stores /// grant signatures opaquely rather than verifying them. Shape is what is under test here. /// internal PersonalVaultRequest DefaultVault(Guid? vaultId = null, string name = "Personal") => new( VaultId: vaultId ?? VaultId, Name: name, WrappedVaultKey: Bytes(80, 0x77), GrantSignature: SignatureAlgorithm.Ed25519.Sign(signingKey, Bytes(32, 0x88)), GrantedAt: CreatedAt); /// Bearer client for this enrollment's subject. internal HttpClient CreateClient(ApiFixture fixture) => fixture.CreateClientFor(Subject); /// public void Dispose() { encryptionKey.Dispose(); signingKey.Dispose(); } private static byte[] Bytes(int length, byte seed) => [.. Enumerable.Range(0, length).Select(i => (byte)(seed + i))]; /// /// Mapped here rather than reusing the server's mapper, so a change to either side's field list /// shows up as a failing enrollment instead of two copies of the same mistake agreeing. /// 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); }