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:
1 parent
321cacac32
commit
fc2bbdcc6d
35 files changed
+1374
-40
No files matched your search
@@ -0,0 +1,45 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Fleet.Core;
|
||||
|
||||
/// <summary>passkey 对一段语句签出的断言(字段都是 base64url)。信任链的格式和验证在 Fleet.Identity 的 <c>OperatorTrust</c>。</summary>
|
||||
public sealed class SignedStatementDto
|
||||
{
|
||||
[JsonPropertyName("statement")]
|
||||
public string Statement { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("credential_id")]
|
||||
public string CredentialId { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("authenticator_data")]
|
||||
public string AuthenticatorData { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("client_data_json")]
|
||||
public string ClientDataJson { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("signature")]
|
||||
public string Signature { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>一个成员带的证明:操作员签的委托,加加入码的私钥签的绑定。</summary>
|
||||
public sealed class MemberProofDto
|
||||
{
|
||||
[JsonPropertyName("grant")]
|
||||
public SignedStatementDto Grant { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("binding")]
|
||||
public string Binding { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("binding_signature")]
|
||||
public string BindingSignature { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>操作员集合的链(创世在前),可选带一个委托(注册前取的,节点验过之后才继续)。</summary>
|
||||
public sealed class TrustBundle
|
||||
{
|
||||
[JsonPropertyName("chain")]
|
||||
public List<SignedStatementDto> Chain { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("grant")]
|
||||
public SignedStatementDto? Grant { get; set; }
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Fleet.Core;
|
||||
using Fleet.Identity;
|
||||
|
||||
namespace Fleet.Hosting;
|
||||
|
||||
/// <summary><c>trust.json</c>:注册时固定的创世指纹、见过的最新操作员集合链,以及(如果有)本节点自己的证明。</summary>
|
||||
public sealed class TrustFile
|
||||
{
|
||||
[JsonPropertyName("genesis")]
|
||||
public string Genesis { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("chain")]
|
||||
public List<SignedStatementDto> Chain { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("proof")]
|
||||
public MemberProofDto? Proof { get; set; }
|
||||
}
|
||||
|
||||
[JsonSerializable(typeof(TrustFile))]
|
||||
public sealed partial class TrustFileContext : JsonSerializerContext;
|
||||
|
||||
/// <summary>
|
||||
/// 节点的信任根:安装时固定下来的创世指纹,加验证过的操作员集合链。有它就是「强制」模式——
|
||||
/// 名册里的对端必须带操作员批准的证明才接纳(Backend 改不了,也签不出新的);没有 <c>trust.json</c>(旧节点、没有指纹的安装)就不强制。
|
||||
/// 链随期望状态下发,节点只接受从固定的创世出发、签名完整、版本更新的链。
|
||||
/// </summary>
|
||||
public sealed class TrustAnchor
|
||||
{
|
||||
public const string FileName = "trust.json";
|
||||
|
||||
private readonly ConfigStore? _store;
|
||||
private readonly INodeLog _log;
|
||||
private readonly object _gate = new();
|
||||
private readonly TrustFile? _file;
|
||||
private OperatorSet? _current;
|
||||
|
||||
private TrustAnchor(ConfigStore? store, TrustFile? file, OperatorSet? current, INodeLog log)
|
||||
{
|
||||
_store = store;
|
||||
_file = file;
|
||||
_current = current;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
/// <summary>不强制(没有固定的创世)。</summary>
|
||||
public static TrustAnchor None { get; } = new(null, null, null, NullNodeLog.Instance);
|
||||
|
||||
public bool Enforcing => _file is not null;
|
||||
|
||||
public string? Genesis => _file?.Genesis;
|
||||
|
||||
public MemberProofDto? OwnProof => _file?.Proof;
|
||||
|
||||
public static TrustAnchor Load(string identityDirectory, INodeLog log)
|
||||
{
|
||||
string path = Path.Combine(identityDirectory, FileName);
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return new TrustAnchor(new ConfigStore(identityDirectory), null, null, log);
|
||||
}
|
||||
|
||||
TrustFile file = NodeConfig.Load(path, TrustFileContext.Default.TrustFile);
|
||||
OperatorSet? current = OperatorTrust.VerifyChain(file.Genesis, [.. file.Chain.Select(c => c.ToSigned())]);
|
||||
if (current is null)
|
||||
{
|
||||
// 固定的创世和存下来的链对不上:不能当成「不强制」放过,直接让节点起不来比悄悄失去保护好
|
||||
throw new InvalidDataException($"{path} does not verify against its pinned genesis; remove the node's identity and enroll again");
|
||||
}
|
||||
|
||||
return new TrustAnchor(new ConfigStore(identityDirectory), file, current, log);
|
||||
}
|
||||
|
||||
/// <summary>注册时写下:固定的创世、验证过的链、本节点的证明。</summary>
|
||||
public static void Pin(string identityDirectory, string genesis, IReadOnlyList<SignedStatementDto> chain, MemberProofDto proof) =>
|
||||
new ConfigStore(identityDirectory).Write(FileName, JsonSerializer.SerializeToUtf8Bytes(new TrustFile { Genesis = genesis, Chain = [.. chain], Proof = proof }, TrustFileContext.Default.TrustFile));
|
||||
|
||||
/// <summary>期望状态里带来的链:从固定的创世验起,比现有的新才采用并保存。返回当前链的版本(没有强制时为 0)。</summary>
|
||||
public int Update(IReadOnlyList<SignedStatementDto>? chain)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_file is null || chain is not { Count: > 0 })
|
||||
{
|
||||
return _current?.Version ?? 0;
|
||||
}
|
||||
|
||||
OperatorSet? candidate = OperatorTrust.VerifyChain(_file.Genesis, [.. chain.Select(c => c.ToSigned())]);
|
||||
if (candidate is null)
|
||||
{
|
||||
_log.Warn("the operator set from the panel does not verify against the pinned genesis; ignoring it");
|
||||
return _current!.Version;
|
||||
}
|
||||
|
||||
if (candidate.Version > _current!.Version)
|
||||
{
|
||||
_file.Chain = [.. chain];
|
||||
_current = candidate;
|
||||
try
|
||||
{
|
||||
_store?.Write(FileName, JsonSerializer.SerializeToUtf8Bytes(_file, TrustFileContext.Default.TrustFile));
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
_log.Warn("could not save trust.json", ("err", ex.Message));
|
||||
}
|
||||
|
||||
_log.Info("operator set updated", ("version", candidate.Version));
|
||||
}
|
||||
|
||||
return _current.Version;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>不强制就放行;强制时对端必须带证明,且验证通过(密钥是用来认证它的那把)。</summary>
|
||||
public TrustFailure VerifyPeer(MemberProofDto? proof, ReadOnlySpan<byte> peerPublicKey, string role)
|
||||
{
|
||||
OperatorSet? current;
|
||||
lock (_gate)
|
||||
{
|
||||
if (_file is null)
|
||||
{
|
||||
return TrustFailure.None;
|
||||
}
|
||||
|
||||
current = _current;
|
||||
}
|
||||
|
||||
return proof is null || current is null ? TrustFailure.Malformed : OperatorTrust.VerifyPeer(current, proof.ToProof(), peerPublicKey, role);
|
||||
}
|
||||
}
|
||||
@@ -187,6 +187,16 @@ public static class OperatorTrust
|
||||
return WebAuthnAssertion.Verify(key.PublicKeySpki, authData, clientData, signature, set.RpId, SHA256.HashData(Encoding.UTF8.GetBytes(signed.Statement)), set.Origins.Contains, requireUserVerification: true, out _);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证一个对端:和 <see cref="VerifyMember"/> 一样,只是调用方手里只有它用来认证这个对端的那把密钥(对等面公钥),
|
||||
/// 所以只核对绑定里的对等面公钥;节点身份公钥由 Backend 在注册时核对。
|
||||
/// </summary>
|
||||
public static TrustFailure VerifyPeer(OperatorSet current, MemberProof proof, ReadOnlySpan<byte> peerPublicKey, string role)
|
||||
{
|
||||
Binding? binding = ParseBinding(proof.BindingStatement);
|
||||
return binding is null ? TrustFailure.Malformed : VerifyMember(current, proof, binding.NodePublicKey, peerPublicKey, role);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证一个成员:委托由当前操作员集合里的凭证签(并且在有效期内),绑定由委托里的码公钥签,
|
||||
/// 绑定里的节点公钥、对等面公钥和角色对得上调用方手里的这个成员。
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
using Fleet.Core;
|
||||
|
||||
namespace Fleet.Identity;
|
||||
|
||||
public static class TrustMapping
|
||||
{
|
||||
public static SignedStatement ToSigned(this SignedStatementDto dto) => new(dto.Statement, dto.CredentialId, dto.AuthenticatorData, dto.ClientDataJson, dto.Signature);
|
||||
|
||||
public static SignedStatementDto ToDto(this SignedStatement signed) =>
|
||||
new() { Statement = signed.Statement, CredentialId = signed.CredentialId, AuthenticatorData = signed.AuthenticatorData, ClientDataJson = signed.ClientDataJson, Signature = signed.Signature };
|
||||
|
||||
public static MemberProof ToProof(this MemberProofDto dto) => new(dto.Grant.ToSigned(), dto.Binding, dto.BindingSignature);
|
||||
|
||||
public static MemberProofDto ToDto(this MemberProof proof) => new() { Grant = proof.Grant.ToDto(), Binding = proof.BindingStatement, BindingSignature = proof.BindingSignature };
|
||||
}
|
||||
@@ -34,7 +34,8 @@ public sealed record NewNode(
|
||||
long NowMs,
|
||||
string Kind = "",
|
||||
byte[]? TlsPublicKey = null,
|
||||
string? AddedBy = null);
|
||||
string? AddedBy = null,
|
||||
MemberProofDto? Proof = null);
|
||||
|
||||
public sealed record JoinTokenRecord(
|
||||
string Id,
|
||||
@@ -48,6 +49,7 @@ public sealed record JoinTokenRecord(
|
||||
long? RevokedAtMs,
|
||||
long CreatedAtMs,
|
||||
string Kind = "",
|
||||
string? CreatedBy = null);
|
||||
string? CreatedBy = null,
|
||||
SignedStatementDto? Grant = null);
|
||||
|
||||
public sealed record Presence(bool Online, long AtMs, string? NodeVersion, IReadOnlyList<string> Capabilities, string? HelloMetaJson);
|
||||
@@ -6,6 +6,12 @@ namespace Fleet.Server;
|
||||
|
||||
public sealed record EnrollmentContext(string? RemoteIp, JsonElement? ProductMeta);
|
||||
|
||||
/// <summary>操作员集合的链(passkey 签的,创世在前)。没有初始化信任根时是空的。节点注册时用它确认「这个面板确实是我的操作员的」。</summary>
|
||||
public interface IOperatorTrustSource
|
||||
{
|
||||
ValueTask<IReadOnlyList<SignedStatementDto>> ChainAsync(CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>产品挂钩:节点注册成功之后调用。</summary>
|
||||
public interface IEnrollmentHook
|
||||
{
|
||||
|
||||
@@ -33,10 +33,23 @@ public sealed class EnrollRequest
|
||||
[JsonPropertyName("node_version")]
|
||||
public string NodeVersion { get; set; } = "";
|
||||
|
||||
/// <summary>带操作员委托的加入码要附上:用码的私钥签的绑定语句(节点的两把公钥和名字)和它的签名。</summary>
|
||||
[JsonPropertyName("binding")]
|
||||
public string? Binding { get; set; }
|
||||
|
||||
[JsonPropertyName("binding_signature")]
|
||||
public string? BindingSignature { get; set; }
|
||||
|
||||
[JsonPropertyName("meta")]
|
||||
public JsonElement? Meta { get; set; }
|
||||
}
|
||||
|
||||
public sealed class TrustRequest
|
||||
{
|
||||
[JsonPropertyName("join_token")]
|
||||
public string JoinToken { get; set; } = "";
|
||||
}
|
||||
|
||||
public sealed class EnrollResponse
|
||||
{
|
||||
[JsonPropertyName("node_id")]
|
||||
@@ -69,5 +82,7 @@ public sealed class FleetProblem
|
||||
[JsonSourceGenerationOptions(DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
[JsonSerializable(typeof(EnrollRequest))]
|
||||
[JsonSerializable(typeof(EnrollResponse))]
|
||||
[JsonSerializable(typeof(TrustRequest))]
|
||||
[JsonSerializable(typeof(Fleet.Core.TrustBundle))]
|
||||
[JsonSerializable(typeof(FleetProblem))]
|
||||
internal sealed partial class EnrollmentJsonContext : JsonSerializerContext;
|
||||
@@ -20,9 +20,42 @@ public static class FleetEnrollmentEndpoints
|
||||
.WithTags("agent")
|
||||
.WithSummary("Enroll a node with a join token")
|
||||
.DisableAntiforgery();
|
||||
// 注册之前取信任链:节点先确认这个面板的操作员集合和委托确实是它固定的创世签出来的,再把自己的密钥交出去
|
||||
app.MapPost(path + "/trust", HandleTrustAsync)
|
||||
.WithName("fleet_enroll_trust")
|
||||
.WithTags("agent")
|
||||
.WithSummary("The operator set chain and the grant of a join token, for the node to verify before it enrolls")
|
||||
.DisableAntiforgery();
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleTrustAsync(HttpContext http, FleetEnrollmentService service)
|
||||
{
|
||||
TrustRequest? request;
|
||||
try
|
||||
{
|
||||
request = await JsonSerializer.DeserializeAsync(http.Request.Body, EnrollmentJsonContext.Default.TrustRequest, http.RequestAborted);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
request = null;
|
||||
}
|
||||
|
||||
if (request is null || string.IsNullOrEmpty(request.JoinToken))
|
||||
{
|
||||
return Problem(StatusCodes.Status400BadRequest, FleetErrors.InvalidParams, "join_token is required");
|
||||
}
|
||||
|
||||
Result<TrustBundle> result = await service.GetTrustAsync(request.JoinToken, http.RequestAborted);
|
||||
if (!result.TryGet(out TrustBundle? bundle, out FleetError? error))
|
||||
{
|
||||
return Problem(error.Code == FleetErrors.JoinTokenExpired ? StatusCodes.Status410Gone : StatusCodes.Status401Unauthorized, error.Code, error.Message);
|
||||
}
|
||||
|
||||
http.Response.Headers.CacheControl = "no-store";
|
||||
return Results.Json(bundle, EnrollmentJsonContext.Default.TrustBundle);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleAsync(HttpContext http, FleetEnrollmentService service)
|
||||
{
|
||||
EnrollRequest? request;
|
||||
|
||||
@@ -9,13 +9,15 @@ public sealed partial class FleetEnrollmentService
|
||||
{
|
||||
private readonly IFleetStore _store;
|
||||
private readonly IEnumerable<IEnrollmentHook> _hooks;
|
||||
private readonly IEnumerable<IOperatorTrustSource> _trust;
|
||||
private readonly FleetServerOptions _options;
|
||||
private readonly TimeProvider _time;
|
||||
|
||||
public FleetEnrollmentService(IFleetStore store, IEnumerable<IEnrollmentHook> hooks, FleetServerOptions options, TimeProvider time)
|
||||
public FleetEnrollmentService(IFleetStore store, IEnumerable<IEnrollmentHook> hooks, IEnumerable<IOperatorTrustSource> trust, FleetServerOptions options, TimeProvider time)
|
||||
{
|
||||
_store = store;
|
||||
_hooks = hooks;
|
||||
_trust = trust;
|
||||
_options = options;
|
||||
_time = time;
|
||||
}
|
||||
@@ -59,6 +61,17 @@ public sealed partial class FleetEnrollmentService
|
||||
return Result<EnrollResponse>.Fail(FleetErrors.ProofInvalid, "proof of possession does not verify");
|
||||
}
|
||||
|
||||
// 带操作员委托的码:节点必须附上用码的私钥签的绑定,委托、绑定和这次注册的密钥、名字、角色都对得上才收
|
||||
MemberProofDto? memberProof = null;
|
||||
if (token.Grant is not null)
|
||||
{
|
||||
Result<MemberProofDto> verified = await VerifyBindingAsync(token, request, publicKey, tlsPublicKey, ct);
|
||||
if (!verified.TryGet(out memberProof, out FleetError? bindingError))
|
||||
{
|
||||
return Result<EnrollResponse>.Fail(bindingError);
|
||||
}
|
||||
}
|
||||
|
||||
if (!await _store.TryConsumeJoinTokenAsync(token.Id, nowMs, ct))
|
||||
{
|
||||
return Result<EnrollResponse>.Fail(FleetErrors.JoinTokenInvalid, "join token is invalid");
|
||||
@@ -81,7 +94,8 @@ public sealed partial class FleetEnrollmentService
|
||||
nowMs,
|
||||
token.Kind,
|
||||
tlsPublicKey,
|
||||
token.CreatedBy);
|
||||
token.CreatedBy,
|
||||
memberProof);
|
||||
|
||||
Result<NodeRecord> created = await _store.CreateNodeAsync(node, ct);
|
||||
if (!created.TryGet(out NodeRecord? record, out FleetError? error))
|
||||
@@ -102,6 +116,50 @@ public sealed partial class FleetEnrollmentService
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>节点注册之前取:操作员集合的链,和这个码的委托(如果有)。码要是有效的;不能用的码不给。</summary>
|
||||
public async ValueTask<Result<TrustBundle>> GetTrustAsync(string joinToken, CancellationToken ct)
|
||||
{
|
||||
JoinTokenRecord? token = await _store.FindJoinTokenAsync(TokenHash.Sha256(joinToken), ct);
|
||||
long nowMs = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
if (token is null || token.RevokedAtMs is not null || token.Uses >= token.MaxUses)
|
||||
{
|
||||
return Result<TrustBundle>.Fail(FleetErrors.JoinTokenInvalid, "join token is invalid");
|
||||
}
|
||||
|
||||
if (token.ExpiresAtMs <= nowMs)
|
||||
{
|
||||
return Result<TrustBundle>.Fail(FleetErrors.JoinTokenExpired, "join token has expired");
|
||||
}
|
||||
|
||||
var bundle = new TrustBundle { Grant = token.Grant };
|
||||
foreach (IOperatorTrustSource source in _trust)
|
||||
{
|
||||
bundle.Chain.AddRange(await source.ChainAsync(ct));
|
||||
}
|
||||
|
||||
return Result<TrustBundle>.Ok(bundle);
|
||||
}
|
||||
|
||||
private async ValueTask<Result<MemberProofDto>> VerifyBindingAsync(JoinTokenRecord token, EnrollRequest request, byte[] publicKey, byte[] tlsPublicKey, CancellationToken ct)
|
||||
{
|
||||
IReadOnlyList<SignedStatementDto> chain = _trust.Any() ? await _trust.First().ChainAsync(ct) : [];
|
||||
OperatorSet? current = chain.Count == 0 ? null : OperatorTrust.VerifyChain(OperatorTrust.Fingerprint(chain[0].Statement), [.. chain.Select(c => c.ToSigned())]);
|
||||
if (current is null || string.IsNullOrEmpty(request.Binding) || string.IsNullOrEmpty(request.BindingSignature))
|
||||
{
|
||||
return Result<MemberProofDto>.Fail(FleetErrors.ProofInvalid, "this join code carries an operator grant: the enrollment needs a binding signed with the code key");
|
||||
}
|
||||
|
||||
string role = token.Kind.Length == 0 ? "agent" : token.Kind;
|
||||
var proof = new MemberProofDto { Grant = token.Grant!, Binding = request.Binding, BindingSignature = request.BindingSignature };
|
||||
TrustFailure failure = OperatorTrust.VerifyMember(current, proof.ToProof(), publicKey, tlsPublicKey, role);
|
||||
if (failure != TrustFailure.None || OperatorTrust.ParseBinding(request.Binding)?.Name != request.Name)
|
||||
{
|
||||
return Result<MemberProofDto>.Fail(FleetErrors.ProofInvalid, "the binding does not verify against the operator's grant (" + failure + ")");
|
||||
}
|
||||
|
||||
return Result<MemberProofDto>.Ok(proof);
|
||||
}
|
||||
|
||||
private static bool TryDecode(string text, out byte[] data)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -12,7 +12,7 @@ string? name = Arg("--name");
|
||||
string? directory = Arg("--identity-dir");
|
||||
if (server is null || join is null || name is null || directory is null)
|
||||
{
|
||||
Console.Error.WriteLine("usage: fleet-enroll --server <url> --join <token> --name <node name> --identity-dir <dir> [--meta <json>] [--version <v>]");
|
||||
Console.Error.WriteLine("usage: fleet-enroll --server <url> --join <token> --name <node name> --identity-dir <dir> [--meta <json>] [--version <v>] [--role agent|probe --seed <code seed> --genesis <operator fingerprint>]");
|
||||
return 2;
|
||||
}
|
||||
|
||||
@@ -30,14 +30,18 @@ if (Arg("--meta") is { } metaText)
|
||||
}
|
||||
}
|
||||
|
||||
string? seed = Arg("--seed");
|
||||
string? genesis = Arg("--genesis");
|
||||
string role = Arg("--role") ?? "agent";
|
||||
|
||||
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
|
||||
Result<EnrollmentResult> result = await EnrollmentClient.EnrollAsync(
|
||||
http, new Uri(server), join, name, directory, Arg("--version") ?? "0.0.0", meta, TimeProvider.System, CancellationToken.None);
|
||||
http, new Uri(server), join, name, directory, Arg("--version") ?? "0.0.0", meta, TimeProvider.System, CancellationToken.None, seed, genesis, role);
|
||||
if (!result.TryGet(out EnrollmentResult? enrolled, out FleetError? error))
|
||||
{
|
||||
Console.Error.WriteLine($"enrollment failed ({error.Code}): {error.Message}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
Console.WriteLine($"enrolled as {enrolled.NodeId} ({enrolled.Status}); identity written to {enrolled.IdentityDirectory}");
|
||||
Console.WriteLine($"enrolled as {enrolled.NodeId} ({enrolled.Status}); identity written to {enrolled.IdentityDirectory}" + (enrolled.Pinned ? "; operator trust pinned" : "; operator trust NOT pinned (no --genesis)"));
|
||||
return 0;
|
||||
Reference in new issue
Block a user