Files
MoShiro/backend/fleet/tests/Fleet.Tests/OperatorTrustTests.cs
T

196 lines
10 KiB
C#

using System.Buffers.Text;
using System.Security.Cryptography;
using System.Text;
using Fleet.Identity;
namespace Fleet.Tests;
/// <summary>软件 passkey:只做断言(navigator.credentials.get 的结果),用来测信任链。</summary>
internal sealed class TestAuthenticator : IDisposable
{
public const string RpId = "panel.example.com";
public const string Origin = "https://panel.example.com";
private readonly ECDsa _key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
private uint _counter;
public string CredentialId { get; } = Base64Url.EncodeToString(RandomNumberGenerator.GetBytes(32));
public OperatorKey Key => new(CredentialId, _key.ExportSubjectPublicKeyInfo());
public SignedStatement Sign(string statement, byte flags = 0x05, string origin = Origin)
{
byte[] challenge = SHA256.HashData(Encoding.UTF8.GetBytes(statement));
_counter++;
byte[] authData = [.. SHA256.HashData(Encoding.UTF8.GetBytes(RpId)), flags, (byte)(_counter >> 24), (byte)(_counter >> 16), (byte)(_counter >> 8), (byte)_counter];
byte[] clientData = Encoding.UTF8.GetBytes($"{{\"type\":\"webauthn.get\",\"challenge\":\"{Base64Url.EncodeToString(challenge)}\",\"origin\":\"{origin}\",\"crossOrigin\":false}}");
byte[] signature = _key.SignData([.. authData, .. SHA256.HashData(clientData)], HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence);
return new SignedStatement(statement, CredentialId, Base64Url.EncodeToString(authData), Base64Url.EncodeToString(clientData), Base64Url.EncodeToString(signature));
}
public void Dispose() => _key.Dispose();
}
public sealed class OperatorTrustTests : IDisposable
{
private readonly TestAuthenticator _alice = new();
private readonly TestAuthenticator _bob = new();
public void Dispose()
{
_alice.Dispose();
_bob.Dispose();
}
private OperatorSet Genesis(params TestAuthenticator[] keys) =>
new(1, OperatorTrust.Genesis, TestAuthenticator.RpId, [TestAuthenticator.Origin], [.. keys.Select(k => k.Key)]);
private SignedStatement SignedGenesis(OperatorSet set, TestAuthenticator signer) => signer.Sign(OperatorTrust.Encode(set));
[Fact]
public void TheGenesis_IsPinnedByItsFingerprint_AndMustBeSelfSigned()
{
OperatorSet set = Genesis(_alice);
SignedStatement genesis = SignedGenesis(set, _alice);
string pin = OperatorTrust.Fingerprint(genesis.Statement);
Assert.NotNull(OperatorTrust.VerifyChain(pin, [genesis]));
Assert.Null(OperatorTrust.VerifyChain(new string('0', 64), [genesis])); // 指纹对不上:Backend 换了一份创世
Assert.Null(OperatorTrust.VerifyChain(pin, [SignedGenesis(set, _bob)])); // 不是集合里的凭证签的(也改不了指纹)
Assert.Null(OperatorTrust.VerifyChain(pin, []));
}
[Fact]
public void ABackendThatSwapsTheKeys_CannotMakeAChainTheNodeAccepts()
{
SignedStatement real = SignedGenesis(Genesis(_alice), _alice);
string pin = OperatorTrust.Fingerprint(real.Statement);
using var attacker = new TestAuthenticator();
SignedStatement forged = SignedGenesis(Genesis(attacker), attacker); // 自洽的假创世,但指纹不同
Assert.Null(OperatorTrust.VerifyChain(pin, [forged]));
// 在真创世后面接一个攻击者签的更新:上一版里没有他的凭证
var update = new OperatorSet(2, OperatorTrust.Fingerprint(real.Statement), TestAuthenticator.RpId, [TestAuthenticator.Origin], [_alice.Key, attacker.Key]);
Assert.Null(OperatorTrust.VerifyChain(pin, [real, attacker.Sign(OperatorTrust.Encode(update))]));
}
[Fact]
public void AnUpdate_NeedsAKeyOfThePreviousVersion_AndFollowsTheChain()
{
SignedStatement genesis = SignedGenesis(Genesis(_alice), _alice);
string pin = OperatorTrust.Fingerprint(genesis.Statement);
var second = new OperatorSet(2, OperatorTrust.Fingerprint(genesis.Statement), TestAuthenticator.RpId, [TestAuthenticator.Origin], [_alice.Key, _bob.Key]);
SignedStatement signedSecond = _alice.Sign(OperatorTrust.Encode(second));
OperatorSet? current = OperatorTrust.VerifyChain(pin, [genesis, signedSecond]);
Assert.Equal(2, current?.Version);
Assert.Equal(2, current?.Keys.Count);
// 新加的 bob 可以签下一版(上一版里有他)
var third = new OperatorSet(3, OperatorTrust.Fingerprint(signedSecond.Statement), TestAuthenticator.RpId, [TestAuthenticator.Origin], [_bob.Key]);
Assert.Equal(3, OperatorTrust.VerifyChain(pin, [genesis, signedSecond, _bob.Sign(OperatorTrust.Encode(third))])?.Version);
// 版本号跳了、prev 不对、域名换了:都不行
var skipped = second with { Version = 3 };
Assert.Null(OperatorTrust.VerifyChain(pin, [genesis, _alice.Sign(OperatorTrust.Encode(skipped))]));
var wrongPrev = second with { Previous = new string('a', 64) };
Assert.Null(OperatorTrust.VerifyChain(pin, [genesis, _alice.Sign(OperatorTrust.Encode(wrongPrev))]));
var otherRp = second with { RpId = "evil.example" };
Assert.Null(OperatorTrust.VerifyChain(pin, [genesis, _alice.Sign(OperatorTrust.Encode(otherRp))]));
}
[Fact]
public void AnAssertion_NeedsUserVerification_TheRightOrigin_AndTheExactStatement()
{
OperatorSet set = Genesis(_alice);
Assert.True(OperatorTrust.VerifyOperator(_alice.Sign("hello"), set));
Assert.False(OperatorTrust.VerifyOperator(_alice.Sign("hello", flags: 0x01), set)); // 没有用户验证
Assert.False(OperatorTrust.VerifyOperator(_alice.Sign("hello", origin: "https://evil.example"), set));
Assert.False(OperatorTrust.VerifyOperator(_alice.Sign("hello") with { Statement = "hellO" }, set)); // 语句被改
Assert.False(OperatorTrust.VerifyOperator(_bob.Sign("hello"), set)); // 不在集合里
}
private sealed record Enrolled(OperatorSet Set, byte[] Seed, MemberProof Proof, byte[] NodeKey, byte[] PeerKey, string Auth);
private Enrolled Enroll(string role = "probe", long now = 1_000_000, long notBefore = 0, long notAfter = 2_000_000, TestAuthenticator? signer = null)
{
OperatorSet set = Genesis(_alice);
using ECDsa code = ECDsa.Create(ECCurve.NamedCurves.nistP256);
byte[] seed = code.ExportParameters(true).D!;
string auth = OperatorTrust.AuthToken(seed);
var grant = new Grant(role, OperatorTrust.AuthHash(auth), code.ExportSubjectPublicKeyInfo(), notBefore, notAfter, 1);
SignedStatement signedGrant = (signer ?? _alice).Sign(OperatorTrust.Encode(grant));
byte[] nodeKey = RandomNumberGenerator.GetBytes(91);
byte[] peerKey = RandomNumberGenerator.GetBytes(91);
using ECDsa? signing = OperatorTrust.CodeSigningKey(seed, grant.CodeKey);
Assert.NotNull(signing);
string signature = OperatorTrust.SignBinding(signing, new Binding("tokyo", role, nodeKey, peerKey, grant.AuthHash, now), out string statement);
return new Enrolled(set, seed, new MemberProof(signedGrant, statement, signature), nodeKey, peerKey, auth);
}
[Fact]
public void AMember_VerifiesThroughTheWholeChain()
{
Enrolled e = Enroll();
Assert.Equal(TrustFailure.None, OperatorTrust.VerifyMember(e.Set, e.Proof, e.NodeKey, e.PeerKey, "probe"));
}
[Fact]
public void TheSeed_IsNotRecoverableFromWhatTheBackendSees()
{
Enrolled e = Enroll();
Assert.StartsWith("ja_", e.Auth, StringComparison.Ordinal);
Assert.DoesNotContain(Base64Url.EncodeToString(e.Seed), e.Auth, StringComparison.Ordinal);
Assert.DoesNotContain(Base64Url.EncodeToString(e.Seed), e.Proof.Grant.Statement + e.Proof.BindingStatement, StringComparison.Ordinal);
Assert.Equal(e.Auth, OperatorTrust.AuthToken(e.Seed)); // 节点自己能算出同一个
}
[Fact]
public void ABindingForOtherKeys_AnotherRole_OrAnExpiredGrant_IsRefused()
{
Enrolled e = Enroll();
Assert.Equal(TrustFailure.KeyMismatch, OperatorTrust.VerifyMember(e.Set, e.Proof, RandomNumberGenerator.GetBytes(91), e.PeerKey, "probe")); // Backend 想把别的节点密钥挂在这个绑定上
Assert.Equal(TrustFailure.KeyMismatch, OperatorTrust.VerifyMember(e.Set, e.Proof, e.NodeKey, RandomNumberGenerator.GetBytes(91), "probe"));
Assert.Equal(TrustFailure.RoleMismatch, OperatorTrust.VerifyMember(e.Set, e.Proof, e.NodeKey, e.PeerKey, "agent")); // 一个 Probe 的绑定不能冒充 Agent
Enrolled late = Enroll(now: 3_000_000, notAfter: 2_000_000); // 有效期之后才签的绑定
Assert.Equal(TrustFailure.GrantExpired, OperatorTrust.VerifyMember(late.Set, late.Proof, late.NodeKey, late.PeerKey, "probe"));
}
[Fact]
public void AGrantNotSignedByTheOperator_OrABindingNotSignedByTheCodeKey_IsRefused()
{
using var stranger = new TestAuthenticator();
Enrolled forged = Enroll(signer: stranger);
Assert.Equal(TrustFailure.GrantNotSigned, OperatorTrust.VerifyMember(forged.Set, forged.Proof, forged.NodeKey, forged.PeerKey, "probe"));
// Backend 拿到了委托但没有种子:用自己的密钥签绑定
Enrolled e = Enroll();
using ECDsa backend = ECDsa.Create(ECCurve.NamedCurves.nistP256);
Binding stolen = OperatorTrust.ParseBinding(e.Proof.BindingStatement)!;
string bogus = OperatorTrust.SignBinding(backend, stolen, out string statement);
Assert.Equal(TrustFailure.BindingInvalid, OperatorTrust.VerifyMember(e.Set, new MemberProof(e.Proof.Grant, statement, bogus), e.NodeKey, e.PeerKey, "probe"));
}
[Fact]
public void RemovingAnOperatorKey_InvalidatesTheGrantsItSigned()
{
Enrolled e = Enroll();
OperatorSet withoutAlice = Genesis(_bob) with { Version = 2, Previous = new string('a', 64) };
Assert.Equal(TrustFailure.GrantNotSigned, OperatorTrust.VerifyMember(withoutAlice, e.Proof, e.NodeKey, e.PeerKey, "probe"));
}
[Fact]
public void Parsing_IsStrict()
{
Assert.Null(OperatorTrust.ParseGrant("moshiro-grant-v1\nrole=probe"));
Assert.Null(OperatorTrust.ParseBinding("moshiro-binding-v1\nname=x"));
Assert.Null(OperatorTrust.ParseSet("moshiro-opset-v2\nversion=1"));
OperatorSet set = Genesis(_alice);
Assert.Equal(set.Keys[0].CredentialId, OperatorTrust.ParseSet(OperatorTrust.Encode(set))!.Keys[0].CredentialId);
Assert.Null(OperatorTrust.ParseSet(OperatorTrust.Encode(set with { Previous = "abc" }))); // 创世的 prev 必须是 0
}
}