using System.Security.Cryptography;
using DodoSSH.Crypto;
using NSec.Cryptography;
namespace DodoSSH.Crypto.Tests;
///
/// Round-trip behaviour and, more importantly, the negative cases from docs/crypto.md §4.4.
///
///
/// 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.
///
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(() => DshCrypto.ComputeFingerprint(new byte[31], valid));
Should.Throw(() => DshCrypto.ComputeFingerprint(valid, new byte[33]));
}
[Fact]
public void Seal_RejectsWrongSizedKeys()
{
Should.Throw(() => DshCrypto.Seal(new byte[16], "x"u8, Payload(CredentialId)));
}
}