Add the client key hierarchy: bundle, master key, vault and item keys

Everything crypto.md section 3 describes below the identity key, which is
what the desktop client needs before it can enroll or store anything.

DshAad gives every descriptor in the specification a named constructor. The
AAD binding is the most valuable structural property in the design -- it is
what stops a server holding every ciphertext from pasting one row's bytes
onto another, rolling a row back to a superseded generation, or replaying a
revoked grant -- and all of it depends on callers getting purpose, resource
type and ids right at every single call site. Hand-constructing descriptors
makes that a matter of care; picking a method name makes it a matter of
spelling.

UserSecretBundle holds private keys in libsodium's guarded, mlocked
allocations rather than a byte[], so they are not paged out and do not land
in a core dump. They are created exportable, deliberately: re-wrapping the
same bundle for a passphrase change or a new device needs to re-encode it,
and the alternative -- a long-lived managed array so the keys need not be
exportable -- keeps the identical secret in strictly worse memory. Every
export is into a buffer zeroed before the method returns.

Two spec changes, both found by implementing it, which is the argument for
writing code before calling a spec frozen:

- MK is 64 bytes, not 32. Skipping HKDF-Extract is correct for an Argon2id
  output (RFC 5869 3.3), but it means MK *is* the PRK, and .NET's
  HKDF.Expand rejects a PRK shorter than the hash output -- so a 32-byte MK
  cannot be expanded with SHA-512 at all. Widening it keeps the specified
  primitive; the alternatives were dropping to SHA-256 or adding an Extract
  step that conditions nothing.
- The bundle encoding is a fixed 92-byte layout rather than canonical CBOR.
  Canonicality is not load-bearing here -- unlike a key statement the bundle
  is never hashed or signed, only encrypted -- so CBOR's one advantage does
  not apply, while its canonicalisation rules are a real source of
  cross-implementation disagreement. It also costs a dependency
  System.Formats.Cbor is not in the shared framework. Safe to change now
  and not later: no bundle has ever been stored.

53 new tests. The encoding is checked against an independent codec written
in the test rather than by round-tripping production code against itself --
a round trip passes just as happily when both directions are wrong the same
way, and this format cannot change after one bundle is stored. The pinned
92-byte hex constant is the golden vector for the layout.

Most of the rest are negative, because a binding is only demonstrated by
the substitutions that fail: a wrap for another user, a grant from a
superseded generation, a payload pasted onto another item, a metadata blob
offered as a payload, a version rolled back.
This commit is contained in:
2026-07-28 21:02:52 +02:00
parent 885fb17bdc
commit e65d738912
9 changed files with 1763 additions and 3 deletions
+9
View File
@@ -32,6 +32,15 @@ public static class CryptoSpec
/// <summary>Size of a symmetric content or wrapping key.</summary>
public const int SymmetricKeySize = 32;
/// <summary>
/// Size of the passphrase-derived master key, which subkeys are expanded from.
/// </summary>
/// <remarks>
/// 64 bytes, not 32, because it is the PRK of an HKDF-SHA512 expansion and .NET's
/// <c>HKDF.Expand</c> rejects a PRK shorter than the hash output. See docs/crypto.md §3.
/// </remarks>
public const int MasterKeySize = 64;
/// <summary>Size of an X25519 or Ed25519 public key.</summary>
public const int PublicKeySize = 32;
+139
View File
@@ -0,0 +1,139 @@
namespace DodoSSH.Crypto;
/// <summary>
/// Named constructors for every AAD descriptor the specification defines. See docs/crypto.md §4.
/// </summary>
/// <remarks>
/// <para>
/// The AAD binding is the most valuable structural property in the design: it is what stops a
/// server that holds every ciphertext from pasting one row's bytes onto another, rolling a row back
/// to a superseded key generation, replaying a revoked grant, or substituting a metadata blob for a
/// payload. None of that follows from access control.
/// </para>
/// <para>
/// All of it also depends on callers getting the purpose, resource type and ids right at every call
/// site. Hand-constructing <see cref="AadDescriptor"/> makes that a matter of care; going through
/// these factories makes it a matter of picking the right method name, which is the difference
/// between a property that holds and one that mostly holds.
/// </para>
/// </remarks>
public static class DshAad
{
/// <summary>
/// Binds a wrap of the user's secret bundle to that user and key generation.
/// </summary>
/// <remarks>
/// <para>
/// Every wrap kind — passphrase, device, recovery, escrow — protects the same bundle and uses
/// this same descriptor. In particular a device wrap is <em>not</em> bound to its device row:
/// the client cannot be, since the server assigns the device id after the wrap is built, and it
/// need not be, because the wrap is sealed to that device's public key. Relocating the row to
/// another device gains an attacker nothing they could open.
/// </para>
/// <para>
/// Including the generation means a server cannot serve back a superseded bundle after a key
/// rotation. The client learns the current generation from <c>/me</c>, so a lie there produces
/// a tag failure rather than a silent downgrade.
/// </para>
/// </remarks>
/// <param name="userId">
/// The user's identifier, as assigned by the server. A client must therefore read
/// <c>/api/v1/me</c> — which provisions the account and returns its id even before enrollment —
/// before it can build a wrap.
/// </param>
/// <param name="keyGeneration">Generation of the identity key pair inside the bundle.</param>
public static AadDescriptor UserSecretBundle(Guid userId, uint keyGeneration = 1) =>
AadDescriptor.Create(
CryptoSpec.AadPurpose.UserSecretBundle,
CryptoSpec.AadResourceType.User,
userId,
keyGeneration: keyGeneration);
/// <summary>
/// Binds a vault key grant to its vault and generation.
/// </summary>
/// <remarks>
/// The generation is what makes revocation stick against a malicious server: after a rekey it
/// cannot re-serve a previous generation's grant to a removed member, because the AAD no longer
/// matches. That is a bound on future reads only — anything already downloaded is already gone,
/// which is why offboarding means rotating the SSH credentials themselves. See ADR 0001.
/// </remarks>
public static AadDescriptor VaultKeyGrant(Guid vaultId, uint keyGeneration) =>
AadDescriptor.Create(
CryptoSpec.AadPurpose.VaultKeyGrant,
CryptoSpec.AadResourceType.Vault,
vaultId,
keyGeneration: keyGeneration);
/// <summary>Binds an item's data key, wrapped under the vault key, to that item and version.</summary>
public static AadDescriptor ItemDataKey(
CryptoSpec.AadResourceType resourceType,
Guid itemId,
uint keyGeneration,
uint itemVersion) =>
AadDescriptor.Create(
CryptoSpec.AadPurpose.ItemDataKey,
resourceType,
itemId,
keyGeneration: keyGeneration,
itemVersion: itemVersion);
/// <summary>Binds an item's payload to the item, its data key, generation and version.</summary>
/// <param name="resourceType">What kind of item this is.</param>
/// <param name="itemId">The item.</param>
/// <param name="dataKeyId">
/// The data key the payload is under — the <c>content_key_id</c> column. Reserved so that
/// per-item grants can make item-level access cryptographic in M5 without a migration.
/// </param>
/// <param name="keyGeneration">Vault key generation in force.</param>
/// <param name="itemVersion">Item version, so an earlier version cannot be replayed.</param>
public static AadDescriptor ItemPayload(
CryptoSpec.AadResourceType resourceType,
Guid itemId,
Guid dataKeyId,
uint keyGeneration,
uint itemVersion) =>
AadDescriptor.Create(
CryptoSpec.AadPurpose.ItemPayload,
resourceType,
itemId,
dataKeyId,
keyGeneration,
itemVersion);
/// <summary>
/// Binds an item's encrypted metadata.
/// </summary>
/// <remarks>
/// A distinct purpose from <see cref="ItemPayload"/> on the same item, which is what stops a
/// server swapping one blob for the other — a substitution that would otherwise leave a client
/// decrypting a password where it expected a display name.
/// </remarks>
public static AadDescriptor ItemMetadata(
CryptoSpec.AadResourceType resourceType,
Guid itemId,
Guid dataKeyId,
uint keyGeneration,
uint itemVersion) =>
AadDescriptor.Create(
CryptoSpec.AadPurpose.ItemMetadata,
resourceType,
itemId,
dataKeyId,
keyGeneration,
itemVersion);
/// <summary>
/// Binds a record in the client's own on-disk cache.
/// </summary>
/// <remarks>
/// Separate from every server-side purpose so a cache record can never be accepted as vault
/// content, nor the reverse. The cache is local, so the adversary here is another process on
/// the same machine rather than the server.
/// </remarks>
public static AadDescriptor LocalCache(Guid userId) =>
AadDescriptor.Create(
CryptoSpec.AadPurpose.LocalCache,
CryptoSpec.AadResourceType.User,
userId);
}
+140
View File
@@ -0,0 +1,140 @@
using System.Security.Cryptography;
namespace DodoSSH.Crypto;
/// <summary>
/// Per-item data keys and the payloads they protect. See docs/crypto.md §3.
/// </summary>
/// <remarks>
/// <para>
/// Every item version gets its own 32-byte data key, wrapped under the vault key. Four reasons, all
/// of which matter: rotating a vault key re-wraps N × 32 bytes and never touches a content blob, so a
/// 10,000-item vault rotates in a few hundred kilobytes; a single item can be re-wrapped to another
/// key without disturbing the rest; each key encrypts about one message, which makes nonce-collision
/// analysis moot; and a new version gets a new key, so earlier ciphertext stays independently
/// decryptable for history and undo.
/// </para>
/// <para>
/// Be plain about what this does <em>not</em> yet buy. Data keys are wrapped under the vault key, not
/// to individual users, so anyone holding the vault key can decrypt any ciphertext they obtain. Until
/// per-item grants land in M5, an item-level ACL is access control, not cryptographic isolation. The
/// <c>content_key_id</c> column exists from the first migration so that change needs no migration.
/// </para>
/// </remarks>
public static class ItemKeys
{
/// <summary>Generates a fresh data key. The caller owns and must zero it.</summary>
public static byte[] CreateDataKey() =>
RandomNumberGenerator.GetBytes(CryptoSpec.SymmetricKeySize);
/// <summary>Wraps an item's data key under the vault key.</summary>
/// <param name="dataKey">The 32-byte data key.</param>
/// <param name="vaultKey">The vault key it is wrapped under.</param>
/// <param name="resourceType">What kind of item this is.</param>
/// <param name="itemId">The item.</param>
/// <param name="keyGeneration">Vault key generation in force.</param>
/// <param name="itemVersion">Item version.</param>
public static byte[] WrapDataKey(
ReadOnlySpan<byte> dataKey,
ReadOnlySpan<byte> vaultKey,
CryptoSpec.AadResourceType resourceType,
Guid itemId,
uint keyGeneration,
uint itemVersion)
{
RequireDataKey(dataKey);
VaultKeys.RequireVaultKey(vaultKey);
return DshCrypto.Seal(
vaultKey,
dataKey,
DshAad.ItemDataKey(resourceType, itemId, keyGeneration, itemVersion));
}
/// <summary>Opens a wrapped data key.</summary>
/// <returns>The data key, or <see langword="null"/> if it does not belong to this item, version
/// or generation.</returns>
public static byte[]? TryUnwrapDataKey(
ReadOnlySpan<byte> vaultKey,
ReadOnlySpan<byte> envelope,
CryptoSpec.AadResourceType resourceType,
Guid itemId,
uint keyGeneration,
uint itemVersion)
{
VaultKeys.RequireVaultKey(vaultKey);
var plaintext = DshCrypto.Open(
vaultKey,
envelope,
DshAad.ItemDataKey(resourceType, itemId, keyGeneration, itemVersion));
if (plaintext is null)
{
return null;
}
if (plaintext.Length != CryptoSpec.SymmetricKeySize)
{
CryptographicOperations.ZeroMemory(plaintext);
return null;
}
return plaintext;
}
/// <summary>Encrypts an item's payload under its data key.</summary>
/// <param name="dataKey">The item's data key.</param>
/// <param name="plaintext">The payload.</param>
/// <param name="resourceType">What kind of item this is.</param>
/// <param name="itemId">The item.</param>
/// <param name="dataKeyId">The data key's identifier, stored as <c>content_key_id</c>.</param>
/// <param name="keyGeneration">Vault key generation in force.</param>
/// <param name="itemVersion">Item version.</param>
public static byte[] SealPayload(
ReadOnlySpan<byte> dataKey,
ReadOnlySpan<byte> plaintext,
CryptoSpec.AadResourceType resourceType,
Guid itemId,
Guid dataKeyId,
uint keyGeneration,
uint itemVersion)
{
RequireDataKey(dataKey);
return DshCrypto.Seal(
dataKey,
plaintext,
DshAad.ItemPayload(resourceType, itemId, dataKeyId, keyGeneration, itemVersion));
}
/// <summary>Decrypts an item's payload.</summary>
/// <returns>The payload, or <see langword="null"/> on any mismatch. A null here after a
/// successful data key unwrap means the server moved, rolled back or substituted the blob.</returns>
public static byte[]? TryOpenPayload(
ReadOnlySpan<byte> dataKey,
ReadOnlySpan<byte> envelope,
CryptoSpec.AadResourceType resourceType,
Guid itemId,
Guid dataKeyId,
uint keyGeneration,
uint itemVersion)
{
RequireDataKey(dataKey);
return DshCrypto.Open(
dataKey,
envelope,
DshAad.ItemPayload(resourceType, itemId, dataKeyId, keyGeneration, itemVersion));
}
private static void RequireDataKey(ReadOnlySpan<byte> dataKey)
{
if (dataKey.Length != CryptoSpec.SymmetricKeySize)
{
throw new ArgumentException(
$"A data key is {CryptoSpec.SymmetricKeySize} bytes, got {dataKey.Length}.",
nameof(dataKey));
}
}
}
+159
View File
@@ -0,0 +1,159 @@
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);
}
}
/// <summary>
/// Derives the key that encrypts the client's on-disk cache.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public void DeriveLocalCacheKey(Span<byte> destination) =>
DeriveSubkey(CryptoSpec.DerivationLabels.LocalCache, destination);
/// <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);
}
}
+322
View File
@@ -0,0 +1,322 @@
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>
/// 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;
private 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;
}
}
+109
View File
@@ -0,0 +1,109 @@
using System.Security.Cryptography;
using NSec.Cryptography;
namespace DodoSSH.Crypto;
/// <summary>
/// Vault keys and the grants that wrap them to members. See docs/crypto.md §3 and §6.
/// </summary>
/// <remarks>
/// <para>
/// A vault key is 32 random bytes, one per vault per generation. It never leaves a client in
/// plaintext: each member holds it sealed to their own X25519 key, which is why the server can store
/// every grant and read none of them.
/// </para>
/// <para>
/// Sealing is anonymous-sender by construction, so a grant proves nothing about who created it. That
/// is why every grant additionally carries a detached Ed25519 signature from the granter — without
/// one, a server could fabricate a grant containing a key of its own choosing and the recipient
/// would have no way to tell. The signature makes it detectable and attributable; it cannot make it
/// impossible, because verification would require the server to hold the key.
/// </para>
/// </remarks>
public static class VaultKeys
{
/// <summary>
/// Generates a fresh vault key.
/// </summary>
/// <remarks>
/// Returned as an array the caller owns and must zero. There is no wrapper type here on purpose:
/// the key is needed as a raw span by every item operation, and a type that had to be unwrapped
/// at each call site would be unwrapped carelessly.
/// </remarks>
public static byte[] Create() =>
RandomNumberGenerator.GetBytes(CryptoSpec.SymmetricKeySize);
/// <summary>
/// Seals a vault key to a member's encryption key.
/// </summary>
/// <param name="vaultKey">The 32-byte vault key.</param>
/// <param name="recipientEncryptionPublicKey">
/// The recipient's X25519 public key. <b>Verify this key before calling.</b> Wrapping to an
/// unverified key is the one mistake that undoes end-to-end encryption entirely — check the
/// identity-provider binding, the pinned fingerprint and the key log head first. See ADR 0001.
/// </param>
/// <param name="vaultId">The vault, which the AAD binds this grant to.</param>
/// <param name="keyGeneration">
/// The generation in force. Part of the AAD, so a server cannot re-serve a superseded
/// generation's grant after a rekey.
/// </param>
public static byte[] WrapTo(
ReadOnlySpan<byte> vaultKey,
ReadOnlySpan<byte> recipientEncryptionPublicKey,
Guid vaultId,
uint keyGeneration)
{
RequireVaultKey(vaultKey);
return DshCrypto.SealTo(
recipientEncryptionPublicKey,
vaultKey,
DshAad.VaultKeyGrant(vaultId, keyGeneration));
}
/// <summary>
/// Opens a grant with the recipient's private key.
/// </summary>
/// <returns>
/// The vault key, or <see langword="null"/> if the grant is not for this key, not for this vault,
/// or not for this generation. A malicious granter can seal garbage; that surfaces here as a
/// null, and the grant's signature names who did it.
/// </returns>
public static byte[]? TryUnwrap(
Key recipientKey,
ReadOnlySpan<byte> envelope,
Guid vaultId,
uint keyGeneration)
{
var plaintext = DshCrypto.OpenSealed(
recipientKey,
envelope,
DshAad.VaultKeyGrant(vaultId, keyGeneration));
if (plaintext is null)
{
return null;
}
// A grant that opens but does not contain a key-sized value is not a vault key. Failing here
// rather than passing it on means the error names the grant instead of surfacing later as an
// unexplained item decryption failure.
if (plaintext.Length != CryptoSpec.SymmetricKeySize)
{
CryptographicOperations.ZeroMemory(plaintext);
return null;
}
return plaintext;
}
internal static void RequireVaultKey(ReadOnlySpan<byte> vaultKey)
{
if (vaultKey.Length != CryptoSpec.SymmetricKeySize)
{
throw new ArgumentException(
$"A vault key is {CryptoSpec.SymmetricKeySize} bytes, got {vaultKey.Length}.",
nameof(vaultKey));
}
}
}