feat: trusted release keys setting; drop the duplicate release registration route [CI]
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:
justme committed 2026-10-08 18:50:16 +08:00
1 parent 904ea2b0ae
commit 0f8f3f9a54
24 files changed
+914 -325

No files matched your search

+241 -115
View File
@@ -1369,118 +1369,6 @@
}
}
},
"/api/admin/releases": {
"post": {
"tags": [
"admin-build"
],
"summary": "Register a CI-signed release manifest (the signature is checked against the release key); online nodes get it right away. Admin or release tokens",
"operationId": "releases_publish",
"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"
}
}
}
},
"401": {
"description": "Unauthorized",
"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"
}
}
}
}
},
"x-moshiro-capability": "release.publish"
},
"get": {
"tags": [
"admin-build"
],
"summary": "The latest signed release manifest of each component",
"operationId": "releases_list",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PageDtoOfReleaseDto"
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDto"
}
}
}
},
"403": {
"description": "Forbidden",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDto"
}
}
}
}
},
"x-moshiro-capability": "update.read"
}
},
"/api/admin/auth/logout": {
"post": {
"tags": [
@@ -4412,6 +4300,48 @@
"x-moshiro-capability": "update.read"
}
},
"/api/admin/releases": {
"get": {
"tags": [
"admin-build"
],
"summary": "The latest signed release manifest of each component",
"operationId": "releases_list",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PageDtoOfReleaseDto"
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDto"
}
}
}
},
"403": {
"description": "Forbidden",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDto"
}
}
}
}
},
"x-moshiro-capability": "update.read"
}
},
"/api/admin/nodes/{id}/update": {
"post": {
"tags": [
@@ -4617,6 +4547,148 @@
"x-moshiro-capability": "update.read"
}
},
"/api/admin/release-key": {
"get": {
"tags": [
"admin-release-key"
],
"summary": "The release keys this panel trusts: the built-in official key and the optional own key",
"operationId": "release_key_get",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ReleaseKeysDto"
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDto"
}
}
}
},
"403": {
"description": "Forbidden",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDto"
}
}
}
}
},
"x-moshiro-capability": "update.read"
},
"put": {
"tags": [
"admin-release-key"
],
"summary": "Trust your own release public key (P-256 PEM; never the private key) in addition to the official one",
"operationId": "release_key_set",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SetReleaseKeyRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ReleaseKeysDto"
}
}
}
},
"400": {
"description": "Bad Request",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDto"
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDto"
}
}
}
},
"403": {
"description": "Forbidden",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDto"
}
}
}
}
},
"x-moshiro-capability": "update.confirm"
},
"delete": {
"tags": [
"admin-release-key"
],
"summary": "Stop trusting your own release key; manifests only it signed are dropped",
"operationId": "release_key_clear",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ReleaseKeysDto"
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDto"
}
}
}
},
"403": {
"description": "Forbidden",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDto"
}
}
}
}
},
"x-moshiro-capability": "update.confirm"
}
},
"/api/admin/links": {
"get": {
"tags": [
@@ -8575,6 +8647,43 @@
}
}
},
"ReleaseKeyDto": {
"required": [
"fingerprint",
"public_key"
],
"type": "object",
"properties": {
"fingerprint": {
"type": "string"
},
"public_key": {
"type": "string"
}
}
},
"ReleaseKeysDto": {
"required": [
"official",
"custom"
],
"type": "object",
"properties": {
"official": {
"$ref": "#/components/schemas/ReleaseKeyDto"
},
"custom": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/ReleaseKeyDto"
}
]
}
}
},
"ScoreDto": {
"required": [
"score",
@@ -8737,6 +8846,20 @@
}
}
},
"SetReleaseKeyRequest": {
"required": [
"public_key"
],
"type": "object",
"properties": {
"public_key": {
"type": [
"null",
"string"
]
}
}
},
"SettingDto": {
"required": [
"key",
@@ -9343,9 +9466,6 @@
{
"name": "admin-auth"
},
{
"name": "admin-build"
},
{
"name": "admin-agents"
},
@@ -9379,6 +9499,12 @@
{
"name": "admin-users"
},
{
"name": "admin-build"
},
{
"name": "admin-release-key"
},
{
"name": "admin-links"
},
@@ -36,15 +36,6 @@ public static class BuildEndpoints
.Requires(Capability.UpdateRead);
}
/// <summary>登记发布清单:挂在 <see cref="ReleaseGuard"/> 下,除了管理员,CI 用的 <c>release</c> 令牌也能调用(它只能做这一件事)。</summary>
public static void MapPublish(RouteGroupBuilder group)
{
group.MapPost("/releases", PublishReleaseAsync)
.Api("releases_publish", "Register a CI-signed release manifest (the signature is checked against the release key); online nodes get it right away. Admin or release tokens", Tag)
.Returns<ReleaseDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409)
.Requires(Capability.ReleasePublish);
}
/// <summary>
/// CI 登记清单的入口,<b>不需要令牌</b>:请求里的清单必须带有效的 CI 发布签名(用 Backend 内嵌的发布公钥验)才收下。
/// 这样登记不依赖面板里的任何凭证(清库、重装、还没有 passkey 的时候照常工作),也没有一把能被偷的长期令牌;
@@ -57,12 +48,12 @@ public static class BuildEndpoints
.Returns<ReleaseDto>(StatusCodes.Status201Created).ProducesProblems(400, 403, 409);
}
private static async Task<IResult> OfferReleaseAsync(HttpContext http, ReleaseService releases, IReleaseKey releaseKey, PublishReleaseRequest request)
private static async Task<IResult> OfferReleaseAsync(HttpContext http, ReleaseService releases, ReleaseKeyService keys, 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(releaseKey.Pem, manifest, signature))
|| !await keys.VerifyAsync(manifest, signature, http.RequestAborted))
{
return Problems.Create(http, MoShiroErrors.Forbidden, "the manifest does not carry a valid release signature");
}
@@ -0,0 +1,54 @@
using Fleet.Core;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using MoShiro.Backend.Application;
namespace MoShiro.Backend.Api;
/// <summary>可信资产来源:官方发布密钥(只读)和可选的自有发布公钥(默认没有)。改动写审计。</summary>
public static class ReleaseKeyEndpoints
{
private const string Tag = "admin-release-key";
public static void Map(RouteGroupBuilder group)
{
group.MapGet("/release-key", GetAsync)
.Api("release_key_get", "The release keys this panel trusts: the built-in official key and the optional own key", Tag)
.Returns<ReleaseKeysDto>().ProducesProblems(401, 403)
.Requires(Capability.UpdateRead);
group.MapPut("/release-key", SetAsync)
.Api("release_key_set", "Trust your own release public key (P-256 PEM; never the private key) in addition to the official one", Tag)
.Returns<ReleaseKeysDto>().ProducesProblems(400, 401, 403)
.Requires(Capability.UpdateConfirm);
group.MapDelete("/release-key", ClearAsync)
.Api("release_key_clear", "Stop trusting your own release key; manifests only it signed are dropped", Tag)
.Returns<ReleaseKeysDto>().ProducesProblems(401, 403)
.Requires(Capability.UpdateConfirm);
}
private static async Task<IResult> GetAsync(ReleaseKeyService keys, CancellationToken ct) => Json(await keys.GetAsync(ct));
private static async Task<IResult> SetAsync(HttpContext http, ReleaseKeyService keys, SetReleaseKeyRequest request)
{
Result<ReleaseKeys> result = await keys.SetCustomAsync(request.PublicKey, http.RequestAborted);
if (!result.TryGet(out ReleaseKeys? view, out FleetError? error))
{
return Problems.From(http, error);
}
http.SetDetail("own release key " + view.Custom!.Fingerprint);
return Json(view);
}
private static async Task<IResult> ClearAsync(HttpContext http, ReleaseKeyService keys, ReleaseService releases)
{
await keys.ClearCustomAsync(http.RequestAborted);
await releases.PurgeUnverifiedAsync(http.RequestAborted);
http.SetDetail("own release key removed");
return Json(await keys.GetAsync(http.RequestAborted));
}
private static Microsoft.AspNetCore.Http.HttpResults.JsonHttpResult<ReleaseKeysDto> Json(ReleaseKeys keys) =>
TypedResults.Json(new ReleaseKeysDto(new ReleaseKeyDto(keys.Official.Fingerprint, keys.Official.PublicKey), keys.Custom is null ? null : new ReleaseKeyDto(keys.Custom.Fingerprint, keys.Custom.PublicKey)), ApiJsonContext.Default.ReleaseKeysDto);
}
+1 -2
View File
@@ -105,8 +105,6 @@ public static class ApiModule
RouteGroupBuilder admin = app.MapGroup("/api/admin");
AuthEndpoints.MapLogin(admin);
BuildEndpoints.MapPublish(admin.MapGroup(string.Empty).AddEndpointFilter(CapabilityGuard.InvokeAsync));
RouteGroupBuilder guarded = admin.MapGroup(string.Empty).AddEndpointFilter(CapabilityGuard.InvokeAsync);
AuthEndpoints.MapSession(guarded);
AgentEndpoints.Map(guarded);
@@ -116,6 +114,7 @@ public static class ApiModule
TrustEndpoints.Map(guarded);
SystemEndpoints.Map(guarded);
BuildEndpoints.Map(guarded);
ReleaseKeyEndpoints.Map(guarded);
LinkEndpoints.Map(guarded);
OpsEndpoints.Map(app);
@@ -198,6 +198,14 @@ public sealed record ReleaseDto(string Component, long Seq, string Tag, string V
public sealed record PublishReleaseRequest(string? Manifest, string? Signature);
/// <summary>一把发布公钥:指纹是 SPKI 的 SHA-256(小写十六进制)。</summary>
public sealed record ReleaseKeyDto(string Fingerprint, string PublicKey);
/// <summary>可信资产来源:官方发布密钥(始终信任)和可选的自有发布公钥(默认没有)。</summary>
public sealed record ReleaseKeysDto(ReleaseKeyDto Official, ReleaseKeyDto? Custom);
public sealed record SetReleaseKeyRequest(string? PublicKey);
public sealed record BackendUpdateDto(string Channel, bool Enabled, string State, string? CurrentTag, string? CurrentDigest, ReleaseDto? Available, string? Error);
public sealed record ConfirmBackendUpdateRequest(long Seq);
@@ -45,6 +45,8 @@ namespace MoShiro.Backend.Api;
[JsonSerializable(typeof(ReleaseDto))]
[JsonSerializable(typeof(PageDto<ReleaseDto>))]
[JsonSerializable(typeof(PublishReleaseRequest))]
[JsonSerializable(typeof(ReleaseKeysDto))]
[JsonSerializable(typeof(SetReleaseKeyRequest))]
[JsonSerializable(typeof(AboutDto))]
[JsonSerializable(typeof(NodeBuildDto))]
[JsonSerializable(typeof(NodeOwnerDto))]
@@ -40,6 +40,7 @@ public static class ApplicationServiceCollectionExtensions
services.AddSingleton<ServerIdentityService>();
services.AddSingleton<SetupService>();
services.AddSingleton<ReleaseFeed>();
services.AddSingleton<ReleaseKeyService>();
services.AddSingleton<NodeOwnerService>();
services.AddSingleton<Fleet.Server.IServerIdentity>(sp => sp.GetRequiredService<ServerIdentityService>());
services.AddSingleton<TrustService>();
@@ -27,7 +27,6 @@ public static class Capability
public const string UpdateRead = "update.read";
public const string UpdateConfirm = "update.confirm";
public const string ReleasePublish = "release.publish";
public const string KeysRead = "keys.read";
public const string KeysManage = "keys.manage";
@@ -74,7 +73,6 @@ public static class CapabilityCatalog
new(Capability.UpdateRead, "update"),
new(Capability.UpdateConfirm, "update", false, false, Capability.UpdateRead),
new(Capability.ReleasePublish, "update"),
new(Capability.KeysRead, "keys"),
new(Capability.KeysManage, "keys", false, false, Capability.KeysRead),
@@ -99,7 +97,6 @@ public static class CapabilityCatalog
new("read-only", [.. All.Where(c => !c.Reserved && c.Id.EndsWith(".read", StringComparison.Ordinal)).Select(c => c.Id)]),
new("operations", Expand([Capability.PublicRead, Capability.FleetWrite, Capability.TasksWrite, Capability.DataWrite, Capability.SettingsWrite, Capability.UpdateConfirm, Capability.EventsRead, Capability.AuditRead])),
new("enroll", Expand([Capability.FleetEnroll])),
new("ci-release", [Capability.ReleasePublish]),
new("all", [.. All.Select(c => c.Id)]),
];
@@ -0,0 +1,105 @@
using System.Security.Cryptography;
using Fleet.Core;
namespace MoShiro.Backend.Application;
/// <summary>一把发布公钥的指纹(SPKI 的 SHA-256,小写十六进制)和 PEM。</summary>
public sealed record ReleaseKeyInfo(string Fingerprint, string PublicKey);
/// <summary>可信资产来源:官方 CI 发布密钥(内嵌,始终信任)和可选的自有发布公钥(默认没有)。</summary>
public sealed record ReleaseKeys(ReleaseKeyInfo Official, ReleaseKeyInfo? Custom);
/// <summary>
/// 面板信任哪些发布密钥:内嵌的官方 CI 公钥始终信任,不能关;部署的人可以另外导入<b>自己的发布公钥</b>(默认没有),
/// 用自己的私钥给自己构建的清单签名。验签(登记清单、取镜像仓库里的清单)通过官方或自有任意一把即可。
/// 私钥只在部署的人手里,面板只存公钥,拒绝带私钥的 PEM。
/// </summary>
public sealed class ReleaseKeyService
{
internal const string SettingKey = "release.custom_key";
private const int MaxPemBytes = 1024;
private const string P256Oid = "1.2.840.10045.3.1.7";
private readonly IReleaseKey _official;
private readonly ISettingsStore _settings;
public ReleaseKeyService(IReleaseKey official, ISettingsStore settings)
{
_official = official;
_settings = settings;
}
public async ValueTask<ReleaseKeys> GetAsync(CancellationToken ct) =>
new(Describe(_official.Pem)!, Describe(await CustomAsync(ct)));
/// <summary>清单的签名由官方密钥或自有密钥(开启时)验过。</summary>
public async ValueTask<bool> VerifyAsync(string manifest, string signature, CancellationToken ct) =>
ReleaseSignature.Verify(_official.Pem, manifest, signature)
|| (await CustomAsync(ct) is { } custom && ReleaseSignature.Verify(custom, manifest, signature));
public async ValueTask<Result<ReleaseKeys>> SetCustomAsync(string? publicKey, CancellationToken ct)
{
if (!TryNormalize(publicKey, out string? pem, out string? problem))
{
return ValidationError.Fail<ReleaseKeys>("public_key", problem);
}
await _settings.SetAsync(SettingKey, pem, ct);
return Result<ReleaseKeys>.Ok(await GetAsync(ct));
}
public async ValueTask ClearCustomAsync(CancellationToken ct) => await _settings.SetAsync(SettingKey, string.Empty, ct);
private async ValueTask<string?> CustomAsync(CancellationToken ct) =>
await _settings.GetAsync(SettingKey, ct) is { Length: > 0 } pem ? pem : null;
private static ReleaseKeyInfo? Describe(string? pem)
{
if (pem is null)
{
return null;
}
using var key = ECDsa.Create();
key.ImportFromPem(pem);
return new ReleaseKeyInfo(Convert.ToHexStringLower(SHA256.HashData(key.ExportSubjectPublicKeyInfo())), key.ExportSubjectPublicKeyInfoPem().Trim());
}
/// <summary>只收 P-256 的公钥:带私钥的 PEM 直接拒绝(私钥不该离开你的机器)。</summary>
private static bool TryNormalize(string? text, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out string? pem, [System.Diagnostics.CodeAnalysis.NotNullWhen(false)] out string? problem)
{
pem = null;
problem = null;
string trimmed = (text ?? string.Empty).Trim();
if (trimmed.Length is 0 or > MaxPemBytes)
{
problem = "must be a PEM public key";
return false;
}
if (trimmed.Contains("PRIVATE", StringComparison.OrdinalIgnoreCase))
{
problem = "must be a public key: never upload the private key, keep it on your own machine";
return false;
}
try
{
using var key = ECDsa.Create();
key.ImportFromPem(trimmed);
if (key.ExportParameters(false).Curve.Oid.Value != P256Oid)
{
problem = "must be an ECDSA P-256 public key";
return false;
}
pem = key.ExportSubjectPublicKeyInfoPem().Trim();
return true;
}
catch (Exception ex) when (ex is CryptographicException or ArgumentException)
{
problem = "is not a valid PEM public key";
return false;
}
}
}
@@ -26,14 +26,14 @@ public sealed partial class ReleaseService : IDisposable
private readonly IAgentGateway _gateway;
private readonly IBackendUpdateFiles _backendFiles;
private readonly BackendOptions _options;
private readonly IReleaseKey _key;
private readonly ReleaseKeyService _keys;
private readonly ReleaseFeed _feed;
private readonly ConcurrentDictionary<string, long> _feedChecked = new();
private readonly ConcurrentDictionary<string, UpdateStatus> _status = new();
public ReleaseService(ISettingsStore settings, IAgentGateway gateway, IBackendUpdateFiles backendFiles, BackendOptions options, IReleaseKey key, ReleaseFeed feed)
public ReleaseService(ISettingsStore settings, IAgentGateway gateway, IBackendUpdateFiles backendFiles, BackendOptions options, ReleaseKeyService keys, ReleaseFeed feed)
{
_key = key;
_keys = keys;
_feed = feed;
_settings = settings;
_gateway = gateway;
@@ -53,7 +53,7 @@ public sealed partial class ReleaseService : IDisposable
return ValidationError.Fail<ReleaseOffer>("manifest", problem);
}
if (!ReleaseSignature.Verify(_key.Pem, manifest, signature))
if (!await _keys.VerifyAsync(manifest, signature, ct))
{
return Result<ReleaseOffer>.Fail(MoShiroErrors.Forbidden, "the manifest does not carry a valid release signature");
}
@@ -109,7 +109,7 @@ public sealed partial class ReleaseService : IDisposable
}
_feedChecked[component] = now;
if (await _feed.FetchAsync(component, BackendUpdates.ChannelOf(_options), ct) is { } fetched && ReleaseSignature.Verify(_key.Pem, fetched.Manifest, fetched.Signature))
if (await _feed.FetchAsync(component, BackendUpdates.ChannelOf(_options), ct) is { } fetched && await _keys.VerifyAsync(fetched.Manifest, fetched.Signature, ct))
{
Result<ReleaseOffer> published = await PublishAsync(fetched.Manifest, fetched.Signature, ct);
if (published.IsSuccess)
@@ -121,6 +121,21 @@ public sealed partial class ReleaseService : IDisposable
return stored;
}
/// <summary>不再信任某把密钥之后(删除自有发布密钥):已登记的清单里,现在验不过的丢掉,免得继续分发。</summary>
public async ValueTask PurgeUnverifiedAsync(CancellationToken ct)
{
foreach (string key in new[] { NodeKinds.Agent, NodeKinds.Probe, BackendComponent + ".dev", BackendComponent + ".release" })
{
string? manifest = await _settings.GetAsync(Key(key, "manifest"), ct);
string? signature = await _settings.GetAsync(Key(key, "signature"), ct);
if (!string.IsNullOrEmpty(manifest) && !string.IsNullOrEmpty(signature) && !await _keys.VerifyAsync(manifest, signature, ct))
{
await _settings.SetAsync(Key(key, "manifest"), string.Empty, ct);
await _settings.SetAsync(Key(key, "signature"), string.Empty, ct);
}
}
}
public async ValueTask<ReleaseOffer?> GetAsync(string component, CancellationToken ct)
{
string? manifest = await _settings.GetAsync(Key(component, "manifest"), ct);
@@ -187,7 +202,7 @@ public sealed partial class ReleaseService : IDisposable
: node.Value.StartsWith(ProbeId.Prefix, StringComparison.Ordinal) ? NodeKinds.Probe
: null;
internal static string Key(string component, string part) => $"release.{component}.{part}";
private static string Key(string component, string part) => $"release.{component}.{part}";
private static bool TryParse(string manifest, string signature, [NotNullWhen(true)] out ReleaseOffer? offer, [NotNullWhen(false)] out string? problem)
{
@@ -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]