using System.Security.Cryptography;
using NSec.Cryptography;
namespace DodoSSH.Crypto;
///
/// The DSH1 operations: symmetric content encryption and anonymous-sender key wrapping.
///
///
///
/// docs/crypto.md is normative. Every operation takes an rather
/// than a raw AAD, so a caller cannot omit the binding that stops a server relocating
/// ciphertext between rows or key generations.
///
///
/// Decryption returns 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.
///
///
public static class DshCrypto
{
private static AeadAlgorithm Aead => AeadAlgorithm.XChaCha20Poly1305;
private static KeyAgreementAlgorithm Agreement => KeyAgreementAlgorithm.X25519;
///
/// Encrypts under a known symmetric key, producing a complete DSH1 envelope
/// ().
///
/// 32-byte content key.
/// Data to protect.
/// Identifies the row this ciphertext belongs to.
public static byte[] Seal(ReadOnlySpan key, ReadOnlySpan plaintext, in AadDescriptor descriptor)
{
RequireSymmetricKey(key);
Span 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 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);
}
///
/// Opens an envelope produced by .
///
/// The plaintext, or if the envelope is malformed, uses
/// another construction, or fails authentication under this descriptor.
public static byte[]? Open(ReadOnlySpan key, ReadOnlySpan envelope, in AadDescriptor descriptor)
{
RequireSymmetricKey(key);
if (!DshEnvelope.TryRead(envelope, out var view))
{
return null;
}
if (view.Algorithm != CryptoSpec.AlgorithmId.XChaCha20Poly1305)
{
return null;
}
Span 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);
}
///
/// Seals to a recipient's X25519 public key, producing a
/// envelope. See docs/crypto.md §6.
///
///
/// 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.
///
public static byte[] SealTo(
ReadOnlySpan recipientPublicKey,
ReadOnlySpan plaintext,
in AadDescriptor descriptor)
{
RequirePublicKey(recipientPublicKey);
Span 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 derived = stackalloc byte[CryptoSpec.SymmetricKeySize];
DeriveSealKey(shared, ephemeralPublic, recipientPublicKey, aad, derived);
Span 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);
}
///
/// Opens an envelope produced by using the recipient's private key.
///
/// The plaintext, or on any failure.
public static byte[]? OpenSealed(Key recipientKey, ReadOnlySpan 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 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 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;
}
///
/// Computes an identity fingerprint over a user's two public keys. See docs/crypto.md §8.
///
public static byte[] ComputeFingerprint(
ReadOnlySpan x25519PublicKey,
ReadOnlySpan ed25519PublicKey)
{
RequirePublicKey(x25519PublicKey);
RequirePublicKey(ed25519PublicKey);
var label = CryptoSpec.DerivationLabels.Fingerprint;
Span 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;
}
///
/// 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.
///
private static void DeriveSealKey(
SharedSecret shared,
ReadOnlySpan ephemeralPublicKey,
ReadOnlySpan recipientPublicKey,
ReadOnlySpan aad,
Span destination)
{
Span salt = stackalloc byte[CryptoSpec.PublicKeySize * 2];
ephemeralPublicKey.CopyTo(salt);
recipientPublicKey.CopyTo(salt[CryptoSpec.PublicKeySize..]);
var prefix = CryptoSpec.DerivationLabels.SealTo;
Span 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 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 publicKey)
{
if (publicKey.Length != CryptoSpec.PublicKeySize)
{
throw new ArgumentException(
$"Public key must be {CryptoSpec.PublicKeySize} bytes, got {publicKey.Length}.",
nameof(publicKey));
}
}
}