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.
124 lines
6.1 KiB
C#
124 lines
6.1 KiB
C#
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);
|
|
}
|
|
}
|