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:
@@ -9,6 +9,13 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../src/DodoSSH.Contracts/DodoSSH.Contracts.csproj" />
|
||||
|
||||
<!--
|
||||
Only so KeyStatementDriftTests can compare the wire DTO against the type the canonical
|
||||
encoding is defined over. Contracts itself must not reference Crypto: the two are
|
||||
deliberately independent, which is the thing that test guards.
|
||||
-->
|
||||
<ProjectReference Include="../../src/DodoSSH.Crypto/DodoSSH.Crypto.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
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)];
|
||||
}
|
||||
@@ -196,6 +196,27 @@
|
||||
},
|
||||
"dodossh.contracts": {
|
||||
"type": "Project"
|
||||
},
|
||||
"dodossh.crypto": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"NSec.Cryptography": "[26.4.0, )"
|
||||
}
|
||||
},
|
||||
"libsodium": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[1.0.22, )",
|
||||
"resolved": "1.0.22",
|
||||
"contentHash": "KPD9SloJFclrsjnhABu7dzWrcyYkwPbvx5l1gRSPAX/0n+OBtSiVCKtGFv4n+ecWUHU0tCG9LSSwoZZx673zBQ=="
|
||||
},
|
||||
"NSec.Cryptography": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[26.4.0, )",
|
||||
"resolved": "26.4.0",
|
||||
"contentHash": "0vsCtY5f+YgQROiWNqzgWp+l2pddfk9FkWoGV/bEo0MuEYPKlJWuoA8aOfO6qp3f+EnObKE3zSJhn1PspJeJVg==",
|
||||
"dependencies": {
|
||||
"libsodium": "[1.0.22, 1.0.23)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
using NSec.Cryptography;
|
||||
|
||||
namespace DodoSSH.Crypto.Tests;
|
||||
|
||||
/// <summary>Key statement signing and verification. See docs/crypto.md §7.</summary>
|
||||
public sealed class DshSignaturesTests
|
||||
{
|
||||
[Fact]
|
||||
public void AFreshSignature_Verifies()
|
||||
{
|
||||
using var key = CreateSigningKey();
|
||||
var statement = Canonical(key);
|
||||
|
||||
var signature = DshSignatures.SignKeyStatement(key, statement);
|
||||
|
||||
signature.Length.ShouldBe(CryptoSpec.SignatureSize);
|
||||
DshSignatures.VerifyKeyStatement(PublicKeyBytes(key), statement, signature).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ATamperedStatement_DoesNotVerify()
|
||||
{
|
||||
using var key = CreateSigningKey();
|
||||
var statement = Canonical(key);
|
||||
var signature = DshSignatures.SignKeyStatement(key, statement);
|
||||
|
||||
var tampered = statement.ToArray();
|
||||
tampered[^1] ^= 0x01;
|
||||
|
||||
DshSignatures.VerifyKeyStatement(PublicKeyBytes(key), tampered, signature).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnotherKeysSignature_DoesNotVerify()
|
||||
{
|
||||
using var key = CreateSigningKey();
|
||||
using var other = CreateSigningKey();
|
||||
var statement = Canonical(key);
|
||||
|
||||
var signature = DshSignatures.SignKeyStatement(other, statement);
|
||||
|
||||
DshSignatures.VerifyKeyStatement(PublicKeyBytes(key), statement, signature).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ASignatureMissingTheSigningContext_DoesNotVerify()
|
||||
{
|
||||
// Domain separation. A signature over the bare canonical encoding must not be accepted as a
|
||||
// key statement signature, or the same bytes could be replayed into another role.
|
||||
using var key = CreateSigningKey();
|
||||
var statement = Canonical(key);
|
||||
|
||||
var contextless = SignatureAlgorithm.Ed25519.Sign(key, statement);
|
||||
|
||||
DshSignatures.VerifyKeyStatement(PublicKeyBytes(key), statement, contextless).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(31)]
|
||||
[InlineData(33)]
|
||||
public void AMalformedPublicKey_ReturnsFalseRatherThanThrowing(int length)
|
||||
{
|
||||
// These values arrive from an untrusted server, so rejection has to be an ordinary outcome.
|
||||
using var key = CreateSigningKey();
|
||||
var statement = Canonical(key);
|
||||
var signature = DshSignatures.SignKeyStatement(key, statement);
|
||||
|
||||
DshSignatures.VerifyKeyStatement(new byte[length], statement, signature).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(63)]
|
||||
[InlineData(65)]
|
||||
public void AMalformedSignature_ReturnsFalseRatherThanThrowing(int length)
|
||||
{
|
||||
using var key = CreateSigningKey();
|
||||
|
||||
DshSignatures.VerifyKeyStatement(PublicKeyBytes(key), Canonical(key), new byte[length])
|
||||
.ShouldBeFalse();
|
||||
}
|
||||
|
||||
private static Key CreateSigningKey() =>
|
||||
Key.Create(
|
||||
SignatureAlgorithm.Ed25519,
|
||||
new KeyCreationParameters { ExportPolicy = KeyExportPolicies.AllowPlaintextExport });
|
||||
|
||||
private static byte[] PublicKeyBytes(Key key) => key.PublicKey.Export(KeyBlobFormat.RawPublicKey);
|
||||
|
||||
private static byte[] Canonical(Key signingKey) =>
|
||||
KeyStatementCodec.Encode(new KeyStatementFields(
|
||||
Version: 1,
|
||||
Issuer: "https://idp.example/realms/dodossh",
|
||||
Subject: "alice-subject",
|
||||
Email: "alice@example.com",
|
||||
EncryptionPublicKey: TestKeys.Encryption,
|
||||
SigningPublicKey: PublicKeyBytes(signingKey),
|
||||
KeyGeneration: 1,
|
||||
CreatedAt: DateTimeOffset.FromUnixTimeMilliseconds(1_750_000_000_123),
|
||||
DeviceName: "alice-laptop"));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
namespace DodoSSH.Crypto.Tests;
|
||||
|
||||
/// <summary>The key log hash chain. See docs/crypto.md §7.2.</summary>
|
||||
public sealed class KeyLogChainTests
|
||||
{
|
||||
private static readonly Guid Alice = Guid.Parse("0192f0c8-1a2b-7c3d-8e4f-5a6b7c8d9e0f");
|
||||
private static readonly Guid Bob = Guid.Parse("0192f0c8-1a2b-7c3d-8e4f-5a6b7c8d9e10");
|
||||
private static readonly DateTimeOffset CreatedAt = DateTimeOffset.FromUnixTimeMilliseconds(1_750_000_000_123);
|
||||
|
||||
[Fact]
|
||||
public void GenesisPreviousHash_IsThirtyTwoZeroBytes()
|
||||
{
|
||||
var genesis = KeyLogChain.CreateGenesisPreviousHash();
|
||||
|
||||
genesis.Length.ShouldBe(CryptoSpec.DigestSize);
|
||||
genesis.ShouldAllBe(b => b == 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenesisPreviousHash_IsANewArrayEachCall()
|
||||
{
|
||||
// Shared mutable state in a hash input would be a spectacular way to corrupt a chain.
|
||||
var first = KeyLogChain.CreateGenesisPreviousHash();
|
||||
first[0] = 0xFF;
|
||||
|
||||
KeyLogChain.CreateGenesisPreviousHash()[0].ShouldBe((byte)0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeEntryHash_IsDeterministic()
|
||||
{
|
||||
Hash().ShouldBe(Hash());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChangingThePreviousHash_ChangesTheEntryHash()
|
||||
{
|
||||
// The link itself. If this held, a server could reorder or drop entries undetectably.
|
||||
var linked = Hash(previousHash: Hash());
|
||||
|
||||
linked.ShouldNotBe(Hash());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("user")]
|
||||
[InlineData("generation")]
|
||||
[InlineData("encryptionKey")]
|
||||
[InlineData("signingKey")]
|
||||
[InlineData("signature")]
|
||||
[InlineData("createdAt")]
|
||||
public void ChangingAnyField_ChangesTheEntryHash(string field)
|
||||
{
|
||||
var baseline = Hash();
|
||||
|
||||
var altered = field switch
|
||||
{
|
||||
"user" => Hash(userId: Bob),
|
||||
"generation" => Hash(generation: 2),
|
||||
"encryptionKey" => Hash(encryptionPublicKey: TestKeys.Alternate),
|
||||
"signingKey" => Hash(signingPublicKey: TestKeys.Alternate),
|
||||
"signature" => Hash(signature: AlternateSignature),
|
||||
"createdAt" => Hash(createdAt: CreatedAt.AddMilliseconds(1)),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(field), field, "Unknown field."),
|
||||
};
|
||||
|
||||
altered.ShouldNotBe(baseline);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubMillisecondPrecision_IsTruncatedAway()
|
||||
{
|
||||
// The stored column round-trips through PostgreSQL's microseconds. If the hash used finer
|
||||
// precision than the storage, no entry could ever reproduce its own hash after being read.
|
||||
Hash(createdAt: CreatedAt.AddTicks(9_999)).ShouldBe(Hash(createdAt: CreatedAt));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TruncateTimestamp_MatchesWhatTheHashUses()
|
||||
{
|
||||
var truncated = KeyLogChain.TruncateTimestamp(CreatedAt.AddTicks(9_999));
|
||||
|
||||
truncated.ToUnixTimeMilliseconds().ShouldBe(CreatedAt.ToUnixTimeMilliseconds());
|
||||
truncated.Ticks.ShouldBe(truncated.Ticks / TimeSpan.TicksPerMillisecond * TimeSpan.TicksPerMillisecond);
|
||||
truncated.Offset.ShouldBe(TimeSpan.Zero);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(31)]
|
||||
[InlineData(33)]
|
||||
public void ComputeEntryHash_RejectsAWrongLengthPreviousHash(int length)
|
||||
{
|
||||
Should.Throw<ArgumentException>(() => Hash(previousHash: new byte[length]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeEntryHash_RejectsAWrongLengthSignature()
|
||||
{
|
||||
Should.Throw<ArgumentException>(() => Hash(signature: new byte[32]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeEntryHash_RejectsAGenerationBelowOne()
|
||||
{
|
||||
Should.Throw<ArgumentOutOfRangeException>(() => Hash(generation: 0));
|
||||
}
|
||||
|
||||
private static byte[] AlternateSignature { get; } =
|
||||
[.. Enumerable.Range(0, CryptoSpec.SignatureSize).Select(i => (byte)(0xC0 + i))];
|
||||
|
||||
private static byte[] Signature { get; } =
|
||||
[.. Enumerable.Range(0, CryptoSpec.SignatureSize).Select(i => (byte)(0x80 + i))];
|
||||
|
||||
private static byte[] Hash(
|
||||
byte[]? previousHash = null,
|
||||
Guid? userId = null,
|
||||
int generation = 1,
|
||||
byte[]? encryptionPublicKey = null,
|
||||
byte[]? signingPublicKey = null,
|
||||
byte[]? signature = null,
|
||||
DateTimeOffset? createdAt = null) =>
|
||||
KeyLogChain.ComputeEntryHash(
|
||||
previousHash ?? KeyLogChain.CreateGenesisPreviousHash(),
|
||||
userId ?? Alice,
|
||||
generation,
|
||||
encryptionPublicKey ?? TestKeys.Encryption,
|
||||
signingPublicKey ?? TestKeys.Signing,
|
||||
signature ?? Signature,
|
||||
createdAt ?? CreatedAt);
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
using System.Buffers.Text;
|
||||
using System.Text;
|
||||
|
||||
namespace DodoSSH.Crypto.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The canonical key statement encoding and the nonce derived from it. See docs/crypto.md §7.1.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// These properties are what make the identity-provider binding checkable at all. The failure mode
|
||||
/// they guard against is nasty: a client and a server that encode differently produce different
|
||||
/// nonces, so every enrollment is rejected against a real provider while every local test passes.
|
||||
/// </remarks>
|
||||
public sealed class KeyStatementCodecTests
|
||||
{
|
||||
private static readonly DateTimeOffset CreatedAt = DateTimeOffset.FromUnixTimeMilliseconds(1_750_000_000_123);
|
||||
|
||||
[Fact]
|
||||
public void Encode_IsDeterministic()
|
||||
{
|
||||
var first = KeyStatementCodec.Encode(Statement());
|
||||
var second = KeyStatementCodec.Encode(Statement());
|
||||
|
||||
first.ShouldBe(second);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Encode_BeginsWithTheDomainLabel()
|
||||
{
|
||||
var encoding = KeyStatementCodec.Encode(Statement());
|
||||
|
||||
encoding.AsSpan(0, KeyStatementCodec.Label.Length)
|
||||
.SequenceEqual(KeyStatementCodec.Label)
|
||||
.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnAbsentEmail_EncodesDifferentlyFromAnEmptyOne()
|
||||
{
|
||||
// The presence byte exists for exactly this. Without it the encoding is not injective and
|
||||
// two genuinely different statements share a binding.
|
||||
var absent = KeyStatementCodec.Encode(Statement(email: null));
|
||||
var empty = KeyStatementCodec.Encode(Statement(email: string.Empty));
|
||||
|
||||
absent.ShouldNotBe(empty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheSameInstantInDifferentOffsets_EncodesIdentically()
|
||||
{
|
||||
// The timezone a client happens to hold must not change the hash the provider signs.
|
||||
var utc = KeyStatementCodec.Encode(Statement(createdAt: CreatedAt));
|
||||
var shifted = KeyStatementCodec.Encode(
|
||||
Statement(createdAt: CreatedAt.ToOffset(TimeSpan.FromHours(-7))));
|
||||
|
||||
utc.ShouldBe(shifted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubMillisecondPrecision_IsTruncatedAway()
|
||||
{
|
||||
// PostgreSQL stores microseconds. A statement that has been through the database must still
|
||||
// hash to what the client hashed before sending it.
|
||||
var exact = KeyStatementCodec.Encode(Statement(createdAt: CreatedAt));
|
||||
var noisy = KeyStatementCodec.Encode(Statement(createdAt: CreatedAt.AddTicks(9_999)));
|
||||
|
||||
exact.ShouldBe(noisy);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("issuer")]
|
||||
[InlineData("subject")]
|
||||
[InlineData("email")]
|
||||
[InlineData("deviceName")]
|
||||
[InlineData("version")]
|
||||
[InlineData("keyGeneration")]
|
||||
[InlineData("createdAt")]
|
||||
[InlineData("encryptionPublicKey")]
|
||||
[InlineData("signingPublicKey")]
|
||||
public void ChangingAnyField_ChangesTheBinding(string field)
|
||||
{
|
||||
var baseline = KeyStatementCodec.ComputeBinding(Statement());
|
||||
var altered = KeyStatementCodec.ComputeBinding(Mutate(field));
|
||||
|
||||
altered.ShouldNotBe(baseline);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MovingACharacterAcrossAFieldBoundary_ChangesTheBinding()
|
||||
{
|
||||
// The property length prefixes buy: no field value can forge a boundary. A delimited
|
||||
// encoding would give these two the same bytes.
|
||||
var left = KeyStatementCodec.ComputeBinding(
|
||||
Statement(issuer: "https://idp.example/a", subject: "bcd"));
|
||||
|
||||
var right = KeyStatementCodec.ComputeBinding(
|
||||
Statement(issuer: "https://idp.example/ab", subject: "cd"));
|
||||
|
||||
left.ShouldNotBe(right);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StringLengths_AreCountedInBytesNotCharacters()
|
||||
{
|
||||
// A device name whose UTF-8 length exceeds its character count must still round-trip, and
|
||||
// must not collide with a shorter one. A length prefix counting characters truncates here.
|
||||
var multiByte = Statement(deviceName: "büro — ThinkPad");
|
||||
var encoding = KeyStatementCodec.Encode(multiByte);
|
||||
|
||||
var expectedNameBytes = Encoding.UTF8.GetByteCount(multiByte.DeviceName);
|
||||
expectedNameBytes.ShouldBeGreaterThan(multiByte.DeviceName.Length);
|
||||
|
||||
// The name is the last field, so it occupies the tail of the encoding.
|
||||
Encoding.UTF8.GetString(encoding.AsSpan(encoding.Length - expectedNameBytes))
|
||||
.ShouldBe(multiByte.DeviceName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Nonce_IsUnpaddedBase64UrlOfTheBinding()
|
||||
{
|
||||
var statement = Statement();
|
||||
var binding = KeyStatementCodec.ComputeBinding(statement);
|
||||
var nonce = KeyStatementCodec.ComputeNonce(statement);
|
||||
|
||||
nonce.Length.ShouldBe(KeyStatementCodec.NonceLength);
|
||||
nonce.ShouldNotContain("=");
|
||||
nonce.ShouldNotContain("+");
|
||||
nonce.ShouldNotContain("/");
|
||||
|
||||
Base64Url.DecodeFromChars(nonce).ShouldBe(binding);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeBinding_AgreesBetweenBothOverloads()
|
||||
{
|
||||
var statement = Statement();
|
||||
|
||||
KeyStatementCodec.ComputeBinding(KeyStatementCodec.Encode(statement))
|
||||
.ShouldBe(KeyStatementCodec.ComputeBinding(statement));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(31)]
|
||||
[InlineData(33)]
|
||||
[InlineData(64)]
|
||||
public void Encode_RejectsAPublicKeyOfTheWrongLength(int length)
|
||||
{
|
||||
var statement = Statement() with { EncryptionPublicKey = new byte[length] };
|
||||
|
||||
Should.Throw<ArgumentOutOfRangeException>(() => KeyStatementCodec.Encode(statement));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-1)]
|
||||
[InlineData(65536)]
|
||||
public void Encode_RejectsAnUnusableVersion(int version)
|
||||
{
|
||||
var statement = Statement() with { Version = version };
|
||||
|
||||
Should.Throw<ArgumentOutOfRangeException>(() => KeyStatementCodec.Encode(statement));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Encode_RejectsAGenerationBelowOne()
|
||||
{
|
||||
var statement = Statement() with { KeyGeneration = 0 };
|
||||
|
||||
Should.Throw<ArgumentOutOfRangeException>(() => KeyStatementCodec.Encode(statement));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToNonce_RejectsSomethingThatIsNotADigest()
|
||||
{
|
||||
Should.Throw<ArgumentException>(() => KeyStatementCodec.ToNonce(new byte[16]));
|
||||
}
|
||||
|
||||
private static KeyStatementFields Statement(
|
||||
string issuer = "https://idp.example/realms/dodossh",
|
||||
string subject = "alice-subject",
|
||||
string? email = "alice@example.com",
|
||||
string deviceName = "alice-laptop",
|
||||
int keyGeneration = 1,
|
||||
DateTimeOffset? createdAt = null) =>
|
||||
new(
|
||||
Version: 1,
|
||||
Issuer: issuer,
|
||||
Subject: subject,
|
||||
Email: email,
|
||||
EncryptionPublicKey: TestKeys.Encryption,
|
||||
SigningPublicKey: TestKeys.Signing,
|
||||
KeyGeneration: keyGeneration,
|
||||
CreatedAt: createdAt ?? CreatedAt,
|
||||
DeviceName: deviceName);
|
||||
|
||||
private static KeyStatementFields Mutate(string field) => field switch
|
||||
{
|
||||
"issuer" => Statement(issuer: "https://idp.example/realms/other"),
|
||||
"subject" => Statement(subject: "bob-subject"),
|
||||
"email" => Statement(email: "bob@example.com"),
|
||||
"deviceName" => Statement(deviceName: "bob-desktop"),
|
||||
"version" => Statement() with { Version = 2 },
|
||||
"keyGeneration" => Statement(keyGeneration: 2),
|
||||
"createdAt" => Statement(createdAt: CreatedAt.AddSeconds(1)),
|
||||
"encryptionPublicKey" => Statement() with { EncryptionPublicKey = TestKeys.Alternate },
|
||||
"signingPublicKey" => Statement() with { SigningPublicKey = TestKeys.Alternate },
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(field), field, "Unknown field."),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Fixed public-key bytes, so encodings are reproducible.</summary>
|
||||
internal static class TestKeys
|
||||
{
|
||||
internal static byte[] Encryption { get; } =
|
||||
[.. Enumerable.Range(0, CryptoSpec.PublicKeySize).Select(i => (byte)(0x40 + i))];
|
||||
|
||||
internal static byte[] Signing { get; } =
|
||||
[.. Enumerable.Range(0, CryptoSpec.PublicKeySize).Select(i => (byte)(0x60 + i))];
|
||||
|
||||
internal static byte[] Alternate { get; } =
|
||||
[.. Enumerable.Range(0, CryptoSpec.PublicKeySize).Select(i => (byte)(0xA0 + i))];
|
||||
}
|
||||
Vendored
+116
@@ -186,5 +186,121 @@
|
||||
"ed25519PublicKey": "606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f",
|
||||
"fingerprint": "fe8d8673f517688bf0d5d9b812327619a303c765af1f47dbd6a777db193c36e5"
|
||||
}
|
||||
],
|
||||
"keyStatement": [
|
||||
{
|
||||
"name": "with-email",
|
||||
"version": 1,
|
||||
"issuer": "https://idp.example/realms/dodossh",
|
||||
"subject": "alice-subject",
|
||||
"email": "alice@example.com",
|
||||
"keyGeneration": 1,
|
||||
"createdAtUnixMilliseconds": 1750000000123,
|
||||
"deviceName": "alice-laptop",
|
||||
"x25519PublicKey": "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f",
|
||||
"ed25519PublicKey": "606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f",
|
||||
"canonicalEncoding": "647368312f6b657973746174656d656e742f7631000100000001000001977420dc7b404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f010000002268747470733a2f2f6964702e6578616d706c652f7265616c6d732f646f646f737368010000000d616c6963652d7375626a6563740100000011616c696365406578616d706c652e636f6d010000000c616c6963652d6c6170746f70",
|
||||
"binding": "2540115460ae00848233d56a19e6c8e48fafcfe96de649b63a325946524aa294",
|
||||
"nonce": "JUARVGCuAISCM9VqGebI5I-vz-lt5km2OjJZRlJKopQ"
|
||||
},
|
||||
{
|
||||
"name": "without-email",
|
||||
"version": 1,
|
||||
"issuer": "https://idp.example/realms/dodossh",
|
||||
"subject": "alice-subject",
|
||||
"email": null,
|
||||
"keyGeneration": 1,
|
||||
"createdAtUnixMilliseconds": 1750000000123,
|
||||
"deviceName": "alice-laptop",
|
||||
"x25519PublicKey": "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f",
|
||||
"ed25519PublicKey": "606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f",
|
||||
"canonicalEncoding": "647368312f6b657973746174656d656e742f7631000100000001000001977420dc7b404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f010000002268747470733a2f2f6964702e6578616d706c652f7265616c6d732f646f646f737368010000000d616c6963652d7375626a65637400010000000c616c6963652d6c6170746f70",
|
||||
"binding": "e1d73073fe20c35ec4afbd89c7d03525aebe34047dd4695970de95fb4c845be4",
|
||||
"nonce": "4dcwc_4gw17Er72Jx9A1Ja6-NAR91GlZcN6V-0yEW-Q"
|
||||
},
|
||||
{
|
||||
"name": "empty-email",
|
||||
"version": 1,
|
||||
"issuer": "https://idp.example/realms/dodossh",
|
||||
"subject": "alice-subject",
|
||||
"email": "",
|
||||
"keyGeneration": 1,
|
||||
"createdAtUnixMilliseconds": 1750000000123,
|
||||
"deviceName": "alice-laptop",
|
||||
"x25519PublicKey": "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f",
|
||||
"ed25519PublicKey": "606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f",
|
||||
"canonicalEncoding": "647368312f6b657973746174656d656e742f7631000100000001000001977420dc7b404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f010000002268747470733a2f2f6964702e6578616d706c652f7265616c6d732f646f646f737368010000000d616c6963652d7375626a6563740100000000010000000c616c6963652d6c6170746f70",
|
||||
"binding": "1106dc5bc7da7941ab565b40ec1a32d36472bac3ced03b5013ed0375d19b4585",
|
||||
"nonce": "EQbcW8faeUGrVltA7Boy02RyusPO0DtQE-0DddGbRYU"
|
||||
},
|
||||
{
|
||||
"name": "unicode-device-name",
|
||||
"version": 1,
|
||||
"issuer": "https://idp.example/realms/dodossh",
|
||||
"subject": "alice-subject",
|
||||
"email": "alice@example.com",
|
||||
"keyGeneration": 1,
|
||||
"createdAtUnixMilliseconds": 1750000000123,
|
||||
"deviceName": "alice\u0027s ThinkPad \u2014 b\u00FCro",
|
||||
"x25519PublicKey": "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f",
|
||||
"ed25519PublicKey": "606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f",
|
||||
"canonicalEncoding": "647368312f6b657973746174656d656e742f7631000100000001000001977420dc7b404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f010000002268747470733a2f2f6964702e6578616d706c652f7265616c6d732f646f646f737368010000000d616c6963652d7375626a6563740100000011616c696365406578616d706c652e636f6d010000001a616c6963652773205468696e6b50616420e280942062c3bc726f",
|
||||
"binding": "dbf47dd3bbce9b94b42b815758dfe599841aeca0a042da9b778036ff53f4d536",
|
||||
"nonce": "2_R907vOm5S0K4FXWN_lmYQa7KCgQtqbd4A2_1P01TY"
|
||||
},
|
||||
{
|
||||
"name": "offset-and-sub-millisecond-timestamp",
|
||||
"version": 1,
|
||||
"issuer": "https://idp.example/realms/dodossh",
|
||||
"subject": "alice-subject",
|
||||
"email": "alice@example.com",
|
||||
"keyGeneration": 1,
|
||||
"createdAtUnixMilliseconds": 1750000000123,
|
||||
"deviceName": "alice-laptop",
|
||||
"x25519PublicKey": "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f",
|
||||
"ed25519PublicKey": "606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f",
|
||||
"canonicalEncoding": "647368312f6b657973746174656d656e742f7631000100000001000001977420dc7b404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f010000002268747470733a2f2f6964702e6578616d706c652f7265616c6d732f646f646f737368010000000d616c6963652d7375626a6563740100000011616c696365406578616d706c652e636f6d010000000c616c6963652d6c6170746f70",
|
||||
"binding": "2540115460ae00848233d56a19e6c8e48fafcfe96de649b63a325946524aa294",
|
||||
"nonce": "JUARVGCuAISCM9VqGebI5I-vz-lt5km2OjJZRlJKopQ"
|
||||
},
|
||||
{
|
||||
"name": "later-generation",
|
||||
"version": 1,
|
||||
"issuer": "https://idp.example/realms/dodossh",
|
||||
"subject": "alice-subject",
|
||||
"email": "alice@example.com",
|
||||
"keyGeneration": 4,
|
||||
"createdAtUnixMilliseconds": 1750000000123,
|
||||
"deviceName": "alice-laptop",
|
||||
"x25519PublicKey": "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f",
|
||||
"ed25519PublicKey": "606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f",
|
||||
"canonicalEncoding": "647368312f6b657973746174656d656e742f7631000100000004000001977420dc7b404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f010000002268747470733a2f2f6964702e6578616d706c652f7265616c6d732f646f646f737368010000000d616c6963652d7375626a6563740100000011616c696365406578616d706c652e636f6d010000000c616c6963652d6c6170746f70",
|
||||
"binding": "2c760b871347d072ee58c407a710cb9b8cc53bd36dc698a955ae7a708db386ee",
|
||||
"nonce": "LHYLhxNH0HLuWMQHpxDLm4zFO9NtxpipVa56cI2zhu4"
|
||||
}
|
||||
],
|
||||
"keyLog": [
|
||||
{
|
||||
"name": "genesis",
|
||||
"previousHash": "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"userId": "0192f0c8-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
|
||||
"generation": 1,
|
||||
"x25519PublicKey": "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f",
|
||||
"ed25519PublicKey": "606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f",
|
||||
"statementSignature": "808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf",
|
||||
"createdAtUnixMilliseconds": 1750000000123,
|
||||
"hash": "de9419c582e4729b937f4e2f5043ed4ee51f5b2e6297e65ffea50e30e3e67d57"
|
||||
},
|
||||
{
|
||||
"name": "second-entry",
|
||||
"previousHash": "de9419c582e4729b937f4e2f5043ed4ee51f5b2e6297e65ffea50e30e3e67d57",
|
||||
"userId": "0192f0c8-1a2b-7c3d-8e4f-5a6b7c8d9e10",
|
||||
"generation": 1,
|
||||
"x25519PublicKey": "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f",
|
||||
"ed25519PublicKey": "606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f",
|
||||
"statementSignature": "808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf",
|
||||
"createdAtUnixMilliseconds": 1750000000123,
|
||||
"hash": "e0ac141a9113afa2e4ce5e9562641f706449a52bc63f5249baa7fa9bd8326187"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user