diff --git a/backend/fleet/src/Fleet.Identity/OperatorTrust.cs b/backend/fleet/src/Fleet.Identity/OperatorTrust.cs
new file mode 100644
index 0000000..980b5a5
--- /dev/null
+++ b/backend/fleet/src/Fleet.Identity/OperatorTrust.cs
@@ -0,0 +1,322 @@
+using System.Buffers.Text;
+using System.Globalization;
+using System.Security.Cryptography;
+using System.Text;
+
+namespace Fleet.Identity;
+
+/// passkey(WebAuthn)对一段文本签出的断言:challenge = SHA-256(Statement 的 UTF-8)。四个字段都是 base64url。
+public sealed record SignedStatement(string Statement, string CredentialId, string AuthenticatorData, string ClientDataJson, string Signature);
+
+/// 一个操作员凭证:passkey 的凭证 id 和公钥(SPKI)。
+public sealed record OperatorKey(string CredentialId, byte[] PublicKeySpki);
+
+///
+/// 操作员集合的一个版本。RpId 和 Origins 也在里面:节点验 WebAuthn 断言要用,而它们被操作员的签名覆盖,Backend 改不了。
+/// 创世版本(1)由它自己的某把凭证签名,指纹()在安装时固定到节点;之后每个版本由上一版里的凭证签名。
+///
+public sealed record OperatorSet(int Version, string Previous, string RpId, IReadOnlyList Origins, IReadOnlyList Keys);
+
+/// 委托:操作员授权了一个加入码。CodeKey 是这个码的公钥(SPKI),码的种子是对应的私钥,只在安装命令里。
+public sealed record Grant(string Role, string AuthHash, byte[] CodeKey, long NotBeforeMs, long NotAfterMs, int Uses);
+
+/// 节点绑定:用码的私钥签的,把节点的两把公钥和这个码连起来。AtMs 必须落在委托的有效期内。
+public sealed record Binding(string Name, string Role, byte[] NodePublicKey, byte[] PeerPublicKey, string AuthHash, long AtMs);
+
+/// 一个成员带的证明:委托(passkey 签)加绑定(码的私钥签)。
+public sealed record MemberProof(SignedStatement Grant, string BindingStatement, string BindingSignature);
+
+public enum TrustFailure
+{
+ None,
+ Malformed,
+ BrokenChain,
+ GrantNotSigned,
+ GrantExpired,
+ BindingInvalid,
+ KeyMismatch,
+ RoleMismatch,
+}
+
+///
+/// 信任根:操作员(passkey)→ 操作员集合 → 委托 → 绑定 → 节点公钥。设计见 docs/access.md 第 7 节。
+/// 全是纯函数,只用 BCL,节点和 Backend 共用:节点用它确认「对端是我的操作员批准的」,Backend 用它拒绝没有合法绑定的注册。
+/// 语句是 key=value 的文本,第一行是带版本的标题;签名覆盖整段文本,规范化靠固定的字段顺序和严格的解析。
+///
+public static class OperatorTrust
+{
+ public const string SetHeader = "moshiro-opset-v1";
+ public const string GrantHeader = "moshiro-grant-v1";
+ public const string BindingHeader = "moshiro-binding-v1";
+ public const string Genesis = "0";
+
+ private const string AuthLabel = "moshiro-join-auth-v1";
+
+ // ───────────────────────── 编码 ─────────────────────────
+
+ public static string Encode(OperatorSet set)
+ {
+ var text = new StringBuilder();
+ text.Append(SetHeader).Append('\n')
+ .Append("version=").Append(set.Version.ToString(CultureInfo.InvariantCulture)).Append('\n')
+ .Append("prev=").Append(set.Previous).Append('\n')
+ .Append("rp=").Append(set.RpId).Append('\n')
+ .Append("origins=").Append(string.Join(',', set.Origins));
+ foreach (OperatorKey key in set.Keys)
+ {
+ text.Append("\nkey=").Append(key.CredentialId).Append(':').Append(Convert.ToBase64String(key.PublicKeySpki));
+ }
+
+ return text.ToString();
+ }
+
+ public static string Encode(Grant grant) =>
+ string.Join(
+ '\n', GrantHeader, "role=" + grant.Role, "auth=" + grant.AuthHash, "key=" + Convert.ToBase64String(grant.CodeKey),
+ "nb=" + grant.NotBeforeMs.ToString(CultureInfo.InvariantCulture), "na=" + grant.NotAfterMs.ToString(CultureInfo.InvariantCulture), "uses=" + grant.Uses.ToString(CultureInfo.InvariantCulture));
+
+ public static string Encode(Binding binding) =>
+ string.Join(
+ '\n', BindingHeader, "name=" + binding.Name, "role=" + binding.Role, "pub=" + Convert.ToBase64String(binding.NodePublicKey),
+ "peer=" + Convert.ToBase64String(binding.PeerPublicKey), "auth=" + binding.AuthHash, "at=" + binding.AtMs.ToString(CultureInfo.InvariantCulture));
+
+ // ───────────────────────── 解析(严格:多一行、少一行、顺序不对都算坏) ─────────────────────────
+
+ public static OperatorSet? ParseSet(string text)
+ {
+ string[] lines = text.Split('\n');
+ if (lines.Length < 6 || lines[0] != SetHeader || !TryValue(lines[1], "version", out string version) || !int.TryParse(version, NumberStyles.None, CultureInfo.InvariantCulture, out int number) || number < 1
+ || !TryValue(lines[2], "prev", out string previous) || !TryValue(lines[3], "rp", out string rp) || rp.Length == 0 || !TryValue(lines[4], "origins", out string origins) || origins.Length == 0)
+ {
+ return null;
+ }
+
+ var keys = new List();
+ foreach (string line in lines.Skip(5))
+ {
+ if (!TryValue(line, "key", out string value) || value.IndexOf(':', StringComparison.Ordinal) is not (> 0 and int split) || !TryBase64(value[(split + 1)..], out byte[] spki))
+ {
+ return null;
+ }
+
+ keys.Add(new OperatorKey(value[..split], spki));
+ }
+
+ return keys.Count == 0 || (number == 1) != (previous == Genesis) ? null : new OperatorSet(number, previous, rp, origins.Split(','), keys);
+ }
+
+ public static Grant? ParseGrant(string text)
+ {
+ string[] lines = text.Split('\n');
+ if (lines.Length != 7 || lines[0] != GrantHeader || !TryValue(lines[1], "role", out string role) || !TryValue(lines[2], "auth", out string auth) || !TryValue(lines[3], "key", out string key) || !TryBase64(key, out byte[] codeKey)
+ || !TryLong(lines[4], "nb", out long notBefore) || !TryLong(lines[5], "na", out long notAfter) || !TryValue(lines[6], "uses", out string uses) || !int.TryParse(uses, NumberStyles.None, CultureInfo.InvariantCulture, out int count)
+ || role.Length == 0 || auth.Length != 64)
+ {
+ return null;
+ }
+
+ return new Grant(role, auth, codeKey, notBefore, notAfter, count);
+ }
+
+ public static Binding? ParseBinding(string text)
+ {
+ string[] lines = text.Split('\n');
+ if (lines.Length != 7 || lines[0] != BindingHeader || !TryValue(lines[1], "name", out string name) || !TryValue(lines[2], "role", out string role) || !TryValue(lines[3], "pub", out string pub) || !TryBase64(pub, out byte[] node)
+ || !TryValue(lines[4], "peer", out string peer) || !TryBase64(peer, out byte[] peerKey) || !TryValue(lines[5], "auth", out string auth) || !TryLong(lines[6], "at", out long at) || auth.Length != 64)
+ {
+ return null;
+ }
+
+ return new Binding(name, role, node, peerKey, auth, at);
+ }
+
+ // ───────────────────────── 指纹与验证 ─────────────────────────
+
+ /// 一段语句的指纹(SHA-256,小写十六进制)。创世版本的指纹就是安装命令里固定的那个。
+ public static string Fingerprint(string statement) => Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(statement)));
+
+ ///
+ /// 验证操作员集合的链:第一项是创世版本(指纹等于 ,自签),其后每一项由上一版里的凭证签名、版本加一、prev 指向上一版的指纹、域名不变。
+ /// 返回最后一版;不对返回 null。
+ ///
+ public static OperatorSet? VerifyChain(string pinnedGenesis, IReadOnlyList chain)
+ {
+ if (chain.Count == 0 || !string.Equals(Fingerprint(chain[0].Statement), pinnedGenesis, StringComparison.OrdinalIgnoreCase))
+ {
+ return null;
+ }
+
+ OperatorSet? current = null;
+ string? previousFingerprint = null;
+ foreach (SignedStatement link in chain)
+ {
+ OperatorSet? next = ParseSet(link.Statement);
+ if (next is null)
+ {
+ return null;
+ }
+
+ if (current is null)
+ {
+ if (next.Version != 1 || !VerifyOperator(link, next))
+ {
+ return null;
+ }
+ }
+ else if (next.Version != current.Version + 1 || next.Previous != previousFingerprint || next.RpId != current.RpId || !VerifyOperator(link, current))
+ {
+ return null;
+ }
+
+ current = next;
+ previousFingerprint = Fingerprint(link.Statement);
+ }
+
+ return current;
+ }
+
+ /// 断言是不是这个集合里某把凭证对 Statement 签的(要求用户验证;挑战是语句的 SHA-256)。
+ public static bool VerifyOperator(SignedStatement signed, OperatorSet set)
+ {
+ OperatorKey? key = set.Keys.FirstOrDefault(k => k.CredentialId == signed.CredentialId);
+ if (key is null || !TryDecode(signed.AuthenticatorData, out byte[] authData) || !TryDecode(signed.ClientDataJson, out byte[] clientData) || !TryDecode(signed.Signature, out byte[] signature))
+ {
+ return false;
+ }
+
+ return WebAuthnAssertion.Verify(key.PublicKeySpki, authData, clientData, signature, set.RpId, SHA256.HashData(Encoding.UTF8.GetBytes(signed.Statement)), set.Origins.Contains, requireUserVerification: true, out _);
+ }
+
+ ///
+ /// 验证一个成员:委托由当前操作员集合里的凭证签(并且在有效期内),绑定由委托里的码公钥签,
+ /// 绑定里的节点公钥、对等面公钥和角色对得上调用方手里的这个成员。
+ ///
+ public static TrustFailure VerifyMember(OperatorSet current, MemberProof proof, ReadOnlySpan nodePublicKey, ReadOnlySpan peerPublicKey, string role)
+ {
+ Grant? grant = ParseGrant(proof.Grant.Statement);
+ Binding? binding = ParseBinding(proof.BindingStatement);
+ if (grant is null || binding is null || !TryDecode(proof.BindingSignature, out byte[] bindingSignature))
+ {
+ return TrustFailure.Malformed;
+ }
+
+ if (!VerifyOperator(proof.Grant, current))
+ {
+ return TrustFailure.GrantNotSigned;
+ }
+
+ if (binding.AtMs < grant.NotBeforeMs || binding.AtMs > grant.NotAfterMs)
+ {
+ return TrustFailure.GrantExpired;
+ }
+
+ if (binding.AuthHash != grant.AuthHash || binding.Role != grant.Role)
+ {
+ return TrustFailure.KeyMismatch;
+ }
+
+ if (!binding.NodePublicKey.AsSpan().SequenceEqual(nodePublicKey) || !binding.PeerPublicKey.AsSpan().SequenceEqual(peerPublicKey))
+ {
+ return TrustFailure.KeyMismatch;
+ }
+
+ if (grant.Role != role)
+ {
+ return TrustFailure.RoleMismatch;
+ }
+
+ try
+ {
+ using ECDsa codeKey = ECDsa.Create();
+ codeKey.ImportSubjectPublicKeyInfo(grant.CodeKey, out _);
+ return codeKey.VerifyData(Encoding.UTF8.GetBytes(proof.BindingStatement), bindingSignature, HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence)
+ ? TrustFailure.None
+ : TrustFailure.BindingInvalid;
+ }
+ catch (CryptographicException)
+ {
+ return TrustFailure.BindingInvalid;
+ }
+ }
+
+ // ───────────────────────── 加入码的种子 ─────────────────────────
+
+ ///
+ /// 种子(32 字节,就是码的 P-256 私钥)派生出交给 Backend 的认证串:Backend 只会看到它的哈希,推不回种子,
+ /// 所以 Backend 没法替这个码签绑定。HKDF-SHA256,空盐,用途标签在 info 里;浏览器端的 WebCrypto 用同样的参数。
+ ///
+ public static string AuthToken(ReadOnlySpan seed) =>
+ "ja_" + Base64Url.EncodeToString(Derive(seed));
+
+ private static byte[] Derive(ReadOnlySpan seed)
+ {
+ byte[] output = new byte[32];
+ HKDF.DeriveKey(HashAlgorithmName.SHA256, seed, output, ReadOnlySpan.Empty, Encoding.UTF8.GetBytes(AuthLabel));
+ return output;
+ }
+
+ public static string AuthHash(string authToken) => Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(authToken)));
+
+ /// 用种子和委托里的码公钥还原码的签名密钥(公钥从已验证的委托里来,种子里只有私钥标量)。
+ public static ECDsa? CodeSigningKey(ReadOnlySpan seed, ReadOnlySpan codeKeySpki)
+ {
+ try
+ {
+ using ECDsa publicOnly = ECDsa.Create();
+ publicOnly.ImportSubjectPublicKeyInfo(codeKeySpki, out _);
+ ECParameters parameters = publicOnly.ExportParameters(false);
+ parameters.D = seed.ToArray();
+ return ECDsa.Create(parameters);
+ }
+ catch (CryptographicException)
+ {
+ return null;
+ }
+ }
+
+ public static string SignBinding(ECDsa codeKey, Binding binding, out string statement)
+ {
+ statement = Encode(binding);
+ return Base64Url.EncodeToString(codeKey.SignData(Encoding.UTF8.GetBytes(statement), HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence));
+ }
+
+ // ───────────────────────── 小工具 ─────────────────────────
+
+ private static bool TryValue(string line, string key, out string value)
+ {
+ value = "";
+ if (!line.StartsWith(key + "=", StringComparison.Ordinal))
+ {
+ return false;
+ }
+
+ value = line[(key.Length + 1)..];
+ return true;
+ }
+
+ private static bool TryLong(string line, string key, out long value)
+ {
+ value = 0;
+ return TryValue(line, key, out string text) && long.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out value);
+ }
+
+ private static bool TryBase64(string text, out byte[] data)
+ {
+ try
+ {
+ data = Convert.FromBase64String(text);
+ return data.Length > 0;
+ }
+ catch (FormatException)
+ {
+ data = [];
+ return false;
+ }
+ }
+
+ private static bool TryDecode(string text, out byte[] data)
+ {
+ data = [];
+ return Base64Url.IsValid(text) && (data = Base64Url.DecodeFromChars(text)).Length > 0;
+ }
+}
diff --git a/backend/fleet/tests/Fleet.Tests/OperatorTrustTests.cs b/backend/fleet/tests/Fleet.Tests/OperatorTrustTests.cs
new file mode 100644
index 0000000..ae42f15
--- /dev/null
+++ b/backend/fleet/tests/Fleet.Tests/OperatorTrustTests.cs
@@ -0,0 +1,195 @@
+using System.Buffers.Text;
+using System.Security.Cryptography;
+using System.Text;
+using Fleet.Identity;
+
+namespace Fleet.Tests;
+
+/// 软件 passkey:只做断言(navigator.credentials.get 的结果),用来测信任链。
+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
+ }
+}