feat: CI registers release manifests without a token; the panel verifies the CI signature instead [CI]
ci/woodpecker/push/main-amd64-build Pipeline failed
ci/woodpecker/push/main-amd64-build Pipeline failed
This commit is contained in:
1 parent
fbda9487d5
commit
f704c66dd9
15 files changed
+278
-19
No files matched your search
@@ -5020,6 +5020,67 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/release/offer": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"release"
|
||||
],
|
||||
"summary": "Register a release manifest that carries a valid CI signature (no token needed)",
|
||||
"operationId": "release_offer",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PublishReleaseRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Created",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ReleaseDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Conflict",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/agent/v1/enroll": {
|
||||
"post": {
|
||||
"tags": [
|
||||
@@ -9384,6 +9445,9 @@
|
||||
{
|
||||
"name": "ops"
|
||||
},
|
||||
{
|
||||
"name": "release"
|
||||
},
|
||||
{
|
||||
"name": "agent"
|
||||
}
|
||||
|
||||
@@ -45,6 +45,31 @@ public static class BuildEndpoints
|
||||
.Requires(Capability.ReleasePublish);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CI 登记清单的入口,<b>不需要令牌</b>:请求里的清单必须带有效的 CI 发布签名(用 Backend 内嵌的发布公钥验)才收下。
|
||||
/// 这样登记不依赖面板里的任何凭证(清库、重装、还没有 passkey 的时候照常工作),也没有一把能被偷的长期令牌;
|
||||
/// 旧清单重放由 <c>seq</c> 递增挡住,节点的更新器还会用本机公钥再验一次。
|
||||
/// </summary>
|
||||
public static void MapOffer(IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapPost("/api/release/offer", OfferReleaseAsync)
|
||||
.Api("release_offer", "Register a release manifest that carries a valid CI signature (no token needed)", "release")
|
||||
.Returns<ReleaseDto>(StatusCodes.Status201Created).ProducesProblems(400, 403, 409);
|
||||
}
|
||||
|
||||
private static async Task<IResult> OfferReleaseAsync(HttpContext http, ReleaseService releases, PublishReleaseRequest request)
|
||||
{
|
||||
string manifest = request.Manifest ?? "";
|
||||
string signature = request.Signature ?? "";
|
||||
if (manifest.Length is 0 or > ReleaseService.MaxManifestBytes || signature.Length is 0 or > ReleaseService.MaxSignatureBytes
|
||||
|| !ReleaseSignature.Verify(InstallScripts.ReleaseKey(), manifest, signature))
|
||||
{
|
||||
return Problems.Create(http, MoShiroErrors.Forbidden, "the manifest does not carry a valid release signature");
|
||||
}
|
||||
|
||||
return await PublishReleaseAsync(http, releases, request);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetAsync(HttpContext http, NodeInfoService nodes, ReleaseService releases, BackendUpdates backendUpdates, CancellationToken ct)
|
||||
{
|
||||
var items = (await nodes.ListAsync(ct))
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace MoShiro.Backend.Api;
|
||||
|
||||
/// <summary>
|
||||
/// 发布清单的签名校验:用 Backend 内嵌的 CI 发布公钥(<c>packaging/update/release-key.pub.pem</c>)验 ECDSA P-256 / SHA-256(DER)签名,签名对象是清单的原始字节。
|
||||
/// 登记清单因此不需要任何令牌:能被登记的只有 CI 签过的清单,节点的更新器之后还会用本机的公钥再验一遍。
|
||||
/// </summary>
|
||||
public static class ReleaseSignature
|
||||
{
|
||||
public static bool Verify(string publicKeyPem, string manifest, string signatureBase64)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var key = ECDsa.Create();
|
||||
key.ImportFromPem(publicKeyPem);
|
||||
return key.VerifyData(Encoding.UTF8.GetBytes(manifest), Convert.FromBase64String(signatureBase64), HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence);
|
||||
}
|
||||
catch (Exception ex) when (ex is FormatException or CryptographicException or ArgumentException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -118,6 +118,7 @@ public static class ApiModule
|
||||
LinkEndpoints.Map(guarded);
|
||||
|
||||
OpsEndpoints.Map(app);
|
||||
BuildEndpoints.MapOffer(app);
|
||||
app.MapOpenApi("/openapi/v1.json");
|
||||
app.MapStaticSite();
|
||||
return app;
|
||||
|
||||
@@ -12,7 +12,7 @@ public sealed record ReleaseOffer(string Component, long Seq, string Tag, string
|
||||
|
||||
/// <summary>
|
||||
/// 发布清单的分发:CI 签名之后推给面板,面板存下来并原样转给在线的 Agent、Probe(节点连上时再补发一次)。
|
||||
/// <b>面板不验签、也不决定拉什么</b>:节点宿主机上的更新器用本机的发布公钥验签,并且只认本机配置的镜像仓库;
|
||||
/// 登记入口(<c>/api/release/offer</c>)只收带有效 CI 签名的清单;面板<b>不决定拉什么</b>:节点宿主机上的更新器用本机的发布公钥验签,并且只认本机配置的镜像仓库;
|
||||
/// 所以面板即使被攻破,也推不出一个能被装上的假镜像。面板只负责「通知」和「请求更新」,状态由节点随心跳报回来。
|
||||
/// </summary>
|
||||
public sealed partial class ReleaseService : IDisposable
|
||||
|
||||
@@ -53,7 +53,7 @@ public sealed partial class OpenApiConformanceTests
|
||||
foreach ((string path, _) in doc["paths"]!.AsObject())
|
||||
{
|
||||
string[] prefixes = ["/api/public/", "/api/admin/", "/api/agent/v1/"];
|
||||
string[] roots = ["/healthz", "/metrics", "/install", "/install.ps1", "/install/manifest", "/install/manifest.sig"];
|
||||
string[] roots = ["/healthz", "/metrics", "/install", "/install.ps1", "/install/manifest", "/install/manifest.sig", "/api/release/offer"];
|
||||
Assert.True(prefixes.Any(p => path.StartsWith(p, StringComparison.Ordinal)) || roots.Contains(path), $"{path} is outside the documented layers");
|
||||
foreach (string segment in path.Split('/', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
using System.Net;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
using MoShiro.Backend.Api;
|
||||
|
||||
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 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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user