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,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);
|
||||
}
|
||||
Reference in New Issue
Block a user