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(),
["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)),
},
];
}
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)),
},
];
}
///
/// 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);
}