using System.Net; using System.Net.Http.Headers; using System.Runtime.CompilerServices; using System.Security.Cryptography; using System.Text; using System.Text.Json.Nodes; using Fleet.Core; using Fleet.Enrollment.Client; using Fleet.Hosting; using Fleet.Identity; namespace MoShiro.Backend.Tests; /// /// 信任根:操作员(passkey)签操作员集合和加入码的委托,节点从固定的创世指纹出发验证;码的种子面板看不到。 /// 设计见 docs/access.md 第 7 节。 /// [Collection("probe-e2e")] public sealed class TrustTests { private const string Password = "correct horse battery"; private static readonly ConditionalWeakTable s_operators = []; private static readonly SemaphoreSlim s_gate = new(1, 1); private readonly BackendFixture _backend; public TrustTests(BackendFixture backend) { _backend = backend; } /// 一个已经建立了信任根的操作员:自己的用户、passkey、会话,以及创世指纹。 private sealed record Operator(HttpClient Session, SoftwarePasskey Passkey, string Genesis); private string Origin => _backend.BaseAddress.GetLeftPart(UriPartial.Authority); private async Task OperatorAsync() { await s_gate.WaitAsync(); try { if (s_operators.TryGetValue(_backend, out Operator? existing)) { return existing; } string name = _backend.UniqueName("operator"); using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/users", BackendFixture.Json(new JsonObject { ["username"] = name, ["password"] = Password })); Assert.Equal(HttpStatusCode.Created, created.StatusCode); var session = _backend.NewClient(); using HttpResponseMessage login = await session.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = name, ["password"] = Password, ["return_session"] = true })); session.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", JsonNode.Parse(await login.Content.ReadAsStringAsync())!["session_token"]!.GetValue()); var passkey = new SoftwarePasskey(_backend.BaseAddress.Host, Origin); using (HttpResponseMessage options = await session.PostAsync("/api/admin/auth/passkeys/options", null)) { string challenge = JsonNode.Parse(await options.Content.ReadAsStringAsync())!["challenge"]!.GetValue(); using HttpResponseMessage registered = await session.PostAsync("/api/admin/auth/passkeys", BackendFixture.Json(passkey.Register("operator", challenge))); Assert.Equal(HttpStatusCode.Created, registered.StatusCode); } // 创世要凭证窗口(立根的人要先证明自己),再用 passkey 签集合本身 await ElevateAsync(session, passkey); JsonNode state = JsonNode.Parse(await session.GetStringAsync("/api/admin/trust"))!; Assert.False(state["initialized"]!.GetValue()); string statement = state["proposal"]!["statement"]!.GetValue(); Assert.Equal(Base64UrlOf(statement), state["proposal"]!["challenge"]!.GetValue()); using HttpResponseMessage genesis = await session.PostAsync("/api/admin/trust", BackendFixture.Json(Sign(passkey, statement))); Assert.True(genesis.StatusCode == HttpStatusCode.Created, await genesis.Content.ReadAsStringAsync()); string fingerprint = JsonNode.Parse(await genesis.Content.ReadAsStringAsync())!["genesis"]!.GetValue(); Assert.Equal(OperatorTrust.Fingerprint(statement), fingerprint); var made = new Operator(session, passkey, fingerprint); s_operators.Add(_backend, made); return made; } finally { s_gate.Release(); } } private static string Base64UrlOf(string statement) => Fleet.Core.Base64Url.Encode(SHA256.HashData(Encoding.UTF8.GetBytes(statement))); /// passkey 对语句签名(挑战是语句的 SHA-256),拼成接口要的断言对象。 private static JsonObject Sign(SoftwarePasskey passkey, string statement) { JsonObject assertion = passkey.Assert(Base64UrlOf(statement)); return new JsonObject { ["statement"] = statement, ["credential_id"] = assertion["credential_id"]!.DeepClone(), ["authenticator_data"] = assertion["authenticator_data"]!.DeepClone(), ["client_data_json"] = assertion["client_data_json"]!.DeepClone(), ["signature"] = assertion["signature"]!.DeepClone(), }; } private static async Task ElevateAsync(HttpClient session, SoftwarePasskey passkey) { using HttpResponseMessage options = await session.PostAsync("/api/admin/auth/elevation/options", BackendFixture.Json(new JsonObject { ["class"] = "credential" })); JsonObject assertion = passkey.Assert(JsonNode.Parse(await options.Content.ReadAsStringAsync())!["challenge"]!.GetValue()); assertion["class"] = "credential"; using HttpResponseMessage elevated = await session.PostAsync("/api/admin/auth/elevation", BackendFixture.Json(assertion)); Assert.Equal(HttpStatusCode.OK, elevated.StatusCode); } /// 浏览器做的事:生成码的密钥,种子派生认证串,passkey 对委托签名,创建码。返回种子和认证串。 private async Task<(string Seed, string Auth, HttpStatusCode Status)> CreateSignedCodeAsync(Operator op, string role = "probe", SoftwarePasskey? signer = null, long? notAfter = null) { using ECDsa code = ECDsa.Create(ECCurve.NamedCurves.nistP256); byte[] seed = code.ExportParameters(true).D!; string auth = OperatorTrust.AuthToken(seed); long now = TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds(); string statement = OperatorTrust.Encode(new Grant(role, OperatorTrust.AuthHash(auth), code.ExportSubjectPublicKeyInfo(), now - 1000, notAfter ?? now + 3_600_000, 1)); using HttpResponseMessage response = await op.Session.PostAsync("/api/admin/join-tokens/signed", BackendFixture.Json(new JsonObject { ["grant"] = Sign(signer ?? op.Passkey, statement), ["note"] = "trust test" })); return (Fleet.Core.Base64Url.Encode(seed), auth, response.StatusCode); } private async Task> EnrollAsync(string auth, string? seed, string? genesis, string role = "probe", string? directory = null) { directory ??= Path.Combine(Path.GetTempPath(), "moshiro-trust-id-" + Guid.NewGuid().ToString("N")); using var http = new HttpClient(); return await EnrollmentClient.EnrollAsync(http, _backend.BaseAddress, auth, _backend.UniqueName("node"), directory, "0.0.0-test", null, TimeProvider.System, default, seed, genesis, role); } [Fact] public async Task ASignedCode_EnrollsANode_ThatPinsTheOperatorAndCarriesTheProof() { Operator op = await OperatorAsync(); (string seed, string auth, HttpStatusCode status) = await CreateSignedCodeAsync(op); Assert.Equal(HttpStatusCode.Created, status); string directory = Path.Combine(Path.GetTempPath(), "moshiro-trust-id-" + Guid.NewGuid().ToString("N")); Result enrolled = await EnrollAsync(auth, seed, op.Genesis, directory: directory); Assert.True(enrolled.IsSuccess, enrolled.IsSuccess ? "" : enrolled.Error.ToString()); Assert.True(enrolled.Value.Pinned); // 节点本地:固定的创世、验证过的链、自己的证明;强制模式下对端必须带证明 TrustAnchor anchor = TrustAnchor.Load(directory, NullNodeLog.Instance); Assert.True(anchor.Enforcing); Assert.Equal(op.Genesis, anchor.Genesis); using LoadedNodeIdentity identity = LoadedNodeIdentity.Load(directory); Assert.Equal(TrustFailure.None, anchor.VerifyPeer(anchor.OwnProof, identity.PeerIdentity.PublicKeySpki, "probe")); Assert.Equal(TrustFailure.Malformed, anchor.VerifyPeer(null, identity.PeerIdentity.PublicKeySpki, "probe")); Assert.NotEqual(TrustFailure.None, anchor.VerifyPeer(anchor.OwnProof, RandomNumberGenerator.GetBytes(91), "probe")); // 面板上这个码标记为已签名 JsonNode tokens = JsonNode.Parse(await _backend.Admin.GetStringAsync("/api/admin/join-tokens"))!; Assert.Contains(tokens["items"]!.AsArray(), t => t!["signed"]!.GetValue() && t["note"]?.GetValue() == "trust test"); } [Fact] public async Task ANodeWithAWrongPin_RefusesBeforeSendingAnything_AndTheCodeStaysUsable() { Operator op = await OperatorAsync(); (string seed, string auth, _) = await CreateSignedCodeAsync(op); Result wrong = await EnrollAsync(auth, seed, new string('0', 64)); Assert.False(wrong.IsSuccess); Assert.Contains("genesis", wrong.Error.Message, StringComparison.Ordinal); Result right = await EnrollAsync(auth, seed, op.Genesis); // 码没被消耗 Assert.True(right.IsSuccess, right.IsSuccess ? "" : right.Error.ToString()); } [Fact] public async Task TheSeedIsNeededToEnroll_ACodeAloneNeverGetsANodeIn() { Operator op = await OperatorAsync(); (string seed, string auth, _) = await CreateSignedCodeAsync(op); // 只有认证串(面板看得到的那部分):不带种子,旧客户端的流程,面板要求绑定 Result withoutSeed = await EnrollAsync(auth, null, null); Assert.False(withoutSeed.IsSuccess); // 种子不是这个码的:节点自己就不干 (string otherSeed, _, _) = await CreateSignedCodeAsync(op); Result mismatched = await EnrollAsync(auth, otherSeed, op.Genesis); Assert.False(mismatched.IsSuccess); Assert.True((await EnrollAsync(auth, seed, op.Genesis)).IsSuccess); } [Fact] public async Task ARoleMismatch_OrAnExpiredGrant_OrAForeignSigner_IsRefused() { Operator op = await OperatorAsync(); (string seed, string auth, _) = await CreateSignedCodeAsync(op, role: "agent"); Result asProbe = await EnrollAsync(auth, seed, op.Genesis, role: "probe"); // 节点以为自己是 probe,委托是给 agent 的 Assert.False(asProbe.IsSuccess); (_, _, HttpStatusCode expired) = await CreateSignedCodeAsync(op, notAfter: TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds() - 1000); Assert.Equal(HttpStatusCode.BadRequest, expired); using var stranger = new SoftwarePasskey(_backend.BaseAddress.Host, Origin); // 没注册的 passkey 签的委托 (_, _, HttpStatusCode foreign) = await CreateSignedCodeAsync(op, signer: stranger); Assert.Equal(HttpStatusCode.Forbidden, foreign); } [Fact] public async Task TheGenesis_IsSetOnce_AndNeedsTheOperatorsSignature() { Operator op = await OperatorAsync(); JsonNode state = JsonNode.Parse(await op.Session.GetStringAsync("/api/admin/trust"))!; Assert.True(state["initialized"]!.GetValue()); Assert.Equal(op.Genesis, state["genesis"]!.GetValue()); // 没有操作员签名的语句:不收 JsonObject unsigned = Sign(op.Passkey, "moshiro-opset-v1\nversion=2\nprev=" + op.Genesis + "\nrp=x\norigins=x\nkey=a:AAAA"); unsigned["statement"] = "moshiro-opset-v1\nversion=2\nprev=" + op.Genesis + "\nrp=" + _backend.BaseAddress.Host + "\norigins=" + Origin + "\nkey=a:AAAA"; // 语句被换掉,签名对不上 using HttpResponseMessage tampered = await op.Session.PostAsync("/api/admin/trust", BackendFixture.Json(unsigned)); Assert.True(tampered.StatusCode is HttpStatusCode.BadRequest or HttpStatusCode.Forbidden or HttpStatusCode.Conflict); // 匿名和只有 API key 的调用也没有可能立根 using HttpResponseMessage anonymous = await _backend.Anonymous.PostAsync("/api/admin/trust", BackendFixture.Json(unsigned)); Assert.Equal(HttpStatusCode.Unauthorized, anonymous.StatusCode); } [Fact] public async Task APinnedAgent_OnlyAcceptsProbesTheOperatorApproved_EvenIfThePanelListsOthers() { Operator op = await OperatorAsync(); (string agentSeed, string agentAuth, _) = await CreateSignedCodeAsync(op, role: "agent"); await using AgentHarness agent = await AgentHarness.StartAsync(_backend, signed: (agentAuth, agentSeed, op.Genesis)); // 面板里有一个没有操作员证明的 Probe(相当于面板自己塞进名册的),再有一个操作员批准的 using EnrolledProbe plain = await _backend.CreateProbeAsync(); (string seed, string auth, _) = await CreateSignedCodeAsync(op, role: "probe"); string directory = Path.Combine(Path.GetTempPath(), "moshiro-trust-id-" + Guid.NewGuid().ToString("N")); Result enrolled = await EnrollAsync(auth, seed, op.Genesis, directory: directory); Assert.True(enrolled.IsSuccess, enrolled.IsSuccess ? "" : enrolled.Error.ToString()); using LoadedNodeIdentity approved = LoadedNodeIdentity.Load(directory); await BackendFixture.EventuallyAsync( () => Task.FromResult(agent.App.IsPeerKnown(enrolled.Value.NodeId, Convert.ToBase64String(approved.PeerIdentity.PublicKeySpki))), because: "the agent should accept the probe the operator approved"); Assert.False(agent.App.IsPeerKnown(plain.ProbeId, Convert.ToBase64String(plain.Identity.PeerIdentity.PublicKeySpki)), "a probe without the operator's proof must not be accepted"); } [Fact] public async Task APinnedProbe_OnlyConnectsToAgentsTheOperatorApproved() { Operator op = await OperatorAsync(); (string probeSeed, string probeAuth, _) = await CreateSignedCodeAsync(op, role: "probe"); string probeDirectory = Path.Combine(Path.GetTempPath(), "moshiro-trust-id-" + Guid.NewGuid().ToString("N")); Assert.True((await EnrollAsync(probeAuth, probeSeed, op.Genesis, directory: probeDirectory)).IsSuccess); (string agentSeed, string agentAuth, _) = await CreateSignedCodeAsync(op, role: "agent"); await using AgentHarness agent = await AgentHarness.StartAsync(_backend, signed: (agentAuth, agentSeed, op.Genesis)); MemberProofDto? proof = TrustAnchor.Load(agent.DataDirectory, NullNodeLog.Instance).OwnProof; string key = Convert.ToBase64String(agent.TlsPublicKey); string configDirectory = Path.Combine(Path.GetTempPath(), "moshiro-probe-cfg-" + Guid.NewGuid().ToString("N")); var directory = new MoShiro.Probe.AgentDirectory(new ConfigStore(configDirectory), configDirectory, NullNodeLog.Instance, TrustAnchor.Load(probeDirectory, NullNodeLog.Instance)); directory.Apply(new MoShiro.Protocol.ProbeAgentList { Version = 1, Agents = [ new MoShiro.Protocol.AgentEndpoint { AgentId = new NodeId("agt_approved"), Host = "127.0.0.1", TcpPort = 1, PublicKey = key, Proof = proof }, new MoShiro.Protocol.AgentEndpoint { AgentId = new NodeId("agt_planted"), Host = "127.0.0.1", TcpPort = 2, PublicKey = key }, // 面板塞进来的:没有证明 new MoShiro.Protocol.AgentEndpoint { AgentId = new NodeId("agt_swapped"), Host = "127.0.0.1", TcpPort = 3, PublicKey = Convert.ToBase64String(RandomNumberGenerator.GetBytes(91)), Proof = proof }, // 拿着别人的证明、换了自己的公钥 ], }); Assert.Equal(["agt_approved"], directory.Agents.Select(a => a.AgentId)); } }