feat: operator trust root: passkey-signed operator set, signed join codes, nodes verify and pin the chain at enrollment [CI SKIP]

This commit is contained in:
justme committed 2026-10-07 23:19:59 +08:00
1 parent 321cacac32
commit fc2bbdcc6d
35 files changed
+1374 -40

No files matched your search

@@ -2,13 +2,17 @@ using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using Fleet.Core;
using Fleet.Hosting;
using Fleet.Identity;
namespace Fleet.Enrollment.Client;
public sealed record EnrollmentResult(string NodeId, string Status, string IdentityDirectory);
[JsonSerializable(typeof(TrustBundle))]
internal sealed partial class TrustBundleContext : JsonSerializerContext;
public sealed record EnrollmentResult(string NodeId, string Status, string IdentityDirectory, bool Pinned = false);
/// <summary>
/// 用加入令牌注册(供 <c>fleet-enroll</c> 使用,也可以嵌入产品自己的安装工具):
@@ -18,7 +22,8 @@ public sealed record EnrollmentResult(string NodeId, string Status, string Ident
public static class EnrollmentClient
{
public static async Task<Result<EnrollmentResult>> EnrollAsync(
HttpClient http, Uri server, string joinToken, string name, string identityDirectory, string nodeVersion, JsonNode? productMeta, TimeProvider time, CancellationToken ct)
HttpClient http, Uri server, string joinToken, string name, string identityDirectory, string nodeVersion, JsonNode? productMeta, TimeProvider time, CancellationToken ct,
string? seed = null, string? genesis = null, string role = "agent")
{
string nodeFile = Path.Combine(identityDirectory, LoadedNodeIdentity.NodeFileName);
if (File.Exists(nodeFile))
@@ -36,6 +41,27 @@ public static class EnrollmentClient
tlsPublicKey = certificate.PublicKey.ExportSubjectPublicKeyInfo();
}
// 固定了操作员的创世(--genesis)和码的种子(--seed):先取信任链,确认这个面板的操作员集合和委托确实是固定的创世签出来的,
// 再用码的私钥签绑定。任何一步对不上都不注册,也不把自己的密钥交出去。
Binding? binding = null;
string? bindingStatement = null;
string? bindingSignature = null;
TrustBundle? bundle = null;
if (seed is not null || genesis is not null)
{
Result<(TrustBundle Bundle, Binding Binding, string Statement, string Signature)> trusted =
await EstablishTrustAsync(http, server, joinToken, name, role, seed, genesis, identity.PublicKeySpki, tlsPublicKey, time, ct);
if (!trusted.TryGet(out (TrustBundle Bundle, Binding Binding, string Statement, string Signature) value, out FleetError? trustError))
{
return Result<EnrollmentResult>.Fail(trustError);
}
bundle = value.Bundle;
binding = value.Binding;
bindingStatement = value.Statement;
bindingSignature = value.Signature;
}
string pubkey = Convert.ToBase64String(identity.PublicKeySpki);
string tokenHash = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(joinToken)));
var body = new JsonObject
@@ -48,6 +74,8 @@ public static class EnrollmentClient
["proof"] = Convert.ToBase64String(identity.Sign(SignaturePurpose.Enroll, tokenHash, pubkey)),
["node_version"] = nodeVersion,
["meta"] = productMeta,
["binding"] = bindingStatement,
["binding_signature"] = bindingSignature,
};
using var content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json");
@@ -76,10 +104,77 @@ public static class EnrollmentClient
var store = new ConfigStore(identityDirectory);
store.Write(LoadedNodeIdentity.KeyFileName, identity.ExportPkcs8());
store.Write(LoadedNodeIdentity.TlsFileName, pfx);
if (bundle is not null && binding is not null)
{
TrustAnchor.Pin(identityDirectory, genesis!, bundle.Chain, new MemberProofDto { Grant = bundle.Grant!, Binding = bindingStatement!, BindingSignature = bindingSignature! });
}
// node.json 最后写:它存在就表示注册完整完成
store.Write(
LoadedNodeIdentity.NodeFileName,
JsonSerializer.SerializeToUtf8Bytes(new NodeFile { NodeId = nodeId, Server = server.ToString().TrimEnd('/'), TlsSpki = Convert.ToBase64String(spki) }, NodeFileContext.Default.NodeFile));
return Result<EnrollmentResult>.Ok(new EnrollmentResult(nodeId, result["status"]!.GetValue<string>(), identityDirectory));
return Result<EnrollmentResult>.Ok(new EnrollmentResult(nodeId, result["status"]!.GetValue<string>(), identityDirectory, bundle is not null));
}
private static async Task<Result<(TrustBundle Bundle, Binding Binding, string Statement, string Signature)>> EstablishTrustAsync(
HttpClient http, Uri server, string joinToken, string name, string role, string? seedText, string? genesis, byte[] nodeKey, byte[] peerKey, TimeProvider time, CancellationToken ct)
{
static Result<(TrustBundle, Binding, string, string)> Refuse(string reason) => Result<(TrustBundle, Binding, string, string)>.Fail(FleetErrors.ProofInvalid, "refusing to enroll: " + reason);
if (seedText is null || genesis is null)
{
return Refuse("--seed and --genesis go together (the panel's install command has both)");
}
if (!Base64Url.TryDecode(seedText, out byte[] seed) || seed.Length != 32)
{
return Refuse("the code seed is malformed");
}
if (OperatorTrust.AuthToken(seed) != joinToken)
{
return Refuse("the join token is not derived from the code seed");
}
using var request = new StringContent(new JsonObject { ["join_token"] = joinToken }.ToJsonString(), Encoding.UTF8, "application/json");
using HttpResponseMessage response = await http.PostAsync(new Uri(server, "/api/agent/v1/enroll/trust"), request, ct);
if (!response.IsSuccessStatusCode)
{
return Result<(TrustBundle, Binding, string, string)>.Fail(FleetErrors.JoinTokenInvalid, $"{(int)response.StatusCode}: the panel would not give the trust chain");
}
TrustBundle? bundle = JsonSerializer.Deserialize(await response.Content.ReadAsStringAsync(ct), TrustBundleContext.Default.TrustBundle);
if (bundle?.Grant is null || bundle.Chain.Count == 0)
{
return Refuse("the panel has no operator trust set up, or this code carries no grant from the operator");
}
OperatorSet? current = OperatorTrust.VerifyChain(genesis, [.. bundle.Chain.Select(c => c.ToSigned())]);
if (current is null)
{
return Refuse("the panel's operator set does not match the pinned genesis fingerprint (wrong panel, or the panel was tampered with)");
}
Grant? grant = OperatorTrust.ParseGrant(bundle.Grant.Statement);
long now = time.GetUtcNow().ToUnixTimeMilliseconds();
if (grant is null || !OperatorTrust.VerifyOperator(bundle.Grant.ToSigned(), current))
{
return Refuse("the grant is not signed by your operator passkey");
}
if (grant.AuthHash != OperatorTrust.AuthHash(joinToken) || grant.Role != role || now < grant.NotBeforeMs || now > grant.NotAfterMs)
{
return Refuse("the grant is not for this code, this role, or this time");
}
using ECDsa? codeKey = OperatorTrust.CodeSigningKey(seed, grant.CodeKey);
if (codeKey is null)
{
return Refuse("the code seed does not match the grant's code key");
}
var binding = new Binding(name, role, nodeKey, peerKey, grant.AuthHash, now);
string signature = OperatorTrust.SignBinding(codeKey, binding, out string statement);
return Result<(TrustBundle, Binding, string, string)>.Ok((bundle, binding, statement, signature));
}
}