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