diff --git a/.gitattributes b/.gitattributes
index a074163..52bd65d 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -6,3 +6,4 @@ packaging/update/*.sh text eol=lf
packaging/container/*.sh text eol=lf
ci/release-node-images.sh text eol=lf
packaging/install/*.sh text eol=lf
+packaging/install/*.ps1 text eol=lf
diff --git a/README.md b/README.md
index 019004a..9c5c48c 100644
--- a/README.md
+++ b/README.md
@@ -20,7 +20,7 @@ MoShiro 是一个自托管的**网络线路质量观测工具**。它在若干 P
## 信任关系
```text
- 管理员 ── 账号密码(Cookie 会话 + CSRF)或 API 令牌(read · admin · release)──▶ Backend
+ 管理员 ── 账号密码(Cookie 会话 + CSRF)或 API 令牌(按能力授权)──▶ Backend
访客 ── 匿名只读,站点设为公开时才可用(没有 IP、城市、ASN)──────────────▶ Backend 公开接口
节点 ── 加入令牌一次性注册,之后用自己的 ECDSA P-256 密钥签名认证 ───────▶ Backend(只存公钥)
@@ -97,7 +97,8 @@ Backend、Agent、Probe 以 OCI 镜像发布(Linux amd64、arm64),Agent
| 文档 | 内容 |
| --- | --- |
| [`docs/architecture.md`](docs/architecture.md) | 架构、数据链路、安全边界、设计决策 |
-| [`docs/security.md`](docs/security.md) | 零信任设计稿(尚未实现):威胁模型、名册和操作员凭证、监听隐身、extension 授权 |
+| [`docs/security.md`](docs/security.md) | 零信任设计稿(部分已实现):威胁模型、入网信任链、监听隐身、extension 授权 |
+| [`docs/access.md`](docs/access.md) | 访问控制与入网信任链设计(尚未实现):账户、角色、能力、API key、提权窗口、passkey 到节点的签名链 |
| [`docs/protocol.md`](docs/protocol.md)、[`backend/fleet/README.md`](backend/fleet/README.md) | 线协议、Fleet 节点框架(身份、注册、连接) |
| [`docs/enrollment.md`](docs/enrollment.md) | 注册、身份、Probe 与 Agent 的接入 |
| [`docs/measurement.md`](docs/measurement.md)、[`docs/analysis.md`](docs/analysis.md) | 采集引擎与任务模型、分析层与指标 |
diff --git a/backend/Directory.Build.props b/backend/Directory.Build.props
index 65e3c69..c120fbd 100644
--- a/backend/Directory.Build.props
+++ b/backend/Directory.Build.props
@@ -13,7 +13,7 @@
true
$(MSBuildThisFileDirectory)artifacts
true
- 0.1.1
+ 0.2.0
false
diff --git a/backend/agent/src/MoShiro.Agent/AgentApp.cs b/backend/agent/src/MoShiro.Agent/AgentApp.cs
index 8aba5c9..2c23bd6 100644
--- a/backend/agent/src/MoShiro.Agent/AgentApp.cs
+++ b/backend/agent/src/MoShiro.Agent/AgentApp.cs
@@ -18,6 +18,7 @@ public sealed class AgentApp : IDisposable
private readonly AgentConfig _config;
private readonly bool _ipv6Supported;
private readonly KnockGate _knock;
+ private readonly TrustAnchor _trust;
private readonly RestartController _restart;
private readonly RestartController? _ownedRestart;
private readonly LoadedNodeIdentity _identity;
@@ -63,6 +64,7 @@ public sealed class AgentApp : IDisposable
_admission = new AgentAdmission(_streams);
_udp = new UdpMux(_sessions, _admission, _clock, _counters.Data, log, bind, config.Listen.UdpPorts);
// 预握手敲门:控制端口和数据端口共用一个关卡(限速和去重一起算);名册就是期望状态里的 peers
+ _trust = TrustAnchor.Load(config.Fleet.IdentityDir, log);
_knock = new KnockGate(identity.PeerIdentity, RosterKeys, time);
_tcp = new TcpAcceptor(bind, config.Listen.TcpPorts, identity.Certificate, _sessions, _counters.Data, _knock);
@@ -81,7 +83,7 @@ public sealed class AgentApp : IDisposable
};
var chunks = new ChunkAssembler(_streams, _clock, _counters, _outbox, identity.NodeId, time, log);
var targets = new TargetRunners(chunks, identity.NodeId, _clock, time, log);
- _tasks = new TaskManager(CreateGuard, _streams, env, targets, () => _udp.BoundPorts, () => _tcp.BoundPorts);
+ _tasks = new TaskManager(CreateGuard, _streams, env, targets, () => _udp.BoundPorts, () => _tcp.BoundPorts, _trust);
_hub = new PeerHub(_sessions, _clock, chunks, _tasks, _uplink, time, log);
_tests = new TestRunner(_tasks, _sessions, _streams, env, _uplink, config.Limits.MaxConcurrentTests, transport => transport == "udp" ? _udp.BoundPorts : _tcp.BoundPorts, time, log);
@@ -123,6 +125,9 @@ public sealed class AgentApp : IDisposable
public int ControlPort => _peers.Port;
+ /// 节点当前采用的操作员集合版本(没有固定信任根时是 0)。
+ public int TrustVersion => _trust.Version;
+
public AgentCounters Counters => _counters;
public FleetLink Link => _link;
@@ -270,6 +275,7 @@ public sealed class AgentApp : IDisposable
{
Server = new Uri(_config.Fleet.Server),
Identity = _identity.Identity,
+ ServerVerifier = _trust,
NodeId = _identity.NodeId,
NodeVersion = Version,
Capabilities = [MoShiroCapabilities.Base, MoShiroCapabilities.KindsPrefix + string.Join(',', BlockCatalog.AgentKinds)],
diff --git a/backend/agent/src/MoShiro.Agent/Tasks/TaskManager.cs b/backend/agent/src/MoShiro.Agent/Tasks/TaskManager.cs
index e61f699..d63792c 100644
--- a/backend/agent/src/MoShiro.Agent/Tasks/TaskManager.cs
+++ b/backend/agent/src/MoShiro.Agent/Tasks/TaskManager.cs
@@ -1,4 +1,5 @@
using System.Text.Json;
+using Fleet.Hosting;
using Fleet.Core;
using Fleet.Rpc;
using MoShiro.Measurement;
@@ -25,14 +26,16 @@ public sealed class TaskManager
private readonly TargetRunners _targets;
private readonly Func> _udpPorts;
private readonly Func> _tcpPorts;
+ private readonly TrustAnchor _trust;
private readonly Dictionary> _sessionLines = [];
private volatile Dictionary _peers = new(StringComparer.Ordinal);
private Dictionary _lineSpecs = [];
private long _version;
private BudgetGuard? _guard;
- public TaskManager(Func guardFactory, StreamRegistry streams, BlockEnv env, TargetRunners targets, Func> udpPorts, Func> tcpPorts)
+ public TaskManager(Func guardFactory, StreamRegistry streams, BlockEnv env, TargetRunners targets, Func> udpPorts, Func> tcpPorts, TrustAnchor? trust = null)
{
+ _trust = trust ?? TrustAnchor.None;
_guardFactory = guardFactory;
_streams = streams;
_env = env;
@@ -98,6 +101,8 @@ public sealed class TaskManager
_ = ct;
var result = new StateSyncResult { AppliedVersion = version };
+ // 固定了操作员创世的节点:面板带来的新版本操作员集合,从固定的创世验过才采用(名册本身由面板决定)
+ _trust.Update(set.TrustChain);
var peers = new Dictionary(StringComparer.Ordinal);
foreach (PeerSpec peer in set.Peers)
{
diff --git a/backend/fleet/src/Fleet.Core/TrustDtos.cs b/backend/fleet/src/Fleet.Core/TrustDtos.cs
new file mode 100644
index 0000000..22d16e3
--- /dev/null
+++ b/backend/fleet/src/Fleet.Core/TrustDtos.cs
@@ -0,0 +1,29 @@
+using System.Text.Json.Serialization;
+
+namespace Fleet.Core;
+
+/// passkey 对一段语句签出的断言(字段都是 base64url)。信任根的格式和验证在 Fleet.Identity 的 OperatorTrust。
+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; } = "";
+}
+
+/// 操作员集合的链(创世在前):节点注册时取来,从固定的创世指纹验过才采用。
+public sealed class TrustBundle
+{
+ [JsonPropertyName("chain")]
+ public List Chain { get; set; } = [];
+}
diff --git a/backend/fleet/src/Fleet.Enrollment.Client/EnrollmentClient.cs b/backend/fleet/src/Fleet.Enrollment.Client/EnrollmentClient.cs
index 726d27c..35cb796 100644
--- a/backend/fleet/src/Fleet.Enrollment.Client/EnrollmentClient.cs
+++ b/backend/fleet/src/Fleet.Enrollment.Client/EnrollmentClient.cs
@@ -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);
///
/// 用加入令牌注册(供 fleet-enroll 使用,也可以嵌入产品自己的安装工具):
@@ -18,7 +22,8 @@ public sealed record EnrollmentResult(string NodeId, string Status, string Ident
public static class EnrollmentClient
{
public static async Task> 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? genesis = null, IReadOnlyList? controllers = null)
{
string nodeFile = Path.Combine(identityDirectory, LoadedNodeIdentity.NodeFileName);
if (File.Exists(nodeFile))
@@ -36,6 +41,18 @@ public static class EnrollmentClient
tlsPublicKey = certificate.PublicKey.ExportSubjectPublicKeyInfo();
}
+ // 安装命令里带了创世指纹(--genesis)和属主(--controllers):先取操作员集合链,从固定的创世验起,
+ // 属主必须是集合里的凭证;对不上就不注册,也不把自己的密钥交出去。通过之后固定到本地 trust.json,Backend 之后改不了。
+ TrustBundle? bundle = null;
+ if (genesis is not null)
+ {
+ Result trusted = await FetchTrustAsync(http, server, joinToken, genesis, controllers ?? [], ct);
+ if (!trusted.TryGet(out bundle, out FleetError? trustError))
+ {
+ return Result.Fail(trustError);
+ }
+ }
+
string pubkey = Convert.ToBase64String(identity.PublicKeySpki);
string tokenHash = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(joinToken)));
var body = new JsonObject
@@ -76,10 +93,46 @@ 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)
+ {
+ TrustAnchor.Pin(identityDirectory, genesis!, bundle.Chain, controllers ?? []);
+ }
+
// 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.Ok(new EnrollmentResult(nodeId, result["status"]!.GetValue(), identityDirectory));
+ return Result.Ok(new EnrollmentResult(nodeId, result["status"]!.GetValue(), identityDirectory, bundle is not null));
+ }
+
+ private static async Task> FetchTrustAsync(HttpClient http, Uri server, string joinToken, string genesis, IReadOnlyList controllers, CancellationToken ct)
+ {
+ static Result Refuse(string reason) => Result.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.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 is null || bundle.Chain.Count == 0)
+ {
+ return Refuse("the panel has no operator trust root set up");
+ }
+
+ 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)");
+ }
+
+ if (controllers.Any(c => current.Keys.All(k => k.CredentialId != c)))
+ {
+ return Refuse("an owner passkey in the install command is not in the operator set");
+ }
+
+ return Result.Ok(bundle);
}
}
diff --git a/backend/fleet/src/Fleet.Hosting/TrustAnchor.cs b/backend/fleet/src/Fleet.Hosting/TrustAnchor.cs
new file mode 100644
index 0000000..b4cf216
--- /dev/null
+++ b/backend/fleet/src/Fleet.Hosting/TrustAnchor.cs
@@ -0,0 +1,170 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Fleet.Core;
+using Fleet.Identity;
+
+namespace Fleet.Hosting;
+
+/// trust.json:安装时固定的创世指纹和属主,加见过的最新操作员集合链。
+public sealed class TrustFile
+{
+ [JsonPropertyName("genesis")]
+ public string Genesis { get; set; } = "";
+
+ [JsonPropertyName("chain")]
+ public List Chain { get; set; } = [];
+
+ /// 属主:允许授权本节点 extension 会话(web ssh 等)的 passkey 凭证 id,安装时固定,必须是操作员集合里的凭证。
+ [JsonPropertyName("controllers")]
+ public List Controllers { get; set; } = [];
+}
+
+[JsonSerializable(typeof(TrustFile))]
+public sealed partial class TrustFileContext : JsonSerializerContext;
+
+///
+/// 节点的信任根:安装命令里带来的创世指纹和属主,在注册时固定到本地,之后 Backend 改不了。
+/// 操作员集合的新版本随期望状态下发,节点只接受从固定的创世出发、签名完整、版本更新的链。
+/// 属主只是被固定和保存:节点现在没有 extension,等做 extension 时由节点本地强制(见 docs/access.md 第 8 节)。
+///
+public sealed class TrustAnchor : IServerVerifier
+{
+ 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;
+ }
+
+ /// 没有固定的创世(旧节点,或没有指纹的安装)。
+ public static TrustAnchor None { get; } = new(null, null, null, NullNodeLog.Instance);
+
+ public bool Pinned => _file is not null;
+
+ public string? Genesis => _file?.Genesis;
+
+ /// 当前生效的操作员集合版本;没有固定时为 0。
+ public int Version
+ {
+ get
+ {
+ lock (_gate)
+ {
+ return _current?.Version ?? 0;
+ }
+ }
+ }
+
+ /// 当前操作员集合里登记了服务器密钥:服务器必须在认证挑战里证明自己,链路用应用层加密。
+ public bool Required
+ {
+ get
+ {
+ lock (_gate)
+ {
+ return _current?.Servers is { Count: > 0 };
+ }
+ }
+ }
+
+ /// 服务器的签名是不是当前操作员集合里登记的某把服务器密钥签的。
+ public bool Verify(string nonce, ReadOnlySpan serverEphemeralSpki, ReadOnlySpan signature)
+ {
+ IReadOnlyList servers;
+ lock (_gate)
+ {
+ servers = _current?.Servers ?? [];
+ }
+
+ foreach (byte[] server in servers)
+ {
+ if (ServerHello.Verify(server, nonce, serverEphemeralSpki, signature))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /// 安装时固定的属主里,仍然在当前操作员集合里的那些(从集合里去掉的凭证不再有效)。
+ public IReadOnlyList 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)
+ {
+ 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);
+ }
+
+ /// 注册时写下:固定的创世、验证过的链、属主。
+ public static void Pin(string identityDirectory, string genesis, IReadOnlyList chain, IReadOnlyList controllers) =>
+ new ConfigStore(identityDirectory).Write(FileName, JsonSerializer.SerializeToUtf8Bytes(new TrustFile { Genesis = genesis, Chain = [.. chain], Controllers = [.. controllers] }, TrustFileContext.Default.TrustFile));
+
+ /// 期望状态里带来的链:从固定的创世验起,比现有的新才采用并保存。返回当前链的版本(没有固定时为 0)。
+ public int Update(IReadOnlyList? 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;
+ }
+ }
+}
diff --git a/backend/fleet/src/Fleet.Identity/NodeIdentity.cs b/backend/fleet/src/Fleet.Identity/NodeIdentity.cs
index 04d8095..3c23b0d 100644
--- a/backend/fleet/src/Fleet.Identity/NodeIdentity.cs
+++ b/backend/fleet/src/Fleet.Identity/NodeIdentity.cs
@@ -7,6 +7,9 @@ namespace Fleet.Identity;
public static class SignaturePurpose
{
public const string Auth = "auth";
+
+ /// 安全链路的认证:签名同时覆盖服务器和节点的临时公钥,把这次认证和这条加密链路绑在一起。
+ public const string AuthSecure = "auth2";
public const string Peer = "peer";
public const string Enroll = "enroll";
}
diff --git a/backend/fleet/src/Fleet.Identity/OperatorTrust.cs b/backend/fleet/src/Fleet.Identity/OperatorTrust.cs
new file mode 100644
index 0000000..fa25752
--- /dev/null
+++ b/backend/fleet/src/Fleet.Identity/OperatorTrust.cs
@@ -0,0 +1,171 @@
+using System.Buffers.Text;
+using System.Globalization;
+using System.Security.Cryptography;
+using System.Text;
+
+namespace Fleet.Identity;
+
+/// passkey(WebAuthn)对一段文本签出的断言:challenge = SHA-256(Statement 的 UTF-8)。四个字段都是 base64url。
+public sealed record SignedStatement(string Statement, string CredentialId, string AuthenticatorData, string ClientDataJson, string Signature);
+
+/// 一个操作员凭证:passkey 的凭证 id 和公钥(SPKI)。
+public sealed record OperatorKey(string CredentialId, byte[] PublicKeySpki);
+
+///
+/// 操作员集合的一个版本。RpId 和 Origins 也在里面:节点验 WebAuthn 断言要用,而它们被操作员的签名覆盖,Backend 改不了。
+/// 创世版本(1)由它自己的某把凭证签名,指纹()在安装时固定到节点;之后每个版本由上一版里的凭证签名。
+///
+/// 服务器密钥(SPKI):操作员授权的 Backend 签名密钥。集合里登记了服务器密钥之后,节点要求服务器在认证挑战里用它签名,
+/// 并在这条链路上用应用层加密,所以 DNS 或证书层面的冒充拿不出这把密钥就骗不了节点。空 = 还没有启用。
+public sealed record OperatorSet(int Version, string Previous, string RpId, IReadOnlyList Origins, IReadOnlyList Keys, IReadOnlyList? Servers = null);
+
+///
+/// 信任根:操作员(passkey)→ 操作员集合 → 节点本地固定的属主。设计见 docs/access.md 第 7 节。
+/// 安装命令里带着创世指纹和属主(passkey 的凭证 id):节点把它们固定在本地,之后只认从创世出发、由操作员 passkey 签过名的集合版本,
+/// 属主必须是集合里的凭证。Backend 能转发集合,但签不出新的版本,也改不了节点已经固定的东西。
+/// 全是纯函数,只用 BCL,节点和 Backend 共用。语句是 key=value 的文本,第一行是带版本的标题;签名覆盖整段文本。
+///
+public static class OperatorTrust
+{
+ public const string SetHeader = "moshiro-opset-v1";
+ public const string Genesis = "0";
+
+ public static string Encode(OperatorSet set)
+ {
+ var text = new StringBuilder();
+ text.Append(SetHeader).Append('\n')
+ .Append("version=").Append(set.Version.ToString(CultureInfo.InvariantCulture)).Append('\n')
+ .Append("prev=").Append(set.Previous).Append('\n')
+ .Append("rp=").Append(set.RpId).Append('\n')
+ .Append("origins=").Append(string.Join(',', set.Origins));
+ foreach (OperatorKey key in set.Keys)
+ {
+ 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 OperatorSet? ParseSet(string text)
+ {
+ string[] lines = text.Split('\n');
+ if (lines.Length < 6 || lines[0] != SetHeader || !TryValue(lines[1], "version", out string version) || !int.TryParse(version, NumberStyles.None, CultureInfo.InvariantCulture, out int number) || number < 1
+ || !TryValue(lines[2], "prev", out string previous) || !TryValue(lines[3], "rp", out string rp) || rp.Length == 0 || !TryValue(lines[4], "origins", out string origins) || origins.Length == 0)
+ {
+ return null;
+ }
+
+ var keys = new List();
+ var servers = new List();
+ foreach (string line in lines.Skip(5))
+ {
+ 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;
+ }
+ }
+
+ return keys.Count == 0 || (number == 1) != (previous == Genesis) ? null : new OperatorSet(number, previous, rp, origins.Split(','), keys, servers);
+ }
+
+ /// 一段语句的指纹(SHA-256,小写十六进制)。创世版本的指纹就是安装命令里固定的那个。
+ public static string Fingerprint(string statement) => Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(statement)));
+
+ ///
+ /// 验证操作员集合的链:第一项是创世版本(指纹等于 ,自签),其后每一项由上一版里的凭证签名、版本加一、prev 指向上一版的指纹、域名不变。
+ /// 返回最后一版;不对返回 null。
+ ///
+ public static OperatorSet? VerifyChain(string pinnedGenesis, IReadOnlyList chain)
+ {
+ if (chain.Count == 0 || !string.Equals(Fingerprint(chain[0].Statement), pinnedGenesis, StringComparison.OrdinalIgnoreCase))
+ {
+ return null;
+ }
+
+ OperatorSet? current = null;
+ string? previousFingerprint = null;
+ foreach (SignedStatement link in chain)
+ {
+ OperatorSet? next = ParseSet(link.Statement);
+ if (next is null)
+ {
+ return null;
+ }
+
+ if (current is null)
+ {
+ if (next.Version != 1 || !VerifyOperator(link, next))
+ {
+ return null;
+ }
+ }
+ else if (next.Version != current.Version + 1 || next.Previous != previousFingerprint || next.RpId != current.RpId || !VerifyOperator(link, current))
+ {
+ return null;
+ }
+
+ current = next;
+ previousFingerprint = Fingerprint(link.Statement);
+ }
+
+ return current;
+ }
+
+ /// 断言是不是这个集合里某把凭证对 Statement 签的(要求用户验证;挑战是语句的 SHA-256)。
+ public static bool VerifyOperator(SignedStatement signed, OperatorSet set)
+ {
+ OperatorKey? key = set.Keys.FirstOrDefault(k => k.CredentialId == signed.CredentialId);
+ if (key is null || !TryDecode(signed.AuthenticatorData, out byte[] authData) || !TryDecode(signed.ClientDataJson, out byte[] clientData) || !TryDecode(signed.Signature, out byte[] signature))
+ {
+ return false;
+ }
+
+ return WebAuthnAssertion.Verify(key.PublicKeySpki, authData, clientData, signature, set.RpId, SHA256.HashData(Encoding.UTF8.GetBytes(signed.Statement)), set.Origins.Contains, requireUserVerification: true, out _);
+ }
+
+ private static bool TryValue(string line, string key, out string value)
+ {
+ value = "";
+ if (!line.StartsWith(key + "=", StringComparison.Ordinal))
+ {
+ return false;
+ }
+
+ value = line[(key.Length + 1)..];
+ return true;
+ }
+
+ private static bool TryBase64(string text, out byte[] data)
+ {
+ try
+ {
+ data = Convert.FromBase64String(text);
+ return data.Length > 0;
+ }
+ catch (FormatException)
+ {
+ data = [];
+ return false;
+ }
+ }
+
+ private static bool TryDecode(string text, out byte[] data)
+ {
+ data = [];
+ return Base64Url.IsValid(text) && (data = Base64Url.DecodeFromChars(text)).Length > 0;
+ }
+}
diff --git a/backend/fleet/src/Fleet.Identity/SecureChannel.cs b/backend/fleet/src/Fleet.Identity/SecureChannel.cs
new file mode 100644
index 0000000..61e063b
--- /dev/null
+++ b/backend/fleet/src/Fleet.Identity/SecureChannel.cs
@@ -0,0 +1,173 @@
+using System.Buffers.Binary;
+using System.Security.Cryptography;
+using System.Text;
+
+namespace Fleet.Identity;
+
+///
+/// 节点这一侧用来确认「对面确实是我的操作员授权过的 Backend」的校验器:Required 为真(当前操作员集合里登记了服务器密钥)
+/// 时,服务器的挑战必须带着对得上的签名,否则节点不继续认证、也不交出任何东西。
+///
+public interface IServerVerifier
+{
+ bool Required { get; }
+
+ /// 服务器用它的服务器密钥对「挑战随机数加它这次连接的临时公钥」的签名。
+ bool Verify(string nonce, ReadOnlySpan serverEphemeralSpki, ReadOnlySpan signature);
+}
+
+/// 服务器在挑战里给出的身份证明:服务器密钥(操作员签进集合)对「随机数加临时公钥」签名。纯函数。
+public static class ServerHello
+{
+ /// 被签名的内容;服务器那一侧只拿到这个、用自己的密钥签(服务器密钥不一定是 BCL 的 ECDsa,可以在别处)。
+ public static byte[] Payload(string nonce, ReadOnlySpan serverEphemeralSpki) =>
+ Encoding.UTF8.GetBytes("fleet|v1|server|" + nonce + "|" + Convert.ToBase64String(serverEphemeralSpki));
+
+ public static byte[] Sign(ECDsa serverKey, string nonce, ReadOnlySpan serverEphemeralSpki) =>
+ serverKey.SignData(Payload(nonce, serverEphemeralSpki), HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
+
+ public static bool Verify(ReadOnlySpan serverKeySpki, string nonce, ReadOnlySpan serverEphemeralSpki, ReadOnlySpan 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;
+ }
+ }
+}
+
+///
+/// 节点和 Backend 之间控制链路的应用层加密:两边的临时 ECDH 密钥协商出会话密钥,每个方向一把,AES-256-GCM,每帧带计数器。
+/// 所以即使 TLS 在中间被终止(Cloudflare、被冒充的证书),中间人没有服务器密钥,就拿不到明文,也改不了任何一帧。
+/// 帧格式:计数器(8,大端) ‖ 密文 ‖ 标签(16);明文是 帧类型(1) ‖ 内容;计数器必须严格递增,重放、乱序、丢帧、篡改都会解密失败。
+///
+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 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 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 remote, ReadOnlySpan nodeEphemeral, ReadOnlySpan 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);
+ }
+ }
+
+ /// 加密一帧。
+ public byte[] Seal(byte kind, ReadOnlySpan 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;
+ }
+
+ /// 解密一帧;计数器不是期望的下一个、或者标签不对,都返回 false。
+ public bool TryOpen(ReadOnlySpan 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();
+ }
+}
diff --git a/backend/fleet/src/Fleet.Identity/SessionKeys.cs b/backend/fleet/src/Fleet.Identity/SessionKeys.cs
index 2f4eba1..f326b19 100644
--- a/backend/fleet/src/Fleet.Identity/SessionKeys.cs
+++ b/backend/fleet/src/Fleet.Identity/SessionKeys.cs
@@ -16,6 +16,14 @@ public sealed class EphemeralKey : IDisposable
public byte[] PublicKeySpki => _key.ExportSubjectPublicKeyInfo();
+ /// 与对端的临时公钥做 ECDH,返回原始共享秘密;必须再经 KDF 才能当密钥用。
+ public byte[] AgreeRaw(ReadOnlySpan remotePublicKeySpki)
+ {
+ using ECDiffieHellman remote = ECDiffieHellman.Create();
+ remote.ImportSubjectPublicKeyInfo(remotePublicKeySpki, out _);
+ return _key.DeriveRawSecretAgreement(remote.PublicKey);
+ }
+
///
/// 与对端的临时公钥协商,再经 HKDF-SHA256 派生会话凭据。
/// 盐是服务端的随机数,info 绑定双方的临时公钥,防止凭据被挪用到别的握手上。
diff --git a/backend/fleet/src/Fleet.Identity/TrustMapping.cs b/backend/fleet/src/Fleet.Identity/TrustMapping.cs
new file mode 100644
index 0000000..2672fa6
--- /dev/null
+++ b/backend/fleet/src/Fleet.Identity/TrustMapping.cs
@@ -0,0 +1,11 @@
+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 };
+}
diff --git a/backend/fleet/src/Fleet.Identity/WebAuthnAssertion.cs b/backend/fleet/src/Fleet.Identity/WebAuthnAssertion.cs
new file mode 100644
index 0000000..60a0d0d
--- /dev/null
+++ b/backend/fleet/src/Fleet.Identity/WebAuthnAssertion.cs
@@ -0,0 +1,85 @@
+using System.Buffers.Text;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+
+namespace Fleet.Identity;
+
+///
+/// 验证一次 WebAuthn 断言(navigator.credentials.get 的结果)。只支持 ES256(P-256 的 ECDSA),纯函数,只用 BCL:
+/// 面板用它验证 passkey 登录,节点以后用它验证 passkey 签出的委托(docs/access.md 第 7 节)。
+/// 不解析 CBOR——注册时的 attestation 解析只在 Backend 里。
+///
+public static class WebAuthnAssertion
+{
+ public const byte FlagUserPresent = 0x01;
+ public const byte FlagUserVerified = 0x04;
+
+ /// 注册时保存的凭证公钥(SPKI)。
+ /// 这次请求里服务端或签名人规定的挑战原文(不是 base64url)。
+ /// 校验 clientDataJSON.origin。
+ /// authenticatorData 里的签名计数器。
+ public static bool Verify(
+ ReadOnlySpan publicKeySpki, ReadOnlySpan authenticatorData, ReadOnlySpan clientDataJson, ReadOnlySpan signature,
+ string rpId, ReadOnlySpan expectedChallenge, Func originAllowed, bool requireUserVerification, out uint signCount)
+ {
+ signCount = 0;
+ if (authenticatorData.Length < 37 || !CryptographicOperations.FixedTimeEquals(authenticatorData[..32], SHA256.HashData(Encoding.UTF8.GetBytes(rpId))))
+ {
+ return false;
+ }
+
+ byte flags = authenticatorData[32];
+ if ((flags & FlagUserPresent) == 0 || (requireUserVerification && (flags & FlagUserVerified) == 0))
+ {
+ return false;
+ }
+
+ if (!ClientDataValid(clientDataJson, "webauthn.get", expectedChallenge, originAllowed))
+ {
+ return false;
+ }
+
+ try
+ {
+ using ECDsa key = ECDsa.Create();
+ key.ImportSubjectPublicKeyInfo(publicKeySpki, out _);
+ byte[] signed = [.. authenticatorData, .. SHA256.HashData(clientDataJson)];
+ if (!key.VerifyData(signed, signature, HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence))
+ {
+ return false;
+ }
+ }
+ catch (CryptographicException)
+ {
+ return false;
+ }
+
+ signCount = (uint)((authenticatorData[33] << 24) | (authenticatorData[34] << 16) | (authenticatorData[35] << 8) | authenticatorData[36]);
+ return true;
+ }
+
+ /// clientDataJSON 的 type、challenge、origin 都要对得上。注册和登录共用。
+ public static bool ClientDataValid(ReadOnlySpan clientDataJson, string expectedType, ReadOnlySpan expectedChallenge, Func originAllowed)
+ {
+ try
+ {
+ using JsonDocument document = JsonDocument.Parse(clientDataJson.ToArray());
+ JsonElement root = document.RootElement;
+ if (root.ValueKind != JsonValueKind.Object
+ || root.GetProperty("type").GetString() != expectedType
+ || root.GetProperty("origin").GetString() is not { } origin || !originAllowed(origin)
+ || root.GetProperty("challenge").GetString() is not { } challenge)
+ {
+ return false;
+ }
+
+ byte[] decoded = Base64Url.DecodeFromChars(challenge);
+ return CryptographicOperations.FixedTimeEquals(decoded, expectedChallenge);
+ }
+ catch (Exception ex) when (ex is JsonException or KeyNotFoundException or FormatException or InvalidOperationException)
+ {
+ return false;
+ }
+ }
+}
diff --git a/backend/fleet/src/Fleet.Link/FleetLink.cs b/backend/fleet/src/Fleet.Link/FleetLink.cs
index 04a19d1..e31649d 100644
--- a/backend/fleet/src/Fleet.Link/FleetLink.cs
+++ b/backend/fleet/src/Fleet.Link/FleetLink.cs
@@ -66,6 +66,12 @@ public sealed class FleetLinkOptions
public long MaxInflightBytes { get; init; } = 4 << 20;
public Backoff Backoff { get; init; } = new();
+
+ ///
+ /// 校验服务器身份: 为真(节点固定的操作员集合里登记了服务器密钥)时,
+ /// 服务器必须在挑战里证明自己,之后这条链路用应用层加密;否则按旧的方式(没有固定信任根的节点)。
+ ///
+ public IServerVerifier? ServerVerifier { get; init; }
}
///
@@ -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 _router;
private readonly Dictionary _handlers;
private readonly TimeProvider _time;
@@ -153,6 +160,9 @@ public sealed class FleetLink
}
}
+ /// 当前这条链路是不是应用层加密的(服务器证明了身份)。
+ public bool IsSecure => _secure && IsConnected;
+
private async Task 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(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
}
/// 挑战 → 签名 → 认证结果。失败返回原因;服务端禁用该节点时不再重试。
- private async Task AuthenticateAsync(WsTransport transport, CancellationToken ct)
+ private async Task 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 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 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;
diff --git a/backend/fleet/src/Fleet.Link/SecureTransport.cs b/backend/fleet/src/Fleet.Link/SecureTransport.cs
new file mode 100644
index 0000000..6902180
--- /dev/null
+++ b/backend/fleet/src/Fleet.Link/SecureTransport.cs
@@ -0,0 +1,57 @@
+using Fleet.Identity;
+using Fleet.Rpc;
+
+namespace Fleet.Link;
+
+///
+/// 认证阶段是明文,认证通过之后换成应用层加密():此后每帧都是加密的二进制帧,明文帧一律拒绝。
+/// 认证阶段服务器的拒绝回复可能是明文(还没有协商好密钥),所以第一帧加密的回复到达之前允许一次明文文本帧。
+///
+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 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();
+ }
+}
diff --git a/backend/fleet/src/Fleet.Rpc/FleetMethods.cs b/backend/fleet/src/Fleet.Rpc/FleetMethods.cs
index d189253..a1c5e61 100644
--- a/backend/fleet/src/Fleet.Rpc/FleetMethods.cs
+++ b/backend/fleet/src/Fleet.Rpc/FleetMethods.cs
@@ -53,6 +53,14 @@ public sealed class ChallengeParams
[JsonPropertyName("expires_at_ms")]
public long ExpiresAtMs { get; set; }
+
+ /// 服务器这次连接的临时 ECDH 公钥(SPKI,base64);服务器有身份密钥时才有。
+ [JsonPropertyName("server_eph")]
+ public string? ServerEphemeral { get; set; }
+
+ /// 服务器密钥对「随机数加临时公钥」的签名(base64):节点据此确认对面是操作员授权的服务器。
+ [JsonPropertyName("server_sig")]
+ public string? ServerSignature { get; set; }
}
public sealed class AuthParams
@@ -63,6 +71,10 @@ public sealed class AuthParams
/// 对 fleet|v1|auth|{node_id}|{nonce} 的签名(base64)。
[JsonPropertyName("signature")]
public string Signature { get; set; } = "";
+
+ /// 节点的临时 ECDH 公钥(SPKI,base64):有它就是安全链路,签名覆盖双方的临时公钥,认证结果起所有帧都是加密的。
+ [JsonPropertyName("node_eph")]
+ public string? NodeEphemeral { get; set; }
}
public sealed class AuthResult
diff --git a/backend/fleet/src/Fleet.Server.Abstractions/NodeRecords.cs b/backend/fleet/src/Fleet.Server.Abstractions/NodeRecords.cs
index e792a2f..d5db797 100644
--- a/backend/fleet/src/Fleet.Server.Abstractions/NodeRecords.cs
+++ b/backend/fleet/src/Fleet.Server.Abstractions/NodeRecords.cs
@@ -33,7 +33,8 @@ public sealed record NewNode(
string? NodeVersion,
long NowMs,
string Kind = "",
- byte[]? TlsPublicKey = null);
+ byte[]? TlsPublicKey = null,
+ string? AddedBy = null);
public sealed record JoinTokenRecord(
string Id,
@@ -46,6 +47,7 @@ public sealed record JoinTokenRecord(
long ExpiresAtMs,
long? RevokedAtMs,
long CreatedAtMs,
- string Kind = "");
+ string Kind = "",
+ string? CreatedBy = null);
public sealed record Presence(bool Online, long AtMs, string? NodeVersion, IReadOnlyList Capabilities, string? HelloMetaJson);
diff --git a/backend/fleet/src/Fleet.Server.Abstractions/ServerContracts.cs b/backend/fleet/src/Fleet.Server.Abstractions/ServerContracts.cs
index 72c567a..bb8e4be 100644
--- a/backend/fleet/src/Fleet.Server.Abstractions/ServerContracts.cs
+++ b/backend/fleet/src/Fleet.Server.Abstractions/ServerContracts.cs
@@ -6,6 +6,24 @@ namespace Fleet.Server;
public sealed record EnrollmentContext(string? RemoteIp, JsonElement? ProductMeta);
+///
+/// 服务器的身份密钥:操作员用 passkey 把它的公钥签进操作员集合。有它的时候,认证挑战会带上服务器的签名,节点据此确认对面是真的服务器,
+/// 并在这条链路上用应用层加密。没有注册就是旧的行为(没有服务器身份)。
+///
+public interface IServerIdentity
+{
+ byte[] PublicKeySpki { get; }
+
+ /// 对一段内容签名(Fleet.Identity.ServerHello.Payload 给出的)。
+ byte[] Sign(ReadOnlySpan payload);
+}
+
+/// 操作员集合的链(passkey 签的,创世在前)。没有初始化信任根时是空的。节点注册时用它确认「这个面板确实是我的操作员的」。
+public interface IOperatorTrustSource
+{
+ ValueTask> ChainAsync(CancellationToken ct);
+}
+
/// 产品挂钩:节点注册成功之后调用。
public interface IEnrollmentHook
{
diff --git a/backend/fleet/src/Fleet.Server.Enrollment/EnrollmentModels.cs b/backend/fleet/src/Fleet.Server.Enrollment/EnrollmentModels.cs
index de87ec2..124bb39 100644
--- a/backend/fleet/src/Fleet.Server.Enrollment/EnrollmentModels.cs
+++ b/backend/fleet/src/Fleet.Server.Enrollment/EnrollmentModels.cs
@@ -37,6 +37,12 @@ public sealed class EnrollRequest
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 +75,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;
diff --git a/backend/fleet/src/Fleet.Server.Enrollment/FleetEnrollmentEndpoints.cs b/backend/fleet/src/Fleet.Server.Enrollment/FleetEnrollmentEndpoints.cs
index 730cf82..ccd3567 100644
--- a/backend/fleet/src/Fleet.Server.Enrollment/FleetEnrollmentEndpoints.cs
+++ b/backend/fleet/src/Fleet.Server.Enrollment/FleetEnrollmentEndpoints.cs
@@ -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 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 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 HandleAsync(HttpContext http, FleetEnrollmentService service)
{
EnrollRequest? request;
diff --git a/backend/fleet/src/Fleet.Server.Enrollment/FleetEnrollmentService.cs b/backend/fleet/src/Fleet.Server.Enrollment/FleetEnrollmentService.cs
index 64f03a6..8fe2e65 100644
--- a/backend/fleet/src/Fleet.Server.Enrollment/FleetEnrollmentService.cs
+++ b/backend/fleet/src/Fleet.Server.Enrollment/FleetEnrollmentService.cs
@@ -9,13 +9,15 @@ public sealed partial class FleetEnrollmentService
{
private readonly IFleetStore _store;
private readonly IEnumerable _hooks;
+ private readonly IEnumerable _trust;
private readonly FleetServerOptions _options;
private readonly TimeProvider _time;
- public FleetEnrollmentService(IFleetStore store, IEnumerable hooks, FleetServerOptions options, TimeProvider time)
+ public FleetEnrollmentService(IFleetStore store, IEnumerable hooks, IEnumerable trust, FleetServerOptions options, TimeProvider time)
{
_store = store;
_hooks = hooks;
+ _trust = trust;
_options = options;
_time = time;
}
@@ -80,7 +82,8 @@ public sealed partial class FleetEnrollmentService
request.NodeVersion,
nowMs,
token.Kind,
- tlsPublicKey);
+ tlsPublicKey,
+ token.CreatedBy);
Result created = await _store.CreateNodeAsync(node, ct);
if (!created.TryGet(out NodeRecord? record, out FleetError? error))
@@ -101,6 +104,30 @@ public sealed partial class FleetEnrollmentService
});
}
+ /// 节点注册之前取:操作员集合的链。码要是有效的;不能用的码不给。
+ public async ValueTask> 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.Fail(FleetErrors.JoinTokenInvalid, "join token is invalid");
+ }
+
+ if (token.ExpiresAtMs <= nowMs)
+ {
+ return Result.Fail(FleetErrors.JoinTokenExpired, "join token has expired");
+ }
+
+ var bundle = new TrustBundle();
+ foreach (IOperatorTrustSource source in _trust)
+ {
+ bundle.Chain.AddRange(await source.ChainAsync(ct));
+ }
+
+ return Result.Ok(bundle);
+ }
+
private static bool TryDecode(string text, out byte[] data)
{
try
diff --git a/backend/fleet/src/Fleet.Server.Link/FleetLinkHost.cs b/backend/fleet/src/Fleet.Server.Link/FleetLinkHost.cs
index b06fa57..d4d4b82 100644
--- a/backend/fleet/src/Fleet.Server.Link/FleetLinkHost.cs
+++ b/backend/fleet/src/Fleet.Server.Link/FleetLinkHost.cs
@@ -23,8 +23,10 @@ public sealed class FleetLinkHost : IFleetConnections, IStateNotifier
IEnumerable stateProviders,
IEnumerable inboxes,
IEnumerable 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; }
+ /// 服务器的身份密钥;没有时节点连接用旧的明文认证。
+ internal IServerIdentity? ServerIdentity { get; }
+
internal FleetServerOptions Options { get; }
internal TimeProvider Time { get; }
diff --git a/backend/fleet/src/Fleet.Server.Link/FleetServerBuilder.cs b/backend/fleet/src/Fleet.Server.Link/FleetServerBuilder.cs
index ede5c05..0c99b75 100644
--- a/backend/fleet/src/Fleet.Server.Link/FleetServerBuilder.cs
+++ b/backend/fleet/src/Fleet.Server.Link/FleetServerBuilder.cs
@@ -109,7 +109,8 @@ public static class FleetServerExtensions
provider.GetServices(),
provider.GetServices(),
provider.GetServices(),
- provider.GetRequiredService()));
+ provider.GetRequiredService(),
+ provider.GetService()));
services.AddSingleton(provider => provider.GetRequiredService());
services.AddSingleton(provider => provider.GetRequiredService());
return builder;
diff --git a/backend/fleet/src/Fleet.Server.Link/NodeConnection.Auth.cs b/backend/fleet/src/Fleet.Server.Link/NodeConnection.Auth.cs
index 55f33fe..57c87a8 100644
--- a/backend/fleet/src/Fleet.Server.Link/NodeConnection.Auth.cs
+++ b/backend/fleet/src/Fleet.Server.Link/NodeConnection.Auth.cs
@@ -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)
{
// 节点不存在与签名错误返回同样的错误,不泄露节点是否存在。
diff --git a/backend/fleet/src/Fleet.Server.Link/NodeConnection.Send.cs b/backend/fleet/src/Fleet.Server.Link/NodeConnection.Send.cs
index 467ca87..a05d4ca 100644
--- a/backend/fleet/src/Fleet.Server.Link/NodeConnection.Send.cs
+++ b/backend/fleet/src/Fleet.Server.Link/NodeConnection.Send.cs
@@ -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)
diff --git a/backend/fleet/src/Fleet.Server.Link/NodeConnection.Wire.cs b/backend/fleet/src/Fleet.Server.Link/NodeConnection.Wire.cs
index 0c16154..b528943 100644
--- a/backend/fleet/src/Fleet.Server.Link/NodeConnection.Wire.cs
+++ b/backend/fleet/src/Fleet.Server.Link/NodeConnection.Wire.cs
@@ -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());
}
}
diff --git a/backend/fleet/src/Fleet.Server.Link/NodeConnection.cs b/backend/fleet/src/Fleet.Server.Link/NodeConnection.cs
index 1d32de7..95867d6 100644
--- a/backend/fleet/src/Fleet.Server.Link/NodeConnection.cs
+++ b/backend/fleet/src/Fleet.Server.Link/NodeConnection.cs
@@ -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 _stateSignal = Channel.CreateBounded(new BoundedChannelOptions(1) { FullMode = BoundedChannelFullMode.DropWrite });
private readonly Dictionary _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();
}
diff --git a/backend/fleet/tests/Fleet.Tests/OperatorTrustTests.cs b/backend/fleet/tests/Fleet.Tests/OperatorTrustTests.cs
new file mode 100644
index 0000000..2f02e8c
--- /dev/null
+++ b/backend/fleet/tests/Fleet.Tests/OperatorTrustTests.cs
@@ -0,0 +1,123 @@
+using System.Buffers.Text;
+using System.Security.Cryptography;
+using System.Text;
+using Fleet.Identity;
+
+namespace Fleet.Tests;
+
+/// 软件 passkey:只做断言(navigator.credentials.get 的结果),用来测信任链。
+internal sealed class TestAuthenticator : IDisposable
+{
+ public const string RpId = "panel.example.com";
+ public const string Origin = "https://panel.example.com";
+
+ private readonly ECDsa _key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
+ private uint _counter;
+
+ public string CredentialId { get; } = Base64Url.EncodeToString(RandomNumberGenerator.GetBytes(32));
+
+ public OperatorKey Key => new(CredentialId, _key.ExportSubjectPublicKeyInfo());
+
+ public SignedStatement Sign(string statement, byte flags = 0x05, string origin = Origin)
+ {
+ byte[] challenge = SHA256.HashData(Encoding.UTF8.GetBytes(statement));
+ _counter++;
+ byte[] authData = [.. SHA256.HashData(Encoding.UTF8.GetBytes(RpId)), flags, (byte)(_counter >> 24), (byte)(_counter >> 16), (byte)(_counter >> 8), (byte)_counter];
+ byte[] clientData = Encoding.UTF8.GetBytes($"{{\"type\":\"webauthn.get\",\"challenge\":\"{Base64Url.EncodeToString(challenge)}\",\"origin\":\"{origin}\",\"crossOrigin\":false}}");
+ byte[] signature = _key.SignData([.. authData, .. SHA256.HashData(clientData)], HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence);
+ return new SignedStatement(statement, CredentialId, Base64Url.EncodeToString(authData), Base64Url.EncodeToString(clientData), Base64Url.EncodeToString(signature));
+ }
+
+ public void Dispose() => _key.Dispose();
+}
+
+public sealed class OperatorTrustTests : IDisposable
+{
+ private readonly TestAuthenticator _alice = new();
+ private readonly TestAuthenticator _bob = new();
+
+ public void Dispose()
+ {
+ _alice.Dispose();
+ _bob.Dispose();
+ }
+
+ private OperatorSet Genesis(params TestAuthenticator[] keys) =>
+ new(1, OperatorTrust.Genesis, TestAuthenticator.RpId, [TestAuthenticator.Origin], [.. keys.Select(k => k.Key)]);
+
+ private SignedStatement SignedGenesis(OperatorSet set, TestAuthenticator signer) => signer.Sign(OperatorTrust.Encode(set));
+
+ [Fact]
+ public void TheGenesis_IsPinnedByItsFingerprint_AndMustBeSelfSigned()
+ {
+ OperatorSet set = Genesis(_alice);
+ SignedStatement genesis = SignedGenesis(set, _alice);
+ string pin = OperatorTrust.Fingerprint(genesis.Statement);
+
+ Assert.NotNull(OperatorTrust.VerifyChain(pin, [genesis]));
+ Assert.Null(OperatorTrust.VerifyChain(new string('0', 64), [genesis])); // 指纹对不上:Backend 换了一份创世
+ Assert.Null(OperatorTrust.VerifyChain(pin, [SignedGenesis(set, _bob)])); // 不是集合里的凭证签的(也改不了指纹)
+ Assert.Null(OperatorTrust.VerifyChain(pin, []));
+ }
+
+ [Fact]
+ public void ABackendThatSwapsTheKeys_CannotMakeAChainTheNodeAccepts()
+ {
+ SignedStatement real = SignedGenesis(Genesis(_alice), _alice);
+ string pin = OperatorTrust.Fingerprint(real.Statement);
+
+ using var attacker = new TestAuthenticator();
+ SignedStatement forged = SignedGenesis(Genesis(attacker), attacker); // 自洽的假创世,但指纹不同
+ Assert.Null(OperatorTrust.VerifyChain(pin, [forged]));
+
+ // 在真创世后面接一个攻击者签的更新:上一版里没有他的凭证
+ var update = new OperatorSet(2, OperatorTrust.Fingerprint(real.Statement), TestAuthenticator.RpId, [TestAuthenticator.Origin], [_alice.Key, attacker.Key]);
+ Assert.Null(OperatorTrust.VerifyChain(pin, [real, attacker.Sign(OperatorTrust.Encode(update))]));
+ }
+
+ [Fact]
+ public void AnUpdate_NeedsAKeyOfThePreviousVersion_AndFollowsTheChain()
+ {
+ SignedStatement genesis = SignedGenesis(Genesis(_alice), _alice);
+ string pin = OperatorTrust.Fingerprint(genesis.Statement);
+ var second = new OperatorSet(2, OperatorTrust.Fingerprint(genesis.Statement), TestAuthenticator.RpId, [TestAuthenticator.Origin], [_alice.Key, _bob.Key]);
+ SignedStatement signedSecond = _alice.Sign(OperatorTrust.Encode(second));
+
+ OperatorSet? current = OperatorTrust.VerifyChain(pin, [genesis, signedSecond]);
+ Assert.Equal(2, current?.Version);
+ Assert.Equal(2, current?.Keys.Count);
+
+ // 新加的 bob 可以签下一版(上一版里有他)
+ var third = new OperatorSet(3, OperatorTrust.Fingerprint(signedSecond.Statement), TestAuthenticator.RpId, [TestAuthenticator.Origin], [_bob.Key]);
+ Assert.Equal(3, OperatorTrust.VerifyChain(pin, [genesis, signedSecond, _bob.Sign(OperatorTrust.Encode(third))])?.Version);
+
+ // 版本号跳了、prev 不对、域名换了:都不行
+ var skipped = second with { Version = 3 };
+ Assert.Null(OperatorTrust.VerifyChain(pin, [genesis, _alice.Sign(OperatorTrust.Encode(skipped))]));
+ var wrongPrev = second with { Previous = new string('a', 64) };
+ Assert.Null(OperatorTrust.VerifyChain(pin, [genesis, _alice.Sign(OperatorTrust.Encode(wrongPrev))]));
+ var otherRp = second with { RpId = "evil.example" };
+ Assert.Null(OperatorTrust.VerifyChain(pin, [genesis, _alice.Sign(OperatorTrust.Encode(otherRp))]));
+ }
+
+ [Fact]
+ public void AnAssertion_NeedsUserVerification_TheRightOrigin_AndTheExactStatement()
+ {
+ OperatorSet set = Genesis(_alice);
+ Assert.True(OperatorTrust.VerifyOperator(_alice.Sign("hello"), set));
+ Assert.False(OperatorTrust.VerifyOperator(_alice.Sign("hello", flags: 0x01), set)); // 没有用户验证
+ Assert.False(OperatorTrust.VerifyOperator(_alice.Sign("hello", origin: "https://evil.example"), set));
+ Assert.False(OperatorTrust.VerifyOperator(_alice.Sign("hello") with { Statement = "hellO" }, set)); // 语句被改
+ Assert.False(OperatorTrust.VerifyOperator(_bob.Sign("hello"), set)); // 不在集合里
+ }
+
+ [Fact]
+ public void Parsing_IsStrict()
+ {
+ 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"));
+ }
+}
diff --git a/backend/fleet/tests/Fleet.Tests/SecureChannelTests.cs b/backend/fleet/tests/Fleet.Tests/SecureChannelTests.cs
new file mode 100644
index 0000000..4eb9c5d
--- /dev/null
+++ b/backend/fleet/tests/Fleet.Tests/SecureChannelTests.cs
@@ -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=***"));
+ }
+}
diff --git a/backend/fleet/tools/fleet-enroll/Program.cs b/backend/fleet/tools/fleet-enroll/Program.cs
index 3c31989..3e7ee3b 100644
--- a/backend/fleet/tools/fleet-enroll/Program.cs
+++ b/backend/fleet/tools/fleet-enroll/Program.cs
@@ -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 --join --name --identity-dir [--meta ] [--version ]");
+ Console.Error.WriteLine("usage: fleet-enroll --server --join --name --identity-dir [--meta ] [--version ] [--genesis --controllers ]");
return 2;
}
@@ -30,14 +30,17 @@ if (Arg("--meta") is { } metaText)
}
}
+string? genesis = Arg("--genesis");
+string[] controllers = (Arg("--controllers") ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
Result 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, genesis, controllers);
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;
diff --git a/backend/openapi/v1.json b/backend/openapi/v1.json
index a4801fb..30ce08f 100644
--- a/backend/openapi/v1.json
+++ b/backend/openapi/v1.json
@@ -1187,6 +1187,98 @@
}
}
},
+ "/api/admin/auth/passkey/options": {
+ "post": {
+ "tags": [
+ "admin-auth"
+ ],
+ "summary": "A challenge for signing in with a passkey (discoverable credential, no username needed)",
+ "operationId": "auth_passkey_options",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PasskeyRequestOptionsDto"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Too Many Requests",
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDto"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/admin/auth/passkey/login": {
+ "post": {
+ "tags": [
+ "admin-auth"
+ ],
+ "summary": "Sign in with a passkey assertion; same response as the password sign-in",
+ "operationId": "auth_passkey_login",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PasskeyLoginRequest"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/LoginResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDto"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDto"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Too Many Requests",
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDto"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
"/api/admin/releases": {
"post": {
"tags": [
@@ -1255,7 +1347,8 @@
}
}
}
- }
+ },
+ "x-moshiro-capability": "release.publish"
},
"get": {
"tags": [
@@ -1294,7 +1387,8 @@
}
}
}
- }
+ },
+ "x-moshiro-capability": "update.read"
}
},
"/api/admin/auth/logout": {
@@ -1323,7 +1417,414 @@
}
}
}
- }
+ },
+ "x-moshiro-capability": "authenticated"
+ }
+ },
+ "/api/admin/auth/passkeys": {
+ "get": {
+ "tags": [
+ "admin-auth"
+ ],
+ "summary": "The signed-in user's passkeys",
+ "operationId": "auth_passkeys_list",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PageDtoOfPasskeyDto"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDto"
+ }
+ }
+ }
+ }
+ },
+ "x-moshiro-capability": "authenticated"
+ },
+ "post": {
+ "tags": [
+ "admin-auth"
+ ],
+ "summary": "Register a passkey from a WebAuthn attestation (ES256, user verification required)",
+ "operationId": "auth_passkeys_register",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RegisterPasskeyRequest"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "201": {
+ "description": "Created",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PasskeyDto"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDto"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDto"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDto"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "Conflict",
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDto"
+ }
+ }
+ }
+ }
+ },
+ "x-moshiro-capability": "authenticated"
+ }
+ },
+ "/api/admin/auth/passkeys/options": {
+ "post": {
+ "tags": [
+ "admin-auth"
+ ],
+ "summary": "A challenge for registering a new passkey (the user must be signed in)",
+ "operationId": "auth_passkeys_options",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PasskeyCreationOptionsDto"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDto"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDto"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDto"
+ }
+ }
+ }
+ }
+ },
+ "x-moshiro-capability": "authenticated"
+ }
+ },
+ "/api/admin/auth/passkeys/{id}": {
+ "delete": {
+ "tags": [
+ "admin-auth"
+ ],
+ "summary": "Remove one of your own passkeys",
+ "operationId": "auth_passkeys_delete",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "204": {
+ "description": "No Content",
+ "content": {
+ "application/json": {
+ "schema": { }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDto"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDto"
+ }
+ }
+ }
+ }
+ },
+ "x-moshiro-capability": "authenticated"
+ }
+ },
+ "/api/admin/auth/elevation": {
+ "get": {
+ "tags": [
+ "admin-auth"
+ ],
+ "summary": "The elevation windows this session has open (sensitive actions need one)",
+ "operationId": "auth_elevation_list",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PageDtoOfElevationDto"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDto"
+ }
+ }
+ }
+ }
+ },
+ "x-moshiro-capability": "authenticated"
+ },
+ "post": {
+ "tags": [
+ "admin-auth"
+ ],
+ "summary": "Open an elevation window from a passkey assertion (enroll 15 min, revoke 5 min, credential one use within 60 s)",
+ "operationId": "auth_elevate",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ElevationAssertionRequest"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ElevationDto"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDto"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDto"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDto"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Too Many Requests",
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDto"
+ }
+ }
+ }
+ }
+ },
+ "x-moshiro-capability": "authenticated"
+ },
+ "delete": {
+ "tags": [
+ "admin-auth"
+ ],
+ "summary": "Close all elevation windows of this session",
+ "operationId": "auth_elevation_end",
+ "responses": {
+ "204": {
+ "description": "No Content",
+ "content": {
+ "application/json": {
+ "schema": { }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDto"
+ }
+ }
+ }
+ }
+ },
+ "x-moshiro-capability": "authenticated"
+ }
+ },
+ "/api/admin/auth/elevation/options": {
+ "post": {
+ "tags": [
+ "admin-auth"
+ ],
+ "summary": "A challenge for opening an elevation window with a passkey touch",
+ "operationId": "auth_elevation_options",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ElevationRequest"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PasskeyRequestOptionsDto"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDto"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDto"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDto"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Too Many Requests",
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDto"
+ }
+ }
+ }
+ }
+ },
+ "x-moshiro-capability": "authenticated"
}
},
"/api/admin/auth/me": {
@@ -1354,7 +1855,8 @@
}
}
}
- }
+ },
+ "x-moshiro-capability": "authenticated"
}
},
"/api/admin/agents": {
@@ -1395,7 +1897,8 @@
}
}
}
- }
+ },
+ "x-moshiro-capability": "fleet.read"
}
},
"/api/admin/agents/{id}": {
@@ -1456,7 +1959,8 @@
}
}
}
- }
+ },
+ "x-moshiro-capability": "fleet.read"
},
"patch": {
"tags": [
@@ -1560,7 +2064,8 @@
}
}
}
- }
+ },
+ "x-moshiro-capability": "fleet.write"
},
"delete": {
"tags": [
@@ -1607,7 +2112,8 @@
}
}
}
- }
+ },
+ "x-moshiro-capability": "fleet.revoke"
}
},
"/api/admin/agents/{id}/approve": {
@@ -1678,7 +2184,8 @@
}
}
}
- }
+ },
+ "x-moshiro-capability": "fleet.enroll"
}
},
"/api/admin/agents/{id}/disable": {
@@ -1749,7 +2256,8 @@
}
}
}
- }
+ },
+ "x-moshiro-capability": "fleet.write"
}
},
"/api/admin/agents/{id}/enable": {
@@ -1820,7 +2328,8 @@
}
}
}
- }
+ },
+ "x-moshiro-capability": "fleet.write"
}
},
"/api/admin/agents/{id}/takeover": {
@@ -1881,7 +2390,8 @@
}
}
}
- }
+ },
+ "x-moshiro-capability": "fleet.enroll"
}
},
"/api/admin/join-tokens": {
@@ -1922,7 +2432,8 @@
}
}
}
- }
+ },
+ "x-moshiro-capability": "fleet.read"
},
"post": {
"tags": [
@@ -1981,7 +2492,8 @@
}
}
}
- }
+ },
+ "x-moshiro-capability": "fleet.enroll"
}
},
"/api/admin/join-tokens/{id}": {
@@ -2037,7 +2549,8 @@
}
}
}
- }
+ },
+ "x-moshiro-capability": "fleet.enroll"
}
},
"/api/admin/probes": {
@@ -2078,7 +2591,8 @@
}
}
}
- }
+ },
+ "x-moshiro-capability": "fleet.read"
}
},
"/api/admin/probes/{id}": {
@@ -2139,7 +2653,8 @@
}
}
}
- }
+ },
+ "x-moshiro-capability": "fleet.read"
},
"patch": {
"tags": [
@@ -2228,7 +2743,8 @@
}
}
}
- }
+ },
+ "x-moshiro-capability": "fleet.write"
},
"delete": {
"tags": [
@@ -2275,7 +2791,8 @@
}
}
}
- }
+ },
+ "x-moshiro-capability": "fleet.revoke"
}
},
"/api/admin/probes/{id}/revoke": {
@@ -2336,7 +2853,8 @@
}
}
}
- }
+ },
+ "x-moshiro-capability": "fleet.revoke"
}
},
"/api/admin/agents/{id}/probes/{probe_id}/disconnect": {
@@ -2413,7 +2931,8 @@
}
}
}
- }
+ },
+ "x-moshiro-capability": "fleet.write"
}
},
"/api/admin/tasks": {
@@ -2470,7 +2989,8 @@
}
}
}
- }
+ },
+ "x-moshiro-capability": "tasks.read"
},
"post": {
"tags": [
@@ -2539,7 +3059,8 @@
}
}
}
- }
+ },
+ "x-moshiro-capability": "tasks.write"
}
},
"/api/admin/tasks/{id}": {
@@ -2600,7 +3121,8 @@
}
}
}
- }
+ },
+ "x-moshiro-capability": "tasks.read"
},
"patch": {
"tags": [
@@ -2694,7 +3216,8 @@
}
}
}
- }
+ },
+ "x-moshiro-capability": "tasks.write"
},
"delete": {
"tags": [
@@ -2741,7 +3264,8 @@
}
}
}
- }
+ },
+ "x-moshiro-capability": "tasks.write"
}
},
"/api/admin/lines/{line_id}/tests": {
@@ -2832,7 +3356,8 @@
}
}
}
- }
+ },
+ "x-moshiro-capability": "tasks.write"
},
"get": {
"tags": [
@@ -2893,7 +3418,8 @@
}
}
}
- }
+ },
+ "x-moshiro-capability": "tasks.read"
}
},
"/api/admin/tests/{id}/cancel": {
@@ -2954,7 +3480,8 @@
}
}
}
- }
+ },
+ "x-moshiro-capability": "tasks.write"
}
},
"/api/admin/raw/chunks": {
@@ -3061,7 +3588,8 @@
}
}
}
- }
+ },
+ "x-moshiro-capability": "data.read"
}
},
"/api/admin/raw/chunks/{chunk_id}": {
@@ -3125,7 +3653,8 @@
}
}
}
- }
+ },
+ "x-moshiro-capability": "data.read"
}
},
"/api/admin/events": {
@@ -3225,7 +3754,8 @@
}
}
}
- }
+ },
+ "x-moshiro-capability": "events.read"
}
},
"/api/admin/analysis/recompute": {
@@ -3266,7 +3796,207 @@
}
}
}
- }
+ },
+ "x-moshiro-capability": "data.write"
+ }
+ },
+ "/api/admin/audit": {
+ "get": {
+ "tags": [
+ "admin-audit"
+ ],
+ "summary": "Audit log of admin actions, newest first",
+ "operationId": "audit_list",
+ "parameters": [
+ {
+ "name": "actor",
+ "in": "query",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "action",
+ "in": "query",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "limit",
+ "in": "query",
+ "schema": {
+ "pattern": "^-?(?:0|[1-9]\\d*)$",
+ "type": [
+ "integer",
+ "string"
+ ],
+ "format": "int32"
+ }
+ },
+ {
+ "name": "cursor",
+ "in": "query",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PageDtoOfAuditDto"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDto"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDto"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDto"
+ }
+ }
+ }
+ }
+ },
+ "x-moshiro-capability": "audit.read"
+ }
+ },
+ "/api/admin/trust": {
+ "get": {
+ "tags": [
+ "admin-trust"
+ ],
+ "summary": "The operator trust root: genesis fingerprint, version, whether it matches the registered passkeys, and the next version to sign",
+ "operationId": "trust_get",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/TrustStateDto"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDto"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDto"
+ }
+ }
+ }
+ }
+ },
+ "x-moshiro-capability": "fleet.read"
+ },
+ "post": {
+ "tags": [
+ "admin-trust"
+ ],
+ "summary": "Store the next operator set, signed with a passkey (the first one sets the genesis and needs a credential window)",
+ "operationId": "trust_commit",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/SignedStatementDto"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "201": {
+ "description": "Created",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/TrustStateDto"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDto"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDto"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDto"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "Conflict",
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDto"
+ }
+ }
+ }
+ }
+ },
+ "x-moshiro-capability": "accounts.manage"
}
},
"/api/admin/api-tokens": {
@@ -3307,13 +4037,14 @@
}
}
}
- }
+ },
+ "x-moshiro-capability": "keys.read"
},
"post": {
"tags": [
"admin-api-tokens"
],
- "summary": "Create an API token (scope read or admin); the plaintext is returned only once",
+ "summary": "Create an API token with the chosen capabilities (a subset of your own); the plaintext is returned only once",
"operationId": "api_tokens_create",
"requestBody": {
"content": {
@@ -3366,7 +4097,8 @@
}
}
}
- }
+ },
+ "x-moshiro-capability": "keys.manage"
}
},
"/api/admin/api-tokens/{id}": {
@@ -3415,7 +4147,50 @@
}
}
}
- }
+ },
+ "x-moshiro-capability": "keys.manage"
+ }
+ },
+ "/api/admin/capabilities": {
+ "get": {
+ "tags": [
+ "admin-api-tokens"
+ ],
+ "summary": "The capability catalog: groups, which ones change trust, which are reserved, and the presets",
+ "operationId": "capabilities_list",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CapabilityCatalogDto"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDto"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDto"
+ }
+ }
+ }
+ }
+ },
+ "x-moshiro-capability": "keys.read"
}
},
"/api/admin/settings": {
@@ -3456,7 +4231,8 @@
}
}
}
- }
+ },
+ "x-moshiro-capability": "settings.read"
},
"patch": {
"tags": [
@@ -3520,7 +4296,8 @@
}
}
}
- }
+ },
+ "x-moshiro-capability": "settings.write"
}
},
"/api/admin/users": {
@@ -3561,7 +4338,8 @@
}
}
}
- }
+ },
+ "x-moshiro-capability": "accounts.read"
},
"post": {
"tags": [
@@ -3630,7 +4408,8 @@
}
}
}
- }
+ },
+ "x-moshiro-capability": "accounts.manage"
}
},
"/api/admin/users/{id}": {
@@ -3699,7 +4478,8 @@
}
}
}
- }
+ },
+ "x-moshiro-capability": "accounts.manage"
}
},
"/api/admin/build-info": {
@@ -3740,7 +4520,8 @@
}
}
}
- }
+ },
+ "x-moshiro-capability": "update.read"
}
},
"/api/admin/nodes/{id}/update": {
@@ -3799,7 +4580,8 @@
}
}
}
- }
+ },
+ "x-moshiro-capability": "update.confirm"
}
},
"/api/admin/backend/update": {
@@ -3840,7 +4622,8 @@
}
}
}
- }
+ },
+ "x-moshiro-capability": "update.read"
},
"post": {
"tags": [
@@ -3907,7 +4690,8 @@
}
}
}
- }
+ },
+ "x-moshiro-capability": "update.confirm"
}
},
"/api/admin/backend/update/live": {
@@ -3941,7 +4725,8 @@
}
}
}
- }
+ },
+ "x-moshiro-capability": "update.read"
}
},
"/api/admin/links": {
@@ -3982,7 +4767,8 @@
}
}
}
- }
+ },
+ "x-moshiro-capability": "fleet.read"
}
},
"/api/admin/links/{probe_id}/{agent_id}": {
@@ -4071,7 +4857,8 @@
}
}
}
- }
+ },
+ "x-moshiro-capability": "fleet.write"
}
},
"/healthz": {
@@ -4110,7 +4897,7 @@
"tags": [
"ops"
],
- "summary": "Prometheus metrics (admin token)",
+ "summary": "Prometheus metrics (requires ops.metrics)",
"operationId": "metrics_get",
"responses": {
"200": {
@@ -4144,8 +4931,55 @@
"tags": [
"ops"
],
- "summary": "One-line installer for Linux and macOS nodes (agent or probe, decided by the code)",
+ "summary": "The static one-line installer for Linux and macOS nodes (same bytes for everyone; its SHA-256 is published with each release)",
"operationId": "install_sh",
+ "responses": {
+ "200": {
+ "description": "OK"
+ }
+ }
+ }
+ },
+ "/install/manifest": {
+ "get": {
+ "tags": [
+ "ops"
+ ],
+ "summary": "The signed release manifest of the component an install code is for (exact signed bytes)",
+ "operationId": "install_manifest",
+ "parameters": [
+ {
+ "name": "code",
+ "in": "query",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK"
+ },
+ "404": {
+ "description": "Not Found",
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDto"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/install/manifest.sig": {
+ "get": {
+ "tags": [
+ "ops"
+ ],
+ "summary": "Base64 DER ECDSA P-256 signature of the manifest",
+ "operationId": "install_manifest_signature",
"parameters": [
{
"name": "code",
@@ -4177,30 +5011,11 @@
"tags": [
"ops"
],
- "summary": "One-line installer for Windows nodes (agent or probe, decided by the code)",
+ "summary": "The static one-line installer for Windows nodes (same bytes for everyone; its SHA-256 is published with each release)",
"operationId": "install_ps1",
- "parameters": [
- {
- "name": "code",
- "in": "query",
- "schema": {
- "type": "string"
- }
- }
- ],
"responses": {
"200": {
"description": "OK"
- },
- "404": {
- "description": "Not Found",
- "content": {
- "application/problem+json": {
- "schema": {
- "$ref": "#/components/schemas/ProblemDto"
- }
- }
- }
}
}
}
@@ -4218,6 +5033,20 @@
}
}
}
+ },
+ "/api/agent/v1/enroll/trust": {
+ "post": {
+ "tags": [
+ "agent"
+ ],
+ "summary": "The operator set chain and the grant of a join token, for the node to verify before it enrolls",
+ "operationId": "fleet_enroll_trust",
+ "responses": {
+ "200": {
+ "description": "OK"
+ }
+ }
+ }
}
},
"components": {
@@ -4426,6 +5255,12 @@
"ipv6_host_auto": {
"type": "boolean",
"default": true
+ },
+ "added_by": {
+ "type": [
+ "null",
+ "string"
+ ]
}
}
},
@@ -4512,9 +5347,10 @@
"required": [
"id",
"name",
- "scope",
+ "capabilities",
"created_at",
"last_used_at",
+ "expires_at",
"revoked_at"
],
"type": "object",
@@ -4525,8 +5361,11 @@
"name": {
"type": "string"
},
- "scope": {
- "type": "string"
+ "capabilities": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
},
"created_at": {
"format": "date-time"
@@ -4534,11 +5373,71 @@
"last_used_at": {
"format": "date-time"
},
+ "expires_at": {
+ "format": "date-time"
+ },
"revoked_at": {
"format": "date-time"
}
}
},
+ "AuditDto": {
+ "required": [
+ "id",
+ "at",
+ "actor",
+ "method",
+ "action",
+ "target",
+ "detail",
+ "remote",
+ "success"
+ ],
+ "type": "object",
+ "properties": {
+ "id": {
+ "pattern": "^-?(?:0|[1-9]\\d*)$",
+ "type": [
+ "integer",
+ "string"
+ ],
+ "format": "int64"
+ },
+ "at": {
+ "format": "date-time"
+ },
+ "actor": {
+ "type": "string"
+ },
+ "method": {
+ "type": "string"
+ },
+ "action": {
+ "type": "string"
+ },
+ "target": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "detail": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "remote": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "success": {
+ "type": "boolean"
+ }
+ }
+ },
"BackendUpdateDto": {
"required": [
"channel",
@@ -4680,6 +5579,89 @@
}
}
},
+ "CapabilityCatalogDto": {
+ "required": [
+ "groups",
+ "presets"
+ ],
+ "type": "object",
+ "properties": {
+ "groups": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/CapabilityGroupDto"
+ }
+ },
+ "presets": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/CapabilityPresetDto"
+ }
+ }
+ }
+ },
+ "CapabilityDto": {
+ "required": [
+ "id",
+ "trust",
+ "reserved",
+ "implies"
+ ],
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "trust": {
+ "type": "boolean"
+ },
+ "reserved": {
+ "type": "boolean"
+ },
+ "implies": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "CapabilityGroupDto": {
+ "required": [
+ "id",
+ "capabilities"
+ ],
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "capabilities": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/CapabilityDto"
+ }
+ }
+ }
+ },
+ "CapabilityPresetDto": {
+ "required": [
+ "id",
+ "capabilities"
+ ],
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "capabilities": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ },
"ChangePasswordRequest": {
"required": [
"password"
@@ -4845,15 +5827,27 @@
"CreateApiTokenRequest": {
"required": [
"name",
- "scope"
+ "capabilities"
],
"type": "object",
"properties": {
"name": {
"type": "string"
},
- "scope": {
- "type": "string"
+ "capabilities": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "expires_in_days": {
+ "pattern": "^-?(?:0|[1-9]\\d*)$",
+ "type": [
+ "null",
+ "integer",
+ "string"
+ ],
+ "format": "int32"
}
}
},
@@ -4911,6 +5905,15 @@
"null",
"string"
]
+ },
+ "controllers": {
+ "type": [
+ "null",
+ "array"
+ ],
+ "items": {
+ "type": "string"
+ }
}
}
},
@@ -5165,6 +6168,59 @@
}
}
},
+ "ElevationAssertionRequest": {
+ "required": [
+ "class",
+ "credential_id",
+ "authenticator_data",
+ "client_data_json",
+ "signature"
+ ],
+ "type": "object",
+ "properties": {
+ "class": {
+ "type": "string"
+ },
+ "credential_id": {
+ "type": "string"
+ },
+ "authenticator_data": {
+ "type": "string"
+ },
+ "client_data_json": {
+ "type": "string"
+ },
+ "signature": {
+ "type": "string"
+ }
+ }
+ },
+ "ElevationDto": {
+ "required": [
+ "class",
+ "expires_at"
+ ],
+ "type": "object",
+ "properties": {
+ "class": {
+ "type": "string"
+ },
+ "expires_at": {
+ "format": "date-time"
+ }
+ }
+ },
+ "ElevationRequest": {
+ "required": [
+ "class"
+ ],
+ "type": "object",
+ "properties": {
+ "class": {
+ "type": "string"
+ }
+ }
+ },
"EventDto": {
"required": [
"id",
@@ -5716,6 +6772,12 @@
"role": {
"type": "string",
"default": "agent"
+ },
+ "created_by": {
+ "type": [
+ "null",
+ "string"
+ ]
}
}
},
@@ -5988,7 +7050,7 @@
"MeDto": {
"required": [
"kind",
- "scope",
+ "capabilities",
"user_id",
"username",
"csrf_token"
@@ -5998,8 +7060,11 @@
"kind": {
"type": "string"
},
- "scope": {
- "type": "string"
+ "capabilities": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
},
"user_id": {
"type": [
@@ -6018,6 +7083,12 @@
"null",
"string"
]
+ },
+ "auth_method": {
+ "type": [
+ "null",
+ "string"
+ ]
}
}
},
@@ -6479,6 +7550,48 @@
}
}
},
+ "PageDtoOfAuditDto": {
+ "required": [
+ "items",
+ "next_cursor"
+ ],
+ "type": "object",
+ "properties": {
+ "items": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/AuditDto"
+ }
+ },
+ "next_cursor": {
+ "type": [
+ "null",
+ "string"
+ ]
+ }
+ }
+ },
+ "PageDtoOfElevationDto": {
+ "required": [
+ "items",
+ "next_cursor"
+ ],
+ "type": "object",
+ "properties": {
+ "items": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/ElevationDto"
+ }
+ },
+ "next_cursor": {
+ "type": [
+ "null",
+ "string"
+ ]
+ }
+ }
+ },
"PageDtoOfEventDto": {
"required": [
"items",
@@ -6626,6 +7739,27 @@
}
}
},
+ "PageDtoOfPasskeyDto": {
+ "required": [
+ "items",
+ "next_cursor"
+ ],
+ "type": "object",
+ "properties": {
+ "items": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/PasskeyDto"
+ }
+ },
+ "next_cursor": {
+ "type": [
+ "null",
+ "string"
+ ]
+ }
+ }
+ },
"PageDtoOfPlanDto": {
"required": [
"items",
@@ -6875,6 +8009,142 @@
}
}
},
+ "PasskeyCreationOptionsDto": {
+ "required": [
+ "challenge",
+ "rp_id",
+ "rp_name",
+ "user_id",
+ "user_name",
+ "exclude_credentials",
+ "timeout_ms"
+ ],
+ "type": "object",
+ "properties": {
+ "challenge": {
+ "type": "string"
+ },
+ "rp_id": {
+ "type": "string"
+ },
+ "rp_name": {
+ "type": "string"
+ },
+ "user_id": {
+ "type": "string"
+ },
+ "user_name": {
+ "type": "string"
+ },
+ "exclude_credentials": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "timeout_ms": {
+ "pattern": "^-?(?:0|[1-9]\\d*)$",
+ "type": [
+ "integer",
+ "string"
+ ],
+ "format": "int32"
+ }
+ }
+ },
+ "PasskeyDto": {
+ "required": [
+ "id",
+ "name",
+ "created_at",
+ "last_used_at",
+ "transports"
+ ],
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "created_at": {
+ "format": "date-time"
+ },
+ "last_used_at": {
+ "format": "date-time"
+ },
+ "transports": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "PasskeyLoginRequest": {
+ "required": [
+ "credential_id",
+ "authenticator_data",
+ "client_data_json",
+ "signature",
+ "return_session"
+ ],
+ "type": "object",
+ "properties": {
+ "credential_id": {
+ "type": "string"
+ },
+ "authenticator_data": {
+ "type": "string"
+ },
+ "client_data_json": {
+ "type": "string"
+ },
+ "signature": {
+ "type": "string"
+ },
+ "return_session": {
+ "type": [
+ "null",
+ "boolean"
+ ]
+ }
+ }
+ },
+ "PasskeyRequestOptionsDto": {
+ "required": [
+ "challenge",
+ "rp_id",
+ "timeout_ms"
+ ],
+ "type": "object",
+ "properties": {
+ "challenge": {
+ "type": "string"
+ },
+ "rp_id": {
+ "type": "string"
+ },
+ "timeout_ms": {
+ "pattern": "^-?(?:0|[1-9]\\d*)$",
+ "type": [
+ "integer",
+ "string"
+ ],
+ "format": "int32"
+ },
+ "allow_credentials": {
+ "type": [
+ "null",
+ "array"
+ ],
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ },
"PatchProbeRequest": {
"required": [
"name"
@@ -7082,6 +8352,12 @@
"$ref": "#/components/schemas/Ipv6DiagnosticDto"
}
]
+ },
+ "added_by": {
+ "type": [
+ "null",
+ "string"
+ ]
}
}
},
@@ -7287,6 +8563,39 @@
}
}
},
+ "RegisterPasskeyRequest": {
+ "required": [
+ "name",
+ "credential_id",
+ "attestation_object",
+ "client_data_json",
+ "transports"
+ ],
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "credential_id": {
+ "type": "string"
+ },
+ "attestation_object": {
+ "type": "string"
+ },
+ "client_data_json": {
+ "type": "string"
+ },
+ "transports": {
+ "type": [
+ "null",
+ "array"
+ ],
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ },
"RejectedDto": {
"required": [
"id",
@@ -7527,6 +8836,26 @@
}
}
},
+ "SignedStatementDto": {
+ "type": "object",
+ "properties": {
+ "statement": {
+ "type": "string"
+ },
+ "credential_id": {
+ "type": "string"
+ },
+ "authenticator_data": {
+ "type": "string"
+ },
+ "client_data_json": {
+ "type": "string"
+ },
+ "signature": {
+ "type": "string"
+ }
+ }
+ },
"StartTestRequestDto": {
"required": [
"plan",
@@ -7878,6 +9207,107 @@
}
}
},
+ "TrustProposalDto": {
+ "required": [
+ "statement",
+ "version",
+ "challenge"
+ ],
+ "type": "object",
+ "properties": {
+ "statement": {
+ "type": "string"
+ },
+ "version": {
+ "pattern": "^-?(?:0|[1-9]\\d*)$",
+ "type": [
+ "integer",
+ "string"
+ ],
+ "format": "int32"
+ },
+ "challenge": {
+ "type": "string"
+ }
+ }
+ },
+ "TrustStateDto": {
+ "required": [
+ "initialized",
+ "genesis",
+ "version",
+ "in_sync",
+ "proposal"
+ ],
+ "type": "object",
+ "properties": {
+ "initialized": {
+ "type": "boolean"
+ },
+ "genesis": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "version": {
+ "pattern": "^-?(?:0|[1-9]\\d*)$",
+ "type": [
+ "integer",
+ "string"
+ ],
+ "format": "int32"
+ },
+ "in_sync": {
+ "type": "boolean"
+ },
+ "proposal": {
+ "oneOf": [
+ {
+ "type": "null"
+ },
+ {
+ "$ref": "#/components/schemas/TrustProposalDto"
+ }
+ ]
+ },
+ "install_url": {
+ "type": "string",
+ "default": ""
+ },
+ "operators": {
+ "type": [
+ "null",
+ "array"
+ ],
+ "items": {
+ "type": "string"
+ }
+ },
+ "install_sh_sha256": {
+ "type": "string",
+ "default": ""
+ },
+ "install_ps1_sha256": {
+ "type": "string",
+ "default": ""
+ },
+ "install_matches_release": {
+ "type": [
+ "null",
+ "boolean"
+ ]
+ },
+ "server_key": {
+ "type": "string",
+ "default": ""
+ },
+ "server_key_listed": {
+ "type": "boolean",
+ "default": false
+ }
+ }
+ },
"UserDto": {
"required": [
"id",
@@ -7933,6 +9363,12 @@
{
"name": "admin-data"
},
+ {
+ "name": "admin-audit"
+ },
+ {
+ "name": "admin-trust"
+ },
{
"name": "admin-api-tokens"
},
diff --git a/backend/probe/src/MoShiro.Probe/Link/AgentDirectory.cs b/backend/probe/src/MoShiro.Probe/Link/AgentDirectory.cs
index 69beae9..69c68cc 100644
--- a/backend/probe/src/MoShiro.Probe/Link/AgentDirectory.cs
+++ b/backend/probe/src/MoShiro.Probe/Link/AgentDirectory.cs
@@ -16,11 +16,13 @@ public sealed class AgentDirectory
private readonly object _gate = new();
private readonly ConfigStore _store;
private readonly INodeLog _log;
+ private readonly TrustAnchor _trust;
private List _agents = [];
private long _version;
- public AgentDirectory(ConfigStore store, string configDirectory, INodeLog log)
+ public AgentDirectory(ConfigStore store, string configDirectory, INodeLog log, TrustAnchor? trust = null)
{
+ _trust = trust ?? TrustAnchor.None;
_store = store;
_log = log;
string cache = Path.Combine(configDirectory, FileName);
@@ -68,6 +70,7 @@ public sealed class AgentDirectory
public void Apply(ProbeAgentList result)
{
var next = new List();
+ _trust.Update(result.TrustChain);
foreach (AgentEndpoint agent in result.Agents)
{
if (!string.IsNullOrWhiteSpace(agent.Host))
diff --git a/backend/probe/src/MoShiro.Probe/ProbeApp.cs b/backend/probe/src/MoShiro.Probe/ProbeApp.cs
index 1dc5fca..a5cfe26 100644
--- a/backend/probe/src/MoShiro.Probe/ProbeApp.cs
+++ b/backend/probe/src/MoShiro.Probe/ProbeApp.cs
@@ -27,7 +27,8 @@ public sealed class ProbeApp : IDisposable
public ProbeApp(ProbeFile file, LoadedNodeIdentity identity, string configDirectory, TimeProvider time, INodeLog log, RestartController? restart = null)
{
_log = log;
- var directory = new AgentDirectory(new ConfigStore(configDirectory), configDirectory, log);
+ var trust = TrustAnchor.Load(file.Fleet.IdentityDir, log);
+ var directory = new AgentDirectory(new ConfigStore(configDirectory), configDirectory, log, trust);
_env = new ConnectionEnv(identity.PeerIdentity, identity.NodeId.Value, file.Limits, directory, new MonoClock(time), time, log);
directory.Changed += () => _changed.Release();
@@ -36,6 +37,7 @@ public sealed class ProbeApp : IDisposable
{
Server = new Uri(file.Fleet.Server),
Identity = identity.Identity,
+ ServerVerifier = trust,
NodeId = identity.NodeId,
NodeVersion = Version,
Capabilities = [MoShiroCapabilities.Base],
diff --git a/backend/shared/MoShiro.Protocol/MoShiroErrors.cs b/backend/shared/MoShiro.Protocol/MoShiroErrors.cs
index 91c7259..0cd40dc 100644
--- a/backend/shared/MoShiro.Protocol/MoShiroErrors.cs
+++ b/backend/shared/MoShiro.Protocol/MoShiroErrors.cs
@@ -11,6 +11,7 @@ public static class MoShiroErrors
public static readonly ErrorCode CsrfFailed = new("csrf_failed");
public static readonly ErrorCode Forbidden = new("forbidden");
public static readonly ErrorCode SitePrivate = new("site_private");
+ public static readonly ErrorCode ElevationRequired = new("elevation_required");
public static readonly ErrorCode NotFound = new("not_found");
public static readonly ErrorCode AgentNotFound = new("agent_not_found");
public static readonly ErrorCode ProbeNotFound = new("probe_not_found");
diff --git a/backend/shared/MoShiro.Protocol/Rpc/MoShiroRpc.cs b/backend/shared/MoShiro.Protocol/Rpc/MoShiroRpc.cs
index 9703560..43c7b34 100644
--- a/backend/shared/MoShiro.Protocol/Rpc/MoShiroRpc.cs
+++ b/backend/shared/MoShiro.Protocol/Rpc/MoShiroRpc.cs
@@ -164,6 +164,9 @@ public sealed class ProbeAgentList
[JsonPropertyName("agents")]
public List Agents { get; set; } = [];
+
+ [JsonPropertyName("trust_chain")]
+ public List? TrustChain { get; set; }
}
/// 注册元数据(MoShiro 定义,经 Fleet 的 meta 字段带上来)。
diff --git a/backend/shared/MoShiro.Protocol/Tasks/TaskSet.cs b/backend/shared/MoShiro.Protocol/Tasks/TaskSet.cs
index 88771f4..6094e66 100644
--- a/backend/shared/MoShiro.Protocol/Tasks/TaskSet.cs
+++ b/backend/shared/MoShiro.Protocol/Tasks/TaskSet.cs
@@ -56,6 +56,10 @@ public sealed class TaskSet
/// 允许连接到这个 Agent 的 Probe 公钥。
[JsonPropertyName("peers")]
public List Peers { get; set; } = [];
+
+ /// 操作员集合的链(passkey 签的,创世在前):节点从固定的创世指纹验过才采用。
+ [JsonPropertyName("trust_chain")]
+ public List? TrustChain { get; set; }
}
public static class TaskSchedule
diff --git a/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs
index 63ed0c3..8fe3026 100644
--- a/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs
+++ b/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs
@@ -1,4 +1,5 @@
using Fleet.Core;
+using Fleet.Identity;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
@@ -17,50 +18,67 @@ public static class AgentEndpoints
public static void Map(RouteGroupBuilder group)
{
group.MapGet("/agents", ListAsync)
- .Api("agents_list", "All agents including pending and disabled ones", AgentsTag).Returns>().ProducesProblems(401, 403);
+ .Api("agents_list", "All agents including pending and disabled ones", AgentsTag).Returns>().ProducesProblems(401, 403)
+ .Requires(Capability.FleetRead);
group.MapGet("/agents/{id}", GetAsync)
- .Api("agents_get", "One agent", AgentsTag).Returns().ProducesProblems(401, 403, 404);
+ .Api("agents_get", "One agent", AgentsTag).Returns().ProducesProblems(401, 403, 404)
+ .Requires(Capability.FleetRead);
group.MapPatch("/agents/{id}", PatchAsync)
.Accepts("application/merge-patch+json", "application/json")
.Api("agents_update", "Update name, labels, public host or visibility (merge-patch, If-Match supported)", AgentsTag)
- .Returns().ProducesProblems(400, 401, 403, 404, 409, 412);
+ .Returns().ProducesProblems(400, 401, 403, 404, 409, 412)
+ .Requires(Capability.FleetWrite);
group.MapPost("/agents/{id}/approve", (HttpContext http, AgentService agents, string id) => TransitionAsync(http, id, (nodeId, ct) => agents.ApproveAsync(nodeId, ct)))
- .Api("agents_approve", "Approve a pending agent", AgentsTag).Returns().ProducesProblems(401, 403, 404, 409);
+ .Api("agents_approve", "Approve a pending agent", AgentsTag).Returns().ProducesProblems(401, 403, 404, 409)
+ .Requires(Capability.FleetEnroll);
group.MapPost("/agents/{id}/disable", (HttpContext http, AgentService agents, string id) => TransitionAsync(http, id, (nodeId, ct) => agents.DisableAsync(nodeId, ct)))
- .Api("agents_disable", "Disable an agent and drop its connection", AgentsTag).Returns().ProducesProblems(401, 403, 404, 409);
+ .Api("agents_disable", "Disable an agent and drop its connection", AgentsTag).Returns().ProducesProblems(401, 403, 404, 409)
+ .Requires(Capability.FleetWrite);
group.MapPost("/agents/{id}/enable", (HttpContext http, AgentService agents, string id) => TransitionAsync(http, id, (nodeId, ct) => agents.EnableAsync(nodeId, ct)))
- .Api("agents_enable", "Enable a disabled agent", AgentsTag).Returns().ProducesProblems(401, 403, 404, 409);
+ .Api("agents_enable", "Enable a disabled agent", AgentsTag).Returns().ProducesProblems(401, 403, 404, 409)
+ .Requires(Capability.FleetWrite);
group.MapPost("/agents/{id}/takeover", (HttpContext http, AgentService agents, string id) => TransitionAsync(http, id, (nodeId, ct) => agents.TakeoverAsync(nodeId, ct)))
.Api("agents_takeover", "Replace the existing node of the same name with this pending one, keeping history", AgentsTag)
- .Returns().ProducesProblems(401, 403, 404);
+ .Returns().ProducesProblems(401, 403, 404)
+ .Requires(Capability.FleetEnroll);
group.MapDelete("/agents/{id}", DeleteAsync)
.Api("agents_delete", "Delete an agent; history expires with the retention policy", AgentsTag)
- .Returns