From 9bb6841dce3109a2bb897e88dce9579e34f0fb8d Mon Sep 17 00:00:00 2001 From: JustMe Date: Wed, 7 Oct 2026 23:24:33 +0800 Subject: [PATCH] feat: pinned agents and probes only accept peers carrying the operator's proof; the panel ships the signed operator chain with the rosters [CI SKIP] --- backend/agent/src/MoShiro.Agent/AgentApp.cs | 2 +- .../src/MoShiro.Agent/Tasks/TaskManager.cs | 18 ++++++- .../src/MoShiro.Probe/Link/AgentDirectory.cs | 27 +++++++++- backend/probe/src/MoShiro.Probe/ProbeApp.cs | 2 +- .../shared/MoShiro.Protocol/Rpc/MoShiroRpc.cs | 7 +++ .../shared/MoShiro.Protocol/Tasks/TaskSet.cs | 8 +++ .../Admin/TrustEndpoints.cs | 3 +- .../Agents/AgentService.cs | 8 ++- .../Tasks/TaskPlanner.cs | 9 +++- .../MoShiro.Backend.Tests/AgentHarness.cs | 8 +-- .../tests/MoShiro.Backend.Tests/TrustTests.cs | 49 +++++++++++++++++++ 11 files changed, 129 insertions(+), 12 deletions(-) diff --git a/backend/agent/src/MoShiro.Agent/AgentApp.cs b/backend/agent/src/MoShiro.Agent/AgentApp.cs index 8aba5c9..6a5cbe2 100644 --- a/backend/agent/src/MoShiro.Agent/AgentApp.cs +++ b/backend/agent/src/MoShiro.Agent/AgentApp.cs @@ -81,7 +81,7 @@ public sealed class AgentApp : IDisposable }; var chunks = new ChunkAssembler(_streams, _clock, _counters, _outbox, identity.NodeId, time, log); var targets = new TargetRunners(chunks, identity.NodeId, _clock, time, log); - _tasks = new TaskManager(CreateGuard, _streams, env, targets, () => _udp.BoundPorts, () => _tcp.BoundPorts); + _tasks = new TaskManager(CreateGuard, _streams, env, targets, () => _udp.BoundPorts, () => _tcp.BoundPorts, TrustAnchor.Load(config.Fleet.IdentityDir, log)); _hub = new PeerHub(_sessions, _clock, chunks, _tasks, _uplink, time, log); _tests = new TestRunner(_tasks, _sessions, _streams, env, _uplink, config.Limits.MaxConcurrentTests, transport => transport == "udp" ? _udp.BoundPorts : _tcp.BoundPorts, time, log); diff --git a/backend/agent/src/MoShiro.Agent/Tasks/TaskManager.cs b/backend/agent/src/MoShiro.Agent/Tasks/TaskManager.cs index e61f699..72f9503 100644 --- a/backend/agent/src/MoShiro.Agent/Tasks/TaskManager.cs +++ b/backend/agent/src/MoShiro.Agent/Tasks/TaskManager.cs @@ -1,4 +1,6 @@ using System.Text.Json; +using Fleet.Hosting; +using Fleet.Identity; using Fleet.Core; using Fleet.Rpc; using MoShiro.Measurement; @@ -25,14 +27,16 @@ public sealed class TaskManager private readonly TargetRunners _targets; private readonly Func> _udpPorts; private readonly Func> _tcpPorts; + private readonly TrustAnchor _trust; private readonly Dictionary> _sessionLines = []; private volatile Dictionary _peers = new(StringComparer.Ordinal); private Dictionary _lineSpecs = []; private long _version; private BudgetGuard? _guard; - public TaskManager(Func guardFactory, StreamRegistry streams, BlockEnv env, TargetRunners targets, Func> udpPorts, Func> tcpPorts) + public TaskManager(Func guardFactory, StreamRegistry streams, BlockEnv env, TargetRunners targets, Func> udpPorts, Func> tcpPorts, TrustAnchor? trust = null) { + _trust = trust ?? TrustAnchor.None; _guardFactory = guardFactory; _streams = streams; _env = env; @@ -98,12 +102,22 @@ public sealed class TaskManager _ = ct; var result = new StateSyncResult { AppliedVersion = version }; + // 固定了操作员创世的节点:先验面板带来的操作员集合链(从固定的创世出发),再要求每个 Probe 带着操作员批准的证明。 + // 面板自己改名册、塞进自己的 Probe 公钥是过不了这一关的:它签不出操作员的委托。 + _trust.Update(set.TrustChain); var peers = new Dictionary(StringComparer.Ordinal); foreach (PeerSpec peer in set.Peers) { try { - peers[peer.ProbeId.Value] = Convert.FromBase64String(peer.PublicKey); + byte[] key = Convert.FromBase64String(peer.PublicKey); + if (_trust.VerifyPeer(peer.Proof, key, "probe") != TrustFailure.None) + { + result.Rejected.Add(new RejectedItem { Id = peer.ProbeId.Value, Reason = RejectReasons.PolicyDenied }); + continue; + } + + peers[peer.ProbeId.Value] = key; } catch (FormatException) { diff --git a/backend/probe/src/MoShiro.Probe/Link/AgentDirectory.cs b/backend/probe/src/MoShiro.Probe/Link/AgentDirectory.cs index 69beae9..6872ccc 100644 --- a/backend/probe/src/MoShiro.Probe/Link/AgentDirectory.cs +++ b/backend/probe/src/MoShiro.Probe/Link/AgentDirectory.cs @@ -1,5 +1,6 @@ using System.Text.Json; using Fleet.Core; +using Fleet.Identity; using Fleet.Hosting; using MoShiro.Protocol; @@ -16,11 +17,13 @@ public sealed class AgentDirectory private readonly object _gate = new(); private readonly ConfigStore _store; private readonly INodeLog _log; + private readonly TrustAnchor _trust; private List _agents = []; private long _version; - public AgentDirectory(ConfigStore store, string configDirectory, INodeLog log) + public AgentDirectory(ConfigStore store, string configDirectory, INodeLog log, TrustAnchor? trust = null) { + _trust = trust ?? TrustAnchor.None; _store = store; _log = log; string cache = Path.Combine(configDirectory, FileName); @@ -64,12 +67,34 @@ public sealed class AgentDirectory } } + private static bool TryKey(string text, out byte[] key) + { + try + { + key = Convert.FromBase64String(text); + return key.Length > 0; + } + catch (FormatException) + { + key = []; + return false; + } + } + /// 应用面板下发的列表。版本不比当前新的忽略(乱序到达的旧推送)。 public void Apply(ProbeAgentList result) { var next = new List(); + _trust.Update(result.TrustChain); foreach (AgentEndpoint agent in result.Agents) { + // 固定了操作员创世的 Probe 只连操作员批准过的 Agent(证明验不过的,面板塞进来的也不连) + if (!TryKey(agent.PublicKey, out byte[] key) || _trust.VerifyPeer(agent.Proof, key, "agent") != TrustFailure.None) + { + _log.Warn("an agent from the panel carries no valid operator proof; not connecting", ("agent", agent.AgentId.Value)); + continue; + } + if (!string.IsNullOrWhiteSpace(agent.Host)) { next.Add(new KnownAgent { AgentId = agent.AgentId.Value, Host = agent.Host, TcpPort = agent.TcpPort, PublicKey = agent.PublicKey }); diff --git a/backend/probe/src/MoShiro.Probe/ProbeApp.cs b/backend/probe/src/MoShiro.Probe/ProbeApp.cs index 1dc5fca..00ba2b3 100644 --- a/backend/probe/src/MoShiro.Probe/ProbeApp.cs +++ b/backend/probe/src/MoShiro.Probe/ProbeApp.cs @@ -27,7 +27,7 @@ public sealed class ProbeApp : IDisposable public ProbeApp(ProbeFile file, LoadedNodeIdentity identity, string configDirectory, TimeProvider time, INodeLog log, RestartController? restart = null) { _log = log; - var directory = new AgentDirectory(new ConfigStore(configDirectory), configDirectory, log); + var directory = new AgentDirectory(new ConfigStore(configDirectory), configDirectory, log, TrustAnchor.Load(file.Fleet.IdentityDir, log)); _env = new ConnectionEnv(identity.PeerIdentity, identity.NodeId.Value, file.Limits, directory, new MonoClock(time), time, log); directory.Changed += () => _changed.Release(); diff --git a/backend/shared/MoShiro.Protocol/Rpc/MoShiroRpc.cs b/backend/shared/MoShiro.Protocol/Rpc/MoShiroRpc.cs index 9703560..b4c66c4 100644 --- a/backend/shared/MoShiro.Protocol/Rpc/MoShiroRpc.cs +++ b/backend/shared/MoShiro.Protocol/Rpc/MoShiroRpc.cs @@ -152,6 +152,10 @@ public sealed class AgentEndpoint /// Agent 面向 Probe 的 TLS 证书公钥(SPKI,base64):证书按它的 SHA-256 指纹固定,敲门密钥由它和 Probe 的身份密钥派生。 [JsonPropertyName("public_key")] public string PublicKey { get; set; } = ""; + + /// 操作员批准这个 Agent 的证明;固定了创世的 Probe 没有它就不连。 + [JsonPropertyName("proof")] + public MemberProofDto? Proof { get; set; } } /// 期望状态文档(Fleet state type = probe_agents):Backend 直接下发给每个 Probe 的 Agent 列表。 @@ -164,6 +168,9 @@ public sealed class ProbeAgentList [JsonPropertyName("agents")] public List Agents { get; set; } = []; + + [JsonPropertyName("trust_chain")] + public List? TrustChain { get; set; } } /// 注册元数据(MoShiro 定义,经 Fleet 的 meta 字段带上来)。 diff --git a/backend/shared/MoShiro.Protocol/Tasks/TaskSet.cs b/backend/shared/MoShiro.Protocol/Tasks/TaskSet.cs index 88771f4..9a41582 100644 --- a/backend/shared/MoShiro.Protocol/Tasks/TaskSet.cs +++ b/backend/shared/MoShiro.Protocol/Tasks/TaskSet.cs @@ -40,6 +40,10 @@ public sealed class PeerSpec /// Probe 的身份公钥(SPKI,base64)。 [JsonPropertyName("pubkey")] public string PublicKey { get; set; } = ""; + + /// 操作员批准这个 Probe 的证明(passkey 签的委托加码的私钥签的绑定)。固定了创世的 Agent 没有它就不接纳。 + [JsonPropertyName("proof")] + public MemberProofDto? Proof { get; set; } } /// 期望状态文档(Fleet state type = task_set):Backend 为每个 Agent 计算的有效任务集。 @@ -56,6 +60,10 @@ public sealed class TaskSet /// 允许连接到这个 Agent 的 Probe 公钥。 [JsonPropertyName("peers")] public List Peers { get; set; } = []; + + /// 操作员集合的链(passkey 签的,创世在前):节点从固定的创世指纹验过才采用。 + [JsonPropertyName("trust_chain")] + public List? TrustChain { get; set; } } public static class TaskSchedule diff --git a/backend/src/MoShiro.Backend.Api/Admin/TrustEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/TrustEndpoints.cs index 7c1d855..a87dd03 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/TrustEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Admin/TrustEndpoints.cs @@ -40,7 +40,7 @@ public static class TrustEndpoints return TypedResults.Json(Mappers.ToDto(state), ApiJsonContext.Default.TrustStateDto); } - private static async Task CommitAsync(HttpContext http, SignedStatementDto signed, TrustService trust, BackendOptions options) + private static async Task CommitAsync(HttpContext http, SignedStatementDto signed, TrustService trust, AgentService agents, BackendOptions options) { PasskeyContext context = AuthEndpoints.PasskeyContextOf(http, options); // 第一次(创世)是整个信任根的起点:除了 passkey 签名,还要凭证窗口,避免只拿到一个密码会话的人抢先立根 @@ -55,6 +55,7 @@ public static class TrustEndpoints return Problems.From(http, error); } + await agents.AfterAgentSetChangedAsync(http.RequestAborted); // 新的操作员集合要随名册下发给节点 http.SetDetail($"operator set v{state.Version}"); return TypedResults.Json(Mappers.ToDto(state), ApiJsonContext.Default.TrustStateDto, statusCode: StatusCodes.Status201Created); } diff --git a/backend/src/MoShiro.Backend.Application/Agents/AgentService.cs b/backend/src/MoShiro.Backend.Application/Agents/AgentService.cs index 2f1e150..eeddc83 100644 --- a/backend/src/MoShiro.Backend.Application/Agents/AgentService.cs +++ b/backend/src/MoShiro.Backend.Application/Agents/AgentService.cs @@ -24,9 +24,11 @@ public sealed partial class AgentService private readonly NodeInfoService _nodes; private readonly LinkService _links; + private readonly TrustService _trust; - public AgentService(IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, TaskPlanner planner, ISettingsStore settings, NodeInfoService nodes, LinkService links) + public AgentService(IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, TaskPlanner planner, ISettingsStore settings, NodeInfoService nodes, LinkService links, TrustService trust) { + _trust = trust; _links = links; _nodes = nodes; _agents = agents; @@ -169,9 +171,13 @@ public sealed partial class AgentService TcpPort = agent.ProbeTcpPort, UdpPorts = [.. agent.ProbeUdpPorts], PublicKey = Convert.ToBase64String(agent.TlsPublicKey!), + Proof = agent.Proof, }); } + IReadOnlyList chain = await _trust.ChainAsync(ct); + result.TrustChain = chain.Count == 0 ? null : [.. chain]; + return result; } diff --git a/backend/src/MoShiro.Backend.Application/Tasks/TaskPlanner.cs b/backend/src/MoShiro.Backend.Application/Tasks/TaskPlanner.cs index 58cdf6c..f8852db 100644 --- a/backend/src/MoShiro.Backend.Application/Tasks/TaskPlanner.cs +++ b/backend/src/MoShiro.Backend.Application/Tasks/TaskPlanner.cs @@ -21,10 +21,12 @@ public sealed class TaskPlanner : IDisposable private readonly IProbeStore _probes; private readonly ISettingsStore _settings; private readonly LinkService _links; + private readonly TrustService _trust; private readonly SemaphoreSlim _gate = new(1, 1); - public TaskPlanner(ITaskStore tasks, IAgentStore agents, IProbeStore probes, ISettingsStore settings, LinkService links) + public TaskPlanner(ITaskStore tasks, IAgentStore agents, IProbeStore probes, ISettingsStore settings, LinkService links, TrustService trust) { + _trust = trust; _links = links; _tasks = tasks; _agents = agents; @@ -62,9 +64,12 @@ public sealed class TaskPlanner : IDisposable foreach (ProbeRecord probe in probes.Where(p => p.RevokedAtMs is null && p.PeerPublicKey is { Length: > 0 } && links.IsLinked(p.Id, agentId.Value))) { - document.Peers.Add(new PeerSpec { ProbeId = new ProbeId(probe.Id), PublicKey = Convert.ToBase64String(probe.PeerPublicKey!) }); + document.Peers.Add(new PeerSpec { ProbeId = new ProbeId(probe.Id), PublicKey = Convert.ToBase64String(probe.PeerPublicKey!), Proof = probe.Proof }); } + IReadOnlyList chain = await _trust.ChainAsync(ct); + document.TrustChain = chain.Count == 0 ? null : [.. chain]; + string hash = Convert.ToHexStringLower(SHA256.HashData(JsonSerializer.SerializeToUtf8Bytes(document, MoShiroJsonContext.Default.TaskSet))); await _gate.WaitAsync(ct); diff --git a/backend/tests/MoShiro.Backend.Tests/AgentHarness.cs b/backend/tests/MoShiro.Backend.Tests/AgentHarness.cs index cd63e15..a3c4ceb 100644 --- a/backend/tests/MoShiro.Backend.Tests/AgentHarness.cs +++ b/backend/tests/MoShiro.Backend.Tests/AgentHarness.cs @@ -41,12 +41,14 @@ public sealed class AgentHarness : IAsyncDisposable public byte[] TlsPublicKey => _identity.Certificate.PublicKey.ExportSubjectPublicKeyInfo(); - public static async Task StartAsync(BackendFixture backend, Action? configure = null, string? name = null) + /// 操作员签过委托的加入码(认证串、种子、创世指纹):节点按固定了创世的方式注册,名册里的 Probe 必须带操作员批准的证明。 + public static async Task StartAsync(BackendFixture backend, Action? configure = null, string? name = null, (string Auth, string Seed, string Genesis)? signed = null) { string directory = Path.Combine(Path.GetTempPath(), "moshiro-agent-" + Guid.NewGuid().ToString("N")); - string token = await backend.CreateJoinTokenAsync(); + string token = signed?.Auth ?? await backend.CreateJoinTokenAsync(); using var http = new HttpClient(); - Result enrolled = await EnrollmentClient.EnrollAsync(http, backend.BaseAddress, token, name ?? backend.UniqueName("agent"), directory, AgentApp.Version, null, TimeProvider.System, CancellationToken.None); + Result enrolled = await EnrollmentClient.EnrollAsync( + http, backend.BaseAddress, token, name ?? backend.UniqueName("agent"), directory, AgentApp.Version, null, TimeProvider.System, CancellationToken.None, signed?.Seed, signed?.Genesis, "agent"); Assert.True(enrolled.IsSuccess, enrolled.IsSuccess ? "" : enrolled.Error.ToString()); // 测试里的 Agent 只在回环上监听:手填 public_host(手动的优先于自动识别,Agent 上报本机真实地址也不会覆盖) diff --git a/backend/tests/MoShiro.Backend.Tests/TrustTests.cs b/backend/tests/MoShiro.Backend.Tests/TrustTests.cs index 36c9cb1..8ceeca8 100644 --- a/backend/tests/MoShiro.Backend.Tests/TrustTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/TrustTests.cs @@ -217,4 +217,53 @@ public sealed class TrustTests 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)); + } }