using System.Security.Cryptography; namespace DodoSSH.Crypto; /// /// The master key derived from a vault passphrase, and the subkeys it yields. See docs/crypto.md §3. /// /// /// /// RAM only. Never persisted, never transmitted, never written to a keystore. What is /// cached locally is the KDF salt and the wrapped bundle, because unlock has to work with no /// network: a salt fetched at unlock time would mean an offline launch cannot open the vault, which /// is the single most common thing a user does on a plane. /// /// /// Argon2id parameters travel with each wrap rather than being global, so raising them later is a /// per-user migration at next unlock rather than a breaking change — an older client can still open /// its own wrap. See docs/crypto.md §2 for why the memory unit needs guarding. /// /// public sealed class MasterKey : IDisposable { private readonly byte[] material = new byte[CryptoSpec.MasterKeySize]; private bool disposed; private MasterKey(ReadOnlySpan derived) => derived.CopyTo(material); /// /// Stretches a passphrase into the master key. /// /// /// The cost is deliberately high enough to be noticeable — a few hundred milliseconds on a /// desktop at the default profile — because this is the only thing standing between a stolen /// database dump and every credential in the vault. /// /// The user's vault passphrase. /// Per-wrap salt, at least 16 bytes. Not a secret. /// Argon2id cost parameters. public static MasterKey Derive(string passphrase, ReadOnlySpan salt, Argon2Profile profile) { ArgumentException.ThrowIfNullOrEmpty(passphrase); ArgumentNullException.ThrowIfNull(profile); if (salt.Length < CryptoSpec.SaltSize) { throw new ArgumentException( $"Salt must be at least {CryptoSpec.SaltSize} bytes, got {salt.Length}.", nameof(salt)); } var derived = profile .CreateAlgorithm() .DeriveBytes(passphrase, salt, CryptoSpec.MasterKeySize); try { return new MasterKey(derived); } finally { CryptographicOperations.ZeroMemory(derived); } } /// /// Derives the key that encrypts the client's on-disk cache. /// /// /// Domain-separated from the bundle's key-encryption key by its HKDF info label, so a cache /// record can never be opened with the wrap key or the reverse — the two live in very different /// threat models and must not share a key. /// public void DeriveLocalCacheKey(Span destination) => DeriveSubkey(CryptoSpec.DerivationLabels.LocalCache, destination); /// Wraps a bundle under the passphrase-derived key-encryption key. /// /// The key-encryption key is never handed out. Callers that only need to wrap or unwrap should /// not be holding it, and one that does is one more place it can be leaked or logged. /// public byte[] WrapBundle(UserSecretBundle bundle, in AadDescriptor descriptor) { ArgumentNullException.ThrowIfNull(bundle); ObjectDisposedException.ThrowIf(disposed, this); Span kek = stackalloc byte[CryptoSpec.SymmetricKeySize]; try { DeriveSubkey(CryptoSpec.DerivationLabels.PassphraseKek, kek); return bundle.WrapUnder(kek, descriptor); } finally { CryptographicOperations.ZeroMemory(kek); } } /// /// Opens a bundle wrapped by . /// /// /// The bundle, or when the passphrase is wrong or the wrap does not /// belong to this descriptor. A wrong passphrase is the overwhelmingly common case, so it is a /// return value rather than an exception. /// public UserSecretBundle? TryOpenBundle(ReadOnlySpan envelope, in AadDescriptor descriptor) { ObjectDisposedException.ThrowIf(disposed, this); Span kek = stackalloc byte[CryptoSpec.SymmetricKeySize]; try { DeriveSubkey(CryptoSpec.DerivationLabels.PassphraseKek, kek); return UserSecretBundle.TryOpenUnder(kek, envelope, descriptor); } finally { CryptographicOperations.ZeroMemory(kek); } } /// public void Dispose() { if (disposed) { return; } disposed = true; CryptographicOperations.ZeroMemory(material); } /// /// /// HKDF-Expand rather than the full extract-then-expand: the master key is already a uniformly /// random Argon2id output, so there is no low-entropy input left for an extract step to /// condition. RFC 5869 §3.3 covers exactly this case. /// /// /// That choice is why the master key is bytes rather than /// 32: skipping extract means the master key is the PRK, and .NET's /// HKDF.Expand rejects a PRK shorter than the hash output. /// /// private void DeriveSubkey(ReadOnlySpan info, Span destination) { ObjectDisposedException.ThrowIf(disposed, this); if (destination.Length != CryptoSpec.SymmetricKeySize) { throw new ArgumentException( $"Subkeys are {CryptoSpec.SymmetricKeySize} bytes, got {destination.Length}.", nameof(destination)); } HKDF.Expand(HashAlgorithmName.SHA512, material, destination, info); } }