123 lines
6.1 KiB
C#
123 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>
|
|
public sealed class ReleaseKeyTests : IClassFixture<BackendFixture>
|
|
{
|
|
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);
|
|
}
|
|
}
|