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/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 6a5cbe2..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, TrustAnchor.Load(config.Fleet.IdentityDir, log));
+ _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 72f9503..d63792c 100644
--- a/backend/agent/src/MoShiro.Agent/Tasks/TaskManager.cs
+++ b/backend/agent/src/MoShiro.Agent/Tasks/TaskManager.cs
@@ -1,6 +1,5 @@
using System.Text.Json;
using Fleet.Hosting;
-using Fleet.Identity;
using Fleet.Core;
using Fleet.Rpc;
using MoShiro.Measurement;
@@ -102,22 +101,14 @@ public sealed class TaskManager
_ = ct;
var result = new StateSyncResult { AppliedVersion = version };
- // 固定了操作员创世的节点:先验面板带来的操作员集合链(从固定的创世出发),再要求每个 Probe 带着操作员批准的证明。
- // 面板自己改名册、塞进自己的 Probe 公钥是过不了这一关的:它签不出操作员的委托。
+ // 固定了操作员创世的节点:面板带来的新版本操作员集合,从固定的创世验过才采用(名册本身由面板决定)
_trust.Update(set.TrustChain);
var peers = new Dictionary(StringComparer.Ordinal);
foreach (PeerSpec peer in set.Peers)
{
try
{
- byte[] key = Convert.FromBase64String(peer.PublicKey);
- if (_trust.VerifyPeer(peer.Proof, key, "probe") != TrustFailure.None)
- {
- result.Rejected.Add(new RejectedItem { Id = peer.ProbeId.Value, Reason = RejectReasons.PolicyDenied });
- continue;
- }
-
- peers[peer.ProbeId.Value] = key;
+ peers[peer.ProbeId.Value] = Convert.FromBase64String(peer.PublicKey);
}
catch (FormatException)
{
diff --git a/backend/fleet/src/Fleet.Core/TrustDtos.cs b/backend/fleet/src/Fleet.Core/TrustDtos.cs
index 05ce501..22d16e3 100644
--- a/backend/fleet/src/Fleet.Core/TrustDtos.cs
+++ b/backend/fleet/src/Fleet.Core/TrustDtos.cs
@@ -2,7 +2,7 @@ using System.Text.Json.Serialization;
namespace Fleet.Core;
-/// passkey 对一段语句签出的断言(字段都是 base64url)。信任链的格式和验证在 Fleet.Identity 的 OperatorTrust。
+/// passkey 对一段语句签出的断言(字段都是 base64url)。信任根的格式和验证在 Fleet.Identity 的 OperatorTrust。
public sealed class SignedStatementDto
{
[JsonPropertyName("statement")]
@@ -21,25 +21,9 @@ public sealed class SignedStatementDto
public string Signature { get; set; } = "";
}
-/// 一个成员带的证明:操作员签的委托,加加入码的私钥签的绑定。
-public sealed class MemberProofDto
-{
- [JsonPropertyName("grant")]
- public SignedStatementDto Grant { get; set; } = new();
-
- [JsonPropertyName("binding")]
- public string Binding { get; set; } = "";
-
- [JsonPropertyName("binding_signature")]
- public string BindingSignature { get; set; } = "";
-}
-
-/// 操作员集合的链(创世在前),可选带一个委托(注册前取的,节点验过之后才继续)。
+/// 操作员集合的链(创世在前):节点注册时取来,从固定的创世指纹验过才采用。
public sealed class TrustBundle
{
[JsonPropertyName("chain")]
public List Chain { get; set; } = [];
-
- [JsonPropertyName("grant")]
- public SignedStatementDto? Grant { get; set; }
}
diff --git a/backend/fleet/src/Fleet.Enrollment.Client/EnrollmentClient.cs b/backend/fleet/src/Fleet.Enrollment.Client/EnrollmentClient.cs
index bc81783..35cb796 100644
--- a/backend/fleet/src/Fleet.Enrollment.Client/EnrollmentClient.cs
+++ b/backend/fleet/src/Fleet.Enrollment.Client/EnrollmentClient.cs
@@ -23,7 +23,7 @@ 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,
- string? seed = null, string? genesis = null, string role = "agent")
+ string? genesis = null, IReadOnlyList? controllers = null)
{
string nodeFile = Path.Combine(identityDirectory, LoadedNodeIdentity.NodeFileName);
if (File.Exists(nodeFile))
@@ -41,25 +41,16 @@ public static class EnrollmentClient
tlsPublicKey = certificate.PublicKey.ExportSubjectPublicKeyInfo();
}
- // 固定了操作员的创世(--genesis)和码的种子(--seed):先取信任链,确认这个面板的操作员集合和委托确实是固定的创世签出来的,
- // 再用码的私钥签绑定。任何一步对不上都不注册,也不把自己的密钥交出去。
- Binding? binding = null;
- string? bindingStatement = null;
- string? bindingSignature = null;
+ // 安装命令里带了创世指纹(--genesis)和属主(--controllers):先取操作员集合链,从固定的创世验起,
+ // 属主必须是集合里的凭证;对不上就不注册,也不把自己的密钥交出去。通过之后固定到本地 trust.json,Backend 之后改不了。
TrustBundle? bundle = null;
- if (seed is not null || genesis is not null)
+ if (genesis is not null)
{
- Result<(TrustBundle Bundle, Binding Binding, string Statement, string Signature)> trusted =
- await EstablishTrustAsync(http, server, joinToken, name, role, seed, genesis, identity.PublicKeySpki, tlsPublicKey, time, ct);
- if (!trusted.TryGet(out (TrustBundle Bundle, Binding Binding, string Statement, string Signature) value, out FleetError? trustError))
+ Result trusted = await FetchTrustAsync(http, server, joinToken, genesis, controllers ?? [], ct);
+ if (!trusted.TryGet(out bundle, out FleetError? trustError))
{
return Result.Fail(trustError);
}
-
- bundle = value.Bundle;
- binding = value.Binding;
- bindingStatement = value.Statement;
- bindingSignature = value.Signature;
}
string pubkey = Convert.ToBase64String(identity.PublicKeySpki);
@@ -74,8 +65,6 @@ public static class EnrollmentClient
["proof"] = Convert.ToBase64String(identity.Sign(SignaturePurpose.Enroll, tokenHash, pubkey)),
["node_version"] = nodeVersion,
["meta"] = productMeta,
- ["binding"] = bindingStatement,
- ["binding_signature"] = bindingSignature,
};
using var content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json");
@@ -104,9 +93,9 @@ public static class EnrollmentClient
var store = new ConfigStore(identityDirectory);
store.Write(LoadedNodeIdentity.KeyFileName, identity.ExportPkcs8());
store.Write(LoadedNodeIdentity.TlsFileName, pfx);
- if (bundle is not null && binding is not null)
+ if (bundle is not null)
{
- TrustAnchor.Pin(identityDirectory, genesis!, bundle.Chain, new MemberProofDto { Grant = bundle.Grant!, Binding = bindingStatement!, BindingSignature = bindingSignature! });
+ TrustAnchor.Pin(identityDirectory, genesis!, bundle.Chain, controllers ?? []);
}
// node.json 最后写:它存在就表示注册完整完成
@@ -116,37 +105,21 @@ public static class EnrollmentClient
return Result.Ok(new EnrollmentResult(nodeId, result["status"]!.GetValue(), identityDirectory, bundle is not null));
}
- private static async Task> EstablishTrustAsync(
- HttpClient http, Uri server, string joinToken, string name, string role, string? seedText, string? genesis, byte[] nodeKey, byte[] peerKey, TimeProvider time, CancellationToken ct)
+ private static async Task> FetchTrustAsync(HttpClient http, Uri server, string joinToken, string genesis, IReadOnlyList controllers, CancellationToken ct)
{
- static Result<(TrustBundle, Binding, string, string)> Refuse(string reason) => Result<(TrustBundle, Binding, string, string)>.Fail(FleetErrors.ProofInvalid, "refusing to enroll: " + reason);
-
- if (seedText is null || genesis is null)
- {
- return Refuse("--seed and --genesis go together (the panel's install command has both)");
- }
-
- if (!Base64Url.TryDecode(seedText, out byte[] seed) || seed.Length != 32)
- {
- return Refuse("the code seed is malformed");
- }
-
- if (OperatorTrust.AuthToken(seed) != joinToken)
- {
- return Refuse("the join token is not derived from the code seed");
- }
+ static Result 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<(TrustBundle, Binding, string, string)>.Fail(FleetErrors.JoinTokenInvalid, $"{(int)response.StatusCode}: the panel would not give the trust chain");
+ 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?.Grant is null || bundle.Chain.Count == 0)
+ if (bundle is null || bundle.Chain.Count == 0)
{
- return Refuse("the panel has no operator trust set up, or this code carries no grant from the operator");
+ return Refuse("the panel has no operator trust root set up");
}
OperatorSet? current = OperatorTrust.VerifyChain(genesis, [.. bundle.Chain.Select(c => c.ToSigned())]);
@@ -155,26 +128,11 @@ public static class EnrollmentClient
return Refuse("the panel's operator set does not match the pinned genesis fingerprint (wrong panel, or the panel was tampered with)");
}
- Grant? grant = OperatorTrust.ParseGrant(bundle.Grant.Statement);
- long now = time.GetUtcNow().ToUnixTimeMilliseconds();
- if (grant is null || !OperatorTrust.VerifyOperator(bundle.Grant.ToSigned(), current))
+ if (controllers.Any(c => current.Keys.All(k => k.CredentialId != c)))
{
- return Refuse("the grant is not signed by your operator passkey");
+ return Refuse("an owner passkey in the install command is not in the operator set");
}
- if (grant.AuthHash != OperatorTrust.AuthHash(joinToken) || grant.Role != role || now < grant.NotBeforeMs || now > grant.NotAfterMs)
- {
- return Refuse("the grant is not for this code, this role, or this time");
- }
-
- using ECDsa? codeKey = OperatorTrust.CodeSigningKey(seed, grant.CodeKey);
- if (codeKey is null)
- {
- return Refuse("the code seed does not match the grant's code key");
- }
-
- var binding = new Binding(name, role, nodeKey, peerKey, grant.AuthHash, now);
- string signature = OperatorTrust.SignBinding(codeKey, binding, out string statement);
- return Result<(TrustBundle, Binding, string, string)>.Ok((bundle, binding, statement, signature));
+ return Result.Ok(bundle);
}
}
diff --git a/backend/fleet/src/Fleet.Hosting/TrustAnchor.cs b/backend/fleet/src/Fleet.Hosting/TrustAnchor.cs
index be5051c..b4cf216 100644
--- a/backend/fleet/src/Fleet.Hosting/TrustAnchor.cs
+++ b/backend/fleet/src/Fleet.Hosting/TrustAnchor.cs
@@ -5,7 +5,7 @@ using Fleet.Identity;
namespace Fleet.Hosting;
-/// trust.json:注册时固定的创世指纹、见过的最新操作员集合链,以及(如果有)本节点自己的证明。
+/// trust.json:安装时固定的创世指纹和属主,加见过的最新操作员集合链。
public sealed class TrustFile
{
[JsonPropertyName("genesis")]
@@ -14,19 +14,20 @@ public sealed class TrustFile
[JsonPropertyName("chain")]
public List Chain { get; set; } = [];
- [JsonPropertyName("proof")]
- public MemberProofDto? Proof { get; set; }
+ /// 属主:允许授权本节点 extension 会话(web ssh 等)的 passkey 凭证 id,安装时固定,必须是操作员集合里的凭证。
+ [JsonPropertyName("controllers")]
+ public List Controllers { get; set; } = [];
}
[JsonSerializable(typeof(TrustFile))]
public sealed partial class TrustFileContext : JsonSerializerContext;
///
-/// 节点的信任根:安装时固定下来的创世指纹,加验证过的操作员集合链。有它就是「强制」模式——
-/// 名册里的对端必须带操作员批准的证明才接纳(Backend 改不了,也签不出新的);没有 trust.json(旧节点、没有指纹的安装)就不强制。
-/// 链随期望状态下发,节点只接受从固定的创世出发、签名完整、版本更新的链。
+/// 节点的信任根:安装命令里带来的创世指纹和属主,在注册时固定到本地,之后 Backend 改不了。
+/// 操作员集合的新版本随期望状态下发,节点只接受从固定的创世出发、签名完整、版本更新的链。
+/// 属主只是被固定和保存:节点现在没有 extension,等做 extension 时由节点本地强制(见 docs/access.md 第 8 节)。
///
-public sealed class TrustAnchor
+public sealed class TrustAnchor : IServerVerifier
{
public const string FileName = "trust.json";
@@ -44,14 +45,68 @@ public sealed class TrustAnchor
_log = log;
}
- /// 不强制(没有固定的创世)。
+ /// 没有固定的创世(旧节点,或没有指纹的安装)。
public static TrustAnchor None { get; } = new(null, null, null, NullNodeLog.Instance);
- public bool Enforcing => _file is not null;
+ public bool Pinned => _file is not null;
public string? Genesis => _file?.Genesis;
- public MemberProofDto? OwnProof => _file?.Proof;
+ /// 当前生效的操作员集合版本;没有固定时为 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)
{
@@ -65,18 +120,18 @@ public sealed class TrustAnchor
OperatorSet? current = OperatorTrust.VerifyChain(file.Genesis, [.. file.Chain.Select(c => c.ToSigned())]);
if (current is null)
{
- // 固定的创世和存下来的链对不上:不能当成「不强制」放过,直接让节点起不来比悄悄失去保护好
+ // 固定的创世和存下来的链对不上:不能当成「没有固定」放过,直接让节点起不来比悄悄失去保护好
throw new InvalidDataException($"{path} does not verify against its pinned genesis; remove the node's identity and enroll again");
}
return new TrustAnchor(new ConfigStore(identityDirectory), file, current, log);
}
- /// 注册时写下:固定的创世、验证过的链、本节点的证明。
- public static void Pin(string identityDirectory, string genesis, IReadOnlyList chain, MemberProofDto proof) =>
- new ConfigStore(identityDirectory).Write(FileName, JsonSerializer.SerializeToUtf8Bytes(new TrustFile { Genesis = genesis, Chain = [.. chain], Proof = proof }, TrustFileContext.Default.TrustFile));
+ /// 注册时写下:固定的创世、验证过的链、属主。
+ 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)。
+ /// 期望状态里带来的链:从固定的创世验起,比现有的新才采用并保存。返回当前链的版本(没有固定时为 0)。
public int Update(IReadOnlyList? chain)
{
lock (_gate)
@@ -112,21 +167,4 @@ public sealed class TrustAnchor
return _current.Version;
}
}
-
- /// 不强制就放行;强制时对端必须带证明,且验证通过(密钥是用来认证它的那把)。
- public TrustFailure VerifyPeer(MemberProofDto? proof, ReadOnlySpan peerPublicKey, string role)
- {
- OperatorSet? current;
- lock (_gate)
- {
- if (_file is null)
- {
- return TrustFailure.None;
- }
-
- current = _current;
- }
-
- return proof is null || current is null ? TrustFailure.Malformed : OperatorTrust.VerifyPeer(current, proof.ToProof(), peerPublicKey, role);
- }
}
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
index 320196a..fa25752 100644
--- a/backend/fleet/src/Fleet.Identity/OperatorTrust.cs
+++ b/backend/fleet/src/Fleet.Identity/OperatorTrust.cs
@@ -15,45 +15,21 @@ public sealed record OperatorKey(string CredentialId, byte[] PublicKeySpki);
/// 操作员集合的一个版本。RpId 和 Origins 也在里面:节点验 WebAuthn 断言要用,而它们被操作员的签名覆盖,Backend 改不了。
/// 创世版本(1)由它自己的某把凭证签名,指纹()在安装时固定到节点;之后每个版本由上一版里的凭证签名。
///
-public sealed record OperatorSet(int Version, string Previous, string RpId, IReadOnlyList Origins, IReadOnlyList Keys);
-
-/// 委托:操作员授权了一个加入码。CodeKey 是这个码的公钥(SPKI),码的种子是对应的私钥,只在安装命令里。
-public sealed record Grant(string Role, string AuthHash, byte[] CodeKey, long NotBeforeMs, long NotAfterMs, int Uses);
-
-/// 节点绑定:用码的私钥签的,把节点的两把公钥和这个码连起来。AtMs 必须落在委托的有效期内。
-public sealed record Binding(string Name, string Role, byte[] NodePublicKey, byte[] PeerPublicKey, string AuthHash, long AtMs);
-
-/// 一个成员带的证明:委托(passkey 签)加绑定(码的私钥签)。
-public sealed record MemberProof(SignedStatement Grant, string BindingStatement, string BindingSignature);
-
-public enum TrustFailure
-{
- None,
- Malformed,
- BrokenChain,
- GrantNotSigned,
- GrantExpired,
- BindingInvalid,
- KeyMismatch,
- RoleMismatch,
-}
+/// 服务器密钥(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 节。
-/// 全是纯函数,只用 BCL,节点和 Backend 共用:节点用它确认「对端是我的操作员批准的」,Backend 用它拒绝没有合法绑定的注册。
-/// 语句是 key=value 的文本,第一行是带版本的标题;签名覆盖整段文本,规范化靠固定的字段顺序和严格的解析。
+/// 信任根:操作员(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 GrantHeader = "moshiro-grant-v1";
- public const string BindingHeader = "moshiro-binding-v1";
public const string Genesis = "0";
- private const string AuthLabel = "moshiro-join-auth-v1";
-
- // ───────────────────────── 编码 ─────────────────────────
-
public static string Encode(OperatorSet set)
{
var text = new StringBuilder();
@@ -67,21 +43,15 @@ public static class OperatorTrust
text.Append("\nkey=").Append(key.CredentialId).Append(':').Append(Convert.ToBase64String(key.PublicKeySpki));
}
+ foreach (byte[] server in set.Servers ?? [])
+ {
+ text.Append("\nserver=").Append(Convert.ToBase64String(server));
+ }
+
return text.ToString();
}
- public static string Encode(Grant grant) =>
- string.Join(
- '\n', GrantHeader, "role=" + grant.Role, "auth=" + grant.AuthHash, "key=" + Convert.ToBase64String(grant.CodeKey),
- "nb=" + grant.NotBeforeMs.ToString(CultureInfo.InvariantCulture), "na=" + grant.NotAfterMs.ToString(CultureInfo.InvariantCulture), "uses=" + grant.Uses.ToString(CultureInfo.InvariantCulture));
-
- public static string Encode(Binding binding) =>
- string.Join(
- '\n', BindingHeader, "name=" + binding.Name, "role=" + binding.Role, "pub=" + Convert.ToBase64String(binding.NodePublicKey),
- "peer=" + Convert.ToBase64String(binding.PeerPublicKey), "auth=" + binding.AuthHash, "at=" + binding.AtMs.ToString(CultureInfo.InvariantCulture));
-
- // ───────────────────────── 解析(严格:多一行、少一行、顺序不对都算坏) ─────────────────────────
-
+ /// 严格解析:多一行、少一行、顺序不对都算坏。
public static OperatorSet? ParseSet(string text)
{
string[] lines = text.Split('\n');
@@ -92,46 +62,26 @@ public static class OperatorTrust
}
var keys = new List();
+ var servers = new List();
foreach (string line in lines.Skip(5))
{
- if (!TryValue(line, "key", out string value) || value.IndexOf(':', StringComparison.Ordinal) is not (> 0 and int split) || !TryBase64(value[(split + 1)..], out byte[] spki))
+ if (servers.Count == 0 && TryValue(line, "key", out string value) && value.IndexOf(':', StringComparison.Ordinal) is > 0 and int split && TryBase64(value[(split + 1)..], out byte[] spki))
+ {
+ keys.Add(new OperatorKey(value[..split], spki));
+ }
+ else if (TryValue(line, "server", out string serverText) && TryBase64(serverText, out byte[] serverKey))
+ {
+ servers.Add(serverKey);
+ }
+ else
{
return null;
}
-
- keys.Add(new OperatorKey(value[..split], spki));
}
- return keys.Count == 0 || (number == 1) != (previous == Genesis) ? null : new OperatorSet(number, previous, rp, origins.Split(','), keys);
+ return keys.Count == 0 || (number == 1) != (previous == Genesis) ? null : new OperatorSet(number, previous, rp, origins.Split(','), keys, servers);
}
- public static Grant? ParseGrant(string text)
- {
- string[] lines = text.Split('\n');
- if (lines.Length != 7 || lines[0] != GrantHeader || !TryValue(lines[1], "role", out string role) || !TryValue(lines[2], "auth", out string auth) || !TryValue(lines[3], "key", out string key) || !TryBase64(key, out byte[] codeKey)
- || !TryLong(lines[4], "nb", out long notBefore) || !TryLong(lines[5], "na", out long notAfter) || !TryValue(lines[6], "uses", out string uses) || !int.TryParse(uses, NumberStyles.None, CultureInfo.InvariantCulture, out int count)
- || role.Length == 0 || auth.Length != 64)
- {
- return null;
- }
-
- return new Grant(role, auth, codeKey, notBefore, notAfter, count);
- }
-
- public static Binding? ParseBinding(string text)
- {
- string[] lines = text.Split('\n');
- if (lines.Length != 7 || lines[0] != BindingHeader || !TryValue(lines[1], "name", out string name) || !TryValue(lines[2], "role", out string role) || !TryValue(lines[3], "pub", out string pub) || !TryBase64(pub, out byte[] node)
- || !TryValue(lines[4], "peer", out string peer) || !TryBase64(peer, out byte[] peerKey) || !TryValue(lines[5], "auth", out string auth) || !TryLong(lines[6], "at", out long at) || auth.Length != 64)
- {
- return null;
- }
-
- return new Binding(name, role, node, peerKey, auth, at);
- }
-
- // ───────────────────────── 指纹与验证 ─────────────────────────
-
/// 一段语句的指纹(SHA-256,小写十六进制)。创世版本的指纹就是安装命令里固定的那个。
public static string Fingerprint(string statement) => Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(statement)));
@@ -187,111 +137,6 @@ public static class OperatorTrust
return WebAuthnAssertion.Verify(key.PublicKeySpki, authData, clientData, signature, set.RpId, SHA256.HashData(Encoding.UTF8.GetBytes(signed.Statement)), set.Origins.Contains, requireUserVerification: true, out _);
}
- ///
- /// 验证一个对端:和 一样,只是调用方手里只有它用来认证这个对端的那把密钥(对等面公钥),
- /// 所以只核对绑定里的对等面公钥;节点身份公钥由 Backend 在注册时核对。
- ///
- public static TrustFailure VerifyPeer(OperatorSet current, MemberProof proof, ReadOnlySpan peerPublicKey, string role)
- {
- Binding? binding = ParseBinding(proof.BindingStatement);
- return binding is null ? TrustFailure.Malformed : VerifyMember(current, proof, binding.NodePublicKey, peerPublicKey, role);
- }
-
- ///
- /// 验证一个成员:委托由当前操作员集合里的凭证签(并且在有效期内),绑定由委托里的码公钥签,
- /// 绑定里的节点公钥、对等面公钥和角色对得上调用方手里的这个成员。
- ///
- public static TrustFailure VerifyMember(OperatorSet current, MemberProof proof, ReadOnlySpan nodePublicKey, ReadOnlySpan peerPublicKey, string role)
- {
- Grant? grant = ParseGrant(proof.Grant.Statement);
- Binding? binding = ParseBinding(proof.BindingStatement);
- if (grant is null || binding is null || !TryDecode(proof.BindingSignature, out byte[] bindingSignature))
- {
- return TrustFailure.Malformed;
- }
-
- if (!VerifyOperator(proof.Grant, current))
- {
- return TrustFailure.GrantNotSigned;
- }
-
- if (binding.AtMs < grant.NotBeforeMs || binding.AtMs > grant.NotAfterMs)
- {
- return TrustFailure.GrantExpired;
- }
-
- if (binding.AuthHash != grant.AuthHash || binding.Role != grant.Role)
- {
- return TrustFailure.KeyMismatch;
- }
-
- if (!binding.NodePublicKey.AsSpan().SequenceEqual(nodePublicKey) || !binding.PeerPublicKey.AsSpan().SequenceEqual(peerPublicKey))
- {
- return TrustFailure.KeyMismatch;
- }
-
- if (grant.Role != role)
- {
- return TrustFailure.RoleMismatch;
- }
-
- try
- {
- using ECDsa codeKey = ECDsa.Create();
- codeKey.ImportSubjectPublicKeyInfo(grant.CodeKey, out _);
- return codeKey.VerifyData(Encoding.UTF8.GetBytes(proof.BindingStatement), bindingSignature, HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence)
- ? TrustFailure.None
- : TrustFailure.BindingInvalid;
- }
- catch (CryptographicException)
- {
- return TrustFailure.BindingInvalid;
- }
- }
-
- // ───────────────────────── 加入码的种子 ─────────────────────────
-
- ///
- /// 种子(32 字节,就是码的 P-256 私钥)派生出交给 Backend 的认证串:Backend 只会看到它的哈希,推不回种子,
- /// 所以 Backend 没法替这个码签绑定。HKDF-SHA256,空盐,用途标签在 info 里;浏览器端的 WebCrypto 用同样的参数。
- ///
- public static string AuthToken(ReadOnlySpan seed) =>
- "ja_" + Base64Url.EncodeToString(Derive(seed));
-
- private static byte[] Derive(ReadOnlySpan seed)
- {
- byte[] output = new byte[32];
- HKDF.DeriveKey(HashAlgorithmName.SHA256, seed, output, ReadOnlySpan.Empty, Encoding.UTF8.GetBytes(AuthLabel));
- return output;
- }
-
- public static string AuthHash(string authToken) => Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(authToken)));
-
- /// 用种子和委托里的码公钥还原码的签名密钥(公钥从已验证的委托里来,种子里只有私钥标量)。
- public static ECDsa? CodeSigningKey(ReadOnlySpan seed, ReadOnlySpan codeKeySpki)
- {
- try
- {
- using ECDsa publicOnly = ECDsa.Create();
- publicOnly.ImportSubjectPublicKeyInfo(codeKeySpki, out _);
- ECParameters parameters = publicOnly.ExportParameters(false);
- parameters.D = seed.ToArray();
- return ECDsa.Create(parameters);
- }
- catch (CryptographicException)
- {
- return null;
- }
- }
-
- public static string SignBinding(ECDsa codeKey, Binding binding, out string statement)
- {
- statement = Encode(binding);
- return Base64Url.EncodeToString(codeKey.SignData(Encoding.UTF8.GetBytes(statement), HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence));
- }
-
- // ───────────────────────── 小工具 ─────────────────────────
-
private static bool TryValue(string line, string key, out string value)
{
value = "";
@@ -304,12 +149,6 @@ public static class OperatorTrust
return true;
}
- private static bool TryLong(string line, string key, out long value)
- {
- value = 0;
- return TryValue(line, key, out string text) && long.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out value);
- }
-
private static bool TryBase64(string text, out byte[] data)
{
try
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
index 59a4843..2672fa6 100644
--- a/backend/fleet/src/Fleet.Identity/TrustMapping.cs
+++ b/backend/fleet/src/Fleet.Identity/TrustMapping.cs
@@ -8,8 +8,4 @@ public static class TrustMapping
public static SignedStatementDto ToDto(this SignedStatement signed) =>
new() { Statement = signed.Statement, CredentialId = signed.CredentialId, AuthenticatorData = signed.AuthenticatorData, ClientDataJson = signed.ClientDataJson, Signature = signed.Signature };
-
- public static MemberProof ToProof(this MemberProofDto dto) => new(dto.Grant.ToSigned(), dto.Binding, dto.BindingSignature);
-
- public static MemberProofDto ToDto(this MemberProof proof) => new() { Grant = proof.Grant.ToDto(), Binding = proof.BindingStatement, BindingSignature = proof.BindingSignature };
}
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 7f88641..d5db797 100644
--- a/backend/fleet/src/Fleet.Server.Abstractions/NodeRecords.cs
+++ b/backend/fleet/src/Fleet.Server.Abstractions/NodeRecords.cs
@@ -34,8 +34,7 @@ public sealed record NewNode(
long NowMs,
string Kind = "",
byte[]? TlsPublicKey = null,
- string? AddedBy = null,
- MemberProofDto? Proof = null);
+ string? AddedBy = null);
public sealed record JoinTokenRecord(
string Id,
@@ -49,7 +48,6 @@ public sealed record JoinTokenRecord(
long? RevokedAtMs,
long CreatedAtMs,
string Kind = "",
- string? CreatedBy = null,
- SignedStatementDto? Grant = null);
+ string? CreatedBy = null);
public sealed record Presence(bool Online, long AtMs, string? NodeVersion, IReadOnlyList Capabilities, string? HelloMetaJson);
diff --git a/backend/fleet/src/Fleet.Server.Abstractions/ServerContracts.cs b/backend/fleet/src/Fleet.Server.Abstractions/ServerContracts.cs
index 66a50b0..bb8e4be 100644
--- a/backend/fleet/src/Fleet.Server.Abstractions/ServerContracts.cs
+++ b/backend/fleet/src/Fleet.Server.Abstractions/ServerContracts.cs
@@ -6,6 +6,18 @@ 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
{
diff --git a/backend/fleet/src/Fleet.Server.Enrollment/EnrollmentModels.cs b/backend/fleet/src/Fleet.Server.Enrollment/EnrollmentModels.cs
index bfa2dec..124bb39 100644
--- a/backend/fleet/src/Fleet.Server.Enrollment/EnrollmentModels.cs
+++ b/backend/fleet/src/Fleet.Server.Enrollment/EnrollmentModels.cs
@@ -33,13 +33,6 @@ public sealed class EnrollRequest
[JsonPropertyName("node_version")]
public string NodeVersion { get; set; } = "";
- /// 带操作员委托的加入码要附上:用码的私钥签的绑定语句(节点的两把公钥和名字)和它的签名。
- [JsonPropertyName("binding")]
- public string? Binding { get; set; }
-
- [JsonPropertyName("binding_signature")]
- public string? BindingSignature { get; set; }
-
[JsonPropertyName("meta")]
public JsonElement? Meta { get; set; }
}
diff --git a/backend/fleet/src/Fleet.Server.Enrollment/FleetEnrollmentService.cs b/backend/fleet/src/Fleet.Server.Enrollment/FleetEnrollmentService.cs
index 346cbe1..8fe2e65 100644
--- a/backend/fleet/src/Fleet.Server.Enrollment/FleetEnrollmentService.cs
+++ b/backend/fleet/src/Fleet.Server.Enrollment/FleetEnrollmentService.cs
@@ -61,17 +61,6 @@ public sealed partial class FleetEnrollmentService
return Result.Fail(FleetErrors.ProofInvalid, "proof of possession does not verify");
}
- // 带操作员委托的码:节点必须附上用码的私钥签的绑定,委托、绑定和这次注册的密钥、名字、角色都对得上才收
- MemberProofDto? memberProof = null;
- if (token.Grant is not null)
- {
- Result verified = await VerifyBindingAsync(token, request, publicKey, tlsPublicKey, ct);
- if (!verified.TryGet(out memberProof, out FleetError? bindingError))
- {
- return Result.Fail(bindingError);
- }
- }
-
if (!await _store.TryConsumeJoinTokenAsync(token.Id, nowMs, ct))
{
return Result.Fail(FleetErrors.JoinTokenInvalid, "join token is invalid");
@@ -94,8 +83,7 @@ public sealed partial class FleetEnrollmentService
nowMs,
token.Kind,
tlsPublicKey,
- token.CreatedBy,
- memberProof);
+ token.CreatedBy);
Result created = await _store.CreateNodeAsync(node, ct);
if (!created.TryGet(out NodeRecord? record, out FleetError? error))
@@ -116,7 +104,7 @@ public sealed partial class FleetEnrollmentService
});
}
- /// 节点注册之前取:操作员集合的链,和这个码的委托(如果有)。码要是有效的;不能用的码不给。
+ /// 节点注册之前取:操作员集合的链。码要是有效的;不能用的码不给。
public async ValueTask> GetTrustAsync(string joinToken, CancellationToken ct)
{
JoinTokenRecord? token = await _store.FindJoinTokenAsync(TokenHash.Sha256(joinToken), ct);
@@ -131,7 +119,7 @@ public sealed partial class FleetEnrollmentService
return Result.Fail(FleetErrors.JoinTokenExpired, "join token has expired");
}
- var bundle = new TrustBundle { Grant = token.Grant };
+ var bundle = new TrustBundle();
foreach (IOperatorTrustSource source in _trust)
{
bundle.Chain.AddRange(await source.ChainAsync(ct));
@@ -140,26 +128,6 @@ public sealed partial class FleetEnrollmentService
return Result.Ok(bundle);
}
- private async ValueTask> VerifyBindingAsync(JoinTokenRecord token, EnrollRequest request, byte[] publicKey, byte[] tlsPublicKey, CancellationToken ct)
- {
- IReadOnlyList chain = _trust.Any() ? await _trust.First().ChainAsync(ct) : [];
- OperatorSet? current = chain.Count == 0 ? null : OperatorTrust.VerifyChain(OperatorTrust.Fingerprint(chain[0].Statement), [.. chain.Select(c => c.ToSigned())]);
- if (current is null || string.IsNullOrEmpty(request.Binding) || string.IsNullOrEmpty(request.BindingSignature))
- {
- return Result.Fail(FleetErrors.ProofInvalid, "this join code carries an operator grant: the enrollment needs a binding signed with the code key");
- }
-
- string role = token.Kind.Length == 0 ? "agent" : token.Kind;
- var proof = new MemberProofDto { Grant = token.Grant!, Binding = request.Binding, BindingSignature = request.BindingSignature };
- TrustFailure failure = OperatorTrust.VerifyMember(current, proof.ToProof(), publicKey, tlsPublicKey, role);
- if (failure != TrustFailure.None || OperatorTrust.ParseBinding(request.Binding)?.Name != request.Name)
- {
- return Result.Fail(FleetErrors.ProofInvalid, "the binding does not verify against the operator's grant (" + failure + ")");
- }
-
- return Result.Ok(proof);
- }
-
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
index ae42f15..2f02e8c 100644
--- a/backend/fleet/tests/Fleet.Tests/OperatorTrustTests.cs
+++ b/backend/fleet/tests/Fleet.Tests/OperatorTrustTests.cs
@@ -111,85 +111,13 @@ public sealed class OperatorTrustTests : IDisposable
Assert.False(OperatorTrust.VerifyOperator(_bob.Sign("hello"), set)); // 不在集合里
}
- private sealed record Enrolled(OperatorSet Set, byte[] Seed, MemberProof Proof, byte[] NodeKey, byte[] PeerKey, string Auth);
-
- private Enrolled Enroll(string role = "probe", long now = 1_000_000, long notBefore = 0, long notAfter = 2_000_000, TestAuthenticator? signer = null)
- {
- OperatorSet set = Genesis(_alice);
- using ECDsa code = ECDsa.Create(ECCurve.NamedCurves.nistP256);
- byte[] seed = code.ExportParameters(true).D!;
- string auth = OperatorTrust.AuthToken(seed);
- var grant = new Grant(role, OperatorTrust.AuthHash(auth), code.ExportSubjectPublicKeyInfo(), notBefore, notAfter, 1);
- SignedStatement signedGrant = (signer ?? _alice).Sign(OperatorTrust.Encode(grant));
-
- byte[] nodeKey = RandomNumberGenerator.GetBytes(91);
- byte[] peerKey = RandomNumberGenerator.GetBytes(91);
- using ECDsa? signing = OperatorTrust.CodeSigningKey(seed, grant.CodeKey);
- Assert.NotNull(signing);
- string signature = OperatorTrust.SignBinding(signing, new Binding("tokyo", role, nodeKey, peerKey, grant.AuthHash, now), out string statement);
- return new Enrolled(set, seed, new MemberProof(signedGrant, statement, signature), nodeKey, peerKey, auth);
- }
-
- [Fact]
- public void AMember_VerifiesThroughTheWholeChain()
- {
- Enrolled e = Enroll();
- Assert.Equal(TrustFailure.None, OperatorTrust.VerifyMember(e.Set, e.Proof, e.NodeKey, e.PeerKey, "probe"));
- }
-
- [Fact]
- public void TheSeed_IsNotRecoverableFromWhatTheBackendSees()
- {
- Enrolled e = Enroll();
- Assert.StartsWith("ja_", e.Auth, StringComparison.Ordinal);
- Assert.DoesNotContain(Base64Url.EncodeToString(e.Seed), e.Auth, StringComparison.Ordinal);
- Assert.DoesNotContain(Base64Url.EncodeToString(e.Seed), e.Proof.Grant.Statement + e.Proof.BindingStatement, StringComparison.Ordinal);
- Assert.Equal(e.Auth, OperatorTrust.AuthToken(e.Seed)); // 节点自己能算出同一个
- }
-
- [Fact]
- public void ABindingForOtherKeys_AnotherRole_OrAnExpiredGrant_IsRefused()
- {
- Enrolled e = Enroll();
- Assert.Equal(TrustFailure.KeyMismatch, OperatorTrust.VerifyMember(e.Set, e.Proof, RandomNumberGenerator.GetBytes(91), e.PeerKey, "probe")); // Backend 想把别的节点密钥挂在这个绑定上
- Assert.Equal(TrustFailure.KeyMismatch, OperatorTrust.VerifyMember(e.Set, e.Proof, e.NodeKey, RandomNumberGenerator.GetBytes(91), "probe"));
- Assert.Equal(TrustFailure.RoleMismatch, OperatorTrust.VerifyMember(e.Set, e.Proof, e.NodeKey, e.PeerKey, "agent")); // 一个 Probe 的绑定不能冒充 Agent
-
- Enrolled late = Enroll(now: 3_000_000, notAfter: 2_000_000); // 有效期之后才签的绑定
- Assert.Equal(TrustFailure.GrantExpired, OperatorTrust.VerifyMember(late.Set, late.Proof, late.NodeKey, late.PeerKey, "probe"));
- }
-
- [Fact]
- public void AGrantNotSignedByTheOperator_OrABindingNotSignedByTheCodeKey_IsRefused()
- {
- using var stranger = new TestAuthenticator();
- Enrolled forged = Enroll(signer: stranger);
- Assert.Equal(TrustFailure.GrantNotSigned, OperatorTrust.VerifyMember(forged.Set, forged.Proof, forged.NodeKey, forged.PeerKey, "probe"));
-
- // Backend 拿到了委托但没有种子:用自己的密钥签绑定
- Enrolled e = Enroll();
- using ECDsa backend = ECDsa.Create(ECCurve.NamedCurves.nistP256);
- Binding stolen = OperatorTrust.ParseBinding(e.Proof.BindingStatement)!;
- string bogus = OperatorTrust.SignBinding(backend, stolen, out string statement);
- Assert.Equal(TrustFailure.BindingInvalid, OperatorTrust.VerifyMember(e.Set, new MemberProof(e.Proof.Grant, statement, bogus), e.NodeKey, e.PeerKey, "probe"));
- }
-
- [Fact]
- public void RemovingAnOperatorKey_InvalidatesTheGrantsItSigned()
- {
- Enrolled e = Enroll();
- OperatorSet withoutAlice = Genesis(_bob) with { Version = 2, Previous = new string('a', 64) };
- Assert.Equal(TrustFailure.GrantNotSigned, OperatorTrust.VerifyMember(withoutAlice, e.Proof, e.NodeKey, e.PeerKey, "probe"));
- }
-
[Fact]
public void Parsing_IsStrict()
{
- Assert.Null(OperatorTrust.ParseGrant("moshiro-grant-v1\nrole=probe"));
- Assert.Null(OperatorTrust.ParseBinding("moshiro-binding-v1\nname=x"));
Assert.Null(OperatorTrust.ParseSet("moshiro-opset-v2\nversion=1"));
OperatorSet set = Genesis(_alice);
Assert.Equal(set.Keys[0].CredentialId, OperatorTrust.ParseSet(OperatorTrust.Encode(set))!.Keys[0].CredentialId);
Assert.Null(OperatorTrust.ParseSet(OperatorTrust.Encode(set with { Previous = "abc" }))); // 创世的 prev 必须是 0
+ Assert.Null(OperatorTrust.ParseSet(OperatorTrust.Encode(set) + "\nnot-a-key"));
}
}
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 c2610d2..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 ] [--role agent|probe --seed --genesis ]");
+ Console.Error.WriteLine("usage: fleet-enroll --server --join --name --identity-dir [--meta ] [--version ] [--genesis --controllers ]");
return 2;
}
@@ -30,13 +30,12 @@ if (Arg("--meta") is { } metaText)
}
}
-string? seed = Arg("--seed");
string? genesis = Arg("--genesis");
-string role = Arg("--role") ?? "agent";
+string[] controllers = (Arg("--controllers") ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
Result result = await EnrollmentClient.EnrollAsync(
- http, new Uri(server), join, name, directory, Arg("--version") ?? "0.0.0", meta, TimeProvider.System, CancellationToken.None, seed, genesis, role);
+ http, new Uri(server), join, name, directory, Arg("--version") ?? "0.0.0", meta, TimeProvider.System, CancellationToken.None, genesis, controllers);
if (!result.TryGet(out EnrollmentResult? enrolled, out FleetError? error))
{
Console.Error.WriteLine($"enrollment failed ({error.Code}): {error.Message}");
diff --git a/backend/openapi/v1.json b/backend/openapi/v1.json
index c766cf4..30ce08f 100644
--- a/backend/openapi/v1.json
+++ b/backend/openapi/v1.json
@@ -3999,78 +3999,6 @@
"x-moshiro-capability": "accounts.manage"
}
},
- "/api/admin/join-tokens/signed": {
- "post": {
- "tags": [
- "admin-trust"
- ],
- "summary": "Create a join code from a grant the operator signed with a passkey; the browser derives the code from a seed the panel never sees",
- "operationId": "join_tokens_create_signed",
- "requestBody": {
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/CreateSignedJoinTokenRequest"
- }
- }
- },
- "required": true
- },
- "responses": {
- "201": {
- "description": "Created",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/JoinTokenDto"
- }
- }
- }
- },
- "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": "fleet.enroll"
- }
- },
"/api/admin/api-tokens": {
"get": {
"tags": [
@@ -5003,30 +4931,11 @@
"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",
- "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"
- }
- }
- }
}
}
}
@@ -5102,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"
- }
- }
- }
}
}
}
@@ -6015,28 +5905,8 @@
"null",
"string"
]
- }
- }
- },
- "CreateSignedJoinTokenRequest": {
- "required": [
- "grant",
- "note",
- "labels",
- "auto_approve"
- ],
- "type": "object",
- "properties": {
- "grant": {
- "$ref": "#/components/schemas/SignedStatementDto"
},
- "note": {
- "type": [
- "null",
- "string"
- ]
- },
- "labels": {
+ "controllers": {
"type": [
"null",
"array"
@@ -6044,12 +5914,6 @@
"items": {
"type": "string"
}
- },
- "auto_approve": {
- "type": [
- "null",
- "boolean"
- ]
}
}
},
@@ -6914,10 +6778,6 @@
"null",
"string"
]
- },
- "signed": {
- "type": "boolean",
- "default": false
}
}
},
@@ -7223,6 +7083,12 @@
"null",
"string"
]
+ },
+ "auth_method": {
+ "type": [
+ "null",
+ "string"
+ ]
}
}
},
@@ -9404,6 +9270,41 @@
"$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
}
}
},
diff --git a/backend/probe/src/MoShiro.Probe/Link/AgentDirectory.cs b/backend/probe/src/MoShiro.Probe/Link/AgentDirectory.cs
index 6872ccc..69c68cc 100644
--- a/backend/probe/src/MoShiro.Probe/Link/AgentDirectory.cs
+++ b/backend/probe/src/MoShiro.Probe/Link/AgentDirectory.cs
@@ -1,6 +1,5 @@
using System.Text.Json;
using Fleet.Core;
-using Fleet.Identity;
using Fleet.Hosting;
using MoShiro.Protocol;
@@ -67,20 +66,6 @@ public sealed class AgentDirectory
}
}
- private static bool TryKey(string text, out byte[] key)
- {
- try
- {
- key = Convert.FromBase64String(text);
- return key.Length > 0;
- }
- catch (FormatException)
- {
- key = [];
- return false;
- }
- }
-
/// 应用面板下发的列表。版本不比当前新的忽略(乱序到达的旧推送)。
public void Apply(ProbeAgentList result)
{
@@ -88,13 +73,6 @@ public sealed class AgentDirectory
_trust.Update(result.TrustChain);
foreach (AgentEndpoint agent in result.Agents)
{
- // 固定了操作员创世的 Probe 只连操作员批准过的 Agent(证明验不过的,面板塞进来的也不连)
- if (!TryKey(agent.PublicKey, out byte[] key) || _trust.VerifyPeer(agent.Proof, key, "agent") != TrustFailure.None)
- {
- _log.Warn("an agent from the panel carries no valid operator proof; not connecting", ("agent", agent.AgentId.Value));
- continue;
- }
-
if (!string.IsNullOrWhiteSpace(agent.Host))
{
next.Add(new KnownAgent { AgentId = agent.AgentId.Value, Host = agent.Host, TcpPort = agent.TcpPort, PublicKey = agent.PublicKey });
diff --git a/backend/probe/src/MoShiro.Probe/ProbeApp.cs b/backend/probe/src/MoShiro.Probe/ProbeApp.cs
index 00ba2b3..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, TrustAnchor.Load(file.Fleet.IdentityDir, 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/Rpc/MoShiroRpc.cs b/backend/shared/MoShiro.Protocol/Rpc/MoShiroRpc.cs
index b4c66c4..43c7b34 100644
--- a/backend/shared/MoShiro.Protocol/Rpc/MoShiroRpc.cs
+++ b/backend/shared/MoShiro.Protocol/Rpc/MoShiroRpc.cs
@@ -152,10 +152,6 @@ public sealed class AgentEndpoint
/// Agent 面向 Probe 的 TLS 证书公钥(SPKI,base64):证书按它的 SHA-256 指纹固定,敲门密钥由它和 Probe 的身份密钥派生。
[JsonPropertyName("public_key")]
public string PublicKey { get; set; } = "";
-
- /// 操作员批准这个 Agent 的证明;固定了创世的 Probe 没有它就不连。
- [JsonPropertyName("proof")]
- public MemberProofDto? Proof { get; set; }
}
/// 期望状态文档(Fleet state type = probe_agents):Backend 直接下发给每个 Probe 的 Agent 列表。
diff --git a/backend/shared/MoShiro.Protocol/Tasks/TaskSet.cs b/backend/shared/MoShiro.Protocol/Tasks/TaskSet.cs
index 9a41582..6094e66 100644
--- a/backend/shared/MoShiro.Protocol/Tasks/TaskSet.cs
+++ b/backend/shared/MoShiro.Protocol/Tasks/TaskSet.cs
@@ -40,10 +40,6 @@ public sealed class PeerSpec
/// Probe 的身份公钥(SPKI,base64)。
[JsonPropertyName("pubkey")]
public string PublicKey { get; set; } = "";
-
- /// 操作员批准这个 Probe 的证明(passkey 签的委托加码的私钥签的绑定)。固定了创世的 Agent 没有它就不接纳。
- [JsonPropertyName("proof")]
- public MemberProofDto? Proof { get; set; }
}
/// 期望状态文档(Fleet state type = task_set):Backend 为每个 Agent 计算的有效任务集。
diff --git a/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs
index f80e267..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;
@@ -129,12 +130,23 @@ public static class AgentEndpoints
private static async Task ListTokensAsync(JoinTokenService tokens, CancellationToken ct) =>
TypedResults.Json(new PageDto([.. (await tokens.ListAsync(ct)).Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoJoinTokenDto);
- private static async Task CreateTokenAsync(HttpContext http, JoinTokenService tokens, CreateJoinTokenRequest request, BackendOptions options)
+ private static async Task CreateTokenAsync(HttpContext http, JoinTokenService tokens, TrustService trust, CreateJoinTokenRequest request, BackendOptions options)
{
string baseUrl = options.PublicUrl ?? $"{http.Request.Scheme}://{http.Request.Host}";
+
+ // 信任根建立之后,命令里带上创世指纹和属主;属主默认是操作员集合里的全部 passkey,也可以只选其中几把
+ OperatorSet? set = await trust.CurrentAsync(http.RequestAborted);
+ string? genesis = set is null ? null : (await trust.StateAsync(AuthEndpoints.PasskeyContextOf(http, options), http.RequestAborted)).Genesis;
+ string[] controllers = set is null ? [] : [.. request.Controllers ?? [.. set.Keys.Select(k => k.CredentialId)]];
+ if (set is not null && controllers.Any(c => set.Keys.All(k => k.CredentialId != c)))
+ {
+ return Problems.From(http, ValidationError.Single("controllers", "must be passkeys of the operator set"));
+ }
+
+ var plan = new InstallPlan(genesis, controllers, InstallScripts.Sha256(InstallScripts.Sh), InstallScripts.Sha256(InstallScripts.Ps1));
http.SetDetail($"{request.Role ?? JoinTokenService.AgentRole}: {request.Note}");
Result result = await tokens.CreateAsync(
- new Application.CreateJoinTokenRequest(request.Note, request.Labels, request.AutoApprove ?? true, request.MaxUses ?? 1, request.ExpiresInHours ?? 24, request.Role ?? JoinTokenService.AgentRole, http.GetPrincipal().Label), baseUrl, http.RequestAborted);
+ new Application.CreateJoinTokenRequest(request.Note, request.Labels, request.AutoApprove ?? true, request.MaxUses ?? 1, request.ExpiresInHours ?? 24, request.Role ?? JoinTokenService.AgentRole, http.GetPrincipal().Label), baseUrl, plan, http.RequestAborted);
if (!result.TryGet(out CreatedJoinToken? created, out FleetError? error))
{
return Problems.From(http, error);
diff --git a/backend/src/MoShiro.Backend.Api/Admin/AuthEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/AuthEndpoints.cs
index bbe039a..fb13212 100644
--- a/backend/src/MoShiro.Backend.Api/Admin/AuthEndpoints.cs
+++ b/backend/src/MoShiro.Backend.Api/Admin/AuthEndpoints.cs
@@ -320,7 +320,7 @@ public static class AuthEndpoints
{
Principal principal = http.GetPrincipal();
return TypedResults.Json(
- new MeDto(principal.Kind.ToString().ToLowerInvariant(), [.. principal.Capabilities.Order(StringComparer.Ordinal)], principal.UserId, principal.Username, principal.CsrfToken),
+ new MeDto(principal.Kind.ToString().ToLowerInvariant(), [.. principal.Capabilities.Order(StringComparer.Ordinal)], principal.UserId, principal.Username, principal.CsrfToken, principal.AuthMethod.Length > 0 ? principal.AuthMethod : null),
ApiJsonContext.Default.MeDto);
}
}
diff --git a/backend/src/MoShiro.Backend.Api/Admin/TrustEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/TrustEndpoints.cs
index a87dd03..1d8cdf1 100644
--- a/backend/src/MoShiro.Backend.Api/Admin/TrustEndpoints.cs
+++ b/backend/src/MoShiro.Backend.Api/Admin/TrustEndpoints.cs
@@ -26,18 +26,35 @@ public static class TrustEndpoints
.Returns(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409)
.Requires(Capability.AccountsManage)
.ElevationExempt();
- guarded.MapPost("/join-tokens/signed", CreateSignedTokenAsync)
- .Api("join_tokens_create_signed", "Create a join code from a grant the operator signed with a passkey; the browser derives the code from a seed the panel never sees", Tag)
- .Returns(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409)
- .Requires(Capability.FleetEnroll)
- .ElevationExempt();
}
- private static async Task StateAsync(HttpContext http, TrustService trust, BackendOptions options)
+ private static async Task StateAsync(HttpContext http, TrustService trust, ReleaseService releases, BackendOptions options)
{
TrustState state = await trust.StateAsync(AuthEndpoints.PasskeyContextOf(http, options), http.RequestAborted);
http.Response.Headers.CacheControl = "no-store";
- return TypedResults.Json(Mappers.ToDto(state), ApiJsonContext.Default.TrustStateDto);
+ string sh = InstallScripts.Sha256(InstallScripts.Sh);
+ string ps1 = InstallScripts.Sha256(InstallScripts.Ps1);
+ return TypedResults.Json(Mappers.ToDto(state, options.PublicUrl ?? $"{http.Request.Scheme}://{http.Request.Host}", sh, ps1, await MatchesReleaseAsync(releases, sh, ps1, http.RequestAborted)), ApiJsonContext.Default.TrustStateDto);
+ }
+
+ /// 面板提供的脚本和 CI 签名的发布清单里记的哈希是否一致(清单由 CI 签名,节点和浏览器之外的人改不了)。没有清单或没记哈希时返回 null。
+ private static async Task MatchesReleaseAsync(ReleaseService releases, string sh, string ps1, CancellationToken ct)
+ {
+ foreach (string component in new[] { JoinTokenService.AgentRole, JoinTokenService.ProbeRole })
+ {
+ if (await releases.GetAsync(component, ct) is not { } offer)
+ {
+ continue;
+ }
+
+ using System.Text.Json.JsonDocument document = System.Text.Json.JsonDocument.Parse(offer.Manifest);
+ if (document.RootElement.TryGetProperty("install_sh", out System.Text.Json.JsonElement recordedSh) && document.RootElement.TryGetProperty("install_ps1", out System.Text.Json.JsonElement recordedPs1))
+ {
+ return string.Equals(recordedSh.GetString(), sh, StringComparison.Ordinal) && string.Equals(recordedPs1.GetString(), ps1, StringComparison.Ordinal);
+ }
+ }
+
+ return null;
}
private static async Task CommitAsync(HttpContext http, SignedStatementDto signed, TrustService trust, AgentService agents, BackendOptions options)
@@ -59,17 +76,4 @@ public static class TrustEndpoints
http.SetDetail($"operator set v{state.Version}");
return TypedResults.Json(Mappers.ToDto(state), ApiJsonContext.Default.TrustStateDto, statusCode: StatusCodes.Status201Created);
}
-
- private static async Task CreateSignedTokenAsync(HttpContext http, CreateSignedJoinTokenRequest request, JoinTokenService tokens)
- {
- Result result = await tokens.CreateSignedAsync(request.Grant, request.Note, request.Labels, request.AutoApprove ?? true, http.GetPrincipal().Label, http.RequestAborted);
- if (!result.TryGet(out JoinTokenInfo? info, out FleetError? error))
- {
- return Problems.From(http, error);
- }
-
- http.SetDetail($"{JoinTokenService.RoleOf(info.Kind)}: {request.Note}");
- http.Response.Headers.CacheControl = "no-store";
- return TypedResults.Json(Mappers.ToDto(info), ApiJsonContext.Default.JoinTokenDto, statusCode: StatusCodes.Status201Created);
- }
}
diff --git a/backend/src/MoShiro.Backend.Api/Common/Mappers.cs b/backend/src/MoShiro.Backend.Api/Common/Mappers.cs
index c776cd9..b751f06 100644
--- a/backend/src/MoShiro.Backend.Api/Common/Mappers.cs
+++ b/backend/src/MoShiro.Backend.Api/Common/Mappers.cs
@@ -154,9 +154,9 @@ public static class Mappers
new(t.Id.Value, t.Scope, t.AgentId?.Value, t.BaseTaskId?.Value, t.Kind, t.Target, t.Params, t.Schedule, t.Enabled, Time(t.UpdatedAtMs));
public static JoinTokenDto ToDto(JoinTokenInfo t) =>
- new(t.Id, t.Note, t.Labels, t.AutoApprove, t.MaxUses, t.Uses, Time(t.ExpiresAtMs), Time(t.RevokedAtMs), Time(t.CreatedAtMs), JoinTokenService.RoleOf(t.Kind), t.CreatedBy, t.Grant is not null);
+ new(t.Id, t.Note, t.Labels, t.AutoApprove, t.MaxUses, t.Uses, Time(t.ExpiresAtMs), Time(t.RevokedAtMs), Time(t.CreatedAtMs), JoinTokenService.RoleOf(t.Kind), t.CreatedBy);
- public static TrustStateDto ToDto(TrustState s) => new(s.Initialized, s.Genesis, s.Version, s.InSync, s.Proposal is null ? null : new TrustProposalDto(s.Proposal.Statement, s.Proposal.Version, s.Proposal.Challenge));
+ public static TrustStateDto ToDto(TrustState s, string installUrl = "", string installSh = "", string installPs1 = "", bool? matches = null) => new(s.Initialized, s.Genesis, s.Version, s.InSync, s.Proposal is null ? null : new TrustProposalDto(s.Proposal.Statement, s.Proposal.Version, s.Proposal.Challenge), installUrl, s.Operators ?? [], installSh, installPs1, matches, s.ServerKey, s.ServerKeyListed);
public static ElevationDto ToDto(ElevationWindow w) => new(Elevation.IdOf(w.Class), Time(w.ExpiresAtMs));
diff --git a/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs b/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs
index cf66d62..0ee5bf1 100644
--- a/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs
+++ b/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs
@@ -28,7 +28,8 @@ public sealed record PasskeyDto(string Id, string Name, DateTimeOffset CreatedAt
public sealed record LoginResponse(string UserId, string Username, string CsrfToken, DateTimeOffset ExpiresAt, string? SessionToken);
-public sealed record MeDto(string Kind, IReadOnlyList Capabilities, string? UserId, string? Username, string? CsrfToken);
+/// 会话是怎么登录的:password(没有任何能力,只能注册 passkey)或 passkey;API 令牌为空。
+public sealed record MeDto(string Kind, IReadOnlyList Capabilities, string? UserId, string? Username, string? CsrfToken, string? AuthMethod = null);
public sealed record LocalLimitsDto(double MaxTotalMbps, long MaxTotalPps, IReadOnlyList AllowedTargetCidrs, int MaxConcurrentTests);
@@ -74,22 +75,23 @@ public sealed record Ipv6DiagnosticDto(bool? AddressConfigured, bool SocketSuppo
public sealed record AgentPatchRequest(string? Name, IReadOnlyList? Labels, string? PublicHost, bool? PublicVisible, string? Ipv6Host = null, bool? PublicHostAuto = null, bool? Ipv6HostAuto = null);
/// agent 或 probe:用这个令牌注册出来的是哪种节点。
-public sealed record JoinTokenDto(string Id, string? Note, IReadOnlyList Labels, bool AutoApprove, int MaxUses, int Uses, DateTimeOffset ExpiresAt, DateTimeOffset? RevokedAt, DateTimeOffset CreatedAt, string Role = "agent", string? CreatedBy = null, bool Signed = false);
+public sealed record JoinTokenDto(string Id, string? Note, IReadOnlyList Labels, bool AutoApprove, int MaxUses, int Uses, DateTimeOffset ExpiresAt, DateTimeOffset? RevokedAt, DateTimeOffset CreatedAt, string Role = "agent", string? CreatedBy = null);
-public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList? Labels, bool? AutoApprove, int? MaxUses, int? ExpiresInHours, string? Role = null);
+/// 属主:允许授权这个节点 extension 会话的 passkey 凭证 id(必须是操作员集合里的);缺省是集合里的全部。没有建立信任根时忽略。
+public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList? Labels, bool? AutoApprove, int? MaxUses, int? ExpiresInHours, string? Role = null, IReadOnlyList? Controllers = null);
/// 令牌明文只在创建的响应里出现一次。
public sealed record InstallCommandsDto(string Linux, string Macos, string Windows);
-/// 操作员用 passkey 对这个码签的委托(语句加断言)。
-public sealed record CreateSignedJoinTokenRequest(SignedStatementDto Grant, string? Note, IReadOnlyList? Labels, bool? AutoApprove);
-
public sealed record TrustProposalDto(string Statement, int Version, string Challenge);
/// 创世版本的指纹:安装命令里固定到节点上的就是它。还没建立信任根时为空。
/// 当前生效的集合和已注册的 passkey、域名一致;不一致(比如新加了 passkey)就要签一个新版本。
/// 下一个要签的版本(challenge 就是 WebAuthn 的挑战)。
-public sealed record TrustStateDto(bool Initialized, string? Genesis, int Version, bool InSync, TrustProposalDto? Proposal);
+/// 当前操作员集合里的 passkey 凭证 id:加入码的 controllers 只能从这里选。
+/// 面板提供的安装脚本的 SHA-256(命令里核对的就是它)。
+/// 和 CI 签名的发布清单里记的哈希是否一致;没有清单或清单里没记时为空。不一致说明这个面板提供的脚本不是 CI 构建出来的那份。
+public sealed record TrustStateDto(bool Initialized, string? Genesis, int Version, bool InSync, TrustProposalDto? Proposal, string InstallUrl = "", IReadOnlyList? Operators = null, string InstallShSha256 = "", string InstallPs1Sha256 = "", bool? InstallMatchesRelease = null, string ServerKey = "", bool ServerKeyListed = false);
public sealed record CreatedJoinTokenDto(JoinTokenDto Token, string Plaintext, InstallCommandsDto InstallCommands);
diff --git a/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs b/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs
index 536dd9a..82e8b9b 100644
--- a/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs
+++ b/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs
@@ -80,7 +80,6 @@ namespace MoShiro.Backend.Api;
[JsonSerializable(typeof(RegisterPasskeyRequest))]
[JsonSerializable(typeof(PasskeyLoginRequest))]
[JsonSerializable(typeof(Fleet.Core.SignedStatementDto), TypeInfoPropertyName = "FleetSignedStatement")]
-[JsonSerializable(typeof(CreateSignedJoinTokenRequest))]
[JsonSerializable(typeof(TrustStateDto))]
[JsonSerializable(typeof(ElevationRequest))]
[JsonSerializable(typeof(ElevationAssertionRequest))]
diff --git a/backend/src/MoShiro.Backend.Api/InstallScripts.cs b/backend/src/MoShiro.Backend.Api/InstallScripts.cs
index 1969b20..6417fbe 100644
--- a/backend/src/MoShiro.Backend.Api/InstallScripts.cs
+++ b/backend/src/MoShiro.Backend.Api/InstallScripts.cs
@@ -1,33 +1,32 @@
using System.Reflection;
-using MoShiro.Backend.Application;
+using System.Security.Cryptography;
+using System.Text;
namespace MoShiro.Backend.Api;
///
-/// 一行安装脚本:packaging/install/ 下的 install.sh 和 install.ps1 作为嵌入资源打进 Backend,
-/// 这里把角色、网关、代码和镜像填进去。Agent 和 Probe 共用同一份,不分开维护。
+/// 一行安装脚本:packaging/install/ 下的 install.sh 和 install.ps1 作为嵌入资源打进 Backend,原样返回,不替换任何值。
+/// 静态,所以它的 SHA-256 在一个版本内固定:CI 构建时算出来写进签名的发布清单,面板给的命令先核对哈希再执行(docs/access.md 第 15 节)。
+/// 角色、面板地址、代码都走命令里的环境变量。
///
public static class InstallScripts
{
- /// CI 的发布公钥(packaging/update/release-key.pub.pem):脚本用它验发布清单的签名。
- public static string ReleaseKey()
- {
- using Stream stream = typeof(InstallScripts).Assembly.GetManifestResourceStream("release-key.pub.pem")
- ?? throw new InvalidOperationException("the embedded release key is missing");
- using var reader = new StreamReader(stream);
- return reader.ReadToEnd();
- }
+ public const string Sh = "install.sh";
+ public const string Ps1 = "install.ps1";
- public static string Render(string resource, InstallTarget target)
+ /// CI 的发布公钥(packaging/update/release-key.pub.pem):脚本里内置的就是这一把,测试核对两边一致。
+ public static string ReleaseKey() => Read("release-key.pub.pem").Trim();
+
+ public static string Script(string resource) => Read(resource);
+
+ /// 脚本内容的 SHA-256(小写十六进制,按 UTF-8 字节)。
+ public static string Sha256(string resource) => Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(Read(resource))));
+
+ private static string Read(string resource)
{
using Stream stream = typeof(InstallScripts).Assembly.GetManifestResourceStream(resource)
?? throw new InvalidOperationException($"the embedded resource {resource} is missing");
using var reader = new StreamReader(stream);
- return reader.ReadToEnd()
- .Replace("@@RELEASE_KEY@@", ReleaseKey().Trim(), StringComparison.Ordinal)
- .Replace("@@ROLE@@", target.Role, StringComparison.Ordinal)
- .Replace("@@SERVER@@", target.Server, StringComparison.Ordinal)
- .Replace("@@CODE@@", target.Code, StringComparison.Ordinal)
- .Replace("@@IMAGE@@", target.Image, StringComparison.Ordinal);
+ return reader.ReadToEnd();
}
}
diff --git a/backend/src/MoShiro.Backend.Api/OpsEndpoints.cs b/backend/src/MoShiro.Backend.Api/OpsEndpoints.cs
index 801f51a..5799778 100644
--- a/backend/src/MoShiro.Backend.Api/OpsEndpoints.cs
+++ b/backend/src/MoShiro.Backend.Api/OpsEndpoints.cs
@@ -19,12 +19,12 @@ public static class OpsEndpoints
app.MapGet("/metrics", MetricsAsync)
.Api("metrics_get", "Prometheus metrics (requires ops.metrics)", Tag)
.Produces(StatusCodes.Status200OK, contentType: "text/plain").ProducesProblems(401, 403);
- // 一行安装:curl -fsSL 'https://panel/install?code=…' | bash(macOS 用 zsh),irm 'https://panel/install.ps1?code=…' | iex(Windows)。
- // Agent 和 Probe 是同一份脚本,角色、网关和镜像由一次性代码决定;代码不能用就是 404。
- app.MapGet("/install", (HttpContext http, JoinTokenService tokens, BackendOptions options, string? code) => InstallAsync(http, tokens, options, code, "install.sh", "text/x-shellscript"))
- .Api("install_sh", "One-line installer for Linux and macOS nodes (agent or probe, decided by the code)", Tag)
- .Produces(StatusCodes.Status200OK, contentType: "text/x-shellscript").ProducesProblems(404);
- app.MapGet("/install.sh", (HttpContext http, JoinTokenService tokens, BackendOptions options, string? code) => InstallAsync(http, tokens, options, code, "install.sh", "text/x-shellscript"))
+ // 一行安装:面板给的命令先下载这份静态脚本、核对 SHA-256,再用环境变量里的面板地址、角色和一次性代码执行。
+ // 脚本不含任何一次性的值,所以不需要代码,也可以直接拿去和发布页公布的哈希核对。
+ app.MapGet("/install", (HttpContext http) => Static(http, InstallScripts.Sh, "text/x-shellscript"))
+ .Api("install_sh", "The static one-line installer for Linux and macOS nodes (same bytes for everyone; its SHA-256 is published with each release)", Tag)
+ .Produces(StatusCodes.Status200OK, contentType: "text/x-shellscript");
+ app.MapGet("/install.sh", (HttpContext http) => Static(http, InstallScripts.Sh, "text/x-shellscript"))
.ExcludeFromDescription();
// 脚本要装的版本:CI 签名的发布清单(原样的字节)和签名。脚本用内置的发布公钥验签,再按清单里的 digest 拉镜像。
app.MapGet("/install/manifest", (HttpContext http, JoinTokenService tokens, ReleaseService releases, BackendOptions options, string? code) => ManifestAsync(http, tokens, releases, options, code, signature: false))
@@ -33,9 +33,9 @@ public static class OpsEndpoints
app.MapGet("/install/manifest.sig", (HttpContext http, JoinTokenService tokens, ReleaseService releases, BackendOptions options, string? code) => ManifestAsync(http, tokens, releases, options, code, signature: true))
.Api("install_manifest_signature", "Base64 DER ECDSA P-256 signature of the manifest", Tag)
.Produces(StatusCodes.Status200OK, contentType: "text/plain").ProducesProblems(404);
- app.MapGet("/install.ps1", (HttpContext http, JoinTokenService tokens, BackendOptions options, string? code) => InstallAsync(http, tokens, options, code, "install.ps1", "text/plain"))
- .Api("install_ps1", "One-line installer for Windows nodes (agent or probe, decided by the code)", Tag)
- .Produces(StatusCodes.Status200OK, contentType: "text/plain").ProducesProblems(404);
+ app.MapGet("/install.ps1", (HttpContext http) => Static(http, InstallScripts.Ps1, "text/plain"))
+ .Api("install_ps1", "The static one-line installer for Windows nodes (same bytes for everyone; its SHA-256 is published with each release)", Tag)
+ .Produces(StatusCodes.Status200OK, contentType: "text/plain");
}
private static async Task ManifestAsync(HttpContext http, JoinTokenService tokens, ReleaseService releases, BackendOptions options, string? code, bool signature)
@@ -52,18 +52,10 @@ public static class OpsEndpoints
return signature ? TypedResults.Text(offer.Signature, "text/plain; charset=utf-8") : TypedResults.Text(offer.Manifest, "application/json; charset=utf-8");
}
- private static async Task InstallAsync(HttpContext http, JoinTokenService tokens, BackendOptions options, string? code, string resource, string contentType)
+ private static Microsoft.AspNetCore.Http.HttpResults.ContentHttpResult Static(HttpContext http, string resource, string contentType)
{
- string baseUrl = options.PublicUrl ?? $"{http.Request.Scheme}://{http.Request.Host}";
- InstallTarget? target = string.IsNullOrEmpty(code) ? null : await tokens.ResolveInstallAsync(code, baseUrl, http.RequestAborted);
- if (target is null)
- {
- return Problems.Create(http, MoShiroErrors.NotFound, "the install code is invalid, expired or already used");
- }
-
- string script = InstallScripts.Render(resource, target);
http.Response.Headers.CacheControl = "no-store";
- return TypedResults.Text(script, contentType + "; charset=utf-8");
+ return TypedResults.Text(InstallScripts.Script(resource), contentType + "; charset=utf-8");
}
private static async Task HealthAsync(HttpContext http, OpsService ops)
diff --git a/backend/src/MoShiro.Backend.Application/Admin/JoinTokenService.cs b/backend/src/MoShiro.Backend.Application/Admin/JoinTokenService.cs
index ee17437..05c40b6 100644
--- a/backend/src/MoShiro.Backend.Application/Admin/JoinTokenService.cs
+++ b/backend/src/MoShiro.Backend.Application/Admin/JoinTokenService.cs
@@ -10,6 +10,12 @@ public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList?
/// 各个系统上的一行安装命令:Linux 用 bash、macOS 用 zsh、Windows 用 PowerShell。命令里只有一次性代码,节点名字默认取主机名;Agent 和 Probe 是同一份脚本,角色由代码决定。
public sealed record InstallCommands(string Linux, string Macos, string Windows);
+///
+/// 安装命令里除了一次性的码,还带的公开值:信任根的创世指纹(节点把它固定下来)、属主(允许授权这个节点 extension 的 passkey 凭证 id)、
+/// 和面板提供的静态安装脚本的 SHA-256(命令先核对哈希再执行)。没有建立信任根时 Genesis 为空,节点不固定信任根。
+///
+public sealed record InstallPlan(string? Genesis, IReadOnlyList Controllers, string ScriptShSha256, string ScriptPs1Sha256);
+
public sealed record CreatedJoinToken(JoinTokenInfo Info, string Plaintext, InstallCommands InstallCommands);
/// 安装脚本里要填的四个值。
@@ -27,19 +33,17 @@ public sealed class JoinTokenService
public static string RoleOf(string kind) => kind.Length == 0 ? AgentRole : kind;
private readonly IJoinTokenStore _store;
- private readonly TrustService _trust;
private readonly BackendOptions _options;
private readonly TimeProvider _time;
- public JoinTokenService(IJoinTokenStore store, TrustService trust, BackendOptions options, TimeProvider time)
+ public JoinTokenService(IJoinTokenStore store, BackendOptions options, TimeProvider time)
{
_store = store;
- _trust = trust;
_options = options;
_time = time;
}
- public async ValueTask> CreateAsync(CreateJoinTokenRequest request, string? requestBaseUrl, CancellationToken ct)
+ public async ValueTask> CreateAsync(CreateJoinTokenRequest request, string? requestBaseUrl, InstallPlan plan, CancellationToken ct)
{
var errors = new Dictionary();
if (request.Role is not (AgentRole or ProbeRole))
@@ -47,9 +51,9 @@ public sealed class JoinTokenService
errors["role"] = "must be agent or probe";
}
- if (request.MaxUses is < 1 or > 1000)
+ if (request.MaxUses is < 1 or > 100_000)
{
- errors["max_uses"] = "must be between 1 and 1000";
+ errors["max_uses"] = "must be between 1 and 100000";
}
if (request.ExpiresInHours is < 1 or > 24 * 365)
@@ -76,79 +80,25 @@ public sealed class JoinTokenService
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
JoinTokenInfo info = await _store.CreateAsync(
Ulid.NewPrefixed("jtk", _time), TokenHash.Sha256(plaintext), request.Note, request.Labels ?? [], request.AutoApprove, KindOf(request.Role),
- request.MaxUses, now + (request.ExpiresInHours * 3_600_000L), now, request.CreatedBy, null, ct);
+ request.MaxUses, now + (request.ExpiresInHours * 3_600_000L), now, request.CreatedBy, ct);
- string baseUrl = BaseUrl(requestBaseUrl);
- string query = $"{baseUrl}/install?code={plaintext}";
- return Result.Ok(new CreatedJoinToken(info, plaintext, new InstallCommands(
- $"curl -fsSL '{query}' | bash",
- $"curl -fsSL '{query}' | zsh",
- $"irm '{baseUrl}/install.ps1?code={plaintext}' | iex")));
+ return Result.Ok(new CreatedJoinToken(info, plaintext, Commands(BaseUrl(requestBaseUrl), request.Role, plaintext, plan)));
}
///
- /// 用操作员 passkey 签的委托创建加入码:码本身(认证串)由浏览器用种子派生,面板只拿到它的哈希,推不出种子,
- /// 所以面板自己没法为这个码签绑定。有效期、角色、次数都以签名的语句为准。
+ /// 一行安装命令:先下载静态脚本、核对 SHA-256,一致才执行;面板地址、角色、一次性码、信任根指纹和属主都走环境变量。
+ /// 里面没有任何私钥,码泄露最多让别人注册出一个节点。
///
- public async ValueTask> CreateSignedAsync(SignedStatementDto signedGrant, string? note, IReadOnlyList? labels, bool autoApprove, string? createdBy, CancellationToken ct)
+ public static InstallCommands Commands(string baseUrl, string role, string code, InstallPlan plan)
{
- OperatorSet? set = await _trust.CurrentAsync(ct);
- if (set is null)
- {
- return Result.Fail(MoShiroErrors.ValidationFailed, "set up the operator trust root first (System, Trust)");
- }
-
- Grant? grant = OperatorTrust.ParseGrant(signedGrant.Statement);
- if (grant is null || !OperatorTrust.VerifyOperator(signedGrant.ToSigned(), set))
- {
- return Result.Fail(MoShiroErrors.Forbidden, "the grant is not signed by an operator passkey");
- }
-
- long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
- var errors = new Dictionary();
- if (grant.Role is not (AgentRole or ProbeRole))
- {
- errors["role"] = "must be agent or probe";
- }
-
- if (grant.Uses is < 1 or > 1000)
- {
- errors["uses"] = "must be between 1 and 1000";
- }
-
- if (grant.NotAfterMs <= now || grant.NotAfterMs - now > 24 * 365 * 3_600_000L || grant.NotBeforeMs > now + 300_000)
- {
- errors["validity"] = "the grant must be valid now and for at most one year";
- }
-
- if (!System.Text.RegularExpressions.Regex.IsMatch(grant.AuthHash, "^[0-9a-f]{64}$"))
- {
- errors["auth"] = "must be a lowercase sha-256 hex";
- }
-
- if (labels is { } l && (l.Count > 16 || l.Any(x => x.Length is 0 or > 32)))
- {
- errors["labels"] = "at most 16 labels of 1-32 characters";
- }
-
- if (note is { Length: > 200 })
- {
- errors["note"] = "must be at most 200 characters";
- }
-
- if (errors.Count > 0)
- {
- return ValidationError.Fail(errors);
- }
-
- byte[] hash = Convert.FromHexString(grant.AuthHash);
- if (await _store.FindAsync(hash, ct) is not null)
- {
- return Result.Fail(MoShiroErrors.NameConflict, "this code already exists");
- }
-
- return Result.Ok(await _store.CreateAsync(
- Ulid.NewPrefixed("jtk", _time), hash, note, labels ?? [], autoApprove, KindOf(grant.Role), grant.Uses, grant.NotAfterMs, now, createdBy, signedGrant, ct));
+ string pin = plan.Genesis is null ? "" : $" MOSHIRO_GENESIS='{plan.Genesis}' MOSHIRO_CONTROLLERS='{string.Join(',', plan.Controllers)}'";
+ string env = $"MOSHIRO_SERVER='{baseUrl}' MOSHIRO_ROLE='{role}' MOSHIRO_CODE='{code}'{pin}";
+ string psPin = plan.Genesis is null ? "" : $"$env:MOSHIRO_GENESIS='{plan.Genesis}'; $env:MOSHIRO_CONTROLLERS='{string.Join(',', plan.Controllers)}'; ";
+ return new InstallCommands(
+ $"f=$(mktemp) && curl -fsSL '{baseUrl}/install' -o \"$f\" && echo \"{plan.ScriptShSha256} $f\" | sha256sum -c - >/dev/null && {env} sh \"$f\"",
+ $"f=$(mktemp) && curl -fsSL '{baseUrl}/install' -o \"$f\" && echo \"{plan.ScriptShSha256} $f\" | shasum -a 256 -c - >/dev/null && {env} sh \"$f\"",
+ $"$f = Join-Path $env:TEMP 'moshiro-install.ps1'; irm '{baseUrl}/install.ps1' -OutFile $f; if ((Get-FileHash $f -Algorithm SHA256).Hash -ne '{plan.ScriptPs1Sha256}') {{ throw 'the install script does not match its published hash' }}; "
+ + $"$env:MOSHIRO_SERVER='{baseUrl}'; $env:MOSHIRO_ROLE='{role}'; $env:MOSHIRO_CODE='{code}'; {psPin}iex (Get-Content -Raw $f)");
}
private string BaseUrl(string? requestBaseUrl) => (_options.PublicUrl ?? requestBaseUrl ?? "https://panel.example.com").TrimEnd('/');
diff --git a/backend/src/MoShiro.Backend.Application/Agents/AgentService.cs b/backend/src/MoShiro.Backend.Application/Agents/AgentService.cs
index eeddc83..c513461 100644
--- a/backend/src/MoShiro.Backend.Application/Agents/AgentService.cs
+++ b/backend/src/MoShiro.Backend.Application/Agents/AgentService.cs
@@ -171,7 +171,6 @@ public sealed partial class AgentService
TcpPort = agent.ProbeTcpPort,
UdpPorts = [.. agent.ProbeUdpPorts],
PublicKey = Convert.ToBase64String(agent.TlsPublicKey!),
- Proof = agent.Proof,
});
}
diff --git a/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs b/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs
index ba8fbe9..0dbb58e 100644
--- a/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs
+++ b/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs
@@ -37,6 +37,8 @@ public static class ApplicationServiceCollectionExtensions
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton(sp => sp.GetRequiredService());
services.AddSingleton();
services.AddSingleton(sp => sp.GetRequiredService());
services.AddSingleton();
diff --git a/backend/src/MoShiro.Backend.Application/Auth/AuthService.cs b/backend/src/MoShiro.Backend.Application/Auth/AuthService.cs
index 458f717..a03a70e 100644
--- a/backend/src/MoShiro.Backend.Application/Auth/AuthService.cs
+++ b/backend/src/MoShiro.Backend.Application/Auth/AuthService.cs
@@ -71,7 +71,7 @@ public sealed partial class AuthService
}
UserRecord? user = await _store.GetUserAsync(session.UserId, ct);
- return user is null ? null : new Principal(PrincipalKind.Session, Principal.SetOf(CapabilityCatalog.ForSession()), user.Id, user.Username, Base64Url.Encode(session.CsrfToken), null) { AuthMethod = session.AuthMethod, SessionId = Convert.ToHexString(session.IdSha256) };
+ return user is null ? null : new Principal(PrincipalKind.Session, Principal.SetOf(CapabilityCatalog.ForSession(session.AuthMethod)), user.Id, user.Username, Base64Url.Encode(session.CsrfToken), null) { AuthMethod = session.AuthMethod, SessionId = Convert.ToHexString(session.IdSha256) };
}
public async ValueTask AuthenticateTokenAsync(string bearer, CancellationToken ct)
diff --git a/backend/src/MoShiro.Backend.Application/Auth/Capabilities.cs b/backend/src/MoShiro.Backend.Application/Auth/Capabilities.cs
index 85784a8..503a3a2 100644
--- a/backend/src/MoShiro.Backend.Application/Auth/Capabilities.cs
+++ b/backend/src/MoShiro.Backend.Application/Auth/Capabilities.cs
@@ -59,7 +59,7 @@ public static class CapabilityCatalog
new(Capability.FleetRead, "fleet"),
new(Capability.FleetWrite, "fleet", false, false, Capability.FleetRead),
- new(Capability.FleetEnroll, "fleet", true, false, Capability.FleetRead),
+ new(Capability.FleetEnroll, "fleet", false, false, Capability.FleetRead),
new(Capability.FleetRevoke, "fleet", true, false, Capability.FleetRead),
new(Capability.TasksRead, "tasks"),
@@ -134,4 +134,10 @@ public static class CapabilityCatalog
/// 登录会话的能力:全部,包括信任类;信任类的操作还要有效的提权窗口()。
public static IReadOnlyList ForSession() => [.. All.Select(c => c.Id)];
+
+ ///
+ /// 按登录方式给会话能力:passkey 登录的拿全部;密码登录的什么能力都没有,只能做「已登录」就能做的事——
+ /// 看自己是谁、注册自己的第一个 passkey。管理员要先用密码进来加一个 passkey,之后用 passkey 登录才能管事。
+ ///
+ public static IReadOnlyList ForSession(string authMethod) => authMethod == AuthMethods.Passkey ? ForSession() : [];
}
diff --git a/backend/src/MoShiro.Backend.Application/Auth/ElevationService.cs b/backend/src/MoShiro.Backend.Application/Auth/ElevationService.cs
index 41a75f0..de6c7c1 100644
--- a/backend/src/MoShiro.Backend.Application/Auth/ElevationService.cs
+++ b/backend/src/MoShiro.Backend.Application/Auth/ElevationService.cs
@@ -37,7 +37,6 @@ public static class Elevation
/// 端点要求的能力需要哪一种窗口;不是信任类的能力返回 null。
public static ElevationClass? For(string capability) => capability switch
{
- Capability.FleetEnroll => ElevationClass.Enroll,
Capability.FleetRevoke => ElevationClass.Revoke,
Capability.AccountsManage => ElevationClass.Credential,
_ => null,
diff --git a/backend/src/MoShiro.Backend.Application/Auth/ServerIdentityService.cs b/backend/src/MoShiro.Backend.Application/Auth/ServerIdentityService.cs
new file mode 100644
index 0000000..5ac65b7
--- /dev/null
+++ b/backend/src/MoShiro.Backend.Application/Auth/ServerIdentityService.cs
@@ -0,0 +1,82 @@
+using System.Security.Cryptography;
+using Fleet.Identity;
+using Fleet.Server;
+
+namespace MoShiro.Backend.Application;
+
+///
+/// 服务器的身份密钥(P-256):操作员用 passkey 把公钥签进操作员集合,之后节点只认带这把密钥签名的服务器,
+/// 并在这条链路上用应用层加密(TLS 在 Cloudflare 或被冒充的证书那里终止时,中间人拿不出这把密钥)。
+/// 私钥存在 Backend 的设置里:它保护的是「面板被冒充」,不是「面板被攻破」;面板被攻破时它也会泄露,那时要由 passkey 签一个换掉它的新集合版本。
+///
+public sealed class ServerIdentityService : IServerIdentity, IDisposable
+{
+ private const string Key = "server.identity_key";
+
+ private readonly ISettingsStore _settings;
+ private readonly object _gate = new();
+ private ECDsa? _key;
+ private byte[]? _spki;
+
+ public ServerIdentityService(ISettingsStore settings)
+ {
+ _settings = settings;
+ }
+
+ public byte[] PublicKeySpki
+ {
+ get
+ {
+ EnsureLoaded();
+ return _spki!;
+ }
+ }
+
+ /// 公钥的指纹(SHA-256,小写十六进制):页面上给人核对。
+ public string Fingerprint => Convert.ToHexStringLower(SHA256.HashData(PublicKeySpki));
+
+ public byte[] Sign(ReadOnlySpan payload)
+ {
+ EnsureLoaded();
+ return _key!.SignData(payload, HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
+ }
+
+ /// 启动时调用:读出或生成密钥。节点可能在启动完成之前就连上来,所以 也会按需加载。
+ public ValueTask EnsureAsync(CancellationToken ct)
+ {
+ EnsureLoaded(ct);
+ return ValueTask.CompletedTask;
+ }
+
+ private void EnsureLoaded(CancellationToken ct = default)
+ {
+ if (_key is not null)
+ {
+ return;
+ }
+
+ lock (_gate)
+ {
+ if (_key is not null)
+ {
+ return;
+ }
+
+ string? stored = _settings.GetAsync(Key, ct).AsTask().GetAwaiter().GetResult();
+ var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
+ if (stored is not null)
+ {
+ key.ImportPkcs8PrivateKey(Convert.FromBase64String(stored), out _);
+ }
+ else
+ {
+ _settings.SetAsync(Key, Convert.ToBase64String(key.ExportPkcs8PrivateKey()), ct).AsTask().GetAwaiter().GetResult();
+ }
+
+ _spki = key.ExportSubjectPublicKeyInfo();
+ _key = key;
+ }
+ }
+
+ public void Dispose() => _key?.Dispose();
+}
diff --git a/backend/src/MoShiro.Backend.Application/Auth/TrustService.cs b/backend/src/MoShiro.Backend.Application/Auth/TrustService.cs
index 0fc0e89..db0ebff 100644
--- a/backend/src/MoShiro.Backend.Application/Auth/TrustService.cs
+++ b/backend/src/MoShiro.Backend.Application/Auth/TrustService.cs
@@ -11,7 +11,8 @@ namespace MoShiro.Backend.Application;
public sealed record TrustProposal(string Statement, int Version, string Challenge);
/// InSync:当前生效的集合和现有的 passkey、域名是否一致;不一致就需要签一个新版本。
-public sealed record TrustState(bool Initialized, string? Genesis, int Version, bool InSync, IReadOnlyList Chain, TrustProposal? Proposal);
+/// 这个面板的服务器身份密钥的指纹;ServerKeyListed:当前生效的操作员集合里已经登记了它(节点据此认证服务器、加密链路)。
+public sealed record TrustState(bool Initialized, string? Genesis, int Version, bool InSync, IReadOnlyList Chain, TrustProposal? Proposal, IReadOnlyList? Operators = null, string ServerKey = "", bool ServerKeyListed = false);
///
/// 信任根的 Backend 一侧:保存操作员集合的链、给操作员出下一版的语句、验证他签回来的版本。
@@ -22,11 +23,13 @@ public sealed class TrustService : IOperatorTrustSource, IDisposable
{
private readonly IOperatorSetStore _store;
private readonly IAuthStore _auth;
+ private readonly ServerIdentityService _server;
private readonly TimeProvider _time;
private readonly SemaphoreSlim _gate = new(1, 1);
- public TrustService(IOperatorSetStore store, IAuthStore auth, TimeProvider time)
+ public TrustService(IOperatorSetStore store, IAuthStore auth, ServerIdentityService server, TimeProvider time)
{
+ _server = server;
_store = store;
_auth = auth;
_time = time;
@@ -50,7 +53,7 @@ public sealed class TrustService : IOperatorTrustSource, IDisposable
TrustProposal? proposal = await ProposeAsync(chain, current, context, ct);
OperatorSet? proposed = proposal is null ? null : OperatorTrust.ParseSet(proposal.Statement);
bool inSync = current is not null && proposed is not null && Same(current, proposed);
- return new TrustState(current is not null, chain.Count == 0 ? null : OperatorTrust.Fingerprint(chain[0].Statement), current?.Version ?? 0, inSync, chain, inSync ? null : proposal);
+ return new TrustState(current is not null, chain.Count == 0 ? null : OperatorTrust.Fingerprint(chain[0].Statement), current?.Version ?? 0, inSync, chain, inSync ? null : proposal, current is null ? [] : [.. current.Keys.Select(k => k.CredentialId)], _server.Fingerprint, current?.Servers?.Any(k => k.AsSpan().SequenceEqual(_server.PublicKeySpki)) == true);
}
private async ValueTask ProposeAsync(IReadOnlyList chain, OperatorSet? current, PasskeyContext context, CancellationToken ct)
@@ -66,14 +69,16 @@ public sealed class TrustService : IOperatorTrustSource, IDisposable
chain.Count == 0 ? OperatorTrust.Genesis : OperatorTrust.Fingerprint(chain[^1].Statement),
context.RpId,
[.. context.Origins.Order(StringComparer.Ordinal)],
- [.. passkeys.Select(p => new OperatorKey(p.Id, p.PublicKeySpki))]);
+ [.. passkeys.Select(p => new OperatorKey(p.Id, p.PublicKeySpki))],
+ [_server.PublicKeySpki]);
string statement = OperatorTrust.Encode(next);
return new TrustProposal(statement, next.Version, Base64Url.Encode(SHA256.HashData(Encoding.UTF8.GetBytes(statement))));
}
private static bool Same(OperatorSet a, OperatorSet b) =>
a.RpId == b.RpId && a.Origins.Order(StringComparer.Ordinal).SequenceEqual(b.Origins.Order(StringComparer.Ordinal))
- && a.Keys.Select(k => k.CredentialId).Order(StringComparer.Ordinal).SequenceEqual(b.Keys.Select(k => k.CredentialId).Order(StringComparer.Ordinal));
+ && a.Keys.Select(k => k.CredentialId).Order(StringComparer.Ordinal).SequenceEqual(b.Keys.Select(k => k.CredentialId).Order(StringComparer.Ordinal))
+ && (a.Servers ?? []).Select(Convert.ToBase64String).Order(StringComparer.Ordinal).SequenceEqual((b.Servers ?? []).Select(Convert.ToBase64String).Order(StringComparer.Ordinal));
///
/// 收下操作员签回来的一版(创世,或上一版里的凭证签的更新):语句里的每把凭证都要是已注册的 passkey、域名对得上、链验得过。
@@ -102,6 +107,12 @@ public sealed class TrustService : IOperatorTrustSource, IDisposable
return ValidationError.Fail("statement", "the relying party or origins do not match this panel");
}
+ if (set.Servers is not { Count: > 0 } || !set.Servers.Any(k => k.AsSpan().SequenceEqual(_server.PublicKeySpki)))
+ {
+ // 集合里没有这个面板的服务器密钥:签下去之后固定了它的节点就认不出这个面板了
+ return ValidationError.Fail("statement", "must list this panel's server key");
+ }
+
IReadOnlyList registered = await _auth.ListAllPasskeysAsync(ct);
if (set.Keys.Any(k => !registered.Any(p => p.Id == k.CredentialId && p.PublicKeySpki.AsSpan().SequenceEqual(k.PublicKeySpki))))
{
diff --git a/backend/src/MoShiro.Backend.Application/Models/AgentModels.cs b/backend/src/MoShiro.Backend.Application/Models/AgentModels.cs
index 62871d1..33ef79e 100644
--- a/backend/src/MoShiro.Backend.Application/Models/AgentModels.cs
+++ b/backend/src/MoShiro.Backend.Application/Models/AgentModels.cs
@@ -30,8 +30,7 @@ public sealed record AgentRecord(
bool Ipv6HostManual = false,
IReadOnlyList? ReportedAddresses = null,
byte[]? TlsPublicKey = null,
- string? AddedBy = null,
- MemberProofDto? Proof = null);
+ string? AddedBy = null);
/// 管理员可以修改的字段(merge-patch 语义:为 null 的字段不改)。
public sealed class AgentPatch
@@ -79,11 +78,10 @@ public sealed record JoinTokenInfo(
long? RevokedAtMs,
long CreatedAtMs,
string Kind = "",
- string? CreatedBy = null,
- SignedStatementDto? Grant = null);
+ string? CreatedBy = null);
///
/// 是连 Backend 的身份公钥, 是对等面(连 Agent)的公钥,两把是节点自己生成的独立密钥;
/// Agent 的 peers 里放的是后者。
///
-public sealed record ProbeRecord(string Id, string Name, byte[] PublicKey, long CreatedAtMs, long? RevokedAtMs, string? Ipv6DiagnosticsJson = null, byte[]? PeerPublicKey = null, string? AddedBy = null, MemberProofDto? Proof = null);
+public sealed record ProbeRecord(string Id, string Name, byte[] PublicKey, long CreatedAtMs, long? RevokedAtMs, string? Ipv6DiagnosticsJson = null, byte[]? PeerPublicKey = null, string? AddedBy = null);
diff --git a/backend/src/MoShiro.Backend.Application/Ports/StorePorts.cs b/backend/src/MoShiro.Backend.Application/Ports/StorePorts.cs
index c9605a2..7f1e666 100644
--- a/backend/src/MoShiro.Backend.Application/Ports/StorePorts.cs
+++ b/backend/src/MoShiro.Backend.Application/Ports/StorePorts.cs
@@ -54,7 +54,7 @@ public interface IGeoLookup
public interface IJoinTokenStore
{
- ValueTask CreateAsync(string id, byte[] tokenSha256, string? note, IReadOnlyList labels, bool autoApprove, string kind, int maxUses, long expiresAtMs, long nowMs, string? createdBy, SignedStatementDto? grant, CancellationToken ct);
+ ValueTask CreateAsync(string id, byte[] tokenSha256, string? note, IReadOnlyList labels, bool autoApprove, string kind, int maxUses, long expiresAtMs, long nowMs, string? createdBy, CancellationToken ct);
ValueTask> ListAsync(CancellationToken ct);
diff --git a/backend/src/MoShiro.Backend.Application/StartupTasks.cs b/backend/src/MoShiro.Backend.Application/StartupTasks.cs
index 3716e30..55000a7 100644
--- a/backend/src/MoShiro.Backend.Application/StartupTasks.cs
+++ b/backend/src/MoShiro.Backend.Application/StartupTasks.cs
@@ -11,9 +11,11 @@ public sealed class StartupTasks : IHostedService
private readonly IRawStore _raw;
private readonly AuthService _auth;
private readonly TaskService _tasks;
+ private readonly ServerIdentityService _identity;
- public StartupTasks(IRawStore raw, AuthService auth, TaskService tasks)
+ public StartupTasks(IRawStore raw, AuthService auth, TaskService tasks, ServerIdentityService identity)
{
+ _identity = identity;
_raw = raw;
_auth = auth;
_tasks = tasks;
@@ -24,6 +26,7 @@ public sealed class StartupTasks : IHostedService
await _raw.RecoverAsync(cancellationToken);
await _auth.EnsureInitialAdminAsync(cancellationToken);
await _tasks.SeedDefaultsAsync(cancellationToken);
+ await _identity.EnsureAsync(cancellationToken);
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
diff --git a/backend/src/MoShiro.Backend.Application/Tasks/TaskPlanner.cs b/backend/src/MoShiro.Backend.Application/Tasks/TaskPlanner.cs
index f8852db..dfe7cc0 100644
--- a/backend/src/MoShiro.Backend.Application/Tasks/TaskPlanner.cs
+++ b/backend/src/MoShiro.Backend.Application/Tasks/TaskPlanner.cs
@@ -64,7 +64,7 @@ public sealed class TaskPlanner : IDisposable
foreach (ProbeRecord probe in probes.Where(p => p.RevokedAtMs is null && p.PeerPublicKey is { Length: > 0 } && links.IsLinked(p.Id, agentId.Value)))
{
- document.Peers.Add(new PeerSpec { ProbeId = new ProbeId(probe.Id), PublicKey = Convert.ToBase64String(probe.PeerPublicKey!), Proof = probe.Proof });
+ document.Peers.Add(new PeerSpec { ProbeId = new ProbeId(probe.Id), PublicKey = Convert.ToBase64String(probe.PeerPublicKey!) });
}
IReadOnlyList chain = await _trust.ChainAsync(ct);
diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs
index ad26ebd..ba8a7c5 100644
--- a/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs
+++ b/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs
@@ -218,9 +218,6 @@ internal static class Schema
created_at INTEGER NOT NULL
)
""");
- connection.Exec("ALTER TABLE join_tokens ADD COLUMN op_grant TEXT");
- connection.Exec("ALTER TABLE agents ADD COLUMN trust TEXT");
- connection.Exec("ALTER TABLE probes ADD COLUMN trust TEXT");
connection.Exec("PRAGMA user_version = 13");
transaction.Commit();
}
diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAgentStore.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAgentStore.cs
index a3b3f31..6f13a7f 100644
--- a/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAgentStore.cs
+++ b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAgentStore.cs
@@ -14,7 +14,7 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore
{
private const string AgentColumns =
"id, name, status, pubkey, tls_spki, public_host, probe_tcp_port, probe_udp_ports, labels, public_visible, kinds, agent_version, local_limits, " +
- "task_version, applied_task_version, rejected, created_at, last_seen_at, row_version, ipv6_host, ipv6_supported, ipv6_diagnostics, public_host_manual, ipv6_host_manual, reported_addresses, tls_pubkey, added_by, trust";
+ "task_version, applied_task_version, rejected, created_at, last_seen_at, row_version, ipv6_host, ipv6_supported, ipv6_diagnostics, public_host_manual, ipv6_host_manual, reported_addresses, tls_pubkey, added_by";
private readonly SqliteDatabase _db;
@@ -157,8 +157,8 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore
byte[] hash = tokenSha256.ToArray();
return _db.ReadAsync(
c => c.QueryOne(
- "SELECT id, token_sha256, note, labels, auto_approve, max_uses, uses, expires_at, revoked_at, created_at, kind, created_by, op_grant FROM join_tokens WHERE token_sha256 = @h",
- r => new JoinTokenRecord(r.GetString(0), r.Bytes(1), r.NullableString(2), StorageJson.Strings(r.GetString(3)), r.GetInt64(4) != 0, r.GetInt32(5), r.GetInt32(6), r.GetInt64(7), r.NullableLong(8), r.GetInt64(9), r.GetString(10), r.NullableString(11), StorageJson.Grant(r.NullableString(12))),
+ "SELECT id, token_sha256, note, labels, auto_approve, max_uses, uses, expires_at, revoked_at, created_at, kind, created_by FROM join_tokens WHERE token_sha256 = @h",
+ r => new JoinTokenRecord(r.GetString(0), r.Bytes(1), r.NullableString(2), StorageJson.Strings(r.GetString(3)), r.GetInt64(4) != 0, r.GetInt32(5), r.GetInt32(6), r.GetInt64(7), r.NullableLong(8), r.GetInt64(9), r.GetString(10), r.NullableString(11)),
("@h", hash)),
ct);
}
@@ -184,9 +184,9 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore
}
c.Exec(
- "INSERT INTO agents (id, name, status, pubkey, tls_spki, labels, meta, agent_version, created_at, added_by, trust) VALUES (@id, @name, @status, @pubkey, @spki, @labels, @meta, @version, @now, @by, @trust)",
+ "INSERT INTO agents (id, name, status, pubkey, tls_spki, labels, meta, agent_version, created_at, added_by) VALUES (@id, @name, @status, @pubkey, @spki, @labels, @meta, @version, @now, @by)",
("@id", node.Id.Value), ("@name", node.Name), ("@status", StatusText(node.Status)), ("@pubkey", node.PublicKey), ("@spki", node.TlsSpki),
- ("@labels", StorageJson.ToJson(node.Labels)), ("@meta", node.MetaJson), ("@version", node.NodeVersion), ("@now", node.NowMs), ("@by", node.AddedBy), ("@trust", StorageJson.ToJson(node.Proof)));
+ ("@labels", StorageJson.ToJson(node.Labels)), ("@meta", node.MetaJson), ("@version", node.NodeVersion), ("@now", node.NowMs), ("@by", node.AddedBy));
return Result.Ok(ToNode(GetInternal(c, node.Id)!, node.MetaJson));
}, ct);
@@ -204,8 +204,8 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore
}
c.Exec(
- "INSERT INTO probes (id, name, pubkey, peer_pubkey, created_at, added_by, trust) VALUES (@id, @name, @pubkey, @peer, @now, @by, @trust)",
- ("@id", node.Id.Value), ("@name", node.Name), ("@pubkey", node.PublicKey), ("@peer", peerKey), ("@now", node.NowMs), ("@by", node.AddedBy), ("@trust", StorageJson.ToJson(node.Proof)));
+ "INSERT INTO probes (id, name, pubkey, peer_pubkey, created_at, added_by) VALUES (@id, @name, @pubkey, @peer, @now, @by)",
+ ("@id", node.Id.Value), ("@name", node.Name), ("@pubkey", node.PublicKey), ("@peer", peerKey), ("@now", node.NowMs), ("@by", node.AddedBy));
return Result.Ok(new NodeRecord(node.Id, node.Name, NodeStatus.Active, node.PublicKey, node.TlsSpki, node.Labels, node.MetaJson, node.NodeVersion, node.NowMs, null));
}
@@ -268,8 +268,7 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore
r.GetInt64(23) != 0,
StorageJson.Strings(r.GetString(24)),
r.NullableBytes(25),
- r.NullableString(26),
- StorageJson.Proof(r.NullableString(27)));
+ r.NullableString(26));
private static NodeRecord ToNode(AgentRecord a, string? metaJson) =>
new(a.Id, a.Name, a.Status, a.PublicKey, a.TlsSpki, a.Labels, metaJson, a.AgentVersion, a.CreatedAtMs, a.LastSeenAtMs);
diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteConfigStores.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteConfigStores.cs
index a18356a..1244c2d 100644
--- a/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteConfigStores.cs
+++ b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteConfigStores.cs
@@ -15,28 +15,28 @@ public sealed class SqliteJoinTokenStore : IJoinTokenStore
_db = db;
}
- public ValueTask CreateAsync(string id, byte[] tokenSha256, string? note, IReadOnlyList labels, bool autoApprove, string kind, int maxUses, long expiresAtMs, long nowMs, string? createdBy, SignedStatementDto? grant, CancellationToken ct) =>
+ public ValueTask CreateAsync(string id, byte[] tokenSha256, string? note, IReadOnlyList labels, bool autoApprove, string kind, int maxUses, long expiresAtMs, long nowMs, string? createdBy, CancellationToken ct) =>
_db.WriteAsync(c =>
{
c.Exec(
- "INSERT INTO join_tokens (id, token_sha256, note, labels, auto_approve, kind, max_uses, expires_at, created_at, created_by, op_grant) VALUES (@id, @h, @note, @labels, @auto, @kind, @max, @exp, @now, @by, @grant)",
+ "INSERT INTO join_tokens (id, token_sha256, note, labels, auto_approve, kind, max_uses, expires_at, created_at, created_by) VALUES (@id, @h, @note, @labels, @auto, @kind, @max, @exp, @now, @by)",
("@id", id), ("@h", tokenSha256), ("@note", note), ("@labels", StorageJson.ToJson(labels)), ("@auto", autoApprove ? 1 : 0), ("@kind", kind),
- ("@max", maxUses), ("@exp", expiresAtMs), ("@now", nowMs), ("@by", createdBy), ("@grant", StorageJson.ToJson(grant)));
- return new JoinTokenInfo(id, note, labels, autoApprove, maxUses, 0, expiresAtMs, null, nowMs, kind, createdBy, grant);
+ ("@max", maxUses), ("@exp", expiresAtMs), ("@now", nowMs), ("@by", createdBy));
+ return new JoinTokenInfo(id, note, labels, autoApprove, maxUses, 0, expiresAtMs, null, nowMs, kind, createdBy);
}, ct);
public ValueTask> ListAsync(CancellationToken ct) =>
_db.ReadAsync>(
c => c.Query(
- "SELECT id, note, labels, auto_approve, max_uses, uses, expires_at, revoked_at, created_at, kind, created_by, op_grant FROM join_tokens ORDER BY created_at DESC, id",
- r => new JoinTokenInfo(r.GetString(0), r.NullableString(1), StorageJson.Strings(r.GetString(2)), r.GetInt64(3) != 0, r.GetInt32(4), r.GetInt32(5), r.GetInt64(6), r.NullableLong(7), r.GetInt64(8), r.GetString(9), r.NullableString(10), StorageJson.Grant(r.NullableString(11)))),
+ "SELECT id, note, labels, auto_approve, max_uses, uses, expires_at, revoked_at, created_at, kind, created_by FROM join_tokens ORDER BY created_at DESC, id",
+ r => new JoinTokenInfo(r.GetString(0), r.NullableString(1), StorageJson.Strings(r.GetString(2)), r.GetInt64(3) != 0, r.GetInt32(4), r.GetInt32(5), r.GetInt64(6), r.NullableLong(7), r.GetInt64(8), r.GetString(9), r.NullableString(10))),
ct);
public ValueTask FindAsync(byte[] tokenSha256, CancellationToken ct) =>
_db.ReadAsync(
c => c.QueryOne(
- "SELECT id, note, labels, auto_approve, max_uses, uses, expires_at, revoked_at, created_at, kind, created_by, op_grant FROM join_tokens WHERE token_sha256 = @h",
- r => new JoinTokenInfo(r.GetString(0), r.NullableString(1), StorageJson.Strings(r.GetString(2)), r.GetInt64(3) != 0, r.GetInt32(4), r.GetInt32(5), r.GetInt64(6), r.NullableLong(7), r.GetInt64(8), r.GetString(9), r.NullableString(10), StorageJson.Grant(r.NullableString(11))),
+ "SELECT id, note, labels, auto_approve, max_uses, uses, expires_at, revoked_at, created_at, kind, created_by FROM join_tokens WHERE token_sha256 = @h",
+ r => new JoinTokenInfo(r.GetString(0), r.NullableString(1), StorageJson.Strings(r.GetString(2)), r.GetInt64(3) != 0, r.GetInt32(4), r.GetInt32(5), r.GetInt64(6), r.NullableLong(7), r.GetInt64(8), r.GetString(9), r.NullableString(10)),
("@h", tokenSha256)),
ct);
@@ -58,12 +58,12 @@ public sealed class SqliteProbeStore : IProbeStore
public ValueTask> ListAsync(CancellationToken ct) =>
_db.ReadAsync>(
- c => c.Query("SELECT id, name, pubkey, created_at, revoked_at, ipv6_diagnostics, peer_pubkey, added_by, trust FROM probes ORDER BY created_at, id", r => new ProbeRecord(r.GetString(0), r.GetString(1), r.Bytes(2), r.GetInt64(3), r.NullableLong(4), r.NullableString(5), r.NullableBytes(6), r.NullableString(7), StorageJson.Proof(r.NullableString(8)))),
+ c => c.Query("SELECT id, name, pubkey, created_at, revoked_at, ipv6_diagnostics, peer_pubkey, added_by FROM probes ORDER BY created_at, id", r => new ProbeRecord(r.GetString(0), r.GetString(1), r.Bytes(2), r.GetInt64(3), r.NullableLong(4), r.NullableString(5), r.NullableBytes(6), r.NullableString(7))),
ct);
public ValueTask GetAsync(string id, CancellationToken ct) =>
_db.ReadAsync(
- c => c.QueryOne("SELECT id, name, pubkey, created_at, revoked_at, ipv6_diagnostics, peer_pubkey, added_by, trust FROM probes WHERE id = @id", r => new ProbeRecord(r.GetString(0), r.GetString(1), r.Bytes(2), r.GetInt64(3), r.NullableLong(4), r.NullableString(5), r.NullableBytes(6), r.NullableString(7), StorageJson.Proof(r.NullableString(8))), ("@id", id)),
+ c => c.QueryOne("SELECT id, name, pubkey, created_at, revoked_at, ipv6_diagnostics, peer_pubkey, added_by FROM probes WHERE id = @id", r => new ProbeRecord(r.GetString(0), r.GetString(1), r.Bytes(2), r.GetInt64(3), r.NullableLong(4), r.NullableString(5), r.NullableBytes(6), r.NullableString(7)), ("@id", id)),
ct);
public ValueTask SetIpv6DiagnosticsAsync(string id, string diagnosticsJson, CancellationToken ct) =>
diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/StorageJson.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/StorageJson.cs
index 996c734..51b9490 100644
--- a/backend/src/MoShiro.Backend.Storage.Sqlite/StorageJson.cs
+++ b/backend/src/MoShiro.Backend.Storage.Sqlite/StorageJson.cs
@@ -1,12 +1,9 @@
using System.Text.Json;
-using Fleet.Core;
using System.Text.Json.Serialization;
namespace MoShiro.Backend.Storage.Sqlite;
[JsonSerializable(typeof(List))]
-[JsonSerializable(typeof(SignedStatementDto))]
-[JsonSerializable(typeof(MemberProofDto))]
[JsonSerializable(typeof(List))]
[JsonSerializable(typeof(Dictionary), TypeInfoPropertyName = "ParamMap")]
internal sealed partial class StorageJson : JsonSerializerContext
@@ -18,14 +15,6 @@ internal sealed partial class StorageJson : JsonSerializerContext
public static List Strings(string? json) =>
string.IsNullOrEmpty(json) ? [] : JsonSerializer.Deserialize(json, Default.ListString) ?? [];
- public static string? ToJson(SignedStatementDto? value) => value is null ? null : JsonSerializer.Serialize(value, Default.SignedStatementDto);
-
- public static string? ToJson(MemberProofDto? value) => value is null ? null : JsonSerializer.Serialize(value, Default.MemberProofDto);
-
- public static SignedStatementDto? Grant(string? json) => string.IsNullOrEmpty(json) ? null : JsonSerializer.Deserialize(json, Default.SignedStatementDto);
-
- public static MemberProofDto? Proof(string? json) => string.IsNullOrEmpty(json) ? null : JsonSerializer.Deserialize(json, Default.MemberProofDto);
-
public static List Ints(string? json) =>
string.IsNullOrEmpty(json) ? [] : JsonSerializer.Deserialize(json, Default.ListInt32) ?? [];
}
diff --git a/backend/tests/MoShiro.Backend.Tests/AgentHarness.cs b/backend/tests/MoShiro.Backend.Tests/AgentHarness.cs
index a3c4ceb..2a4854b 100644
--- a/backend/tests/MoShiro.Backend.Tests/AgentHarness.cs
+++ b/backend/tests/MoShiro.Backend.Tests/AgentHarness.cs
@@ -41,14 +41,14 @@ public sealed class AgentHarness : IAsyncDisposable
public byte[] TlsPublicKey => _identity.Certificate.PublicKey.ExportSubjectPublicKeyInfo();
- /// 操作员签过委托的加入码(认证串、种子、创世指纹):节点按固定了创世的方式注册,名册里的 Probe 必须带操作员批准的证明。
- public static async Task StartAsync(BackendFixture backend, Action? configure = null, string? name = null, (string Auth, string Seed, string Genesis)? signed = null)
+ /// 带信任根固定的加入码(码、创世指纹、属主):节点按固定了创世和属主的方式注册。
+ public static async Task StartAsync(BackendFixture backend, Action? configure = null, string? name = null, (string Code, string Genesis, string[] Controllers)? pin = null)
{
string directory = Path.Combine(Path.GetTempPath(), "moshiro-agent-" + Guid.NewGuid().ToString("N"));
- string token = signed?.Auth ?? await backend.CreateJoinTokenAsync();
+ string token = pin?.Code ?? await backend.CreateJoinTokenAsync();
using var http = new HttpClient();
Result enrolled = await EnrollmentClient.EnrollAsync(
- http, backend.BaseAddress, token, name ?? backend.UniqueName("agent"), directory, AgentApp.Version, null, TimeProvider.System, CancellationToken.None, signed?.Seed, signed?.Genesis, "agent");
+ http, backend.BaseAddress, token, name ?? backend.UniqueName("agent"), directory, AgentApp.Version, null, TimeProvider.System, CancellationToken.None, pin?.Genesis, pin?.Controllers);
Assert.True(enrolled.IsSuccess, enrolled.IsSuccess ? "" : enrolled.Error.ToString());
// 测试里的 Agent 只在回环上监听:手填 public_host(手动的优先于自动识别,Agent 上报本机真实地址也不会覆盖)
diff --git a/backend/tests/MoShiro.Backend.Tests/ApiBasicsTests.cs b/backend/tests/MoShiro.Backend.Tests/ApiBasicsTests.cs
index 3f114fb..b7a7625 100644
--- a/backend/tests/MoShiro.Backend.Tests/ApiBasicsTests.cs
+++ b/backend/tests/MoShiro.Backend.Tests/ApiBasicsTests.cs
@@ -61,8 +61,11 @@ public sealed class ApiBasicsTests
[Fact]
public async Task Login_WithReturnSession_GivesABearerSession_WithoutACookie_AndNoCsrfNeeded()
{
+ // 密码登录的会话没有任何能力(只能注册 passkey);有能力的会话来自 passkey 登录
+ (string username, SoftwarePasskey passkey) = await _backend.CreateUserWithPasskeyAsync();
+ using SoftwarePasskey passkeyScope = passkey;
using HttpClient client = _backend.NewClient();
- using HttpResponseMessage login = await client.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = BackendFixture.AdminUser, ["password"] = BackendFixture.AdminPassword, ["return_session"] = true }));
+ using HttpResponseMessage login = await _backend.PasskeyLoginAsync(client, passkey);
Assert.Equal(HttpStatusCode.OK, login.StatusCode);
Assert.False(login.Headers.Contains("Set-Cookie"));
string session = JsonNode.Parse(await login.Content.ReadAsStringAsync())!["session_token"]!.GetValue();
@@ -73,7 +76,7 @@ public sealed class ApiBasicsTests
me.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", session);
using HttpResponseMessage meResponse = await client.SendAsync(me);
Assert.Equal(HttpStatusCode.OK, meResponse.StatusCode);
- Assert.Equal(BackendFixture.AdminUser, JsonNode.Parse(await meResponse.Content.ReadAsStringAsync())!["username"]!.GetValue());
+ Assert.Equal(username, JsonNode.Parse(await meResponse.Content.ReadAsStringAsync())!["username"]!.GetValue());
using var create = new HttpRequestMessage(HttpMethod.Post, "/api/admin/api-tokens") { Content = BackendFixture.Json(new JsonObject { ["name"] = "session-write", ["capabilities"] = new JsonArray("fleet.read") }) };
create.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", session);
@@ -94,8 +97,10 @@ public sealed class ApiBasicsTests
[Fact]
public async Task SessionCookie_RequiresCsrfHeaderOnUnsafeMethods()
{
+ (_, SoftwarePasskey passkey) = await _backend.CreateUserWithPasskeyAsync();
+ using SoftwarePasskey passkeyScope = passkey;
using HttpClient client = _backend.NewClient();
- using HttpResponseMessage login = await client.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = BackendFixture.AdminUser, ["password"] = BackendFixture.AdminPassword }));
+ using HttpResponseMessage login = await _backend.PasskeyLoginAsync(client, passkey, returnSession: false);
string csrf = JsonNode.Parse(await login.Content.ReadAsStringAsync())!["csrf_token"]!.GetValue();
string cookie = login.Headers.GetValues("Set-Cookie").Single().Split(';')[0];
@@ -220,15 +225,6 @@ public sealed class ApiBasicsTests
Assert.Contains("moshiro_agents_online", text);
Assert.Contains("moshiro_analysis_queue_length", text);
}
-
- [Fact]
- public async Task InstallScripts_RequireAValidCode()
- {
- // 一行安装脚本凭一次性代码获取;没有代码或代码不能用是 404(完整行为见 InstallTests)
- using HttpResponseMessage response = await _backend.Anonymous.GetAsync("/install.sh");
- Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
- Assert.Equal("not_found", JsonNode.Parse(await response.Content.ReadAsStringAsync())!["code"]!.GetValue());
- }
}
[CollectionDefinition("backend")]
diff --git a/backend/tests/MoShiro.Backend.Tests/BackendFixture.cs b/backend/tests/MoShiro.Backend.Tests/BackendFixture.cs
index f0d57e1..d7f4a9f 100644
--- a/backend/tests/MoShiro.Backend.Tests/BackendFixture.cs
+++ b/backend/tests/MoShiro.Backend.Tests/BackendFixture.cs
@@ -135,6 +135,46 @@ public class BackendFixture : IAsyncLifetime
Admin.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _adminToken);
}
+ /// 新建一个用户(管理员用 API key 建),用密码登录一次注册好 passkey。之后用 登录才有能力。
+ public async Task<(string Username, SoftwarePasskey Passkey)> CreateUserWithPasskeyAsync(string prefix = "user")
+ {
+ string name = UniqueName(prefix);
+ using HttpResponseMessage created = await Admin.PostAsync("/api/admin/users", Json(new JsonObject { ["username"] = name, ["password"] = "correct horse battery" }));
+ created.EnsureSuccessStatusCode();
+ using HttpClient session = NewClient();
+ using HttpResponseMessage login = await session.PostAsync("/api/admin/auth/login", Json(new JsonObject { ["username"] = name, ["password"] = "correct horse battery", ["return_session"] = true }));
+ login.EnsureSuccessStatusCode();
+ session.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", JsonNode.Parse(await login.Content.ReadAsStringAsync())!["session_token"]!.GetValue());
+ var passkey = new SoftwarePasskey(BaseAddress.Host, BaseAddress.GetLeftPart(UriPartial.Authority));
+ using HttpResponseMessage options = await session.PostAsync("/api/admin/auth/passkeys/options", null);
+ options.EnsureSuccessStatusCode();
+ string challenge = JsonNode.Parse(await options.Content.ReadAsStringAsync())!["challenge"]!.GetValue();
+ using HttpResponseMessage registered = await session.PostAsync("/api/admin/auth/passkeys", Json(passkey.Register("key", challenge)));
+ registered.EnsureSuccessStatusCode();
+ return (name, passkey);
+ }
+
+ /// 用 passkey 登录(可发现凭证,不用用户名); 为真时不设 Cookie、把会话令牌放在响应里。
+ public async Task PasskeyLoginAsync(HttpClient client, SoftwarePasskey passkey, bool returnSession = true)
+ {
+ using HttpResponseMessage options = await client.PostAsync("/api/admin/auth/passkey/options", null);
+ options.EnsureSuccessStatusCode();
+ JsonObject assertion = passkey.Assert(JsonNode.Parse(await options.Content.ReadAsStringAsync())!["challenge"]!.GetValue());
+ assertion["return_session"] = returnSession;
+ return await client.PostAsync("/api/admin/auth/passkey/login", Json(assertion));
+ }
+
+ /// 有能力的会话:新用户加 passkey,用 passkey 登录,会话令牌走 Bearer。
+ public async Task<(HttpClient Client, SoftwarePasskey Passkey, string Username)> CreateStrongSessionAsync(string prefix = "strong")
+ {
+ (string name, SoftwarePasskey passkey) = await CreateUserWithPasskeyAsync(prefix);
+ HttpClient client = NewClient();
+ using HttpResponseMessage login = await PasskeyLoginAsync(client, passkey);
+ login.EnsureSuccessStatusCode();
+ client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", JsonNode.Parse(await login.Content.ReadAsStringAsync())!["session_token"]!.GetValue());
+ return (client, passkey, name);
+ }
+
public HttpClient NewClient() => new() { BaseAddress = BaseAddress };
public string UniqueName(string prefix) => $"{prefix}-{Interlocked.Increment(ref _counter)}";
@@ -233,45 +273,49 @@ public class BackendFixture : IAsyncLifetime
await App.DisposeAsync();
}
+ ///
+ /// 管理员的全能力 API key:和线上一样的路径——密码登录的会话没有任何能力,只能注册 passkey;
+ /// 注册之后用 passkey 登录才有能力,再触碰一次开凭证窗口,才能创建带信任类能力的 key。
+ ///
private async Task CreateAdminTokenAsync()
{
using HttpClient session = NewClient();
using HttpResponseMessage login = await session.PostAsync(
- "/api/admin/auth/login", Json(new JsonObject { ["username"] = AdminUser, ["password"] = AdminPassword }));
+ "/api/admin/auth/login", Json(new JsonObject { ["username"] = AdminUser, ["password"] = AdminPassword, ["return_session"] = true }));
login.EnsureSuccessStatusCode();
- JsonNode body = JsonNode.Parse(await login.Content.ReadAsStringAsync())!;
- string cookie = login.Headers.GetValues("Set-Cookie").First().Split(';')[0];
-
- // 带信任类能力的 API key 要用 passkey 签一次才能创建(和线上一样):给管理员注册一个 passkey,触碰一次再创建
- string csrf = body["csrf_token"]!.GetValue();
- async Task SendAsync(string path, JsonObject? content)
- {
- using var request = new HttpRequestMessage(HttpMethod.Post, path) { Content = content is null ? null : Json(content) };
- request.Headers.Add("Cookie", cookie);
- request.Headers.Add("X-CSRF-Token", csrf);
- return await session.SendAsync(request);
- }
+ session.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", JsonNode.Parse(await login.Content.ReadAsStringAsync())!["session_token"]!.GetValue());
string origin = BaseAddress.GetLeftPart(UriPartial.Authority);
using var passkey = new SoftwarePasskey(BaseAddress.Host, origin);
- using (HttpResponseMessage options = await SendAsync("/api/admin/auth/passkeys/options", null))
+ using (HttpResponseMessage options = await session.PostAsync("/api/admin/auth/passkeys/options", null))
{
options.EnsureSuccessStatusCode();
string challenge = JsonNode.Parse(await options.Content.ReadAsStringAsync())!["challenge"]!.GetValue();
- using HttpResponseMessage registered = await SendAsync("/api/admin/auth/passkeys", passkey.Register("fixture", challenge));
+ using HttpResponseMessage registered = await session.PostAsync("/api/admin/auth/passkeys", Json(passkey.Register("fixture", challenge)));
registered.EnsureSuccessStatusCode();
}
- using (HttpResponseMessage options = await SendAsync("/api/admin/auth/elevation/options", new JsonObject { ["class"] = "credential" }))
+ // passkey 登录:拿到有能力的会话(令牌走 Bearer)
+ using HttpClient strong = NewClient();
+ using (HttpResponseMessage options = await strong.PostAsync("/api/admin/auth/passkey/options", null))
+ {
+ options.EnsureSuccessStatusCode();
+ JsonObject assertion = passkey.Assert(JsonNode.Parse(await options.Content.ReadAsStringAsync())!["challenge"]!.GetValue());
+ using HttpResponseMessage signedIn = await strong.PostAsync("/api/admin/auth/passkey/login", Json(assertion));
+ signedIn.EnsureSuccessStatusCode();
+ strong.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", JsonNode.Parse(await signedIn.Content.ReadAsStringAsync())!["session_token"]!.GetValue());
+ }
+
+ using (HttpResponseMessage options = await strong.PostAsync("/api/admin/auth/elevation/options", Json(new JsonObject { ["class"] = "credential" })))
{
options.EnsureSuccessStatusCode();
JsonObject assertion = passkey.Assert(JsonNode.Parse(await options.Content.ReadAsStringAsync())!["challenge"]!.GetValue());
assertion["class"] = "credential";
- using HttpResponseMessage elevated = await SendAsync("/api/admin/auth/elevation", assertion);
+ using HttpResponseMessage elevated = await strong.PostAsync("/api/admin/auth/elevation", Json(assertion));
elevated.EnsureSuccessStatusCode();
}
- using HttpResponseMessage created = await SendAsync("/api/admin/api-tokens", new JsonObject { ["name"] = "tests", ["capabilities"] = AllCapabilities() });
+ using HttpResponseMessage created = await strong.PostAsync("/api/admin/api-tokens", Json(new JsonObject { ["name"] = "tests", ["capabilities"] = AllCapabilities() }));
created.EnsureSuccessStatusCode();
return JsonNode.Parse(await created.Content.ReadAsStringAsync())!["plaintext"]!.GetValue();
}
diff --git a/backend/tests/MoShiro.Backend.Tests/CapabilityTests.cs b/backend/tests/MoShiro.Backend.Tests/CapabilityTests.cs
index ec58046..118d1f7 100644
--- a/backend/tests/MoShiro.Backend.Tests/CapabilityTests.cs
+++ b/backend/tests/MoShiro.Backend.Tests/CapabilityTests.cs
@@ -119,7 +119,7 @@ public sealed class CapabilityTests
string[] ids = [.. catalog["groups"]!.AsArray().SelectMany(g => g!["capabilities"]!.AsArray()).Select(c => c!["id"]!.GetValue())];
Assert.Equal(CapabilityCatalog.All.Select(c => c.Id).Order(StringComparer.Ordinal).ToArray(), ids.Order(StringComparer.Ordinal).ToArray());
Assert.Contains(catalog["presets"]!.AsArray(), p => p!["id"]!.GetValue() == "all");
- Assert.Contains(catalog["groups"]!.AsArray().SelectMany(g => g!["capabilities"]!.AsArray()), c => c!["id"]!.GetValue() == Capability.FleetEnroll && c["trust"]!.GetValue());
+ Assert.Contains(catalog["groups"]!.AsArray().SelectMany(g => g!["capabilities"]!.AsArray()), c => c!["id"]!.GetValue() == Capability.FleetRevoke && c["trust"]!.GetValue());
}
[Fact]
diff --git a/backend/tests/MoShiro.Backend.Tests/ElevationTests.cs b/backend/tests/MoShiro.Backend.Tests/ElevationTests.cs
index 4f47c72..d34c93b 100644
--- a/backend/tests/MoShiro.Backend.Tests/ElevationTests.cs
+++ b/backend/tests/MoShiro.Backend.Tests/ElevationTests.cs
@@ -33,6 +33,12 @@ public sealed class ElevationTests
return client;
}
+ private async Task<(HttpClient, SoftwarePasskey)> StrongAsync()
+ {
+ (HttpClient client, SoftwarePasskey passkey, _) = await _backend.CreateStrongSessionAsync("elev");
+ return (client, passkey);
+ }
+
private async Task RegisterAsync(HttpClient session, string name = "key")
{
var passkey = new SoftwarePasskey(RpId, Origin);
@@ -57,57 +63,61 @@ public sealed class ElevationTests
private static async Task ProblemCodeAsync(HttpResponseMessage response) => JsonNode.Parse(await response.Content.ReadAsStringAsync())?["code"]?.GetValue();
- private static StringContent NewJoinCode() => BackendFixture.Json(new JsonObject { ["role"] = "agent" });
+ /// 需要「吊销」窗口的操作(删除节点):有窗口时返回 204(删除是幂等的),没有窗口返回 403 elevation_required。
+ private static Task Gated(HttpClient client) => client.DeleteAsync("/api/admin/agents/agt_nobody");
[Fact]
public async Task EnrollingNeedsAWindow_ThatOnlyAPasskeyTouchOpens()
{
- using HttpClient session = await NewSessionAsync();
- using SoftwarePasskey passkey = await RegisterAsync(session);
+ (HttpClient session, SoftwarePasskey passkey) = await StrongAsync();
+ using HttpClient sessionScope = session;
+ using SoftwarePasskey passkeyScope = passkey;
- using HttpResponseMessage denied = await session.PostAsync("/api/admin/join-tokens", NewJoinCode());
+ using HttpResponseMessage denied = await Gated(session);
Assert.Equal(HttpStatusCode.Forbidden, denied.StatusCode);
Assert.Equal("elevation_required", await ProblemCodeAsync(denied));
- Assert.Contains("enroll", await denied.Content.ReadAsStringAsync(), StringComparison.Ordinal);
+ Assert.Contains("revoke", await denied.Content.ReadAsStringAsync(), StringComparison.Ordinal);
- using HttpResponseMessage elevated = await ElevateAsync(session, passkey, "enroll");
+ using HttpResponseMessage elevated = await ElevateAsync(session, passkey, "revoke");
Assert.Equal(HttpStatusCode.OK, elevated.StatusCode);
- Assert.Equal(HttpStatusCode.Created, (await session.PostAsync("/api/admin/join-tokens", NewJoinCode())).StatusCode);
- Assert.Equal(HttpStatusCode.Created, (await session.PostAsync("/api/admin/join-tokens", NewJoinCode())).StatusCode); // 窗口内不用再触碰
+ Assert.Equal(HttpStatusCode.NoContent, (await Gated(session)).StatusCode); // 有窗口:到了业务层(删除是幂等的)
+ Assert.Equal(HttpStatusCode.NoContent, (await Gated(session)).StatusCode); // 窗口内不用再触碰
// 窗口只管它那一种
- using HttpResponseMessage revoke = await session.DeleteAsync("/api/admin/agents/agt_nobody");
- Assert.Equal("elevation_required", await ProblemCodeAsync(revoke));
+ using HttpResponseMessage credential = await session.PostAsync("/api/admin/users", BackendFixture.Json(new JsonObject { ["username"] = _backend.UniqueName("x"), ["password"] = Password }));
+ Assert.Equal("elevation_required", await ProblemCodeAsync(credential));
}
[Fact]
public async Task ApiKeys_CarryTheirCapabilities_AndSkipTheWindow()
{
- using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/join-tokens", NewJoinCode());
- Assert.Equal(HttpStatusCode.Created, created.StatusCode);
+ using HttpResponseMessage allowed = await Gated(_backend.Admin);
+ Assert.Equal(HttpStatusCode.NoContent, allowed.StatusCode); // API key 自带能力,不经过提权窗口
}
[Fact]
public async Task TheWindowCanBeListed_AndEndedEarly()
{
- using HttpClient session = await NewSessionAsync();
- using SoftwarePasskey passkey = await RegisterAsync(session);
+ (HttpClient session, SoftwarePasskey passkey) = await StrongAsync();
+ using HttpClient sessionScope = session;
+ using SoftwarePasskey passkeyScope = passkey;
Assert.Empty(JsonNode.Parse(await session.GetStringAsync("/api/admin/auth/elevation"))!["items"]!.AsArray());
- (await ElevateAsync(session, passkey, "enroll")).Dispose();
+ (await ElevateAsync(session, passkey, "revoke")).Dispose();
JsonArray open = JsonNode.Parse(await session.GetStringAsync("/api/admin/auth/elevation"))!["items"]!.AsArray();
- Assert.Equal("enroll", Assert.Single(open)!["class"]!.GetValue());
+ Assert.Equal("revoke", Assert.Single(open)!["class"]!.GetValue());
Assert.Equal(HttpStatusCode.NoContent, (await session.DeleteAsync("/api/admin/auth/elevation")).StatusCode);
Assert.Empty(JsonNode.Parse(await session.GetStringAsync("/api/admin/auth/elevation"))!["items"]!.AsArray());
- Assert.Equal(HttpStatusCode.Forbidden, (await session.PostAsync("/api/admin/join-tokens", NewJoinCode())).StatusCode);
+ Assert.Equal(HttpStatusCode.Forbidden, (await Gated(session)).StatusCode);
}
[Fact]
public async Task ACredentialWindow_IsSingleUse()
{
- using HttpClient session = await NewSessionAsync();
- using SoftwarePasskey passkey = await RegisterAsync(session);
+ (HttpClient session, SoftwarePasskey passkey) = await StrongAsync();
+ using HttpClient sessionScope = session;
+ using SoftwarePasskey passkeyScope = passkey;
(await ElevateAsync(session, passkey, "credential")).Dispose();
using HttpResponseMessage first = await session.PostAsync("/api/admin/users", BackendFixture.Json(new JsonObject { ["username"] = _backend.UniqueName("made"), ["password"] = Password }));
@@ -119,10 +129,8 @@ public sealed class ElevationTests
[Fact]
public async Task OnlyYourOwnPasskey_WithTheRightChallengeAndKind_OpensAWindow()
{
- using HttpClient mine = await NewSessionAsync();
- using SoftwarePasskey myPasskey = await RegisterAsync(mine);
- using HttpClient theirs = await NewSessionAsync();
- using SoftwarePasskey theirPasskey = await RegisterAsync(theirs);
+ (HttpClient mine, SoftwarePasskey myPasskey) = await StrongAsync();
+ (HttpClient theirs, SoftwarePasskey theirPasskey) = await StrongAsync();
// 别人的 passkey 签了我的挑战:不行(也不在 allow_credentials 里)
using HttpResponseMessage options = await mine.PostAsync("/api/admin/auth/elevation/options", BackendFixture.Json(new JsonObject { ["class"] = "enroll" }));
@@ -144,7 +152,7 @@ public sealed class ElevationTests
Assert.Equal(HttpStatusCode.Unauthorized, (await mine.PostAsync("/api/admin/auth/elevation", BackendFixture.Json(once))).StatusCode);
// 另一个用户的会话没有因此提权
- Assert.Equal(HttpStatusCode.Forbidden, (await theirs.PostAsync("/api/admin/join-tokens", NewJoinCode())).StatusCode);
+ Assert.Equal(HttpStatusCode.Forbidden, (await Gated(theirs)).StatusCode);
}
[Fact]
@@ -169,9 +177,10 @@ public sealed class ElevationTests
[Fact]
public async Task AKeyThatCarriesTrust_NeedsAPasskeySignatureToCreate()
{
- using HttpClient session = await NewSessionAsync();
- using SoftwarePasskey passkey = await RegisterAsync(session);
- var trusted = new JsonObject { ["name"] = _backend.UniqueName("ci"), ["capabilities"] = new JsonArray("fleet.enroll") };
+ (HttpClient session, SoftwarePasskey passkey) = await StrongAsync();
+ using HttpClient sessionScope = session;
+ using SoftwarePasskey passkeyScope = passkey;
+ var trusted = new JsonObject { ["name"] = _backend.UniqueName("ci"), ["capabilities"] = new JsonArray("fleet.revoke") };
var harmless = new JsonObject { ["name"] = _backend.UniqueName("ro"), ["capabilities"] = new JsonArray("fleet.read") };
Assert.Equal(HttpStatusCode.Created, (await session.PostAsync("/api/admin/api-tokens", BackendFixture.Json(harmless))).StatusCode);
@@ -184,7 +193,9 @@ public sealed class ElevationTests
[Fact]
public async Task ListingUsers_NeedsOnlyTheReadCapability()
{
- using HttpClient session = await NewSessionAsync();
+ (HttpClient session, SoftwarePasskey passkey) = await StrongAsync();
+ using HttpClient sessionScope = session;
+ using SoftwarePasskey passkeyScope = passkey;
Assert.Equal(HttpStatusCode.OK, (await session.GetAsync("/api/admin/users")).StatusCode); // 看列表不用提权
}
}
diff --git a/backend/tests/MoShiro.Backend.Tests/InstallTests.cs b/backend/tests/MoShiro.Backend.Tests/InstallTests.cs
index 8d38ada..95529b3 100644
--- a/backend/tests/MoShiro.Backend.Tests/InstallTests.cs
+++ b/backend/tests/MoShiro.Backend.Tests/InstallTests.cs
@@ -3,7 +3,10 @@ using System.Text.Json.Nodes;
namespace MoShiro.Backend.Tests;
-/// 一行安装:Agent 和 Probe 共用同一份脚本,角色、网关、镜像由一次性代码决定,节点名字默认取主机名。
+///
+/// 一行安装:Agent 和 Probe 共用同一份静态脚本(所有人拿到的字节都一样,所以哈希固定、可以先核对再执行),
+/// 面板地址、角色、一次性代码都走命令里的环境变量;脚本再向面板取 CI 签名的发布清单来验证镜像。
+///
[Collection("probe-e2e")]
public sealed class InstallTests
{
@@ -23,7 +26,7 @@ public sealed class InstallTests
[Theory]
[InlineData("agent")]
[InlineData("probe")]
- public async Task Commands_AreOneLiners_WithoutAPlaceholder_AndTheScriptIsFilledInForTheRole(string role)
+ public async Task Commands_VerifyTheScriptHash_ThenRunItWithThePublicValues(string role)
{
JsonNode created = await CreateAsync(role);
string code = created["plaintext"]!.GetValue();
@@ -31,33 +34,55 @@ public sealed class InstallTests
string linux = commands["linux"]!.GetValue();
string macos = commands["macos"]!.GetValue();
string windows = commands["windows"]!.GetValue();
- Assert.StartsWith("curl -fsSL '", linux, StringComparison.Ordinal);
- Assert.EndsWith("/install?code=" + code + "' | bash", linux, StringComparison.Ordinal);
- Assert.EndsWith("/install?code=" + code + "' | zsh", macos, StringComparison.Ordinal);
- Assert.StartsWith("irm '", windows, StringComparison.Ordinal);
- Assert.EndsWith("/install.ps1?code=" + code + "' | iex", windows, StringComparison.Ordinal);
+
+ Assert.StartsWith("f=$(mktemp) && curl -fsSL '", linux, StringComparison.Ordinal);
+ Assert.Contains("/install' -o \"$f\" && echo \"", linux, StringComparison.Ordinal);
+ Assert.Contains("sha256sum -c -", linux, StringComparison.Ordinal);
+ Assert.Contains($"MOSHIRO_ROLE='{role}'", linux, StringComparison.Ordinal);
+ Assert.Contains($"MOSHIRO_CODE='{code}'", linux, StringComparison.Ordinal);
+ Assert.EndsWith("sh \"$f\"", linux, StringComparison.Ordinal);
+ Assert.Contains("shasum -a 256 -c -", macos, StringComparison.Ordinal);
+ Assert.StartsWith("$f = Join-Path $env:TEMP", windows, StringComparison.Ordinal);
+ Assert.Contains($"$env:MOSHIRO_ROLE='{role}'", windows, StringComparison.Ordinal);
+ Assert.EndsWith("iex (Get-Content -Raw $f)", windows, StringComparison.Ordinal);
Assert.DoesNotContain("<", linux + macos + windows, StringComparison.Ordinal); // 没有要用户自己替换的占位符
-
- // 脚本不需要登录(拿代码就是授权),不带缓存
- using HttpResponseMessage sh = await _backend.Anonymous.GetAsync("/install?code=" + code);
- string script = await sh.Content.ReadAsStringAsync();
- Assert.Equal(HttpStatusCode.OK, sh.StatusCode);
- Assert.Equal("no-store", sh.Headers.CacheControl?.ToString());
- Assert.Contains($"ROLE='{role}'", script, StringComparison.Ordinal);
- Assert.Contains($"CODE='{code}'", script, StringComparison.Ordinal);
- Assert.Contains($"/moshiro-{role}:latest'", script, StringComparison.Ordinal);
- Assert.Contains("SERVER='http", script, StringComparison.Ordinal);
- Assert.DoesNotContain("@@", script, StringComparison.Ordinal);
-
- using HttpResponseMessage ps = await _backend.Anonymous.GetAsync("/install.ps1?code=" + code);
- string powershell = await ps.Content.ReadAsStringAsync();
- Assert.Equal(HttpStatusCode.OK, ps.StatusCode);
- Assert.Contains($"$role = '{role}'", powershell, StringComparison.Ordinal);
- Assert.DoesNotContain("@@", powershell, StringComparison.Ordinal);
}
[Fact]
- public async Task TheScript_GetsTheSignedManifestOfItsRole_ByteForByte_AndNothingWithoutAValidCode()
+ public async Task TheScripts_AreStatic_TheSameBytesForEveryone_AndNeedNoCode()
+ {
+ foreach (string path in new[] { "/install", "/install.sh", "/install.ps1" })
+ {
+ using HttpResponseMessage first = await _backend.Anonymous.GetAsync(path);
+ using HttpResponseMessage second = await _backend.Anonymous.GetAsync(path + "?code=jt_whatever");
+ Assert.Equal(HttpStatusCode.OK, first.StatusCode);
+ Assert.Equal("no-store", first.Headers.CacheControl?.ToString());
+ string body = await first.Content.ReadAsStringAsync();
+ Assert.Equal(body, await second.Content.ReadAsStringAsync()); // 带不带码、带什么码,内容都一样
+ Assert.DoesNotContain("@@", body, StringComparison.Ordinal);
+ Assert.Contains("MOSHIRO_SERVER", body, StringComparison.Ordinal);
+ Assert.Contains("MOSHIRO_ROLE", body, StringComparison.Ordinal);
+ Assert.Contains("MOSHIRO_CODE", body, StringComparison.Ordinal);
+ }
+
+ // 同一份字节:sh 的两个路径一致
+ Assert.Equal(await _backend.Anonymous.GetStringAsync("/install"), await _backend.Anonymous.GetStringAsync("/install.sh"));
+ }
+
+ [Fact]
+ public async Task TheScripts_CarryTheReleaseKey_ThatTheBackendShips()
+ {
+ string key = MoShiro.Backend.Api.InstallScripts.ReleaseKey();
+ Assert.StartsWith("-----BEGIN PUBLIC KEY-----", key, StringComparison.Ordinal);
+ foreach (string path in new[] { "/install", "/install.ps1" })
+ {
+ string script = (await _backend.Anonymous.GetStringAsync(path)).Replace("\r\n", "\n", StringComparison.Ordinal);
+ Assert.Contains(key.Replace("\r\n", "\n", StringComparison.Ordinal), script, StringComparison.Ordinal); // 脚本里内置的和面板发布的是同一把
+ }
+ }
+
+ [Fact]
+ public async Task TheScript_GetsTheSignedManifestOfItsRole_ByteForByte()
{
string manifest = """{"schema":1,"component":"probe","channel":"dev","image":"registry.example/moshiro-dev/moshiro-probe","digest":"sha256:0000000000000000000000000000000000000000000000000000000000000000","tag":"dev-1","version":"0.1.1","commit":"abc","built_at":"2026-10-06T00:00:00Z","seq":1}""";
using HttpResponseMessage published = await _backend.Admin.PostAsync("/api/admin/releases", BackendFixture.Json(new JsonObject { ["manifest"] = manifest, ["signature"] = "MEUCIQDc" }));
@@ -67,24 +92,33 @@ public sealed class InstallTests
using HttpResponseMessage served = await _backend.Anonymous.GetAsync("/install/manifest?code=" + code);
Assert.Equal(HttpStatusCode.OK, served.StatusCode);
Assert.Equal("no-store", served.Headers.CacheControl?.ToString());
- string body = await served.Content.ReadAsStringAsync();
- Assert.Contains("\"component\":\"probe\"", body, StringComparison.Ordinal); // 签名对象是原始字节,面板一个字节也不改
+ Assert.Contains("\"component\":\"probe\"", await served.Content.ReadAsStringAsync(), StringComparison.Ordinal); // 签名对象是原始字节,面板一个字节也不改
using HttpResponseMessage signature = await _backend.Anonymous.GetAsync("/install/manifest.sig?code=" + code);
Assert.Equal(HttpStatusCode.OK, signature.StatusCode);
Assert.False(string.IsNullOrWhiteSpace(await signature.Content.ReadAsStringAsync()));
-
- Assert.Equal(HttpStatusCode.NotFound, (await _backend.Anonymous.GetAsync("/install/manifest?code=jt_wrong")).StatusCode);
- Assert.Equal(HttpStatusCode.NotFound, (await _backend.Anonymous.GetAsync("/install/manifest.sig")).StatusCode);
}
[Fact]
- public async Task TheScript_CarriesTheReleaseKey_ToVerifyTheManifest()
+ public async Task TheManifest_IsNotServedForUnknownRevokedOrUsedUpCodes()
{
- string code = (await CreateAsync("agent"))["plaintext"]!.GetValue();
- string script = await _backend.Anonymous.GetStringAsync("/install?code=" + code);
- Assert.Contains("-----BEGIN PUBLIC KEY-----", script, StringComparison.Ordinal);
- Assert.DoesNotContain("@@", script, StringComparison.Ordinal);
- Assert.Contains("install/manifest?code=", script, StringComparison.Ordinal);
+ foreach (string path in new[] { "/install/manifest", "/install/manifest?code=", "/install/manifest?code=jt_nope", "/install/manifest.sig?code=jt_nope" })
+ {
+ using HttpResponseMessage response = await _backend.Anonymous.GetAsync(path);
+ Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
+ }
+
+ JsonNode revoked = await CreateAsync("probe");
+ using HttpResponseMessage delete = await _backend.Admin.DeleteAsync("/api/admin/join-tokens/" + revoked["token"]!["id"]!.GetValue());
+ Assert.True(delete.IsSuccessStatusCode);
+ using HttpResponseMessage gone = await _backend.Anonymous.GetAsync("/install/manifest?code=" + revoked["plaintext"]!.GetValue());
+ Assert.Equal(HttpStatusCode.NotFound, gone.StatusCode);
+
+ // 用完了(一次性令牌已经注册过一个节点):脚本在动手之前就能从这里知道,给出明确的提示
+ JsonNode used = await CreateAsync("probe");
+ string code = used["plaintext"]!.GetValue();
+ await using (await MoShiro.TestSupport.AgentSimulator.EnrollAsync(_backend.Anonymous, code, _backend.UniqueName("used"))) { }
+ using HttpResponseMessage after = await _backend.Anonymous.GetAsync("/install/manifest?code=" + code);
+ Assert.Equal(HttpStatusCode.NotFound, after.StatusCode);
}
[Fact]
@@ -93,29 +127,4 @@ public sealed class InstallTests
JsonNode created = await CreateAsync("agent");
Assert.StartsWith("key:", created["token"]!["created_by"]!.GetValue(), StringComparison.Ordinal); // Admin 客户端是 API key
}
-
- [Fact]
- public async Task Script_IsNotServedForUnknownRevokedOrUsedUpCodes()
- {
- foreach (string path in new[] { "/install", "/install?code=", "/install?code=jt_nope", "/install.ps1?code=jt_nope", "/install.sh?code=jt_nope" })
- {
- using HttpResponseMessage response = await _backend.Anonymous.GetAsync(path);
- Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
- }
-
- JsonNode revoked = await CreateAsync("agent");
- using HttpResponseMessage delete = await _backend.Admin.DeleteAsync("/api/admin/join-tokens/" + revoked["token"]!["id"]!.GetValue());
- Assert.True(delete.IsSuccessStatusCode);
- using HttpResponseMessage gone = await _backend.Anonymous.GetAsync("/install?code=" + revoked["plaintext"]!.GetValue());
- Assert.Equal(HttpStatusCode.NotFound, gone.StatusCode);
-
- // 用完了(一次性令牌已经注册过一个节点)
- JsonNode used = await CreateAsync("probe");
- string code = used["plaintext"]!.GetValue();
- using HttpResponseMessage before = await _backend.Anonymous.GetAsync("/install?code=" + code);
- Assert.Equal(HttpStatusCode.OK, before.StatusCode);
- await using (await MoShiro.TestSupport.AgentSimulator.EnrollAsync(_backend.Anonymous, code, _backend.UniqueName("used"))) { }
- using HttpResponseMessage after = await _backend.Anonymous.GetAsync("/install?code=" + code);
- Assert.Equal(HttpStatusCode.NotFound, after.StatusCode);
- }
}
diff --git a/backend/tests/MoShiro.Backend.Tests/PasskeyTests.cs b/backend/tests/MoShiro.Backend.Tests/PasskeyTests.cs
index 5d20934..2d85ec8 100644
--- a/backend/tests/MoShiro.Backend.Tests/PasskeyTests.cs
+++ b/backend/tests/MoShiro.Backend.Tests/PasskeyTests.cs
@@ -8,7 +8,7 @@ using Fleet.Core;
namespace MoShiro.Backend.Tests;
///