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
@@ -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)
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user