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

+2 -18
View File
@@ -2,7 +2,7 @@ using System.Text.Json.Serialization;
namespace Fleet.Core;
/// <summary>passkey 对一段语句签出的断言(字段都是 base64url)。信任链的格式和验证在 Fleet.Identity 的 <c>OperatorTrust</c>。</summary>
/// <summary>passkey 对一段语句签出的断言(字段都是 base64url)。信任根的格式和验证在 Fleet.Identity 的 <c>OperatorTrust</c>。</summary>
public sealed class SignedStatementDto
{
[JsonPropertyName("statement")]
@@ -21,25 +21,9 @@ public sealed class SignedStatementDto
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>
/// <summary>操作员集合的链(创世在前):节点注册时取来,从固定的创世指纹验过才采用。</summary>
public sealed class TrustBundle
{
[JsonPropertyName("chain")]
public List<SignedStatementDto> Chain { get; set; } = [];
[JsonPropertyName("grant")]
public SignedStatementDto? Grant { get; set; }
}
@@ -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);
}
}
+70 -32
View File
@@ -5,7 +5,7 @@ using Fleet.Identity;
namespace Fleet.Hosting;
/// <summary><c>trust.json</c>:注册时固定的创世指纹、见过的最新操作员集合链,以及(如果有)本节点自己的证明。</summary>
/// <summary><c>trust.json</c>:安装时固定的创世指纹和属主,加见过的最新操作员集合链。</summary>
public sealed class TrustFile
{
[JsonPropertyName("genesis")]
@@ -14,19 +14,20 @@ public sealed class TrustFile
[JsonPropertyName("chain")]
public List<SignedStatementDto> Chain { get; set; } = [];
[JsonPropertyName("proof")]
public MemberProofDto? Proof { get; set; }
/// <summary>属主:允许授权本节点 extension 会话(web ssh 等)的 passkey 凭证 id,安装时固定,必须是操作员集合里的凭证。</summary>
[JsonPropertyName("controllers")]
public List<string> Controllers { get; set; } = [];
}
[JsonSerializable(typeof(TrustFile))]
public sealed partial class TrustFileContext : JsonSerializerContext;
/// <summary>
/// 节点的信任根:安装时固定下来的创世指纹,加验证过的操作员集合链。有它就是「强制」模式——
/// 名册里的对端必须带操作员批准的证明才接纳(Backend 改不了,也签不出新的);没有 <c>trust.json</c>(旧节点、没有指纹的安装)就不强制。
/// 链随期望状态下发,节点只接受从固定的创世出发、签名完整、版本更新的链。
/// 节点的信任根:安装命令里带来的创世指纹和属主,在注册时固定到本地,之后 Backend 改不了。
/// 操作员集合的新版本随期望状态下发,节点只接受从固定的创世出发、签名完整、版本更新的链。
/// 属主只是被固定和保存:节点现在没有 extension,等做 extension 时由节点本地强制(见 docs/access.md 第 8 节)。
/// </summary>
public sealed class TrustAnchor
public sealed class TrustAnchor : IServerVerifier
{
public const string FileName = "trust.json";
@@ -44,14 +45,68 @@ public sealed class TrustAnchor
_log = log;
}
/// <summary>不强制(没有固定的创世)。</summary>
/// <summary>没有固定的创世(旧节点,或没有指纹的安装)。</summary>
public static TrustAnchor None { get; } = new(null, null, null, NullNodeLog.Instance);
public bool Enforcing => _file is not null;
public bool Pinned => _file is not null;
public string? Genesis => _file?.Genesis;
public MemberProofDto? OwnProof => _file?.Proof;
/// <summary>当前生效的操作员集合版本;没有固定时为 0。</summary>
public int Version
{
get
{
lock (_gate)
{
return _current?.Version ?? 0;
}
}
}
/// <summary>当前操作员集合里登记了服务器密钥:服务器必须在认证挑战里证明自己,链路用应用层加密。</summary>
public bool Required
{
get
{
lock (_gate)
{
return _current?.Servers is { Count: > 0 };
}
}
}
/// <summary>服务器的签名是不是当前操作员集合里登记的某把服务器密钥签的。</summary>
public bool Verify(string nonce, ReadOnlySpan<byte> serverEphemeralSpki, ReadOnlySpan<byte> signature)
{
IReadOnlyList<byte[]> servers;
lock (_gate)
{
servers = _current?.Servers ?? [];
}
foreach (byte[] server in servers)
{
if (ServerHello.Verify(server, nonce, serverEphemeralSpki, signature))
{
return true;
}
}
return false;
}
/// <summary>安装时固定的属主里,仍然在当前操作员集合里的那些(从集合里去掉的凭证不再有效)。</summary>
public IReadOnlyList<string> Controllers
{
get
{
lock (_gate)
{
return _file is null || _current is null ? [] : [.. _file.Controllers.Where(c => _current.Keys.Any(k => k.CredentialId == c))];
}
}
}
public static TrustAnchor Load(string identityDirectory, INodeLog log)
{
@@ -65,18 +120,18 @@ public sealed class TrustAnchor
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>注册时写下:固定的创世、验证过的链、属主。</summary>
public static void Pin(string identityDirectory, string genesis, IReadOnlyList<SignedStatementDto> chain, IReadOnlyList<string> controllers) =>
new ConfigStore(identityDirectory).Write(FileName, JsonSerializer.SerializeToUtf8Bytes(new TrustFile { Genesis = genesis, Chain = [.. chain], Controllers = [.. controllers] }, TrustFileContext.Default.TrustFile));
/// <summary>期望状态里带来的链:从固定的创世验起,比现有的新才采用并保存。返回当前链的版本(没有强制时为 0)。</summary>
/// <summary>期望状态里带来的链:从固定的创世验起,比现有的新才采用并保存。返回当前链的版本(没有固定时为 0)。</summary>
public int Update(IReadOnlyList<SignedStatementDto>? chain)
{
lock (_gate)
@@ -112,21 +167,4 @@ public sealed class TrustAnchor
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);
}
}
@@ -7,6 +7,9 @@ namespace Fleet.Identity;
public static class SignaturePurpose
{
public const string Auth = "auth";
/// <summary>安全链路的认证:签名同时覆盖服务器和节点的临时公钥,把这次认证和这条加密链路绑在一起。</summary>
public const string AuthSecure = "auth2";
public const string Peer = "peer";
public const string Enroll = "enroll";
}
+24 -185
View File
@@ -15,45 +15,21 @@ public sealed record OperatorKey(string CredentialId, byte[] PublicKeySpki);
/// 操作员集合的一个版本。<c>RpId</c> 和 <c>Origins</c> 也在里面:节点验 WebAuthn 断言要用,而它们被操作员的签名覆盖,Backend 改不了。
/// 创世版本(1)由它自己的某把凭证签名,指纹(<see cref="OperatorTrust.Fingerprint"/>)在安装时固定到节点;之后每个版本由上一版里的凭证签名。
/// </summary>
public sealed record OperatorSet(int Version, string Previous, string RpId, IReadOnlyList<string> Origins, IReadOnlyList<OperatorKey> Keys);
/// <summary>委托:操作员授权了一个加入码。<c>CodeKey</c> 是这个码的公钥(SPKI),码的种子是对应的私钥,只在安装命令里。</summary>
public sealed record Grant(string Role, string AuthHash, byte[] CodeKey, long NotBeforeMs, long NotAfterMs, int Uses);
/// <summary>节点绑定:用码的私钥签的,把节点的两把公钥和这个码连起来。<c>AtMs</c> 必须落在委托的有效期内。</summary>
public sealed record Binding(string Name, string Role, byte[] NodePublicKey, byte[] PeerPublicKey, string AuthHash, long AtMs);
/// <summary>一个成员带的证明:委托(passkey 签)加绑定(码的私钥签)。</summary>
public sealed record MemberProof(SignedStatement Grant, string BindingStatement, string BindingSignature);
public enum TrustFailure
{
None,
Malformed,
BrokenChain,
GrantNotSigned,
GrantExpired,
BindingInvalid,
KeyMismatch,
RoleMismatch,
}
/// <param name="Servers">服务器密钥(SPKI):操作员授权的 Backend 签名密钥。集合里登记了服务器密钥之后,节点要求服务器在认证挑战里用它签名,
/// 并在这条链路上用应用层加密,所以 DNS 或证书层面的冒充拿不出这把密钥就骗不了节点。空 = 还没有启用。</param>
public sealed record OperatorSet(int Version, string Previous, string RpId, IReadOnlyList<string> Origins, IReadOnlyList<OperatorKey> Keys, IReadOnlyList<byte[]>? Servers = null);
/// <summary>
/// 信任根:操作员(passkey)→ 操作员集合 → 委托 → 绑定 → 节点公钥。设计见 docs/access.md 第 7 节。
/// 全是纯函数,只用 BCL,节点和 Backend 共用:节点用它确认「对端是我的操作员批准的」,Backend 用它拒绝没有合法绑定的注册。
/// 语句是 <c>key=value</c> 的文本,第一行是带版本的标题;签名覆盖整段文本,规范化靠固定的字段顺序和严格的解析。
/// 信任根:操作员(passkey)→ 操作员集合 → 节点本地固定的属主。设计见 docs/access.md 第 7 节。
/// 安装命令里带着创世指纹和属主(passkey 的凭证 id):节点把它们固定在本地,之后只认从创世出发、由操作员 passkey 签过名的集合版本,
/// 属主必须是集合里的凭证。Backend 能转发集合,但签不出新的版本,也改不了节点已经固定的东西。
/// 全是纯函数,只用 BCL,节点和 Backend 共用。语句是 <c>key=value</c> 的文本,第一行是带版本的标题;签名覆盖整段文本。
/// </summary>
public static class OperatorTrust
{
public const string SetHeader = "moshiro-opset-v1";
public const string GrantHeader = "moshiro-grant-v1";
public const string BindingHeader = "moshiro-binding-v1";
public const string Genesis = "0";
private const string AuthLabel = "moshiro-join-auth-v1";
// ───────────────────────── 编码 ─────────────────────────
public static string Encode(OperatorSet set)
{
var text = new StringBuilder();
@@ -67,21 +43,15 @@ public static class OperatorTrust
text.Append("\nkey=").Append(key.CredentialId).Append(':').Append(Convert.ToBase64String(key.PublicKeySpki));
}
foreach (byte[] server in set.Servers ?? [])
{
text.Append("\nserver=").Append(Convert.ToBase64String(server));
}
return text.ToString();
}
public static string Encode(Grant grant) =>
string.Join(
'\n', GrantHeader, "role=" + grant.Role, "auth=" + grant.AuthHash, "key=" + Convert.ToBase64String(grant.CodeKey),
"nb=" + grant.NotBeforeMs.ToString(CultureInfo.InvariantCulture), "na=" + grant.NotAfterMs.ToString(CultureInfo.InvariantCulture), "uses=" + grant.Uses.ToString(CultureInfo.InvariantCulture));
public static string Encode(Binding binding) =>
string.Join(
'\n', BindingHeader, "name=" + binding.Name, "role=" + binding.Role, "pub=" + Convert.ToBase64String(binding.NodePublicKey),
"peer=" + Convert.ToBase64String(binding.PeerPublicKey), "auth=" + binding.AuthHash, "at=" + binding.AtMs.ToString(CultureInfo.InvariantCulture));
// ───────────────────────── 解析(严格:多一行、少一行、顺序不对都算坏) ─────────────────────────
/// <summary>严格解析:多一行、少一行、顺序不对都算坏。</summary>
public static OperatorSet? ParseSet(string text)
{
string[] lines = text.Split('\n');
@@ -92,46 +62,26 @@ public static class OperatorTrust
}
var keys = new List<OperatorKey>();
var servers = new List<byte[]>();
foreach (string line in lines.Skip(5))
{
if (!TryValue(line, "key", out string value) || value.IndexOf(':', StringComparison.Ordinal) is not (> 0 and int split) || !TryBase64(value[(split + 1)..], out byte[] spki))
if (servers.Count == 0 && TryValue(line, "key", out string value) && value.IndexOf(':', StringComparison.Ordinal) is > 0 and int split && TryBase64(value[(split + 1)..], out byte[] spki))
{
keys.Add(new OperatorKey(value[..split], spki));
}
else if (TryValue(line, "server", out string serverText) && TryBase64(serverText, out byte[] serverKey))
{
servers.Add(serverKey);
}
else
{
return null;
}
keys.Add(new OperatorKey(value[..split], spki));
}
return keys.Count == 0 || (number == 1) != (previous == Genesis) ? null : new OperatorSet(number, previous, rp, origins.Split(','), keys);
return keys.Count == 0 || (number == 1) != (previous == Genesis) ? null : new OperatorSet(number, previous, rp, origins.Split(','), keys, servers);
}
public static Grant? ParseGrant(string text)
{
string[] lines = text.Split('\n');
if (lines.Length != 7 || lines[0] != GrantHeader || !TryValue(lines[1], "role", out string role) || !TryValue(lines[2], "auth", out string auth) || !TryValue(lines[3], "key", out string key) || !TryBase64(key, out byte[] codeKey)
|| !TryLong(lines[4], "nb", out long notBefore) || !TryLong(lines[5], "na", out long notAfter) || !TryValue(lines[6], "uses", out string uses) || !int.TryParse(uses, NumberStyles.None, CultureInfo.InvariantCulture, out int count)
|| role.Length == 0 || auth.Length != 64)
{
return null;
}
return new Grant(role, auth, codeKey, notBefore, notAfter, count);
}
public static Binding? ParseBinding(string text)
{
string[] lines = text.Split('\n');
if (lines.Length != 7 || lines[0] != BindingHeader || !TryValue(lines[1], "name", out string name) || !TryValue(lines[2], "role", out string role) || !TryValue(lines[3], "pub", out string pub) || !TryBase64(pub, out byte[] node)
|| !TryValue(lines[4], "peer", out string peer) || !TryBase64(peer, out byte[] peerKey) || !TryValue(lines[5], "auth", out string auth) || !TryLong(lines[6], "at", out long at) || auth.Length != 64)
{
return null;
}
return new Binding(name, role, node, peerKey, auth, at);
}
// ───────────────────────── 指纹与验证 ─────────────────────────
/// <summary>一段语句的指纹(SHA-256,小写十六进制)。创世版本的指纹就是安装命令里固定的那个。</summary>
public static string Fingerprint(string statement) => Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(statement)));
@@ -187,111 +137,6 @@ 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>
/// 验证一个成员:委托由当前操作员集合里的凭证签(并且在有效期内),绑定由委托里的码公钥签,
/// 绑定里的节点公钥、对等面公钥和角色对得上调用方手里的这个成员。
/// </summary>
public static TrustFailure VerifyMember(OperatorSet current, MemberProof proof, ReadOnlySpan<byte> nodePublicKey, ReadOnlySpan<byte> peerPublicKey, string role)
{
Grant? grant = ParseGrant(proof.Grant.Statement);
Binding? binding = ParseBinding(proof.BindingStatement);
if (grant is null || binding is null || !TryDecode(proof.BindingSignature, out byte[] bindingSignature))
{
return TrustFailure.Malformed;
}
if (!VerifyOperator(proof.Grant, current))
{
return TrustFailure.GrantNotSigned;
}
if (binding.AtMs < grant.NotBeforeMs || binding.AtMs > grant.NotAfterMs)
{
return TrustFailure.GrantExpired;
}
if (binding.AuthHash != grant.AuthHash || binding.Role != grant.Role)
{
return TrustFailure.KeyMismatch;
}
if (!binding.NodePublicKey.AsSpan().SequenceEqual(nodePublicKey) || !binding.PeerPublicKey.AsSpan().SequenceEqual(peerPublicKey))
{
return TrustFailure.KeyMismatch;
}
if (grant.Role != role)
{
return TrustFailure.RoleMismatch;
}
try
{
using ECDsa codeKey = ECDsa.Create();
codeKey.ImportSubjectPublicKeyInfo(grant.CodeKey, out _);
return codeKey.VerifyData(Encoding.UTF8.GetBytes(proof.BindingStatement), bindingSignature, HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence)
? TrustFailure.None
: TrustFailure.BindingInvalid;
}
catch (CryptographicException)
{
return TrustFailure.BindingInvalid;
}
}
// ───────────────────────── 加入码的种子 ─────────────────────────
/// <summary>
/// 种子(32 字节,就是码的 P-256 私钥)派生出交给 Backend 的认证串:Backend 只会看到它的哈希,推不回种子,
/// 所以 Backend 没法替这个码签绑定。HKDF-SHA256,空盐,用途标签在 info 里;浏览器端的 WebCrypto 用同样的参数。
/// </summary>
public static string AuthToken(ReadOnlySpan<byte> seed) =>
"ja_" + Base64Url.EncodeToString(Derive(seed));
private static byte[] Derive(ReadOnlySpan<byte> seed)
{
byte[] output = new byte[32];
HKDF.DeriveKey(HashAlgorithmName.SHA256, seed, output, ReadOnlySpan<byte>.Empty, Encoding.UTF8.GetBytes(AuthLabel));
return output;
}
public static string AuthHash(string authToken) => Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(authToken)));
/// <summary>用种子和委托里的码公钥还原码的签名密钥(公钥从已验证的委托里来,种子里只有私钥标量)。</summary>
public static ECDsa? CodeSigningKey(ReadOnlySpan<byte> seed, ReadOnlySpan<byte> codeKeySpki)
{
try
{
using ECDsa publicOnly = ECDsa.Create();
publicOnly.ImportSubjectPublicKeyInfo(codeKeySpki, out _);
ECParameters parameters = publicOnly.ExportParameters(false);
parameters.D = seed.ToArray();
return ECDsa.Create(parameters);
}
catch (CryptographicException)
{
return null;
}
}
public static string SignBinding(ECDsa codeKey, Binding binding, out string statement)
{
statement = Encode(binding);
return Base64Url.EncodeToString(codeKey.SignData(Encoding.UTF8.GetBytes(statement), HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence));
}
// ───────────────────────── 小工具 ─────────────────────────
private static bool TryValue(string line, string key, out string value)
{
value = "";
@@ -304,12 +149,6 @@ public static class OperatorTrust
return true;
}
private static bool TryLong(string line, string key, out long value)
{
value = 0;
return TryValue(line, key, out string text) && long.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out value);
}
private static bool TryBase64(string text, out byte[] data)
{
try
@@ -0,0 +1,173 @@
using System.Buffers.Binary;
using System.Security.Cryptography;
using System.Text;
namespace Fleet.Identity;
/// <summary>
/// 节点这一侧用来确认「对面确实是我的操作员授权过的 Backend」的校验器:<c>Required</c> 为真(当前操作员集合里登记了服务器密钥)
/// 时,服务器的挑战必须带着对得上的签名,否则节点不继续认证、也不交出任何东西。
/// </summary>
public interface IServerVerifier
{
bool Required { get; }
/// <summary>服务器用它的服务器密钥对「挑战随机数加它这次连接的临时公钥」的签名。</summary>
bool Verify(string nonce, ReadOnlySpan<byte> serverEphemeralSpki, ReadOnlySpan<byte> signature);
}
/// <summary>服务器在挑战里给出的身份证明:服务器密钥(操作员签进集合)对「随机数加临时公钥」签名。纯函数。</summary>
public static class ServerHello
{
/// <summary>被签名的内容;服务器那一侧只拿到这个、用自己的密钥签(服务器密钥不一定是 BCL 的 ECDsa,可以在别处)。</summary>
public static byte[] Payload(string nonce, ReadOnlySpan<byte> serverEphemeralSpki) =>
Encoding.UTF8.GetBytes("fleet|v1|server|" + nonce + "|" + Convert.ToBase64String(serverEphemeralSpki));
public static byte[] Sign(ECDsa serverKey, string nonce, ReadOnlySpan<byte> serverEphemeralSpki) =>
serverKey.SignData(Payload(nonce, serverEphemeralSpki), HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
public static bool Verify(ReadOnlySpan<byte> serverKeySpki, string nonce, ReadOnlySpan<byte> serverEphemeralSpki, ReadOnlySpan<byte> signature)
{
try
{
using ECDsa key = ECDsa.Create();
key.ImportSubjectPublicKeyInfo(serverKeySpki, out _);
return key.VerifyData(Payload(nonce, serverEphemeralSpki), signature, HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
}
catch (CryptographicException)
{
return false;
}
}
}
/// <summary>
/// 节点和 Backend 之间控制链路的应用层加密:两边的临时 ECDH 密钥协商出会话密钥,每个方向一把,AES-256-GCM,每帧带计数器。
/// 所以即使 TLS 在中间被终止(Cloudflare、被冒充的证书),中间人没有服务器密钥,就拿不到明文,也改不了任何一帧。
/// 帧格式:<c>计数器(8,大端) ‖ 密文 ‖ 标签(16)</c>;明文是 <c>帧类型(1) ‖ 内容</c>;计数器必须严格递增,重放、乱序、丢帧、篡改都会解密失败。
/// </summary>
public sealed class LinkCipher : IDisposable
{
public const int Overhead = 8 + 16;
private const byte NodeToServer = 0;
private const byte ServerToNode = 1;
private readonly AesGcm _send;
private readonly AesGcm _receive;
private readonly byte _sendDirection;
private readonly byte _receiveDirection;
private readonly object _gate = new();
private long _sendCounter;
private long _receiveCounter;
private LinkCipher(byte[] sendKey, byte[] receiveKey, byte sendDirection, byte receiveDirection)
{
_send = new AesGcm(sendKey, 16);
_receive = new AesGcm(receiveKey, 16);
_sendDirection = sendDirection;
_receiveDirection = receiveDirection;
}
public static LinkCipher ForNode(EphemeralKey own, ReadOnlySpan<byte> serverEphemeralSpki, string nonce)
{
(byte[] n2s, byte[] s2n) = Derive(own, serverEphemeralSpki, own.PublicKeySpki, serverEphemeralSpki, nonce);
return new LinkCipher(n2s, s2n, NodeToServer, ServerToNode);
}
public static LinkCipher ForServer(EphemeralKey own, ReadOnlySpan<byte> nodeEphemeralSpki, string nonce)
{
(byte[] n2s, byte[] s2n) = Derive(own, nodeEphemeralSpki, nodeEphemeralSpki, own.PublicKeySpki, nonce);
return new LinkCipher(s2n, n2s, ServerToNode, NodeToServer);
}
private static (byte[] NodeToServerKey, byte[] ServerToNodeKey) Derive(EphemeralKey own, ReadOnlySpan<byte> remote, ReadOnlySpan<byte> nodeEphemeral, ReadOnlySpan<byte> serverEphemeral, string nonce)
{
byte[] secret = own.AgreeRaw(remote);
try
{
byte[] info = [.. Encoding.UTF8.GetBytes("fleet|v1|link|"), .. SHA256.HashData(serverEphemeral), .. SHA256.HashData(nodeEphemeral)];
byte[] material = HKDF.DeriveKey(HashAlgorithmName.SHA256, secret, 64, Encoding.UTF8.GetBytes(nonce), info);
return (material[..32], material[32..]);
}
finally
{
CryptographicOperations.ZeroMemory(secret);
}
}
/// <summary>加密一帧。</summary>
public byte[] Seal(byte kind, ReadOnlySpan<byte> data)
{
long counter;
lock (_gate)
{
counter = _sendCounter++;
}
byte[] output = new byte[Overhead + 1 + data.Length];
BinaryPrimitives.WriteInt64BigEndian(output, counter);
byte[] plaintext = new byte[1 + data.Length];
plaintext[0] = kind;
data.CopyTo(plaintext.AsSpan(1));
_send.Encrypt(Nonce(counter), plaintext, output.AsSpan(8, plaintext.Length), output.AsSpan(8 + plaintext.Length, 16), Aad(_sendDirection, counter));
return output;
}
/// <summary>解密一帧;计数器不是期望的下一个、或者标签不对,都返回 false。</summary>
public bool TryOpen(ReadOnlySpan<byte> frame, out byte kind, out byte[] data)
{
kind = 0;
data = [];
if (frame.Length < Overhead + 1)
{
return false;
}
long counter = BinaryPrimitives.ReadInt64BigEndian(frame);
lock (_gate)
{
if (counter != _receiveCounter)
{
return false;
}
int length = frame.Length - Overhead;
byte[] plaintext = new byte[length];
try
{
_receive.Decrypt(Nonce(counter), frame.Slice(8, length), frame.Slice(8 + length, 16), plaintext, Aad(_receiveDirection, counter));
}
catch (CryptographicException)
{
return false;
}
_receiveCounter++;
kind = plaintext[0];
data = plaintext[1..];
return true;
}
}
private static byte[] Nonce(long counter)
{
byte[] nonce = new byte[12];
BinaryPrimitives.WriteInt64BigEndian(nonce.AsSpan(4), counter);
return nonce;
}
private static byte[] Aad(byte direction, long counter)
{
byte[] aad = new byte[9];
aad[0] = direction;
BinaryPrimitives.WriteInt64BigEndian(aad.AsSpan(1), counter);
return aad;
}
public void Dispose()
{
_send.Dispose();
_receive.Dispose();
}
}
@@ -16,6 +16,14 @@ public sealed class EphemeralKey : IDisposable
public byte[] PublicKeySpki => _key.ExportSubjectPublicKeyInfo();
/// <summary>与对端的临时公钥做 ECDH,返回原始共享秘密;必须再经 KDF 才能当密钥用。</summary>
public byte[] AgreeRaw(ReadOnlySpan<byte> remotePublicKeySpki)
{
using ECDiffieHellman remote = ECDiffieHellman.Create();
remote.ImportSubjectPublicKeyInfo(remotePublicKeySpki, out _);
return _key.DeriveRawSecretAgreement(remote.PublicKey);
}
/// <summary>
/// 与对端的临时公钥协商,再经 HKDF-SHA256 派生会话凭据。
/// 盐是服务端的随机数,info 绑定双方的临时公钥,防止凭据被挪用到别的握手上。
@@ -8,8 +8,4 @@ public static class TrustMapping
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 };
}
+72 -6
View File
@@ -66,6 +66,12 @@ public sealed class FleetLinkOptions
public long MaxInflightBytes { get; init; } = 4 << 20;
public Backoff Backoff { get; init; } = new();
/// <summary>
/// 校验服务器身份:<see cref="IServerVerifier.Required"/> 为真(节点固定的操作员集合里登记了服务器密钥)时,
/// 服务器必须在挑战里证明自己,之后这条链路用应用层加密;否则按旧的方式(没有固定信任根的节点)。
/// </summary>
public IServerVerifier? ServerVerifier { get; init; }
}
/// <summary>
@@ -77,6 +83,7 @@ public sealed class FleetLink
private const string RpcPath = "/api/agent/v1/rpc";
private readonly FleetLinkOptions _options;
private volatile bool _secure;
private readonly RpcRouter<LinkContext> _router;
private readonly Dictionary<string, IStateHandler> _handlers;
private readonly TimeProvider _time;
@@ -153,6 +160,9 @@ public sealed class FleetLink
}
}
/// <summary>当前这条链路是不是应用层加密的(服务器证明了身份)。</summary>
public bool IsSecure => _secure && IsConnected;
private async Task<string> RunOnceAsync(CancellationToken ct)
{
using var socket = new ClientWebSocket();
@@ -160,7 +170,7 @@ public sealed class FleetLink
socket.Options.KeepAliveTimeout = TimeSpan.FromSeconds(20); // 对端 20 秒没有回 Pong 就判定连接已死
var builder = new UriBuilder(_options.Server) { Scheme = _options.Server.Scheme == "https" ? "wss" : "ws", Path = RpcPath };
await socket.ConnectAsync(builder.Uri, ct);
await using var transport = new WsTransport(socket, _options.MaxFrameBytes);
await using var transport = new SecureTransport(new WsTransport(socket, _options.MaxFrameBytes));
string? failure = await AuthenticateAsync(transport, ct);
if (failure is not null)
@@ -168,6 +178,8 @@ public sealed class FleetLink
return failure;
}
_secure = transport.Secure;
var session = new RpcSession<LinkContext>(transport, _router, new LinkContext(this), _time);
using CancellationTokenSource sessionCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
Task run = session.RunAsync(sessionCts.Token);
@@ -211,7 +223,7 @@ public sealed class FleetLink
}
/// <summary>挑战 → 签名 → 认证结果。失败返回原因;服务端禁用该节点时不再重试。</summary>
private async Task<string?> AuthenticateAsync(WsTransport transport, CancellationToken ct)
private async Task<string?> AuthenticateAsync(SecureTransport transport, CancellationToken ct)
{
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15), _time);
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);
@@ -222,9 +234,44 @@ public sealed class FleetLink
}
ChallengeParams nonce = challenge.Params.Value.Deserialize(FleetRpcContext.Default.ChallengeParams)!;
string signature = Convert.ToBase64String(_options.Identity.Sign(SignaturePurpose.Auth, _options.NodeId.Value, nonce.Nonce));
RpcMessage request = RpcMessage.Request(1, FleetMethods.Auth, new AuthParams { NodeId = _options.NodeId.Value, Signature = signature }, FleetRpcContext.Default.AuthParams);
await transport.SendAsync(new RpcFrame(FrameKind.Text, JsonSerializer.SerializeToUtf8Bytes(request, FleetRpcContext.Default.RpcMessage)), linked.Token);
// 固定了操作员集合、而集合里登记了服务器密钥:服务器必须证明自己,否则不继续,也不交出任何东西
EphemeralKey? ephemeral = null;
AuthParams authParams;
try
{
if (_options.ServerVerifier is { Required: true } verifier)
{
if (!TryDecodeBase64(nonce.ServerEphemeral, out byte[] serverEphemeral) || !TryDecodeBase64(nonce.ServerSignature, out byte[] serverSignature)
|| !verifier.Verify(nonce.Nonce, serverEphemeral, serverSignature))
{
return "the server could not prove its identity (it is not the server your operator set authorizes)";
}
ephemeral = new EphemeralKey();
string serverEphemeralText = Convert.ToBase64String(serverEphemeral);
string nodeEphemeralText = Convert.ToBase64String(ephemeral.PublicKeySpki);
authParams = new AuthParams
{
NodeId = _options.NodeId.Value,
Signature = Convert.ToBase64String(_options.Identity.Sign(SignaturePurpose.AuthSecure, _options.NodeId.Value, nonce.Nonce, serverEphemeralText, nodeEphemeralText)),
NodeEphemeral = nodeEphemeralText,
};
RpcMessage secureRequest = RpcMessage.Request(1, FleetMethods.Auth, authParams, FleetRpcContext.Default.AuthParams);
await transport.SendAsync(new RpcFrame(FrameKind.Text, JsonSerializer.SerializeToUtf8Bytes(secureRequest, FleetRpcContext.Default.RpcMessage)), linked.Token);
transport.Enable(LinkCipher.ForNode(ephemeral, serverEphemeral, nonce.Nonce));
}
else
{
authParams = new AuthParams { NodeId = _options.NodeId.Value, Signature = Convert.ToBase64String(_options.Identity.Sign(SignaturePurpose.Auth, _options.NodeId.Value, nonce.Nonce)) };
RpcMessage request = RpcMessage.Request(1, FleetMethods.Auth, authParams, FleetRpcContext.Default.AuthParams);
await transport.SendAsync(new RpcFrame(FrameKind.Text, JsonSerializer.SerializeToUtf8Bytes(request, FleetRpcContext.Default.RpcMessage)), linked.Token);
}
}
finally
{
ephemeral?.Dispose();
}
while (true)
{
@@ -259,7 +306,26 @@ public sealed class FleetLink
}
}
private static async Task<RpcMessage?> ReceiveMessageAsync(WsTransport transport, CancellationToken ct)
private static bool TryDecodeBase64(string? text, out byte[] data)
{
data = [];
if (string.IsNullOrEmpty(text))
{
return false;
}
try
{
data = Convert.FromBase64String(text);
return true;
}
catch (FormatException)
{
return false;
}
}
private static async Task<RpcMessage?> ReceiveMessageAsync(SecureTransport transport, CancellationToken ct)
{
RpcFrame? frame = await transport.ReceiveAsync(ct);
return frame is { Kind: FrameKind.Text } f ? JsonSerializer.Deserialize(f.Data.Span, FleetRpcContext.Default.RpcMessage) : null;
@@ -0,0 +1,57 @@
using Fleet.Identity;
using Fleet.Rpc;
namespace Fleet.Link;
/// <summary>
/// 认证阶段是明文,认证通过之后换成应用层加密(<see cref="LinkCipher"/>):此后每帧都是加密的二进制帧,明文帧一律拒绝。
/// 认证阶段服务器的拒绝回复可能是明文(还没有协商好密钥),所以第一帧加密的回复到达之前允许一次明文文本帧。
/// </summary>
internal sealed class SecureTransport : IRpcTransport
{
private readonly IRpcTransport _inner;
private LinkCipher? _cipher;
private bool _allowPlaintext = true;
public SecureTransport(IRpcTransport inner)
{
_inner = inner;
}
public bool Secure => _cipher is not null;
public void Enable(LinkCipher cipher) => _cipher = cipher;
public ValueTask SendAsync(RpcFrame frame, CancellationToken ct) =>
_cipher is null ? _inner.SendAsync(frame, ct) : _inner.SendAsync(new RpcFrame(FrameKind.Binary, _cipher.Seal((byte)frame.Kind, frame.Data.Span)), ct);
public async ValueTask<RpcFrame?> ReceiveAsync(CancellationToken ct)
{
RpcFrame? frame = await _inner.ReceiveAsync(ct);
if (frame is not { } received || _cipher is null)
{
return frame;
}
if (received.Kind == FrameKind.Binary && _cipher.TryOpen(received.Data.Span, out byte kind, out byte[] data))
{
_allowPlaintext = false;
return new RpcFrame((FrameKind)kind, data);
}
if (_allowPlaintext && received.Kind == FrameKind.Text)
{
return received; // 认证被拒绝时服务器还没有协商好密钥,回复是明文
}
throw new IOException("a frame on the secure link failed to authenticate");
}
public ValueTask CloseAsync(string reason, CancellationToken ct) => _inner.CloseAsync(reason, ct);
public async ValueTask DisposeAsync()
{
_cipher?.Dispose();
await _inner.DisposeAsync();
}
}
@@ -53,6 +53,14 @@ public sealed class ChallengeParams
[JsonPropertyName("expires_at_ms")]
public long ExpiresAtMs { get; set; }
/// <summary>服务器这次连接的临时 ECDH 公钥(SPKI,base64);服务器有身份密钥时才有。</summary>
[JsonPropertyName("server_eph")]
public string? ServerEphemeral { get; set; }
/// <summary>服务器密钥对「随机数加临时公钥」的签名(base64):节点据此确认对面是操作员授权的服务器。</summary>
[JsonPropertyName("server_sig")]
public string? ServerSignature { get; set; }
}
public sealed class AuthParams
@@ -63,6 +71,10 @@ public sealed class AuthParams
/// <summary>对 <c>fleet|v1|auth|{node_id}|{nonce}</c> 的签名(base64)。</summary>
[JsonPropertyName("signature")]
public string Signature { get; set; } = "";
/// <summary>节点的临时 ECDH 公钥(SPKI,base64):有它就是安全链路,签名覆盖双方的临时公钥,认证结果起所有帧都是加密的。</summary>
[JsonPropertyName("node_eph")]
public string? NodeEphemeral { get; set; }
}
public sealed class AuthResult
@@ -34,8 +34,7 @@ public sealed record NewNode(
long NowMs,
string Kind = "",
byte[]? TlsPublicKey = null,
string? AddedBy = null,
MemberProofDto? Proof = null);
string? AddedBy = null);
public sealed record JoinTokenRecord(
string Id,
@@ -49,7 +48,6 @@ public sealed record JoinTokenRecord(
long? RevokedAtMs,
long CreatedAtMs,
string Kind = "",
string? CreatedBy = null,
SignedStatementDto? Grant = null);
string? CreatedBy = null);
public sealed record Presence(bool Online, long AtMs, string? NodeVersion, IReadOnlyList<string> Capabilities, string? HelloMetaJson);
@@ -6,6 +6,18 @@ namespace Fleet.Server;
public sealed record EnrollmentContext(string? RemoteIp, JsonElement? ProductMeta);
/// <summary>
/// 服务器的身份密钥:操作员用 passkey 把它的公钥签进操作员集合。有它的时候,认证挑战会带上服务器的签名,节点据此确认对面是真的服务器,
/// 并在这条链路上用应用层加密。没有注册就是旧的行为(没有服务器身份)。
/// </summary>
public interface IServerIdentity
{
byte[] PublicKeySpki { get; }
/// <summary>对一段内容签名(<c>Fleet.Identity.ServerHello.Payload</c> 给出的)。</summary>
byte[] Sign(ReadOnlySpan<byte> payload);
}
/// <summary>操作员集合的链(passkey 签的,创世在前)。没有初始化信任根时是空的。节点注册时用它确认「这个面板确实是我的操作员的」。</summary>
public interface IOperatorTrustSource
{
@@ -33,13 +33,6 @@ 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; }
}
@@ -61,17 +61,6 @@ 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");
@@ -94,8 +83,7 @@ public sealed partial class FleetEnrollmentService
nowMs,
token.Kind,
tlsPublicKey,
token.CreatedBy,
memberProof);
token.CreatedBy);
Result<NodeRecord> created = await _store.CreateNodeAsync(node, ct);
if (!created.TryGet(out NodeRecord? record, out FleetError? error))
@@ -116,7 +104,7 @@ public sealed partial class FleetEnrollmentService
});
}
/// <summary>节点注册之前取:操作员集合的链,和这个码的委托(如果有)。码要是有效的;不能用的码不给。</summary>
/// <summary>节点注册之前取:操作员集合的链。码要是有效的;不能用的码不给。</summary>
public async ValueTask<Result<TrustBundle>> GetTrustAsync(string joinToken, CancellationToken ct)
{
JoinTokenRecord? token = await _store.FindJoinTokenAsync(TokenHash.Sha256(joinToken), ct);
@@ -131,7 +119,7 @@ public sealed partial class FleetEnrollmentService
return Result<TrustBundle>.Fail(FleetErrors.JoinTokenExpired, "join token has expired");
}
var bundle = new TrustBundle { Grant = token.Grant };
var bundle = new TrustBundle();
foreach (IOperatorTrustSource source in _trust)
{
bundle.Chain.AddRange(await source.ChainAsync(ct));
@@ -140,26 +128,6 @@ public sealed partial class FleetEnrollmentService
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
@@ -23,8 +23,10 @@ public sealed class FleetLinkHost : IFleetConnections, IStateNotifier
IEnumerable<IStateProvider> stateProviders,
IEnumerable<IInbox> inboxes,
IEnumerable<INodeEventSink> sinks,
FleetRouter router)
FleetRouter router,
IServerIdentity? serverIdentity = null)
{
ServerIdentity = serverIdentity;
Store = store;
Options = options;
Time = time;
@@ -38,6 +40,9 @@ public sealed class FleetLinkHost : IFleetConnections, IStateNotifier
internal IFleetStore Store { get; }
/// <summary>服务器的身份密钥;没有时节点连接用旧的明文认证。</summary>
internal IServerIdentity? ServerIdentity { get; }
internal FleetServerOptions Options { get; }
internal TimeProvider Time { get; }
@@ -109,7 +109,8 @@ public static class FleetServerExtensions
provider.GetServices<IStateProvider>(),
provider.GetServices<IInbox>(),
provider.GetServices<INodeEventSink>(),
provider.GetRequiredService<FleetRouter>()));
provider.GetRequiredService<FleetRouter>(),
provider.GetService<IServerIdentity>()));
services.AddSingleton<IFleetConnections>(provider => provider.GetRequiredService<FleetLinkHost>());
services.AddSingleton<IStateNotifier>(provider => provider.GetRequiredService<FleetLinkHost>());
return builder;
@@ -14,9 +14,19 @@ internal sealed partial class NodeConnection
{
string nonce = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
long expiresAtMs = _host.Time.GetUtcNow().Add(_host.Options.ChallengeTtl).ToUnixTimeMilliseconds();
await SendAsync(
RpcMessage.Notification(FleetMethods.Challenge, new ChallengeParams { Nonce = nonce, ExpiresAtMs = expiresAtMs }, FleetRpcContext.Default.ChallengeParams),
ct);
// 有服务器身份密钥:挑战里带上这次连接的临时公钥和服务器的签名,节点据此确认服务器、协商加密链路
using var serverEphemeral = _host.ServerIdentity is null ? null : new EphemeralKey();
var challenge = new ChallengeParams { Nonce = nonce, ExpiresAtMs = expiresAtMs };
string? serverEphemeralText = null;
if (_host.ServerIdentity is { } identity && serverEphemeral is not null)
{
serverEphemeralText = Convert.ToBase64String(serverEphemeral.PublicKeySpki);
challenge.ServerEphemeral = serverEphemeralText;
challenge.ServerSignature = Convert.ToBase64String(identity.Sign(ServerHello.Payload(nonce, serverEphemeral.PublicKeySpki)));
}
await SendAsync(RpcMessage.Notification(FleetMethods.Challenge, challenge, FleetRpcContext.Default.ChallengeParams), ct);
using var timeout = new CancellationTokenSource(s_authTimeout, _host.Time);
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);
@@ -48,10 +58,21 @@ internal sealed partial class NodeConnection
}
NodeRecord? node = auth is null ? null : await _host.Store.GetNodeAsync(new NodeId(auth.NodeId), ct);
// 安全链路:节点的签名覆盖双方的临时公钥(AuthSecure),验过之后这条连接上的所有帧都是加密的
byte[] nodeEphemeral = [];
bool secure = auth?.NodeEphemeral is not null && serverEphemeral is not null && serverEphemeralText is not null && TryDecode(auth.NodeEphemeral, out nodeEphemeral);
bool signatureOk = node is not null
&& _host.Time.GetUtcNow().ToUnixTimeMilliseconds() <= expiresAtMs
&& TryDecode(auth!.Signature, out byte[] signature)
&& NodeIdentity.Verify(node.PublicKey, signature, SignaturePurpose.Auth, auth.NodeId, nonce);
&& (secure
? NodeIdentity.Verify(node.PublicKey, signature, SignaturePurpose.AuthSecure, auth.NodeId, nonce, serverEphemeralText!, auth.NodeEphemeral!)
: auth.NodeEphemeral is null && NodeIdentity.Verify(node.PublicKey, signature, SignaturePurpose.Auth, auth.NodeId, nonce));
if (signatureOk && secure)
{
_cipher = LinkCipher.ForServer(serverEphemeral!, nodeEphemeral, nonce);
}
if (!signatureOk)
{
// 节点不存在与签名错误返回同样的错误,不泄露节点是否存在。
@@ -75,7 +75,15 @@ internal sealed partial class NodeConnection
return false;
}
await _socket.SendAsync(bytes, WebSocketMessageType.Text, endOfMessage: true, ct);
if (_cipher is { } cipher)
{
await _socket.SendAsync(cipher.Seal(0, bytes), WebSocketMessageType.Binary, endOfMessage: true, ct);
}
else
{
await _socket.SendAsync(bytes, WebSocketMessageType.Text, endOfMessage: true, ct);
}
return true;
}
catch (WebSocketException)
@@ -30,6 +30,19 @@ internal sealed partial class NodeConnection
if (result.EndOfMessage)
{
if (_cipher is { } cipher)
{
// 加密链路上只接受通过认证的二进制帧;明文、被改过的、重放的、乱序的一律断开
if (result.MessageType != WebSocketMessageType.Binary || !cipher.TryOpen(writer.WrittenSpan, out byte kind, out byte[] plain))
{
_closeReason ??= "frame failed authentication";
await _socket.CloseOutputAsync(WebSocketCloseStatus.PolicyViolation, "frame failed authentication", CancellationToken.None);
return null;
}
return new Frame(kind == 0, plain);
}
return new Frame(result.MessageType == WebSocketMessageType.Text, writer.WrittenSpan.ToArray());
}
}
@@ -2,6 +2,7 @@ using System.Collections.Concurrent;
using System.Net.WebSockets;
using System.Threading.Channels;
using Fleet.Core;
using Fleet.Identity;
using Fleet.Rpc;
namespace Fleet.Server;
@@ -24,6 +25,7 @@ internal sealed partial class NodeConnection : IDisposable
private readonly Channel<bool> _stateSignal = Channel.CreateBounded<bool>(new BoundedChannelOptions(1) { FullMode = BoundedChannelFullMode.DropWrite });
private readonly Dictionary<string, long> _lastPushedVersion = new(StringComparer.Ordinal);
private readonly CancellationTokenSource _lifetime = new();
private LinkCipher? _cipher;
private long _nextRequestId;
private long _lastPresenceTicks;
private NodeRecord? _node;
@@ -134,6 +136,7 @@ internal sealed partial class NodeConnection : IDisposable
public void Dispose()
{
_cipher?.Dispose();
_lifetime.Dispose();
_sendLock.Dispose();
}
@@ -111,85 +111,13 @@ public sealed class OperatorTrustTests : IDisposable
Assert.False(OperatorTrust.VerifyOperator(_bob.Sign("hello"), set)); // 不在集合里
}
private sealed record Enrolled(OperatorSet Set, byte[] Seed, MemberProof Proof, byte[] NodeKey, byte[] PeerKey, string Auth);
private Enrolled Enroll(string role = "probe", long now = 1_000_000, long notBefore = 0, long notAfter = 2_000_000, TestAuthenticator? signer = null)
{
OperatorSet set = Genesis(_alice);
using ECDsa code = ECDsa.Create(ECCurve.NamedCurves.nistP256);
byte[] seed = code.ExportParameters(true).D!;
string auth = OperatorTrust.AuthToken(seed);
var grant = new Grant(role, OperatorTrust.AuthHash(auth), code.ExportSubjectPublicKeyInfo(), notBefore, notAfter, 1);
SignedStatement signedGrant = (signer ?? _alice).Sign(OperatorTrust.Encode(grant));
byte[] nodeKey = RandomNumberGenerator.GetBytes(91);
byte[] peerKey = RandomNumberGenerator.GetBytes(91);
using ECDsa? signing = OperatorTrust.CodeSigningKey(seed, grant.CodeKey);
Assert.NotNull(signing);
string signature = OperatorTrust.SignBinding(signing, new Binding("tokyo", role, nodeKey, peerKey, grant.AuthHash, now), out string statement);
return new Enrolled(set, seed, new MemberProof(signedGrant, statement, signature), nodeKey, peerKey, auth);
}
[Fact]
public void AMember_VerifiesThroughTheWholeChain()
{
Enrolled e = Enroll();
Assert.Equal(TrustFailure.None, OperatorTrust.VerifyMember(e.Set, e.Proof, e.NodeKey, e.PeerKey, "probe"));
}
[Fact]
public void TheSeed_IsNotRecoverableFromWhatTheBackendSees()
{
Enrolled e = Enroll();
Assert.StartsWith("ja_", e.Auth, StringComparison.Ordinal);
Assert.DoesNotContain(Base64Url.EncodeToString(e.Seed), e.Auth, StringComparison.Ordinal);
Assert.DoesNotContain(Base64Url.EncodeToString(e.Seed), e.Proof.Grant.Statement + e.Proof.BindingStatement, StringComparison.Ordinal);
Assert.Equal(e.Auth, OperatorTrust.AuthToken(e.Seed)); // 节点自己能算出同一个
}
[Fact]
public void ABindingForOtherKeys_AnotherRole_OrAnExpiredGrant_IsRefused()
{
Enrolled e = Enroll();
Assert.Equal(TrustFailure.KeyMismatch, OperatorTrust.VerifyMember(e.Set, e.Proof, RandomNumberGenerator.GetBytes(91), e.PeerKey, "probe")); // Backend 想把别的节点密钥挂在这个绑定上
Assert.Equal(TrustFailure.KeyMismatch, OperatorTrust.VerifyMember(e.Set, e.Proof, e.NodeKey, RandomNumberGenerator.GetBytes(91), "probe"));
Assert.Equal(TrustFailure.RoleMismatch, OperatorTrust.VerifyMember(e.Set, e.Proof, e.NodeKey, e.PeerKey, "agent")); // 一个 Probe 的绑定不能冒充 Agent
Enrolled late = Enroll(now: 3_000_000, notAfter: 2_000_000); // 有效期之后才签的绑定
Assert.Equal(TrustFailure.GrantExpired, OperatorTrust.VerifyMember(late.Set, late.Proof, late.NodeKey, late.PeerKey, "probe"));
}
[Fact]
public void AGrantNotSignedByTheOperator_OrABindingNotSignedByTheCodeKey_IsRefused()
{
using var stranger = new TestAuthenticator();
Enrolled forged = Enroll(signer: stranger);
Assert.Equal(TrustFailure.GrantNotSigned, OperatorTrust.VerifyMember(forged.Set, forged.Proof, forged.NodeKey, forged.PeerKey, "probe"));
// Backend 拿到了委托但没有种子:用自己的密钥签绑定
Enrolled e = Enroll();
using ECDsa backend = ECDsa.Create(ECCurve.NamedCurves.nistP256);
Binding stolen = OperatorTrust.ParseBinding(e.Proof.BindingStatement)!;
string bogus = OperatorTrust.SignBinding(backend, stolen, out string statement);
Assert.Equal(TrustFailure.BindingInvalid, OperatorTrust.VerifyMember(e.Set, new MemberProof(e.Proof.Grant, statement, bogus), e.NodeKey, e.PeerKey, "probe"));
}
[Fact]
public void RemovingAnOperatorKey_InvalidatesTheGrantsItSigned()
{
Enrolled e = Enroll();
OperatorSet withoutAlice = Genesis(_bob) with { Version = 2, Previous = new string('a', 64) };
Assert.Equal(TrustFailure.GrantNotSigned, OperatorTrust.VerifyMember(withoutAlice, e.Proof, e.NodeKey, e.PeerKey, "probe"));
}
[Fact]
public void Parsing_IsStrict()
{
Assert.Null(OperatorTrust.ParseGrant("moshiro-grant-v1\nrole=probe"));
Assert.Null(OperatorTrust.ParseBinding("moshiro-binding-v1\nname=x"));
Assert.Null(OperatorTrust.ParseSet("moshiro-opset-v2\nversion=1"));
OperatorSet set = Genesis(_alice);
Assert.Equal(set.Keys[0].CredentialId, OperatorTrust.ParseSet(OperatorTrust.Encode(set))!.Keys[0].CredentialId);
Assert.Null(OperatorTrust.ParseSet(OperatorTrust.Encode(set with { Previous = "abc" }))); // 创世的 prev 必须是 0
Assert.Null(OperatorTrust.ParseSet(OperatorTrust.Encode(set) + "\nnot-a-key"));
}
}
@@ -0,0 +1,114 @@
using System.Security.Cryptography;
using Fleet.Identity;
namespace Fleet.Tests;
public sealed class SecureChannelTests
{
private const string Nonce = "n0nce-from-the-server";
private static (LinkCipher Node, LinkCipher Server) Pair(string nodeNonce = Nonce, string serverNonce = Nonce)
{
using var node = new EphemeralKey();
using var server = new EphemeralKey();
return (LinkCipher.ForNode(node, server.PublicKeySpki, nodeNonce), LinkCipher.ForServer(server, node.PublicKeySpki, serverNonce));
}
[Fact]
public void TheServerProof_VerifiesOnlyForTheRightKey_Nonce_AndEphemeralKey()
{
using ECDsa serverKey = ECDsa.Create(ECCurve.NamedCurves.nistP256);
using ECDsa other = ECDsa.Create(ECCurve.NamedCurves.nistP256);
using var eph = new EphemeralKey();
using var otherEph = new EphemeralKey();
byte[] spki = serverKey.ExportSubjectPublicKeyInfo();
byte[] proof = ServerHello.Sign(serverKey, Nonce, eph.PublicKeySpki);
Assert.True(ServerHello.Verify(spki, Nonce, eph.PublicKeySpki, proof));
Assert.False(ServerHello.Verify(other.ExportSubjectPublicKeyInfo(), Nonce, eph.PublicKeySpki, proof)); // 别人的服务器密钥
Assert.False(ServerHello.Verify(spki, "another-nonce", eph.PublicKeySpki, proof)); // 挪到别的挑战上
Assert.False(ServerHello.Verify(spki, Nonce, otherEph.PublicKeySpki, proof)); // 中间人换了自己的临时公钥
Assert.False(ServerHello.Verify(spki, Nonce, eph.PublicKeySpki, proof.AsSpan(0, proof.Length - 1)));
Assert.False(ServerHello.Verify([1, 2, 3], Nonce, eph.PublicKeySpki, proof)); // 公钥不合法也不抛异常
}
[Fact]
public void BothDirections_RoundTrip_AndKeepTheFrameKind()
{
(LinkCipher node, LinkCipher server) = Pair();
using LinkCipher n = node;
using LinkCipher s = server;
byte[] toServer = n.Seal(0, "hello server"u8);
Assert.True(s.TryOpen(toServer, out byte kind, out byte[] data));
Assert.Equal(0, kind);
Assert.Equal("hello server"u8.ToArray(), data);
byte[] big = RandomNumberGenerator.GetBytes(300_000);
byte[] toNode = s.Seal(1, big);
Assert.True(n.TryOpen(toNode, out kind, out data));
Assert.Equal(1, kind);
Assert.Equal(big, data);
Assert.Equal(big.Length + LinkCipher.Overhead + 1, toNode.Length);
}
[Fact]
public void AReplayedReorderedTamperedOrReflectedFrame_IsRefused()
{
(LinkCipher node, LinkCipher server) = Pair();
using LinkCipher n = node;
using LinkCipher s = server;
byte[] first = n.Seal(0, "one"u8);
byte[] second = n.Seal(0, "two"u8);
Assert.False(s.TryOpen(second, out _, out _)); // 乱序(丢了第一帧)
Assert.True(s.TryOpen(first, out _, out _));
Assert.False(s.TryOpen(first, out _, out _)); // 重放
Assert.True(s.TryOpen(second, out _, out _));
byte[] tampered = n.Seal(0, "three"u8);
tampered[12] ^= 0x01;
Assert.False(s.TryOpen(tampered, out _, out _)); // 改了一位
byte[] fromNode = n.Seal(0, "four"u8);
(LinkCipher otherNode, LinkCipher otherServer) = Pair();
using LinkCipher on = otherNode;
using LinkCipher os = otherServer;
Assert.False(os.TryOpen(fromNode, out _, out _)); // 另一条连接的密钥打不开
byte[] fromServer = s.Seal(0, "reflected"u8);
Assert.False(s.TryOpen(fromServer, out _, out _)); // 方向不同:服务器发的东西不能原样塞回给服务器
Assert.False(n.TryOpen([1, 2, 3], out _, out _));
}
[Fact]
public void ADifferentNonce_GivesDifferentKeys()
{
(LinkCipher node, LinkCipher server) = Pair("nonce-a", "nonce-b"); // 两边对不上:没人能解开对方的东西
using LinkCipher n = node;
using LinkCipher s = server;
Assert.False(s.TryOpen(n.Seal(0, "x"u8), out _, out _));
}
[Fact]
public void AnOperatorSet_CarriesTheServerKeys_ThroughTheChain()
{
using var alice = new TestAuthenticator();
using ECDsa serverKey = ECDsa.Create(ECCurve.NamedCurves.nistP256);
byte[] spki = serverKey.ExportSubjectPublicKeyInfo();
var set = new OperatorSet(1, OperatorTrust.Genesis, TestAuthenticator.RpId, [TestAuthenticator.Origin], [alice.Key], [spki]);
string text = OperatorTrust.Encode(set);
OperatorSet parsed = OperatorTrust.ParseSet(text)!;
Assert.Equal(spki, Assert.Single(parsed.Servers!));
Assert.Equal(text, OperatorTrust.Encode(parsed)); // 编码和解析互逆,指纹稳定
SignedStatement genesis = alice.Sign(text);
OperatorSet verified = OperatorTrust.VerifyChain(OperatorTrust.Fingerprint(text), [genesis])!;
Assert.Equal(spki, Assert.Single(verified.Servers!));
// 服务器密钥只能写在凭证后面;顺序不对、内容不是 base64 都算坏
Assert.Null(OperatorTrust.ParseSet(text + "\nkey=late:AAAA"));
Assert.Null(OperatorTrust.ParseSet(text + "\nserver=***"));
}
}
+3 -4
View File
@@ -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>] [--role agent|probe --seed <code seed> --genesis <operator fingerprint>]");
Console.Error.WriteLine("usage: fleet-enroll --server <url> --join <token> --name <node name> --identity-dir <dir> [--meta <json>] [--version <v>] [--genesis <trust root fingerprint> --controllers <passkey credential ids, comma separated>]");
return 2;
}
@@ -30,13 +30,12 @@ if (Arg("--meta") is { } metaText)
}
}
string? seed = Arg("--seed");
string? genesis = Arg("--genesis");
string role = Arg("--role") ?? "agent";
string[] controllers = (Arg("--controllers") ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
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, seed, genesis, role);
http, new Uri(server), join, name, directory, Arg("--version") ?? "0.0.0", meta, TimeProvider.System, CancellationToken.None, genesis, controllers);
if (!result.TryGet(out EnrollmentResult? enrolled, out FleetError? error))
{
Console.Error.WriteLine($"enrollment failed ({error.Code}): {error.Message}");