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