feat!: genesis-pinned operator set, server identity with an encrypted control link, key-less join codes; release key continuity
ci/woodpecker/tag/gitea-release Pipeline is pending
ci/woodpecker/tag/linux-release Pipeline failed
ci/woodpecker/tag/windows-release Pipeline was canceled

Join codes need no passkey touch and carry only public values (genesis
fingerprint, owner credentials, installer hashes). Nodes pin the genesis and
owners at enrollment and only follow passkey-signed operator set versions.
The panel's server key is listed in the set; pinned nodes require its
signature and run the control link under AES-256-GCM. Password sessions carry
no capabilities. The updater adopts a next_key from a signed manifest.

Bump version to 0.2.0. [CI SKIP]
This commit is contained in:
justme committed 2026-10-08 03:21:44 +08:00
1 parent 9bb6841dce
commit a2f88a344b
89 files changed
+2145 -1195

No files matched your search

@@ -23,7 +23,7 @@ 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,
string? seed = null, string? genesis = null, string role = "agent")
string? genesis = null, IReadOnlyList<string>? controllers = null)
{
string nodeFile = Path.Combine(identityDirectory, LoadedNodeIdentity.NodeFileName);
if (File.Exists(nodeFile))
@@ -41,25 +41,16 @@ public static class EnrollmentClient
tlsPublicKey = certificate.PublicKey.ExportSubjectPublicKeyInfo();
}
// 固定了操作员的创世(--genesis)和码的种子(--seed):先取信任链,确认这个面板的操作员集合和委托确实是固定的创世签出来的,
// 再用码的私钥签绑定。任何一步对不上都不注册,也不把自己的密钥交出去。
Binding? binding = null;
string? bindingStatement = null;
string? bindingSignature = null;
// 安装命令里带了创世指纹(--genesis)和属主(--controllers):先取操作员集合链,从固定的创世验起,
// 属主必须是集合里的凭证;对不上就不注册,也不把自己的密钥交出去。通过之后固定到本地 trust.json,Backend 之后改不了。
TrustBundle? bundle = null;
if (seed is not null || genesis is not null)
if (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))
Result<TrustBundle> trusted = await FetchTrustAsync(http, server, joinToken, genesis, controllers ?? [], ct);
if (!trusted.TryGet(out bundle, 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);
@@ -74,8 +65,6 @@ 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");
@@ -104,9 +93,9 @@ 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)
if (bundle is not null)
{
TrustAnchor.Pin(identityDirectory, genesis!, bundle.Chain, new MemberProofDto { Grant = bundle.Grant!, Binding = bindingStatement!, BindingSignature = bindingSignature! });
TrustAnchor.Pin(identityDirectory, genesis!, bundle.Chain, controllers ?? []);
}
// node.json 最后写:它存在就表示注册完整完成
@@ -116,37 +105,21 @@ public static class EnrollmentClient
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)
private static async Task<Result<TrustBundle>> FetchTrustAsync(HttpClient http, Uri server, string joinToken, string genesis, IReadOnlyList<string> controllers, 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");
}
static Result<TrustBundle> Refuse(string reason) => Result<TrustBundle>.Fail(FleetErrors.ProofInvalid, "refusing to enroll: " + reason);
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");
return Result<TrustBundle>.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)
if (bundle 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");
return Refuse("the panel has no operator trust root set up");
}
OperatorSet? current = OperatorTrust.VerifyChain(genesis, [.. bundle.Chain.Select(c => c.ToSigned())]);
@@ -155,26 +128,11 @@ public static class EnrollmentClient
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))
if (controllers.Any(c => current.Keys.All(k => k.CredentialId != c)))
{
return Refuse("the grant is not signed by your operator passkey");
return Refuse("an owner passkey in the install command is not in the operator set");
}
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));
return Result<TrustBundle>.Ok(bundle);
}
}