refactor: remove the runtime binary binding; every release registration path verifies the CI signature [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful

The hash a node reports about itself cannot be trusted (a swapped binary can
copy the old hash), so the runtime binding only caught files that were replaced
by something that did not lie. Provenance stays where it can be verified: the
signed release manifest (digest) and the signed SHA256SUMS.

- Remove BinaryBindingService, the node_bindings table (schema v15 drops it),
  BinaryPolicy, the handshake hash/manifest fields and the admin binary column.
- Drop the per-platform "binaries" list from the release manifest, the hash
  scripts and the updater's installed.manifest copy.
- ReleaseService.PublishAsync now verifies the release signature, so the admin
  POST /releases route is no longer an unsigned path; tests sign with a test key.
- Docs: section 16 explains why the runtime check is gone.
This commit is contained in:
justme committed 2026-10-08 18:30:05 +08:00
1 parent a4f3d1892a
commit 904ea2b0ae
49 files changed
+115 -1143

No files matched your search

@@ -265,7 +265,8 @@ public class BackendFixture : IAsyncLifetime
options.RequireOwner = false; // 大部分测试不建立信任根;属主必选的测试自己打开(OwnerTests)
options.GeoLookupUrl = string.Empty; // 测试里不访问外部服务
options.PhaseDurationScale = 0.2; // 端到端测试里 connect、hold 这些固定 30 秒的阶段缩成 6 秒
});
},
configureServices: services => services.AddSingleton<MoShiro.Backend.Application.IReleaseKey>(TestReleaseKey.Instance)); // 登记清单要验发布签名:测试用自己的密钥签
await App.StartAsync();
string address = App.Services.GetRequiredService<IServer>().Features.Get<IServerAddressesFeature>()!.Addresses.First();
BaseAddress = new Uri(address);
@@ -33,7 +33,7 @@ public sealed class BackendUpdateTests : IAsyncLifetime
private async Task<HttpResponseMessage> PublishAsync(long seq, string channel = "dev")
{
string manifest = $$"""{"component":"backend","channel":"{{channel}}","image":"registry.example/moshiro-{{channel}}/moshiro-backend","digest":"{{Digest}}","tag":"{{channel}}-{{seq}}","version":"0.1.1","seq":{{seq}}}""";
return await _backend.Admin.PostAsync("/api/admin/releases", BackendFixture.Json(new JsonObject { ["manifest"] = manifest, ["signature"] = "MEUC" }));
return await _backend.Admin.PostAsync("/api/admin/releases", BackendFixture.Json(new JsonObject { ["manifest"] = manifest, ["signature"] = TestReleaseKey.Sign(manifest) }));
}
private Task<JsonNode> ViewAsync() => _backend.GetJsonAsync(_backend.Admin, "/api/admin/backend/update");
@@ -1,348 +0,0 @@
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"]);
}
}
@@ -85,7 +85,7 @@ public sealed class InstallTests
public async Task TheScript_GetsTheSignedManifestOfItsRole_ByteForByte()
{
string manifest = """{"schema":1,"component":"probe","channel":"dev","image":"registry.example/moshiro-dev/moshiro-probe","digest":"sha256:0000000000000000000000000000000000000000000000000000000000000000","tag":"dev-1","version":"0.1.1","commit":"abc","built_at":"2026-10-06T00:00:00Z","seq":1}""";
using HttpResponseMessage published = await _backend.Admin.PostAsync("/api/admin/releases", BackendFixture.Json(new JsonObject { ["manifest"] = manifest, ["signature"] = "MEUCIQDc" }));
using HttpResponseMessage published = await _backend.Admin.PostAsync("/api/admin/releases", BackendFixture.Json(new JsonObject { ["manifest"] = manifest, ["signature"] = TestReleaseKey.Sign(manifest) }));
Assert.True(published.StatusCode is HttpStatusCode.Created or HttpStatusCode.Conflict, await published.Content.ReadAsStringAsync());
string code = (await CreateAsync("probe"))["plaintext"]!.GetValue<string>();
@@ -74,6 +74,22 @@ public sealed class ReleaseOfferTests
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
[Fact]
public async Task OfferWithAValidSignature_IsAccepted_WithoutAnyToken_AndTheAdminEndpointChecksTheSignatureToo()
{
long seq = TimeProvider.System.GetUtcNow().ToUnixTimeSeconds() * 1000; // backend 的 release 通道:不和别的用例抢 agent、probe 的 seq
string manifest = $$"""{"schema":1,"component":"backend","channel":"release","image":"registry.example/moshiro/moshiro-backend","digest":"sha256:0000000000000000000000000000000000000000000000000000000000000000","tag":"dev-x","version":"0.1.1","seq":{{seq}}}""";
using HttpClient anonymous = _backend.NewClient();
using HttpResponseMessage offered = await anonymous.PostAsync("/api/release/offer", BackendFixture.Json(new JsonObject { ["manifest"] = manifest, ["signature"] = TestReleaseKey.Sign(manifest) }));
Assert.Equal(HttpStatusCode.Created, offered.StatusCode);
// 管理员通道不再是不验签的口子:签名不对一样拒绝
string next = manifest.Replace($"\"seq\":{seq}", $"\"seq\":{seq + 1}", StringComparison.Ordinal);
using HttpResponseMessage forged = await _backend.Admin.PostAsync("/api/admin/releases", BackendFixture.Json(new JsonObject { ["manifest"] = next, ["signature"] = OpenSslSignature }));
Assert.Equal(HttpStatusCode.Forbidden, forged.StatusCode);
}
[Fact]
public async Task TheAdminEndpoint_StillNeedsTheCapability()
{
@@ -0,0 +1,21 @@
using System.Security.Cryptography;
using System.Text;
using MoShiro.Backend.Application;
namespace MoShiro.Backend.Tests;
/// <summary>
/// 测试用的发布密钥:真正的 CI 私钥不在仓库里,测试后端用这把公钥验签(<see cref="BackendFixture"/> 替换了内嵌的那把),
/// 登记清单的用例用它签名——和 CI 一样是 DER 编码的 ECDSA P-256 / SHA-256,签名对象是清单的原始字节。
/// </summary>
public sealed class TestReleaseKey : IReleaseKey
{
private static readonly ECDsa s_key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
public static TestReleaseKey Instance { get; } = new();
public string Pem => s_key.ExportSubjectPublicKeyInfoPem();
public static string Sign(string manifest) =>
Convert.ToBase64String(s_key.SignData(Encoding.UTF8.GetBytes(manifest), HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence));
}
@@ -22,12 +22,12 @@ public sealed class UpdateTests
_backend = backend;
}
// 面板不验签(节点宿主机上的更新器验),所以测试里签名随便写一个格式对的
// 面板验发布签名(节点宿主机上的更新器之后还会再验):测试后端换成了测试密钥,清单用同一把签
private static string Manifest(string component, long seq, string digest = Digest) =>
$$"""{"schema":1,"component":"{{component}}","channel":"dev","image":"registry.example/moshiro-dev/moshiro-{{component}}","digest":"{{digest}}","tag":"dev-{{seq}}","version":"0.1.1","commit":"abc","built_at":"2026-10-06T00:00:00Z","seq":{{seq}}}""";
private async Task<HttpResponseMessage> PublishAsync(string manifest, string signature = "MEUCIQDc") =>
await _backend.Admin.PostAsync("/api/admin/releases", BackendFixture.Json(new JsonObject { ["manifest"] = manifest, ["signature"] = signature }));
private async Task<HttpResponseMessage> PublishAsync(string manifest, string? signature = null) =>
await _backend.Admin.PostAsync("/api/admin/releases", BackendFixture.Json(new JsonObject { ["manifest"] = manifest, ["signature"] = signature ?? TestReleaseKey.Sign(manifest) }));
[Fact]
public async Task Release_IsForwardedByteForByte_ToOnlineNodesOfThatComponent_AndToNodesThatConnectLater()
@@ -35,14 +35,15 @@ public sealed class UpdateTests
long seq = NextSeq();
await using AgentSimulator online = await _backend.ConnectAgentAsync();
string manifest = Manifest("agent", seq);
string signature = TestReleaseKey.Sign(manifest); // ECDSA 每次签出来都不一样:签一次,登记和比较共用
using HttpResponseMessage published = await PublishAsync(manifest);
using HttpResponseMessage published = await PublishAsync(manifest, signature);
Assert.Equal(HttpStatusCode.Created, published.StatusCode);
JsonNode release = JsonNode.Parse(await published.Content.ReadAsStringAsync())!;
Assert.Equal("agent", release["component"]!.GetValue<string>());
Assert.Equal(Digest, release["digest"]!.GetValue<string>());
await BackendFixture.EventuallyAsync(() => Task.FromResult(online.UpdateOffers.ToArray().Any(o => o.Manifest == manifest && o.Signature == "MEUCIQDc")), because: "the offer reaches online agents unchanged");
await BackendFixture.EventuallyAsync(() => Task.FromResult(online.UpdateOffers.ToArray().Any(o => o.Manifest == manifest && o.Signature == signature)), because: "the offer reaches online agents unchanged");
// 之后才连上的节点,hello 之后补发
await using AgentSimulator late = await _backend.ConnectAgentAsync();
@@ -64,11 +65,16 @@ public sealed class UpdateTests
Assert.Equal(HttpStatusCode.BadRequest, (await PublishAsync(Manifest("unknown", seq))).StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, (await PublishAsync(Manifest("agent", seq), signature: "not base64!")).StatusCode);
using var other = System.Security.Cryptography.ECDsa.Create(System.Security.Cryptography.ECCurve.NamedCurves.nistP256);
string foreign = Convert.ToBase64String(other.SignData(System.Text.Encoding.UTF8.GetBytes(Manifest("agent", seq)), System.Security.Cryptography.HashAlgorithmName.SHA256, System.Security.Cryptography.DSASignatureFormat.Rfc3279DerSequence));
Assert.Equal(HttpStatusCode.Forbidden, (await PublishAsync(Manifest("agent", seq), foreign)).StatusCode); // 别人的密钥签的
Assert.Equal(HttpStatusCode.Forbidden, (await PublishAsync(Manifest("agent", seq), "MEUCIQDc")).StatusCode); // 格式对、签名不对
Assert.Equal(HttpStatusCode.Forbidden, (await PublishAsync(Manifest("agent", seq), TestReleaseKey.Sign(Manifest("agent", seq + 5)))).StatusCode); // 签的是另一份清单
Assert.Equal(HttpStatusCode.Created, (await PublishAsync(Manifest("agent", seq))).StatusCode);
Assert.Equal(HttpStatusCode.Conflict, (await PublishAsync(Manifest("agent", seq))).StatusCode); // 同一个 seq:重放
Assert.Equal(HttpStatusCode.Conflict, (await PublishAsync(Manifest("agent", seq - 1))).StatusCode); // 更旧的 seq
using HttpResponseMessage anonymous = await _backend.Anonymous.PostAsync("/api/admin/releases", BackendFixture.Json(new JsonObject { ["manifest"] = Manifest("agent", seq + 1), ["signature"] = "MEUC" }));
using HttpResponseMessage anonymous = await _backend.Anonymous.PostAsync("/api/admin/releases", BackendFixture.Json(new JsonObject { ["manifest"] = Manifest("agent", seq + 1), ["signature"] = TestReleaseKey.Sign(Manifest("agent", seq + 1)) }));
Assert.Contains(anonymous.StatusCode, new[] { HttpStatusCode.Unauthorized, HttpStatusCode.Forbidden });
}
@@ -92,9 +98,9 @@ public sealed class UpdateTests
long seq = NextSeq();
using HttpClient release = Client(await NewTokenAsync("release.publish"));
using HttpClient read = Client(await NewTokenAsync("public.read", "fleet.read"));
var body = BackendFixture.Json(new JsonObject { ["manifest"] = Manifest("probe", seq), ["signature"] = "MEUC" });
var body = BackendFixture.Json(new JsonObject { ["manifest"] = Manifest("probe", seq), ["signature"] = TestReleaseKey.Sign(Manifest("probe", seq)) });
using HttpResponseMessage forbidden = await read.PostAsync("/api/admin/releases", BackendFixture.Json(new JsonObject { ["manifest"] = Manifest("probe", seq), ["signature"] = "MEUC" }));
using HttpResponseMessage forbidden = await read.PostAsync("/api/admin/releases", BackendFixture.Json(new JsonObject { ["manifest"] = Manifest("probe", seq), ["signature"] = TestReleaseKey.Sign(Manifest("probe", seq)) }));
Assert.Equal(HttpStatusCode.Forbidden, forbidden.StatusCode);
using HttpResponseMessage published = await release.PostAsync("/api/admin/releases", body);
Assert.Equal(HttpStatusCode.Created, published.StatusCode);
@@ -103,7 +109,7 @@ public sealed class UpdateTests
Assert.Equal(HttpStatusCode.Forbidden, (await release.GetAsync("/api/admin/agents")).StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await release.GetAsync("/api/admin/releases")).StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await release.PostAsync("/api/admin/nodes/agt_x/update", null)).StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized, (await _backend.Anonymous.PostAsync("/api/admin/releases", BackendFixture.Json(new JsonObject { ["manifest"] = Manifest("probe", seq + 1), ["signature"] = "MEUC" }))).StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized, (await _backend.Anonymous.PostAsync("/api/admin/releases", BackendFixture.Json(new JsonObject { ["manifest"] = Manifest("probe", seq + 1), ["signature"] = TestReleaseKey.Sign(Manifest("probe", seq + 1)) }))).StatusCode);
}
[Fact]