using DodoSSH.Crypto; namespace DodoSSH.Crypto.Tests; /// /// Pins the specification constants that are written into stored data. /// /// /// 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. /// 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)] [InlineData(CryptoSpec.AadResourceType.HostTag, 12)] [InlineData(CryptoSpec.AadResourceType.HostCredential, 13)] [InlineData(CryptoSpec.AadResourceType.ConnectionLogEntry, 14)] [InlineData(CryptoSpec.AadResourceType.ActivityLogEntry, 15)] [InlineData(CryptoSpec.AadResourceType.ObjectStore, 16)] 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()); // v2 since 2026-07-30: the cache key derives from the bundle rather than the master key, so that a // device or recovery unlock reaches the same cache. docs/crypto.md ยง3.2. Bumping the label is what // makes a v1 cache fail to open rather than decrypt to nonsense. CryptoSpec.DerivationLabels.LocalCache.ToArray() .ShouldBe("dsh1/localcache/v2"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); } }