using System.Security.Cryptography; using DodoSSH.Client.Auth; using DodoSSH.Contracts; using DodoSSH.Crypto; using NSec.Cryptography; namespace DodoSSH.Client.Api; /// What enrolling produced, for the caller to hold and persist. /// /// The bundle and the vault key are live secrets. The caller owns their lifetime and must dispose the /// bundle; neither is ever written anywhere but the OS keystore and the encrypted local cache. /// /// The server's answer, including the vault and key log position. /// The identity key pair, unlocked for this session. /// The personal vault's key, in plaintext for this session. /// /// The enrolled device's X25519 private key, or when no device was bound. /// Belongs in the OS keystore — it is what lets a later launch unlock without the passphrase, and it /// is the only thing that can open the device wrap held server-side. /// /// /// The generated recovery code, which must be shown to the user once and never stored. Losing this /// along with the passphrase and every device means the vault is unrecoverable, and no server-side /// reset is possible by design. /// public sealed record EnrollmentOutcome( EnrollmentResponse Response, UserSecretBundle Bundle, byte[] PersonalVaultKey, byte[]? DevicePrivateKey, string RecoveryCode); /// /// Runs enrollment: generate keys, have the identity provider sign over them, and publish. /// /// /// /// Ordering here is not a matter of taste. The secret bundle's AAD binds to the server-assigned user /// id, so /me must be read before anything can be wrapped — which is why /me provisions /// the account and returns its id even when it reports that enrollment is required. /// /// /// Everything the server receives is opaque to it. It gets public keys, wrapped blobs it cannot open, /// and signatures it does not verify beyond the statement's own. That is the whole point: the server /// stores the vault and cannot read it. /// /// public sealed class ClientEnrollment( IAccountApi api, IKeyBindingAuthorizer keyBinding, TimeProvider clock, Argon2Profile? passphraseProfile = null) { /// /// Configurable because the cost is a product decision, not a constant: the plan exposes 128, 256 and /// 512 MiB security levels, and the parameters travel with the wrap so a user's choice is theirs /// alone. It also lets a test pay milliseconds instead of a third of a second to prove something that /// has nothing to do with how hard the passphrase is to attack. /// private readonly Argon2Profile passphraseProfile = passphraseProfile ?? Argon2Profile.PassphraseDefault; /// Bytes of entropy behind a recovery code. private const int RecoveryEntropyBytes = 20; /// /// Enrolls the caller. /// /// /// The result of , which supplies the user id the wraps /// bind to. /// /// The vault passphrase. Never transmitted or stored. /// Human-readable name for this machine. /// Display name for the personal vault. Plaintext, as vault names are. /// /// Whether to register a device key so a later launch can unlock without the passphrase. /// /// Pass when the caller has nowhere durable to keep the private half. A /// device wrap whose private key does not survive the process is a row on the server that nobody can /// ever open, and it makes the account's device list claim a capability this machine does not have — /// which is worse than not offering it. /// /// /// Cancellation token. public async Task EnrollAsync( MeResponse me, string passphrase, string deviceName, string vaultName, bool bindThisDevice, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(me); ArgumentException.ThrowIfNullOrWhiteSpace(passphrase); ArgumentException.ThrowIfNullOrWhiteSpace(deviceName); var now = clock.GetUtcNow(); var bundle = UserSecretBundle.Create(now); try { var statement = BuildStatement(me, bundle, now, deviceName); // The provider signs over the statement's hash, which is what stops the DodoSSH server // fabricating a key for a user who never enrolled. See ADR 0001. var idToken = await keyBinding .AuthorizeKeyBindingAsync( KeyStatementCodec.ComputeNonce(ToFields(statement)), cancellationToken) .ConfigureAwait(false); var request = BuildRequest( me, bundle, statement, idToken, passphrase, passphraseProfile, vaultName, bindThisDevice, now, out var material); var response = await api.EnrollAsync(request, cancellationToken).ConfigureAwait(false); return new EnrollmentOutcome( response, bundle, material.VaultKey, material.DevicePrivateKey, material.RecoveryCode); } catch { // The caller never receives the bundle on failure, so this is the only place that can // release its guarded memory. bundle.Dispose(); throw; } } private static KeyStatement BuildStatement( MeResponse me, UserSecretBundle bundle, DateTimeOffset now, string deviceName) => new( Version: KeyStatementCodec.CurrentVersion, Issuer: me.Issuer, Subject: me.Subject, Email: me.Email, EncryptionPublicKey: bundle.EncryptionPublicKey, SigningPublicKey: bundle.SigningPublicKey, KeyGeneration: 1, CreatedAt: now, DeviceName: deviceName); /// Secrets the caller keeps after a successful enrollment. private readonly record struct SessionMaterial( byte[] VaultKey, byte[]? DevicePrivateKey, string RecoveryCode); /// /// The passphrase is a parameter rather than a field, so it lives only for the duration of this call /// and never becomes state on a long-lived object that a heap dump would find. /// private static EnrollmentRequest BuildRequest( MeResponse me, UserSecretBundle bundle, KeyStatement statement, string idToken, string passphrase, Argon2Profile passphraseProfile, string vaultName, bool bindThisDevice, DateTimeOffset now, out SessionMaterial material) { var descriptor = DshAad.UserSecretBundle(me.UserId); // A fresh salt per wrap, and the parameters travel with it — so raising the cost later is a // per-user migration at next unlock rather than a breaking change. var passphraseSalt = RandomNumberGenerator.GetBytes(CryptoSpec.SaltSize); var recoverySalt = RandomNumberGenerator.GetBytes(CryptoSpec.SaltSize); var recoveryCode = GenerateRecoveryCode(); byte[] passphraseWrap; byte[] recoveryWrap; using (var master = MasterKey.Derive(passphrase, passphraseSalt, passphraseProfile)) { passphraseWrap = master.WrapBundle(bundle, descriptor); } // The recovery code carries real entropy, so it needs far less stretching than a passphrase. using (var recoveryMaster = MasterKey.Derive( recoveryCode, recoverySalt, Argon2Profile.RandomSecret)) { recoveryWrap = recoveryMaster.WrapBundle(bundle, descriptor); } byte[]? devicePublicKey = null; byte[]? deviceWrap = null; byte[]? devicePrivateKey = null; if (bindThisDevice) { using var deviceKey = Key.Create( KeyAgreementAlgorithm.X25519, new KeyCreationParameters { ExportPolicy = KeyExportPolicies.AllowPlaintextExport }); devicePublicKey = deviceKey.PublicKey.Export(KeyBlobFormat.RawPublicKey); deviceWrap = bundle.SealTo(devicePublicKey, descriptor); devicePrivateKey = deviceKey.Export(KeyBlobFormat.RawPrivateKey); } var vault = BuildPersonalVault(me, bundle, vaultName, now, out var vaultKey); material = new SessionMaterial(vaultKey, devicePrivateKey, recoveryCode); return new EnrollmentRequest( Statement: statement, StatementSignature: DshSignatures.SignKeyStatement( bundle.SigningKey, KeyStatementCodec.Encode(ToFields(statement))), IdentityProviderToken: idToken, WrappedPrivateKey: passphraseWrap, KdfParameters: ToContract(passphraseSalt, passphraseProfile), DevicePublicKey: devicePublicKey, DeviceWrappedPrivateKey: deviceWrap, RecoveryWrappedPrivateKey: recoveryWrap, RecoveryKdfParameters: ToContract(recoverySalt, Argon2Profile.RandomSecret), PersonalVault: vault); } /// /// The vault id is chosen here rather than by the server, which is what makes enrollment safely /// retryable and is required by the grant signature — the signed tuple covers the vault id. /// /// A self-grant carries no key log head: there is no third party whose key could have been /// substituted, and the log entry that would supply one is written by the server in the same /// transaction, so it cannot be signed over here. /// /// private static PersonalVaultRequest BuildPersonalVault( MeResponse me, UserSecretBundle bundle, string vaultName, DateTimeOffset now, out byte[] vaultKey) { var vaultId = Guid.CreateVersion7(); vaultKey = VaultKeys.Create(); var wrappedVaultKey = VaultKeys.WrapTo(vaultKey, bundle.EncryptionPublicKey, vaultId, 1); var fingerprint = DshCrypto.ComputeFingerprint( bundle.EncryptionPublicKey, bundle.SigningPublicKey); var grant = GrantStatementCodec.Encode( vaultId, keyGeneration: 1, GrantPurpose.Member, granteeUserId: me.UserId, granteeKeyFingerprint: fingerprint, wrappedKey: wrappedVaultKey, granterUserId: me.UserId, granterKeyFingerprint: fingerprint, keyLogHead: default, grantedAt: now); return new PersonalVaultRequest( VaultId: vaultId, Name: vaultName, WrappedVaultKey: wrappedVaultKey, GrantSignature: GrantStatementCodec.Sign(bundle.SigningKey, grant), GrantedAt: now); } /// /// Generates a printable recovery code. /// /// /// Base32 over Crockford's alphabet, which omits I, L, O and U — so a code read aloud or copied off /// a screen cannot be mistranscribed into a different valid code, and cannot spell anything /// unfortunate. Grouped for legibility, and the groups are not part of the secret: the derivation /// uses the string exactly as shown, dashes included, because that is what the user will type back. /// private static string GenerateRecoveryCode() { const string Alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; const int BitsPerCharacter = 5; const int CharactersPerGroup = 5; // 20 bytes is 160 bits, which divides evenly into 32 five-bit characters — so no bits are // discarded and no padding is needed. Separators go between groups, hence one fewer than the // number of groups. var totalCharacters = RecoveryEntropyBytes * 8 / BitsPerCharacter; var separators = (totalCharacters - 1) / CharactersPerGroup; var entropy = RandomNumberGenerator.GetBytes(RecoveryEntropyBytes); var characters = new char[totalCharacters + separators]; var index = 0; for (var position = 0; position < totalCharacters; position++) { if (position > 0 && position % CharactersPerGroup == 0) { characters[index++] = '-'; } var value = 0; for (var offset = 0; offset < BitsPerCharacter; offset++) { var bit = (position * BitsPerCharacter) + offset; value = (value << 1) | ((entropy[bit / 8] >> (7 - (bit % 8))) & 1); } characters[index++] = Alphabet[value]; } CryptographicOperations.ZeroMemory(entropy); return new string(characters); } private static KdfParameters ToContract(byte[] salt, Argon2Profile profile) => new( Algorithm: "argon2id", Salt: salt, MemoryKibibytes: profile.MemoryKibibytes, Passes: profile.Passes, Parallelism: Argon2Profile.Parallelism); 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); }