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; /// /// CI 登记清单不需要令牌,但清单必须带 CI 发布密钥的有效签名:令牌存在面板的数据库里,清库、重装或还没有 passkey 的时候就拿不到, /// 而清单本来就是签名的,面板直接验签更简单,也没有长期令牌可偷。 /// [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); } }