using System.Net; using System.Security.Cryptography; using System.Text; using System.Text.Json.Nodes; using Fleet.Core; using Fleet.Server; using MoShiro.Backend.Application; using MoShiro.Protocol; namespace MoShiro.Backend.Tests; /// /// 节点二进制绑定:第一次握手成功的那一刻绑定正在运行的二进制的哈希,之后对不上又不在 CI 签名的更新清单里,就不让它继续运行。 /// 官方哈希集合来自 CI 签名的清单(节点握手时带着),所以被攻破的 Backend 伪造不了,也不依赖面板有没有收到过这次发布。 /// public sealed class BinaryBindingTests : IDisposable { private const string A = "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1"; private const string B = "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2"; private const string C = "c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3"; private readonly ECDsa _ci = ECDsa.Create(ECCurve.NamedCurves.nistP256); private readonly ECDsa _other = ECDsa.Create(ECCurve.NamedCurves.nistP256); private readonly MemoryStore _store = new(); private readonly MemorySettings _settings = new(); private readonly NodeRecord _agent = Node(AgentIds.Prefix + "_test1"); public void Dispose() { _ci.Dispose(); _other.Dispose(); } private static string Hash(string seed) => seed[..2] + new string(seed[1], 62); private static NodeRecord Node(string id) => new(new NodeId(id), "n", NodeStatus.Active, [1], [2], [], null, null, 0, null); private BinaryBindingService Service(string policy = "enforce") => new(_store, _settings, new TestKey(_ci), new BackendOptions { BinaryPolicy = policy }, TimeProvider.System); private sealed class TestKey(ECDsa key) : IReleaseKey { public string Pem => key.ExportSubjectPublicKeyInfoPem(); } private static string Sign(ECDsa key, string manifest) => Convert.ToBase64String(key.SignData(Encoding.UTF8.GetBytes(manifest), HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence)); /// 节点握手时带的证据:哈希,加上(可选)某把密钥签名的清单。 private BinaryEvidence Evidence(string hash, long? seq = null, string[]? binaries = null, string component = "agent", ECDsa? signer = null) { if (seq is null) { return new BinaryEvidence(hash, null, null); } string manifest = $"{{\"schema\":1,\"component\":\"{component}\",\"seq\":{seq},\"binaries\":\"{string.Join(',', binaries ?? [hash])}\"}}"; return new BinaryEvidence(hash, manifest, Sign(signer ?? _ci, manifest)); } private static string H(char c) => new(c, 64); [Fact] public async Task TheFirstHandshakeBinds_AndTheSameBinaryKeepsWorking() { BinaryBindingService service = Service(); Assert.True((await service.CheckAsync(_agent, Evidence(H('a')), default)).Accept); Assert.Equal(H('a'), (await _store.GetAsync(_agent.Id.Value, default))!.Sha256); Assert.True((await service.CheckAsync(_agent, Evidence(H('a')), default)).Accept); } [Fact] public async Task AReplacedBinary_IsRefused_AndTheReasonIsKept() { BinaryBindingService service = Service(); await service.CheckAsync(_agent, Evidence(H('a')), default); BinaryDecision decision = await service.CheckAsync(_agent, Evidence(H('b')), default); Assert.False(decision.Accept); Assert.Contains("changed", decision.Reason, StringComparison.Ordinal); NodeBinding bound = (await _store.GetAsync(_agent.Id.Value, default))!; Assert.Equal(H('a'), bound.Sha256); // 绑定不会因为一次不符而被改写 Assert.NotNull(bound.Violation); // 原来的二进制回来了:恢复,原因清掉 Assert.True((await service.CheckAsync(_agent, Evidence(H('a')), default)).Accept); Assert.Null((await _store.GetAsync(_agent.Id.Value, default))!.Violation); } [Fact] public async Task AnUpgrade_NeedsACiSignedManifestThatListsTheNewBinary() { BinaryBindingService service = Service(); await service.CheckAsync(_agent, Evidence(H('a'), seq: 100, binaries: [H('a')]), default); // 新二进制在一份更新的 CI 签名清单里:合法升级,绑定跟着改 Assert.True((await service.CheckAsync(_agent, Evidence(H('b'), seq: 200, binaries: [H('b')]), default)).Accept); NodeBinding upgraded = (await _store.GetAsync(_agent.Id.Value, default))!; Assert.Equal(H('b'), upgraded.Sha256); Assert.Equal(200, upgraded.Seq); Assert.True(upgraded.Signed); // 退回旧版本(清单 seq 更低):降级,拒绝 Assert.False((await service.CheckAsync(_agent, Evidence(H('a'), seq: 100, binaries: [H('a')]), default)).Accept); // 同一份清单里的另一个官方二进制(比如另一个平台的构建)不算降级 Assert.True((await service.CheckAsync(_agent, Evidence(H('c'), seq: 200, binaries: [H('b'), H('c')]), default)).Accept); } [Fact] public async Task AManifestSignedByAnotherKey_OrForAnotherComponent_ProvesNothing() { BinaryBindingService service = Service(); await service.CheckAsync(_agent, Evidence(H('a')), default); Assert.False((await service.CheckAsync(_agent, Evidence(H('b'), seq: 500, binaries: [H('b')], signer: _other), default)).Accept); Assert.False((await service.CheckAsync(_agent, Evidence(H('b'), seq: 500, binaries: [H('b')], component: "probe"), default)).Accept); Assert.Equal(H('a'), (await _store.GetAsync(_agent.Id.Value, default))!.Sha256); } [Fact] public async Task ASignedManifestThatDoesNotListTheRunningBinary_IsRefusedAtTheFirstHandshake() { BinaryBindingService service = Service(); BinaryDecision decision = await service.CheckAsync(_agent, Evidence(H('a'), seq: 100, binaries: [H('b')]), default); Assert.False(decision.Accept); Assert.Null(await _store.GetAsync(_agent.Id.Value, default)); } [Fact] public async Task AnUnsignedBinding_BecomesSignedWhenTheManifestArrivesLater() { // 容器节点第一次握手时更新器可能还没写出清单:先按首次握手绑定,清单到了再标成已签名 BinaryBindingService service = Service(); await service.CheckAsync(_agent, Evidence(H('a')), default); Assert.False((await _store.GetAsync(_agent.Id.Value, default))!.Signed); Assert.True((await service.CheckAsync(_agent, Evidence(H('a'), seq: 100, binaries: [H('a')]), default)).Accept); NodeBinding bound = (await _store.GetAsync(_agent.Id.Value, default))!; Assert.True(bound.Signed); Assert.Equal(100, bound.Seq); } [Fact] public async Task ALegacyNode_IsTolerated_ButCannotGoBackOnceBound() { BinaryBindingService service = Service(); NodeRecord legacy = Node(ProbeId.Prefix + "_old"); Assert.True((await service.CheckAsync(legacy, new BinaryEvidence(null, null, null), default)).Accept); Assert.Null(await _store.GetAsync(legacy.Id.Value, default)); await service.CheckAsync(_agent, Evidence(H('a')), default); Assert.False((await service.CheckAsync(_agent, new BinaryEvidence(null, null, null), default)).Accept); // 绑定过的不许退回不上报 } [Fact] public async Task MalformedHashes_AreRefused() { BinaryBindingService service = Service(); Assert.False((await service.CheckAsync(_agent, new BinaryEvidence("not-a-hash", null, null), default)).Accept); Assert.False((await service.CheckAsync(_agent, new BinaryEvidence(new string('A', 64), null, null), default)).Accept); // 要小写十六进制 Assert.Null(await _store.GetAsync(_agent.Id.Value, default)); } [Fact] public async Task TheAdministratorCanAcceptANewBinary_ByClearingTheBinding() { BinaryBindingService service = Service(); await service.CheckAsync(_agent, Evidence(H('a')), default); Assert.False((await service.CheckAsync(_agent, Evidence(H('b')), default)).Accept); Assert.True(await service.ClearAsync(_agent.Id.Value, default)); Assert.True((await service.CheckAsync(_agent, Evidence(H('b')), default)).Accept); Assert.Equal(H('b'), (await _store.GetAsync(_agent.Id.Value, default))!.Sha256); } [Fact] public async Task WarnRecordsButDoesNotRefuse_AndOffDoesNothing() { BinaryBindingService warn = Service("warn"); await warn.CheckAsync(_agent, Evidence(H('a')), default); BinaryDecision decision = await warn.CheckAsync(_agent, Evidence(H('b')), default); Assert.True(decision.Accept); Assert.NotNull((await _store.GetAsync(_agent.Id.Value, default))!.Violation); BinaryBindingService off = Service("off"); NodeRecord other = Node(AgentIds.Prefix + "_test2"); Assert.True((await off.CheckAsync(other, Evidence(H('a')), default)).Accept); Assert.Null(await _store.GetAsync(other.Id.Value, default)); } [Fact] public async Task EachNodeHasItsOwnBinding() { BinaryBindingService service = Service(); NodeRecord second = Node(AgentIds.Prefix + "_test3"); await service.CheckAsync(_agent, Evidence(H('a')), default); await service.CheckAsync(second, Evidence(H('b')), default); Assert.False((await service.CheckAsync(_agent, Evidence(H('b')), default)).Accept); // 别的节点的二进制,不是自己绑定的 Assert.True((await service.CheckAsync(second, Evidence(H('b')), default)).Accept); } /// 面板手里的、CI 签名的清单(登记时存下的):没有更新器的节点握手时没有自己的清单,就拿它来认官方二进制。 private void PanelHolds(string component, long seq, string[] binaries, ECDsa? signer = null) { string manifest = $"{{\"schema\":1,\"component\":\"{component}\",\"seq\":{seq},\"binaries\":\"{string.Join(',', binaries)}\"}}"; _settings.Values["release." + component + ".manifest"] = manifest; _settings.Values["release." + component + ".signature"] = Sign(signer ?? _ci, manifest); } [Fact] public async Task ANodeWithoutAnUpdater_IsMarkedSigned_WhenThePanelHoldsAManifestThatListsItsBinary() { // 一键容器安装的节点没有宿主机更新器,握手时带不来清单:面板用自己手里(登记时验过 CI 签名)的那份 PanelHolds("agent", seq: 100, [H('a')]); BinaryBindingService service = Service(); Assert.True((await service.CheckAsync(_agent, Evidence(H('a')), default)).Accept); NodeBinding bound = (await _store.GetAsync(_agent.Id.Value, default))!; Assert.True(bound.Signed); Assert.Equal(100, bound.Seq); } [Fact] public async Task TheManifestThePanelHolds_NeverRefusesANodeAtFirstBinding_OnlyLabelsIt() { // 老一点的官方版本、自己构建的版本:不在面板手里最新的清单里,但没有证据说它不是官方的,所以不拒绝,只是不标成已签名 PanelHolds("agent", seq: 100, [H('b')]); BinaryBindingService service = Service(); Assert.True((await service.CheckAsync(_agent, Evidence(H('a')), default)).Accept); NodeBinding bound = (await _store.GetAsync(_agent.Id.Value, default))!; Assert.False(bound.Signed); Assert.Equal(0, bound.Seq); } [Fact] public async Task AManifestThePanelHoldsThatIsSignedByAnotherKey_ProvesNothing() { PanelHolds("agent", seq: 100, [H('a')], signer: _other); BinaryBindingService service = Service(); Assert.True((await service.CheckAsync(_agent, Evidence(H('a')), default)).Accept); Assert.False((await _store.GetAsync(_agent.Id.Value, default))!.Signed); } [Fact] public async Task AnUpgradeOfANodeWithoutAnUpdater_IsFollowedFromTheManifestThePanelHolds() { PanelHolds("agent", seq: 100, [H('a')]); BinaryBindingService service = Service(); await service.CheckAsync(_agent, Evidence(H('a')), default); // 之后发布了新版,面板登记了新的清单;节点换成新二进制重连 PanelHolds("agent", seq: 200, [H('b')]); Assert.True((await service.CheckAsync(_agent, Evidence(H('b')), default)).Accept); Assert.Equal(200, (await _store.GetAsync(_agent.Id.Value, default))!.Seq); // 没人签过的二进制仍然被拒绝 Assert.False((await service.CheckAsync(_agent, Evidence(H('c')), default)).Accept); } private sealed class MemorySettings : ISettingsStore { public Dictionary Values { get; } = []; public ValueTask GetAsync(string key, CancellationToken ct) => new(Values.GetValueOrDefault(key)); public ValueTask SetAsync(string key, string value, CancellationToken ct) { Values[key] = value; return ValueTask.CompletedTask; } public ValueTask> GetAllAsync(CancellationToken ct) => new(Values); public ValueTask IncrementAsync(string key, CancellationToken ct) => new(0); } private sealed class MemoryStore : INodeBindingStore { private readonly Dictionary _rows = []; public ValueTask GetAsync(string nodeId, CancellationToken ct) => new(_rows.GetValueOrDefault(nodeId)); public ValueTask> ListAsync(CancellationToken ct) => new([.. _rows.Values]); public ValueTask UpsertAsync(NodeBinding binding, CancellationToken ct) { _rows[binding.NodeId] = binding; return ValueTask.CompletedTask; } public ValueTask DeleteAsync(string nodeId, CancellationToken ct) => new(_rows.Remove(nodeId)); } } /// 真实的 Agent 在进程里连上 Backend:第一次握手就绑定了它正在运行的二进制,管理端看得到,也可以清除。 [Collection("probe-e2e")] public sealed class BinaryBindingEndToEndTests { private readonly BackendFixture _backend; public BinaryBindingEndToEndTests(BackendFixture backend) { _backend = backend; } [Fact] public async Task ARealAgent_IsBoundOnItsFirstHandshake_AndTheAdministratorCanClearIt() { await using AgentHarness agent = await AgentHarness.StartAsync(_backend); string? expected = NodeBinary.Sha256Hex; Assert.NotNull(expected); JsonNode? binary = null; await BackendFixture.EventuallyAsync( async () => { JsonNode info = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/build-info"); binary = info["nodes"]!.AsArray().FirstOrDefault(n => n!["id"]!.GetValue() == agent.Id)?["binary"]; return binary is not null; }, TimeSpan.FromSeconds(30), "the agent is bound on its first handshake"); Assert.Equal(expected, binary!["sha256"]!.GetValue()); Assert.False(binary["signed"]!.GetValue()); // 测试里的 Agent 没有更新器,也就没有 CI 签名的清单 Assert.Null(binary["violation"]); using HttpResponseMessage cleared = await _backend.Admin.DeleteAsync("/api/admin/nodes/" + agent.Id + "/binary"); Assert.Equal(HttpStatusCode.NoContent, cleared.StatusCode); JsonNode after = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/build-info"); Assert.Null(after["nodes"]!.AsArray().First(n => n!["id"]!.GetValue() == agent.Id)!["binary"]); } }