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.
105 lines
4.0 KiB
C#
105 lines
4.0 KiB
C#
namespace DodoSSH.Crypto.Tests;
|
|
|
|
/// <summary>
|
|
/// Asserts the committed golden vectors still hold.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// This is the single most important test in the product. The server holds ciphertext and no
|
|
/// keys, so it can never re-encrypt anything: a change to the envelope layout or to AAD
|
|
/// derivation that reaches a release makes every existing vault undecryptable, with no
|
|
/// server-side remedy and no rollback.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>A failure here is never fixed by regenerating the fixture.</b> It means either a genuine
|
|
/// regression, or an intentional format change — which requires a new
|
|
/// <c>aadVersion</c>/<c>algId</c> and a client-side lazy re-encrypt-on-write path to exist
|
|
/// first. See docs/crypto.md §8.
|
|
/// </para>
|
|
/// <para>
|
|
/// To regenerate deliberately, set <c>DODOSSH_REGENERATE_VECTORS=1</c>. The test rewrites the
|
|
/// fixture in the source tree and then fails, so the diff has to be reviewed rather than
|
|
/// silently absorbed.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class GoldenVectorTests
|
|
{
|
|
private const string RegenerateVariable = "DODOSSH_REGENERATE_VECTORS";
|
|
private const string FixtureRelativePath = "fixtures/crypto/vectors.json";
|
|
|
|
[Fact]
|
|
public void CommittedVectors_MatchCurrentImplementation()
|
|
{
|
|
var actual = GoldenVectors.Generate();
|
|
|
|
if (string.Equals(Environment.GetEnvironmentVariable(RegenerateVariable), "1", StringComparison.Ordinal))
|
|
{
|
|
var sourcePath = ResolveSourceTreeFixturePath();
|
|
Directory.CreateDirectory(Path.GetDirectoryName(sourcePath)!);
|
|
File.WriteAllText(sourcePath, actual);
|
|
|
|
Assert.Fail(
|
|
$"Regenerated {sourcePath}. Review the diff and unset {RegenerateVariable}. "
|
|
+ "If the envelope or AAD changed, a version bump and a client migration path are required first.");
|
|
}
|
|
|
|
var expected = File.ReadAllText(OutputFixturePath());
|
|
|
|
Normalise(actual).ShouldBe(
|
|
Normalise(expected),
|
|
"The DSH1 format or AAD derivation changed. This would make every existing vault "
|
|
+ "undecryptable. Do not regenerate the fixture to silence this.");
|
|
}
|
|
|
|
[Fact]
|
|
public void Fixture_IsCommittedAndNonTrivial()
|
|
{
|
|
var content = File.ReadAllText(OutputFixturePath());
|
|
|
|
content.Length.ShouldBeGreaterThan(1000);
|
|
content.ShouldContain("canonicalEncoding");
|
|
content.ShouldContain("\"specVersion\": 1");
|
|
}
|
|
|
|
private static string Normalise(string json) => json.ReplaceLineEndings("\n").TrimEnd();
|
|
|
|
/// <summary>
|
|
/// The fixture as copied beside the test assembly. Robust under deterministic source paths.
|
|
/// </summary>
|
|
private static string OutputFixturePath()
|
|
{
|
|
var path = Path.Combine(AppContext.BaseDirectory, FixtureRelativePath);
|
|
|
|
File.Exists(path).ShouldBeTrue(
|
|
$"Golden vector fixture missing at {path}. It should be copied to the output "
|
|
+ $"directory by the project file. Set {RegenerateVariable}=1 to create it.");
|
|
|
|
return path;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Locates the fixture in the source tree by walking up to the solution file.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Used only when regenerating, which is a developer-local action.
|
|
/// </remarks>
|
|
private static string ResolveSourceTreeFixturePath()
|
|
{
|
|
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
|
|
|
while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "DodoSSH.slnx")))
|
|
{
|
|
directory = directory.Parent;
|
|
}
|
|
|
|
if (directory is null)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"Could not locate the repository root (no DodoSSH.slnx found above "
|
|
+ $"{AppContext.BaseDirectory}). Regenerate from within the repository.");
|
|
}
|
|
|
|
return Path.Combine(directory.FullName, "tests", FixtureRelativePath.Replace('/', Path.DirectorySeparatorChar));
|
|
}
|
|
}
|