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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user