Public Access
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.
328 lines
11 KiB
C#
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)));
|
|
}
|
|
}
|