feat: trusted release keys setting; drop the duplicate release registration route [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful
ci/woodpecker/push/main-amd64-build Pipeline was successful
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.
This commit is contained in:
1 parent
904ea2b0ae
commit
0f8f3f9a54
24 files changed
+914
-325
No files matched your search
@@ -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"] = TestReleaseKey.Sign(manifest) }));
|
||||
return await _backend.Anonymous.PostAsync("/api/release/offer", BackendFixture.Json(new JsonObject { ["manifest"] = manifest, ["signature"] = TestReleaseKey.Sign(manifest) }));
|
||||
}
|
||||
|
||||
private Task<JsonNode> ViewAsync() => _backend.GetJsonAsync(_backend.Admin, "/api/admin/backend/update");
|
||||
|
||||
@@ -121,12 +121,4 @@ public sealed class CapabilityTests
|
||||
Assert.Contains(catalog["presets"]!.AsArray(), p => p!["id"]!.GetValue<string>() == "all");
|
||||
Assert.Contains(catalog["groups"]!.AsArray().SelectMany(g => g!["capabilities"]!.AsArray()), c => c!["id"]!.GetValue<string>() == Capability.FleetRevoke && c["trust"]!.GetValue<bool>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReleaseKey_CanOnlyPublishReleases()
|
||||
{
|
||||
using HttpClient ci = await KeyAsync(Capability.ReleasePublish);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, (await ci.GetAsync("/api/admin/agents")).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, (await ci.GetAsync("/api/admin/backend/update")).StatusCode);
|
||||
}
|
||||
}
|
||||
@@ -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"] = TestReleaseKey.Sign(manifest) }));
|
||||
using HttpResponseMessage published = await _backend.Anonymous.PostAsync("/api/release/offer", 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>();
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
using System.Net;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using MoShiro.Backend.Application;
|
||||
|
||||
namespace MoShiro.Backend.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 可信资产来源:官方发布密钥始终信任;部署的人可以另外导入自己的发布公钥(默认没有),用自己的私钥给自己构建的清单签名。
|
||||
/// 面板只收公钥,拒绝私钥;删除之后只有它签过的清单不再被接受,已登记的也丢掉。
|
||||
/// </summary>
|
||||
[Collection("probe-e2e")]
|
||||
public sealed class ReleaseKeyTests
|
||||
{
|
||||
private static long s_seq = TimeProvider.System.GetUtcNow().ToUnixTimeSeconds() * 1000 + 500_000;
|
||||
|
||||
private readonly BackendFixture _backend;
|
||||
|
||||
public ReleaseKeyTests(BackendFixture backend)
|
||||
{
|
||||
_backend = backend;
|
||||
}
|
||||
|
||||
private static string Manifest(long seq) =>
|
||||
$$"""{"schema":1,"component":"backend","channel":"dev","image":"registry.example/moshiro-dev/moshiro-backend","digest":"sha256:1111111111111111111111111111111111111111111111111111111111111111","tag":"own-{{seq}}","version":"0.1.1","seq":{{seq}}}""";
|
||||
|
||||
private static string Sign(ECDsa key, string manifest) =>
|
||||
Convert.ToBase64String(key.SignData(Encoding.UTF8.GetBytes(manifest), HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence));
|
||||
|
||||
private async Task<HttpResponseMessage> PutKeyAsync(string? pem) =>
|
||||
await _backend.Admin.PutAsync("/api/admin/release-key", BackendFixture.Json(new JsonObject { ["public_key"] = pem }));
|
||||
|
||||
private async Task<HttpResponseMessage> OfferAsync(string manifest, string signature)
|
||||
{
|
||||
using HttpClient anonymous = _backend.NewClient();
|
||||
return await anonymous.PostAsync("/api/release/offer", BackendFixture.Json(new JsonObject { ["manifest"] = manifest, ["signature"] = signature }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ByDefaultOnlyTheOfficialKeyIsTrusted_AndItCannotBeRemoved()
|
||||
{
|
||||
using HttpResponseMessage cleared = await _backend.Admin.DeleteAsync("/api/admin/release-key");
|
||||
Assert.Equal(HttpStatusCode.OK, cleared.StatusCode);
|
||||
|
||||
JsonNode keys = JsonNode.Parse(await cleared.Content.ReadAsStringAsync())!;
|
||||
Assert.Null(keys["custom"]);
|
||||
Assert.Equal(64, keys["official"]!["fingerprint"]!.GetValue<string>().Length);
|
||||
Assert.StartsWith("-----BEGIN PUBLIC KEY-----", keys["official"]!["public_key"]!.GetValue<string>(), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("not a key")]
|
||||
[InlineData(null)]
|
||||
public async Task ABadKey_IsRefused(string? pem)
|
||||
{
|
||||
using HttpResponseMessage response = await PutKeyAsync(pem);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ThePrivateKey_IsNeverAccepted_AndOnlyP256IsAllowed()
|
||||
{
|
||||
using var p256 = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
using var p384 = ECDsa.Create(ECCurve.NamedCurves.nistP384);
|
||||
|
||||
using HttpResponseMessage privateKey = await PutKeyAsync(p256.ExportPkcs8PrivateKeyPem());
|
||||
using HttpResponseMessage ecPrivateKey = await PutKeyAsync(p256.ExportECPrivateKeyPem());
|
||||
using HttpResponseMessage otherCurve = await PutKeyAsync(p384.ExportSubjectPublicKeyInfoPem());
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, privateKey.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, ecPrivateKey.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, otherCurve.StatusCode);
|
||||
Assert.Contains("private key", await privateKey.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AManifestSignedByTheOwnKey_IsAccepted_UntilTheKeyIsRemoved()
|
||||
{
|
||||
using var own = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
long first = Interlocked.Increment(ref s_seq);
|
||||
long second = Interlocked.Increment(ref s_seq);
|
||||
|
||||
// 还没有导入:自己的密钥签的清单不被接受
|
||||
Assert.Equal(HttpStatusCode.Forbidden, (await OfferAsync(Manifest(first), Sign(own, Manifest(first)))).StatusCode);
|
||||
|
||||
using HttpResponseMessage set = await PutKeyAsync(own.ExportSubjectPublicKeyInfoPem());
|
||||
Assert.Equal(HttpStatusCode.OK, set.StatusCode);
|
||||
string fingerprint = Convert.ToHexStringLower(SHA256.HashData(own.ExportSubjectPublicKeyInfo()));
|
||||
Assert.Equal(fingerprint, JsonNode.Parse(await set.Content.ReadAsStringAsync())!["custom"]!["fingerprint"]!.GetValue<string>());
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, (await OfferAsync(Manifest(first), Sign(own, Manifest(first)))).StatusCode);
|
||||
ReleaseService releases = _backend.Services.GetRequiredService<ReleaseService>();
|
||||
Assert.Equal(first, (await releases.GetBackendAsync("dev", default))!.Seq);
|
||||
|
||||
// 别人的密钥照样不行
|
||||
using var stranger = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, (await OfferAsync(Manifest(second), Sign(stranger, Manifest(second)))).StatusCode);
|
||||
|
||||
// 删除之后:只有它签过的清单不再被接受,已经登记的也丢掉
|
||||
using HttpResponseMessage cleared = await _backend.Admin.DeleteAsync("/api/admin/release-key");
|
||||
Assert.Equal(HttpStatusCode.OK, cleared.StatusCode);
|
||||
Assert.Null(JsonNode.Parse(await cleared.Content.ReadAsStringAsync())!["custom"]);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, (await OfferAsync(Manifest(second), Sign(own, Manifest(second)))).StatusCode);
|
||||
Assert.Null(await releases.GetBackendAsync("dev", default));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OnlyAnAdministrator_MayChangeTheTrustedKeys()
|
||||
{
|
||||
using HttpClient anonymous = _backend.NewClient();
|
||||
using var own = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
|
||||
using HttpResponseMessage read = await anonymous.GetAsync("/api/admin/release-key");
|
||||
using HttpResponseMessage write = await anonymous.PutAsync("/api/admin/release-key", BackendFixture.Json(new JsonObject { ["public_key"] = own.ExportSubjectPublicKeyInfoPem() }));
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, read.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, write.StatusCode);
|
||||
}
|
||||
}
|
||||
@@ -75,7 +75,7 @@ public sealed class ReleaseOfferTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OfferWithAValidSignature_IsAccepted_WithoutAnyToken_AndTheAdminEndpointChecksTheSignatureToo()
|
||||
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}}}""";
|
||||
@@ -83,19 +83,5 @@ public sealed class ReleaseOfferTests
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -26,8 +26,11 @@ public sealed class UpdateTests
|
||||
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 = null) =>
|
||||
await _backend.Admin.PostAsync("/api/admin/releases", BackendFixture.Json(new JsonObject { ["manifest"] = manifest, ["signature"] = signature ?? TestReleaseKey.Sign(manifest) }));
|
||||
private async Task<HttpResponseMessage> PublishAsync(string manifest, string? signature = null)
|
||||
{
|
||||
signature ??= TestReleaseKey.Sign(manifest);
|
||||
return await _backend.Anonymous.PostAsync("/api/release/offer", BackendFixture.Json(new JsonObject { ["manifest"] = manifest, ["signature"] = signature }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Release_IsForwardedByteForByte_ToOnlineNodesOfThatComponent_AndToNodesThatConnectLater()
|
||||
@@ -57,13 +60,13 @@ public sealed class UpdateTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Release_Registration_RefusesBadManifests_StaleSeq_AndNeedsAnAdmin()
|
||||
public async Task Release_Registration_RefusesBadManifests_BadSignatures_AndStaleSeq()
|
||||
{
|
||||
long seq = NextSeq();
|
||||
Assert.Equal(HttpStatusCode.BadRequest, (await PublishAsync("{not json")).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, (await PublishAsync(Manifest("agent", seq, digest: "sha256:short"))).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, (await PublishAsync(Manifest("unknown", seq))).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, (await PublishAsync(Manifest("agent", seq), signature: "not base64!")).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, (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));
|
||||
@@ -73,43 +76,6 @@ public sealed class UpdateTests
|
||||
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"] = TestReleaseKey.Sign(Manifest("agent", seq + 1)) }));
|
||||
Assert.Contains(anonymous.StatusCode, new[] { HttpStatusCode.Unauthorized, HttpStatusCode.Forbidden });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReleaseToken_CanOnlyRegisterReleases_AndAReadTokenCannotEvenDoThat()
|
||||
{
|
||||
async Task<string> NewTokenAsync(params string[] capabilities)
|
||||
{
|
||||
using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/api-tokens", BackendFixture.Json(new JsonObject { ["name"] = _backend.UniqueName("ci"), ["capabilities"] = BackendFixture.Capabilities(capabilities) }));
|
||||
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
|
||||
return JsonNode.Parse(await created.Content.ReadAsStringAsync())!["plaintext"]!.GetValue<string>();
|
||||
}
|
||||
|
||||
HttpClient Client(string token)
|
||||
{
|
||||
var client = new HttpClient { BaseAddress = _backend.BaseAddress };
|
||||
client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
|
||||
return client;
|
||||
}
|
||||
|
||||
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"] = TestReleaseKey.Sign(Manifest("probe", seq)) });
|
||||
|
||||
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);
|
||||
|
||||
// release 令牌只能登记发布清单:读不了任何东西,也不能做别的管理操作
|
||||
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"] = TestReleaseKey.Sign(Manifest("probe", seq + 1)) }))).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
Reference in new issue
Block a user