Public Access
Specify the key statement encoding and key log chain (crypto.md 7.1, 7.2)
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.
This commit is contained in:
@@ -34,6 +34,8 @@ internal static class GoldenVectors
|
||||
["hkdf"] = BuildHkdfVectors(),
|
||||
["argon2id"] = BuildArgon2Vectors(),
|
||||
["fingerprint"] = BuildFingerprintVectors(),
|
||||
["keyStatement"] = BuildKeyStatementVectors(),
|
||||
["keyLog"] = BuildKeyLogVectors(),
|
||||
};
|
||||
|
||||
return root.ToJsonString(new JsonSerializerOptions { WriteIndented = true }) + "\n";
|
||||
@@ -276,6 +278,137 @@ internal static class GoldenVectors
|
||||
];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pins the key statement encoding and the nonce derived from it. See docs/crypto.md §7.1.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
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")),
|
||||
];
|
||||
}
|
||||
|
||||
/// <summary>Pins the key log chain hash, including the genesis link. See docs/crypto.md §7.2.</summary>
|
||||
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<byte> value) =>
|
||||
Convert.ToHexString(value).ToLower(CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user