Public Access
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.
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
using DodoSSH.Crypto;
|
||||
|
||||
namespace DodoSSH.Crypto.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Canonical AAD encoding, per docs/crypto.md §4.
|
||||
/// </summary>
|
||||
public sealed class AadDescriptorTests
|
||||
{
|
||||
private static readonly Guid ResourceId = Guid.Parse("0192f0c8-1a2b-7c3d-8e4f-5a6b7c8d9e0f");
|
||||
private static readonly Guid KeyId = Guid.Parse("0192f0c8-9999-7aaa-8bbb-cccccccccccc");
|
||||
|
||||
private static AadDescriptor Sample() => AadDescriptor.Create(
|
||||
CryptoSpec.AadPurpose.ItemPayload,
|
||||
CryptoSpec.AadResourceType.Credential,
|
||||
ResourceId,
|
||||
KeyId,
|
||||
keyGeneration: 7,
|
||||
itemVersion: 3);
|
||||
|
||||
[Fact]
|
||||
public void Encoding_IsExactlySixtyFourBytes()
|
||||
{
|
||||
Sample().ToCanonicalEncoding().Length.ShouldBe(CryptoSpec.AadEncodedLength);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Encoding_StartsWithMagicAndVersions()
|
||||
{
|
||||
var encoded = Sample().ToCanonicalEncoding();
|
||||
|
||||
encoded[..5].ShouldBe("dsh1\n"u8.ToArray());
|
||||
encoded[5].ShouldBe(CryptoSpec.CurrentAadVersion);
|
||||
encoded[6].ShouldBe((byte)CryptoSpec.AadPurpose.ItemPayload);
|
||||
encoded[7].ShouldBe((byte)CryptoSpec.AadResourceType.Credential);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Encoding_WritesUuidsInRfc4122ByteOrder()
|
||||
{
|
||||
// Guid.ToByteArray() emits the first three groups little-endian. Using it here would
|
||||
// make our ciphertext undecryptable by any other implementation of this spec, and the
|
||||
// bug would only surface at a cross-implementation boundary.
|
||||
var encoded = Sample().ToCanonicalEncoding();
|
||||
|
||||
encoded[8..24].ShouldBe(ResourceId.ToByteArray(bigEndian: true));
|
||||
encoded[24..40].ShouldBe(KeyId.ToByteArray(bigEndian: true));
|
||||
|
||||
// And prove the mixed-endian form differs, so this test cannot pass vacuously.
|
||||
ResourceId.ToByteArray(bigEndian: true).ShouldNotBe(ResourceId.ToByteArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Encoding_WritesIntegersBigEndian()
|
||||
{
|
||||
var encoded = Sample().ToCanonicalEncoding();
|
||||
|
||||
encoded[40..44].ShouldBe(new byte[] { 0, 0, 0, 7 }); // keyGeneration
|
||||
encoded[44..48].ShouldBe(new byte[] { 0, 0, 0, 3 }); // itemVersion
|
||||
encoded[48..50].ShouldBe(new byte[] { 0, 1 }); // schemaVersion
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Encoding_LeavesReservedBytesZero()
|
||||
{
|
||||
Sample().ToCanonicalEncoding()[50..64].ShouldAllBe(b => b == 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Encoding_IsDeterministic()
|
||||
{
|
||||
Sample().ToCanonicalEncoding().ShouldBe(Sample().ToCanonicalEncoding());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Aad_IsSha256OfTheCanonicalEncoding()
|
||||
{
|
||||
var descriptor = Sample();
|
||||
|
||||
descriptor.ComputeAad().ShouldBe(
|
||||
System.Security.Cryptography.SHA256.HashData(descriptor.ToCanonicalEncoding()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnspecifiedPurpose_IsRejected()
|
||||
{
|
||||
var descriptor = AadDescriptor.Create(
|
||||
CryptoSpec.AadPurpose.Unspecified,
|
||||
CryptoSpec.AadResourceType.Host,
|
||||
ResourceId);
|
||||
|
||||
Should.Throw<InvalidOperationException>(() => descriptor.ToCanonicalEncoding());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShortDestination_IsRejected()
|
||||
{
|
||||
var descriptor = Sample();
|
||||
|
||||
Should.Throw<ArgumentException>(() =>
|
||||
{
|
||||
var tooSmall = new byte[CryptoSpec.AadEncodedLength - 1];
|
||||
descriptor.WriteCanonicalEncoding(tooSmall);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Each field must change the AAD. If one did not, the corresponding substitution attack
|
||||
/// in docs/crypto.md §4.4 would succeed.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void EveryField_ChangesTheAad()
|
||||
{
|
||||
var baseline = Sample();
|
||||
var baselineAad = baseline.ComputeAad();
|
||||
|
||||
var variants = new (string Field, AadDescriptor Descriptor)[]
|
||||
{
|
||||
("purpose", baseline with { Purpose = CryptoSpec.AadPurpose.ItemMetadata }),
|
||||
("resourceType", baseline with { ResourceType = CryptoSpec.AadResourceType.Host }),
|
||||
("resourceId", baseline with { ResourceId = Guid.Parse("0192f0c8-dead-7bee-8fee-000000000001") }),
|
||||
("keyId", baseline with { KeyId = Guid.Empty }),
|
||||
("keyGeneration", baseline with { KeyGeneration = 8 }),
|
||||
("itemVersion", baseline with { ItemVersion = 4 }),
|
||||
("aadVersion", baseline with { AadVersion = 2 }),
|
||||
("schemaVersion", baseline with { SchemaVersion = 2 }),
|
||||
};
|
||||
|
||||
foreach (var (field, descriptor) in variants)
|
||||
{
|
||||
descriptor.ComputeAad().ShouldNotBe(baselineAad, $"changing {field} must change the AAD");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user