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; /// 软件实现的 WebAuthn 认证器(ES256),用来在测试里走完整的注册和登录。 -internal sealed class SoftwarePasskey : IDisposable +public sealed class SoftwarePasskey : IDisposable { private readonly ECDsa _key = ECDsa.Create(ECCurve.NamedCurves.nistP256); private uint _counter; diff --git a/backend/tests/MoShiro.Backend.Tests/StorageTests.cs b/backend/tests/MoShiro.Backend.Tests/StorageTests.cs index d8ef4b8..61ac795 100644 --- a/backend/tests/MoShiro.Backend.Tests/StorageTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/StorageTests.cs @@ -302,7 +302,7 @@ public sealed class StorageTests : IDisposable var tokens = new SqliteJoinTokenStore(_db); var fleet = new SqliteAgentStore(_db); byte[] hash = TokenHash.Sha256("jt_race"); - JoinTokenInfo created = await tokens.CreateAsync("jtk_1", hash, "note", ["a"], true, "", maxUses: 3, expiresAtMs: 10_000, nowMs: 1_000, createdBy: "alice", grant: null, default); + JoinTokenInfo created = await tokens.CreateAsync("jtk_1", hash, "note", ["a"], true, "", maxUses: 3, expiresAtMs: 10_000, nowMs: 1_000, createdBy: "alice", default); Assert.Equal(0, created.Uses); bool[] results = await Task.WhenAll(Enumerable.Range(0, 20).Select(_ => Task.Run(async () => await fleet.TryConsumeJoinTokenAsync("jtk_1", 2_000, default)))); @@ -310,7 +310,7 @@ public sealed class StorageTests : IDisposable Assert.Equal(3, (await fleet.FindJoinTokenAsync(hash, default))!.Uses); Assert.False(await fleet.TryConsumeJoinTokenAsync("jtk_1", 2_000, default)); - JoinTokenInfo expiring = await tokens.CreateAsync("jtk_2", TokenHash.Sha256("jt_exp"), null, [], true, "", 5, expiresAtMs: 5_000, nowMs: 1_000, createdBy: null, grant: null, default); + JoinTokenInfo expiring = await tokens.CreateAsync("jtk_2", TokenHash.Sha256("jt_exp"), null, [], true, "", 5, expiresAtMs: 5_000, nowMs: 1_000, createdBy: null, default); Assert.False(await fleet.TryConsumeJoinTokenAsync(expiring.Id, 5_000, default), "expired at the boundary"); Assert.True(await tokens.RevokeAsync("jtk_2", 3_000, default)); Assert.False(await tokens.RevokeAsync("jtk_2", 3_000, default)); diff --git a/backend/tests/MoShiro.Backend.Tests/TrustTests.cs b/backend/tests/MoShiro.Backend.Tests/TrustTests.cs index 8ceeca8..8a240a1 100644 --- a/backend/tests/MoShiro.Backend.Tests/TrustTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/TrustTests.cs @@ -8,17 +8,18 @@ using Fleet.Core; using Fleet.Enrollment.Client; using Fleet.Hosting; using Fleet.Identity; +using Microsoft.Extensions.DependencyInjection; +using MoShiro.Backend.Application; namespace MoShiro.Backend.Tests; /// -/// 信任根:操作员(passkey)签操作员集合和加入码的委托,节点从固定的创世指纹出发验证;码的种子面板看不到。 +/// 信任根:操作员(passkey)签操作员集合;安装命令里带着创世指纹和属主(都是公开值),节点注册时验证面板的集合并固定在本地。 /// 设计见 docs/access.md 第 7 节。 /// [Collection("probe-e2e")] public sealed class TrustTests { - private const string Password = "correct horse battery"; private static readonly ConditionalWeakTable s_operators = []; private static readonly SemaphoreSlim s_gate = new(1, 1); @@ -32,8 +33,6 @@ public sealed class TrustTests /// 一个已经建立了信任根的操作员:自己的用户、passkey、会话,以及创世指纹。 private sealed record Operator(HttpClient Session, SoftwarePasskey Passkey, string Genesis); - private string Origin => _backend.BaseAddress.GetLeftPart(UriPartial.Authority); - private async Task OperatorAsync() { await s_gate.WaitAsync(); @@ -44,20 +43,8 @@ public sealed class TrustTests return existing; } - string name = _backend.UniqueName("operator"); - using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/users", BackendFixture.Json(new JsonObject { ["username"] = name, ["password"] = Password })); - Assert.Equal(HttpStatusCode.Created, created.StatusCode); - var session = _backend.NewClient(); - using HttpResponseMessage login = await session.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = name, ["password"] = Password, ["return_session"] = true })); - session.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", JsonNode.Parse(await login.Content.ReadAsStringAsync())!["session_token"]!.GetValue()); - - var passkey = new SoftwarePasskey(_backend.BaseAddress.Host, Origin); - using (HttpResponseMessage options = await session.PostAsync("/api/admin/auth/passkeys/options", null)) - { - string challenge = JsonNode.Parse(await options.Content.ReadAsStringAsync())!["challenge"]!.GetValue(); - using HttpResponseMessage registered = await session.PostAsync("/api/admin/auth/passkeys", BackendFixture.Json(passkey.Register("operator", challenge))); - Assert.Equal(HttpStatusCode.Created, registered.StatusCode); - } + // 操作员:有 passkey、用 passkey 登录的会话(密码会话什么能力都没有) + (HttpClient session, SoftwarePasskey passkey, _) = await _backend.CreateStrongSessionAsync("operator"); // 创世要凭证窗口(立根的人要先证明自己),再用 passkey 签集合本身 await ElevateAsync(session, passkey); @@ -105,98 +92,127 @@ public sealed class TrustTests Assert.Equal(HttpStatusCode.OK, elevated.StatusCode); } - /// 浏览器做的事:生成码的密钥,种子派生认证串,passkey 对委托签名,创建码。返回种子和认证串。 - private async Task<(string Seed, string Auth, HttpStatusCode Status)> CreateSignedCodeAsync(Operator op, string role = "probe", SoftwarePasskey? signer = null, long? notAfter = null) + /// 创建一个加入码(普通 API key 就行,不需要 passkey),返回响应里的码和三条安装命令。 + private async Task<(string Code, JsonNode Commands, HttpStatusCode Status)> CreateCodeAsync(string role = "probe", string[]? controllers = null) { - using ECDsa code = ECDsa.Create(ECCurve.NamedCurves.nistP256); - byte[] seed = code.ExportParameters(true).D!; - string auth = OperatorTrust.AuthToken(seed); - long now = TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds(); - string statement = OperatorTrust.Encode(new Grant(role, OperatorTrust.AuthHash(auth), code.ExportSubjectPublicKeyInfo(), now - 1000, notAfter ?? now + 3_600_000, 1)); - using HttpResponseMessage response = await op.Session.PostAsync("/api/admin/join-tokens/signed", BackendFixture.Json(new JsonObject { ["grant"] = Sign(signer ?? op.Passkey, statement), ["note"] = "trust test" })); - return (Fleet.Core.Base64Url.Encode(seed), auth, response.StatusCode); + var body = new JsonObject { ["role"] = role, ["note"] = "trust test" }; + if (controllers is not null) + { + body["controllers"] = new JsonArray([.. controllers.Select(c => (JsonNode?)c)]); + } + + using HttpResponseMessage response = await _backend.Admin.PostAsync("/api/admin/join-tokens", BackendFixture.Json(body)); + JsonNode result = JsonNode.Parse(await response.Content.ReadAsStringAsync())!; + return response.StatusCode == HttpStatusCode.Created ? (result["plaintext"]!.GetValue(), result["install_commands"]!, response.StatusCode) : ("", result, response.StatusCode); } - private async Task> EnrollAsync(string auth, string? seed, string? genesis, string role = "probe", string? directory = null) + private async Task> EnrollAsync(string code, string? genesis, string[]? controllers, string? directory = null) { directory ??= Path.Combine(Path.GetTempPath(), "moshiro-trust-id-" + Guid.NewGuid().ToString("N")); using var http = new HttpClient(); - return await EnrollmentClient.EnrollAsync(http, _backend.BaseAddress, auth, _backend.UniqueName("node"), directory, "0.0.0-test", null, TimeProvider.System, default, seed, genesis, role); + return await EnrollmentClient.EnrollAsync(http, _backend.BaseAddress, code, _backend.UniqueName("node"), directory, "0.0.0-test", null, TimeProvider.System, default, genesis, controllers); } [Fact] - public async Task ASignedCode_EnrollsANode_ThatPinsTheOperatorAndCarriesTheProof() + public async Task TheInstallCommand_CarriesOnlyPublicValues_AndChecksTheScriptHashFirst() { Operator op = await OperatorAsync(); - (string seed, string auth, HttpStatusCode status) = await CreateSignedCodeAsync(op); + (string code, JsonNode commands, HttpStatusCode status) = await CreateCodeAsync(); Assert.Equal(HttpStatusCode.Created, status); + string linux = commands["linux"]!.GetValue(); + string sha = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(await _backend.Anonymous.GetStringAsync("/install")))); + Assert.Contains($"echo \"{sha} $f\" | sha256sum -c -", linux, StringComparison.Ordinal); // 先核对哈希,一致才执行 + Assert.Contains($"MOSHIRO_CODE='{code}'", linux, StringComparison.Ordinal); + Assert.Contains($"MOSHIRO_GENESIS='{op.Genesis}'", linux, StringComparison.Ordinal); + Assert.Contains(op.Passkey.Id, linux, StringComparison.Ordinal); // 默认属主:操作员集合里的全部 passkey + Assert.DoesNotContain("SEED", linux + commands["macos"] + commands["windows"], StringComparison.Ordinal); // 没有任何私钥在命令里 + Assert.Contains("shasum -a 256 -c -", commands["macos"]!.GetValue(), StringComparison.Ordinal); + string windowsSha = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(await _backend.Anonymous.GetStringAsync("/install.ps1")))); + Assert.Contains($"-ne '{windowsSha}'", commands["windows"]!.GetValue(), StringComparison.Ordinal); + } + + [Fact] + public async Task ANode_PinsTheGenesisAndItsOwners_AtEnrollment() + { + Operator op = await OperatorAsync(); + (string code, _, _) = await CreateCodeAsync(controllers: [op.Passkey.Id]); + string directory = Path.Combine(Path.GetTempPath(), "moshiro-trust-id-" + Guid.NewGuid().ToString("N")); - Result enrolled = await EnrollAsync(auth, seed, op.Genesis, directory: directory); + Result enrolled = await EnrollAsync(code, op.Genesis, [op.Passkey.Id], directory); Assert.True(enrolled.IsSuccess, enrolled.IsSuccess ? "" : enrolled.Error.ToString()); Assert.True(enrolled.Value.Pinned); - // 节点本地:固定的创世、验证过的链、自己的证明;强制模式下对端必须带证明 TrustAnchor anchor = TrustAnchor.Load(directory, NullNodeLog.Instance); - Assert.True(anchor.Enforcing); + Assert.True(anchor.Pinned); Assert.Equal(op.Genesis, anchor.Genesis); - using LoadedNodeIdentity identity = LoadedNodeIdentity.Load(directory); - Assert.Equal(TrustFailure.None, anchor.VerifyPeer(anchor.OwnProof, identity.PeerIdentity.PublicKeySpki, "probe")); - Assert.Equal(TrustFailure.Malformed, anchor.VerifyPeer(null, identity.PeerIdentity.PublicKeySpki, "probe")); - Assert.NotEqual(TrustFailure.None, anchor.VerifyPeer(anchor.OwnProof, RandomNumberGenerator.GetBytes(91), "probe")); - - // 面板上这个码标记为已签名 - JsonNode tokens = JsonNode.Parse(await _backend.Admin.GetStringAsync("/api/admin/join-tokens"))!; - Assert.Contains(tokens["items"]!.AsArray(), t => t!["signed"]!.GetValue() && t["note"]?.GetValue() == "trust test"); + Assert.Equal([op.Passkey.Id], anchor.Controllers); + Assert.True(anchor.Version >= 1); } [Fact] public async Task ANodeWithAWrongPin_RefusesBeforeSendingAnything_AndTheCodeStaysUsable() { Operator op = await OperatorAsync(); - (string seed, string auth, _) = await CreateSignedCodeAsync(op); + (string code, _, _) = await CreateCodeAsync(); - Result wrong = await EnrollAsync(auth, seed, new string('0', 64)); + Result wrong = await EnrollAsync(code, new string('0', 64), [op.Passkey.Id]); Assert.False(wrong.IsSuccess); Assert.Contains("genesis", wrong.Error.Message, StringComparison.Ordinal); - Result right = await EnrollAsync(auth, seed, op.Genesis); // 码没被消耗 - Assert.True(right.IsSuccess, right.IsSuccess ? "" : right.Error.ToString()); + Result unknownOwner = await EnrollAsync(code, op.Genesis, ["not-a-credential-of-the-set"]); + Assert.False(unknownOwner.IsSuccess); + Assert.Contains("owner", unknownOwner.Error.Message, StringComparison.Ordinal); + + Assert.True((await EnrollAsync(code, op.Genesis, [op.Passkey.Id])).IsSuccess); // 前两次都在交出密钥之前被拒绝,码没有被消耗 } [Fact] - public async Task TheSeedIsNeededToEnroll_ACodeAloneNeverGetsANodeIn() + public async Task APlainCode_StillWorksWithoutAPin_ButTheNodeIsNotPinned() { - Operator op = await OperatorAsync(); - (string seed, string auth, _) = await CreateSignedCodeAsync(op); - - // 只有认证串(面板看得到的那部分):不带种子,旧客户端的流程,面板要求绑定 - Result withoutSeed = await EnrollAsync(auth, null, null); - Assert.False(withoutSeed.IsSuccess); - - // 种子不是这个码的:节点自己就不干 - (string otherSeed, _, _) = await CreateSignedCodeAsync(op); - Result mismatched = await EnrollAsync(auth, otherSeed, op.Genesis); - Assert.False(mismatched.IsSuccess); - - Assert.True((await EnrollAsync(auth, seed, op.Genesis)).IsSuccess); + (string code, _, _) = await CreateCodeAsync(); + string directory = Path.Combine(Path.GetTempPath(), "moshiro-trust-id-" + Guid.NewGuid().ToString("N")); + Result enrolled = await EnrollAsync(code, null, null, directory); + Assert.True(enrolled.IsSuccess); + Assert.False(enrolled.Value.Pinned); + Assert.False(TrustAnchor.Load(directory, NullNodeLog.Instance).Pinned); } [Fact] - public async Task ARoleMismatch_OrAnExpiredGrant_OrAForeignSigner_IsRefused() + public async Task TheOwnersOfACode_MustBePasskeysOfTheOperatorSet() + { + await OperatorAsync(); + (_, JsonNode problem, HttpStatusCode status) = await CreateCodeAsync(controllers: ["not-in-the-set"]); + Assert.Equal(HttpStatusCode.BadRequest, status); + Assert.Contains("controllers", problem.ToJsonString(), StringComparison.Ordinal); + } + + [Fact] + public async Task APinnedAgent_FollowsTheOperatorSet_WhenThePasskeysChange_AndOnlyWhenTheOperatorSigns() { Operator op = await OperatorAsync(); + (string code, _, _) = await CreateCodeAsync(role: "agent", controllers: [op.Passkey.Id]); + await using AgentHarness agent = await AgentHarness.StartAsync(_backend, pin: (code, op.Genesis, [op.Passkey.Id])); + Assert.True(agent.App.Link.IsSecure, "a pinned agent must be on the encrypted link (the server proved itself with the key in the operator set)"); + int before = agent.App.TrustVersion; + Assert.True(before >= 1); - (string seed, string auth, _) = await CreateSignedCodeAsync(op, role: "agent"); - Result asProbe = await EnrollAsync(auth, seed, op.Genesis, role: "probe"); // 节点以为自己是 probe,委托是给 agent 的 - Assert.False(asProbe.IsSuccess); + // 账号下多了一把 passkey:集合和 passkey 不一致,面板给出下一版要签的语句 + (_, SoftwarePasskey added) = await _backend.CreateUserWithPasskeyAsync("colleague"); + using SoftwarePasskey addedScope = added; + JsonNode state = JsonNode.Parse(await op.Session.GetStringAsync("/api/admin/trust"))!; + Assert.False(state["in_sync"]!.GetValue()); + string next = state["proposal"]!["statement"]!.GetValue(); - (_, _, HttpStatusCode expired) = await CreateSignedCodeAsync(op, notAfter: TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds() - 1000); - Assert.Equal(HttpStatusCode.BadRequest, expired); + // 不是操作员签的(新来的 passkey 还不在上一版里):不收 + using HttpResponseMessage forged = await op.Session.PostAsync("/api/admin/trust", BackendFixture.Json(Sign(added, next))); + Assert.True(forged.StatusCode is HttpStatusCode.Forbidden or HttpStatusCode.BadRequest, await forged.Content.ReadAsStringAsync()); + Assert.Equal(before, agent.App.TrustVersion); - using var stranger = new SoftwarePasskey(_backend.BaseAddress.Host, Origin); // 没注册的 passkey 签的委托 - (_, _, HttpStatusCode foreign) = await CreateSignedCodeAsync(op, signer: stranger); - Assert.Equal(HttpStatusCode.Forbidden, foreign); + // 现有操作员签:收下,随名册下发给节点,节点从固定的创世验过之后采用 + using HttpResponseMessage committed = await op.Session.PostAsync("/api/admin/trust", BackendFixture.Json(Sign(op.Passkey, next))); + Assert.Equal(HttpStatusCode.Created, committed.StatusCode); + await BackendFixture.EventuallyAsync(() => Task.FromResult(agent.App.TrustVersion > before), because: "the pinned agent should adopt the operator-signed set"); } [Fact] @@ -207,63 +223,86 @@ public sealed class TrustTests Assert.True(state["initialized"]!.GetValue()); Assert.Equal(op.Genesis, state["genesis"]!.GetValue()); - // 没有操作员签名的语句:不收 - JsonObject unsigned = Sign(op.Passkey, "moshiro-opset-v1\nversion=2\nprev=" + op.Genesis + "\nrp=x\norigins=x\nkey=a:AAAA"); - unsigned["statement"] = "moshiro-opset-v1\nversion=2\nprev=" + op.Genesis + "\nrp=" + _backend.BaseAddress.Host + "\norigins=" + Origin + "\nkey=a:AAAA"; // 语句被换掉,签名对不上 - using HttpResponseMessage tampered = await op.Session.PostAsync("/api/admin/trust", BackendFixture.Json(unsigned)); - Assert.True(tampered.StatusCode is HttpStatusCode.BadRequest or HttpStatusCode.Forbidden or HttpStatusCode.Conflict); + // 语句被换掉,签名对不上:不收 + JsonObject tampered = Sign(op.Passkey, "moshiro-opset-v1\nversion=99\nprev=x\nrp=x\norigins=x\nkey=a:AAAA"); + tampered["statement"] = "moshiro-opset-v1\nversion=2\nprev=" + op.Genesis + "\nrp=" + _backend.BaseAddress.Host + "\norigins=" + _backend.BaseAddress.GetLeftPart(UriPartial.Authority) + "\nkey=a:AAAA"; + using HttpResponseMessage rejected = await op.Session.PostAsync("/api/admin/trust", BackendFixture.Json(tampered)); + Assert.True(rejected.StatusCode is HttpStatusCode.BadRequest or HttpStatusCode.Forbidden or HttpStatusCode.Conflict); - // 匿名和只有 API key 的调用也没有可能立根 - using HttpResponseMessage anonymous = await _backend.Anonymous.PostAsync("/api/admin/trust", BackendFixture.Json(unsigned)); + // 匿名没有可能立根或改根 + using HttpResponseMessage anonymous = await _backend.Anonymous.PostAsync("/api/admin/trust", BackendFixture.Json(tampered)); Assert.Equal(HttpStatusCode.Unauthorized, anonymous.StatusCode); } [Fact] - public async Task APinnedAgent_OnlyAcceptsProbesTheOperatorApproved_EvenIfThePanelListsOthers() + public async Task APasswordSession_CanOnlyRegisterAPasskey_AndCreatingACodeNeedsNoPasskeyTouch() { - Operator op = await OperatorAsync(); - (string agentSeed, string agentAuth, _) = await CreateSignedCodeAsync(op, role: "agent"); - await using AgentHarness agent = await AgentHarness.StartAsync(_backend, signed: (agentAuth, agentSeed, op.Genesis)); + // 密码登录的会话没有任何能力:连看节点列表都不行,只能注册 passkey + string name = _backend.UniqueName("pwonly"); + using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/users", BackendFixture.Json(new JsonObject { ["username"] = name, ["password"] = "correct horse battery" })); + Assert.Equal(HttpStatusCode.Created, created.StatusCode); + using HttpClient password = _backend.NewClient(); + using HttpResponseMessage login = await password.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = name, ["password"] = "correct horse battery", ["return_session"] = true })); + password.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", JsonNode.Parse(await login.Content.ReadAsStringAsync())!["session_token"]!.GetValue()); + JsonNode me = JsonNode.Parse(await password.GetStringAsync("/api/admin/auth/me"))!; + Assert.Empty(me["capabilities"]!.AsArray()); + Assert.Equal("password", me["auth_method"]!.GetValue()); + Assert.Equal(HttpStatusCode.Forbidden, (await password.GetAsync("/api/admin/agents")).StatusCode); + Assert.Equal(HttpStatusCode.Forbidden, (await password.PostAsync("/api/admin/join-tokens", BackendFixture.Json(new JsonObject { ["role"] = "agent" }))).StatusCode); + using HttpResponseMessage options = await password.PostAsync("/api/admin/auth/passkeys/options", null); + Assert.Equal(HttpStatusCode.OK, options.StatusCode); - // 面板里有一个没有操作员证明的 Probe(相当于面板自己塞进名册的),再有一个操作员批准的 - using EnrolledProbe plain = await _backend.CreateProbeAsync(); - (string seed, string auth, _) = await CreateSignedCodeAsync(op, role: "probe"); - string directory = Path.Combine(Path.GetTempPath(), "moshiro-trust-id-" + Guid.NewGuid().ToString("N")); - Result enrolled = await EnrollAsync(auth, seed, op.Genesis, directory: directory); - Assert.True(enrolled.IsSuccess, enrolled.IsSuccess ? "" : enrolled.Error.ToString()); - using LoadedNodeIdentity approved = LoadedNodeIdentity.Load(directory); - - await BackendFixture.EventuallyAsync( - () => Task.FromResult(agent.App.IsPeerKnown(enrolled.Value.NodeId, Convert.ToBase64String(approved.PeerIdentity.PublicKeySpki))), - because: "the agent should accept the probe the operator approved"); - Assert.False(agent.App.IsPeerKnown(plain.ProbeId, Convert.ToBase64String(plain.Identity.PeerIdentity.PublicKeySpki)), "a probe without the operator's proof must not be accepted"); + // passkey 登录的会话创建码:不需要提权窗口、不需要再触碰(创建码不是信任动作,信任在属主固定里) + (HttpClient strong, SoftwarePasskey passkey, _) = await _backend.CreateStrongSessionAsync("creator"); + using HttpClient strongScope = strong; + using SoftwarePasskey passkeyScope = passkey; + using HttpResponseMessage code = await strong.PostAsync("/api/admin/join-tokens", BackendFixture.Json(new JsonObject { ["role"] = "agent" })); + Assert.Equal(HttpStatusCode.Created, code.StatusCode); } [Fact] - public async Task APinnedProbe_OnlyConnectsToAgentsTheOperatorApproved() + public async Task TheServerKey_IsSignedIntoTheOperatorSet_AndOnlyThatServerPassesTheNodesCheck() { Operator op = await OperatorAsync(); - (string probeSeed, string probeAuth, _) = await CreateSignedCodeAsync(op, role: "probe"); - string probeDirectory = Path.Combine(Path.GetTempPath(), "moshiro-trust-id-" + Guid.NewGuid().ToString("N")); - Assert.True((await EnrollAsync(probeAuth, probeSeed, op.Genesis, directory: probeDirectory)).IsSuccess); + JsonNode state = JsonNode.Parse(await op.Session.GetStringAsync("/api/admin/trust"))!; + Assert.True(state["server_key_listed"]!.GetValue()); + Assert.Equal(64, state["server_key"]!.GetValue().Length); - (string agentSeed, string agentAuth, _) = await CreateSignedCodeAsync(op, role: "agent"); - await using AgentHarness agent = await AgentHarness.StartAsync(_backend, signed: (agentAuth, agentSeed, op.Genesis)); - MemberProofDto? proof = TrustAnchor.Load(agent.DataDirectory, NullNodeLog.Instance).OwnProof; - string key = Convert.ToBase64String(agent.TlsPublicKey); + (string code, _, _) = await CreateCodeAsync(controllers: [op.Passkey.Id]); + string directory = Path.Combine(Path.GetTempPath(), "moshiro-trust-id-" + Guid.NewGuid().ToString("N")); + Assert.True((await EnrollAsync(code, op.Genesis, [op.Passkey.Id], directory)).IsSuccess); + TrustAnchor anchor = TrustAnchor.Load(directory, NullNodeLog.Instance); + Assert.True(anchor.Required); // 集合里登记了服务器密钥:节点要求服务器证明自己 - string configDirectory = Path.Combine(Path.GetTempPath(), "moshiro-probe-cfg-" + Guid.NewGuid().ToString("N")); - var directory = new MoShiro.Probe.AgentDirectory(new ConfigStore(configDirectory), configDirectory, NullNodeLog.Instance, TrustAnchor.Load(probeDirectory, NullNodeLog.Instance)); - directory.Apply(new MoShiro.Protocol.ProbeAgentList + // 真服务器的证明过得了节点的校验;任何别的密钥(冒充者)、或者换了临时公钥的都过不了 + ServerIdentityService server = _backend.Services.GetRequiredService(); + using var ephemeral = new EphemeralKey(); + byte[] proof = server.Sign(ServerHello.Payload("nonce-1", ephemeral.PublicKeySpki)); + Assert.True(anchor.Verify("nonce-1", ephemeral.PublicKeySpki, proof)); + using ECDsa impostor = ECDsa.Create(ECCurve.NamedCurves.nistP256); + Assert.False(anchor.Verify("nonce-1", ephemeral.PublicKeySpki, ServerHello.Sign(impostor, "nonce-1", ephemeral.PublicKeySpki))); + using var otherEphemeral = new EphemeralKey(); + Assert.False(anchor.Verify("nonce-1", otherEphemeral.PublicKeySpki, proof)); + Assert.False(anchor.Verify("nonce-2", ephemeral.PublicKeySpki, proof)); + } + + [Fact] + public async Task AnOperatorSetWithoutThisPanelsServerKey_IsNotAccepted() + { + Operator op = await OperatorAsync(); + JsonNode state = JsonNode.Parse(await op.Session.GetStringAsync("/api/admin/trust"))!; + string current = state["proposal"]?["statement"]?.GetValue() ?? ""; + if (current.Length == 0) { - Version = 1, - Agents = - [ - new MoShiro.Protocol.AgentEndpoint { AgentId = new NodeId("agt_approved"), Host = "127.0.0.1", TcpPort = 1, PublicKey = key, Proof = proof }, - new MoShiro.Protocol.AgentEndpoint { AgentId = new NodeId("agt_planted"), Host = "127.0.0.1", TcpPort = 2, PublicKey = key }, // 面板塞进来的:没有证明 - new MoShiro.Protocol.AgentEndpoint { AgentId = new NodeId("agt_swapped"), Host = "127.0.0.1", TcpPort = 3, PublicKey = Convert.ToBase64String(RandomNumberGenerator.GetBytes(91)), Proof = proof }, // 拿着别人的证明、换了自己的公钥 - ], - }); - Assert.Equal(["agt_approved"], directory.Agents.Select(a => a.AgentId)); + // 已经同步:用当前集合的内容拼一个没有服务器密钥的下一版 + (_, SoftwarePasskey extra) = await _backend.CreateUserWithPasskeyAsync("bystander"); + using SoftwarePasskey extraScope = extra; + state = JsonNode.Parse(await op.Session.GetStringAsync("/api/admin/trust"))!; + current = state["proposal"]!["statement"]!.GetValue(); + } + + string withoutServer = string.Join('\n', current.Split('\n').Where(l => !l.StartsWith("server=", StringComparison.Ordinal))); + using HttpResponseMessage refused = await op.Session.PostAsync("/api/admin/trust", BackendFixture.Json(Sign(op.Passkey, withoutServer))); + Assert.Equal(HttpStatusCode.BadRequest, refused.StatusCode); // 签下去会让固定了它的节点认不出这个面板 } } diff --git a/ci/publish-gitea-release.sh b/ci/publish-gitea-release.sh index 55cc612..35dea76 100644 --- a/ci/publish-gitea-release.sh +++ b/ci/publish-gitea-release.sh @@ -26,7 +26,10 @@ printf 'machine %s login justme password %s\n' "$gitea_host" "$GITEA_TOKEN" > "$ chmod 600 "$netrc" trap 'rm -rf "$tmp"' EXIT -release_body="Agent and Probe builds: Linux glibc and musl (x64, arm64), macOS (x64, arm64), and Windows (x64, arm64). Linux glibc Agent and Probe OCI images: registry.oeo.one/moshiro/moshiro-agent:$version and registry.oeo.one/moshiro/moshiro-probe:$version (linux/amd64 and linux/arm64). Backend OCI image: registry.oeo.one/moshiro/moshiro-backend:$version (linux/amd64 and linux/arm64). macOS binaries are not Developer ID signed or notarized." +root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +install_sh="$(sha256sum "$root_dir/packaging/install/install.sh" | cut -d" " -f1)" +install_ps1="$(sha256sum "$root_dir/packaging/install/install.ps1" | cut -d" " -f1)" +release_body="Install script SHA-256 (compare with the panel before you run an install command): install.sh $install_sh, install.ps1 $install_ps1. Agent and Probe builds: Linux glibc and musl (x64, arm64), macOS (x64, arm64), and Windows (x64, arm64). Linux glibc Agent and Probe OCI images: registry.oeo.one/moshiro/moshiro-agent:$version and registry.oeo.one/moshiro/moshiro-probe:$version (linux/amd64 and linux/arm64). Backend OCI image: registry.oeo.one/moshiro/moshiro-backend:$version (linux/amd64 and linux/arm64). macOS binaries are not Developer ID signed or notarized." payload="$(printf '{"tag_name":"%s","target_commitish":"%s","name":"MoShiro %s","body":"%s","draft":false,"prerelease":false}' \ "$tag" "${CI_COMMIT_SHA:-}" "$tag" "$release_body")" release_status="$(curl --silent --show-error --retry 3 --netrc-file "$netrc" \ diff --git a/ci/release-manifest.sh b/ci/release-manifest.sh index 5a73e7e..2594838 100644 --- a/ci/release-manifest.sh +++ b/ci/release-manifest.sh @@ -1,6 +1,8 @@ #!/usr/bin/env bash # 生成并签名一个镜像的发布清单:ci/release-manifest.sh <组件> <镜像仓库> <通道> <输出目录> # 签名私钥从环境变量 RELEASE_SIGNING_KEY(PEM 内容)读取,只写进一个 600 权限的临时文件,用完即删;私钥只放在 CI 密钥里,不进仓库、不上传面板。 +# 换发布密钥:把新公钥(PEM)放进环境变量 RELEASE_NEXT_KEY,清单里就会带 "next_key"(SPKI DER 的 base64);这份清单仍由旧密钥签名, +# 宿主机更新器验过之后改认新密钥。下一个版本起把 RELEASE_SIGNING_KEY 换成新私钥、去掉 RELEASE_NEXT_KEY,并把安装脚本和 release-key.pub.pem 里的公钥换成新的。 # 输出:<输出目录>/manifest.json(扁平 JSON,签名对象是它的原始字节)和 manifest.sig(base64 的 DER ECDSA 签名)。 set -euo pipefail @@ -16,10 +18,19 @@ version="$(sed -n 's#.*\(.*\).*#\1#p' "$root/backend/Director commit="$(git -C "$root" rev-parse --short=10 HEAD 2>/dev/null || true)" built_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" seq="$(date +%s)" +# 静态安装脚本的 SHA-256:面板给的命令会先核对它再执行;记在签名的清单里,面板和用户都能拿它和这份构建对照 +install_sh="$(sha256sum "$root/packaging/install/install.sh" | cut -d" " -f1)" +install_ps1="$(sha256sum "$root/packaging/install/install.ps1" | cut -d" " -f1)" + +next_key="" +if [[ -n "${RELEASE_NEXT_KEY:-}" ]]; then + next_key="$(printf '%s\n' "$RELEASE_NEXT_KEY" | openssl pkey -pubin -inform PEM -outform DER | base64 -w0 2>/dev/null || true)" + [[ -n "$next_key" ]] || { echo "RELEASE_NEXT_KEY is not a public key" >&2; exit 2; } +fi mkdir -p "$outdir" -printf '{"schema":1,"component":"%s","channel":"%s","image":"%s","digest":"%s","tag":"%s","version":"%s","commit":"%s","built_at":"%s","seq":%s}' \ - "$component" "$channel" "$repository" "$digest" "$tag" "$version" "$commit" "$built_at" "$seq" > "$outdir/manifest.json" +printf '{"schema":1,"component":"%s","channel":"%s","image":"%s","digest":"%s","tag":"%s","version":"%s","commit":"%s","built_at":"%s","seq":%s,"install_sh":"%s","install_ps1":"%s"%s}' \ + "$component" "$channel" "$repository" "$digest" "$tag" "$version" "$commit" "$built_at" "$seq" "$install_sh" "$install_ps1" "${next_key:+,\"next_key\":\"$next_key\"}" > "$outdir/manifest.json" key="$(mktemp)" trap 'rm -f "$key" "$outdir/manifest.sig.bin"' EXIT diff --git a/docs/access.md b/docs/access.md index fede523..b70ae3a 100644 --- a/docs/access.md +++ b/docs/access.md @@ -1,9 +1,38 @@ # 访问控制与入网信任链 -这份文档定义**谁能做什么**(账户、角色、能力、API key)和**谁能让节点入网**(passkey 到节点的签名链)。它是 [security.md](security.md) 第 5、6 节的展开:security.md 讲为什么(缺口和威胁),这里讲怎么做。**状态:设计已定,未实现**(实施顺序见第 12 节)。 +这份文档定义**谁能做什么**(账户、角色、能力、API key)和**谁能让节点入网**(passkey 到节点的签名链)。它是 [security.md](security.md) 第 5、6 节的展开:security.md 讲为什么(缺口和威胁),这里讲怎么做。**状态:部分实现**(当前实现见第 0 节,完整设计的实施顺序见第 12 节)。 一句话:登录和授权分开。登录证明「你是谁」,密码、passkey、OIDC 都可以;改变信任(加节点、吊销、改凭证)只认 passkey 的签名,并且这个签名要一路传到节点,Backend 只搬运,伪造不了。 +## 0. 当前实现(以这一节为准) + +下面的第 1~16 节是完整设计,其中「成员证明」那一整套(Grant、Ticket、Binding、每个节点的入网链,第 7.1~7.3 节)**当前没有采用**:评估之后,被攻破的 Backend 往名册里塞节点拿不到比 Backend 已经有的更多的东西(监测数据本来就是明文给 Backend 的;敏感的 extension 靠节点属主,不靠名册),成本却是每个节点一次触碰、一套证明格式和一套自动化委托。现在实现的是它的精简版: + +```text + 两个根,各管一摊 + 根 A:CI(代码) 发布公钥写在安装脚本和节点二进制里 + └─ CI 签名的发布清单 → 镜像 digest → 节点跑的二进制;安装脚本的 SHA-256 也记在清单里,并公布在发布页 + 假设:CI(Woodpecker、Gitea、镜像仓库、发布密钥)可信;CI 被攻破等于能给所有节点签恶意更新 + 根 B:你的 passkey(运维) + └─ 操作员集合(passkey 签,版本递增;创世指纹在安装时固定到节点) + ├─ 操作员的 passkey 公钥 + ├─ 服务器密钥:Backend 的身份密钥(节点据此认服务器,链路用应用层加密) + └─ 域名(rp、origins) + + 添加节点:创建加入码不需要 passkey、不需要触碰,一个码可以装任意多台。命令里只有公开的值: + 面板地址、角色、一次性码、创世指纹、属主(passkey 凭证 id)、安装脚本的哈希(命令先核对哈希再执行) + 节点注册:向面板取操作员集合链,从命令里的创世指纹验起,属主必须是集合里的凭证;任何一步不对就不注册、不交出密钥; + 通过后写 trust.json(创世指纹、链、属主),之后只认从这个创世出发的新版本 + 节点和 Backend 之间:集合里登记了服务器密钥之后,节点的认证挑战要求服务器用它签名;两边各出一把临时 ECDH 密钥, + 认证签名覆盖双方的临时公钥,之后控制链路每一帧都是 AES-256-GCM(每个方向一把密钥、严格递增的计数器)。 + 所以 TLS 在 Cloudflare 或被冒充的证书那里终止,中间人没有服务器密钥,既冒充不了服务器,也读不了、改不了这条链路 + 密码登录的会话没有任何能力,只能注册 passkey;有能力的会话来自 passkey 登录;信任类操作要提权窗口(passkey 触碰) +``` + +**发布密钥可以换**:见 `docs/deployment.md` 的「换发布密钥」;回滚已经由清单的 `seq` 防住(更新器只收比已安装更大的)。 + +**还没有的**:吊销表;任务和配置内容的操作员签名;节点自证跑的是官方构建;extension 本身和属主的 `controllers`、接收者的实际加密(只是被固定在节点本地,没有行为)。 + ## 1. 名词 | 名词 | 含义 | @@ -105,24 +134,11 @@ - 现有令牌迁移(破坏性):`read` → 全部 `*.read`;`admin` → 全部非信任类能力;`release` → `release.publish`。迁移后,旧的 admin 令牌不能再生成加入代码,需要换成 `enroll` key。 - 每把 key 可设:有效期、可选的 IP 白名单(CIDR,**待确认**)、`enroll` 类的数量上限和单券 TTL 上限;预留:资源范围、速率和数据配额(见 5.4)。 -### 5.2 `enroll` key 怎么工作 +### 5.2 `enroll` key:自动化添加节点 -```text - 创建(浏览器 + passkey 一次触控) - 浏览器生成种子 S,只显示一次(形如 mok_…) - 认证值 = HKDF(S, "auth") → Backend 只存哈希,用来识别这把 key - 签名密钥 = HKDF(S, "sign") → 公钥 P 写进委托;私钥只有拿着 S 的人算得出 - passkey 对委托签名:{P, scope=enroll, not_after, max_ticket_ttl, 数量上限, 签发凭证 id} - Backend 存委托和认证值哈希,不存 S +**当前实现:它就是一把带 `fleet.enroll` 的普通 API key**,用 `POST /api/admin/join-tokens` 创建加入码,响应里直接给出安装命令(带创世指纹、属主和脚本哈希,没有任何私钥)。因为创建码不是信任动作,所以不需要 passkey,也不需要提权窗口;CI 和脚本照常工作。信任在两处:命令里的创世指纹和属主由面板生成,节点注册时固定;以后改属主和集合要操作员 passkey 签名。 - 使用(和普通令牌一样的 HTTP 调用) - POST /api/admin/join-tokens Authorization: Bearer mok_S - Backend 用请求带来的 S 现算签名密钥,签出入网券和 K_code,返回一行安装命令;用完即忘 -``` - -- 数据库被偷走拿不到任何签名密钥。被攻破的 Backend 最多在 key 被使用的那一刻拿到 S,权力受这把 key 的有效期和单券 TTL 限制,key 过期或被撤销就没用了。 -- 数量上限由 Backend 执行,**节点强制不了**;节点只强制时间(见第 7.3 节)。 -- CI 和测试:夹具自己生成 key 种子,把公钥写进测试的操作员集合,不需要真 passkey。 +早先设计过的「委托密钥」(passkey 签一次委托,API key 自己签入网券,节点验证整条链)属于第 7.1~7.3 节的完整设计,当前没有采用,见第 0 节的取舍。 ### 5.3 创建界面 @@ -154,7 +170,9 @@ - 审计日志 `(时间, 账户, 凭证 id, 动作, 对象, 详情)`:创建或撤销 key、加入代码、吊销、删除、手填地址、确认更新、提权、因能力不足被拒绝的请求。保留 365 天(同事件表)。 - **可验证的一层**:委托里写进签名它的 passkey 凭证 id,入网券和节点绑定带着它,节点在 hello 里报告「由哪个凭证准入」。「谁加的」不只是 Backend 的一面之词。 -## 7. 入网信任链 +## 7. 入网信任链(完整设计,当前没有采用;当前实现见第 0 节) + +> 第 7.1~7.3 节描述的 Grant、Ticket、Binding、成员证明和节点验证在代码里**不存在**。要不要叠加回来,取决于你要不要防「被攻破的 Backend 往名册里塞节点」;操作员集合和创世固定已经在,叠加时不用重装节点。 ### 7.1 文档 @@ -221,6 +239,18 @@ 不在这个切片里:重启和改配置不需要签名的票;webssh 等 extension 见第 8 节。 +### 7.6 当前实现要点 + +- 语句是 `key=value` 文本,第一行是带版本的标题,签名覆盖整段文本;WebAuthn 的挑战是语句的 SHA-256。库在 `Fleet.Identity` 的 `OperatorTrust`(操作员集合的编码、解析、链验证)和 `SecureChannel`(服务器证明和链路加密),都是只用 BCL 的纯函数,节点和 Backend 共用。 +- **创世**:第一次建立信任根时,把账号下所有 passkey 和这个面板的服务器密钥登记成一个操作员集合,passkey 对它签名;创世语句的 SHA-256 就是命令里的 `MOSHIRO_GENESIS`。之后加删 passkey 或换服务器密钥要签新版本(上一版里的凭证签),页面会提示;签名更新不会让节点重装。 +- **操作员集合版本**随期望状态(`trust_chain`)下发,节点从固定的创世验起,版本比现有的新才采用并保存。 +- **节点注册**:`fleet-enroll --genesis … --controllers …`:先 `POST /api/agent/v1/enroll/trust` 取链,验过(创世指纹、签名、属主在集合里)才把密钥交出去;之后写 `trust.json`。没有命令里的指纹(`--genesis`)就不固定,节点按旧方式工作。 +- **服务器身份和安全链路**:`ChallengeParams` 带服务器的临时公钥和签名(`server_eph`、`server_sig`),`AuthParams` 带节点的临时公钥(`node_eph`),节点签名的用途是 `auth2`,覆盖双方的临时公钥;通过后两边用 HKDF 派生每个方向一把 AES-256-GCM 密钥,每帧 `计数器(8) ‖ 密文 ‖ 标签(16)`,计数器严格递增,明文帧、改动、重放、乱序一律断开。集合里没有服务器密钥的节点(旧节点)保持明文,不会被降级成放行。 +- **服务器密钥**存在 Backend 的设置里(`server.identity_key`):它防的是面板被冒充,不是面板被攻破;面板被攻破时它也泄露,那时要由 passkey 签一个换掉它的新集合版本。集合里没有这个面板的服务器密钥的版本,Backend 不收(否则固定了它的节点就认不出这个面板了)。 +- **安装脚本是静态的**:不替换任何值,所以 SHA-256 固定;CI 把哈希写进签名的发布清单和发布页,面板的信任页显示它和清单是否一致;命令先下载、核对哈希,一致才执行。 +- **密码登录的会话没有任何能力**,只能注册 passkey;创建加入码需要 `fleet.enroll`,不需要提权窗口。 +- **现状的缺口**:没有吊销表;删除一把操作员 passkey 只是让它不能再签新版本;任务和配置没有操作员签名;节点没有自证构建;服务器密钥换新要靠签一个新集合版本,忘了签会让固定了集合的节点认不出面板(fail closed)。 + ## 8. 节点属主与 extension 授权(预留) 后面会做 extension(web ssh、文件、抓包,见 [security.md](security.md) 第 11 节)。这类能力**连管理员都不信任**:管理员能删,不能读,也不能用。所以入网链现在就要给每个节点留出「属主」这个概念,即使 extension 还没做,字段也先固定下来,之后不用重装节点。 @@ -249,21 +279,46 @@ - **属主是 passkey 凭证,不是账户**:账户只是 Backend 里给人看的标签;节点只认公钥。一个人丢了全部属主 passkey,这个节点上的 extension 就没人能用了,要用就得重装节点(和第 3 节的恢复一致),所以添加时建议绑两把以上。 - 属主字段在**步骤 4(入网链)**就写进 Ticket;extension 本身、ControllerUpdate 和能力令牌放在阶段六。 -### 8.1 添加节点时选属主(接口现在就留) +### 8.0 范围:这一节先固定协议和要求,extension 本身先不做 -- API:`POST /api/admin/join-tokens` 和 `enroll` key 的签发请求都带可选的 `controllers: [credential_id…]`;缺省 = 签发账户的全部 passkey。校验:每个 id 必须是已注册的 passkey(任何账户的都可以,比如把同事的一把加进来),数量不超过上限。 -- 界面:「添加 Agent / Probe」对话框里一个「属主 passkey」多选列表,按账户分组,带**全选**、组内全选和反选;默认勾选自己的全部;至少选一把;提示「只有这些 passkey 能授权这个节点上的 web ssh 等敏感能力,管理员也不能」。 -- 节点详情页显示属主(凭证 id 和所属账户),以及「由谁绑定」;改属主走 `ControllerUpdate`,需要现有属主签名,管理员做不了。 -- 没有 extension 的现阶段,这组字段只是被签名、被节点持久化,不起别的作用。 +- **web ssh、文件、抓包等 extension 现在都不实现**,Agent 和 Probe 一样(Probe 是节点,同样要在本地执行下面的要求)。这里只把「谁来强制」和「怎么证明」定下来,以后实现时不改信任链,也不用重装节点。 +- **已经固定在节点本地的字段**:`controllers`(允许授权本节点 extension 会话的 passkey 凭证 id,必须是操作员集合里的凭证)由安装命令带来,节点注册时保存在 `trust.json`,Backend 之后改不了;从集合里去掉的凭证不再有效。默认是操作员集合里的全部凭证,空 = 不允许任何 extension 会话。接收者名单(`recipients`)等做加密时再加。节点还没有 extension,所以现在只是被固定和保存,不起别的作用。 -### 8.2 数据用属主的私钥解密:Backend 解不开,也改不了 +### 8.1 添加节点时选属主 + +- API:`POST /api/admin/join-tokens` 带可选的 `controllers: [credential_id…]`;缺省 = 操作员集合里的全部凭证。校验:每个 id 必须是操作员集合里的凭证。 +- 界面:「添加 Agent / Probe」对话框里一个「属主(passkey)」多选列表;只是选,不用触碰。选定的写进安装命令,节点装上时固定。 +- 以后改属主要现有属主的 passkey 签名(`ControllerUpdate`,没有实现);管理员改不了。 + +### 8.2 谁来强制:节点本地,不是 Backend + +web ssh 这类会话要不要加密、要不要验证,**由 Agent(和 Probe)自己要求**,不是 Backend。Backend 是被防的一方:让它决定,被攻破的 Backend 一句「这次不用加密」或自己充当会话的一端就绕过去了。所以要求落在节点本地,Backend 只能转发: + +| 节点本地强制的要求 | 为什么不能交给 Backend | +|---|---| +| 没有操作员的现场证明就不启动会话 | Backend 说「已经授权过了」不算数 | +| 会话密钥只和证明里的那把密钥协商,不和 Backend 转来的任何公钥协商 | 否则 Backend 换一把自己的公钥就成了中间人 | +| 加密字段缺失就拒绝,没有明文降级 | 防降级 | +| 能开哪些 extension、哪些 passkey 能授权,只看本节点属主(passkey 签的 `controllers`) | Backend 改不了属主,账户角色也不赋予任何权力 | +| 这些要求写在节点本地(编进二进制或本地 `trust.json`),Backend 下发的配置、任务、更新清单都覆盖不了 | 配置是 Backend 能碰的东西 | + +**要求证明的方向是反的:节点向请求者要证明,不信任 Backend 的转述。** 一次会话由节点先出题(nonce 加它的临时公钥,用节点身份密钥签名),浏览器把这个 nonce 和自己的临时公钥一起放进 passkey 断言的挑战里(见 8.3 的流程);节点验:断言来自固定的操作员集合里的凭证、要求用户验证、挑战里的节点 id、extension、过期时间都对得上、这把凭证在本节点的 `controllers` 里、nonce 是它自己刚出的且没用过。任何一条不满足,节点直接拒绝,不看 Backend 的任何说法。 + +**Backend 在这件事里仍然管**:谁有资格发起请求(一项 `extensions.use` 能力)、审计、限流、转发密文。这些只挡「不该问的人」;真正的放行始终是节点本地验过证明之后。 + +**交互式会话和静态数据用不同的密钥**: +- 交互式会话(终端、ssh):现场签名的临时密钥协商,**不依赖 PRF**,passkey 只用来签名授权,不参与解密,所以不支持 PRF 的 passkey 也能用,还有前向保密。 +- 静态数据(抓包文件、会话录像):需要事后解密,才用接收者名单里的公钥加密(`recipients`,来自 WebAuthn PRF 或浏览器里的不可导出密钥,见 8.3)。 + +### 8.3 数据用属主的私钥解密:Backend 解不开,也改不了 目标:交互式会话(web ssh、终端)、以及以后 extension 产生的数据(文件、抓包),**只有属主 passkey 对应的私钥能解开**。Backend 被篡改或被攻破,拿到的只有密文,且改动密文会被发现。 ```text 浏览器(属主) Backend(只搬运) 节点上的 extension + ⓪ 节点先出题:nonce 和它的临时公钥 n,签名后经 Backend 转给浏览器(见 8.2:要求证明的是节点) ① 生成会话临时 ECDH 密钥 e - ② passkey 对「e 的公钥 ‖ 节点 ID ‖ 命令哈希 ‖ nonce ‖ not_after」做 WebAuthn 断言(能力令牌) + ② passkey 对「e 的公钥 ‖ 节点 ID ‖ 命令哈希 ‖ 节点出的 nonce ‖ not_after」做 WebAuthn 断言(能力令牌) ──────────── 能力令牌 + e 公钥 ───────▶ 转发 ─────────────────────────▶ ③ 验:断言来自本节点属主 + 令牌新鲜、nonce 未用 ◀── 节点的会话公钥(节点身份密钥签名)── 转发 ◀────────────────────────── ④ 节点生成临时密钥 n,签名后返回 @@ -281,6 +336,20 @@ 属主记录里没有 `enc_pubkey` 的节点只支持实时会话,不能存加密的静态数据。 - 加密发生在属主这一侧的浏览器和节点上的 extension 之间,不经过 Backend 的任何账户、角色、会话机制,所以改 Backend 的数据库或代码也无从下手。 +### 8.4 数据边界:哪些明文,哪些加密,谁能解 + +| 数据 | 形态 | 谁能读 | +|---|---|---| +| **监测数据**(延迟、丢包、吞吐曲线、事件、主机资源汇总) | **明文**:Backend 要做分析、聚合和展示页 | 有 `data.read` 等能力的人和 key | +| **extension 会话**(web ssh、文件、抓包等,以后)、原始 payload | **端到端加密**:节点加密给接收者名单里的公钥,Backend 只存和转发密文 | 只有接收者私钥的持有者 | + +- **读取权不是能力,是私钥**:Backend 不持有任何接收者私钥,所以 `data.read`、`data.export` 对加密流只返回密文。API key 解不开,除非你登记过读者密钥。 +- **接收者名单由操作员固定**(做加密时和 `controllers` 一样,写进安装命令并固定在节点本地;现在还没有加密功能): + - 默认只有操作员自己的 passkey(经 WebAuthn PRF 派生的公钥)。 + - 接入密钥签出来的码,名单必须和操作员签的接入委托完全一致:它能让节点加入,不能让节点把数据加密给自己。 +- **读者密钥**(预留):自动化想读明文,要操作员用 passkey 显式登记一把读者公钥(范围、有效期),私钥由读者自己保管;吊销只管之后的数据,已经加密给它的历史数据它仍然能解。 +- 管和读互不相通:passkey 或接入密钥能让节点加入和批准,但不能解密;解密靠私钥,Backend 被攻破也拿不到。 + ## 9. 流程 | 流程 | 步骤 | @@ -317,6 +386,8 @@ 4. **入网链**:Grant、Ticket(含属主 `controllers`)、Binding 的签发和节点验证(验证一次、缓存结果);成员条目随期望状态下发、节点在状态同步时验证;Probe 只拨公网地址;`enroll` key;节点信任状态上报和管理页显示(第 14 节)。添加节点的界面可以选属主(默认添加者的全部 passkey)。 5. **吊销表与操作员集合更新**;自动化夹具(测试里的软件 ECDSA 凭证);**安装脚本的供应链**(第 15 节:静态签名脚本、按 digest 拉镜像)。 +实现状态:能力模型、passkey 登录、提权窗口、审计、操作员集合与创世固定、服务器身份和安全链路、静态安装脚本和哈希核对、属主固定都已实现(第 0 节);成员证明(第 7.1~7.3 节)没有采用;吊销表、任务和配置的操作员签名、节点自证构建、extension 属主的实际强制还没有做。 + ## 13. 局限 - 数量上限只能由签名人执行,节点只强制时间;窗口过期后设备被攻破的人,最多还能多签有效期内的入网券(受节点时钟约束)。 diff --git a/docs/deployment.md b/docs/deployment.md index 1693e8c..f534dd0 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -307,7 +307,14 @@ sh packaging/update/install-updater.sh agent --user \ 发布私钥只放在 Woodpecker 的 `release_signing_key` secret;发布公钥位于 `packaging/update/release-key.pub.pem`。CI 的 `release_api_token` 只有发布登记权限,`RELEASE_API_URL` 指向面板。面板保存并转发签名清单,Agent/Probe 将它与更新请求写进配置 volume;更新器验签后才调用容器引擎和 systemd。管理端仍能看到可更新、更新中、已回滚和无效清单状态。纯二进制更新器尚未实现。 -一行安装会验证镜像:脚本向面板取 CI 签名的发布清单(`/install/manifest`、`/install/manifest.sig`),用脚本里内置的发布公钥(`packaging/update/release-key.pub.pem`)验签,再按清单里的 digest 拉镜像(`镜像仓库@sha256:…`),签名对不上、组件不对或 digest 格式不对就拒绝安装;需要本机有 `openssl`(Windows 用 .NET 自带的)。面板还没有这个组件的已签名清单时,设 `MOSHIRO_ALLOW_UNVERIFIED=1` 才会按标签安装。脚本本身的完整性(哈希核对)见 [access.md](access.md) 第 15 节。 +**换发布密钥**(泄露或定期轮换,不用重装节点): + +1. 生成新密钥对;CI 给发布流水线加 `RELEASE_NEXT_KEY`(新公钥 PEM),仍用旧私钥签名。清单里会带 `next_key`(SPKI DER 的 base64)。 +2. 宿主机更新器验过这份清单后,把新公钥存到自己的状态目录(`<组件>.key.pem`),之后只认新密钥,旧密钥签的清单一律拒绝。清单里的 `next_key` 不是合法公钥就整份拒绝。 +3. 下一个版本起把 `release_signing_key` 换成新私钥、去掉 `RELEASE_NEXT_KEY`,并把 `packaging/update/release-key.pub.pem`、`install.sh`、`install.ps1` 里的公钥换成新的(安装脚本哈希随清单变化)。 +4. 还没收到过渡清单的节点(离线很久)会因为签名对不上而停在旧版本,需要手动换 `release-key.pub.pem`;旧密钥泄露时,这些节点是唯一的暴露面。 + +一行安装:页面给的命令先下载静态安装脚本(`/install`,所有人拿到的字节都一样)并核对它的 SHA-256,一致才执行;面板地址、角色、一次性码、信任根指纹(`MOSHIRO_GENESIS`)和属主(`MOSHIRO_CONTROLLERS`)走环境变量,都是公开值,没有私钥。脚本向面板取 CI 签名的发布清单(`/install/manifest`、`/install/manifest.sig`),用脚本里内置的发布公钥(`packaging/update/release-key.pub.pem`)验签,再按清单里的 digest 拉镜像(`镜像仓库@sha256:…`);签名对不上、组件不对或 digest 格式不对就拒绝安装;需要本机有 `openssl`(Windows 用 .NET 自带的)。面板还没有这个组件的已签名清单时,设 `MOSHIRO_ALLOW_UNVERIFIED=1` 和 `MOSHIRO_IMAGE=镜像` 才会安装。CI 把脚本的 SHA-256 写进签名清单和发布页,面板的「系统 → 信任」页显示它和清单是否一致。详见 [access.md](access.md) 第 0 节。 ## Backend 签名更新 diff --git a/docs/security.md b/docs/security.md index b6b755a..19a3a42 100644 --- a/docs/security.md +++ b/docs/security.md @@ -305,7 +305,7 @@ Agent 和 Probe 的核心永远是纯采集器。**所有敏感能力**(exec 1. 敲门先做等级一,等级二先记 TODO(已按此实现等级一)。 2. 严格程度:节点没有固定操作员公钥就拒绝一切 Backend 给的对端(默认),弱信任模式是安装时的显式选择(见 access.md 第 8 节)。 3. 节点面和外部面是否拆成两个监听端口(8.1 的目标):拆的话部署文档、Caddy 和隧道的配置都要跟着改。 -4. extension 的交互式会话**已定**:端到端加密,Backend 只转发密文(见 access.md 第 8.2 节);剩下的是静态数据私钥来源和 AEAD 的选择。 +4. extension 的交互式会话**已定**:端到端加密,Backend 只转发密文(见 access.md 第 8.2 和 8.3 节);剩下的是静态数据私钥来源和 AEAD 的选择。 5. extension 的第一个具体实现做不做 web ssh,放在哪个阶段。 6. 块摘要固定 SHA-256,不预留算法字段(不协商、不降级,见第 9.4 节)。 7. Agent 的 `max_total_mbps` 默认值调到多少(第 9.5 节),比如 100。 diff --git a/packaging/container/entrypoint.sh b/packaging/container/entrypoint.sh index e701be1..6fbc417 100644 --- a/packaging/container/entrypoint.sh +++ b/packaging/container/entrypoint.sh @@ -13,7 +13,13 @@ if [ ! -f "$identity/node.json" ]; then echo "first start: set MOSHIRO_SERVER, MOSHIRO_JOIN and MOSHIRO_NAME (the panel shows the complete command)" >&2 exit 1 fi - /usr/local/bin/fleet-enroll --server "$MOSHIRO_SERVER" --join "$MOSHIRO_JOIN" --name "$MOSHIRO_NAME" --identity-dir "$identity" + if [ -n "${MOSHIRO_GENESIS:-}" ]; then + # 命令里带着信任根指纹和属主(公开值):先验证面板的操作员集合,并把它们固定在本地 + /usr/local/bin/fleet-enroll --server "$MOSHIRO_SERVER" --join "$MOSHIRO_JOIN" --name "$MOSHIRO_NAME" --identity-dir "$identity" \ + --genesis "$MOSHIRO_GENESIS" --controllers "${MOSHIRO_CONTROLLERS:-}" + else + /usr/local/bin/fleet-enroll --server "$MOSHIRO_SERVER" --join "$MOSHIRO_JOIN" --name "$MOSHIRO_NAME" --identity-dir "$identity" + fi fi if [ ! -f "$config" ]; then diff --git a/packaging/install/install.ps1 b/packaging/install/install.ps1 index b3a662c..ba945c2 100644 --- a/packaging/install/install.ps1 +++ b/packaging/install/install.ps1 @@ -1,8 +1,10 @@ -# MoShiro 节点一键安装(Windows,Agent 和 Probe 共用同一份)。面板在 /install.ps1?code=… 生成这份脚本,把下面四个值填好: -# irm 'https://panel/install.ps1?code=jt_…' | iex -# 镜像:向面板取 CI 签名的发布清单,用内置的发布公钥验签,再按清单里的 digest 拉取。面板还没有签名的清单时,设 $env:MOSHIRO_ALLOW_UNVERIFIED='1' 才会按标签安装。 +# MoShiro 节点一键安装(Windows,Agent 和 Probe 共用同一份,**静态**文件:不替换任何值,所以它的 SHA-256 在一个版本内固定不变)。 +# 面板给的命令先下载这份文件、核对 SHA-256(CI 构建时算出,写进签名的发布清单,也公布在发布页),一致才执行。 +# 必需的环境变量:MOSHIRO_SERVER(面板地址)、MOSHIRO_ROLE(agent 或 probe)、MOSHIRO_CODE(一次性的加入码)。 # 做的事:用 Docker Desktop 起一个容器,身份和配置存在命名卷里(重启、换新镜像重建都不用再给参数),节点名字默认取计算机名。 -# 可选环境变量:MOSHIRO_NAME=节点名字(名字被占用时用)。 +# 可选环境变量:MOSHIRO_NAME=节点名字(名字被占用时用)、MOSHIRO_GENESIS / MOSHIRO_CONTROLLERS(命令里带着:信任根指纹和这个节点的属主 passkey 凭证 id,都是公开值)、 +# MOSHIRO_ALLOW_UNVERIFIED=1 加 MOSHIRO_IMAGE=镜像(面板还没有已签名的发布清单时,不验签、按这个镜像安装;默认拒绝)。 +# 镜像:向面板取 CI 签名的发布清单,用内置的发布公钥验签,再按清单里的 digest 拉取。 function Test-MoShiroSignature([byte[]]$data, [byte[]]$derSignature, [string]$pem) { # 发布公钥是 PEM 的 SPKI(P-256):最后 64 字节是 X、Y;签名是 DER,.NET 要 r||s $b64 = ($pem -split "`n" | Where-Object { $_ -notmatch '^-----' }) -join '' @@ -29,14 +31,22 @@ function Test-MoShiroSignature([byte[]]$data, [byte[]]$derSignature, [string]$pe function Install-MoShiro { $ErrorActionPreference = 'Stop' - $role = '@@ROLE@@' - $server = '@@SERVER@@' - $code = '@@CODE@@' - $image = '@@IMAGE@@' + $role = $env:MOSHIRO_ROLE + $server = $env:MOSHIRO_SERVER + $code = $env:MOSHIRO_CODE + $image = $env:MOSHIRO_IMAGE $releaseKey = @' -@@RELEASE_KEY@@ +-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEfw7EkNcl0FZqOETK+Vt55RMZ9K0q +YOrtO3Vso/QNdSMmepQlx1Q3v+KkN7ro3AY3I6DT2Q2txJu5pS7qzutSQA== +-----END PUBLIC KEY----- '@ + # 这些值来自命令行环境:只允许正常的字符,因为它们会进到 URL 和容器参数里 + if ($role -notin @('agent', 'probe')) { Write-Error 'MOSHIRO_ROLE must be agent or probe'; return } + if ($server -notmatch '^https?://[A-Za-z0-9._:/@~%+\[\]-]+$') { Write-Error 'MOSHIRO_SERVER must be the panel address (http:// or https://)'; return } + if ($code -notmatch '^[A-Za-z0-9._~%+-]+$') { Write-Error 'MOSHIRO_CODE is required (copy the whole command from the panel)'; return } + $name = if ($env:MOSHIRO_NAME) { $env:MOSHIRO_NAME } else { $env:COMPUTERNAME } $name = $name -replace '[^A-Za-z0-9._-]', '-' if ($name.Length -gt 64) { $name = $name.Substring(0, 64) } @@ -72,8 +82,8 @@ function Install-MoShiro { $manifest = $null } if ($null -eq $manifest) { - if ($env:MOSHIRO_ALLOW_UNVERIFIED -ne '1') { - Write-Error "the panel has no signed release for $role yet, so the image cannot be verified. Run again with `$env:MOSHIRO_ALLOW_UNVERIFIED='1' to install $image without verification" + if ($env:MOSHIRO_ALLOW_UNVERIFIED -ne '1' -or -not $image) { + Write-Error "the panel has no signed release for $role yet, so the image cannot be verified. To install anyway set `$env:MOSHIRO_ALLOW_UNVERIFIED='1' and `$env:MOSHIRO_IMAGE" return } Write-Host "warning: installing $image without signature verification" @@ -102,7 +112,13 @@ function Install-MoShiro { '-v', "${volume}:/etc/moshiro") # Docker Desktop 没有宿主网络:Agent 把端口发布出来(面板里的地址需要指向这台机器的公网地址) if ($role -eq 'agent') { $arguments += @('-p', '7443:7443/tcp', '-p', '7443:7443/udp', '-p', '7444:7444/tcp') } - $arguments += @('-e', "MOSHIRO_SERVER=$server", '-e', "MOSHIRO_JOIN=$code", '-e', "MOSHIRO_NAME=$name", $image) + $arguments += @('-e', "MOSHIRO_SERVER=$server", '-e', "MOSHIRO_JOIN=$code", '-e', "MOSHIRO_NAME=$name") + # 命令里带着信任根指纹和属主(公开值):节点在注册时验证面板的操作员集合并把它们固定在本地 + if ($env:MOSHIRO_GENESIS) { + if (($env:MOSHIRO_GENESIS + $env:MOSHIRO_CONTROLLERS) -notmatch '^[A-Za-z0-9_,-]+$') { Write-Error 'MOSHIRO_GENESIS or MOSHIRO_CONTROLLERS has unexpected characters'; return } + $arguments += @('-e', "MOSHIRO_GENESIS=$($env:MOSHIRO_GENESIS)", '-e', "MOSHIRO_CONTROLLERS=$($env:MOSHIRO_CONTROLLERS)") + } + $arguments += $image Write-Host "starting $container as '$name'" docker @arguments | Out-Null diff --git a/packaging/install/install.sh b/packaging/install/install.sh index 04d3271..2a5659b 100644 --- a/packaging/install/install.sh +++ b/packaging/install/install.sh @@ -1,21 +1,33 @@ #!/bin/sh -# MoShiro 节点一键安装(Agent 和 Probe 共用同一份)。面板在 /install?code=… 生成这份脚本,把下面四个值填好: -# curl -fsSL 'https://panel/install?code=jt_…' | bash (macOS 用 zsh) +# MoShiro 节点一键安装(Agent 和 Probe 共用同一份,**静态**文件:不替换任何值,所以它的 SHA-256 在一个版本内固定不变)。 +# 面板给的命令先下载这份文件、核对 SHA-256(CI 构建时算出,写进签名的发布清单,也公布在发布页),一致才执行: +# f=$(mktemp) && curl -fsSL 'https://panel/install' -o "$f" && echo " $f" | sha256sum -c - && MOSHIRO_SERVER=… MOSHIRO_ROLE=… MOSHIRO_CODE=… sh "$f" +# 必需的环境变量:MOSHIRO_SERVER(面板地址)、MOSHIRO_ROLE(agent 或 probe)、MOSHIRO_CODE(一次性的加入码)。 # 做的事:用 Docker 或 Podman 起一个容器,身份和配置存在命名卷里(重启、换新镜像重建都不用再给参数),节点名字默认取主机名。 # 可选环境变量:MOSHIRO_NAME=节点名字(名字被占用时用)、MOSHIRO_ENGINE=docker|podman、MOSHIRO_DRY_RUN=1(只打印命令)、 -# MOSHIRO_ALLOW_UNVERIFIED=1(面板还没有已签名的发布清单时,不验签、按标签安装;默认拒绝)。 +# MOSHIRO_GENESIS / MOSHIRO_CONTROLLERS(命令里带着:你的信任根指纹和这个节点的属主 passkey 凭证 id,节点在注册时验证面板的操作员集合并把它们固定在本地;都是公开值)、 +# MOSHIRO_ALLOW_UNVERIFIED=1 加 MOSHIRO_IMAGE=镜像(面板还没有已签名的发布清单时,不验签、按这个镜像安装;默认拒绝)。 # 镜像:向面板取 CI 签名的发布清单,用下面内置的发布公钥验签,再按清单里的 digest 拉取(镜像按内容寻址,换不了)。 set -eu -ROLE='@@ROLE@@' -SERVER='@@SERVER@@' -CODE='@@CODE@@' -IMAGE='@@IMAGE@@' -RELEASE_KEY='@@RELEASE_KEY@@' +SERVER="${MOSHIRO_SERVER:-}" +ROLE="${MOSHIRO_ROLE:-}" +CODE="${MOSHIRO_CODE:-}" +IMAGE="${MOSHIRO_IMAGE:-}" +RELEASE_KEY='-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEfw7EkNcl0FZqOETK+Vt55RMZ9K0q +YOrtO3Vso/QNdSMmepQlx1Q3v+KkN7ro3AY3I6DT2Q2txJu5pS7qzutSQA== +-----END PUBLIC KEY-----' say() { printf '%s\n' "$*"; } die() { printf 'error: %s\n' "$*" >&2; exit 1; } +# 这些值来自命令行环境:只允许正常的字符,因为它们会进到 URL 和容器参数里 +case "$ROLE" in agent|probe) ;; *) die "MOSHIRO_ROLE must be agent or probe" ;; esac +case "$SERVER" in http://*|https://*) ;; *) die "MOSHIRO_SERVER must be the panel address (http:// or https://)" ;; esac +case "$SERVER$CODE" in *[!A-Za-z0-9._:/@~%+\[\]-]*|'') die "MOSHIRO_SERVER or MOSHIRO_CODE has unexpected characters" ;; esac +[ -n "$CODE" ] || die "MOSHIRO_CODE is required (copy the whole command from the panel)" + name="${MOSHIRO_NAME:-$(hostname -s 2>/dev/null || hostname 2>/dev/null || echo node)}" name="$(printf '%s' "$name" | tr -c 'A-Za-z0-9._-' '-' | cut -c1-64)" [ -n "$name" ] || name=node @@ -51,14 +63,14 @@ fi # 验发布清单:签名对不上、组件不对、digest 格式不对都直接拒绝,不装 resolve_image() { - if [ "${MOSHIRO_DRY_RUN:-}" = 1 ]; then return 0; fi + if [ "${MOSHIRO_DRY_RUN:-}" = 1 ]; then IMAGE="${IMAGE:-}"; return 0; fi tmp="$(mktemp -d)" trap 'rm -rf "$tmp"' EXIT have_manifest=1 curl -fsS "$SERVER/install/manifest?code=$CODE" -o "$tmp/manifest" 2>/dev/null /dev/null " say "warning: installing $IMAGE without signature verification" return 0 fi @@ -96,7 +108,12 @@ elif [ "$ROLE" = agent ]; then # macOS 上的 Docker 没有宿主网络:把 Agent 的端口发布出来(面板里的地址需要指向这台机器的公网地址) set -- "$@" -p 7443:7443/tcp -p 7443:7443/udp -p 7444:7444/tcp fi -set -- "$@" -e "MOSHIRO_SERVER=$SERVER" -e "MOSHIRO_JOIN=$CODE" -e "MOSHIRO_NAME=$name" "$IMAGE" +set -- "$@" -e "MOSHIRO_SERVER=$SERVER" -e "MOSHIRO_JOIN=$CODE" -e "MOSHIRO_NAME=$name" +if [ -n "${MOSHIRO_GENESIS:-}" ]; then + case "$MOSHIRO_GENESIS${MOSHIRO_CONTROLLERS:-}" in *[!A-Za-z0-9_,-]*) die "MOSHIRO_GENESIS or MOSHIRO_CONTROLLERS has unexpected characters" ;; esac + set -- "$@" -e "MOSHIRO_GENESIS=$MOSHIRO_GENESIS" -e "MOSHIRO_CONTROLLERS=${MOSHIRO_CONTROLLERS:-}" +fi +set -- "$@" "$IMAGE" if [ "${MOSHIRO_DRY_RUN:-}" = 1 ]; then say "$engine $*" diff --git a/packaging/update/moshiro-update.sh b/packaging/update/moshiro-update.sh index 3e5940c..218daab 100644 --- a/packaging/update/moshiro-update.sh +++ b/packaging/update/moshiro-update.sh @@ -78,6 +78,7 @@ else chmod 755 "$STATUS_DIR" fi INSTALLED="$STATE_DIR/$COMPONENT.installed" # 两行:seq、digest +ADOPTED="$STATE_DIR/$COMPONENT.key.pem" # 发布密钥换新之后采用的公钥;有它就只认它,配置里的旧公钥不再用 mkdir -p "$STATE_DIR" # Serialize check/apply path events for this component. exec 9>"$STATE_DIR/$COMPONENT.lock" @@ -169,7 +170,18 @@ verify_offer() { [ "$(wc -c < "$OFFER_MANIFEST")" -le 4096 ] || { ERR="manifest too large"; return 1; } sigbin="$WORK/sig.bin" base64 -d "$OFFER_SIG" > "$sigbin" 2>/dev/null || { ERR="bad signature encoding"; return 1; } - openssl dgst -sha256 -verify "$PUBKEY" -signature "$sigbin" "$OFFER_MANIFEST" >/dev/null 2>&1 || { ERR="signature check failed"; return 1; } + key="$PUBKEY" + [ -s "$ADOPTED" ] && key="$ADOPTED" + openssl dgst -sha256 -verify "$key" -signature "$sigbin" "$OFFER_MANIFEST" >/dev/null 2>&1 || { ERR="signature check failed"; return 1; } + # 密钥延续:这份清单由当前密钥签名,里面的 next_key(SPKI DER 的 base64)就是以后要认的密钥;写不成合法的公钥就整份拒绝 + M_NEXT="" + next="$(field next_key)" + if [ -n "$next" ]; then + printf '%s' "$next" | base64 -d > "$WORK/next.der" 2>/dev/null \ + && openssl pkey -pubin -inform DER -in "$WORK/next.der" -outform PEM -out "$WORK/next.pem" >/dev/null 2>&1 \ + && [ -s "$WORK/next.pem" ] || { ERR="next_key is not a public key"; return 1; } + M_NEXT="$WORK/next.pem" + fi [ "$(field component)" = "$COMPONENT" ] || { ERR="manifest is for another component"; return 1; } [ "$(field image)" = "$IMAGE_REPO" ] || { ERR="manifest names another repository"; return 1; } if [ -n "$CHANNEL" ]; then @@ -183,6 +195,14 @@ verify_offer() { [ "$M_SEQ" -gt "$(installed_seq)" ] || { ERR="stale manifest (seq $M_SEQ <= $(installed_seq))"; return 1; } } +# 验过签的清单带了 next_key 就改认它(写临时文件再换名;已经是它就不动) +adopt_next_key() { + [ -n "$M_NEXT" ] || return 0 + if [ -s "$ADOPTED" ] && cmp -s "$M_NEXT" "$ADOPTED"; then return 0; fi + cp "$M_NEXT" "$ADOPTED.new" && mv -f "$ADOPTED.new" "$ADOPTED" + echo "release key replaced by the one in the signed manifest" +} + available_json() { printf '{"version":"%s","tag":"%s","digest":"%s","seq":%s}' "$(printf '%s' "$M_VERSION" | tr -d '\042\134')" "$(printf '%s' "$M_TAG" | tr -d '\042\134')" "$M_DIGEST" "$M_SEQ"; } do_check() { @@ -195,6 +215,7 @@ do_check() { esac return 0 fi + adopt_next_key if [ "$M_DIGEST" = "$(current_digest)" ]; then write_status up_to_date null "" return 0 @@ -215,6 +236,7 @@ do_apply() { fi fetch_offer verify_offer || { write_status invalid null "$ERR"; echo "refusing to apply: $ERR" >&2; return 1; } + adopt_next_key if [ "$COMPONENT" = backend ]; then case "$requested_seq" in ''|*[!0-9]*) write_status failed "$(available_json)" "a confirmed release sequence is required"; return 1 ;; esac [ "$requested_seq" = "$M_SEQ" ] || { write_status available "$(available_json)" "release changed; confirm again"; return 1; } diff --git a/web/admin/src/App.tsx b/web/admin/src/App.tsx index 7bf430a..ffdf817 100644 --- a/web/admin/src/App.tsx +++ b/web/admin/src/App.tsx @@ -9,6 +9,7 @@ import { useAuth } from './auth' import { BackendUpdateNotice } from './BackendUpdateNotice' import { ElevationBar } from './ElevationBar' import { PasskeyNotice } from './PasskeyNotice' +import { PasskeyOnly } from './PasskeyOnly' import { AgentDetail } from './pages/AgentDetail' import { Agents } from './pages/Agents' import { Probes } from './pages/Probes' @@ -242,6 +243,8 @@ export function App() { ) } if (!me) return + // 密码登录的会话没有任何能力:只给它添加 Passkey 的页面 + if (me.kind === 'session' && me.auth_method === 'password') return return (
diff --git a/web/admin/src/PasskeyOnly.tsx b/web/admin/src/PasskeyOnly.tsx new file mode 100644 index 0000000..d10cb2a --- /dev/null +++ b/web/admin/src/PasskeyOnly.tsx @@ -0,0 +1,61 @@ +import { KeyRound, LogOut } from 'lucide-react' +import { useEffect, useState } from 'react' +import { api } from '@shared/api/client' +import { getBase } from '@shared/api/source' +import type { Page, PasskeyDto } from '@shared/api/types' +import { passkeySupported } from '@shared/api/webauthn' +import { useAuth } from './auth' +import { PasskeyAddForm } from './pages/PasskeyAdd' + +/** + * 用密码登录的会话没有任何管理能力,只能做一件事:添加 Passkey。 + * 之后要用 Passkey 登录才能管理节点、令牌和设置——密码泄露最多只能让人给你的账号加一个 Passkey 的机会(还要你已有的 Passkey 批准第二个)。 + */ +export function PasskeyOnly() { + const { me, logout } = useAuth() + const [count, setCount] = useState(null) + + const load = () => { + api + .get>('/api/admin/auth/passkeys') + .then((list) => setCount(list.items.length)) + .catch(() => setCount(null)) + } + useEffect(load, []) + + const remote = !!getBase() + return ( +
+
+
+

MoShiro

+

+ {me?.username},你是用密码登录的。 +

+
+
用密码登录的会话不能管理任何东西,只能添加 Passkey。添加之后退出,用 Passkey 重新登录。
+ {remote ? ( +
这是另一个后端:Passkey 绑定在后端自己的域名上,请直接打开那个后端的管理端地址再登录。
+ ) : !passkeySupported() ? ( +
这个浏览器不支持 Passkey(需要 HTTPS 或 localhost)。
+ ) : count === 0 || count === null ? ( +
+
+ + 添加你的第一个 Passkey +
+ +
+ ) : ( +
你已经有 {count} 个 Passkey。退出后用 Passkey 登录即可。
+ )} +
+ +
+
+
+ ) +} diff --git a/web/admin/src/pages/Agents.tsx b/web/admin/src/pages/Agents.tsx index 7246b45..001e880 100644 --- a/web/admin/src/pages/Agents.tsx +++ b/web/admin/src/pages/Agents.tsx @@ -7,6 +7,7 @@ import { fmtAgo, fmtTime } from '@shared/format' import { useQuery } from '@shared/hooks' import { NodeTag, useNodeInfos } from '@shared/nodes' import { Chip, Confirm, Dialog, Dot, Empty, ErrorNotice, Field, Segmented, Spinner, useAction } from '@shared/ui' +import { OwnerPicker } from './OwnerPicker' import { JoinTokenCreated } from './JoinTokenCreated' export const STATUS: Record = { @@ -33,6 +34,7 @@ function AddAgentDialog({ onClose, onCreated }: { onClose: () => void; onCreated const [maxUses, setMaxUses] = useState(1) const [hours, setHours] = useState(24) const [created, setCreated] = useState() + const [controllers, setControllers] = useState(null) if (created) return @@ -57,6 +59,7 @@ function AddAgentDialog({ onClose, onCreated }: { onClose: () => void; onCreated auto_approve: autoApprove, max_uses: maxUses, expires_in_hours: hours, + controllers: controllers ?? undefined, }) setCreated(token) onCreated() @@ -69,7 +72,7 @@ function AddAgentDialog({ onClose, onCreated }: { onClose: () => void; onCreated } >

- 添加 Agent 就是创建一个一次性的加入令牌:在目标 VPS 上运行安装命令,Agent 会用它注册并出现在列表里。 + 添加 Agent 就是创建一个加入令牌:在目标 VPS 上运行安装命令,Agent 会用它注册并出现在列表里。一个令牌可以装任意多台(可用次数不设上限)。

@@ -83,11 +86,16 @@ function AddAgentDialog({ onClose, onCreated }: { onClose: () => void; onCreated
- setMaxUses(Number(e.target.value))} /> + setMaxUses(Number(e.target.value))} /> setHours(Number(e.target.value))} /> +
+ + + +
) diff --git a/web/admin/src/pages/Trust.tsx b/web/admin/src/pages/Trust.tsx new file mode 100644 index 0000000..ef3401c --- /dev/null +++ b/web/admin/src/pages/Trust.tsx @@ -0,0 +1,110 @@ +import { ShieldCheck } from 'lucide-react' +import { api } from '@shared/api/client' +import type { TrustStateDto } from '@shared/api/types' +import { signStatement } from '@shared/api/webauthn' +import { useQuery } from '@shared/hooks' +import { CopyButton, ErrorNotice, Spinner, useAction, useToast } from '@shared/ui' + +/** + * 信任根:你的 Passkey 是整个系统的根。节点安装时把下面这个指纹和属主(passkey)固定在本地,之后只认从它出发、由你的 Passkey 签过名的 + * 操作员集合,面板自己改不了,也签不出新的版本。 + */ +export function Trust() { + const trust = useQuery(() => api.get('/api/admin/trust'), []) + const { busy, run } = useAction() + const toast = useToast() + const state = trust.data + + const sign = async () => { + if (!state?.proposal) return + const proposal = state.proposal + const ok = await run(async () => { + const signed = await signStatement(proposal.statement) + await api.post('/api/admin/trust', signed) + }) + if (ok) { + toast(state.initialized ? `操作员集合已更新到第 ${proposal.version} 版,会随名册下发给节点。` : '信任根已建立。现在可以添加 Agent 和 Probe 了。', false, { title: '已用 Passkey 签名', ms: 8000 }) + trust.reload() + } + } + + return ( +
+ + {!state ? ( + + ) : !state.initialized ? ( +
+
+ + 还没有建立信任根 +
+

+ 建立之后,新装的节点会把你的信任根指纹和属主固定在本地:以后 web ssh 等敏感操作只认你的 Passkey,Backend 被攻破也改不了。添加节点本身不需要触碰 Passkey。 + 这一步会把账号下已有的 Passkey 登记为操作员,并需要你触碰两次 Passkey(确认身份、给集合签名)。 +

+ {state.proposal ? ( +
+ +
+ ) : ( +
先在「用户 → 我的 Passkey」添加一个 Passkey,才能建立信任根。
+ )} +
+ ) : ( +
+
+ + 信任根已建立 + 操作员集合第 {state.version} 版 +
+
+
+ 创世指纹(安装命令里的 MOSHIRO_GENESIS,节点把它固定下来) +
+
+              {state.genesis}
+            
+ +
+
+
+ 服务器身份密钥(面板用它向节点证明自己;登记进操作员集合之后,节点只认带这把密钥签名的服务器,链路用应用层加密) + {state.server_key_listed ? ':已登记' : ':还没有登记,需要签名更新操作员集合'} +
+
{state.server_key}
+
+
+
+ 安装脚本的 SHA-256(命令会先核对它再执行){state.install_matches_release === true ? ':与 CI 签名的发布清单一致' : state.install_matches_release === false ? ':与 CI 签名的发布清单不一致' : ':发布清单里还没有记录'} +
+ {state.install_matches_release === false ?
这个面板提供的安装脚本和 CI 构建时记下的不一样,先不要用它装节点,去发布页核对。
: null} +
install.sh   {state.install_sh_sha256}
+
install.ps1  {state.install_ps1_sha256}
+
+ {state.in_sync ? ( +

操作员集合和账号下的 Passkey 一致。

+ ) : ( +
+
+ 账号下的 Passkey(或站点域名)和生效的操作员集合不一致,比如新添加或删除了 Passkey。需要用一把现有操作员的 Passkey签一个新版本,节点才会采用。 +
+
+ 注意:从集合里去掉一把 Passkey 之后,由它批准过的节点会被固定了信任根的对端拒绝。 +
+ {state.proposal ? ( +
+ +
+ ) : null} +
+ )} +
+ )} +
+ ) +} diff --git a/web/shared/api/schema.ts b/web/shared/api/schema.ts index 4d0fcf7..db73696 100644 --- a/web/shared/api/schema.ts +++ b/web/shared/api/schema.ts @@ -867,6 +867,24 @@ export interface paths { patch?: never; trace?: never; }; + "/api/admin/trust": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** The operator trust root: genesis fingerprint, version, whether it matches the registered passkeys, and the next version to sign */ + get: operations["trust_get"]; + put?: never; + /** Store the next operator set, signed with a passkey (the first one sets the genesis and needs a credential window) */ + post: operations["trust_commit"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/admin/api-tokens": { parameters: { query?: never; @@ -1116,7 +1134,7 @@ export interface paths { path?: never; cookie?: never; }; - /** One-line installer for Linux and macOS nodes (agent or probe, decided by the code) */ + /** The static one-line installer for Linux and macOS nodes (same bytes for everyone; its SHA-256 is published with each release) */ get: operations["install_sh"]; put?: never; post?: never; @@ -1126,6 +1144,40 @@ export interface paths { patch?: never; trace?: never; }; + "/install/manifest": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** The signed release manifest of the component an install code is for (exact signed bytes) */ + get: operations["install_manifest"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/install/manifest.sig": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Base64 DER ECDSA P-256 signature of the manifest */ + get: operations["install_manifest_signature"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/install.ps1": { parameters: { query?: never; @@ -1133,7 +1185,7 @@ export interface paths { path?: never; cookie?: never; }; - /** One-line installer for Windows nodes (agent or probe, decided by the code) */ + /** The static one-line installer for Windows nodes (same bytes for everyone; its SHA-256 is published with each release) */ get: operations["install_ps1"]; put?: never; post?: never; @@ -1160,6 +1212,23 @@ export interface paths { patch?: never; trace?: never; }; + "/api/agent/v1/enroll/trust": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** The operator set chain and the grant of a join token, for the node to verify before it enrolls */ + post: operations["fleet_enroll_trust"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { @@ -1349,6 +1418,7 @@ export interface components { /** Format: int32 */ expires_in_hours: null | number | string; role?: null | string; + controllers?: null | string[]; }; CreateTaskRequest: { scope: string; @@ -1619,6 +1689,7 @@ export interface components { user_id: null | string; username: null | string; csrf_token: null | string; + auth_method?: null | string; }; MetricDto: { id: string; @@ -1999,6 +2070,13 @@ export interface components { default: string; description: string; }; + SignedStatementDto: { + statement?: string; + credential_id?: string; + authenticator_data?: string; + client_data_json?: string; + signature?: string; + }; StartTestRequestDto: { plan: string; params: null | Record; @@ -2085,6 +2163,32 @@ export interface components { /** Format: date-time */ finished_at: unknown; }; + TrustProposalDto: { + statement: string; + /** Format: int32 */ + version: number | string; + challenge: string; + }; + TrustStateDto: { + initialized: boolean; + genesis: null | string; + /** Format: int32 */ + version: number | string; + in_sync: boolean; + proposal: null | components["schemas"]["TrustProposalDto"]; + /** @default */ + install_url: string; + operators?: null | string[]; + /** @default */ + install_sh_sha256: string; + /** @default */ + install_ps1_sha256: string; + install_matches_release?: null | boolean; + /** @default */ + server_key: string; + /** @default false */ + server_key_listed: boolean; + }; UserDto: { id: string; username: string; @@ -5103,6 +5207,104 @@ export interface operations { }; }; }; + trust_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TrustStateDto"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + trust_commit: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SignedStatementDto"]; + }; + }; + responses: { + /** @description Created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TrustStateDto"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; api_tokens_list: { parameters: { query?: never; @@ -5899,6 +6101,53 @@ export interface operations { }; }; install_sh: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + install_manifest: { + parameters: { + query?: { + code?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + install_manifest_signature: { parameters: { query?: { code?: string; @@ -5929,9 +6178,7 @@ export interface operations { }; install_ps1: { parameters: { - query?: { - code?: string; - }; + query?: never; header?: never; path?: never; cookie?: never; @@ -5945,15 +6192,6 @@ export interface operations { }; content?: never; }; - /** @description Not Found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["ProblemDto"]; - }; - }; }; }; fleet_enroll: { @@ -5974,4 +6212,22 @@ export interface operations { }; }; }; + fleet_enroll_trust: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; } diff --git a/web/shared/api/types.ts b/web/shared/api/types.ts index d1059b3..27d44c7 100644 --- a/web/shared/api/types.ts +++ b/web/shared/api/types.ts @@ -42,6 +42,8 @@ export type CreatedApiTokenDto = Narrow export type CapabilityCatalogDto = Narrow export type PasskeyDto = Narrow export type ElevationDto = Narrow +export type TrustStateDto = Narrow +export type SignedStatementDto = Narrow export type PasskeyCreationOptionsDto = Narrow export type PasskeyRequestOptionsDto = Narrow export type MeDto = Narrow diff --git a/web/shared/api/webauthn.ts b/web/shared/api/webauthn.ts index 551403e..1c1e591 100644 --- a/web/shared/api/webauthn.ts +++ b/web/shared/api/webauthn.ts @@ -1,5 +1,5 @@ import { api } from './client' -import type { LoginResponse, PasskeyCreationOptionsDto, PasskeyDto, PasskeyRequestOptionsDto } from './types' +import type { LoginResponse, PasskeyCreationOptionsDto, PasskeyDto, PasskeyRequestOptionsDto, SignedStatementDto } from './types' /** 浏览器是否支持在输入框的自动填充里列出 passkey(conditional UI)。 */ export async function conditionalSupported(): Promise { @@ -58,6 +58,30 @@ export async function signInWithPasskey(opts: { conditional?: boolean; signal?: }) } +/** + * 用 passkey 对一段文本签名(挑战是文本的 SHA-256):操作员集合、加入码的委托都是这样签出来的,节点自己验。 + * 浏览器会让用户从这个站点的 passkey 里选一个;面板再检查它是不是操作员集合里的凭证。 + */ +export async function signStatement(statement: string): Promise { + const challenge = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(statement))) + let credential: PublicKeyCredential | null + try { + credential = (await navigator.credentials.get({ publicKey: { challenge, userVerification: 'required', timeout: 120_000 } })) as PublicKeyCredential | null + } catch (e) { + if (e instanceof DOMException && (e.name === 'NotAllowedError' || e.name === 'AbortError')) throw new Error('已取消') + throw e + } + if (!credential) throw new Error('没有选择 passkey') + const response = credential.response as AuthenticatorAssertionResponse + return { + statement, + credential_id: credential.id, + authenticator_data: toText(response.authenticatorData), + client_data_json: toText(response.clientDataJSON), + signature: toText(response.signature), + } +} + /** 提权窗口的种类:加节点、吊销和删除、改凭证(用一次就没有)。 */ export type ElevationClass = 'enroll' | 'revoke' | 'credential' diff --git a/web/shared/theme/base.css b/web/shared/theme/base.css index 1fd3f3c..1c51d2f 100644 --- a/web/shared/theme/base.css +++ b/web/shared/theme/base.css @@ -478,6 +478,67 @@ textarea { margin-bottom: 16px; } +/* 宽对话框:标题和按钮固定,中间滚动(内容很长的表单,比如选能力) */ +.dialog.wide { + width: min(980px, 100%); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.dialog.wide .dialog-body { + flex: 1; + min-height: 0; + overflow: auto; + margin: 0 -8px; + padding: 0 8px; +} + +.dialog.wide .actions { + margin-top: 16px; +} + +/* 能力选择器:工具栏吸顶,分组排成多列,每项两行(名称,代码和说明) */ +.cap-toolbar { + position: sticky; + top: 0; + z-index: 2; + background: var(--app-card); + padding-bottom: 10px; +} + +.cap-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 12px; + align-items: start; +} + +.cap-group { + border: 1px solid var(--app-outline-soft); + border-radius: 16px; + padding: 12px 14px; +} + +.cap-row { + padding: 5px 0 5px 22px; + border-top: 1px solid var(--app-outline-soft); +} + +.cap-row:first-of-type { + border-top: 0; +} + +.cap-meta { + margin: 1px 0 0 26px; + font-size: 12px; + line-height: 1.45; +} + +.cap-meta .mono { + margin-right: 6px; +} + .dialog .actions { display: flex; justify-content: flex-end; diff --git a/web/shared/ui/index.tsx b/web/shared/ui/index.tsx index d28f984..a7ca5e1 100644 --- a/web/shared/ui/index.tsx +++ b/web/shared/ui/index.tsx @@ -57,7 +57,7 @@ export function Field({ label, hint, children }: { label: string; hint?: string; ) } -export function Dialog({ title, onClose, children, actions }: { title: string; onClose: () => void; children: ReactNode; actions?: ReactNode }) { +export function Dialog({ title, onClose, children, actions, wide }: { title: string; onClose: () => void; children: ReactNode; actions?: ReactNode; wide?: boolean }) { useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() @@ -69,9 +69,9 @@ export function Dialog({ title, onClose, children, actions }: { title: string; o // 它会以那个祖先为定位基准,被挤到顶栏里、一半在屏幕外 return createPortal(
e.target === e.currentTarget && onClose()}> -
+

{title}

- {children} + {wide ?
{children}
: children} {actions ?
{actions}
: null}
,