Files
DodoSSH/tests/DodoSSH.Crypto.Tests/DshCryptoTests.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

328 lines
11 KiB
C#

using System.Security.Cryptography;
using DodoSSH.Crypto;
using NSec.Cryptography;
namespace DodoSSH.Crypto.Tests;
/// <summary>
/// Round-trip behaviour and, more importantly, the negative cases from docs/crypto.md ยง4.4.
/// </summary>
/// <remarks>
/// The negative tests are the point of this file. They are the executable form of the claim
/// that a server holding every ciphertext and every plaintext column still cannot relocate,
/// roll back, replay or repurpose a blob.
/// </remarks>
public sealed class DshCryptoTests
{
private static readonly Guid CredentialId = Guid.Parse("0192f0c8-1a2b-7c3d-8e4f-5a6b7c8d9e0f");
private static readonly Guid OtherCredentialId = Guid.Parse("0192f0c8-1a2b-7c3d-8e4f-5a6b7c8d9e10");
private static byte[] NewKey() => RandomNumberGenerator.GetBytes(CryptoSpec.SymmetricKeySize);
private static AadDescriptor Payload(Guid id, uint generation = 1, uint version = 1) =>
AadDescriptor.Create(
CryptoSpec.AadPurpose.ItemPayload,
CryptoSpec.AadResourceType.Credential,
id,
keyGeneration: generation,
itemVersion: version);
[Fact]
public void Seal_RoundTrips()
{
var key = NewKey();
var plaintext = "correct horse battery staple"u8.ToArray();
var descriptor = Payload(CredentialId);
var envelope = DshCrypto.Seal(key, plaintext, descriptor);
DshCrypto.Open(key, envelope, descriptor).ShouldBe(plaintext);
}
[Fact]
public void Seal_ProducesAWellFormedEnvelope()
{
var envelope = DshCrypto.Seal(NewKey(), "x"u8, Payload(CredentialId));
DshEnvelope.TryRead(envelope, out var view).ShouldBeTrue();
view.Algorithm.ShouldBe(CryptoSpec.AlgorithmId.XChaCha20Poly1305);
view.Nonce.Length.ShouldBe(DshEnvelope.XChaChaNonceSize);
view.EphemeralPublicKey.IsEmpty.ShouldBeTrue();
envelope[..4].ShouldBe("DSH1"u8.ToArray());
}
[Fact]
public void Seal_UsesAFreshNoncePerCall()
{
var key = NewKey();
var descriptor = Payload(CredentialId);
var first = DshCrypto.Seal(key, "same"u8, descriptor);
var second = DshCrypto.Seal(key, "same"u8, descriptor);
first.ShouldNotBe(second);
}
[Fact]
public void Open_RejectsTheWrongKey()
{
var envelope = DshCrypto.Seal(NewKey(), "secret"u8, Payload(CredentialId));
DshCrypto.Open(NewKey(), envelope, Payload(CredentialId)).ShouldBeNull();
}
[Fact]
public void Open_RejectsATamperedCiphertext()
{
var key = NewKey();
var descriptor = Payload(CredentialId);
var envelope = DshCrypto.Seal(key, "secret"u8, descriptor);
envelope[^1] ^= 0x01;
DshCrypto.Open(key, envelope, descriptor).ShouldBeNull();
}
[Fact]
public void Open_RejectsANonZeroFlagByte()
{
var key = NewKey();
var descriptor = Payload(CredentialId);
var envelope = DshCrypto.Seal(key, "secret"u8, descriptor);
// Fail closed on an envelope we do not fully understand.
envelope[5] = 0x01;
DshCrypto.Open(key, envelope, descriptor).ShouldBeNull();
}
[Theory]
[InlineData("DSH0")]
[InlineData("XSH1")]
public void Open_RejectsABadMagic(string magic)
{
var key = NewKey();
var descriptor = Payload(CredentialId);
var envelope = DshCrypto.Seal(key, "secret"u8, descriptor);
System.Text.Encoding.ASCII.GetBytes(magic).CopyTo(envelope, 0);
DshCrypto.Open(key, envelope, descriptor).ShouldBeNull();
}
[Fact]
public void Open_RejectsATruncatedEnvelope()
{
var key = NewKey();
var descriptor = Payload(CredentialId);
var envelope = DshCrypto.Seal(key, "secret"u8, descriptor);
DshCrypto.Open(key, envelope.AsSpan(0, envelope.Length / 2), descriptor).ShouldBeNull();
DshCrypto.Open(key, [], descriptor).ShouldBeNull();
}
// ---- docs/crypto.md ยง4.4: what a malicious server cannot do ----
[Fact]
public void Server_CannotMoveCiphertextToAnotherResource()
{
var key = NewKey();
var envelope = DshCrypto.Seal(key, "host-a password"u8, Payload(CredentialId));
// Same vault key, same everything, different row.
DshCrypto.Open(key, envelope, Payload(OtherCredentialId)).ShouldBeNull();
}
[Fact]
public void Server_CannotRollBackAKeyGeneration()
{
var key = NewKey();
var envelope = DshCrypto.Seal(key, "secret"u8, Payload(CredentialId, generation: 5));
DshCrypto.Open(key, envelope, Payload(CredentialId, generation: 4)).ShouldBeNull();
}
[Fact]
public void Server_CannotRollBackAnItemVersion()
{
var key = NewKey();
var envelope = DshCrypto.Seal(key, "v2 secret"u8, Payload(CredentialId, version: 2));
DshCrypto.Open(key, envelope, Payload(CredentialId, version: 1)).ShouldBeNull();
}
[Fact]
public void Server_CannotRepurposeAPayloadAsMetadata()
{
var key = NewKey();
var envelope = DshCrypto.Seal(key, "secret"u8, Payload(CredentialId));
var asMetadata = AadDescriptor.Create(
CryptoSpec.AadPurpose.ItemMetadata,
CryptoSpec.AadResourceType.Credential,
CredentialId,
itemVersion: 1);
DshCrypto.Open(key, envelope, asMetadata).ShouldBeNull();
}
[Fact]
public void Server_CannotRepurposeAcrossResourceTypes()
{
var key = NewKey();
var envelope = DshCrypto.Seal(key, "secret"u8, Payload(CredentialId));
var asHost = AadDescriptor.Create(
CryptoSpec.AadPurpose.ItemPayload,
CryptoSpec.AadResourceType.Host,
CredentialId,
itemVersion: 1);
DshCrypto.Open(key, envelope, asHost).ShouldBeNull();
}
// ---- SealTo ----
private static Key NewAgreementKey() => Key.Create(
KeyAgreementAlgorithm.X25519,
new KeyCreationParameters { ExportPolicy = KeyExportPolicies.AllowPlaintextExport });
private static AadDescriptor Grant(Guid vaultId, uint generation = 1) => AadDescriptor.Create(
CryptoSpec.AadPurpose.VaultKeyGrant,
CryptoSpec.AadResourceType.Vault,
vaultId,
keyGeneration: generation);
[Fact]
public void SealTo_RoundTrips()
{
var vaultId = Guid.CreateVersion7();
using var recipient = NewAgreementKey();
var recipientPublic = recipient.PublicKey.Export(KeyBlobFormat.RawPublicKey);
var vaultKey = NewKey();
var descriptor = Grant(vaultId);
var envelope = DshCrypto.SealTo(recipientPublic, vaultKey, descriptor);
DshCrypto.OpenSealed(recipient, envelope, descriptor).ShouldBe(vaultKey);
}
[Fact]
public void SealTo_ProducesAWellFormedEnvelopeCarryingAnEphemeralKey()
{
using var recipient = NewAgreementKey();
var envelope = DshCrypto.SealTo(
recipient.PublicKey.Export(KeyBlobFormat.RawPublicKey),
NewKey(),
Grant(Guid.CreateVersion7()));
DshEnvelope.TryRead(envelope, out var view).ShouldBeTrue();
view.Algorithm.ShouldBe(CryptoSpec.AlgorithmId.SealToX25519);
view.EphemeralPublicKey.Length.ShouldBe(CryptoSpec.PublicKeySize);
view.Nonce.Length.ShouldBe(DshEnvelope.XChaChaNonceSize);
}
[Fact]
public void SealTo_IsNotOpenableByAnotherRecipient()
{
using var intended = NewAgreementKey();
using var attacker = NewAgreementKey();
var descriptor = Grant(Guid.CreateVersion7());
var envelope = DshCrypto.SealTo(
intended.PublicKey.Export(KeyBlobFormat.RawPublicKey),
NewKey(),
descriptor);
DshCrypto.OpenSealed(attacker, envelope, descriptor).ShouldBeNull();
}
[Fact]
public void SealTo_IsBoundToItsVaultAndGeneration()
{
var vaultId = Guid.CreateVersion7();
using var recipient = NewAgreementKey();
var recipientPublic = recipient.PublicKey.Export(KeyBlobFormat.RawPublicKey);
var envelope = DshCrypto.SealTo(recipientPublic, NewKey(), Grant(vaultId, generation: 3));
// A revoked grant from an earlier generation must not be replayable.
DshCrypto.OpenSealed(recipient, envelope, Grant(vaultId, generation: 2)).ShouldBeNull();
DshCrypto.OpenSealed(recipient, envelope, Grant(Guid.CreateVersion7(), generation: 3)).ShouldBeNull();
}
[Fact]
public void SealTo_UsesAFreshEphemeralKeyPerCall()
{
using var recipient = NewAgreementKey();
var recipientPublic = recipient.PublicKey.Export(KeyBlobFormat.RawPublicKey);
var descriptor = Grant(Guid.CreateVersion7());
var vaultKey = NewKey();
var first = DshCrypto.SealTo(recipientPublic, vaultKey, descriptor);
var second = DshCrypto.SealTo(recipientPublic, vaultKey, descriptor);
DshEnvelope.TryRead(first, out var a).ShouldBeTrue();
DshEnvelope.TryRead(second, out var b).ShouldBeTrue();
a.EphemeralPublicKey.SequenceEqual(b.EphemeralPublicKey).ShouldBeFalse();
}
[Fact]
public void OpenSealed_RejectsASymmetricEnvelope()
{
// Cross-construction confusion: a symmetric envelope must not be accepted here.
using var recipient = NewAgreementKey();
var descriptor = Grant(Guid.CreateVersion7());
var symmetric = DshCrypto.Seal(NewKey(), "secret"u8, descriptor);
DshCrypto.OpenSealed(recipient, symmetric, descriptor).ShouldBeNull();
}
[Fact]
public void Open_RejectsASealedEnvelope()
{
using var recipient = NewAgreementKey();
var descriptor = Grant(Guid.CreateVersion7());
var sealedEnvelope = DshCrypto.SealTo(
recipient.PublicKey.Export(KeyBlobFormat.RawPublicKey),
NewKey(),
descriptor);
DshCrypto.Open(NewKey(), sealedEnvelope, descriptor).ShouldBeNull();
}
// ---- Fingerprints ----
[Fact]
public void Fingerprint_IsStableAndOrderSensitive()
{
var x25519 = RandomNumberGenerator.GetBytes(CryptoSpec.PublicKeySize);
var ed25519 = RandomNumberGenerator.GetBytes(CryptoSpec.PublicKeySize);
var fingerprint = DshCrypto.ComputeFingerprint(x25519, ed25519);
fingerprint.Length.ShouldBe(CryptoSpec.DigestSize);
fingerprint.ShouldBe(DshCrypto.ComputeFingerprint(x25519, ed25519));
// Swapping the keys must change the fingerprint, or the two roles would be conflated.
fingerprint.ShouldNotBe(DshCrypto.ComputeFingerprint(ed25519, x25519));
}
[Fact]
public void Fingerprint_RejectsWrongSizedKeys()
{
var valid = RandomNumberGenerator.GetBytes(CryptoSpec.PublicKeySize);
Should.Throw<ArgumentException>(() => DshCrypto.ComputeFingerprint(new byte[31], valid));
Should.Throw<ArgumentException>(() => DshCrypto.ComputeFingerprint(valid, new byte[33]));
}
[Fact]
public void Seal_RejectsWrongSizedKeys()
{
Should.Throw<ArgumentException>(() => DshCrypto.Seal(new byte[16], "x"u8, Payload(CredentialId)));
}
}