Files
MoShiro/backend/tests/MoShiro.Backend.Tests/ReleaseOfferTests.cs
T
justme 904ea2b0ae
ci/woodpecker/push/main-amd64-build Pipeline was successful
refactor: remove the runtime binary binding; every release registration path verifies the CI signature [CI]
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.
2026-10-08 18:30:05 +08:00

102 lines
5.4 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Net;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Nodes;
using MoShiro.Backend.Api;
using MoShiro.Backend.Application;
namespace MoShiro.Backend.Tests;
/// <summary>
/// CI 登记清单不需要令牌,但清单必须带 CI 发布密钥的有效签名:令牌存在面板的数据库里,清库、重装或还没有 passkey 的时候就拿不到,
/// 而清单本来就是签名的,面板直接验签更简单,也没有长期令牌可偷。
/// </summary>
[Collection("probe-e2e")]
public sealed class ReleaseOfferTests
{
// openssl 生成的测试密钥对和签名(`openssl dgst -sha256 -sign`,DER):证明验签接受 CI 脚本实际产出的格式
private const string TestKey = "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEQwlE2KjYWszeaTsng6CfPxhOqeIV\n+7dm940f7XfHJr9A6/OTiZ2YnhFWWipZ4Z4poPtb6hMW04tIARO8+Km34w==\n-----END PUBLIC KEY-----";
private const string Manifest = "{\"schema\":1,\"component\":\"agent\",\"seq\":7}";
private const string OpenSslSignature = "MEUCIDrOloi3otWBiCFcXEsPg6kHCS7G09PrO3hW+3AKXAiZAiEAvXgt9K+iZXnWiEgKdanBKbEoL0LyT/xuaEW6dAG0jDY=";
private readonly BackendFixture _backend;
public ReleaseOfferTests(BackendFixture backend)
{
_backend = backend;
}
[Fact]
public void ASignatureFromOpenSsl_Verifies_AndAnyChangeDoesNot()
{
Assert.True(ReleaseSignature.Verify(TestKey, Manifest, OpenSslSignature));
Assert.False(ReleaseSignature.Verify(TestKey, Manifest.Replace("7", "8", StringComparison.Ordinal), OpenSslSignature));
Assert.False(ReleaseSignature.Verify(TestKey, Manifest + " ", OpenSslSignature));
byte[] tampered = Convert.FromBase64String(OpenSslSignature);
tampered[10] ^= 1;
Assert.False(ReleaseSignature.Verify(TestKey, Manifest, Convert.ToBase64String(tampered)));
}
[Fact]
public void AnotherKey_GarbageAndEmptyInputs_AreRejected()
{
using var other = ECDsa.Create(ECCurve.NamedCurves.nistP256);
string signature = Convert.ToBase64String(other.SignData(Encoding.UTF8.GetBytes(Manifest), HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence));
Assert.False(ReleaseSignature.Verify(TestKey, Manifest, signature));
Assert.False(ReleaseSignature.Verify(TestKey, Manifest, "not base64!!"));
Assert.False(ReleaseSignature.Verify(TestKey, Manifest, ""));
Assert.False(ReleaseSignature.Verify("not a key", Manifest, OpenSslSignature));
// P1363(节点和 WebAuthn 用的格式)不是 CI 签名的格式
Assert.False(ReleaseSignature.Verify(TestKey, Manifest, Convert.ToBase64String(new byte[64])));
}
[Fact]
public void TheEmbeddedReleaseKey_IsAUsableP256PublicKey()
{
using var key = ECDsa.Create();
key.ImportFromPem(InstallScripts.ReleaseKey());
Assert.Equal(256, key.KeySize);
}
[Theory]
[InlineData("{\"component\":\"agent\",\"channel\":\"dev\",\"image\":\"r/a\",\"digest\":\"sha256:0000000000000000000000000000000000000000000000000000000000000000\",\"seq\":99999999999}", "AAAA")]
[InlineData("", "")]
[InlineData("not json", "MEUCIDrOloi3otWBiCFcXEsPg6kHCS7G09PrO3hW+3AKXAiZAiEAvXgt9K+iZXnWiEgKdanBKbEoL0LyT/xuaEW6dAG0jDY=")]
public async Task OfferWithoutAValidCiSignature_IsForbidden_EvenWithoutAnyToken(string manifest, string signature)
{
using HttpClient anonymous = _backend.NewClient();
using HttpResponseMessage response = await anonymous.PostAsync("/api/release/offer", BackendFixture.Json(new JsonObject { ["manifest"] = manifest, ["signature"] = signature }));
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()
{
using HttpClient anonymous = _backend.NewClient();
using HttpResponseMessage response = await anonymous.PostAsync("/api/admin/releases", BackendFixture.Json(new JsonObject { ["manifest"] = Manifest, ["signature"] = OpenSslSignature }));
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
}