349 lines
16 KiB
C#
349 lines
16 KiB
C#
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 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));
|
|
|
|
/// <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);
|
|
}
|
|
|
|
/// <summary>面板手里的、CI 签名的清单(登记时存下的):没有更新器的节点握手时没有自己的清单,就拿它来认官方二进制。</summary>
|
|
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<string, string> Values { get; } = [];
|
|
|
|
public ValueTask<string?> 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<IReadOnlyDictionary<string, string>> GetAllAsync(CancellationToken ct) => new(Values);
|
|
|
|
public ValueTask<long> IncrementAsync(string key, CancellationToken ct) => new(0);
|
|
}
|
|
|
|
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"]);
|
|
}
|
|
}
|