176 lines
8.5 KiB
C#
176 lines
8.5 KiB
C#
using System.Text.Json;
|
|
using Fleet.Core;
|
|
using Fleet.Server;
|
|
|
|
namespace MoShiro.Backend.Application;
|
|
|
|
/// <summary>CI 发布公钥(PEM):验节点握手时带来的签名清单。由 Api 层提供(它就是内嵌在程序里的那一把)。</summary>
|
|
public interface IReleaseKey
|
|
{
|
|
string Pem { get; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// 节点二进制的绑定:节点第一次握手成功的那一刻,把它正在运行的二进制的 SHA-256 和这个节点绑在一起;之后哈希对不上,就不让它继续运行。
|
|
/// <para>
|
|
/// 合法的升级:新哈希出现在一份 <b>CI 签名的清单</b>里(节点握手时带着宿主机更新器验过的清单,这里用内嵌的 CI 公钥再验一遍,
|
|
/// 所以不依赖面板有没有收到过这次发布),并且清单的 seq 不低于绑定时的——不许回退到更旧的版本。其余情况(文件被换掉、降级、没人签过的二进制、
|
|
/// 手工替换了二进制的非容器节点)都算不符;管理员确认过之后可以清除绑定,下一次握手重新绑定。
|
|
/// </para>
|
|
/// <para>
|
|
/// 边界:哈希是节点自己报的。一个被换进来、故意撒谎的恶意二进制可以照抄原来的哈希;这一层抓的是文件被换、版本被改、降级和起了不对的二进制。
|
|
/// 容器节点要堵住撒谎,需要宿主机更新器背书(容器之外),见 docs/access.md。
|
|
/// </para>
|
|
/// </summary>
|
|
public sealed class BinaryBindingService : IBinaryAttestor
|
|
{
|
|
public const string Enforce = "enforce";
|
|
public const string Warn = "warn";
|
|
public const string Off = "off";
|
|
|
|
private readonly INodeBindingStore _store;
|
|
private readonly ISettingsStore _settings;
|
|
private readonly IReleaseKey _key;
|
|
private readonly BackendOptions _options;
|
|
private readonly TimeProvider _time;
|
|
|
|
public BinaryBindingService(INodeBindingStore store, ISettingsStore settings, IReleaseKey key, BackendOptions options, TimeProvider time)
|
|
{
|
|
_settings = settings;
|
|
_store = store;
|
|
_key = key;
|
|
_options = options;
|
|
_time = time;
|
|
}
|
|
|
|
public ValueTask<IReadOnlyList<NodeBinding>> ListAsync(CancellationToken ct) => _store.ListAsync(ct);
|
|
|
|
/// <summary>管理员确认新的二进制是自己要的(比如手工升级了非容器节点):清除绑定,下一次握手重新绑定。</summary>
|
|
public ValueTask<bool> ClearAsync(string nodeId, CancellationToken ct) => _store.DeleteAsync(nodeId, ct);
|
|
|
|
/// <summary>节点删除之后顺手清掉它的绑定。</summary>
|
|
public async ValueTask ForgetAsync(string nodeId, CancellationToken ct) => await _store.DeleteAsync(nodeId, ct);
|
|
|
|
public async ValueTask<BinaryDecision> CheckAsync(NodeRecord node, BinaryEvidence evidence, CancellationToken ct)
|
|
{
|
|
string policy = _options.BinaryPolicy;
|
|
if (policy == Off || ReleaseService.ComponentOf(node.Id) is not { } component)
|
|
{
|
|
return new BinaryDecision(true);
|
|
}
|
|
|
|
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
|
NodeBinding? binding = await _store.GetAsync(node.Id.Value, ct);
|
|
|
|
if (evidence.Sha256 is null)
|
|
{
|
|
// 旧节点不上报:还没绑定过就放行(未绑定);绑定过的不许退回去,否则换个旧版本就绕过了
|
|
return binding is null ? new BinaryDecision(true) : await ViolationAsync(binding, "the node stopped reporting its binary hash", policy, now, ct);
|
|
}
|
|
|
|
string hash = evidence.Sha256;
|
|
if (!IsHash(hash))
|
|
{
|
|
return new BinaryDecision(policy != Enforce, "the reported binary hash is malformed");
|
|
}
|
|
|
|
// 节点自己带来的清单(宿主机更新器验过的那份);没有更新器的节点(一键容器安装、二进制安装)没有,就用面板自己手里的:
|
|
// 面板登记它们时已经验过 CI 签名,这里再验一遍。只有节点自带的清单才能因为「不在清单里」而被拒绝,面板手里的只用来把绑定标成已签名。
|
|
Official? official = VerifyManifest(evidence.Manifest, evidence.Signature, component);
|
|
bool carried = official is not null;
|
|
official ??= await StoredOfficialAsync(component, ct);
|
|
bool listed = official is not null && official.Binaries.Contains(hash);
|
|
|
|
if (binding is null)
|
|
{
|
|
if (carried && !listed)
|
|
{
|
|
// 带着一份有效的签名清单,自己的哈希却不在里面:它不是这份清单对应的官方二进制
|
|
return new BinaryDecision(policy != Enforce, "this binary is not in the CI-signed manifest it was installed from");
|
|
}
|
|
|
|
await _store.UpsertAsync(new NodeBinding(node.Id.Value, hash, listed ? official!.Seq : 0, listed, now, null, null), ct);
|
|
return new BinaryDecision(true);
|
|
}
|
|
|
|
if (binding.Sha256 == hash)
|
|
{
|
|
if (listed && (official!.Seq > binding.Seq || !binding.Signed || binding.Violation is not null))
|
|
{
|
|
await _store.UpsertAsync(binding with { Seq = Math.Max(binding.Seq, official!.Seq), Signed = true, Violation = null, ViolationAtMs = null }, ct);
|
|
}
|
|
else if (binding.Violation is not null)
|
|
{
|
|
await _store.UpsertAsync(binding with { Violation = null, ViolationAtMs = null }, ct);
|
|
}
|
|
|
|
return new BinaryDecision(true);
|
|
}
|
|
|
|
// 哈希变了:必须是一份不低于绑定时 seq 的 CI 签名清单里的官方二进制(合法升级),否则不符
|
|
if (listed && official!.Seq >= binding.Seq)
|
|
{
|
|
await _store.UpsertAsync(new NodeBinding(node.Id.Value, hash, official.Seq, true, now, null, null), ct);
|
|
return new BinaryDecision(true);
|
|
}
|
|
|
|
return await ViolationAsync(binding, $"the running binary changed (bound {Short(binding.Sha256)}, now {Short(hash)}) and is not in a newer CI-signed manifest", policy, now, ct);
|
|
}
|
|
|
|
private async ValueTask<BinaryDecision> ViolationAsync(NodeBinding binding, string reason, string policy, long now, CancellationToken ct)
|
|
{
|
|
await _store.UpsertAsync(binding with { Violation = reason, ViolationAtMs = now }, ct);
|
|
return new BinaryDecision(policy != Enforce, reason);
|
|
}
|
|
|
|
private sealed record Official(long Seq, HashSet<string> Binaries);
|
|
|
|
/// <summary>用内嵌的 CI 公钥验清单;验过且是这个组件的才返回它列出的官方二进制哈希。</summary>
|
|
private async ValueTask<Official?> StoredOfficialAsync(string component, CancellationToken ct) =>
|
|
VerifyManifest(await _settings.GetAsync(ReleaseService.Key(component, "manifest"), ct), await _settings.GetAsync(ReleaseService.Key(component, "signature"), ct), component);
|
|
|
|
private Official? VerifyManifest(string? manifestText, string? signatureText, string component)
|
|
{
|
|
if (manifestText is not { Length: > 0 and <= ReleaseService.MaxManifestBytes } manifest
|
|
|| signatureText is not { Length: > 0 and <= ReleaseService.MaxSignatureBytes } signature
|
|
|| !ReleaseSignature.Verify(_key.Pem, manifest, signature))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
try
|
|
{
|
|
using JsonDocument document = JsonDocument.Parse(manifest);
|
|
JsonElement root = document.RootElement;
|
|
if (root.ValueKind != JsonValueKind.Object
|
|
|| !root.TryGetProperty("component", out JsonElement c) || c.GetString() != component
|
|
|| !root.TryGetProperty("seq", out JsonElement s) || !s.TryGetInt64(out long seq) || seq <= 0)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var binaries = new HashSet<string>(StringComparer.Ordinal);
|
|
if (root.TryGetProperty("binaries", out JsonElement list) && list.ValueKind == JsonValueKind.String)
|
|
{
|
|
foreach (string item in (list.GetString() ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
|
{
|
|
if (IsHash(item))
|
|
{
|
|
binaries.Add(item);
|
|
}
|
|
}
|
|
}
|
|
|
|
return new Official(seq, binaries);
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private static bool IsHash(string value) => value.Length == 64 && value.All(ch => ch is (>= '0' and <= '9') or (>= 'a' and <= 'f'));
|
|
|
|
private static string Short(string hash) => hash.Length > 12 ? hash[..12] : hash;
|
|
}
|