Freeze DSH1 crypto specification and implement the core (M1)

docs/crypto.md is now the normative, frozen specification. This had to land before
anything else in M1: the server holds ciphertext and no keys, so it can never
re-encrypt, and a format change after users hold data is a coordinated client rewrite
with no rollback.

Specification:
- DSH1 envelope layout, canonical 64-byte AAD encoding, SealTo construction, key
  hierarchy, Argon2id profiles, fingerprints, and the change rules for each version field.
- AAD encoding is fixed-width binary rather than delimited string concatenation, so no
  field value can forge a field boundary. This supersedes the illustrative form sketched
  in ADR 0001, which now points here.
- UUIDs are RFC 4122 big-endian. Guid.ToByteArray() emits the first three groups
  little-endian and would have made our ciphertext unreadable by any other implementation
  of this spec, failing only at a cross-implementation boundary.

Verified rather than assumed:
- PrimitiveAvailabilityTests proves X25519, Ed25519, XChaCha20-Poly1305, Argon2id and
  HKDF-SHA512 all function on net10.0. NSec 26.4.0 targets net9.0 and is consumed by
  forward compatibility; this closes one of the two package questions the plan flagged.
- Argon2Profile exists because NSec's MemorySize is in KIBIBYTES, not bytes. Passing bytes
  gives either a 256 GiB allocation or a 256 KiB KDF that cracks instantly. The type takes
  mebibytes so the unit cannot be got wrong at a call site. Found by benchmarking: the
  first measurements were ~1000x too slow, which turned out to be 19 GiB of work.
- Parameters measured, not guessed: 256 MiB/t=4 is 323 ms on this machine; the table of
  candidates is in the spec.

Implementation and tests (83 total, up from 17):
- AadDescriptor, DshEnvelope, DshCrypto (Seal/Open/SealTo/OpenSealed/fingerprints).
- Decryption returns null rather than throwing: ciphertext comes from a server that is
  explicitly not trusted, so a failed tag is an expected outcome.
- Envelope readers reject unknown algorithms and any non-zero flag bit, so an envelope
  that is not fully understood fails closed.
- Executable form of the spec's substitution claims: a server cannot move ciphertext
  between resources, roll back a key generation or item version, repurpose a payload as
  metadata, or confuse the two constructions.
- Golden vectors in tests/fixtures/crypto/vectors.json guard the format. Mutation-checked:
  a one-byte schema version change trips four tests including the guard.

Two build-infrastructure bugs found and fixed along the way:
- .editorconfig forced camelCase on const and static readonly fields. PascalCase is the
  .NET convention for both; the config was wrong, not the code.
- The golden fixture was resolved with [CallerFilePath], which ContinuousIntegrationBuild
  rewrites to /_/... under deterministic source paths. It passed locally and would have
  failed only in CI. Now copied to the output directory and read from there.
This commit is contained in:
jaap-jan committed 2026-07-28 13:18:29 +02:00
1 parent ce43f397a6
commit b15af836a3
21 files changed
+2589 -30

No files matched your search

+143 -19
View File
@@ -1,47 +1,171 @@
namespace DodoSSH.Crypto;
/// <summary>
/// Constants of the DodoSSH cryptographic specification.
/// Constants of the DodoSSH cryptographic specification, version 1.
/// </summary>
/// <remarks>
/// docs/crypto.md is the normative specification; this type must agree with it exactly.
/// The implementation of the envelope, AAD derivation and key wrapping lands in M1, once
/// the specification and its test vectors are frozen. Nothing else may be built on top of
/// an unfrozen AAD: only clients can re-encrypt, so a change after users hold data cannot
/// be migrated server-side.
/// docs/crypto.md is normative; this type must agree with it exactly. The values here are
/// written into stored data, so changing one reinterprets or orphans existing ciphertext.
/// Only clients can re-encrypt, so the server cannot migrate a change here.
/// </remarks>
public static class CryptoSpec
{
/// <summary>Magic prefix identifying a DSH1 envelope.</summary>
public const string EnvelopeMagic = "DSH1";
public static ReadOnlySpan<byte> EnvelopeMagic => "DSH1"u8;
/// <summary>Magic prefix of the canonical AAD encoding.</summary>
public static ReadOnlySpan<byte> AadMagic => "dsh1\n"u8;
/// <summary>Length of the canonical AAD encoding, before hashing.</summary>
public const int AadEncodedLength = 64;
/// <summary>Version of the AAD derivation rule that payloads are bound to.</summary>
/// <remarks>
/// Stored per row as <c>payload_aad_version</c> so a future change can be applied
/// lazily, re-encrypting on next write rather than in a migration.
/// Stored per row as <c>payload_aad_version</c> so a future change can be applied lazily,
/// re-encrypting on next write rather than in a migration.
/// </remarks>
public const short CurrentAadVersion = 1;
public const byte CurrentAadVersion = 1;
/// <summary>Domain-separation prefix for every AAD computation.</summary>
public const string AadDomainPrefix = "dsh1\n";
/// <summary>Version of the relational schema that AAD is bound to.</summary>
public const ushort CurrentSchemaVersion = 1;
/// <summary>Identifiers for the algorithms an envelope may declare.</summary>
/// <summary>Size of a symmetric content or wrapping key.</summary>
public const int SymmetricKeySize = 32;
/// <summary>Size of an X25519 or Ed25519 public key.</summary>
public const int PublicKeySize = 32;
/// <summary>Size of an Ed25519 signature.</summary>
public const int SignatureSize = 64;
/// <summary>Size of a SHA-256 output, used for AAD, fingerprints and digests.</summary>
public const int DigestSize = 32;
/// <summary>Size of an AEAD authentication tag.</summary>
public const int TagSize = 16;
/// <summary>Recommended salt length for password-based derivation.</summary>
public const int SaltSize = 16;
/// <summary>Identifies the construction used by a DSH1 envelope.</summary>
public enum AlgorithmId : byte
{
/// <summary>Reserved; never written.</summary>
/// <summary>Reserved; never written, and rejected on read.</summary>
Unspecified = 0,
/// <summary>Symmetric content encryption under a known key.</summary>
XChaCha20Poly1305 = 1,
/// <summary>Symmetric fallback where XChaCha20 is unavailable.</summary>
/// <summary>
/// Symmetric fallback for environments without XChaCha20-Poly1305. Accepted on read,
/// not currently emitted.
/// </summary>
Aes256Gcm = 2,
/// <summary>Anonymous-sender seal to an X25519 public key.</summary>
/// <summary>Anonymous-sender seal to an X25519 public key. See docs/crypto.md §6.</summary>
SealToX25519 = 3,
// 4 is reserved for a hybrid X25519 + ML-KEM-768 seal. Store-now-decrypt-later is
// a genuine threat for long-lived SSH keys, so the identifier is claimed now even
// though the construction ships later.
// 4 is reserved for a hybrid X25519 + ML-KEM-768 seal. Claimed now so it cannot be
// reused: store-now-decrypt-later is a real threat for long-lived SSH keys.
}
/// <summary>
/// What a given ciphertext is, so that one kind of blob can never be substituted for
/// another. Part of the AAD; see docs/crypto.md §4.2.
/// </summary>
public enum AadPurpose : byte
{
/// <summary>Not a legal value.</summary>
Unspecified = 0,
/// <summary>A wrap of the user's secret bundle.</summary>
UserSecretBundle = 1,
/// <summary>A vault key sealed to a member's public key.</summary>
VaultKeyGrant = 2,
/// <summary>An item data key wrapped under a vault key.</summary>
ItemDataKey = 3,
/// <summary>Item plaintext under its data key.</summary>
ItemPayload = 4,
/// <summary>Encrypted item metadata under its data key.</summary>
ItemMetadata = 5,
/// <summary>A record in a client's on-disk cache.</summary>
LocalCache = 6,
}
/// <summary>
/// The kind of entity a ciphertext belongs to. Part of the AAD; see docs/crypto.md §4.3.
/// Append only.
/// </summary>
public enum AadResourceType : byte
{
/// <summary>No resource. Legal only where the purpose implies none.</summary>
None = 0,
/// <summary>A user account.</summary>
User = 1,
/// <summary>An enrolled device.</summary>
Device = 2,
/// <summary>A vault.</summary>
Vault = 3,
/// <summary>An SSH host.</summary>
Host = 4,
/// <summary>A credential.</summary>
Credential = 5,
/// <summary>An SSH key pair.</summary>
SshKey = 6,
/// <summary>A host group.</summary>
HostGroup = 7,
/// <summary>A tag.</summary>
Tag = 8,
/// <summary>A snippet.</summary>
Snippet = 9,
/// <summary>A port forward.</summary>
PortForward = 10,
/// <summary>A known SSH host key.</summary>
KnownHostKey = 11,
}
/// <summary>HKDF info labels. Domain-separated so one subkey cannot stand in for another.</summary>
public static class DerivationLabels
{
/// <summary>Derives the key-encryption key that wraps the secret bundle.</summary>
public static ReadOnlySpan<byte> PassphraseKek => "dsh1/kek/passphrase/v1"u8;
/// <summary>Derives the key that encrypts the client's on-disk cache.</summary>
public static ReadOnlySpan<byte> LocalCache => "dsh1/localcache/v1"u8;
/// <summary>Prefix of the SealTo key-derivation info, concatenated with the AAD.</summary>
public static ReadOnlySpan<byte> SealTo => "dsh1/sealto/v1|"u8;
/// <summary>Prefix of the identity key fingerprint input.</summary>
public static ReadOnlySpan<byte> Fingerprint => "dsh1/fp/v1"u8;
}
/// <summary>Ed25519 signing contexts. Prevents a signature being replayed in another role.</summary>
public static class SigningContexts
{
/// <summary>Signs an enrollment key statement.</summary>
public static ReadOnlySpan<byte> KeyStatement => "dsh1/sig/keystatement/v1"u8;
/// <summary>Signs a vault key grant tuple.</summary>
public static ReadOnlySpan<byte> Grant => "dsh1/sig/grant/v1"u8;
/// <summary>Signs an admin attestation of another user's key statement.</summary>
public static ReadOnlySpan<byte> Attestation => "dsh1/sig/attestation/v1"u8;
}
}