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

@@ -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)
{