Files
DodoSSH/src/DodoSSH.Crypto/DshCrypto.cs
T
jaap-jan b15af836a3 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.
2026-07-28 13:18:29 +02:00

242 lines
9.0 KiB
C#

using System.Security.Cryptography;
using NSec.Cryptography;
namespace DodoSSH.Crypto;
/// <summary>
/// The DSH1 operations: symmetric content encryption and anonymous-sender key wrapping.
/// </summary>
/// <remarks>
/// <para>
/// docs/crypto.md is normative. Every operation takes an <see cref="AadDescriptor"/> rather
/// than a raw AAD, so a caller cannot omit the binding that stops a server relocating
/// ciphertext between rows or key generations.
/// </para>
/// <para>
/// Decryption returns <see langword="null"/> on authentication failure rather than throwing.
/// Ciphertext arrives from a server that is explicitly not trusted, so a failed tag is an
/// expected outcome to be handled, not an exceptional one.
/// </para>
/// </remarks>
public static class DshCrypto
{
private static AeadAlgorithm Aead => AeadAlgorithm.XChaCha20Poly1305;
private static KeyAgreementAlgorithm Agreement => KeyAgreementAlgorithm.X25519;
/// <summary>
/// Encrypts under a known symmetric key, producing a complete DSH1 envelope
/// (<see cref="CryptoSpec.AlgorithmId.XChaCha20Poly1305"/>).
/// </summary>
/// <param name="key">32-byte content key.</param>
/// <param name="plaintext">Data to protect.</param>
/// <param name="descriptor">Identifies the row this ciphertext belongs to.</param>
public static byte[] Seal(ReadOnlySpan<byte> key, ReadOnlySpan<byte> plaintext, in AadDescriptor descriptor)
{
RequireSymmetricKey(key);
Span<byte> aad = stackalloc byte[CryptoSpec.DigestSize];
descriptor.ComputeAad(aad);
// A 192-bit nonce is why a random nonce per message is safe with no counter to track.
Span<byte> nonce = stackalloc byte[DshEnvelope.XChaChaNonceSize];
RandomNumberGenerator.Fill(nonce);
using var aeadKey = Key.Import(Aead, key, KeyBlobFormat.RawSymmetricKey);
var ciphertext = Aead.Encrypt(aeadKey, nonce, aad, plaintext);
return DshEnvelope.Write(CryptoSpec.AlgorithmId.XChaCha20Poly1305, nonce, ciphertext);
}
/// <summary>
/// Opens an envelope produced by <see cref="Seal"/>.
/// </summary>
/// <returns>The plaintext, or <see langword="null"/> if the envelope is malformed, uses
/// another construction, or fails authentication under this descriptor.</returns>
public static byte[]? Open(ReadOnlySpan<byte> key, ReadOnlySpan<byte> envelope, in AadDescriptor descriptor)
{
RequireSymmetricKey(key);
if (!DshEnvelope.TryRead(envelope, out var view))
{
return null;
}
if (view.Algorithm != CryptoSpec.AlgorithmId.XChaCha20Poly1305)
{
return null;
}
Span<byte> aad = stackalloc byte[CryptoSpec.DigestSize];
descriptor.ComputeAad(aad);
using var aeadKey = Key.Import(Aead, key, KeyBlobFormat.RawSymmetricKey);
return Aead.Decrypt(aeadKey, view.Nonce, aad, view.Ciphertext);
}
/// <summary>
/// Seals to a recipient's X25519 public key, producing a
/// <see cref="CryptoSpec.AlgorithmId.SealToX25519"/> envelope. See docs/crypto.md §6.
/// </summary>
/// <remarks>
/// Anonymous-sender by construction: this proves nothing about who created the envelope.
/// Callers that need attribution — grants, in particular — must additionally attach a
/// detached Ed25519 signature, or a server could fabricate a grant undetectably.
/// </remarks>
public static byte[] SealTo(
ReadOnlySpan<byte> recipientPublicKey,
ReadOnlySpan<byte> plaintext,
in AadDescriptor descriptor)
{
RequirePublicKey(recipientPublicKey);
Span<byte> aad = stackalloc byte[CryptoSpec.DigestSize];
descriptor.ComputeAad(aad);
var recipient = PublicKey.Import(Agreement, recipientPublicKey, KeyBlobFormat.RawPublicKey);
// The ephemeral key must be exportable: its public half goes into the envelope.
using var ephemeral = Key.Create(
Agreement,
new KeyCreationParameters { ExportPolicy = KeyExportPolicies.AllowPlaintextExport });
var ephemeralPublic = ephemeral.PublicKey.Export(KeyBlobFormat.RawPublicKey);
using var shared = Agreement.Agree(ephemeral, recipient)
?? throw new CryptographicException("X25519 agreement failed; the recipient key is invalid.");
Span<byte> derived = stackalloc byte[CryptoSpec.SymmetricKeySize];
DeriveSealKey(shared, ephemeralPublic, recipientPublicKey, aad, derived);
Span<byte> nonce = stackalloc byte[DshEnvelope.XChaChaNonceSize];
RandomNumberGenerator.Fill(nonce);
using var contentKey = Key.Import(Aead, derived, KeyBlobFormat.RawSymmetricKey);
var ciphertext = Aead.Encrypt(contentKey, nonce, aad, plaintext);
CryptographicOperations.ZeroMemory(derived);
return DshEnvelope.Write(
CryptoSpec.AlgorithmId.SealToX25519,
nonce,
ciphertext,
ephemeralPublic);
}
/// <summary>
/// Opens an envelope produced by <see cref="SealTo"/> using the recipient's private key.
/// </summary>
/// <returns>The plaintext, or <see langword="null"/> on any failure.</returns>
public static byte[]? OpenSealed(Key recipientKey, ReadOnlySpan<byte> envelope, in AadDescriptor descriptor)
{
ArgumentNullException.ThrowIfNull(recipientKey);
if (!DshEnvelope.TryRead(envelope, out var view))
{
return null;
}
if (view.Algorithm != CryptoSpec.AlgorithmId.SealToX25519)
{
return null;
}
Span<byte> aad = stackalloc byte[CryptoSpec.DigestSize];
descriptor.ComputeAad(aad);
PublicKey ephemeralPublic;
try
{
ephemeralPublic = PublicKey.Import(Agreement, view.EphemeralPublicKey, KeyBlobFormat.RawPublicKey);
}
catch (FormatException)
{
return null;
}
using var shared = Agreement.Agree(recipientKey, ephemeralPublic);
if (shared is null)
{
// All-zero agreement: a low-order or otherwise invalid ephemeral key.
return null;
}
var recipientPublic = recipientKey.PublicKey.Export(KeyBlobFormat.RawPublicKey);
Span<byte> derived = stackalloc byte[CryptoSpec.SymmetricKeySize];
DeriveSealKey(shared, view.EphemeralPublicKey, recipientPublic, aad, derived);
using var contentKey = Key.Import(Aead, derived, KeyBlobFormat.RawSymmetricKey);
var plaintext = Aead.Decrypt(contentKey, view.Nonce, aad, view.Ciphertext);
CryptographicOperations.ZeroMemory(derived);
return plaintext;
}
/// <summary>
/// Computes an identity fingerprint over a user's two public keys. See docs/crypto.md §8.
/// </summary>
public static byte[] ComputeFingerprint(
ReadOnlySpan<byte> x25519PublicKey,
ReadOnlySpan<byte> ed25519PublicKey)
{
RequirePublicKey(x25519PublicKey);
RequirePublicKey(ed25519PublicKey);
var label = CryptoSpec.DerivationLabels.Fingerprint;
Span<byte> input = stackalloc byte[label.Length + (CryptoSpec.PublicKeySize * 2)];
label.CopyTo(input);
x25519PublicKey.CopyTo(input[label.Length..]);
ed25519PublicKey.CopyTo(input[(label.Length + CryptoSpec.PublicKeySize)..]);
var fingerprint = new byte[CryptoSpec.DigestSize];
SHA256.HashData(input, fingerprint);
return fingerprint;
}
/// <summary>
/// HKDF-SHA256 over the X25519 shared secret, salted with both public keys and bound to
/// the AAD. Specified rather than reusing libsodium's sealed box, whose KDF covers only the
/// key pair and would not carry the AAD binding.
/// </summary>
private static void DeriveSealKey(
SharedSecret shared,
ReadOnlySpan<byte> ephemeralPublicKey,
ReadOnlySpan<byte> recipientPublicKey,
ReadOnlySpan<byte> aad,
Span<byte> destination)
{
Span<byte> salt = stackalloc byte[CryptoSpec.PublicKeySize * 2];
ephemeralPublicKey.CopyTo(salt);
recipientPublicKey.CopyTo(salt[CryptoSpec.PublicKeySize..]);
var prefix = CryptoSpec.DerivationLabels.SealTo;
Span<byte> info = stackalloc byte[prefix.Length + aad.Length];
prefix.CopyTo(info);
aad.CopyTo(info[prefix.Length..]);
KeyDerivationAlgorithm.HkdfSha256.DeriveBytes(shared, salt, info, destination);
}
private static void RequireSymmetricKey(ReadOnlySpan<byte> key)
{
if (key.Length != CryptoSpec.SymmetricKeySize)
{
throw new ArgumentException(
$"Key must be {CryptoSpec.SymmetricKeySize} bytes, got {key.Length}.",
nameof(key));
}
}
private static void RequirePublicKey(ReadOnlySpan<byte> publicKey)
{
if (publicKey.Length != CryptoSpec.PublicKeySize)
{
throw new ArgumentException(
$"Public key must be {CryptoSpec.PublicKeySize} bytes, got {publicKey.Length}.",
nameof(publicKey));
}
}
}