Files
MoShiro/backend/src/MoShiro.Backend.Application/Updates/ReleaseKeyService.cs
T
justme 0f8f3f9a54
ci/woodpecker/push/main-amd64-build Pipeline was successful
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.
2026-10-08 18:50:16 +08:00

106 lines
4.3 KiB
C#

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;
}
}
}