From 0f8f3f9a544a43d03d9875d8433f37f7fba85475 Mon Sep 17 00:00:00 2001 From: JustMe Date: Thu, 8 Oct 2026 18:50:16 +0800 Subject: [PATCH] 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. --- backend/openapi/v1.json | 356 ++++++++++++------ .../Admin/BuildEndpoints.cs | 13 +- .../Admin/ReleaseKeyEndpoints.cs | 54 +++ backend/src/MoShiro.Backend.Api/ApiModule.cs | 3 +- .../Contracts/AdminDtos.cs | 8 + .../Contracts/ApiJsonContext.cs | 2 + .../ApplicationServiceCollectionExtensions.cs | 1 + .../Auth/Capabilities.cs | 3 - .../Updates/ReleaseKeyService.cs | 105 ++++++ .../Updates/ReleaseService.cs | 27 +- .../BackendUpdateTests.cs | 2 +- .../MoShiro.Backend.Tests/CapabilityTests.cs | 8 - .../MoShiro.Backend.Tests/InstallTests.cs | 2 +- .../MoShiro.Backend.Tests/ReleaseKeyTests.cs | 123 ++++++ .../ReleaseOfferTests.cs | 16 +- .../MoShiro.Backend.Tests/UpdateTests.cs | 48 +-- docs/access.md | 3 +- docs/api.md | 5 +- docs/deployment.md | 2 + web/admin/src/pages/CapabilityPicker.tsx | 1 - web/admin/src/pages/ReleaseKeys.tsx | 119 ++++++ web/admin/src/pages/System.tsx | 8 +- web/shared/api/schema.ts | 328 ++++++++++------ web/shared/api/types.ts | 2 + 24 files changed, 914 insertions(+), 325 deletions(-) create mode 100644 backend/src/MoShiro.Backend.Api/Admin/ReleaseKeyEndpoints.cs create mode 100644 backend/src/MoShiro.Backend.Application/Updates/ReleaseKeyService.cs create mode 100644 backend/tests/MoShiro.Backend.Tests/ReleaseKeyTests.cs create mode 100644 web/admin/src/pages/ReleaseKeys.tsx diff --git a/backend/openapi/v1.json b/backend/openapi/v1.json index 9dc9124..ae49dbf 100644 --- a/backend/openapi/v1.json +++ b/backend/openapi/v1.json @@ -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" }, diff --git a/backend/src/MoShiro.Backend.Api/Admin/BuildEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/BuildEndpoints.cs index c6929de..388b9b6 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/BuildEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Admin/BuildEndpoints.cs @@ -36,15 +36,6 @@ public static class BuildEndpoints .Requires(Capability.UpdateRead); } - /// 登记发布清单:挂在 下,除了管理员,CI 用的 release 令牌也能调用(它只能做这一件事)。 - 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(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409) - .Requires(Capability.ReleasePublish); - } - /// /// CI 登记清单的入口,不需要令牌:请求里的清单必须带有效的 CI 发布签名(用 Backend 内嵌的发布公钥验)才收下。 /// 这样登记不依赖面板里的任何凭证(清库、重装、还没有 passkey 的时候照常工作),也没有一把能被偷的长期令牌; @@ -57,12 +48,12 @@ public static class BuildEndpoints .Returns(StatusCodes.Status201Created).ProducesProblems(400, 403, 409); } - private static async Task OfferReleaseAsync(HttpContext http, ReleaseService releases, IReleaseKey releaseKey, PublishReleaseRequest request) + private static async Task 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"); } diff --git a/backend/src/MoShiro.Backend.Api/Admin/ReleaseKeyEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/ReleaseKeyEndpoints.cs new file mode 100644 index 0000000..30b8d09 --- /dev/null +++ b/backend/src/MoShiro.Backend.Api/Admin/ReleaseKeyEndpoints.cs @@ -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; + +/// 可信资产来源:官方发布密钥(只读)和可选的自有发布公钥(默认没有)。改动写审计。 +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().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().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().ProducesProblems(401, 403) + .Requires(Capability.UpdateConfirm); + } + + private static async Task GetAsync(ReleaseKeyService keys, CancellationToken ct) => Json(await keys.GetAsync(ct)); + + private static async Task SetAsync(HttpContext http, ReleaseKeyService keys, SetReleaseKeyRequest request) + { + Result 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 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 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); +} diff --git a/backend/src/MoShiro.Backend.Api/ApiModule.cs b/backend/src/MoShiro.Backend.Api/ApiModule.cs index e17cc71..60bfc5e 100644 --- a/backend/src/MoShiro.Backend.Api/ApiModule.cs +++ b/backend/src/MoShiro.Backend.Api/ApiModule.cs @@ -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); diff --git a/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs b/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs index 7d14674..4acf36e 100644 --- a/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs +++ b/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs @@ -198,6 +198,14 @@ public sealed record ReleaseDto(string Component, long Seq, string Tag, string V public sealed record PublishReleaseRequest(string? Manifest, string? Signature); +/// 一把发布公钥:指纹是 SPKI 的 SHA-256(小写十六进制)。 +public sealed record ReleaseKeyDto(string Fingerprint, string PublicKey); + +/// 可信资产来源:官方发布密钥(始终信任)和可选的自有发布公钥(默认没有)。 +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); diff --git a/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs b/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs index 2341ec5..0c46eb0 100644 --- a/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs +++ b/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs @@ -45,6 +45,8 @@ namespace MoShiro.Backend.Api; [JsonSerializable(typeof(ReleaseDto))] [JsonSerializable(typeof(PageDto))] [JsonSerializable(typeof(PublishReleaseRequest))] +[JsonSerializable(typeof(ReleaseKeysDto))] +[JsonSerializable(typeof(SetReleaseKeyRequest))] [JsonSerializable(typeof(AboutDto))] [JsonSerializable(typeof(NodeBuildDto))] [JsonSerializable(typeof(NodeOwnerDto))] diff --git a/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs b/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs index c9863d2..257cc71 100644 --- a/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs +++ b/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs @@ -40,6 +40,7 @@ public static class ApplicationServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(); diff --git a/backend/src/MoShiro.Backend.Application/Auth/Capabilities.cs b/backend/src/MoShiro.Backend.Application/Auth/Capabilities.cs index c93544f..e34718c 100644 --- a/backend/src/MoShiro.Backend.Application/Auth/Capabilities.cs +++ b/backend/src/MoShiro.Backend.Application/Auth/Capabilities.cs @@ -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)]), ]; diff --git a/backend/src/MoShiro.Backend.Application/Updates/ReleaseKeyService.cs b/backend/src/MoShiro.Backend.Application/Updates/ReleaseKeyService.cs new file mode 100644 index 0000000..414298f --- /dev/null +++ b/backend/src/MoShiro.Backend.Application/Updates/ReleaseKeyService.cs @@ -0,0 +1,105 @@ +using System.Security.Cryptography; +using Fleet.Core; + +namespace MoShiro.Backend.Application; + +/// 一把发布公钥的指纹(SPKI 的 SHA-256,小写十六进制)和 PEM。 +public sealed record ReleaseKeyInfo(string Fingerprint, string PublicKey); + +/// 可信资产来源:官方 CI 发布密钥(内嵌,始终信任)和可选的自有发布公钥(默认没有)。 +public sealed record ReleaseKeys(ReleaseKeyInfo Official, ReleaseKeyInfo? Custom); + +/// +/// 面板信任哪些发布密钥:内嵌的官方 CI 公钥始终信任,不能关;部署的人可以另外导入自己的发布公钥(默认没有), +/// 用自己的私钥给自己构建的清单签名。验签(登记清单、取镜像仓库里的清单)通过官方或自有任意一把即可。 +/// 私钥只在部署的人手里,面板只存公钥,拒绝带私钥的 PEM。 +/// +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 GetAsync(CancellationToken ct) => + new(Describe(_official.Pem)!, Describe(await CustomAsync(ct))); + + /// 清单的签名由官方密钥或自有密钥(开启时)验过。 + public async ValueTask 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> SetCustomAsync(string? publicKey, CancellationToken ct) + { + if (!TryNormalize(publicKey, out string? pem, out string? problem)) + { + return ValidationError.Fail("public_key", problem); + } + + await _settings.SetAsync(SettingKey, pem, ct); + return Result.Ok(await GetAsync(ct)); + } + + public async ValueTask ClearCustomAsync(CancellationToken ct) => await _settings.SetAsync(SettingKey, string.Empty, ct); + + private async ValueTask 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()); + } + + /// 只收 P-256 的公钥:带私钥的 PEM 直接拒绝(私钥不该离开你的机器)。 + 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; + } + } +} diff --git a/backend/src/MoShiro.Backend.Application/Updates/ReleaseService.cs b/backend/src/MoShiro.Backend.Application/Updates/ReleaseService.cs index b1456fd..0275c31 100644 --- a/backend/src/MoShiro.Backend.Application/Updates/ReleaseService.cs +++ b/backend/src/MoShiro.Backend.Application/Updates/ReleaseService.cs @@ -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 _feedChecked = new(); private readonly ConcurrentDictionary _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("manifest", problem); } - if (!ReleaseSignature.Verify(_key.Pem, manifest, signature)) + if (!await _keys.VerifyAsync(manifest, signature, ct)) { return Result.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 published = await PublishAsync(fetched.Manifest, fetched.Signature, ct); if (published.IsSuccess) @@ -121,6 +121,21 @@ public sealed partial class ReleaseService : IDisposable return stored; } + /// 不再信任某把密钥之后(删除自有发布密钥):已登记的清单里,现在验不过的丢掉,免得继续分发。 + 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 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) { diff --git a/backend/tests/MoShiro.Backend.Tests/BackendUpdateTests.cs b/backend/tests/MoShiro.Backend.Tests/BackendUpdateTests.cs index 56f8536..aee6078 100644 --- a/backend/tests/MoShiro.Backend.Tests/BackendUpdateTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/BackendUpdateTests.cs @@ -33,7 +33,7 @@ public sealed class BackendUpdateTests : IAsyncLifetime private async Task 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 ViewAsync() => _backend.GetJsonAsync(_backend.Admin, "/api/admin/backend/update"); diff --git a/backend/tests/MoShiro.Backend.Tests/CapabilityTests.cs b/backend/tests/MoShiro.Backend.Tests/CapabilityTests.cs index a0647a1..a3362f7 100644 --- a/backend/tests/MoShiro.Backend.Tests/CapabilityTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/CapabilityTests.cs @@ -121,12 +121,4 @@ public sealed class CapabilityTests Assert.Contains(catalog["presets"]!.AsArray(), p => p!["id"]!.GetValue() == "all"); Assert.Contains(catalog["groups"]!.AsArray().SelectMany(g => g!["capabilities"]!.AsArray()), c => c!["id"]!.GetValue() == Capability.FleetRevoke && c["trust"]!.GetValue()); } - - [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); - } } diff --git a/backend/tests/MoShiro.Backend.Tests/InstallTests.cs b/backend/tests/MoShiro.Backend.Tests/InstallTests.cs index 614ee2f..0ab7f43 100644 --- a/backend/tests/MoShiro.Backend.Tests/InstallTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/InstallTests.cs @@ -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(); diff --git a/backend/tests/MoShiro.Backend.Tests/ReleaseKeyTests.cs b/backend/tests/MoShiro.Backend.Tests/ReleaseKeyTests.cs new file mode 100644 index 0000000..725c50e --- /dev/null +++ b/backend/tests/MoShiro.Backend.Tests/ReleaseKeyTests.cs @@ -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; + +/// +/// 可信资产来源:官方发布密钥始终信任;部署的人可以另外导入自己的发布公钥(默认没有),用自己的私钥给自己构建的清单签名。 +/// 面板只收公钥,拒绝私钥;删除之后只有它签过的清单不再被接受,已登记的也丢掉。 +/// +[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 PutKeyAsync(string? pem) => + await _backend.Admin.PutAsync("/api/admin/release-key", BackendFixture.Json(new JsonObject { ["public_key"] = pem })); + + private async Task 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().Length); + Assert.StartsWith("-----BEGIN PUBLIC KEY-----", keys["official"]!["public_key"]!.GetValue(), 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()); + + Assert.Equal(HttpStatusCode.Created, (await OfferAsync(Manifest(first), Sign(own, Manifest(first)))).StatusCode); + ReleaseService releases = _backend.Services.GetRequiredService(); + 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); + } +} diff --git a/backend/tests/MoShiro.Backend.Tests/ReleaseOfferTests.cs b/backend/tests/MoShiro.Backend.Tests/ReleaseOfferTests.cs index 40cc367..9f20e68 100644 --- a/backend/tests/MoShiro.Backend.Tests/ReleaseOfferTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/ReleaseOfferTests.cs @@ -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); } } diff --git a/backend/tests/MoShiro.Backend.Tests/UpdateTests.cs b/backend/tests/MoShiro.Backend.Tests/UpdateTests.cs index 9d48f5d..33a6b21 100644 --- a/backend/tests/MoShiro.Backend.Tests/UpdateTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/UpdateTests.cs @@ -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 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 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 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(); - } - - 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] diff --git a/docs/access.md b/docs/access.md index f1d1587..d0e2b33 100644 --- a/docs/access.md +++ b/docs/access.md @@ -65,7 +65,6 @@ | 数据 | `data.read` · `data.write` | 原始块、导出;清理 | | | 设置 | `settings.read` · `settings.write` | 站点设置、保留策略、重启计划 | | | 更新 | `update.read` · `update.confirm` | 看 Backend 更新、确认更新 | | -| | `release.publish` | CI 登记发布清单(只此一件事) | | | 令牌 | `keys.read` · `keys.manage` | 列出、创建、撤销 API key | | | 账户 | `accounts.manage` | 账户、passkey、操作员集合 | 是 | | 运维 | `audit.read` · `ops.metrics` | 审计日志;`/metrics` | | @@ -133,7 +132,7 @@ | 普通 key | 不要 | 勾选的非信任类能力(只读、运维、CI 发布等),只能是创建者能力的子集 | | `enroll` key | **要,签一次** | 勾选的非信任类能力 + `fleet.enroll`(可吊销自己加的节点) | -- 现有令牌迁移(破坏性):`read` → 全部 `*.read`;`admin` → 全部非信任类能力;`release` → `release.publish`。迁移后,旧的 admin 令牌不能再生成加入代码,需要换成 `enroll` key。 +- 现有令牌迁移(破坏性):`read` → 全部 `*.read`;`admin` → 全部非信任类能力;`release` 令牌已取消(CI 登记发布清单不需要令牌,见 `POST /api/release/offer`)。迁移后,旧的 admin 令牌不能再生成加入代码,需要换成 `enroll` key。 - 每把 key 可设:有效期、可选的 IP 白名单(CIDR,**待确认**)、`enroll` 类的数量上限和单券 TTL 上限;预留:资源范围、速率和数据配额(见 5.4)。 ### 5.2 `enroll` key:自动化添加节点 diff --git a/docs/api.md b/docs/api.md index 49c167e..a4ef5f8 100644 --- a/docs/api.md +++ b/docs/api.md @@ -146,8 +146,9 @@ | GET | `/api/admin/capabilities` | 能力目录:分组、哪些改变信任、哪些是预留、依赖关系和预设(只读、运维、接入、CI 发布、全部) | | GET/PATCH | `/api/admin/settings` | 站点公开或私有、是否对访客显示构建信息(`site.show_build`)、保留期、节点定时重启(`fleet.restart_enabled`、`fleet.restart_hour`、`fleet.restart_minute`、`fleet.restart_utc_offset_minutes`,默认开、04:30、UTC+8;改完立即通知在线节点)等 | | GET | `/api/admin/build-info` | 后端和每个 Agent、Probe 的版本、tag、提交、构建时间、系统,以及节点宿主机更新器报告的更新状态(管理端「系统 → 版本」) | -| GET/POST | `/api/admin/releases` | CI 签名的发布清单:POST 登记(`{manifest, signature}`,需要 `release.publish`,原样转给在线的节点和之后连上的节点),GET 看每个组件最新的一份 | -| POST | `/api/release/offer` | CI 登记发布清单的入口,**不需要令牌**:`{manifest, signature}` 的签名必须能用 Backend 内嵌的发布公钥验过(ECDSA P-256 / SHA-256,DER),否则 403;`seq` 必须比已登记的新,否则 409。登记不依赖面板里的任何凭证,清库、重装、还没有 passkey 时照常工作 | +| GET | `/api/admin/releases` | 每个组件最新的一份签名发布清单 | +| GET/PUT/DELETE | `/api/admin/release-key` | 可信资产来源:GET 看官方发布密钥和(如果有)自有发布公钥的指纹;PUT `{public_key}` 另外信任自己的发布公钥(P-256 的 PEM,拒绝私钥);DELETE 停用它,只有它签过的已登记清单会被丢掉。官方密钥始终信任。PUT/DELETE 需要 `update.confirm`,写审计 | +| POST | `/api/release/offer` | CI 登记发布清单的入口,**不需要令牌**:`{manifest, signature}` 的签名必须能用 Backend 内嵌的官方发布公钥或已开启的自有发布公钥验过(ECDSA P-256 / SHA-256,DER),否则 403;`seq` 必须比已登记的新,否则 409。登记不依赖面板里的任何凭证,清库、重装、还没有 passkey 时照常工作 | | GET | `/api/admin/links` | 每一对(Probe,Agent)的线路开关:`linked`(最终结果)、`override`(手动设置,`null` 跟随自动连接)、`connected`,以及 `auto_link` | | PUT | `/api/admin/links/{probe_id}/{agent_id}` | `{enabled: true|false|null}`:手动强制连或不连,`null` 取消手动设置;改完立即重新下发 | | POST | `/api/admin/nodes/{id}/update` | 请求一个在线的 Agent 或 Probe 更新到它收到的清单(节点不在线返回 409) | diff --git a/docs/deployment.md b/docs/deployment.md index bff3c31..824d5aa 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -315,6 +315,8 @@ sh packaging/update/install-updater.sh agent --user \ 发布私钥只放在 Woodpecker 的 `release_signing_key` secret;发布公钥位于 `packaging/update/release-key.pub.pem`。`RELEASE_API_URL` 指向面板,CI 把签名清单 POST 到 `/api/release/offer`:这个入口不需要令牌,面板用内嵌的发布公钥验签,签名无效就拒绝,所以 CI 不需要在面板里持有任何凭证(清库或重装后照常登记,也没有长期令牌可偷)。面板保存并转发签名清单,Agent/Probe 将它与更新请求写进配置 volume;更新器验签后才调用容器引擎和 systemd。管理端仍能看到可更新、更新中、已回滚和无效清单状态。纯二进制更新器尚未实现。 +**自有发布密钥**(自己构建、自己发布的部署;默认关闭):管理端「系统 → 信任 → 可信资产来源」。官方 CI 发布密钥始终信任,不能关;另外导入**自己的发布公钥**(P-256 的 PEM,面板拒绝私钥,私钥留在你自己的机器或 CI 密钥库)之后,用你的私钥签的清单也会被面板登记和分发。签名方法:`openssl dgst -sha256 -sign release.key -out manifest.sig.bin manifest.json`,再 `base64 -w0` 得到签名;清单是一行扁平的 JSON(字段见 `ci/release-manifest.sh`),交给面板用 `POST /api/release/offer`,或者像官方那样放进你镜像仓库里的清单镜像。节点宿主机的更新器只认本机的公钥文件,所以用你自己的密钥发布时,还要把你的公钥放进这些节点的 `release-key.pub.pem`,并且清单里的 `image` 必须等于它本机配置的镜像仓库。停用自有密钥时,只有它签过的已登记清单会被丢掉。 + **换发布密钥**(泄露或定期轮换,不用重装节点): 1. 生成新密钥对;CI 给发布流水线加 `RELEASE_NEXT_KEY`(新公钥 PEM),仍用旧私钥签名。清单里会带 `next_key`(SPKI DER 的 base64)。 diff --git a/web/admin/src/pages/CapabilityPicker.tsx b/web/admin/src/pages/CapabilityPicker.tsx index 6c38b3c..cf40b07 100644 --- a/web/admin/src/pages/CapabilityPicker.tsx +++ b/web/admin/src/pages/CapabilityPicker.tsx @@ -19,7 +19,6 @@ const NAMES: Record = { 'settings.write': ['修改设置', '站点设置、保留策略、重启计划'], 'update.read': ['查看更新', '版本、发布清单、Backend 更新状态'], 'update.confirm': ['确认更新', '确认 Backend 更新、请求节点更新'], - 'release.publish': ['登记发布清单', '给 CI 用,只能做这一件事'], 'keys.read': ['查看 API 令牌', ''], 'keys.manage': ['管理 API 令牌', '创建、吊销;只能授予自己拥有的能力'], 'accounts.manage': ['管理账户', '面板用户、密码'], diff --git a/web/admin/src/pages/ReleaseKeys.tsx b/web/admin/src/pages/ReleaseKeys.tsx new file mode 100644 index 0000000..b50ba24 --- /dev/null +++ b/web/admin/src/pages/ReleaseKeys.tsx @@ -0,0 +1,119 @@ +import { useState } from 'react' +import { api } from '@shared/api/client' +import type { ReleaseKeysDto } from '@shared/api/types' +import { useQuery } from '@shared/hooks' +import { Chip, Confirm, CopyButton, ErrorNotice, Field, useAction } from '@shared/ui' + +const KEYGEN = `openssl ecparam -name prime256v1 -genkey -noout -out release.key +openssl ec -in release.key -pubout -out release.pub.pem` + +const SIGN = `# manifest.json 是一行扁平的 JSON(字段见 docs/deployment.md),对它的原始字节签名: +openssl dgst -sha256 -sign release.key -out manifest.sig.bin manifest.json +base64 -w0 manifest.sig.bin > manifest.sig` + +/** + * 可信资产来源:面板信任哪些发布密钥。官方 CI 密钥始终信任;自有发布公钥默认关闭,需要时再导入。 + * 只收公钥(私钥留在自己的机器上),改动都会写进审计。 + */ +export function ReleaseKeys() { + const keys = useQuery(() => api.get('/api/admin/release-key'), []) + const { busy, run } = useAction() + const [pem, setPem] = useState('') + const [removing, setRemoving] = useState(false) + const data = keys.data + const looksPrivate = /PRIVATE/i.test(pem) + + return ( +
+
+ 可信资产来源 +

+ 发布清单(镜像 digest)必须带有效的发布签名才会被面板登记和分发;节点宿主机的更新器之后还会用它本机的公钥再验一次。这里决定面板认哪些发布密钥。 +

+
+ + + {data ? ( + <> + +
+ 始终信任 + {data.official.fingerprint} + +
+
+ + + {data.custom ? ( +
+
+ 已开启 + {data.custom.fingerprint} + + +
+
+ ) : ( +
{ + e.preventDefault() + void run(async () => { + await api.put('/api/admin/release-key', { public_key: pem }) + setPem('') + keys.reload() + }, '已开启自有发布密钥') + }} + > +