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

@@ -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