using System.Buffers.Binary; 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; /// /// Produces the deterministic byte-level results of the DSH1 specification. /// /// /// Only deterministic operations belong here. SealTo and signing draw fresh randomness, /// so they are covered by round-trip and negative tests in instead. /// 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(), ["localCacheKey"] = BuildLocalCacheKeyVectors(), ["argon2id"] = BuildArgon2Vectors(), ["fingerprint"] = BuildFingerprintVectors(), ["keyStatement"] = BuildKeyStatementVectors(), ["keyLog"] = BuildKeyLogVectors(), }; 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)), }, ]; } /// /// Only the labels that really are HKDF-Expand over a master key belong here. The local cache key used /// to be one of them and is not any more — it extracts and expands over the bundle's encoding /// instead — so it has its own section rather than an entry here that would describe a derivation this /// implementation no longer performs. See . /// 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()), ]; 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; } /// /// The local cache key, over a bundle whose every byte is pinned. docs/crypto.md §3.2. /// /// /// Built from a fixed encoding rather than from UserSecretBundle.Create, which draws fresh /// randomness and so could never produce a reproducible vector. This is the one that matters for a /// second implementation: it pins the extract-and-expand construction, the info label, and the fact /// that the input is the bundle's canonical encoding rather than any key inside it. /// private static JsonArray BuildLocalCacheKeyVectors() { var encoded = FixedBundleEncoding(); using var bundle = UserSecretBundle.TryDecode(encoded) ?? throw new InvalidOperationException("The fixed bundle encoding is not well-formed."); var cacheKey = new byte[CryptoSpec.SymmetricKeySize]; bundle.DeriveLocalCacheKey(cacheKey); return [ new JsonObject { ["name"] = "local-cache-key-from-bundle", ["algorithm"] = "HKDF-SHA512 extract-and-expand, no salt", ["bundle"] = Hex(encoded), ["info"] = Encoding.UTF8.GetString(CryptoSpec.DerivationLabels.LocalCache), ["outputLength"] = CryptoSpec.SymmetricKeySize, ["output"] = Hex(cacheKey), }, ]; } /// /// The §3.1 layout with every field a constant: label, version 1, generation 1, a fixed timestamp, and /// two key scalars of recognisable byte patterns. Any 32 bytes is a valid X25519 scalar and a valid /// Ed25519 seed, so nothing here needs to be a real generated key. /// private static byte[] FixedBundleEncoding() { var encoded = new byte[UserSecretBundle.EncodedLength]; var span = encoded.AsSpan(); UserSecretBundle.Label.CopyTo(span); BinaryPrimitives.WriteUInt16BigEndian(span[14..], UserSecretBundle.CurrentVersion); BinaryPrimitives.WriteUInt32BigEndian(span[16..], 1u); BinaryPrimitives.WriteInt64BigEndian( span[20..], new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero).ToUnixTimeMilliseconds()); for (var i = 0; i < CryptoSpec.SymmetricKeySize; i++) { span[28 + i] = (byte)(0x40 + i); span[60 + i] = (byte)(0x60 + i); } return encoded; } 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)), }, ]; } /// /// Pins the key statement encoding and the nonce derived from it. See docs/crypto.md §7.1. /// /// /// The highest-value vectors after the AAD ones. The nonce is what an identity provider signs /// over, so a client and a server that disagree by one byte here produce enrollments that can /// never be verified — and unlike a decryption failure, that only shows up against a real /// provider. /// /// The absent-email case is present specifically to pin the presence byte, which is what stops /// an absent email and an empty one sharing a binding. /// /// private static JsonArray BuildKeyStatementVectors() { var array = new JsonArray(); foreach (var (name, statement) in KeyStatementCases()) { var encoding = KeyStatementCodec.Encode(statement); array.Add(new JsonObject { ["name"] = name, ["version"] = statement.Version, ["issuer"] = statement.Issuer, ["subject"] = statement.Subject, ["email"] = statement.Email, ["keyGeneration"] = statement.KeyGeneration, ["createdAtUnixMilliseconds"] = statement.CreatedAt.ToUnixTimeMilliseconds(), ["deviceName"] = statement.DeviceName, ["x25519PublicKey"] = Hex(statement.EncryptionPublicKey), ["ed25519PublicKey"] = Hex(statement.SigningPublicKey), ["canonicalEncoding"] = Hex(encoding), ["binding"] = Hex(KeyStatementCodec.ComputeBinding(encoding)), ["nonce"] = KeyStatementCodec.ComputeNonce(statement), }); } return array; } private static (string Name, KeyStatementFields Statement)[] KeyStatementCases() { 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(); // A fixed instant, so the vectors do not depend on when they were generated. var createdAt = DateTimeOffset.FromUnixTimeMilliseconds(1_750_000_000_123); return [ ("with-email", new KeyStatementFields( 1, "https://idp.example/realms/dodossh", "alice-subject", "alice@example.com", x25519, ed25519, 1, createdAt, "alice-laptop")), // Same statement with the email absent, not empty. ("without-email", new KeyStatementFields( 1, "https://idp.example/realms/dodossh", "alice-subject", null, x25519, ed25519, 1, createdAt, "alice-laptop")), // ...and empty rather than absent. These three must all differ. ("empty-email", new KeyStatementFields( 1, "https://idp.example/realms/dodossh", "alice-subject", string.Empty, x25519, ed25519, 1, createdAt, "alice-laptop")), // Multi-byte UTF-8, so a length prefix counting characters rather than bytes fails here. ("unicode-device-name", new KeyStatementFields( 1, "https://idp.example/realms/dodossh", "alice-subject", "alice@example.com", x25519, ed25519, 1, createdAt, "alice's ThinkPad — büro")), // A sub-millisecond offset-bearing timestamp must encode identically to the UTC one, // because the encoding normalises and truncates. ("offset-and-sub-millisecond-timestamp", new KeyStatementFields( 1, "https://idp.example/realms/dodossh", "alice-subject", "alice@example.com", x25519, ed25519, 1, createdAt.ToOffset(TimeSpan.FromHours(2)).AddTicks(7777), "alice-laptop")), ("later-generation", new KeyStatementFields( 1, "https://idp.example/realms/dodossh", "alice-subject", "alice@example.com", x25519, ed25519, 4, createdAt, "alice-laptop")), ]; } /// Pins the key log chain hash, including the genesis link. See docs/crypto.md §7.2. private static JsonArray BuildKeyLogVectors() { 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(); var signature = Enumerable.Range(0, CryptoSpec.SignatureSize).Select(i => (byte)(0x80 + i)).ToArray(); var createdAt = DateTimeOffset.FromUnixTimeMilliseconds(1_750_000_000_123); var genesisPrevious = KeyLogChain.CreateGenesisPreviousHash(); var genesis = KeyLogChain.ComputeEntryHash( genesisPrevious, ResourceA, 1, x25519, ed25519, signature, createdAt); // The second entry links to the first, so a broken link shows up as a changed hash here // rather than only in a running deployment. var second = KeyLogChain.ComputeEntryHash( genesis, ResourceB, 1, x25519, ed25519, signature, createdAt); return [ new JsonObject { ["name"] = "genesis", ["previousHash"] = Hex(genesisPrevious), ["userId"] = ResourceA.ToString(), ["generation"] = 1, ["x25519PublicKey"] = Hex(x25519), ["ed25519PublicKey"] = Hex(ed25519), ["statementSignature"] = Hex(signature), ["createdAtUnixMilliseconds"] = createdAt.ToUnixTimeMilliseconds(), ["hash"] = Hex(genesis), }, new JsonObject { ["name"] = "second-entry", ["previousHash"] = Hex(genesis), ["userId"] = ResourceB.ToString(), ["generation"] = 1, ["x25519PublicKey"] = Hex(x25519), ["ed25519PublicKey"] = Hex(ed25519), ["statementSignature"] = Hex(signature), ["createdAtUnixMilliseconds"] = createdAt.ToUnixTimeMilliseconds(), ["hash"] = Hex(second), }, ]; } private static string Hex(ReadOnlySpan value) => Convert.ToHexString(value).ToLower(CultureInfo.InvariantCulture); }