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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,23 +6,48 @@ namespace DodoSSH.Crypto.Tests;
|
||||
/// Pins the specification constants that are written into stored data.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// These are not busywork. The envelope magic and AAD version are persisted in every
|
||||
/// ciphertext row, and only clients can re-encrypt: if one of these changes without a
|
||||
/// deliberate migration path, existing vaults stop decrypting and the server cannot help.
|
||||
/// These are not busywork. The envelope magic, AAD version and every enum value are persisted
|
||||
/// in ciphertext rows or in the AAD they are bound to, and only clients can re-encrypt: if one
|
||||
/// changes without a deliberate migration path, existing vaults stop decrypting and the server
|
||||
/// cannot help.
|
||||
/// </remarks>
|
||||
public sealed class CryptoSpecTests
|
||||
{
|
||||
[Fact]
|
||||
public void EnvelopeMagic_IsStable()
|
||||
{
|
||||
CryptoSpec.EnvelopeMagic.ShouldBe("DSH1");
|
||||
CryptoSpec.EnvelopeMagic.ToArray().ShouldBe("DSH1"u8.ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AadMagic_IsStable()
|
||||
{
|
||||
CryptoSpec.AadMagic.ToArray().ShouldBe("dsh1\n"u8.ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CurrentAadVersion_IsStable()
|
||||
{
|
||||
// Bumping this requires a lazy re-encrypt-on-write path in the client first.
|
||||
CryptoSpec.CurrentAadVersion.ShouldBe((short)1);
|
||||
CryptoSpec.CurrentAadVersion.ShouldBe((byte)1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CurrentSchemaVersion_IsStable()
|
||||
{
|
||||
CryptoSpec.CurrentSchemaVersion.ShouldBe((ushort)1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sizes_MatchTheSpecification()
|
||||
{
|
||||
CryptoSpec.AadEncodedLength.ShouldBe(64);
|
||||
CryptoSpec.SymmetricKeySize.ShouldBe(32);
|
||||
CryptoSpec.PublicKeySize.ShouldBe(32);
|
||||
CryptoSpec.SignatureSize.ShouldBe(64);
|
||||
CryptoSpec.DigestSize.ShouldBe(32);
|
||||
CryptoSpec.TagSize.ShouldBe(16);
|
||||
CryptoSpec.SaltSize.ShouldBe(16);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -37,9 +62,76 @@ public sealed class CryptoSpecTests
|
||||
[Fact]
|
||||
public void AlgorithmId_4_IsReservedForHybridPostQuantumSeal()
|
||||
{
|
||||
// Reserved for X25519 + ML-KEM-768. Claimed now so the identifier cannot be
|
||||
// reused: store-now-decrypt-later is a real threat for long-lived SSH keys.
|
||||
// AlgorithmId is byte-backed, matching the single alg_id byte in the envelope.
|
||||
// Reserved for X25519 + ML-KEM-768. Claimed now so the identifier cannot be reused:
|
||||
// store-now-decrypt-later is a real threat for long-lived SSH keys.
|
||||
Enum.IsDefined(typeof(CryptoSpec.AlgorithmId), (byte)4).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(CryptoSpec.AadPurpose.UserSecretBundle, 1)]
|
||||
[InlineData(CryptoSpec.AadPurpose.VaultKeyGrant, 2)]
|
||||
[InlineData(CryptoSpec.AadPurpose.ItemDataKey, 3)]
|
||||
[InlineData(CryptoSpec.AadPurpose.ItemPayload, 4)]
|
||||
[InlineData(CryptoSpec.AadPurpose.ItemMetadata, 5)]
|
||||
[InlineData(CryptoSpec.AadPurpose.LocalCache, 6)]
|
||||
public void AadPurpose_HasStableWireValue(CryptoSpec.AadPurpose purpose, int expected)
|
||||
{
|
||||
((int)purpose).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(CryptoSpec.AadResourceType.User, 1)]
|
||||
[InlineData(CryptoSpec.AadResourceType.Device, 2)]
|
||||
[InlineData(CryptoSpec.AadResourceType.Vault, 3)]
|
||||
[InlineData(CryptoSpec.AadResourceType.Host, 4)]
|
||||
[InlineData(CryptoSpec.AadResourceType.Credential, 5)]
|
||||
[InlineData(CryptoSpec.AadResourceType.SshKey, 6)]
|
||||
[InlineData(CryptoSpec.AadResourceType.HostGroup, 7)]
|
||||
[InlineData(CryptoSpec.AadResourceType.Tag, 8)]
|
||||
[InlineData(CryptoSpec.AadResourceType.Snippet, 9)]
|
||||
[InlineData(CryptoSpec.AadResourceType.PortForward, 10)]
|
||||
[InlineData(CryptoSpec.AadResourceType.KnownHostKey, 11)]
|
||||
public void AadResourceType_HasStableWireValue(CryptoSpec.AadResourceType type, int expected)
|
||||
{
|
||||
((int)type).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DerivationLabels_AreStable()
|
||||
{
|
||||
// These are HKDF info strings; changing one silently derives a different key.
|
||||
CryptoSpec.DerivationLabels.PassphraseKek.ToArray()
|
||||
.ShouldBe("dsh1/kek/passphrase/v1"u8.ToArray());
|
||||
CryptoSpec.DerivationLabels.LocalCache.ToArray()
|
||||
.ShouldBe("dsh1/localcache/v1"u8.ToArray());
|
||||
CryptoSpec.DerivationLabels.SealTo.ToArray()
|
||||
.ShouldBe("dsh1/sealto/v1|"u8.ToArray());
|
||||
CryptoSpec.DerivationLabels.Fingerprint.ToArray()
|
||||
.ShouldBe("dsh1/fp/v1"u8.ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SigningContexts_AreStable()
|
||||
{
|
||||
CryptoSpec.SigningContexts.KeyStatement.ToArray()
|
||||
.ShouldBe("dsh1/sig/keystatement/v1"u8.ToArray());
|
||||
CryptoSpec.SigningContexts.Grant.ToArray()
|
||||
.ShouldBe("dsh1/sig/grant/v1"u8.ToArray());
|
||||
CryptoSpec.SigningContexts.Attestation.ToArray()
|
||||
.ShouldBe("dsh1/sig/attestation/v1"u8.ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SigningContexts_AreAllDistinct()
|
||||
{
|
||||
// A shared context would let a signature in one role be replayed in another.
|
||||
string[] contexts =
|
||||
[
|
||||
System.Text.Encoding.UTF8.GetString(CryptoSpec.SigningContexts.KeyStatement),
|
||||
System.Text.Encoding.UTF8.GetString(CryptoSpec.SigningContexts.Grant),
|
||||
System.Text.Encoding.UTF8.GetString(CryptoSpec.SigningContexts.Attestation),
|
||||
];
|
||||
|
||||
contexts.Distinct(StringComparer.Ordinal).Count().ShouldBe(contexts.Length);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,4 +14,15 @@
|
||||
<ProjectReference Include="../../src/DodoSSH.Crypto/DodoSSH.Crypto.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!--
|
||||
Copied to the output directory and read from there. Resolving it from the source tree
|
||||
via [CallerFilePath] does not work: ContinuousIntegrationBuild enables deterministic
|
||||
source paths, which rewrites caller paths to /_/... and breaks only in CI.
|
||||
-->
|
||||
<Content Include="../fixtures/crypto/vectors.json"
|
||||
Link="fixtures/crypto/vectors.json"
|
||||
CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
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)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
namespace DodoSSH.Crypto.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Asserts the committed golden vectors still hold.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This is the single most important test in the product. The server holds ciphertext and no
|
||||
/// keys, so it can never re-encrypt anything: a change to the envelope layout or to AAD
|
||||
/// derivation that reaches a release makes every existing vault undecryptable, with no
|
||||
/// server-side remedy and no rollback.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A failure here is never fixed by regenerating the fixture.</b> It means either a genuine
|
||||
/// regression, or an intentional format change — which requires a new
|
||||
/// <c>aadVersion</c>/<c>algId</c> and a client-side lazy re-encrypt-on-write path to exist
|
||||
/// first. See docs/crypto.md §8.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// To regenerate deliberately, set <c>DODOSSH_REGENERATE_VECTORS=1</c>. The test rewrites the
|
||||
/// fixture in the source tree and then fails, so the diff has to be reviewed rather than
|
||||
/// silently absorbed.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class GoldenVectorTests
|
||||
{
|
||||
private const string RegenerateVariable = "DODOSSH_REGENERATE_VECTORS";
|
||||
private const string FixtureRelativePath = "fixtures/crypto/vectors.json";
|
||||
|
||||
[Fact]
|
||||
public void CommittedVectors_MatchCurrentImplementation()
|
||||
{
|
||||
var actual = GoldenVectors.Generate();
|
||||
|
||||
if (string.Equals(Environment.GetEnvironmentVariable(RegenerateVariable), "1", StringComparison.Ordinal))
|
||||
{
|
||||
var sourcePath = ResolveSourceTreeFixturePath();
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(sourcePath)!);
|
||||
File.WriteAllText(sourcePath, actual);
|
||||
|
||||
Assert.Fail(
|
||||
$"Regenerated {sourcePath}. Review the diff and unset {RegenerateVariable}. "
|
||||
+ "If the envelope or AAD changed, a version bump and a client migration path are required first.");
|
||||
}
|
||||
|
||||
var expected = File.ReadAllText(OutputFixturePath());
|
||||
|
||||
Normalise(actual).ShouldBe(
|
||||
Normalise(expected),
|
||||
"The DSH1 format or AAD derivation changed. This would make every existing vault "
|
||||
+ "undecryptable. Do not regenerate the fixture to silence this.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fixture_IsCommittedAndNonTrivial()
|
||||
{
|
||||
var content = File.ReadAllText(OutputFixturePath());
|
||||
|
||||
content.Length.ShouldBeGreaterThan(1000);
|
||||
content.ShouldContain("canonicalEncoding");
|
||||
content.ShouldContain("\"specVersion\": 1");
|
||||
}
|
||||
|
||||
private static string Normalise(string json) => json.ReplaceLineEndings("\n").TrimEnd();
|
||||
|
||||
/// <summary>
|
||||
/// The fixture as copied beside the test assembly. Robust under deterministic source paths.
|
||||
/// </summary>
|
||||
private static string OutputFixturePath()
|
||||
{
|
||||
var path = Path.Combine(AppContext.BaseDirectory, FixtureRelativePath);
|
||||
|
||||
File.Exists(path).ShouldBeTrue(
|
||||
$"Golden vector fixture missing at {path}. It should be copied to the output "
|
||||
+ $"directory by the project file. Set {RegenerateVariable}=1 to create it.");
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Locates the fixture in the source tree by walking up to the solution file.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Used only when regenerating, which is a developer-local action.
|
||||
/// </remarks>
|
||||
private static string ResolveSourceTreeFixturePath()
|
||||
{
|
||||
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
|
||||
while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "DodoSSH.slnx")))
|
||||
{
|
||||
directory = directory.Parent;
|
||||
}
|
||||
|
||||
if (directory is null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Could not locate the repository root (no DodoSSH.slnx found above "
|
||||
+ $"{AppContext.BaseDirectory}). Regenerate from within the repository.");
|
||||
}
|
||||
|
||||
return Path.Combine(directory.FullName, "tests", FixtureRelativePath.Replace('/', Path.DirectorySeparatorChar));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using DodoSSH.Crypto;
|
||||
using NSec.Cryptography;
|
||||
|
||||
namespace DodoSSH.Crypto.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Produces the deterministic byte-level results of the DSH1 specification.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Only deterministic operations belong here. <c>SealTo</c> and signing draw fresh randomness,
|
||||
/// so they are covered by round-trip and negative tests in <see cref="DshCryptoTests"/> instead.
|
||||
/// </remarks>
|
||||
internal static class GoldenVectors
|
||||
{
|
||||
private static readonly Guid ResourceA = Guid.Parse("0192f0c8-1a2b-7c3d-8e4f-5a6b7c8d9e0f");
|
||||
private static readonly Guid ResourceB = Guid.Parse("0192f0c8-1a2b-7c3d-8e4f-5a6b7c8d9e10");
|
||||
private static readonly Guid KeyIdA = Guid.Parse("0192f0c8-9999-7aaa-8bbb-cccccccccccc");
|
||||
|
||||
internal static string Generate()
|
||||
{
|
||||
var root = new JsonObject
|
||||
{
|
||||
["_comment"] = "Generated by DodoSSH.Crypto.Tests.GoldenVectors. Normative source: docs/crypto.md.",
|
||||
["_warning"] = "A failing assertion here is a regression or an intentional versioned format change. Do not regenerate to make it pass.",
|
||||
["specVersion"] = 1,
|
||||
["aad"] = BuildAadVectors(),
|
||||
["envelope"] = BuildEnvelopeVectors(),
|
||||
["aead"] = BuildAeadVectors(),
|
||||
["hkdf"] = BuildHkdfVectors(),
|
||||
["argon2id"] = BuildArgon2Vectors(),
|
||||
["fingerprint"] = BuildFingerprintVectors(),
|
||||
};
|
||||
|
||||
return root.ToJsonString(new JsonSerializerOptions { WriteIndented = true }) + "\n";
|
||||
}
|
||||
|
||||
private static JsonArray BuildAadVectors()
|
||||
{
|
||||
(string Name, AadDescriptor Descriptor)[] cases =
|
||||
[
|
||||
("item-payload", AadDescriptor.Create(
|
||||
CryptoSpec.AadPurpose.ItemPayload,
|
||||
CryptoSpec.AadResourceType.Credential,
|
||||
ResourceA,
|
||||
KeyIdA,
|
||||
keyGeneration: 7,
|
||||
itemVersion: 3)),
|
||||
("item-payload-other-resource", AadDescriptor.Create(
|
||||
CryptoSpec.AadPurpose.ItemPayload,
|
||||
CryptoSpec.AadResourceType.Credential,
|
||||
ResourceB,
|
||||
KeyIdA,
|
||||
keyGeneration: 7,
|
||||
itemVersion: 3)),
|
||||
("item-metadata", AadDescriptor.Create(
|
||||
CryptoSpec.AadPurpose.ItemMetadata,
|
||||
CryptoSpec.AadResourceType.Host,
|
||||
ResourceA,
|
||||
keyGeneration: 1,
|
||||
itemVersion: 1)),
|
||||
("vault-key-grant", AadDescriptor.Create(
|
||||
CryptoSpec.AadPurpose.VaultKeyGrant,
|
||||
CryptoSpec.AadResourceType.Vault,
|
||||
ResourceA,
|
||||
keyGeneration: 2)),
|
||||
("user-secret-bundle", AadDescriptor.Create(
|
||||
CryptoSpec.AadPurpose.UserSecretBundle,
|
||||
CryptoSpec.AadResourceType.User,
|
||||
ResourceA)),
|
||||
("all-zero-ids", AadDescriptor.Create(
|
||||
CryptoSpec.AadPurpose.LocalCache,
|
||||
CryptoSpec.AadResourceType.None,
|
||||
Guid.Empty,
|
||||
keyGeneration: 0)),
|
||||
];
|
||||
|
||||
var array = new JsonArray();
|
||||
foreach (var (name, descriptor) in cases)
|
||||
{
|
||||
array.Add(new JsonObject
|
||||
{
|
||||
["name"] = name,
|
||||
["purpose"] = (int)descriptor.Purpose,
|
||||
["resourceType"] = (int)descriptor.ResourceType,
|
||||
["resourceId"] = descriptor.ResourceId.ToString(),
|
||||
["keyId"] = descriptor.KeyId.ToString(),
|
||||
["keyGeneration"] = descriptor.KeyGeneration,
|
||||
["itemVersion"] = descriptor.ItemVersion,
|
||||
["aadVersion"] = descriptor.AadVersion,
|
||||
["schemaVersion"] = descriptor.SchemaVersion,
|
||||
["canonicalEncoding"] = Hex(descriptor.ToCanonicalEncoding()),
|
||||
["aad"] = Hex(descriptor.ComputeAad()),
|
||||
});
|
||||
}
|
||||
|
||||
return array;
|
||||
}
|
||||
|
||||
private static JsonArray BuildEnvelopeVectors()
|
||||
{
|
||||
// Framing only, with fixed inputs, so the byte layout of the header is pinned
|
||||
// independently of any AEAD behaviour.
|
||||
var ciphertext = Enumerable.Range(0, 20).Select(i => (byte)i).ToArray();
|
||||
var xchachaNonce = Enumerable.Range(0, DshEnvelope.XChaChaNonceSize).Select(i => (byte)(0xA0 + i)).ToArray();
|
||||
var gcmNonce = Enumerable.Range(0, DshEnvelope.AesGcmNonceSize).Select(i => (byte)(0xB0 + i)).ToArray();
|
||||
var ephemeral = Enumerable.Range(0, CryptoSpec.PublicKeySize).Select(i => (byte)(0xC0 + i)).ToArray();
|
||||
|
||||
return
|
||||
[
|
||||
new JsonObject
|
||||
{
|
||||
["name"] = "xchacha20poly1305",
|
||||
["algId"] = (int)CryptoSpec.AlgorithmId.XChaCha20Poly1305,
|
||||
["nonce"] = Hex(xchachaNonce),
|
||||
["ciphertext"] = Hex(ciphertext),
|
||||
["prefixSize"] = DshEnvelope.PrefixSizeFor(CryptoSpec.AlgorithmId.XChaCha20Poly1305),
|
||||
["envelope"] = Hex(DshEnvelope.Write(
|
||||
CryptoSpec.AlgorithmId.XChaCha20Poly1305, xchachaNonce, ciphertext)),
|
||||
},
|
||||
new JsonObject
|
||||
{
|
||||
["name"] = "aes256gcm",
|
||||
["algId"] = (int)CryptoSpec.AlgorithmId.Aes256Gcm,
|
||||
["nonce"] = Hex(gcmNonce),
|
||||
["ciphertext"] = Hex(ciphertext),
|
||||
["prefixSize"] = DshEnvelope.PrefixSizeFor(CryptoSpec.AlgorithmId.Aes256Gcm),
|
||||
["envelope"] = Hex(DshEnvelope.Write(
|
||||
CryptoSpec.AlgorithmId.Aes256Gcm, gcmNonce, ciphertext)),
|
||||
},
|
||||
new JsonObject
|
||||
{
|
||||
["name"] = "sealto-x25519",
|
||||
["algId"] = (int)CryptoSpec.AlgorithmId.SealToX25519,
|
||||
["nonce"] = Hex(xchachaNonce),
|
||||
["ephemeralPublicKey"] = Hex(ephemeral),
|
||||
["ciphertext"] = Hex(ciphertext),
|
||||
["prefixSize"] = DshEnvelope.PrefixSizeFor(CryptoSpec.AlgorithmId.SealToX25519),
|
||||
["envelope"] = Hex(DshEnvelope.Write(
|
||||
CryptoSpec.AlgorithmId.SealToX25519, xchachaNonce, ciphertext, ephemeral)),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private static JsonArray BuildAeadVectors()
|
||||
{
|
||||
// Fixed key and nonce, so the AEAD itself is pinned. DshCrypto.Seal draws a random
|
||||
// nonce by design, so the primitive is exercised directly here.
|
||||
var key = Enumerable.Range(0, CryptoSpec.SymmetricKeySize).Select(i => (byte)i).ToArray();
|
||||
var nonce = Enumerable.Range(0, DshEnvelope.XChaChaNonceSize).Select(i => (byte)(0x10 + i)).ToArray();
|
||||
var plaintext = "correct horse battery staple"u8.ToArray();
|
||||
|
||||
var descriptor = AadDescriptor.Create(
|
||||
CryptoSpec.AadPurpose.ItemPayload,
|
||||
CryptoSpec.AadResourceType.Credential,
|
||||
ResourceA,
|
||||
KeyIdA,
|
||||
keyGeneration: 7,
|
||||
itemVersion: 3);
|
||||
|
||||
var aad = descriptor.ComputeAad();
|
||||
|
||||
using var aeadKey = Key.Import(
|
||||
AeadAlgorithm.XChaCha20Poly1305, key, KeyBlobFormat.RawSymmetricKey);
|
||||
var ciphertext = AeadAlgorithm.XChaCha20Poly1305.Encrypt(aeadKey, nonce, aad, plaintext);
|
||||
|
||||
return
|
||||
[
|
||||
new JsonObject
|
||||
{
|
||||
["name"] = "xchacha20poly1305-with-canonical-aad",
|
||||
["key"] = Hex(key),
|
||||
["nonce"] = Hex(nonce),
|
||||
["aad"] = Hex(aad),
|
||||
["plaintext"] = Hex(plaintext),
|
||||
["ciphertext"] = Hex(ciphertext),
|
||||
["envelope"] = Hex(DshEnvelope.Write(
|
||||
CryptoSpec.AlgorithmId.XChaCha20Poly1305, nonce, ciphertext)),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private static JsonArray BuildHkdfVectors()
|
||||
{
|
||||
var prk = Enumerable.Range(0, 64).Select(i => (byte)i).ToArray();
|
||||
|
||||
(string Name, byte[] Info)[] cases =
|
||||
[
|
||||
("passphrase-kek", CryptoSpec.DerivationLabels.PassphraseKek.ToArray()),
|
||||
("local-cache", CryptoSpec.DerivationLabels.LocalCache.ToArray()),
|
||||
];
|
||||
|
||||
var array = new JsonArray();
|
||||
foreach (var (name, info) in cases)
|
||||
{
|
||||
array.Add(new JsonObject
|
||||
{
|
||||
["name"] = name,
|
||||
["algorithm"] = "HKDF-SHA512-Expand",
|
||||
["prk"] = Hex(prk),
|
||||
["info"] = Encoding.UTF8.GetString(info),
|
||||
["outputLength"] = CryptoSpec.SymmetricKeySize,
|
||||
["output"] = Hex(HKDF.Expand(
|
||||
HashAlgorithmName.SHA512, prk, CryptoSpec.SymmetricKeySize, info)),
|
||||
});
|
||||
}
|
||||
|
||||
return array;
|
||||
}
|
||||
|
||||
private static JsonArray BuildArgon2Vectors()
|
||||
{
|
||||
var salt = Enumerable.Range(0, CryptoSpec.SaltSize).Select(i => (byte)(0x20 + i)).ToArray();
|
||||
const string Passphrase = "correct horse battery staple";
|
||||
|
||||
var array = new JsonArray();
|
||||
|
||||
// Parameters of every profile are pinned cheaply. Only the smallest profile's output is
|
||||
// computed, to keep the suite fast; the KDF itself is libsodium's, not ours.
|
||||
(string Name, Argon2Profile Profile)[] profiles =
|
||||
[
|
||||
("passphrase-default", Argon2Profile.PassphraseDefault),
|
||||
("passphrase-reduced", Argon2Profile.PassphraseReduced),
|
||||
("passphrase-high", Argon2Profile.PassphraseHigh),
|
||||
("random-secret", Argon2Profile.RandomSecret),
|
||||
];
|
||||
|
||||
foreach (var (name, profile) in profiles)
|
||||
{
|
||||
array.Add(new JsonObject
|
||||
{
|
||||
["name"] = name,
|
||||
["memoryMebibytes"] = profile.MemoryMebibytes,
|
||||
["memoryKibibytes"] = profile.MemoryKibibytes,
|
||||
["passes"] = profile.Passes,
|
||||
["parallelism"] = Argon2Profile.Parallelism,
|
||||
});
|
||||
}
|
||||
|
||||
array.Add(new JsonObject
|
||||
{
|
||||
["name"] = "random-secret-output",
|
||||
["memoryMebibytes"] = Argon2Profile.RandomSecret.MemoryMebibytes,
|
||||
["memoryKibibytes"] = Argon2Profile.RandomSecret.MemoryKibibytes,
|
||||
["passes"] = Argon2Profile.RandomSecret.Passes,
|
||||
["parallelism"] = Argon2Profile.Parallelism,
|
||||
["passphrase"] = Passphrase,
|
||||
["salt"] = Hex(salt),
|
||||
["outputLength"] = CryptoSpec.SymmetricKeySize,
|
||||
["output"] = Hex(Argon2Profile.RandomSecret
|
||||
.CreateAlgorithm()
|
||||
.DeriveBytes(Passphrase, salt, CryptoSpec.SymmetricKeySize)),
|
||||
});
|
||||
|
||||
return array;
|
||||
}
|
||||
|
||||
private static JsonArray BuildFingerprintVectors()
|
||||
{
|
||||
var x25519 = Enumerable.Range(0, CryptoSpec.PublicKeySize).Select(i => (byte)(0x40 + i)).ToArray();
|
||||
var ed25519 = Enumerable.Range(0, CryptoSpec.PublicKeySize).Select(i => (byte)(0x60 + i)).ToArray();
|
||||
|
||||
return
|
||||
[
|
||||
new JsonObject
|
||||
{
|
||||
["name"] = "identity-fingerprint",
|
||||
["x25519PublicKey"] = Hex(x25519),
|
||||
["ed25519PublicKey"] = Hex(ed25519),
|
||||
["fingerprint"] = Hex(DshCrypto.ComputeFingerprint(x25519, ed25519)),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private static string Hex(ReadOnlySpan<byte> value) =>
|
||||
Convert.ToHexString(value).ToLower(CultureInfo.InvariantCulture);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -195,7 +195,25 @@
|
||||
}
|
||||
},
|
||||
"dodossh.crypto": {
|
||||
"type": "Project"
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"NSec.Cryptography": "[26.4.0, )"
|
||||
}
|
||||
},
|
||||
"libsodium": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[1.0.22, )",
|
||||
"resolved": "1.0.22",
|
||||
"contentHash": "KPD9SloJFclrsjnhABu7dzWrcyYkwPbvx5l1gRSPAX/0n+OBtSiVCKtGFv4n+ecWUHU0tCG9LSSwoZZx673zBQ=="
|
||||
},
|
||||
"NSec.Cryptography": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[26.4.0, )",
|
||||
"resolved": "26.4.0",
|
||||
"contentHash": "0vsCtY5f+YgQROiWNqzgWp+l2pddfk9FkWoGV/bEo0MuEYPKlJWuoA8aOfO6qp3f+EnObKE3zSJhn1PspJeJVg==",
|
||||
"dependencies": {
|
||||
"libsodium": "[1.0.22, 1.0.23)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user