Files
DodoSSH/src/DodoSSH.Crypto/UserSecretBundle.cs
T
jaap-jan 7016ce36f1 Key the local cache to the identity, not to the door it was opened through
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.
2026-07-30 12:46:55 +02:00

378 lines
14 KiB
C#

using System.Buffers.Binary;
using System.Security.Cryptography;
using NSec.Cryptography;
namespace DodoSSH.Crypto;
/// <summary>
/// A user's identity key pair, in the form that gets wrapped. See docs/crypto.md §3.
/// </summary>
/// <remarks>
/// <para>
/// This is the load-bearing structural choice of the whole key hierarchy. The bundle is stored
/// server-side as N independent wraps of the <em>same</em> bytes — one per passphrase, device,
/// recovery code and escrow key — so a passphrase change re-wraps about a hundred bytes and updates
/// one row. No vault data is re-encrypted, and no other member is involved. Adding a device or a
/// recovery code is likewise one more wrap row. Encrypting vault keys under the passphrase key
/// directly would have made every one of those a fan-out over the entire vault.
/// </para>
/// <para>
/// Private keys live in libsodium's guarded, <c>mlock</c>ed allocations rather than in a
/// <see cref="byte"/> array, so they are not paged to disk and do not appear in a core dump. They
/// are created exportable, which is a deliberate trade: re-wrapping the same bundle for a passphrase
/// change or a new device requires re-encoding it, and the alternative — holding the plaintext in a
/// long-lived managed array so the keys need not be exportable — keeps the identical secret in
/// strictly worse memory. Every export here is into a buffer that is zeroed before the method
/// returns.
/// </para>
/// </remarks>
public sealed class UserSecretBundle : IDisposable
{
/// <summary>Domain-separating prefix of the encoding.</summary>
public static ReadOnlySpan<byte> Label => "dsh1/bundle/v1"u8;
/// <summary>Encoding version this implementation writes.</summary>
public const int CurrentVersion = 1;
/// <summary>
/// Total encoded length. Fixed, because every field is fixed-width.
/// </summary>
public const int EncodedLength =
14 // Label
+ sizeof(ushort) // version
+ sizeof(uint) // key generation
+ sizeof(long) // created at, Unix milliseconds
+ (CryptoSpec.SymmetricKeySize * 2); // X25519 then Ed25519 private keys
private const int OffsetVersion = 14;
private const int OffsetKeyGeneration = 16;
private const int OffsetCreatedAt = 20;
private const int OffsetEncryptionKey = 28;
private const int OffsetSigningKey = 60;
private readonly Key encryptionKey;
private readonly Key signingKey;
private bool disposed;
private UserSecretBundle(Key encryptionKey, Key signingKey, uint keyGeneration, DateTimeOffset createdAt)
{
this.encryptionKey = encryptionKey;
this.signingKey = signingKey;
KeyGeneration = keyGeneration;
CreatedAt = createdAt;
EncryptionPublicKey = encryptionKey.PublicKey.Export(KeyBlobFormat.RawPublicKey);
SigningPublicKey = signingKey.PublicKey.Export(KeyBlobFormat.RawPublicKey);
}
/// <summary>Generation of this identity key pair, starting at 1.</summary>
public uint KeyGeneration { get; }
/// <summary>When the keys were generated, truncated to milliseconds.</summary>
public DateTimeOffset CreatedAt { get; }
/// <summary>X25519 public key, 32 bytes. Safe to publish.</summary>
public byte[] EncryptionPublicKey { get; }
/// <summary>Ed25519 public key, 32 bytes. Safe to publish.</summary>
public byte[] SigningPublicKey { get; }
/// <summary>The X25519 key, for unwrapping vault keys sealed to this user.</summary>
public Key EncryptionKey => Alive().encryptionKey;
/// <summary>The Ed25519 key, for signing key statements and grants.</summary>
public Key SigningKey => Alive().signingKey;
/// <summary>Generates a fresh identity key pair.</summary>
/// <param name="createdAt">Creation timestamp; truncated to milliseconds.</param>
/// <param name="keyGeneration">Generation number. 1 at enrollment.</param>
public static UserSecretBundle Create(DateTimeOffset createdAt, uint keyGeneration = 1)
{
ArgumentOutOfRangeException.ThrowIfLessThan(keyGeneration, 1u);
var parameters = new KeyCreationParameters
{
ExportPolicy = KeyExportPolicies.AllowPlaintextExport,
};
var encryption = Key.Create(KeyAgreementAlgorithm.X25519, parameters);
Key signing;
try
{
signing = Key.Create(SignatureAlgorithm.Ed25519, parameters);
}
catch
{
encryption.Dispose();
throw;
}
return new UserSecretBundle(
encryption,
signing,
keyGeneration,
KeyLogChain.TruncateTimestamp(createdAt));
}
/// <summary>
/// Derives the key that encrypts this client's on-disk cache. See docs/crypto.md §3.2.
/// </summary>
/// <remarks>
/// <para>
/// Derived from the bundle rather than from the passphrase master key, because the bundle is the one
/// thing every unlock path ends up holding. A device unlock opens a <see cref="SealTo"/> wrap and never
/// sees a master key at all, so under the old derivation it could open the identity and still not read
/// the cache it had itself written; a recovery-code unlock would derive a different key from a different
/// master and silently orphan every cached row. Keying on the bundle makes the cache readable through
/// whichever door was used, and unreadable exactly when the identity itself has been rotated — which is
/// the correct time to discard it.
/// </para>
/// <para>
/// <b>Extract-and-expand, not expand alone.</b> <see cref="MasterKey"/> can expand directly because an
/// Argon2id output is already 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 it
/// reaches any key material. HKDF's extract step is what turns that into a pseudorandom key, and
/// skipping it would be feeding structured input to a function that assumes it has none.
/// </para>
/// </remarks>
public void DeriveLocalCacheKey(Span<byte> destination)
{
Alive();
if (destination.Length != CryptoSpec.SymmetricKeySize)
{
throw new ArgumentException(
$"The cache key is {CryptoSpec.SymmetricKeySize} bytes, got {destination.Length}.",
nameof(destination));
}
Span<byte> encoded = stackalloc byte[EncodedLength];
try
{
Encode(encoded);
HKDF.DeriveKey(
HashAlgorithmName.SHA512,
encoded,
destination,
salt: default,
info: CryptoSpec.DerivationLabels.LocalCache);
}
finally
{
CryptographicOperations.ZeroMemory(encoded);
}
}
/// <summary>
/// Wraps the bundle under a symmetric key-encryption key.
/// </summary>
/// <remarks>
/// Used for the passphrase and recovery-code wraps, whose keys are derived rather than sealed
/// to. The plaintext exists only inside this method and is zeroed before it returns.
/// </remarks>
public byte[] WrapUnder(ReadOnlySpan<byte> keyEncryptionKey, in AadDescriptor descriptor)
{
Alive();
Span<byte> plaintext = stackalloc byte[EncodedLength];
try
{
Encode(plaintext);
return DshCrypto.Seal(keyEncryptionKey, plaintext, descriptor);
}
finally
{
CryptographicOperations.ZeroMemory(plaintext);
}
}
/// <summary>
/// Seals the bundle to a public key.
/// </summary>
/// <remarks>
/// Used for device and escrow wraps. Anonymous-sender by construction, which is fine here: the
/// recipient is the same user, so there is nothing to attribute.
/// </remarks>
public byte[] SealTo(ReadOnlySpan<byte> recipientPublicKey, in AadDescriptor descriptor)
{
Alive();
Span<byte> plaintext = stackalloc byte[EncodedLength];
try
{
Encode(plaintext);
return DshCrypto.SealTo(recipientPublicKey, plaintext, descriptor);
}
finally
{
CryptographicOperations.ZeroMemory(plaintext);
}
}
/// <summary>
/// Opens a wrap made by <see cref="WrapUnder"/>.
/// </summary>
/// <returns>
/// The bundle, or <see langword="null"/> if the key is wrong, the descriptor does not match, or
/// the encoding is unrecognised. Never throws on bad input: the wrap arrives from a server that
/// is explicitly not trusted, so a failed tag is an expected outcome — most often simply a
/// mistyped passphrase.
/// </returns>
public static UserSecretBundle? TryOpenUnder(
ReadOnlySpan<byte> keyEncryptionKey,
ReadOnlySpan<byte> envelope,
in AadDescriptor descriptor)
{
var plaintext = DshCrypto.Open(keyEncryptionKey, envelope, descriptor);
return FromPlaintext(plaintext);
}
/// <summary>Opens a wrap made by <see cref="SealTo"/>, using the recipient's private key.</summary>
public static UserSecretBundle? TryOpenSealed(
Key recipientKey,
ReadOnlySpan<byte> envelope,
in AadDescriptor descriptor)
{
var plaintext = DshCrypto.OpenSealed(recipientKey, envelope, descriptor);
return FromPlaintext(plaintext);
}
/// <inheritdoc />
public void Dispose()
{
if (disposed)
{
return;
}
disposed = true;
encryptionKey.Dispose();
signingKey.Dispose();
}
private void Encode(Span<byte> destination)
{
Label.CopyTo(destination);
BinaryPrimitives.WriteUInt16BigEndian(destination[OffsetVersion..], CurrentVersion);
BinaryPrimitives.WriteUInt32BigEndian(destination[OffsetKeyGeneration..], KeyGeneration);
BinaryPrimitives.WriteInt64BigEndian(destination[OffsetCreatedAt..], CreatedAt.ToUnixTimeMilliseconds());
// Ed25519's raw private key is the 32-byte seed, and X25519's is the 32-byte scalar, so both
// fit the fixed layout. Exported straight into the caller's buffer, which is zeroed there.
ExportInto(encryptionKey, destination.Slice(OffsetEncryptionKey, CryptoSpec.SymmetricKeySize));
ExportInto(signingKey, destination.Slice(OffsetSigningKey, CryptoSpec.SymmetricKeySize));
}
private static void ExportInto(Key key, Span<byte> destination)
{
var exported = key.Export(KeyBlobFormat.RawPrivateKey);
try
{
if (exported.Length != destination.Length)
{
throw new CryptographicException(
$"Expected a {destination.Length}-byte raw private key, got {exported.Length}.");
}
exported.CopyTo(destination);
}
finally
{
CryptographicOperations.ZeroMemory(exported);
}
}
private static UserSecretBundle? FromPlaintext(byte[]? plaintext)
{
if (plaintext is null)
{
return null;
}
try
{
return TryDecode(plaintext);
}
finally
{
CryptographicOperations.ZeroMemory(plaintext);
}
}
/// <remarks>
/// Fails closed on anything unexpected. A bundle is the root of everything a user can read, so
/// guessing at a malformed one is never the right move.
/// </remarks>
private static bool IsWellFormed(ReadOnlySpan<byte> encoded) =>
encoded.Length == EncodedLength
&& encoded[..Label.Length].SequenceEqual(Label)
&& BinaryPrimitives.ReadUInt16BigEndian(encoded[OffsetVersion..]) == CurrentVersion
&& BinaryPrimitives.ReadUInt32BigEndian(encoded[OffsetKeyGeneration..]) >= 1;
/// <remarks>
/// Internal rather than private so the golden-vector generator can build a bundle from fixed bytes.
/// The cache key derives from this encoding, and a vector for it is worth nothing unless the input is
/// pinned — <see cref="Create"/> draws fresh randomness, so it cannot produce a reproducible one.
/// </remarks>
internal static UserSecretBundle? TryDecode(ReadOnlySpan<byte> encoded)
{
if (!IsWellFormed(encoded))
{
return null;
}
var keyGeneration = BinaryPrimitives.ReadUInt32BigEndian(encoded[OffsetKeyGeneration..]);
var createdAt = DateTimeOffset.FromUnixTimeMilliseconds(
BinaryPrimitives.ReadInt64BigEndian(encoded[OffsetCreatedAt..]));
var parameters = new KeyCreationParameters
{
ExportPolicy = KeyExportPolicies.AllowPlaintextExport,
};
Key? encryption = null;
Key? signing = null;
try
{
encryption = Key.Import(
KeyAgreementAlgorithm.X25519,
encoded.Slice(OffsetEncryptionKey, CryptoSpec.SymmetricKeySize),
KeyBlobFormat.RawPrivateKey,
parameters);
signing = Key.Import(
SignatureAlgorithm.Ed25519,
encoded.Slice(OffsetSigningKey, CryptoSpec.SymmetricKeySize),
KeyBlobFormat.RawPrivateKey,
parameters);
var bundle = new UserSecretBundle(encryption, signing, keyGeneration, createdAt);
// Ownership transferred; do not dispose in the catch below.
encryption = null;
signing = null;
return bundle;
}
catch (FormatException)
{
return null;
}
finally
{
encryption?.Dispose();
signing?.Dispose();
}
}
private UserSecretBundle Alive()
{
ObjectDisposedException.ThrowIf(disposed, this);
return this;
}
}