Public Access
Groundwork for a device key, and a spec change rather than a feature. ADR 0007 records the decision it clears the way for: a Windows Hello gesture guarding a protected blob, with the passphrase kept as a permanent fallback. The reason that decision needed this first is that a device key cannot open a session on its own. SessionOpener derived two things from the passphrase master key — the bundle, and the local cache key — and a device wrap is SealTo(device_x25519_pk), which yields the bundle and never computes a master key at all. A device unlock could therefore have opened the identity and still not read the cache it had itself written. So LocalCacheKey now derives from the bundle: dsh1/localcache/v1 → v2, specified in crypto.md §3.2. Every wrap that opens a vault ends up holding the bundle, so every door reaches the same cache. Extract-and-expand, not expand alone. Everything derived from the master key uses HKDF-Expand directly, which is sound because an Argon2id output is uniformly random over its whole length. The bundle's encoding is not — it opens with a fixed 14-byte label and carries a version, a generation and a timestamp before reaching any key material — so it needs the extract step to become a pseudorandom key first. Two consequences fell out, both improvements and neither the point: - A passphrase change no longer discards the local cache. The bundle is unchanged by a re-wrap, so the cache key is too. Under v1 changing a passphrase silently orphaned every cached row and the next launch re-pulled the whole vault. - Recovery-code unlock is fixed before it ships. It derives a different master key from a different secret and a different salt, so under v1 it would have had the same defect as the device path, and nobody would have noticed until it landed. The cache becomes unreadable exactly when the identity is rotated, which is the correct moment to discard it. Existing caches are discarded and re-pulled on upgrade — already the specified behaviour for a stale cache, and the reason the label is versioned rather than reused: a v1 cache must fail to open rather than decrypt to nonsense. One stated guarantee got weaker and now says so. crypto.md §10 claimed locking meant "nothing on disk can be read again without the passphrase." Where a device wrap exists that is no longer true, and it would have been untrue under either candidate design — the alternative was storing a copy of the cache key in the device blob, which is the same door with an extra key lying next to it. The wording now points at ADR 0007, because what guards the device key is a platform decision and not a property of this specification. A golden vector was quietly lying, which is the part worth reading twice. The "local-cache" entry pinned HKDF-SHA512-Expand over a fixed PRK — a construction the cache key no longer uses. Regenerating it would have produced a green suite describing a derivation this code does not perform. It is replaced by a vector over a bundle whose every byte is pinned: the label, version 1, generation 1, a fixed timestamp and two recognisable key scalars, all visible in the fixture so a second implementation can check itself against it. UserSecretBundle.TryDecode is internal for this, because Create draws fresh randomness and so can never produce a reproducible input. Mutation tested, and this one earns its keep: dropping the extract step now fails CommittedVectors_MatchCurrentImplementation. The vector it replaced could not have caught that, because it never touched the bundle at all. One test became false and says so. ARecordSealedUnderAnotherPassphrase is now ARecordSealedByAnotherIdentity: a different passphrase deliberately no longer changes the cache key, and TheLocalCacheKey_SurvivesAPassphraseChange pins that. What must still be unreadable is another user's cache. CacheHarness therefore generates an identity rather than deriving from a passphrase, and has no passphrase parameter left — the cache key is not a question about passphrases any more. SyncHarness's two simulated machines now derive the same cache key, which is what keying on the bundle means: they are the same user holding the same identity. They still have separate cache databases, so nothing is shared between them but the key that would open either. Both harnesses lost a MasterKey field that existed only to make a protector. 858 tests green. Zero warnings, dotnet format clean. Not done: the device key itself. Three pieces remain, and the middle one was a discovery rather than a plan — EnrollmentService.AddDevice runs only during enrollment, so every already-enrolled account, which is all of them, needs an endpoint to add a device wrap while unlocked. The client proves possession by producing the wrap, so that shape falls out of the crypto. After that: the protector seam with the wrap cached locally for offline unlock, then the Hello implementation and the unlock-screen UI, which is where the Windows TFM lands and where automated testing stops.
153 lines
5.7 KiB
C#
153 lines
5.7 KiB
C#
using System.Security.Cryptography;
|
|
|
|
namespace DodoSSH.Crypto;
|
|
|
|
/// <summary>
|
|
/// The master key derived from a vault passphrase, and the subkeys it yields. See docs/crypto.md §3.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// RAM only. Never persisted, never transmitted, never written to a keystore. What <em>is</em>
|
|
/// 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// 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.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class MasterKey : IDisposable
|
|
{
|
|
private readonly byte[] material = new byte[CryptoSpec.MasterKeySize];
|
|
private bool disposed;
|
|
|
|
private MasterKey(ReadOnlySpan<byte> derived) => derived.CopyTo(material);
|
|
|
|
/// <summary>
|
|
/// Stretches a passphrase into the master key.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
/// <param name="passphrase">The user's vault passphrase.</param>
|
|
/// <param name="salt">Per-wrap salt, at least 16 bytes. Not a secret.</param>
|
|
/// <param name="profile">Argon2id cost parameters.</param>
|
|
public static MasterKey Derive(string passphrase, ReadOnlySpan<byte> 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);
|
|
}
|
|
}
|
|
|
|
// The local cache key used to be derived here. It now derives from the secret bundle — see
|
|
// UserSecretBundle.DeriveLocalCacheKey — because a passphrase is only one of four ways to open a
|
|
// vault, and the cache key has to be the same one whichever was used.
|
|
|
|
/// <summary>Wraps a bundle under the passphrase-derived key-encryption key.</summary>
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
public byte[] WrapBundle(UserSecretBundle bundle, in AadDescriptor descriptor)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(bundle);
|
|
ObjectDisposedException.ThrowIf(disposed, this);
|
|
|
|
Span<byte> kek = stackalloc byte[CryptoSpec.SymmetricKeySize];
|
|
try
|
|
{
|
|
DeriveSubkey(CryptoSpec.DerivationLabels.PassphraseKek, kek);
|
|
return bundle.WrapUnder(kek, descriptor);
|
|
}
|
|
finally
|
|
{
|
|
CryptographicOperations.ZeroMemory(kek);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Opens a bundle wrapped by <see cref="WrapBundle"/>.
|
|
/// </summary>
|
|
/// <returns>
|
|
/// The bundle, or <see langword="null"/> 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.
|
|
/// </returns>
|
|
public UserSecretBundle? TryOpenBundle(ReadOnlySpan<byte> envelope, in AadDescriptor descriptor)
|
|
{
|
|
ObjectDisposedException.ThrowIf(disposed, this);
|
|
|
|
Span<byte> kek = stackalloc byte[CryptoSpec.SymmetricKeySize];
|
|
try
|
|
{
|
|
DeriveSubkey(CryptoSpec.DerivationLabels.PassphraseKek, kek);
|
|
return UserSecretBundle.TryOpenUnder(kek, envelope, descriptor);
|
|
}
|
|
finally
|
|
{
|
|
CryptographicOperations.ZeroMemory(kek);
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public void Dispose()
|
|
{
|
|
if (disposed)
|
|
{
|
|
return;
|
|
}
|
|
|
|
disposed = true;
|
|
CryptographicOperations.ZeroMemory(material);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// <para>
|
|
/// 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// That choice is why the master key is <see cref="CryptoSpec.MasterKeySize"/> bytes rather than
|
|
/// 32: skipping extract means the master key <em>is</em> the PRK, and .NET's
|
|
/// <c>HKDF.Expand</c> rejects a PRK shorter than the hash output.
|
|
/// </para>
|
|
/// </remarks>
|
|
private void DeriveSubkey(ReadOnlySpan<byte> info, Span<byte> 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);
|
|
}
|
|
}
|