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.
116 lines
4.4 KiB
C#
116 lines
4.4 KiB
C#
using System.Security.Cryptography;
|
|
using NSec.Cryptography;
|
|
|
|
namespace DodoSSH.Crypto.Tests;
|
|
|
|
/// <summary>
|
|
/// Proves the primitives docs/crypto.md depends on are actually available and functional on
|
|
/// this runtime and platform.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Not ceremonial. Two concrete risks motivated these:
|
|
/// the BCL has no X25519 or Ed25519 at all, and <c>ChaCha20Poly1305.IsSupported</c> is false
|
|
/// on macOS, which is what disqualified the in-box AEAD for a cross-platform client. If any
|
|
/// of these fail on a target platform, the specification is wrong rather than the code.
|
|
/// </remarks>
|
|
public sealed class PrimitiveAvailabilityTests
|
|
{
|
|
[Fact]
|
|
public void X25519_AgreesOnASharedSecret()
|
|
{
|
|
var algorithm = KeyAgreementAlgorithm.X25519;
|
|
var creation = new KeyCreationParameters { ExportPolicy = KeyExportPolicies.AllowPlaintextExport };
|
|
|
|
using var alice = Key.Create(algorithm, creation);
|
|
using var bob = Key.Create(algorithm, creation);
|
|
|
|
using var aliceView = algorithm.Agree(alice, bob.PublicKey)!;
|
|
using var bobView = algorithm.Agree(bob, alice.PublicKey)!;
|
|
|
|
var derive = KeyDerivationAlgorithm.HkdfSha256;
|
|
var fromAlice = derive.DeriveBytes(aliceView, ReadOnlySpan<byte>.Empty, "test"u8, 32);
|
|
var fromBob = derive.DeriveBytes(bobView, ReadOnlySpan<byte>.Empty, "test"u8, 32);
|
|
|
|
fromAlice.ShouldBe(fromBob);
|
|
}
|
|
|
|
[Fact]
|
|
public void Ed25519_SignsAndVerifies()
|
|
{
|
|
var algorithm = SignatureAlgorithm.Ed25519;
|
|
using var signer = Key.Create(algorithm);
|
|
|
|
var message = "grant tuple"u8;
|
|
var signature = algorithm.Sign(signer, message);
|
|
|
|
signature.Length.ShouldBe(64);
|
|
algorithm.Verify(signer.PublicKey, message, signature).ShouldBeTrue();
|
|
algorithm.Verify(signer.PublicKey, "tampered"u8, signature).ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void XChaCha20Poly1305_RoundTripsAndDetectsAadTampering()
|
|
{
|
|
var algorithm = AeadAlgorithm.XChaCha20Poly1305;
|
|
using var key = Key.Create(algorithm);
|
|
|
|
var nonce = RandomNumberGenerator.GetBytes(algorithm.NonceSize);
|
|
var plaintext = "id_ed25519 private key"u8;
|
|
|
|
var ciphertext = algorithm.Encrypt(key, nonce, "aad-a"u8, plaintext);
|
|
|
|
algorithm.Decrypt(key, nonce, "aad-a"u8, ciphertext).ShouldBe(plaintext.ToArray());
|
|
|
|
// The whole point of binding AAD to row identity: a different AAD must not decrypt.
|
|
algorithm.Decrypt(key, nonce, "aad-b"u8, ciphertext).ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void XChaCha20Poly1305_NonceIs24BytesSoRandomNoncesAreSafe()
|
|
{
|
|
// 192-bit nonces are why we can generate one at random per message without tracking
|
|
// a counter. AES-GCM's 96-bit nonce would not permit that.
|
|
AeadAlgorithm.XChaCha20Poly1305.NonceSize.ShouldBe(24);
|
|
AeadAlgorithm.XChaCha20Poly1305.KeySize.ShouldBe(32);
|
|
AeadAlgorithm.XChaCha20Poly1305.TagSize.ShouldBe(16);
|
|
}
|
|
|
|
[Fact]
|
|
public void Argon2id_IsAvailableAndParallelismIsPinnedToOne()
|
|
{
|
|
// libsodium's Argon2id implementation only supports p=1. docs/crypto.md compensates
|
|
// with memory cost instead; this test pins the constraint so it is not forgotten.
|
|
var algorithm = PasswordBasedKeyDerivationAlgorithm.Argon2id(
|
|
new Argon2Parameters { DegreeOfParallelism = 1, MemorySize = 1 << 20, NumberOfPasses = 1 });
|
|
|
|
var salt = new byte[16];
|
|
var derived = algorithm.DeriveBytes("correct horse battery staple", salt, 32);
|
|
|
|
derived.Length.ShouldBe(32);
|
|
derived.ShouldNotBe(new byte[32]);
|
|
}
|
|
|
|
[Fact]
|
|
public void Argon2id_IsDeterministicForTheSamePassphraseAndSalt()
|
|
{
|
|
var algorithm = PasswordBasedKeyDerivationAlgorithm.Argon2id(
|
|
new Argon2Parameters { DegreeOfParallelism = 1, MemorySize = 1 << 20, NumberOfPasses = 1 });
|
|
|
|
var salt = RandomNumberGenerator.GetBytes(16);
|
|
|
|
algorithm.DeriveBytes("passphrase", salt, 32)
|
|
.ShouldBe(algorithm.DeriveBytes("passphrase", salt, 32));
|
|
}
|
|
|
|
[Fact]
|
|
public void HkdfSha512_IsAvailableInTheBcl()
|
|
{
|
|
// Subkey derivation from the master key uses the BCL, not NSec: HKDF is fully
|
|
// supported on every platform.
|
|
var info = "dsh1/kek/passphrase/v1"u8.ToArray();
|
|
var okm = HKDF.Expand(HashAlgorithmName.SHA512, new byte[64], 32, info);
|
|
|
|
okm.Length.ShouldBe(32);
|
|
}
|
|
}
|