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