Public Access
Section 7 always required "a canonical, length-prefixed encoding" for signatures without ever specifying one. That gap had to be closed before enrollment could exist: the client hashes the key statement and uses the result as an OIDC nonce, so the provider signs over those exact bytes. Two implementations disagreeing by one byte produce two nonces and an enrollment nobody can verify -- and it only shows up against a real provider, never in a local test. JSON cannot be the hashed form. Property order, number formatting, Unicode escaping and whitespace all vary between serialisers. So the statement is transmitted as JSON and hashed as a fixed binary encoding, and the two are independent by construction. Three details are load-bearing rather than stylistic: - The presence byte before each string is what makes the encoding injective. Without it an absent email and an empty one encode identically, and two different statements share a binding. - Timestamps truncate to milliseconds. PostgreSQL stores microseconds, so a statement that has been through the database must still hash to what the client hashed. The same applies to the key log, where an entry that cannot reproduce its own hash after being read back makes the chain unverifiable. - The key log entry hash deliberately excludes the database sequence. It is unknown until the insert runs, and order already follows the hash links -- so a renumbered or gapped sequence column cannot silently reorder history. KeyStatementFields is separate from Contracts.KeyStatement on purpose: one may gain JSON fields freely, the other cannot change without invalidating every stored binding, and Crypto must not depend on the contract assembly. KeyStatementDriftTests makes a field added to one and not the other a build failure, because a wire field outside the binding is unauthenticated data the server can change undetected. 54 new tests and two new golden vector sections. The vectors pin the absent-versus-empty email case and confirm that an offset-bearing sub-millisecond timestamp encodes identically to its truncated UTC form. Only additions to vectors.json; nothing existing moved.
64 lines
2.6 KiB
C#
64 lines
2.6 KiB
C#
using DodoSSH.Crypto;
|
|
|
|
namespace DodoSSH.Contracts.Tests;
|
|
|
|
/// <summary>
|
|
/// Guards the two key statement types against drifting apart.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <see cref="KeyStatement"/> is the wire DTO; <see cref="KeyStatementFields"/> is what the
|
|
/// canonical encoding in docs/crypto.md §7.1 is defined over. They are separate on purpose — one
|
|
/// may gain JSON fields freely, the other cannot change without invalidating every stored binding,
|
|
/// and <c>DodoSSH.Crypto</c> must not depend on the contract assembly.
|
|
/// </para>
|
|
/// <para>
|
|
/// The hazard that separation creates is a field added to the DTO and silently left out of the
|
|
/// hash. A client would then sign and bind a statement that omits it, and the field would be
|
|
/// unauthenticated data the server could change at will. This test makes that a build failure:
|
|
/// adding a field to the DTO forces a deliberate decision about whether it is covered, and if it
|
|
/// is, a spec version bump.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class KeyStatementDriftTests
|
|
{
|
|
[Fact]
|
|
public void BothTypes_DeclareTheSameFields()
|
|
{
|
|
var contract = PropertyNames<KeyStatement>();
|
|
var canonical = PropertyNames<KeyStatementFields>();
|
|
|
|
canonical.ShouldBe(
|
|
contract,
|
|
"KeyStatement and KeyStatementFields disagree. A field on the wire that the canonical "
|
|
+ "encoding does not cover is unauthenticated: the identity provider never signs over "
|
|
+ "it, so the server can change it undetected. Cover it and bump the statement version, "
|
|
+ "or document why it is deliberately outside the binding.");
|
|
}
|
|
|
|
[Fact]
|
|
public void BothTypes_AgreeOnFieldTypes()
|
|
{
|
|
// Names alone would not catch a string becoming a Uri, or an int becoming a long — either
|
|
// of which changes what gets encoded without changing what gets listed.
|
|
var contract = PropertyTypes<KeyStatement>();
|
|
var canonical = PropertyTypes<KeyStatementFields>();
|
|
|
|
canonical.ShouldBe(contract);
|
|
}
|
|
|
|
private static IReadOnlyList<string> PropertyNames<T>() =>
|
|
[.. typeof(T)
|
|
.GetProperties()
|
|
.Select(p => p.Name)
|
|
.Where(name => !string.Equals(name, "EqualityContract", StringComparison.Ordinal))
|
|
.Order(StringComparer.Ordinal)];
|
|
|
|
private static IReadOnlyList<string> PropertyTypes<T>() =>
|
|
[.. typeof(T)
|
|
.GetProperties()
|
|
.Where(p => !string.Equals(p.Name, "EqualityContract", StringComparison.Ordinal))
|
|
.Select(p => $"{p.Name}:{p.PropertyType.Name}")
|
|
.Order(StringComparer.Ordinal)];
|
|
}
|