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.
138 lines
4.9 KiB
C#
138 lines
4.9 KiB
C#
using DodoSSH.Crypto;
|
|
|
|
namespace DodoSSH.Crypto.Tests;
|
|
|
|
/// <summary>
|
|
/// Pins the specification constants that are written into stored data.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// 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.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((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]
|
|
[InlineData(CryptoSpec.AlgorithmId.XChaCha20Poly1305, 1)]
|
|
[InlineData(CryptoSpec.AlgorithmId.Aes256Gcm, 2)]
|
|
[InlineData(CryptoSpec.AlgorithmId.SealToX25519, 3)]
|
|
public void AlgorithmId_HasStableWireValue(CryptoSpec.AlgorithmId algorithm, int expected)
|
|
{
|
|
((int)algorithm).ShouldBe(expected);
|
|
}
|
|
|
|
[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.
|
|
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);
|
|
}
|
|
}
|