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