Files
MoShiro/backend/tests/MoShiro.Backend.Tests/ReleaseOfferTests.cs
T
justme 0f8f3f9a54
ci/woodpecker/push/main-amd64-build Pipeline was successful
feat: trusted release keys setting; drop the duplicate release registration route [CI]
The panel now lets the deployer decide which release keys it trusts. The
official CI key is built in and always trusted; an own P-256 public key can be
added (off by default, the private key is rejected) so manifests signed with it
are accepted too. Removing it drops the manifests only it signed. Changes are
audited and need update.confirm.

Admin UI: System > Trust > "Trusted release keys" with a collapsible guide on
generating a key pair and signing a manifest with openssl.

Registration now has one path, POST /api/release/offer (no token, signature
verified). The admin POST /releases route, the release.publish capability and
the ci-release preset are removed.
2026-10-08 18:50:16 +08:00

88 lines
4.5 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()
{
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);
}
}