feat: bind each node to the binary it runs at its first handshake; refuse changes that are not in a CI-signed manifest [CI]
ci/woodpecker/push/main-amd64-build Pipeline failed
ci/woodpecker/push/main-amd64-build Pipeline failed
This commit is contained in:
1 parent
117d34a2af
commit
4841f7b15b
40 files changed
+1011
-21
No files matched your search
@@ -0,0 +1,267 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// 节点二进制绑定:第一次握手成功的那一刻绑定正在运行的二进制的哈希,之后对不上又不在 CI 签名的更新清单里,就不让它继续运行。
|
||||
/// 官方哈希集合来自 CI 签名的清单(节点握手时带着),所以被攻破的 Backend 伪造不了,也不依赖面板有没有收到过这次发布。
|
||||
/// </summary>
|
||||
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 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, 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));
|
||||
|
||||
/// <summary>节点握手时带的证据:哈希,加上(可选)某把密钥签名的清单。</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
private sealed class MemoryStore : INodeBindingStore
|
||||
{
|
||||
private readonly Dictionary<string, NodeBinding> _rows = [];
|
||||
|
||||
public ValueTask<NodeBinding?> GetAsync(string nodeId, CancellationToken ct) => new(_rows.GetValueOrDefault(nodeId));
|
||||
|
||||
public ValueTask<IReadOnlyList<NodeBinding>> ListAsync(CancellationToken ct) => new([.. _rows.Values]);
|
||||
|
||||
public ValueTask UpsertAsync(NodeBinding binding, CancellationToken ct)
|
||||
{
|
||||
_rows[binding.NodeId] = binding;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask<bool> DeleteAsync(string nodeId, CancellationToken ct) => new(_rows.Remove(nodeId));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>真实的 Agent 在进程里连上 Backend:第一次握手就绑定了它正在运行的二进制,管理端看得到,也可以清除。</summary>
|
||||
[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<string>() == 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<string>());
|
||||
Assert.False(binary["signed"]!.GetValue<bool>()); // 测试里的 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<string>() == agent.Id)!["binary"]);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user