diff --git a/.gitattributes b/.gitattributes index a074163..52bd65d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -6,3 +6,4 @@ packaging/update/*.sh text eol=lf packaging/container/*.sh text eol=lf ci/release-node-images.sh text eol=lf packaging/install/*.sh text eol=lf +packaging/install/*.ps1 text eol=lf diff --git a/README.md b/README.md index 019004a..9c5c48c 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ MoShiro 是一个自托管的**网络线路质量观测工具**。它在若干 P ## 信任关系 ```text - 管理员 ── 账号密码(Cookie 会话 + CSRF)或 API 令牌(read · admin · release)──▶ Backend + 管理员 ── 账号密码(Cookie 会话 + CSRF)或 API 令牌(按能力授权)──▶ Backend 访客 ── 匿名只读,站点设为公开时才可用(没有 IP、城市、ASN)──────────────▶ Backend 公开接口 节点 ── 加入令牌一次性注册,之后用自己的 ECDSA P-256 密钥签名认证 ───────▶ Backend(只存公钥) @@ -97,7 +97,8 @@ Backend、Agent、Probe 以 OCI 镜像发布(Linux amd64、arm64),Agent | 文档 | 内容 | | --- | --- | | [`docs/architecture.md`](docs/architecture.md) | 架构、数据链路、安全边界、设计决策 | -| [`docs/security.md`](docs/security.md) | 零信任设计稿(尚未实现):威胁模型、名册和操作员凭证、监听隐身、extension 授权 | +| [`docs/security.md`](docs/security.md) | 零信任设计稿(部分已实现):威胁模型、入网信任链、监听隐身、extension 授权 | +| [`docs/access.md`](docs/access.md) | 访问控制与入网信任链设计(尚未实现):账户、角色、能力、API key、提权窗口、passkey 到节点的签名链 | | [`docs/protocol.md`](docs/protocol.md)、[`backend/fleet/README.md`](backend/fleet/README.md) | 线协议、Fleet 节点框架(身份、注册、连接) | | [`docs/enrollment.md`](docs/enrollment.md) | 注册、身份、Probe 与 Agent 的接入 | | [`docs/measurement.md`](docs/measurement.md)、[`docs/analysis.md`](docs/analysis.md) | 采集引擎与任务模型、分析层与指标 | diff --git a/backend/Directory.Build.props b/backend/Directory.Build.props index 65e3c69..c120fbd 100644 --- a/backend/Directory.Build.props +++ b/backend/Directory.Build.props @@ -13,7 +13,7 @@ true $(MSBuildThisFileDirectory)artifacts true - 0.1.1 + 0.2.0 false diff --git a/backend/agent/src/MoShiro.Agent/AgentApp.cs b/backend/agent/src/MoShiro.Agent/AgentApp.cs index 8aba5c9..2c23bd6 100644 --- a/backend/agent/src/MoShiro.Agent/AgentApp.cs +++ b/backend/agent/src/MoShiro.Agent/AgentApp.cs @@ -18,6 +18,7 @@ public sealed class AgentApp : IDisposable private readonly AgentConfig _config; private readonly bool _ipv6Supported; private readonly KnockGate _knock; + private readonly TrustAnchor _trust; private readonly RestartController _restart; private readonly RestartController? _ownedRestart; private readonly LoadedNodeIdentity _identity; @@ -63,6 +64,7 @@ public sealed class AgentApp : IDisposable _admission = new AgentAdmission(_streams); _udp = new UdpMux(_sessions, _admission, _clock, _counters.Data, log, bind, config.Listen.UdpPorts); // 预握手敲门:控制端口和数据端口共用一个关卡(限速和去重一起算);名册就是期望状态里的 peers + _trust = TrustAnchor.Load(config.Fleet.IdentityDir, log); _knock = new KnockGate(identity.PeerIdentity, RosterKeys, time); _tcp = new TcpAcceptor(bind, config.Listen.TcpPorts, identity.Certificate, _sessions, _counters.Data, _knock); @@ -81,7 +83,7 @@ public sealed class AgentApp : IDisposable }; var chunks = new ChunkAssembler(_streams, _clock, _counters, _outbox, identity.NodeId, time, log); var targets = new TargetRunners(chunks, identity.NodeId, _clock, time, log); - _tasks = new TaskManager(CreateGuard, _streams, env, targets, () => _udp.BoundPorts, () => _tcp.BoundPorts); + _tasks = new TaskManager(CreateGuard, _streams, env, targets, () => _udp.BoundPorts, () => _tcp.BoundPorts, _trust); _hub = new PeerHub(_sessions, _clock, chunks, _tasks, _uplink, time, log); _tests = new TestRunner(_tasks, _sessions, _streams, env, _uplink, config.Limits.MaxConcurrentTests, transport => transport == "udp" ? _udp.BoundPorts : _tcp.BoundPorts, time, log); @@ -123,6 +125,9 @@ public sealed class AgentApp : IDisposable public int ControlPort => _peers.Port; + /// 节点当前采用的操作员集合版本(没有固定信任根时是 0)。 + public int TrustVersion => _trust.Version; + public AgentCounters Counters => _counters; public FleetLink Link => _link; @@ -270,6 +275,7 @@ public sealed class AgentApp : IDisposable { Server = new Uri(_config.Fleet.Server), Identity = _identity.Identity, + ServerVerifier = _trust, NodeId = _identity.NodeId, NodeVersion = Version, Capabilities = [MoShiroCapabilities.Base, MoShiroCapabilities.KindsPrefix + string.Join(',', BlockCatalog.AgentKinds)], diff --git a/backend/agent/src/MoShiro.Agent/Tasks/TaskManager.cs b/backend/agent/src/MoShiro.Agent/Tasks/TaskManager.cs index e61f699..d63792c 100644 --- a/backend/agent/src/MoShiro.Agent/Tasks/TaskManager.cs +++ b/backend/agent/src/MoShiro.Agent/Tasks/TaskManager.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using Fleet.Hosting; using Fleet.Core; using Fleet.Rpc; using MoShiro.Measurement; @@ -25,14 +26,16 @@ public sealed class TaskManager private readonly TargetRunners _targets; private readonly Func> _udpPorts; private readonly Func> _tcpPorts; + private readonly TrustAnchor _trust; private readonly Dictionary> _sessionLines = []; private volatile Dictionary _peers = new(StringComparer.Ordinal); private Dictionary _lineSpecs = []; private long _version; private BudgetGuard? _guard; - public TaskManager(Func guardFactory, StreamRegistry streams, BlockEnv env, TargetRunners targets, Func> udpPorts, Func> tcpPorts) + public TaskManager(Func guardFactory, StreamRegistry streams, BlockEnv env, TargetRunners targets, Func> udpPorts, Func> tcpPorts, TrustAnchor? trust = null) { + _trust = trust ?? TrustAnchor.None; _guardFactory = guardFactory; _streams = streams; _env = env; @@ -98,6 +101,8 @@ public sealed class TaskManager _ = ct; var result = new StateSyncResult { AppliedVersion = version }; + // 固定了操作员创世的节点:面板带来的新版本操作员集合,从固定的创世验过才采用(名册本身由面板决定) + _trust.Update(set.TrustChain); var peers = new Dictionary(StringComparer.Ordinal); foreach (PeerSpec peer in set.Peers) { diff --git a/backend/fleet/src/Fleet.Core/TrustDtos.cs b/backend/fleet/src/Fleet.Core/TrustDtos.cs new file mode 100644 index 0000000..22d16e3 --- /dev/null +++ b/backend/fleet/src/Fleet.Core/TrustDtos.cs @@ -0,0 +1,29 @@ +using System.Text.Json.Serialization; + +namespace Fleet.Core; + +/// passkey 对一段语句签出的断言(字段都是 base64url)。信任根的格式和验证在 Fleet.Identity 的 OperatorTrust。 +public sealed class SignedStatementDto +{ + [JsonPropertyName("statement")] + public string Statement { get; set; } = ""; + + [JsonPropertyName("credential_id")] + public string CredentialId { get; set; } = ""; + + [JsonPropertyName("authenticator_data")] + public string AuthenticatorData { get; set; } = ""; + + [JsonPropertyName("client_data_json")] + public string ClientDataJson { get; set; } = ""; + + [JsonPropertyName("signature")] + public string Signature { get; set; } = ""; +} + +/// 操作员集合的链(创世在前):节点注册时取来,从固定的创世指纹验过才采用。 +public sealed class TrustBundle +{ + [JsonPropertyName("chain")] + public List Chain { get; set; } = []; +} diff --git a/backend/fleet/src/Fleet.Enrollment.Client/EnrollmentClient.cs b/backend/fleet/src/Fleet.Enrollment.Client/EnrollmentClient.cs index 726d27c..35cb796 100644 --- a/backend/fleet/src/Fleet.Enrollment.Client/EnrollmentClient.cs +++ b/backend/fleet/src/Fleet.Enrollment.Client/EnrollmentClient.cs @@ -2,13 +2,17 @@ using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; +using System.Text.Json.Serialization; using Fleet.Core; using Fleet.Hosting; using Fleet.Identity; namespace Fleet.Enrollment.Client; -public sealed record EnrollmentResult(string NodeId, string Status, string IdentityDirectory); +[JsonSerializable(typeof(TrustBundle))] +internal sealed partial class TrustBundleContext : JsonSerializerContext; + +public sealed record EnrollmentResult(string NodeId, string Status, string IdentityDirectory, bool Pinned = false); /// /// 用加入令牌注册(供 fleet-enroll 使用,也可以嵌入产品自己的安装工具): @@ -18,7 +22,8 @@ public sealed record EnrollmentResult(string NodeId, string Status, string Ident public static class EnrollmentClient { public static async Task> EnrollAsync( - HttpClient http, Uri server, string joinToken, string name, string identityDirectory, string nodeVersion, JsonNode? productMeta, TimeProvider time, CancellationToken ct) + HttpClient http, Uri server, string joinToken, string name, string identityDirectory, string nodeVersion, JsonNode? productMeta, TimeProvider time, CancellationToken ct, + string? genesis = null, IReadOnlyList? controllers = null) { string nodeFile = Path.Combine(identityDirectory, LoadedNodeIdentity.NodeFileName); if (File.Exists(nodeFile)) @@ -36,6 +41,18 @@ public static class EnrollmentClient tlsPublicKey = certificate.PublicKey.ExportSubjectPublicKeyInfo(); } + // 安装命令里带了创世指纹(--genesis)和属主(--controllers):先取操作员集合链,从固定的创世验起, + // 属主必须是集合里的凭证;对不上就不注册,也不把自己的密钥交出去。通过之后固定到本地 trust.json,Backend 之后改不了。 + TrustBundle? bundle = null; + if (genesis is not null) + { + Result trusted = await FetchTrustAsync(http, server, joinToken, genesis, controllers ?? [], ct); + if (!trusted.TryGet(out bundle, out FleetError? trustError)) + { + return Result.Fail(trustError); + } + } + string pubkey = Convert.ToBase64String(identity.PublicKeySpki); string tokenHash = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(joinToken))); var body = new JsonObject @@ -76,10 +93,46 @@ public static class EnrollmentClient var store = new ConfigStore(identityDirectory); store.Write(LoadedNodeIdentity.KeyFileName, identity.ExportPkcs8()); store.Write(LoadedNodeIdentity.TlsFileName, pfx); + if (bundle is not null) + { + TrustAnchor.Pin(identityDirectory, genesis!, bundle.Chain, controllers ?? []); + } + // node.json 最后写:它存在就表示注册完整完成 store.Write( LoadedNodeIdentity.NodeFileName, JsonSerializer.SerializeToUtf8Bytes(new NodeFile { NodeId = nodeId, Server = server.ToString().TrimEnd('/'), TlsSpki = Convert.ToBase64String(spki) }, NodeFileContext.Default.NodeFile)); - return Result.Ok(new EnrollmentResult(nodeId, result["status"]!.GetValue(), identityDirectory)); + return Result.Ok(new EnrollmentResult(nodeId, result["status"]!.GetValue(), identityDirectory, bundle is not null)); + } + + private static async Task> FetchTrustAsync(HttpClient http, Uri server, string joinToken, string genesis, IReadOnlyList controllers, CancellationToken ct) + { + static Result Refuse(string reason) => Result.Fail(FleetErrors.ProofInvalid, "refusing to enroll: " + reason); + + using var request = new StringContent(new JsonObject { ["join_token"] = joinToken }.ToJsonString(), Encoding.UTF8, "application/json"); + using HttpResponseMessage response = await http.PostAsync(new Uri(server, "/api/agent/v1/enroll/trust"), request, ct); + if (!response.IsSuccessStatusCode) + { + return Result.Fail(FleetErrors.JoinTokenInvalid, $"{(int)response.StatusCode}: the panel would not give the trust chain"); + } + + TrustBundle? bundle = JsonSerializer.Deserialize(await response.Content.ReadAsStringAsync(ct), TrustBundleContext.Default.TrustBundle); + if (bundle is null || bundle.Chain.Count == 0) + { + return Refuse("the panel has no operator trust root set up"); + } + + OperatorSet? current = OperatorTrust.VerifyChain(genesis, [.. bundle.Chain.Select(c => c.ToSigned())]); + if (current is null) + { + return Refuse("the panel's operator set does not match the pinned genesis fingerprint (wrong panel, or the panel was tampered with)"); + } + + if (controllers.Any(c => current.Keys.All(k => k.CredentialId != c))) + { + return Refuse("an owner passkey in the install command is not in the operator set"); + } + + return Result.Ok(bundle); } } diff --git a/backend/fleet/src/Fleet.Hosting/TrustAnchor.cs b/backend/fleet/src/Fleet.Hosting/TrustAnchor.cs new file mode 100644 index 0000000..b4cf216 --- /dev/null +++ b/backend/fleet/src/Fleet.Hosting/TrustAnchor.cs @@ -0,0 +1,170 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Fleet.Core; +using Fleet.Identity; + +namespace Fleet.Hosting; + +/// trust.json:安装时固定的创世指纹和属主,加见过的最新操作员集合链。 +public sealed class TrustFile +{ + [JsonPropertyName("genesis")] + public string Genesis { get; set; } = ""; + + [JsonPropertyName("chain")] + public List Chain { get; set; } = []; + + /// 属主:允许授权本节点 extension 会话(web ssh 等)的 passkey 凭证 id,安装时固定,必须是操作员集合里的凭证。 + [JsonPropertyName("controllers")] + public List Controllers { get; set; } = []; +} + +[JsonSerializable(typeof(TrustFile))] +public sealed partial class TrustFileContext : JsonSerializerContext; + +/// +/// 节点的信任根:安装命令里带来的创世指纹和属主,在注册时固定到本地,之后 Backend 改不了。 +/// 操作员集合的新版本随期望状态下发,节点只接受从固定的创世出发、签名完整、版本更新的链。 +/// 属主只是被固定和保存:节点现在没有 extension,等做 extension 时由节点本地强制(见 docs/access.md 第 8 节)。 +/// +public sealed class TrustAnchor : IServerVerifier +{ + public const string FileName = "trust.json"; + + private readonly ConfigStore? _store; + private readonly INodeLog _log; + private readonly object _gate = new(); + private readonly TrustFile? _file; + private OperatorSet? _current; + + private TrustAnchor(ConfigStore? store, TrustFile? file, OperatorSet? current, INodeLog log) + { + _store = store; + _file = file; + _current = current; + _log = log; + } + + /// 没有固定的创世(旧节点,或没有指纹的安装)。 + public static TrustAnchor None { get; } = new(null, null, null, NullNodeLog.Instance); + + public bool Pinned => _file is not null; + + public string? Genesis => _file?.Genesis; + + /// 当前生效的操作员集合版本;没有固定时为 0。 + public int Version + { + get + { + lock (_gate) + { + return _current?.Version ?? 0; + } + } + } + + /// 当前操作员集合里登记了服务器密钥:服务器必须在认证挑战里证明自己,链路用应用层加密。 + public bool Required + { + get + { + lock (_gate) + { + return _current?.Servers is { Count: > 0 }; + } + } + } + + /// 服务器的签名是不是当前操作员集合里登记的某把服务器密钥签的。 + public bool Verify(string nonce, ReadOnlySpan serverEphemeralSpki, ReadOnlySpan signature) + { + IReadOnlyList servers; + lock (_gate) + { + servers = _current?.Servers ?? []; + } + + foreach (byte[] server in servers) + { + if (ServerHello.Verify(server, nonce, serverEphemeralSpki, signature)) + { + return true; + } + } + + return false; + } + + /// 安装时固定的属主里,仍然在当前操作员集合里的那些(从集合里去掉的凭证不再有效)。 + public IReadOnlyList Controllers + { + get + { + lock (_gate) + { + return _file is null || _current is null ? [] : [.. _file.Controllers.Where(c => _current.Keys.Any(k => k.CredentialId == c))]; + } + } + } + + public static TrustAnchor Load(string identityDirectory, INodeLog log) + { + string path = Path.Combine(identityDirectory, FileName); + if (!File.Exists(path)) + { + return new TrustAnchor(new ConfigStore(identityDirectory), null, null, log); + } + + TrustFile file = NodeConfig.Load(path, TrustFileContext.Default.TrustFile); + OperatorSet? current = OperatorTrust.VerifyChain(file.Genesis, [.. file.Chain.Select(c => c.ToSigned())]); + if (current is null) + { + // 固定的创世和存下来的链对不上:不能当成「没有固定」放过,直接让节点起不来比悄悄失去保护好 + throw new InvalidDataException($"{path} does not verify against its pinned genesis; remove the node's identity and enroll again"); + } + + return new TrustAnchor(new ConfigStore(identityDirectory), file, current, log); + } + + /// 注册时写下:固定的创世、验证过的链、属主。 + public static void Pin(string identityDirectory, string genesis, IReadOnlyList chain, IReadOnlyList controllers) => + new ConfigStore(identityDirectory).Write(FileName, JsonSerializer.SerializeToUtf8Bytes(new TrustFile { Genesis = genesis, Chain = [.. chain], Controllers = [.. controllers] }, TrustFileContext.Default.TrustFile)); + + /// 期望状态里带来的链:从固定的创世验起,比现有的新才采用并保存。返回当前链的版本(没有固定时为 0)。 + public int Update(IReadOnlyList? chain) + { + lock (_gate) + { + if (_file is null || chain is not { Count: > 0 }) + { + return _current?.Version ?? 0; + } + + OperatorSet? candidate = OperatorTrust.VerifyChain(_file.Genesis, [.. chain.Select(c => c.ToSigned())]); + if (candidate is null) + { + _log.Warn("the operator set from the panel does not verify against the pinned genesis; ignoring it"); + return _current!.Version; + } + + if (candidate.Version > _current!.Version) + { + _file.Chain = [.. chain]; + _current = candidate; + try + { + _store?.Write(FileName, JsonSerializer.SerializeToUtf8Bytes(_file, TrustFileContext.Default.TrustFile)); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + _log.Warn("could not save trust.json", ("err", ex.Message)); + } + + _log.Info("operator set updated", ("version", candidate.Version)); + } + + return _current.Version; + } + } +} diff --git a/backend/fleet/src/Fleet.Identity/NodeIdentity.cs b/backend/fleet/src/Fleet.Identity/NodeIdentity.cs index 04d8095..3c23b0d 100644 --- a/backend/fleet/src/Fleet.Identity/NodeIdentity.cs +++ b/backend/fleet/src/Fleet.Identity/NodeIdentity.cs @@ -7,6 +7,9 @@ namespace Fleet.Identity; public static class SignaturePurpose { public const string Auth = "auth"; + + /// 安全链路的认证:签名同时覆盖服务器和节点的临时公钥,把这次认证和这条加密链路绑在一起。 + public const string AuthSecure = "auth2"; public const string Peer = "peer"; public const string Enroll = "enroll"; } diff --git a/backend/fleet/src/Fleet.Identity/OperatorTrust.cs b/backend/fleet/src/Fleet.Identity/OperatorTrust.cs new file mode 100644 index 0000000..fa25752 --- /dev/null +++ b/backend/fleet/src/Fleet.Identity/OperatorTrust.cs @@ -0,0 +1,171 @@ +using System.Buffers.Text; +using System.Globalization; +using System.Security.Cryptography; +using System.Text; + +namespace Fleet.Identity; + +/// passkey(WebAuthn)对一段文本签出的断言:challenge = SHA-256(Statement 的 UTF-8)。四个字段都是 base64url。 +public sealed record SignedStatement(string Statement, string CredentialId, string AuthenticatorData, string ClientDataJson, string Signature); + +/// 一个操作员凭证:passkey 的凭证 id 和公钥(SPKI)。 +public sealed record OperatorKey(string CredentialId, byte[] PublicKeySpki); + +/// +/// 操作员集合的一个版本。RpId 和 Origins 也在里面:节点验 WebAuthn 断言要用,而它们被操作员的签名覆盖,Backend 改不了。 +/// 创世版本(1)由它自己的某把凭证签名,指纹()在安装时固定到节点;之后每个版本由上一版里的凭证签名。 +/// +/// 服务器密钥(SPKI):操作员授权的 Backend 签名密钥。集合里登记了服务器密钥之后,节点要求服务器在认证挑战里用它签名, +/// 并在这条链路上用应用层加密,所以 DNS 或证书层面的冒充拿不出这把密钥就骗不了节点。空 = 还没有启用。 +public sealed record OperatorSet(int Version, string Previous, string RpId, IReadOnlyList Origins, IReadOnlyList Keys, IReadOnlyList? Servers = null); + +/// +/// 信任根:操作员(passkey)→ 操作员集合 → 节点本地固定的属主。设计见 docs/access.md 第 7 节。 +/// 安装命令里带着创世指纹和属主(passkey 的凭证 id):节点把它们固定在本地,之后只认从创世出发、由操作员 passkey 签过名的集合版本, +/// 属主必须是集合里的凭证。Backend 能转发集合,但签不出新的版本,也改不了节点已经固定的东西。 +/// 全是纯函数,只用 BCL,节点和 Backend 共用。语句是 key=value 的文本,第一行是带版本的标题;签名覆盖整段文本。 +/// +public static class OperatorTrust +{ + public const string SetHeader = "moshiro-opset-v1"; + public const string Genesis = "0"; + + public static string Encode(OperatorSet set) + { + var text = new StringBuilder(); + text.Append(SetHeader).Append('\n') + .Append("version=").Append(set.Version.ToString(CultureInfo.InvariantCulture)).Append('\n') + .Append("prev=").Append(set.Previous).Append('\n') + .Append("rp=").Append(set.RpId).Append('\n') + .Append("origins=").Append(string.Join(',', set.Origins)); + foreach (OperatorKey key in set.Keys) + { + text.Append("\nkey=").Append(key.CredentialId).Append(':').Append(Convert.ToBase64String(key.PublicKeySpki)); + } + + foreach (byte[] server in set.Servers ?? []) + { + text.Append("\nserver=").Append(Convert.ToBase64String(server)); + } + + return text.ToString(); + } + + /// 严格解析:多一行、少一行、顺序不对都算坏。 + public static OperatorSet? ParseSet(string text) + { + string[] lines = text.Split('\n'); + if (lines.Length < 6 || lines[0] != SetHeader || !TryValue(lines[1], "version", out string version) || !int.TryParse(version, NumberStyles.None, CultureInfo.InvariantCulture, out int number) || number < 1 + || !TryValue(lines[2], "prev", out string previous) || !TryValue(lines[3], "rp", out string rp) || rp.Length == 0 || !TryValue(lines[4], "origins", out string origins) || origins.Length == 0) + { + return null; + } + + var keys = new List(); + var servers = new List(); + foreach (string line in lines.Skip(5)) + { + if (servers.Count == 0 && TryValue(line, "key", out string value) && value.IndexOf(':', StringComparison.Ordinal) is > 0 and int split && TryBase64(value[(split + 1)..], out byte[] spki)) + { + keys.Add(new OperatorKey(value[..split], spki)); + } + else if (TryValue(line, "server", out string serverText) && TryBase64(serverText, out byte[] serverKey)) + { + servers.Add(serverKey); + } + else + { + return null; + } + } + + return keys.Count == 0 || (number == 1) != (previous == Genesis) ? null : new OperatorSet(number, previous, rp, origins.Split(','), keys, servers); + } + + /// 一段语句的指纹(SHA-256,小写十六进制)。创世版本的指纹就是安装命令里固定的那个。 + public static string Fingerprint(string statement) => Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(statement))); + + /// + /// 验证操作员集合的链:第一项是创世版本(指纹等于 ,自签),其后每一项由上一版里的凭证签名、版本加一、prev 指向上一版的指纹、域名不变。 + /// 返回最后一版;不对返回 null。 + /// + public static OperatorSet? VerifyChain(string pinnedGenesis, IReadOnlyList chain) + { + if (chain.Count == 0 || !string.Equals(Fingerprint(chain[0].Statement), pinnedGenesis, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + OperatorSet? current = null; + string? previousFingerprint = null; + foreach (SignedStatement link in chain) + { + OperatorSet? next = ParseSet(link.Statement); + if (next is null) + { + return null; + } + + if (current is null) + { + if (next.Version != 1 || !VerifyOperator(link, next)) + { + return null; + } + } + else if (next.Version != current.Version + 1 || next.Previous != previousFingerprint || next.RpId != current.RpId || !VerifyOperator(link, current)) + { + return null; + } + + current = next; + previousFingerprint = Fingerprint(link.Statement); + } + + return current; + } + + /// 断言是不是这个集合里某把凭证对 Statement 签的(要求用户验证;挑战是语句的 SHA-256)。 + public static bool VerifyOperator(SignedStatement signed, OperatorSet set) + { + OperatorKey? key = set.Keys.FirstOrDefault(k => k.CredentialId == signed.CredentialId); + if (key is null || !TryDecode(signed.AuthenticatorData, out byte[] authData) || !TryDecode(signed.ClientDataJson, out byte[] clientData) || !TryDecode(signed.Signature, out byte[] signature)) + { + return false; + } + + return WebAuthnAssertion.Verify(key.PublicKeySpki, authData, clientData, signature, set.RpId, SHA256.HashData(Encoding.UTF8.GetBytes(signed.Statement)), set.Origins.Contains, requireUserVerification: true, out _); + } + + private static bool TryValue(string line, string key, out string value) + { + value = ""; + if (!line.StartsWith(key + "=", StringComparison.Ordinal)) + { + return false; + } + + value = line[(key.Length + 1)..]; + return true; + } + + private static bool TryBase64(string text, out byte[] data) + { + try + { + data = Convert.FromBase64String(text); + return data.Length > 0; + } + catch (FormatException) + { + data = []; + return false; + } + } + + private static bool TryDecode(string text, out byte[] data) + { + data = []; + return Base64Url.IsValid(text) && (data = Base64Url.DecodeFromChars(text)).Length > 0; + } +} diff --git a/backend/fleet/src/Fleet.Identity/SecureChannel.cs b/backend/fleet/src/Fleet.Identity/SecureChannel.cs new file mode 100644 index 0000000..61e063b --- /dev/null +++ b/backend/fleet/src/Fleet.Identity/SecureChannel.cs @@ -0,0 +1,173 @@ +using System.Buffers.Binary; +using System.Security.Cryptography; +using System.Text; + +namespace Fleet.Identity; + +/// +/// 节点这一侧用来确认「对面确实是我的操作员授权过的 Backend」的校验器:Required 为真(当前操作员集合里登记了服务器密钥) +/// 时,服务器的挑战必须带着对得上的签名,否则节点不继续认证、也不交出任何东西。 +/// +public interface IServerVerifier +{ + bool Required { get; } + + /// 服务器用它的服务器密钥对「挑战随机数加它这次连接的临时公钥」的签名。 + bool Verify(string nonce, ReadOnlySpan serverEphemeralSpki, ReadOnlySpan signature); +} + +/// 服务器在挑战里给出的身份证明:服务器密钥(操作员签进集合)对「随机数加临时公钥」签名。纯函数。 +public static class ServerHello +{ + /// 被签名的内容;服务器那一侧只拿到这个、用自己的密钥签(服务器密钥不一定是 BCL 的 ECDsa,可以在别处)。 + public static byte[] Payload(string nonce, ReadOnlySpan serverEphemeralSpki) => + Encoding.UTF8.GetBytes("fleet|v1|server|" + nonce + "|" + Convert.ToBase64String(serverEphemeralSpki)); + + public static byte[] Sign(ECDsa serverKey, string nonce, ReadOnlySpan serverEphemeralSpki) => + serverKey.SignData(Payload(nonce, serverEphemeralSpki), HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation); + + public static bool Verify(ReadOnlySpan serverKeySpki, string nonce, ReadOnlySpan serverEphemeralSpki, ReadOnlySpan signature) + { + try + { + using ECDsa key = ECDsa.Create(); + key.ImportSubjectPublicKeyInfo(serverKeySpki, out _); + return key.VerifyData(Payload(nonce, serverEphemeralSpki), signature, HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation); + } + catch (CryptographicException) + { + return false; + } + } +} + +/// +/// 节点和 Backend 之间控制链路的应用层加密:两边的临时 ECDH 密钥协商出会话密钥,每个方向一把,AES-256-GCM,每帧带计数器。 +/// 所以即使 TLS 在中间被终止(Cloudflare、被冒充的证书),中间人没有服务器密钥,就拿不到明文,也改不了任何一帧。 +/// 帧格式:计数器(8,大端) ‖ 密文 ‖ 标签(16);明文是 帧类型(1) ‖ 内容;计数器必须严格递增,重放、乱序、丢帧、篡改都会解密失败。 +/// +public sealed class LinkCipher : IDisposable +{ + public const int Overhead = 8 + 16; + + private const byte NodeToServer = 0; + private const byte ServerToNode = 1; + + private readonly AesGcm _send; + private readonly AesGcm _receive; + private readonly byte _sendDirection; + private readonly byte _receiveDirection; + private readonly object _gate = new(); + private long _sendCounter; + private long _receiveCounter; + + private LinkCipher(byte[] sendKey, byte[] receiveKey, byte sendDirection, byte receiveDirection) + { + _send = new AesGcm(sendKey, 16); + _receive = new AesGcm(receiveKey, 16); + _sendDirection = sendDirection; + _receiveDirection = receiveDirection; + } + + public static LinkCipher ForNode(EphemeralKey own, ReadOnlySpan serverEphemeralSpki, string nonce) + { + (byte[] n2s, byte[] s2n) = Derive(own, serverEphemeralSpki, own.PublicKeySpki, serverEphemeralSpki, nonce); + return new LinkCipher(n2s, s2n, NodeToServer, ServerToNode); + } + + public static LinkCipher ForServer(EphemeralKey own, ReadOnlySpan nodeEphemeralSpki, string nonce) + { + (byte[] n2s, byte[] s2n) = Derive(own, nodeEphemeralSpki, nodeEphemeralSpki, own.PublicKeySpki, nonce); + return new LinkCipher(s2n, n2s, ServerToNode, NodeToServer); + } + + private static (byte[] NodeToServerKey, byte[] ServerToNodeKey) Derive(EphemeralKey own, ReadOnlySpan remote, ReadOnlySpan nodeEphemeral, ReadOnlySpan serverEphemeral, string nonce) + { + byte[] secret = own.AgreeRaw(remote); + try + { + byte[] info = [.. Encoding.UTF8.GetBytes("fleet|v1|link|"), .. SHA256.HashData(serverEphemeral), .. SHA256.HashData(nodeEphemeral)]; + byte[] material = HKDF.DeriveKey(HashAlgorithmName.SHA256, secret, 64, Encoding.UTF8.GetBytes(nonce), info); + return (material[..32], material[32..]); + } + finally + { + CryptographicOperations.ZeroMemory(secret); + } + } + + /// 加密一帧。 + public byte[] Seal(byte kind, ReadOnlySpan data) + { + long counter; + lock (_gate) + { + counter = _sendCounter++; + } + + byte[] output = new byte[Overhead + 1 + data.Length]; + BinaryPrimitives.WriteInt64BigEndian(output, counter); + byte[] plaintext = new byte[1 + data.Length]; + plaintext[0] = kind; + data.CopyTo(plaintext.AsSpan(1)); + _send.Encrypt(Nonce(counter), plaintext, output.AsSpan(8, plaintext.Length), output.AsSpan(8 + plaintext.Length, 16), Aad(_sendDirection, counter)); + return output; + } + + /// 解密一帧;计数器不是期望的下一个、或者标签不对,都返回 false。 + public bool TryOpen(ReadOnlySpan frame, out byte kind, out byte[] data) + { + kind = 0; + data = []; + if (frame.Length < Overhead + 1) + { + return false; + } + + long counter = BinaryPrimitives.ReadInt64BigEndian(frame); + lock (_gate) + { + if (counter != _receiveCounter) + { + return false; + } + + int length = frame.Length - Overhead; + byte[] plaintext = new byte[length]; + try + { + _receive.Decrypt(Nonce(counter), frame.Slice(8, length), frame.Slice(8 + length, 16), plaintext, Aad(_receiveDirection, counter)); + } + catch (CryptographicException) + { + return false; + } + + _receiveCounter++; + kind = plaintext[0]; + data = plaintext[1..]; + return true; + } + } + + private static byte[] Nonce(long counter) + { + byte[] nonce = new byte[12]; + BinaryPrimitives.WriteInt64BigEndian(nonce.AsSpan(4), counter); + return nonce; + } + + private static byte[] Aad(byte direction, long counter) + { + byte[] aad = new byte[9]; + aad[0] = direction; + BinaryPrimitives.WriteInt64BigEndian(aad.AsSpan(1), counter); + return aad; + } + + public void Dispose() + { + _send.Dispose(); + _receive.Dispose(); + } +} diff --git a/backend/fleet/src/Fleet.Identity/SessionKeys.cs b/backend/fleet/src/Fleet.Identity/SessionKeys.cs index 2f4eba1..f326b19 100644 --- a/backend/fleet/src/Fleet.Identity/SessionKeys.cs +++ b/backend/fleet/src/Fleet.Identity/SessionKeys.cs @@ -16,6 +16,14 @@ public sealed class EphemeralKey : IDisposable public byte[] PublicKeySpki => _key.ExportSubjectPublicKeyInfo(); + /// 与对端的临时公钥做 ECDH,返回原始共享秘密;必须再经 KDF 才能当密钥用。 + public byte[] AgreeRaw(ReadOnlySpan remotePublicKeySpki) + { + using ECDiffieHellman remote = ECDiffieHellman.Create(); + remote.ImportSubjectPublicKeyInfo(remotePublicKeySpki, out _); + return _key.DeriveRawSecretAgreement(remote.PublicKey); + } + /// /// 与对端的临时公钥协商,再经 HKDF-SHA256 派生会话凭据。 /// 盐是服务端的随机数,info 绑定双方的临时公钥,防止凭据被挪用到别的握手上。 diff --git a/backend/fleet/src/Fleet.Identity/TrustMapping.cs b/backend/fleet/src/Fleet.Identity/TrustMapping.cs new file mode 100644 index 0000000..2672fa6 --- /dev/null +++ b/backend/fleet/src/Fleet.Identity/TrustMapping.cs @@ -0,0 +1,11 @@ +using Fleet.Core; + +namespace Fleet.Identity; + +public static class TrustMapping +{ + public static SignedStatement ToSigned(this SignedStatementDto dto) => new(dto.Statement, dto.CredentialId, dto.AuthenticatorData, dto.ClientDataJson, dto.Signature); + + public static SignedStatementDto ToDto(this SignedStatement signed) => + new() { Statement = signed.Statement, CredentialId = signed.CredentialId, AuthenticatorData = signed.AuthenticatorData, ClientDataJson = signed.ClientDataJson, Signature = signed.Signature }; +} diff --git a/backend/fleet/src/Fleet.Identity/WebAuthnAssertion.cs b/backend/fleet/src/Fleet.Identity/WebAuthnAssertion.cs new file mode 100644 index 0000000..60a0d0d --- /dev/null +++ b/backend/fleet/src/Fleet.Identity/WebAuthnAssertion.cs @@ -0,0 +1,85 @@ +using System.Buffers.Text; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +namespace Fleet.Identity; + +/// +/// 验证一次 WebAuthn 断言(navigator.credentials.get 的结果)。只支持 ES256(P-256 的 ECDSA),纯函数,只用 BCL: +/// 面板用它验证 passkey 登录,节点以后用它验证 passkey 签出的委托(docs/access.md 第 7 节)。 +/// 不解析 CBOR——注册时的 attestation 解析只在 Backend 里。 +/// +public static class WebAuthnAssertion +{ + public const byte FlagUserPresent = 0x01; + public const byte FlagUserVerified = 0x04; + + /// 注册时保存的凭证公钥(SPKI)。 + /// 这次请求里服务端或签名人规定的挑战原文(不是 base64url)。 + /// 校验 clientDataJSON.origin。 + /// authenticatorData 里的签名计数器。 + public static bool Verify( + ReadOnlySpan publicKeySpki, ReadOnlySpan authenticatorData, ReadOnlySpan clientDataJson, ReadOnlySpan signature, + string rpId, ReadOnlySpan expectedChallenge, Func originAllowed, bool requireUserVerification, out uint signCount) + { + signCount = 0; + if (authenticatorData.Length < 37 || !CryptographicOperations.FixedTimeEquals(authenticatorData[..32], SHA256.HashData(Encoding.UTF8.GetBytes(rpId)))) + { + return false; + } + + byte flags = authenticatorData[32]; + if ((flags & FlagUserPresent) == 0 || (requireUserVerification && (flags & FlagUserVerified) == 0)) + { + return false; + } + + if (!ClientDataValid(clientDataJson, "webauthn.get", expectedChallenge, originAllowed)) + { + return false; + } + + try + { + using ECDsa key = ECDsa.Create(); + key.ImportSubjectPublicKeyInfo(publicKeySpki, out _); + byte[] signed = [.. authenticatorData, .. SHA256.HashData(clientDataJson)]; + if (!key.VerifyData(signed, signature, HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence)) + { + return false; + } + } + catch (CryptographicException) + { + return false; + } + + signCount = (uint)((authenticatorData[33] << 24) | (authenticatorData[34] << 16) | (authenticatorData[35] << 8) | authenticatorData[36]); + return true; + } + + /// clientDataJSON 的 type、challenge、origin 都要对得上。注册和登录共用。 + public static bool ClientDataValid(ReadOnlySpan clientDataJson, string expectedType, ReadOnlySpan expectedChallenge, Func originAllowed) + { + try + { + using JsonDocument document = JsonDocument.Parse(clientDataJson.ToArray()); + JsonElement root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object + || root.GetProperty("type").GetString() != expectedType + || root.GetProperty("origin").GetString() is not { } origin || !originAllowed(origin) + || root.GetProperty("challenge").GetString() is not { } challenge) + { + return false; + } + + byte[] decoded = Base64Url.DecodeFromChars(challenge); + return CryptographicOperations.FixedTimeEquals(decoded, expectedChallenge); + } + catch (Exception ex) when (ex is JsonException or KeyNotFoundException or FormatException or InvalidOperationException) + { + return false; + } + } +} diff --git a/backend/fleet/src/Fleet.Link/FleetLink.cs b/backend/fleet/src/Fleet.Link/FleetLink.cs index 04a19d1..e31649d 100644 --- a/backend/fleet/src/Fleet.Link/FleetLink.cs +++ b/backend/fleet/src/Fleet.Link/FleetLink.cs @@ -66,6 +66,12 @@ public sealed class FleetLinkOptions public long MaxInflightBytes { get; init; } = 4 << 20; public Backoff Backoff { get; init; } = new(); + + /// + /// 校验服务器身份: 为真(节点固定的操作员集合里登记了服务器密钥)时, + /// 服务器必须在挑战里证明自己,之后这条链路用应用层加密;否则按旧的方式(没有固定信任根的节点)。 + /// + public IServerVerifier? ServerVerifier { get; init; } } /// @@ -77,6 +83,7 @@ public sealed class FleetLink private const string RpcPath = "/api/agent/v1/rpc"; private readonly FleetLinkOptions _options; + private volatile bool _secure; private readonly RpcRouter _router; private readonly Dictionary _handlers; private readonly TimeProvider _time; @@ -153,6 +160,9 @@ public sealed class FleetLink } } + /// 当前这条链路是不是应用层加密的(服务器证明了身份)。 + public bool IsSecure => _secure && IsConnected; + private async Task RunOnceAsync(CancellationToken ct) { using var socket = new ClientWebSocket(); @@ -160,7 +170,7 @@ public sealed class FleetLink socket.Options.KeepAliveTimeout = TimeSpan.FromSeconds(20); // 对端 20 秒没有回 Pong 就判定连接已死 var builder = new UriBuilder(_options.Server) { Scheme = _options.Server.Scheme == "https" ? "wss" : "ws", Path = RpcPath }; await socket.ConnectAsync(builder.Uri, ct); - await using var transport = new WsTransport(socket, _options.MaxFrameBytes); + await using var transport = new SecureTransport(new WsTransport(socket, _options.MaxFrameBytes)); string? failure = await AuthenticateAsync(transport, ct); if (failure is not null) @@ -168,6 +178,8 @@ public sealed class FleetLink return failure; } + _secure = transport.Secure; + var session = new RpcSession(transport, _router, new LinkContext(this), _time); using CancellationTokenSource sessionCts = CancellationTokenSource.CreateLinkedTokenSource(ct); Task run = session.RunAsync(sessionCts.Token); @@ -211,7 +223,7 @@ public sealed class FleetLink } /// 挑战 → 签名 → 认证结果。失败返回原因;服务端禁用该节点时不再重试。 - private async Task AuthenticateAsync(WsTransport transport, CancellationToken ct) + private async Task AuthenticateAsync(SecureTransport transport, CancellationToken ct) { using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15), _time); using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token); @@ -222,9 +234,44 @@ public sealed class FleetLink } ChallengeParams nonce = challenge.Params.Value.Deserialize(FleetRpcContext.Default.ChallengeParams)!; - string signature = Convert.ToBase64String(_options.Identity.Sign(SignaturePurpose.Auth, _options.NodeId.Value, nonce.Nonce)); - RpcMessage request = RpcMessage.Request(1, FleetMethods.Auth, new AuthParams { NodeId = _options.NodeId.Value, Signature = signature }, FleetRpcContext.Default.AuthParams); - await transport.SendAsync(new RpcFrame(FrameKind.Text, JsonSerializer.SerializeToUtf8Bytes(request, FleetRpcContext.Default.RpcMessage)), linked.Token); + + // 固定了操作员集合、而集合里登记了服务器密钥:服务器必须证明自己,否则不继续,也不交出任何东西 + EphemeralKey? ephemeral = null; + AuthParams authParams; + try + { + if (_options.ServerVerifier is { Required: true } verifier) + { + if (!TryDecodeBase64(nonce.ServerEphemeral, out byte[] serverEphemeral) || !TryDecodeBase64(nonce.ServerSignature, out byte[] serverSignature) + || !verifier.Verify(nonce.Nonce, serverEphemeral, serverSignature)) + { + return "the server could not prove its identity (it is not the server your operator set authorizes)"; + } + + ephemeral = new EphemeralKey(); + string serverEphemeralText = Convert.ToBase64String(serverEphemeral); + string nodeEphemeralText = Convert.ToBase64String(ephemeral.PublicKeySpki); + authParams = new AuthParams + { + NodeId = _options.NodeId.Value, + Signature = Convert.ToBase64String(_options.Identity.Sign(SignaturePurpose.AuthSecure, _options.NodeId.Value, nonce.Nonce, serverEphemeralText, nodeEphemeralText)), + NodeEphemeral = nodeEphemeralText, + }; + RpcMessage secureRequest = RpcMessage.Request(1, FleetMethods.Auth, authParams, FleetRpcContext.Default.AuthParams); + await transport.SendAsync(new RpcFrame(FrameKind.Text, JsonSerializer.SerializeToUtf8Bytes(secureRequest, FleetRpcContext.Default.RpcMessage)), linked.Token); + transport.Enable(LinkCipher.ForNode(ephemeral, serverEphemeral, nonce.Nonce)); + } + else + { + authParams = new AuthParams { NodeId = _options.NodeId.Value, Signature = Convert.ToBase64String(_options.Identity.Sign(SignaturePurpose.Auth, _options.NodeId.Value, nonce.Nonce)) }; + RpcMessage request = RpcMessage.Request(1, FleetMethods.Auth, authParams, FleetRpcContext.Default.AuthParams); + await transport.SendAsync(new RpcFrame(FrameKind.Text, JsonSerializer.SerializeToUtf8Bytes(request, FleetRpcContext.Default.RpcMessage)), linked.Token); + } + } + finally + { + ephemeral?.Dispose(); + } while (true) { @@ -259,7 +306,26 @@ public sealed class FleetLink } } - private static async Task ReceiveMessageAsync(WsTransport transport, CancellationToken ct) + private static bool TryDecodeBase64(string? text, out byte[] data) + { + data = []; + if (string.IsNullOrEmpty(text)) + { + return false; + } + + try + { + data = Convert.FromBase64String(text); + return true; + } + catch (FormatException) + { + return false; + } + } + + private static async Task ReceiveMessageAsync(SecureTransport transport, CancellationToken ct) { RpcFrame? frame = await transport.ReceiveAsync(ct); return frame is { Kind: FrameKind.Text } f ? JsonSerializer.Deserialize(f.Data.Span, FleetRpcContext.Default.RpcMessage) : null; diff --git a/backend/fleet/src/Fleet.Link/SecureTransport.cs b/backend/fleet/src/Fleet.Link/SecureTransport.cs new file mode 100644 index 0000000..6902180 --- /dev/null +++ b/backend/fleet/src/Fleet.Link/SecureTransport.cs @@ -0,0 +1,57 @@ +using Fleet.Identity; +using Fleet.Rpc; + +namespace Fleet.Link; + +/// +/// 认证阶段是明文,认证通过之后换成应用层加密():此后每帧都是加密的二进制帧,明文帧一律拒绝。 +/// 认证阶段服务器的拒绝回复可能是明文(还没有协商好密钥),所以第一帧加密的回复到达之前允许一次明文文本帧。 +/// +internal sealed class SecureTransport : IRpcTransport +{ + private readonly IRpcTransport _inner; + private LinkCipher? _cipher; + private bool _allowPlaintext = true; + + public SecureTransport(IRpcTransport inner) + { + _inner = inner; + } + + public bool Secure => _cipher is not null; + + public void Enable(LinkCipher cipher) => _cipher = cipher; + + public ValueTask SendAsync(RpcFrame frame, CancellationToken ct) => + _cipher is null ? _inner.SendAsync(frame, ct) : _inner.SendAsync(new RpcFrame(FrameKind.Binary, _cipher.Seal((byte)frame.Kind, frame.Data.Span)), ct); + + public async ValueTask ReceiveAsync(CancellationToken ct) + { + RpcFrame? frame = await _inner.ReceiveAsync(ct); + if (frame is not { } received || _cipher is null) + { + return frame; + } + + if (received.Kind == FrameKind.Binary && _cipher.TryOpen(received.Data.Span, out byte kind, out byte[] data)) + { + _allowPlaintext = false; + return new RpcFrame((FrameKind)kind, data); + } + + if (_allowPlaintext && received.Kind == FrameKind.Text) + { + return received; // 认证被拒绝时服务器还没有协商好密钥,回复是明文 + } + + throw new IOException("a frame on the secure link failed to authenticate"); + } + + public ValueTask CloseAsync(string reason, CancellationToken ct) => _inner.CloseAsync(reason, ct); + + public async ValueTask DisposeAsync() + { + _cipher?.Dispose(); + await _inner.DisposeAsync(); + } +} diff --git a/backend/fleet/src/Fleet.Rpc/FleetMethods.cs b/backend/fleet/src/Fleet.Rpc/FleetMethods.cs index d189253..a1c5e61 100644 --- a/backend/fleet/src/Fleet.Rpc/FleetMethods.cs +++ b/backend/fleet/src/Fleet.Rpc/FleetMethods.cs @@ -53,6 +53,14 @@ public sealed class ChallengeParams [JsonPropertyName("expires_at_ms")] public long ExpiresAtMs { get; set; } + + /// 服务器这次连接的临时 ECDH 公钥(SPKI,base64);服务器有身份密钥时才有。 + [JsonPropertyName("server_eph")] + public string? ServerEphemeral { get; set; } + + /// 服务器密钥对「随机数加临时公钥」的签名(base64):节点据此确认对面是操作员授权的服务器。 + [JsonPropertyName("server_sig")] + public string? ServerSignature { get; set; } } public sealed class AuthParams @@ -63,6 +71,10 @@ public sealed class AuthParams /// 对 fleet|v1|auth|{node_id}|{nonce} 的签名(base64)。 [JsonPropertyName("signature")] public string Signature { get; set; } = ""; + + /// 节点的临时 ECDH 公钥(SPKI,base64):有它就是安全链路,签名覆盖双方的临时公钥,认证结果起所有帧都是加密的。 + [JsonPropertyName("node_eph")] + public string? NodeEphemeral { get; set; } } public sealed class AuthResult diff --git a/backend/fleet/src/Fleet.Server.Abstractions/NodeRecords.cs b/backend/fleet/src/Fleet.Server.Abstractions/NodeRecords.cs index e792a2f..d5db797 100644 --- a/backend/fleet/src/Fleet.Server.Abstractions/NodeRecords.cs +++ b/backend/fleet/src/Fleet.Server.Abstractions/NodeRecords.cs @@ -33,7 +33,8 @@ public sealed record NewNode( string? NodeVersion, long NowMs, string Kind = "", - byte[]? TlsPublicKey = null); + byte[]? TlsPublicKey = null, + string? AddedBy = null); public sealed record JoinTokenRecord( string Id, @@ -46,6 +47,7 @@ public sealed record JoinTokenRecord( long ExpiresAtMs, long? RevokedAtMs, long CreatedAtMs, - string Kind = ""); + string Kind = "", + string? CreatedBy = null); public sealed record Presence(bool Online, long AtMs, string? NodeVersion, IReadOnlyList Capabilities, string? HelloMetaJson); diff --git a/backend/fleet/src/Fleet.Server.Abstractions/ServerContracts.cs b/backend/fleet/src/Fleet.Server.Abstractions/ServerContracts.cs index 72c567a..bb8e4be 100644 --- a/backend/fleet/src/Fleet.Server.Abstractions/ServerContracts.cs +++ b/backend/fleet/src/Fleet.Server.Abstractions/ServerContracts.cs @@ -6,6 +6,24 @@ namespace Fleet.Server; public sealed record EnrollmentContext(string? RemoteIp, JsonElement? ProductMeta); +/// +/// 服务器的身份密钥:操作员用 passkey 把它的公钥签进操作员集合。有它的时候,认证挑战会带上服务器的签名,节点据此确认对面是真的服务器, +/// 并在这条链路上用应用层加密。没有注册就是旧的行为(没有服务器身份)。 +/// +public interface IServerIdentity +{ + byte[] PublicKeySpki { get; } + + /// 对一段内容签名(Fleet.Identity.ServerHello.Payload 给出的)。 + byte[] Sign(ReadOnlySpan payload); +} + +/// 操作员集合的链(passkey 签的,创世在前)。没有初始化信任根时是空的。节点注册时用它确认「这个面板确实是我的操作员的」。 +public interface IOperatorTrustSource +{ + ValueTask> ChainAsync(CancellationToken ct); +} + /// 产品挂钩:节点注册成功之后调用。 public interface IEnrollmentHook { diff --git a/backend/fleet/src/Fleet.Server.Enrollment/EnrollmentModels.cs b/backend/fleet/src/Fleet.Server.Enrollment/EnrollmentModels.cs index de87ec2..124bb39 100644 --- a/backend/fleet/src/Fleet.Server.Enrollment/EnrollmentModels.cs +++ b/backend/fleet/src/Fleet.Server.Enrollment/EnrollmentModels.cs @@ -37,6 +37,12 @@ public sealed class EnrollRequest public JsonElement? Meta { get; set; } } +public sealed class TrustRequest +{ + [JsonPropertyName("join_token")] + public string JoinToken { get; set; } = ""; +} + public sealed class EnrollResponse { [JsonPropertyName("node_id")] @@ -69,5 +75,7 @@ public sealed class FleetProblem [JsonSourceGenerationOptions(DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] [JsonSerializable(typeof(EnrollRequest))] [JsonSerializable(typeof(EnrollResponse))] +[JsonSerializable(typeof(TrustRequest))] +[JsonSerializable(typeof(Fleet.Core.TrustBundle))] [JsonSerializable(typeof(FleetProblem))] internal sealed partial class EnrollmentJsonContext : JsonSerializerContext; diff --git a/backend/fleet/src/Fleet.Server.Enrollment/FleetEnrollmentEndpoints.cs b/backend/fleet/src/Fleet.Server.Enrollment/FleetEnrollmentEndpoints.cs index 730cf82..ccd3567 100644 --- a/backend/fleet/src/Fleet.Server.Enrollment/FleetEnrollmentEndpoints.cs +++ b/backend/fleet/src/Fleet.Server.Enrollment/FleetEnrollmentEndpoints.cs @@ -20,9 +20,42 @@ public static class FleetEnrollmentEndpoints .WithTags("agent") .WithSummary("Enroll a node with a join token") .DisableAntiforgery(); + // 注册之前取信任链:节点先确认这个面板的操作员集合和委托确实是它固定的创世签出来的,再把自己的密钥交出去 + app.MapPost(path + "/trust", HandleTrustAsync) + .WithName("fleet_enroll_trust") + .WithTags("agent") + .WithSummary("The operator set chain and the grant of a join token, for the node to verify before it enrolls") + .DisableAntiforgery(); return app; } + private static async Task HandleTrustAsync(HttpContext http, FleetEnrollmentService service) + { + TrustRequest? request; + try + { + request = await JsonSerializer.DeserializeAsync(http.Request.Body, EnrollmentJsonContext.Default.TrustRequest, http.RequestAborted); + } + catch (JsonException) + { + request = null; + } + + if (request is null || string.IsNullOrEmpty(request.JoinToken)) + { + return Problem(StatusCodes.Status400BadRequest, FleetErrors.InvalidParams, "join_token is required"); + } + + Result result = await service.GetTrustAsync(request.JoinToken, http.RequestAborted); + if (!result.TryGet(out TrustBundle? bundle, out FleetError? error)) + { + return Problem(error.Code == FleetErrors.JoinTokenExpired ? StatusCodes.Status410Gone : StatusCodes.Status401Unauthorized, error.Code, error.Message); + } + + http.Response.Headers.CacheControl = "no-store"; + return Results.Json(bundle, EnrollmentJsonContext.Default.TrustBundle); + } + private static async Task HandleAsync(HttpContext http, FleetEnrollmentService service) { EnrollRequest? request; diff --git a/backend/fleet/src/Fleet.Server.Enrollment/FleetEnrollmentService.cs b/backend/fleet/src/Fleet.Server.Enrollment/FleetEnrollmentService.cs index 64f03a6..8fe2e65 100644 --- a/backend/fleet/src/Fleet.Server.Enrollment/FleetEnrollmentService.cs +++ b/backend/fleet/src/Fleet.Server.Enrollment/FleetEnrollmentService.cs @@ -9,13 +9,15 @@ public sealed partial class FleetEnrollmentService { private readonly IFleetStore _store; private readonly IEnumerable _hooks; + private readonly IEnumerable _trust; private readonly FleetServerOptions _options; private readonly TimeProvider _time; - public FleetEnrollmentService(IFleetStore store, IEnumerable hooks, FleetServerOptions options, TimeProvider time) + public FleetEnrollmentService(IFleetStore store, IEnumerable hooks, IEnumerable trust, FleetServerOptions options, TimeProvider time) { _store = store; _hooks = hooks; + _trust = trust; _options = options; _time = time; } @@ -80,7 +82,8 @@ public sealed partial class FleetEnrollmentService request.NodeVersion, nowMs, token.Kind, - tlsPublicKey); + tlsPublicKey, + token.CreatedBy); Result created = await _store.CreateNodeAsync(node, ct); if (!created.TryGet(out NodeRecord? record, out FleetError? error)) @@ -101,6 +104,30 @@ public sealed partial class FleetEnrollmentService }); } + /// 节点注册之前取:操作员集合的链。码要是有效的;不能用的码不给。 + public async ValueTask> GetTrustAsync(string joinToken, CancellationToken ct) + { + JoinTokenRecord? token = await _store.FindJoinTokenAsync(TokenHash.Sha256(joinToken), ct); + long nowMs = _time.GetUtcNow().ToUnixTimeMilliseconds(); + if (token is null || token.RevokedAtMs is not null || token.Uses >= token.MaxUses) + { + return Result.Fail(FleetErrors.JoinTokenInvalid, "join token is invalid"); + } + + if (token.ExpiresAtMs <= nowMs) + { + return Result.Fail(FleetErrors.JoinTokenExpired, "join token has expired"); + } + + var bundle = new TrustBundle(); + foreach (IOperatorTrustSource source in _trust) + { + bundle.Chain.AddRange(await source.ChainAsync(ct)); + } + + return Result.Ok(bundle); + } + private static bool TryDecode(string text, out byte[] data) { try diff --git a/backend/fleet/src/Fleet.Server.Link/FleetLinkHost.cs b/backend/fleet/src/Fleet.Server.Link/FleetLinkHost.cs index b06fa57..d4d4b82 100644 --- a/backend/fleet/src/Fleet.Server.Link/FleetLinkHost.cs +++ b/backend/fleet/src/Fleet.Server.Link/FleetLinkHost.cs @@ -23,8 +23,10 @@ public sealed class FleetLinkHost : IFleetConnections, IStateNotifier IEnumerable stateProviders, IEnumerable inboxes, IEnumerable sinks, - FleetRouter router) + FleetRouter router, + IServerIdentity? serverIdentity = null) { + ServerIdentity = serverIdentity; Store = store; Options = options; Time = time; @@ -38,6 +40,9 @@ public sealed class FleetLinkHost : IFleetConnections, IStateNotifier internal IFleetStore Store { get; } + /// 服务器的身份密钥;没有时节点连接用旧的明文认证。 + internal IServerIdentity? ServerIdentity { get; } + internal FleetServerOptions Options { get; } internal TimeProvider Time { get; } diff --git a/backend/fleet/src/Fleet.Server.Link/FleetServerBuilder.cs b/backend/fleet/src/Fleet.Server.Link/FleetServerBuilder.cs index ede5c05..0c99b75 100644 --- a/backend/fleet/src/Fleet.Server.Link/FleetServerBuilder.cs +++ b/backend/fleet/src/Fleet.Server.Link/FleetServerBuilder.cs @@ -109,7 +109,8 @@ public static class FleetServerExtensions provider.GetServices(), provider.GetServices(), provider.GetServices(), - provider.GetRequiredService())); + provider.GetRequiredService(), + provider.GetService())); services.AddSingleton(provider => provider.GetRequiredService()); services.AddSingleton(provider => provider.GetRequiredService()); return builder; diff --git a/backend/fleet/src/Fleet.Server.Link/NodeConnection.Auth.cs b/backend/fleet/src/Fleet.Server.Link/NodeConnection.Auth.cs index 55f33fe..57c87a8 100644 --- a/backend/fleet/src/Fleet.Server.Link/NodeConnection.Auth.cs +++ b/backend/fleet/src/Fleet.Server.Link/NodeConnection.Auth.cs @@ -14,9 +14,19 @@ internal sealed partial class NodeConnection { string nonce = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)); long expiresAtMs = _host.Time.GetUtcNow().Add(_host.Options.ChallengeTtl).ToUnixTimeMilliseconds(); - await SendAsync( - RpcMessage.Notification(FleetMethods.Challenge, new ChallengeParams { Nonce = nonce, ExpiresAtMs = expiresAtMs }, FleetRpcContext.Default.ChallengeParams), - ct); + + // 有服务器身份密钥:挑战里带上这次连接的临时公钥和服务器的签名,节点据此确认服务器、协商加密链路 + using var serverEphemeral = _host.ServerIdentity is null ? null : new EphemeralKey(); + var challenge = new ChallengeParams { Nonce = nonce, ExpiresAtMs = expiresAtMs }; + string? serverEphemeralText = null; + if (_host.ServerIdentity is { } identity && serverEphemeral is not null) + { + serverEphemeralText = Convert.ToBase64String(serverEphemeral.PublicKeySpki); + challenge.ServerEphemeral = serverEphemeralText; + challenge.ServerSignature = Convert.ToBase64String(identity.Sign(ServerHello.Payload(nonce, serverEphemeral.PublicKeySpki))); + } + + await SendAsync(RpcMessage.Notification(FleetMethods.Challenge, challenge, FleetRpcContext.Default.ChallengeParams), ct); using var timeout = new CancellationTokenSource(s_authTimeout, _host.Time); using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token); @@ -48,10 +58,21 @@ internal sealed partial class NodeConnection } NodeRecord? node = auth is null ? null : await _host.Store.GetNodeAsync(new NodeId(auth.NodeId), ct); + + // 安全链路:节点的签名覆盖双方的临时公钥(AuthSecure),验过之后这条连接上的所有帧都是加密的 + byte[] nodeEphemeral = []; + bool secure = auth?.NodeEphemeral is not null && serverEphemeral is not null && serverEphemeralText is not null && TryDecode(auth.NodeEphemeral, out nodeEphemeral); bool signatureOk = node is not null && _host.Time.GetUtcNow().ToUnixTimeMilliseconds() <= expiresAtMs && TryDecode(auth!.Signature, out byte[] signature) - && NodeIdentity.Verify(node.PublicKey, signature, SignaturePurpose.Auth, auth.NodeId, nonce); + && (secure + ? NodeIdentity.Verify(node.PublicKey, signature, SignaturePurpose.AuthSecure, auth.NodeId, nonce, serverEphemeralText!, auth.NodeEphemeral!) + : auth.NodeEphemeral is null && NodeIdentity.Verify(node.PublicKey, signature, SignaturePurpose.Auth, auth.NodeId, nonce)); + if (signatureOk && secure) + { + _cipher = LinkCipher.ForServer(serverEphemeral!, nodeEphemeral, nonce); + } + if (!signatureOk) { // 节点不存在与签名错误返回同样的错误,不泄露节点是否存在。 diff --git a/backend/fleet/src/Fleet.Server.Link/NodeConnection.Send.cs b/backend/fleet/src/Fleet.Server.Link/NodeConnection.Send.cs index 467ca87..a05d4ca 100644 --- a/backend/fleet/src/Fleet.Server.Link/NodeConnection.Send.cs +++ b/backend/fleet/src/Fleet.Server.Link/NodeConnection.Send.cs @@ -75,7 +75,15 @@ internal sealed partial class NodeConnection return false; } - await _socket.SendAsync(bytes, WebSocketMessageType.Text, endOfMessage: true, ct); + if (_cipher is { } cipher) + { + await _socket.SendAsync(cipher.Seal(0, bytes), WebSocketMessageType.Binary, endOfMessage: true, ct); + } + else + { + await _socket.SendAsync(bytes, WebSocketMessageType.Text, endOfMessage: true, ct); + } + return true; } catch (WebSocketException) diff --git a/backend/fleet/src/Fleet.Server.Link/NodeConnection.Wire.cs b/backend/fleet/src/Fleet.Server.Link/NodeConnection.Wire.cs index 0c16154..b528943 100644 --- a/backend/fleet/src/Fleet.Server.Link/NodeConnection.Wire.cs +++ b/backend/fleet/src/Fleet.Server.Link/NodeConnection.Wire.cs @@ -30,6 +30,19 @@ internal sealed partial class NodeConnection if (result.EndOfMessage) { + if (_cipher is { } cipher) + { + // 加密链路上只接受通过认证的二进制帧;明文、被改过的、重放的、乱序的一律断开 + if (result.MessageType != WebSocketMessageType.Binary || !cipher.TryOpen(writer.WrittenSpan, out byte kind, out byte[] plain)) + { + _closeReason ??= "frame failed authentication"; + await _socket.CloseOutputAsync(WebSocketCloseStatus.PolicyViolation, "frame failed authentication", CancellationToken.None); + return null; + } + + return new Frame(kind == 0, plain); + } + return new Frame(result.MessageType == WebSocketMessageType.Text, writer.WrittenSpan.ToArray()); } } diff --git a/backend/fleet/src/Fleet.Server.Link/NodeConnection.cs b/backend/fleet/src/Fleet.Server.Link/NodeConnection.cs index 1d32de7..95867d6 100644 --- a/backend/fleet/src/Fleet.Server.Link/NodeConnection.cs +++ b/backend/fleet/src/Fleet.Server.Link/NodeConnection.cs @@ -2,6 +2,7 @@ using System.Collections.Concurrent; using System.Net.WebSockets; using System.Threading.Channels; using Fleet.Core; +using Fleet.Identity; using Fleet.Rpc; namespace Fleet.Server; @@ -24,6 +25,7 @@ internal sealed partial class NodeConnection : IDisposable private readonly Channel _stateSignal = Channel.CreateBounded(new BoundedChannelOptions(1) { FullMode = BoundedChannelFullMode.DropWrite }); private readonly Dictionary _lastPushedVersion = new(StringComparer.Ordinal); private readonly CancellationTokenSource _lifetime = new(); + private LinkCipher? _cipher; private long _nextRequestId; private long _lastPresenceTicks; private NodeRecord? _node; @@ -134,6 +136,7 @@ internal sealed partial class NodeConnection : IDisposable public void Dispose() { + _cipher?.Dispose(); _lifetime.Dispose(); _sendLock.Dispose(); } diff --git a/backend/fleet/tests/Fleet.Tests/OperatorTrustTests.cs b/backend/fleet/tests/Fleet.Tests/OperatorTrustTests.cs new file mode 100644 index 0000000..2f02e8c --- /dev/null +++ b/backend/fleet/tests/Fleet.Tests/OperatorTrustTests.cs @@ -0,0 +1,123 @@ +using System.Buffers.Text; +using System.Security.Cryptography; +using System.Text; +using Fleet.Identity; + +namespace Fleet.Tests; + +/// 软件 passkey:只做断言(navigator.credentials.get 的结果),用来测信任链。 +internal sealed class TestAuthenticator : IDisposable +{ + public const string RpId = "panel.example.com"; + public const string Origin = "https://panel.example.com"; + + private readonly ECDsa _key = ECDsa.Create(ECCurve.NamedCurves.nistP256); + private uint _counter; + + public string CredentialId { get; } = Base64Url.EncodeToString(RandomNumberGenerator.GetBytes(32)); + + public OperatorKey Key => new(CredentialId, _key.ExportSubjectPublicKeyInfo()); + + public SignedStatement Sign(string statement, byte flags = 0x05, string origin = Origin) + { + byte[] challenge = SHA256.HashData(Encoding.UTF8.GetBytes(statement)); + _counter++; + byte[] authData = [.. SHA256.HashData(Encoding.UTF8.GetBytes(RpId)), flags, (byte)(_counter >> 24), (byte)(_counter >> 16), (byte)(_counter >> 8), (byte)_counter]; + byte[] clientData = Encoding.UTF8.GetBytes($"{{\"type\":\"webauthn.get\",\"challenge\":\"{Base64Url.EncodeToString(challenge)}\",\"origin\":\"{origin}\",\"crossOrigin\":false}}"); + byte[] signature = _key.SignData([.. authData, .. SHA256.HashData(clientData)], HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence); + return new SignedStatement(statement, CredentialId, Base64Url.EncodeToString(authData), Base64Url.EncodeToString(clientData), Base64Url.EncodeToString(signature)); + } + + public void Dispose() => _key.Dispose(); +} + +public sealed class OperatorTrustTests : IDisposable +{ + private readonly TestAuthenticator _alice = new(); + private readonly TestAuthenticator _bob = new(); + + public void Dispose() + { + _alice.Dispose(); + _bob.Dispose(); + } + + private OperatorSet Genesis(params TestAuthenticator[] keys) => + new(1, OperatorTrust.Genesis, TestAuthenticator.RpId, [TestAuthenticator.Origin], [.. keys.Select(k => k.Key)]); + + private SignedStatement SignedGenesis(OperatorSet set, TestAuthenticator signer) => signer.Sign(OperatorTrust.Encode(set)); + + [Fact] + public void TheGenesis_IsPinnedByItsFingerprint_AndMustBeSelfSigned() + { + OperatorSet set = Genesis(_alice); + SignedStatement genesis = SignedGenesis(set, _alice); + string pin = OperatorTrust.Fingerprint(genesis.Statement); + + Assert.NotNull(OperatorTrust.VerifyChain(pin, [genesis])); + Assert.Null(OperatorTrust.VerifyChain(new string('0', 64), [genesis])); // 指纹对不上:Backend 换了一份创世 + Assert.Null(OperatorTrust.VerifyChain(pin, [SignedGenesis(set, _bob)])); // 不是集合里的凭证签的(也改不了指纹) + Assert.Null(OperatorTrust.VerifyChain(pin, [])); + } + + [Fact] + public void ABackendThatSwapsTheKeys_CannotMakeAChainTheNodeAccepts() + { + SignedStatement real = SignedGenesis(Genesis(_alice), _alice); + string pin = OperatorTrust.Fingerprint(real.Statement); + + using var attacker = new TestAuthenticator(); + SignedStatement forged = SignedGenesis(Genesis(attacker), attacker); // 自洽的假创世,但指纹不同 + Assert.Null(OperatorTrust.VerifyChain(pin, [forged])); + + // 在真创世后面接一个攻击者签的更新:上一版里没有他的凭证 + var update = new OperatorSet(2, OperatorTrust.Fingerprint(real.Statement), TestAuthenticator.RpId, [TestAuthenticator.Origin], [_alice.Key, attacker.Key]); + Assert.Null(OperatorTrust.VerifyChain(pin, [real, attacker.Sign(OperatorTrust.Encode(update))])); + } + + [Fact] + public void AnUpdate_NeedsAKeyOfThePreviousVersion_AndFollowsTheChain() + { + SignedStatement genesis = SignedGenesis(Genesis(_alice), _alice); + string pin = OperatorTrust.Fingerprint(genesis.Statement); + var second = new OperatorSet(2, OperatorTrust.Fingerprint(genesis.Statement), TestAuthenticator.RpId, [TestAuthenticator.Origin], [_alice.Key, _bob.Key]); + SignedStatement signedSecond = _alice.Sign(OperatorTrust.Encode(second)); + + OperatorSet? current = OperatorTrust.VerifyChain(pin, [genesis, signedSecond]); + Assert.Equal(2, current?.Version); + Assert.Equal(2, current?.Keys.Count); + + // 新加的 bob 可以签下一版(上一版里有他) + var third = new OperatorSet(3, OperatorTrust.Fingerprint(signedSecond.Statement), TestAuthenticator.RpId, [TestAuthenticator.Origin], [_bob.Key]); + Assert.Equal(3, OperatorTrust.VerifyChain(pin, [genesis, signedSecond, _bob.Sign(OperatorTrust.Encode(third))])?.Version); + + // 版本号跳了、prev 不对、域名换了:都不行 + var skipped = second with { Version = 3 }; + Assert.Null(OperatorTrust.VerifyChain(pin, [genesis, _alice.Sign(OperatorTrust.Encode(skipped))])); + var wrongPrev = second with { Previous = new string('a', 64) }; + Assert.Null(OperatorTrust.VerifyChain(pin, [genesis, _alice.Sign(OperatorTrust.Encode(wrongPrev))])); + var otherRp = second with { RpId = "evil.example" }; + Assert.Null(OperatorTrust.VerifyChain(pin, [genesis, _alice.Sign(OperatorTrust.Encode(otherRp))])); + } + + [Fact] + public void AnAssertion_NeedsUserVerification_TheRightOrigin_AndTheExactStatement() + { + OperatorSet set = Genesis(_alice); + Assert.True(OperatorTrust.VerifyOperator(_alice.Sign("hello"), set)); + Assert.False(OperatorTrust.VerifyOperator(_alice.Sign("hello", flags: 0x01), set)); // 没有用户验证 + Assert.False(OperatorTrust.VerifyOperator(_alice.Sign("hello", origin: "https://evil.example"), set)); + Assert.False(OperatorTrust.VerifyOperator(_alice.Sign("hello") with { Statement = "hellO" }, set)); // 语句被改 + Assert.False(OperatorTrust.VerifyOperator(_bob.Sign("hello"), set)); // 不在集合里 + } + + [Fact] + public void Parsing_IsStrict() + { + Assert.Null(OperatorTrust.ParseSet("moshiro-opset-v2\nversion=1")); + OperatorSet set = Genesis(_alice); + Assert.Equal(set.Keys[0].CredentialId, OperatorTrust.ParseSet(OperatorTrust.Encode(set))!.Keys[0].CredentialId); + Assert.Null(OperatorTrust.ParseSet(OperatorTrust.Encode(set with { Previous = "abc" }))); // 创世的 prev 必须是 0 + Assert.Null(OperatorTrust.ParseSet(OperatorTrust.Encode(set) + "\nnot-a-key")); + } +} diff --git a/backend/fleet/tests/Fleet.Tests/SecureChannelTests.cs b/backend/fleet/tests/Fleet.Tests/SecureChannelTests.cs new file mode 100644 index 0000000..4eb9c5d --- /dev/null +++ b/backend/fleet/tests/Fleet.Tests/SecureChannelTests.cs @@ -0,0 +1,114 @@ +using System.Security.Cryptography; +using Fleet.Identity; + +namespace Fleet.Tests; + +public sealed class SecureChannelTests +{ + private const string Nonce = "n0nce-from-the-server"; + + private static (LinkCipher Node, LinkCipher Server) Pair(string nodeNonce = Nonce, string serverNonce = Nonce) + { + using var node = new EphemeralKey(); + using var server = new EphemeralKey(); + return (LinkCipher.ForNode(node, server.PublicKeySpki, nodeNonce), LinkCipher.ForServer(server, node.PublicKeySpki, serverNonce)); + } + + [Fact] + public void TheServerProof_VerifiesOnlyForTheRightKey_Nonce_AndEphemeralKey() + { + using ECDsa serverKey = ECDsa.Create(ECCurve.NamedCurves.nistP256); + using ECDsa other = ECDsa.Create(ECCurve.NamedCurves.nistP256); + using var eph = new EphemeralKey(); + using var otherEph = new EphemeralKey(); + byte[] spki = serverKey.ExportSubjectPublicKeyInfo(); + byte[] proof = ServerHello.Sign(serverKey, Nonce, eph.PublicKeySpki); + + Assert.True(ServerHello.Verify(spki, Nonce, eph.PublicKeySpki, proof)); + Assert.False(ServerHello.Verify(other.ExportSubjectPublicKeyInfo(), Nonce, eph.PublicKeySpki, proof)); // 别人的服务器密钥 + Assert.False(ServerHello.Verify(spki, "another-nonce", eph.PublicKeySpki, proof)); // 挪到别的挑战上 + Assert.False(ServerHello.Verify(spki, Nonce, otherEph.PublicKeySpki, proof)); // 中间人换了自己的临时公钥 + Assert.False(ServerHello.Verify(spki, Nonce, eph.PublicKeySpki, proof.AsSpan(0, proof.Length - 1))); + Assert.False(ServerHello.Verify([1, 2, 3], Nonce, eph.PublicKeySpki, proof)); // 公钥不合法也不抛异常 + } + + [Fact] + public void BothDirections_RoundTrip_AndKeepTheFrameKind() + { + (LinkCipher node, LinkCipher server) = Pair(); + using LinkCipher n = node; + using LinkCipher s = server; + + byte[] toServer = n.Seal(0, "hello server"u8); + Assert.True(s.TryOpen(toServer, out byte kind, out byte[] data)); + Assert.Equal(0, kind); + Assert.Equal("hello server"u8.ToArray(), data); + + byte[] big = RandomNumberGenerator.GetBytes(300_000); + byte[] toNode = s.Seal(1, big); + Assert.True(n.TryOpen(toNode, out kind, out data)); + Assert.Equal(1, kind); + Assert.Equal(big, data); + Assert.Equal(big.Length + LinkCipher.Overhead + 1, toNode.Length); + } + + [Fact] + public void AReplayedReorderedTamperedOrReflectedFrame_IsRefused() + { + (LinkCipher node, LinkCipher server) = Pair(); + using LinkCipher n = node; + using LinkCipher s = server; + + byte[] first = n.Seal(0, "one"u8); + byte[] second = n.Seal(0, "two"u8); + Assert.False(s.TryOpen(second, out _, out _)); // 乱序(丢了第一帧) + Assert.True(s.TryOpen(first, out _, out _)); + Assert.False(s.TryOpen(first, out _, out _)); // 重放 + Assert.True(s.TryOpen(second, out _, out _)); + + byte[] tampered = n.Seal(0, "three"u8); + tampered[12] ^= 0x01; + Assert.False(s.TryOpen(tampered, out _, out _)); // 改了一位 + + byte[] fromNode = n.Seal(0, "four"u8); + (LinkCipher otherNode, LinkCipher otherServer) = Pair(); + using LinkCipher on = otherNode; + using LinkCipher os = otherServer; + Assert.False(os.TryOpen(fromNode, out _, out _)); // 另一条连接的密钥打不开 + + byte[] fromServer = s.Seal(0, "reflected"u8); + Assert.False(s.TryOpen(fromServer, out _, out _)); // 方向不同:服务器发的东西不能原样塞回给服务器 + Assert.False(n.TryOpen([1, 2, 3], out _, out _)); + } + + [Fact] + public void ADifferentNonce_GivesDifferentKeys() + { + (LinkCipher node, LinkCipher server) = Pair("nonce-a", "nonce-b"); // 两边对不上:没人能解开对方的东西 + using LinkCipher n = node; + using LinkCipher s = server; + Assert.False(s.TryOpen(n.Seal(0, "x"u8), out _, out _)); + } + + [Fact] + public void AnOperatorSet_CarriesTheServerKeys_ThroughTheChain() + { + using var alice = new TestAuthenticator(); + using ECDsa serverKey = ECDsa.Create(ECCurve.NamedCurves.nistP256); + byte[] spki = serverKey.ExportSubjectPublicKeyInfo(); + var set = new OperatorSet(1, OperatorTrust.Genesis, TestAuthenticator.RpId, [TestAuthenticator.Origin], [alice.Key], [spki]); + string text = OperatorTrust.Encode(set); + + OperatorSet parsed = OperatorTrust.ParseSet(text)!; + Assert.Equal(spki, Assert.Single(parsed.Servers!)); + Assert.Equal(text, OperatorTrust.Encode(parsed)); // 编码和解析互逆,指纹稳定 + + SignedStatement genesis = alice.Sign(text); + OperatorSet verified = OperatorTrust.VerifyChain(OperatorTrust.Fingerprint(text), [genesis])!; + Assert.Equal(spki, Assert.Single(verified.Servers!)); + + // 服务器密钥只能写在凭证后面;顺序不对、内容不是 base64 都算坏 + Assert.Null(OperatorTrust.ParseSet(text + "\nkey=late:AAAA")); + Assert.Null(OperatorTrust.ParseSet(text + "\nserver=***")); + } +} diff --git a/backend/fleet/tools/fleet-enroll/Program.cs b/backend/fleet/tools/fleet-enroll/Program.cs index 3c31989..3e7ee3b 100644 --- a/backend/fleet/tools/fleet-enroll/Program.cs +++ b/backend/fleet/tools/fleet-enroll/Program.cs @@ -12,7 +12,7 @@ string? name = Arg("--name"); string? directory = Arg("--identity-dir"); if (server is null || join is null || name is null || directory is null) { - Console.Error.WriteLine("usage: fleet-enroll --server --join --name --identity-dir [--meta ] [--version ]"); + Console.Error.WriteLine("usage: fleet-enroll --server --join --name --identity-dir [--meta ] [--version ] [--genesis --controllers ]"); return 2; } @@ -30,14 +30,17 @@ if (Arg("--meta") is { } metaText) } } +string? genesis = Arg("--genesis"); +string[] controllers = (Arg("--controllers") ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) }; Result result = await EnrollmentClient.EnrollAsync( - http, new Uri(server), join, name, directory, Arg("--version") ?? "0.0.0", meta, TimeProvider.System, CancellationToken.None); + http, new Uri(server), join, name, directory, Arg("--version") ?? "0.0.0", meta, TimeProvider.System, CancellationToken.None, genesis, controllers); if (!result.TryGet(out EnrollmentResult? enrolled, out FleetError? error)) { Console.Error.WriteLine($"enrollment failed ({error.Code}): {error.Message}"); return 1; } -Console.WriteLine($"enrolled as {enrolled.NodeId} ({enrolled.Status}); identity written to {enrolled.IdentityDirectory}"); +Console.WriteLine($"enrolled as {enrolled.NodeId} ({enrolled.Status}); identity written to {enrolled.IdentityDirectory}" + (enrolled.Pinned ? "; operator trust pinned" : "; operator trust NOT pinned (no --genesis)")); return 0; diff --git a/backend/openapi/v1.json b/backend/openapi/v1.json index a4801fb..30ce08f 100644 --- a/backend/openapi/v1.json +++ b/backend/openapi/v1.json @@ -1187,6 +1187,98 @@ } } }, + "/api/admin/auth/passkey/options": { + "post": { + "tags": [ + "admin-auth" + ], + "summary": "A challenge for signing in with a passkey (discoverable credential, no username needed)", + "operationId": "auth_passkey_options", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PasskeyRequestOptionsDto" + } + } + } + }, + "429": { + "description": "Too Many Requests", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + } + }, + "/api/admin/auth/passkey/login": { + "post": { + "tags": [ + "admin-auth" + ], + "summary": "Sign in with a passkey assertion; same response as the password sign-in", + "operationId": "auth_passkey_login", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PasskeyLoginRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginResponse" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "429": { + "description": "Too Many Requests", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + } + }, "/api/admin/releases": { "post": { "tags": [ @@ -1255,7 +1347,8 @@ } } } - } + }, + "x-moshiro-capability": "release.publish" }, "get": { "tags": [ @@ -1294,7 +1387,8 @@ } } } - } + }, + "x-moshiro-capability": "update.read" } }, "/api/admin/auth/logout": { @@ -1323,7 +1417,414 @@ } } } - } + }, + "x-moshiro-capability": "authenticated" + } + }, + "/api/admin/auth/passkeys": { + "get": { + "tags": [ + "admin-auth" + ], + "summary": "The signed-in user's passkeys", + "operationId": "auth_passkeys_list", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PageDtoOfPasskeyDto" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + }, + "x-moshiro-capability": "authenticated" + }, + "post": { + "tags": [ + "admin-auth" + ], + "summary": "Register a passkey from a WebAuthn attestation (ES256, user verification required)", + "operationId": "auth_passkeys_register", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegisterPasskeyRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PasskeyDto" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "409": { + "description": "Conflict", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + }, + "x-moshiro-capability": "authenticated" + } + }, + "/api/admin/auth/passkeys/options": { + "post": { + "tags": [ + "admin-auth" + ], + "summary": "A challenge for registering a new passkey (the user must be signed in)", + "operationId": "auth_passkeys_options", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PasskeyCreationOptionsDto" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + }, + "x-moshiro-capability": "authenticated" + } + }, + "/api/admin/auth/passkeys/{id}": { + "delete": { + "tags": [ + "admin-auth" + ], + "summary": "Remove one of your own passkeys", + "operationId": "auth_passkeys_delete", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No Content", + "content": { + "application/json": { + "schema": { } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + }, + "x-moshiro-capability": "authenticated" + } + }, + "/api/admin/auth/elevation": { + "get": { + "tags": [ + "admin-auth" + ], + "summary": "The elevation windows this session has open (sensitive actions need one)", + "operationId": "auth_elevation_list", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PageDtoOfElevationDto" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + }, + "x-moshiro-capability": "authenticated" + }, + "post": { + "tags": [ + "admin-auth" + ], + "summary": "Open an elevation window from a passkey assertion (enroll 15 min, revoke 5 min, credential one use within 60 s)", + "operationId": "auth_elevate", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ElevationAssertionRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ElevationDto" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "429": { + "description": "Too Many Requests", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + }, + "x-moshiro-capability": "authenticated" + }, + "delete": { + "tags": [ + "admin-auth" + ], + "summary": "Close all elevation windows of this session", + "operationId": "auth_elevation_end", + "responses": { + "204": { + "description": "No Content", + "content": { + "application/json": { + "schema": { } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + }, + "x-moshiro-capability": "authenticated" + } + }, + "/api/admin/auth/elevation/options": { + "post": { + "tags": [ + "admin-auth" + ], + "summary": "A challenge for opening an elevation window with a passkey touch", + "operationId": "auth_elevation_options", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ElevationRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PasskeyRequestOptionsDto" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "429": { + "description": "Too Many Requests", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + }, + "x-moshiro-capability": "authenticated" } }, "/api/admin/auth/me": { @@ -1354,7 +1855,8 @@ } } } - } + }, + "x-moshiro-capability": "authenticated" } }, "/api/admin/agents": { @@ -1395,7 +1897,8 @@ } } } - } + }, + "x-moshiro-capability": "fleet.read" } }, "/api/admin/agents/{id}": { @@ -1456,7 +1959,8 @@ } } } - } + }, + "x-moshiro-capability": "fleet.read" }, "patch": { "tags": [ @@ -1560,7 +2064,8 @@ } } } - } + }, + "x-moshiro-capability": "fleet.write" }, "delete": { "tags": [ @@ -1607,7 +2112,8 @@ } } } - } + }, + "x-moshiro-capability": "fleet.revoke" } }, "/api/admin/agents/{id}/approve": { @@ -1678,7 +2184,8 @@ } } } - } + }, + "x-moshiro-capability": "fleet.enroll" } }, "/api/admin/agents/{id}/disable": { @@ -1749,7 +2256,8 @@ } } } - } + }, + "x-moshiro-capability": "fleet.write" } }, "/api/admin/agents/{id}/enable": { @@ -1820,7 +2328,8 @@ } } } - } + }, + "x-moshiro-capability": "fleet.write" } }, "/api/admin/agents/{id}/takeover": { @@ -1881,7 +2390,8 @@ } } } - } + }, + "x-moshiro-capability": "fleet.enroll" } }, "/api/admin/join-tokens": { @@ -1922,7 +2432,8 @@ } } } - } + }, + "x-moshiro-capability": "fleet.read" }, "post": { "tags": [ @@ -1981,7 +2492,8 @@ } } } - } + }, + "x-moshiro-capability": "fleet.enroll" } }, "/api/admin/join-tokens/{id}": { @@ -2037,7 +2549,8 @@ } } } - } + }, + "x-moshiro-capability": "fleet.enroll" } }, "/api/admin/probes": { @@ -2078,7 +2591,8 @@ } } } - } + }, + "x-moshiro-capability": "fleet.read" } }, "/api/admin/probes/{id}": { @@ -2139,7 +2653,8 @@ } } } - } + }, + "x-moshiro-capability": "fleet.read" }, "patch": { "tags": [ @@ -2228,7 +2743,8 @@ } } } - } + }, + "x-moshiro-capability": "fleet.write" }, "delete": { "tags": [ @@ -2275,7 +2791,8 @@ } } } - } + }, + "x-moshiro-capability": "fleet.revoke" } }, "/api/admin/probes/{id}/revoke": { @@ -2336,7 +2853,8 @@ } } } - } + }, + "x-moshiro-capability": "fleet.revoke" } }, "/api/admin/agents/{id}/probes/{probe_id}/disconnect": { @@ -2413,7 +2931,8 @@ } } } - } + }, + "x-moshiro-capability": "fleet.write" } }, "/api/admin/tasks": { @@ -2470,7 +2989,8 @@ } } } - } + }, + "x-moshiro-capability": "tasks.read" }, "post": { "tags": [ @@ -2539,7 +3059,8 @@ } } } - } + }, + "x-moshiro-capability": "tasks.write" } }, "/api/admin/tasks/{id}": { @@ -2600,7 +3121,8 @@ } } } - } + }, + "x-moshiro-capability": "tasks.read" }, "patch": { "tags": [ @@ -2694,7 +3216,8 @@ } } } - } + }, + "x-moshiro-capability": "tasks.write" }, "delete": { "tags": [ @@ -2741,7 +3264,8 @@ } } } - } + }, + "x-moshiro-capability": "tasks.write" } }, "/api/admin/lines/{line_id}/tests": { @@ -2832,7 +3356,8 @@ } } } - } + }, + "x-moshiro-capability": "tasks.write" }, "get": { "tags": [ @@ -2893,7 +3418,8 @@ } } } - } + }, + "x-moshiro-capability": "tasks.read" } }, "/api/admin/tests/{id}/cancel": { @@ -2954,7 +3480,8 @@ } } } - } + }, + "x-moshiro-capability": "tasks.write" } }, "/api/admin/raw/chunks": { @@ -3061,7 +3588,8 @@ } } } - } + }, + "x-moshiro-capability": "data.read" } }, "/api/admin/raw/chunks/{chunk_id}": { @@ -3125,7 +3653,8 @@ } } } - } + }, + "x-moshiro-capability": "data.read" } }, "/api/admin/events": { @@ -3225,7 +3754,8 @@ } } } - } + }, + "x-moshiro-capability": "events.read" } }, "/api/admin/analysis/recompute": { @@ -3266,7 +3796,207 @@ } } } - } + }, + "x-moshiro-capability": "data.write" + } + }, + "/api/admin/audit": { + "get": { + "tags": [ + "admin-audit" + ], + "summary": "Audit log of admin actions, newest first", + "operationId": "audit_list", + "parameters": [ + { + "name": "actor", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "action", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "schema": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32" + } + }, + { + "name": "cursor", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PageDtoOfAuditDto" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + }, + "x-moshiro-capability": "audit.read" + } + }, + "/api/admin/trust": { + "get": { + "tags": [ + "admin-trust" + ], + "summary": "The operator trust root: genesis fingerprint, version, whether it matches the registered passkeys, and the next version to sign", + "operationId": "trust_get", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TrustStateDto" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + }, + "x-moshiro-capability": "fleet.read" + }, + "post": { + "tags": [ + "admin-trust" + ], + "summary": "Store the next operator set, signed with a passkey (the first one sets the genesis and needs a credential window)", + "operationId": "trust_commit", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SignedStatementDto" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TrustStateDto" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "409": { + "description": "Conflict", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + }, + "x-moshiro-capability": "accounts.manage" } }, "/api/admin/api-tokens": { @@ -3307,13 +4037,14 @@ } } } - } + }, + "x-moshiro-capability": "keys.read" }, "post": { "tags": [ "admin-api-tokens" ], - "summary": "Create an API token (scope read or admin); the plaintext is returned only once", + "summary": "Create an API token with the chosen capabilities (a subset of your own); the plaintext is returned only once", "operationId": "api_tokens_create", "requestBody": { "content": { @@ -3366,7 +4097,8 @@ } } } - } + }, + "x-moshiro-capability": "keys.manage" } }, "/api/admin/api-tokens/{id}": { @@ -3415,7 +4147,50 @@ } } } - } + }, + "x-moshiro-capability": "keys.manage" + } + }, + "/api/admin/capabilities": { + "get": { + "tags": [ + "admin-api-tokens" + ], + "summary": "The capability catalog: groups, which ones change trust, which are reserved, and the presets", + "operationId": "capabilities_list", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CapabilityCatalogDto" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + }, + "x-moshiro-capability": "keys.read" } }, "/api/admin/settings": { @@ -3456,7 +4231,8 @@ } } } - } + }, + "x-moshiro-capability": "settings.read" }, "patch": { "tags": [ @@ -3520,7 +4296,8 @@ } } } - } + }, + "x-moshiro-capability": "settings.write" } }, "/api/admin/users": { @@ -3561,7 +4338,8 @@ } } } - } + }, + "x-moshiro-capability": "accounts.read" }, "post": { "tags": [ @@ -3630,7 +4408,8 @@ } } } - } + }, + "x-moshiro-capability": "accounts.manage" } }, "/api/admin/users/{id}": { @@ -3699,7 +4478,8 @@ } } } - } + }, + "x-moshiro-capability": "accounts.manage" } }, "/api/admin/build-info": { @@ -3740,7 +4520,8 @@ } } } - } + }, + "x-moshiro-capability": "update.read" } }, "/api/admin/nodes/{id}/update": { @@ -3799,7 +4580,8 @@ } } } - } + }, + "x-moshiro-capability": "update.confirm" } }, "/api/admin/backend/update": { @@ -3840,7 +4622,8 @@ } } } - } + }, + "x-moshiro-capability": "update.read" }, "post": { "tags": [ @@ -3907,7 +4690,8 @@ } } } - } + }, + "x-moshiro-capability": "update.confirm" } }, "/api/admin/backend/update/live": { @@ -3941,7 +4725,8 @@ } } } - } + }, + "x-moshiro-capability": "update.read" } }, "/api/admin/links": { @@ -3982,7 +4767,8 @@ } } } - } + }, + "x-moshiro-capability": "fleet.read" } }, "/api/admin/links/{probe_id}/{agent_id}": { @@ -4071,7 +4857,8 @@ } } } - } + }, + "x-moshiro-capability": "fleet.write" } }, "/healthz": { @@ -4110,7 +4897,7 @@ "tags": [ "ops" ], - "summary": "Prometheus metrics (admin token)", + "summary": "Prometheus metrics (requires ops.metrics)", "operationId": "metrics_get", "responses": { "200": { @@ -4144,8 +4931,55 @@ "tags": [ "ops" ], - "summary": "One-line installer for Linux and macOS nodes (agent or probe, decided by the code)", + "summary": "The static one-line installer for Linux and macOS nodes (same bytes for everyone; its SHA-256 is published with each release)", "operationId": "install_sh", + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/install/manifest": { + "get": { + "tags": [ + "ops" + ], + "summary": "The signed release manifest of the component an install code is for (exact signed bytes)", + "operationId": "install_manifest", + "parameters": [ + { + "name": "code", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + } + }, + "/install/manifest.sig": { + "get": { + "tags": [ + "ops" + ], + "summary": "Base64 DER ECDSA P-256 signature of the manifest", + "operationId": "install_manifest_signature", "parameters": [ { "name": "code", @@ -4177,30 +5011,11 @@ "tags": [ "ops" ], - "summary": "One-line installer for Windows nodes (agent or probe, decided by the code)", + "summary": "The static one-line installer for Windows nodes (same bytes for everyone; its SHA-256 is published with each release)", "operationId": "install_ps1", - "parameters": [ - { - "name": "code", - "in": "query", - "schema": { - "type": "string" - } - } - ], "responses": { "200": { "description": "OK" - }, - "404": { - "description": "Not Found", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDto" - } - } - } } } } @@ -4218,6 +5033,20 @@ } } } + }, + "/api/agent/v1/enroll/trust": { + "post": { + "tags": [ + "agent" + ], + "summary": "The operator set chain and the grant of a join token, for the node to verify before it enrolls", + "operationId": "fleet_enroll_trust", + "responses": { + "200": { + "description": "OK" + } + } + } } }, "components": { @@ -4426,6 +5255,12 @@ "ipv6_host_auto": { "type": "boolean", "default": true + }, + "added_by": { + "type": [ + "null", + "string" + ] } } }, @@ -4512,9 +5347,10 @@ "required": [ "id", "name", - "scope", + "capabilities", "created_at", "last_used_at", + "expires_at", "revoked_at" ], "type": "object", @@ -4525,8 +5361,11 @@ "name": { "type": "string" }, - "scope": { - "type": "string" + "capabilities": { + "type": "array", + "items": { + "type": "string" + } }, "created_at": { "format": "date-time" @@ -4534,11 +5373,71 @@ "last_used_at": { "format": "date-time" }, + "expires_at": { + "format": "date-time" + }, "revoked_at": { "format": "date-time" } } }, + "AuditDto": { + "required": [ + "id", + "at", + "actor", + "method", + "action", + "target", + "detail", + "remote", + "success" + ], + "type": "object", + "properties": { + "id": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + }, + "at": { + "format": "date-time" + }, + "actor": { + "type": "string" + }, + "method": { + "type": "string" + }, + "action": { + "type": "string" + }, + "target": { + "type": [ + "null", + "string" + ] + }, + "detail": { + "type": [ + "null", + "string" + ] + }, + "remote": { + "type": [ + "null", + "string" + ] + }, + "success": { + "type": "boolean" + } + } + }, "BackendUpdateDto": { "required": [ "channel", @@ -4680,6 +5579,89 @@ } } }, + "CapabilityCatalogDto": { + "required": [ + "groups", + "presets" + ], + "type": "object", + "properties": { + "groups": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CapabilityGroupDto" + } + }, + "presets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CapabilityPresetDto" + } + } + } + }, + "CapabilityDto": { + "required": [ + "id", + "trust", + "reserved", + "implies" + ], + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "trust": { + "type": "boolean" + }, + "reserved": { + "type": "boolean" + }, + "implies": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "CapabilityGroupDto": { + "required": [ + "id", + "capabilities" + ], + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "capabilities": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CapabilityDto" + } + } + } + }, + "CapabilityPresetDto": { + "required": [ + "id", + "capabilities" + ], + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "capabilities": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "ChangePasswordRequest": { "required": [ "password" @@ -4845,15 +5827,27 @@ "CreateApiTokenRequest": { "required": [ "name", - "scope" + "capabilities" ], "type": "object", "properties": { "name": { "type": "string" }, - "scope": { - "type": "string" + "capabilities": { + "type": "array", + "items": { + "type": "string" + } + }, + "expires_in_days": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "null", + "integer", + "string" + ], + "format": "int32" } } }, @@ -4911,6 +5905,15 @@ "null", "string" ] + }, + "controllers": { + "type": [ + "null", + "array" + ], + "items": { + "type": "string" + } } } }, @@ -5165,6 +6168,59 @@ } } }, + "ElevationAssertionRequest": { + "required": [ + "class", + "credential_id", + "authenticator_data", + "client_data_json", + "signature" + ], + "type": "object", + "properties": { + "class": { + "type": "string" + }, + "credential_id": { + "type": "string" + }, + "authenticator_data": { + "type": "string" + }, + "client_data_json": { + "type": "string" + }, + "signature": { + "type": "string" + } + } + }, + "ElevationDto": { + "required": [ + "class", + "expires_at" + ], + "type": "object", + "properties": { + "class": { + "type": "string" + }, + "expires_at": { + "format": "date-time" + } + } + }, + "ElevationRequest": { + "required": [ + "class" + ], + "type": "object", + "properties": { + "class": { + "type": "string" + } + } + }, "EventDto": { "required": [ "id", @@ -5716,6 +6772,12 @@ "role": { "type": "string", "default": "agent" + }, + "created_by": { + "type": [ + "null", + "string" + ] } } }, @@ -5988,7 +7050,7 @@ "MeDto": { "required": [ "kind", - "scope", + "capabilities", "user_id", "username", "csrf_token" @@ -5998,8 +7060,11 @@ "kind": { "type": "string" }, - "scope": { - "type": "string" + "capabilities": { + "type": "array", + "items": { + "type": "string" + } }, "user_id": { "type": [ @@ -6018,6 +7083,12 @@ "null", "string" ] + }, + "auth_method": { + "type": [ + "null", + "string" + ] } } }, @@ -6479,6 +7550,48 @@ } } }, + "PageDtoOfAuditDto": { + "required": [ + "items", + "next_cursor" + ], + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AuditDto" + } + }, + "next_cursor": { + "type": [ + "null", + "string" + ] + } + } + }, + "PageDtoOfElevationDto": { + "required": [ + "items", + "next_cursor" + ], + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ElevationDto" + } + }, + "next_cursor": { + "type": [ + "null", + "string" + ] + } + } + }, "PageDtoOfEventDto": { "required": [ "items", @@ -6626,6 +7739,27 @@ } } }, + "PageDtoOfPasskeyDto": { + "required": [ + "items", + "next_cursor" + ], + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PasskeyDto" + } + }, + "next_cursor": { + "type": [ + "null", + "string" + ] + } + } + }, "PageDtoOfPlanDto": { "required": [ "items", @@ -6875,6 +8009,142 @@ } } }, + "PasskeyCreationOptionsDto": { + "required": [ + "challenge", + "rp_id", + "rp_name", + "user_id", + "user_name", + "exclude_credentials", + "timeout_ms" + ], + "type": "object", + "properties": { + "challenge": { + "type": "string" + }, + "rp_id": { + "type": "string" + }, + "rp_name": { + "type": "string" + }, + "user_id": { + "type": "string" + }, + "user_name": { + "type": "string" + }, + "exclude_credentials": { + "type": "array", + "items": { + "type": "string" + } + }, + "timeout_ms": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32" + } + } + }, + "PasskeyDto": { + "required": [ + "id", + "name", + "created_at", + "last_used_at", + "transports" + ], + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "created_at": { + "format": "date-time" + }, + "last_used_at": { + "format": "date-time" + }, + "transports": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "PasskeyLoginRequest": { + "required": [ + "credential_id", + "authenticator_data", + "client_data_json", + "signature", + "return_session" + ], + "type": "object", + "properties": { + "credential_id": { + "type": "string" + }, + "authenticator_data": { + "type": "string" + }, + "client_data_json": { + "type": "string" + }, + "signature": { + "type": "string" + }, + "return_session": { + "type": [ + "null", + "boolean" + ] + } + } + }, + "PasskeyRequestOptionsDto": { + "required": [ + "challenge", + "rp_id", + "timeout_ms" + ], + "type": "object", + "properties": { + "challenge": { + "type": "string" + }, + "rp_id": { + "type": "string" + }, + "timeout_ms": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32" + }, + "allow_credentials": { + "type": [ + "null", + "array" + ], + "items": { + "type": "string" + } + } + } + }, "PatchProbeRequest": { "required": [ "name" @@ -7082,6 +8352,12 @@ "$ref": "#/components/schemas/Ipv6DiagnosticDto" } ] + }, + "added_by": { + "type": [ + "null", + "string" + ] } } }, @@ -7287,6 +8563,39 @@ } } }, + "RegisterPasskeyRequest": { + "required": [ + "name", + "credential_id", + "attestation_object", + "client_data_json", + "transports" + ], + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "credential_id": { + "type": "string" + }, + "attestation_object": { + "type": "string" + }, + "client_data_json": { + "type": "string" + }, + "transports": { + "type": [ + "null", + "array" + ], + "items": { + "type": "string" + } + } + } + }, "RejectedDto": { "required": [ "id", @@ -7527,6 +8836,26 @@ } } }, + "SignedStatementDto": { + "type": "object", + "properties": { + "statement": { + "type": "string" + }, + "credential_id": { + "type": "string" + }, + "authenticator_data": { + "type": "string" + }, + "client_data_json": { + "type": "string" + }, + "signature": { + "type": "string" + } + } + }, "StartTestRequestDto": { "required": [ "plan", @@ -7878,6 +9207,107 @@ } } }, + "TrustProposalDto": { + "required": [ + "statement", + "version", + "challenge" + ], + "type": "object", + "properties": { + "statement": { + "type": "string" + }, + "version": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32" + }, + "challenge": { + "type": "string" + } + } + }, + "TrustStateDto": { + "required": [ + "initialized", + "genesis", + "version", + "in_sync", + "proposal" + ], + "type": "object", + "properties": { + "initialized": { + "type": "boolean" + }, + "genesis": { + "type": [ + "null", + "string" + ] + }, + "version": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32" + }, + "in_sync": { + "type": "boolean" + }, + "proposal": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/TrustProposalDto" + } + ] + }, + "install_url": { + "type": "string", + "default": "" + }, + "operators": { + "type": [ + "null", + "array" + ], + "items": { + "type": "string" + } + }, + "install_sh_sha256": { + "type": "string", + "default": "" + }, + "install_ps1_sha256": { + "type": "string", + "default": "" + }, + "install_matches_release": { + "type": [ + "null", + "boolean" + ] + }, + "server_key": { + "type": "string", + "default": "" + }, + "server_key_listed": { + "type": "boolean", + "default": false + } + } + }, "UserDto": { "required": [ "id", @@ -7933,6 +9363,12 @@ { "name": "admin-data" }, + { + "name": "admin-audit" + }, + { + "name": "admin-trust" + }, { "name": "admin-api-tokens" }, diff --git a/backend/probe/src/MoShiro.Probe/Link/AgentDirectory.cs b/backend/probe/src/MoShiro.Probe/Link/AgentDirectory.cs index 69beae9..69c68cc 100644 --- a/backend/probe/src/MoShiro.Probe/Link/AgentDirectory.cs +++ b/backend/probe/src/MoShiro.Probe/Link/AgentDirectory.cs @@ -16,11 +16,13 @@ public sealed class AgentDirectory private readonly object _gate = new(); private readonly ConfigStore _store; private readonly INodeLog _log; + private readonly TrustAnchor _trust; private List _agents = []; private long _version; - public AgentDirectory(ConfigStore store, string configDirectory, INodeLog log) + public AgentDirectory(ConfigStore store, string configDirectory, INodeLog log, TrustAnchor? trust = null) { + _trust = trust ?? TrustAnchor.None; _store = store; _log = log; string cache = Path.Combine(configDirectory, FileName); @@ -68,6 +70,7 @@ public sealed class AgentDirectory public void Apply(ProbeAgentList result) { var next = new List(); + _trust.Update(result.TrustChain); foreach (AgentEndpoint agent in result.Agents) { if (!string.IsNullOrWhiteSpace(agent.Host)) diff --git a/backend/probe/src/MoShiro.Probe/ProbeApp.cs b/backend/probe/src/MoShiro.Probe/ProbeApp.cs index 1dc5fca..a5cfe26 100644 --- a/backend/probe/src/MoShiro.Probe/ProbeApp.cs +++ b/backend/probe/src/MoShiro.Probe/ProbeApp.cs @@ -27,7 +27,8 @@ public sealed class ProbeApp : IDisposable public ProbeApp(ProbeFile file, LoadedNodeIdentity identity, string configDirectory, TimeProvider time, INodeLog log, RestartController? restart = null) { _log = log; - var directory = new AgentDirectory(new ConfigStore(configDirectory), configDirectory, log); + var trust = TrustAnchor.Load(file.Fleet.IdentityDir, log); + var directory = new AgentDirectory(new ConfigStore(configDirectory), configDirectory, log, trust); _env = new ConnectionEnv(identity.PeerIdentity, identity.NodeId.Value, file.Limits, directory, new MonoClock(time), time, log); directory.Changed += () => _changed.Release(); @@ -36,6 +37,7 @@ public sealed class ProbeApp : IDisposable { Server = new Uri(file.Fleet.Server), Identity = identity.Identity, + ServerVerifier = trust, NodeId = identity.NodeId, NodeVersion = Version, Capabilities = [MoShiroCapabilities.Base], diff --git a/backend/shared/MoShiro.Protocol/MoShiroErrors.cs b/backend/shared/MoShiro.Protocol/MoShiroErrors.cs index 91c7259..0cd40dc 100644 --- a/backend/shared/MoShiro.Protocol/MoShiroErrors.cs +++ b/backend/shared/MoShiro.Protocol/MoShiroErrors.cs @@ -11,6 +11,7 @@ public static class MoShiroErrors public static readonly ErrorCode CsrfFailed = new("csrf_failed"); public static readonly ErrorCode Forbidden = new("forbidden"); public static readonly ErrorCode SitePrivate = new("site_private"); + public static readonly ErrorCode ElevationRequired = new("elevation_required"); public static readonly ErrorCode NotFound = new("not_found"); public static readonly ErrorCode AgentNotFound = new("agent_not_found"); public static readonly ErrorCode ProbeNotFound = new("probe_not_found"); diff --git a/backend/shared/MoShiro.Protocol/Rpc/MoShiroRpc.cs b/backend/shared/MoShiro.Protocol/Rpc/MoShiroRpc.cs index 9703560..43c7b34 100644 --- a/backend/shared/MoShiro.Protocol/Rpc/MoShiroRpc.cs +++ b/backend/shared/MoShiro.Protocol/Rpc/MoShiroRpc.cs @@ -164,6 +164,9 @@ public sealed class ProbeAgentList [JsonPropertyName("agents")] public List Agents { get; set; } = []; + + [JsonPropertyName("trust_chain")] + public List? TrustChain { get; set; } } /// 注册元数据(MoShiro 定义,经 Fleet 的 meta 字段带上来)。 diff --git a/backend/shared/MoShiro.Protocol/Tasks/TaskSet.cs b/backend/shared/MoShiro.Protocol/Tasks/TaskSet.cs index 88771f4..6094e66 100644 --- a/backend/shared/MoShiro.Protocol/Tasks/TaskSet.cs +++ b/backend/shared/MoShiro.Protocol/Tasks/TaskSet.cs @@ -56,6 +56,10 @@ public sealed class TaskSet /// 允许连接到这个 Agent 的 Probe 公钥。 [JsonPropertyName("peers")] public List Peers { get; set; } = []; + + /// 操作员集合的链(passkey 签的,创世在前):节点从固定的创世指纹验过才采用。 + [JsonPropertyName("trust_chain")] + public List? TrustChain { get; set; } } public static class TaskSchedule diff --git a/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs index 63ed0c3..8fe3026 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs @@ -1,4 +1,5 @@ using Fleet.Core; +using Fleet.Identity; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; @@ -17,50 +18,67 @@ public static class AgentEndpoints public static void Map(RouteGroupBuilder group) { group.MapGet("/agents", ListAsync) - .Api("agents_list", "All agents including pending and disabled ones", AgentsTag).Returns>().ProducesProblems(401, 403); + .Api("agents_list", "All agents including pending and disabled ones", AgentsTag).Returns>().ProducesProblems(401, 403) + .Requires(Capability.FleetRead); group.MapGet("/agents/{id}", GetAsync) - .Api("agents_get", "One agent", AgentsTag).Returns().ProducesProblems(401, 403, 404); + .Api("agents_get", "One agent", AgentsTag).Returns().ProducesProblems(401, 403, 404) + .Requires(Capability.FleetRead); group.MapPatch("/agents/{id}", PatchAsync) .Accepts("application/merge-patch+json", "application/json") .Api("agents_update", "Update name, labels, public host or visibility (merge-patch, If-Match supported)", AgentsTag) - .Returns().ProducesProblems(400, 401, 403, 404, 409, 412); + .Returns().ProducesProblems(400, 401, 403, 404, 409, 412) + .Requires(Capability.FleetWrite); group.MapPost("/agents/{id}/approve", (HttpContext http, AgentService agents, string id) => TransitionAsync(http, id, (nodeId, ct) => agents.ApproveAsync(nodeId, ct))) - .Api("agents_approve", "Approve a pending agent", AgentsTag).Returns().ProducesProblems(401, 403, 404, 409); + .Api("agents_approve", "Approve a pending agent", AgentsTag).Returns().ProducesProblems(401, 403, 404, 409) + .Requires(Capability.FleetEnroll); group.MapPost("/agents/{id}/disable", (HttpContext http, AgentService agents, string id) => TransitionAsync(http, id, (nodeId, ct) => agents.DisableAsync(nodeId, ct))) - .Api("agents_disable", "Disable an agent and drop its connection", AgentsTag).Returns().ProducesProblems(401, 403, 404, 409); + .Api("agents_disable", "Disable an agent and drop its connection", AgentsTag).Returns().ProducesProblems(401, 403, 404, 409) + .Requires(Capability.FleetWrite); group.MapPost("/agents/{id}/enable", (HttpContext http, AgentService agents, string id) => TransitionAsync(http, id, (nodeId, ct) => agents.EnableAsync(nodeId, ct))) - .Api("agents_enable", "Enable a disabled agent", AgentsTag).Returns().ProducesProblems(401, 403, 404, 409); + .Api("agents_enable", "Enable a disabled agent", AgentsTag).Returns().ProducesProblems(401, 403, 404, 409) + .Requires(Capability.FleetWrite); group.MapPost("/agents/{id}/takeover", (HttpContext http, AgentService agents, string id) => TransitionAsync(http, id, (nodeId, ct) => agents.TakeoverAsync(nodeId, ct))) .Api("agents_takeover", "Replace the existing node of the same name with this pending one, keeping history", AgentsTag) - .Returns().ProducesProblems(401, 403, 404); + .Returns().ProducesProblems(401, 403, 404) + .Requires(Capability.FleetEnroll); group.MapDelete("/agents/{id}", DeleteAsync) .Api("agents_delete", "Delete an agent; history expires with the retention policy", AgentsTag) - .Returns(StatusCodes.Status204NoContent).ProducesProblems(401, 403); + .Returns(StatusCodes.Status204NoContent).ProducesProblems(401, 403) + .Requires(Capability.FleetRevoke); group.MapGet("/join-tokens", ListTokensAsync) - .Api("join_tokens_list", "Join tokens", TokensTag).Returns>().ProducesProblems(401, 403); + .Api("join_tokens_list", "Join tokens", TokensTag).Returns>().ProducesProblems(401, 403) + .Requires(Capability.FleetRead); group.MapPost("/join-tokens", CreateTokenAsync) .Api("join_tokens_create", "Create a join token for an agent or a probe (role); the plaintext is returned only once", TokensTag) - .Returns(StatusCodes.Status201Created).ProducesProblems(400, 401, 403); + .Returns(StatusCodes.Status201Created).ProducesProblems(400, 401, 403) + .Requires(Capability.FleetEnroll); group.MapDelete("/join-tokens/{id}", RevokeTokenAsync) .Api("join_tokens_revoke", "Revoke a join token; with purge=true delete the record for good", TokensTag) - .Returns(StatusCodes.Status204NoContent).ProducesProblems(401, 403); + .Returns(StatusCodes.Status204NoContent).ProducesProblems(401, 403) + .Requires(Capability.FleetEnroll); group.MapGet("/probes", ListProbesAsync) - .Api("probes_list", "All probes, with the agents each one is connected to", ProbeTag).Returns>().ProducesProblems(401, 403); + .Api("probes_list", "All probes, with the agents each one is connected to", ProbeTag).Returns>().ProducesProblems(401, 403) + .Requires(Capability.FleetRead); group.MapGet("/probes/{id}", GetProbeAsync) - .Api("probes_get", "One probe", ProbeTag).Returns().ProducesProblems(401, 403, 404); + .Api("probes_get", "One probe", ProbeTag).Returns().ProducesProblems(401, 403, 404) + .Requires(Capability.FleetRead); group.MapPatch("/probes/{id}", PatchProbeAsync) - .Api("probes_update", "Rename a probe", ProbeTag).Returns().ProducesProblems(400, 401, 403, 404, 409); + .Api("probes_update", "Rename a probe", ProbeTag).Returns().ProducesProblems(400, 401, 403, 404, 409) + .Requires(Capability.FleetWrite); group.MapPost("/probes/{id}/revoke", RevokeProbeAsync) .Api("probes_revoke", "Revoke a probe: every agent drops its session and refuses it; history stays until it expires", ProbeTag) - .Returns().ProducesProblems(401, 403, 404); + .Returns().ProducesProblems(401, 403, 404) + .Requires(Capability.FleetRevoke); group.MapDelete("/probes/{id}", DeleteProbeAsync) .Api("probes_delete", "Delete a probe and its lines; stored data expires with the retention policy", ProbeTag) - .Returns(StatusCodes.Status204NoContent).ProducesProblems(401, 403); + .Returns(StatusCodes.Status204NoContent).ProducesProblems(401, 403) + .Requires(Capability.FleetRevoke); group.MapPost("/agents/{id}/probes/{probe_id}/disconnect", DisconnectProbeAsync) .Api("agents_probe_disconnect", "Make an agent drop one probe's session (the probe reconnects unless it is revoked)", AgentsTag) - .Returns(StatusCodes.Status204NoContent).ProducesProblems(401, 403, 404, 409); + .Returns(StatusCodes.Status204NoContent).ProducesProblems(401, 403, 404, 409) + .Requires(Capability.FleetWrite); } private static async Task ListAsync(AgentService agents, CancellationToken ct) => @@ -112,11 +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), 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/AuditEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/AuditEndpoints.cs new file mode 100644 index 0000000..fc4180d --- /dev/null +++ b/backend/src/MoShiro.Backend.Api/Admin/AuditEndpoints.cs @@ -0,0 +1,44 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using MoShiro.Backend.Application; +using MoShiro.Protocol; + +namespace MoShiro.Backend.Api; + +/// 审计日志:管理接口里每个写操作、登录和提权都会记一行,这里只读。 +public static class AuditEndpoints +{ + private const string Tag = "admin-audit"; + + /// handler 往这里放一句「这次具体做了什么」(例如新建令牌的名字),守卫记审计时一起写进去。 + public const string DetailItem = "moshiro.audit.detail"; + + public static void Map(RouteGroupBuilder guarded) => + guarded.MapGet("/audit", ListAsync) + .Api("audit_list", "Audit log of admin actions, newest first", Tag).Returns>().ProducesProblems(400, 401, 403) + .Requires(Capability.AuditRead); + + public static void SetDetail(this HttpContext http, string detail) => http.Items[DetailItem] = detail; + + private static async Task ListAsync(HttpContext http, AuditService audit, string? actor, string? action, int? limit, string? cursor) + { + long? before = null; + if (cursor is not null) + { + if (!long.TryParse(cursor, out long parsed)) + { + return Problems.From(http, ValidationError.Single("cursor", "is not a valid cursor")); + } + + before = parsed; + } + + int size = Math.Clamp(limit ?? 50, 1, 500); + IReadOnlyList rows = await audit.ListAsync(new AuditQuery(before, size + 1, string.IsNullOrEmpty(actor) ? null : actor, string.IsNullOrEmpty(action) ? null : action), http.RequestAborted); + bool more = rows.Count > size; + IReadOnlyList page = [.. rows.Take(size)]; + string? next = more ? page[^1].Id.ToString(System.Globalization.CultureInfo.InvariantCulture) : null; + return TypedResults.Json(new PageDto([.. page.Select(Mappers.ToDto)], next), ApiJsonContext.Default.PageDtoAuditDto); + } +} diff --git a/backend/src/MoShiro.Backend.Api/Admin/AuthEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/AuthEndpoints.cs index 54a47d4..fb13212 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/AuthEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Admin/AuthEndpoints.cs @@ -16,36 +16,86 @@ public static class AuthEndpoints public static void MapLogin(RouteGroupBuilder open) { open.MapPost("/auth/login", LoginAsync) + .AllowsAnonymous() .Api("auth_login", "Sign in with a username and password", Tag) .Returns().ProducesProblems(400, 401, 429); + open.MapPost("/auth/passkey/options", PasskeyLoginOptions) + .AllowsAnonymous() + .Api("auth_passkey_options", "A challenge for signing in with a passkey (discoverable credential, no username needed)", Tag) + .Returns().ProducesProblems(429); + open.MapPost("/auth/passkey/login", PasskeyLoginAsync) + .AllowsAnonymous() + .Api("auth_passkey_login", "Sign in with a passkey assertion; same response as the password sign-in", Tag) + .Returns().ProducesProblems(400, 401, 429); } public static void MapSession(RouteGroupBuilder guarded) { guarded.MapPost("/auth/logout", LogoutAsync) .Api("auth_logout", "Sign out and clear the session cookie", Tag) - .Returns(StatusCodes.Status204NoContent).ProducesProblems(401); + .Returns(StatusCodes.Status204NoContent).ProducesProblems(401) + .Requires(Capability.Authenticated); + guarded.MapGet("/auth/passkeys", ListPasskeysAsync) + .Api("auth_passkeys_list", "The signed-in user's passkeys", Tag) + .Returns>().ProducesProblems(401) + .Requires(Capability.Authenticated); + guarded.MapPost("/auth/passkeys/options", PasskeyCreationOptionsAsync) + .Api("auth_passkeys_options", "A challenge for registering a new passkey (the user must be signed in)", Tag) + .Returns().ProducesProblems(400, 401, 403) + .Requires(Capability.Authenticated); + guarded.MapPost("/auth/passkeys", RegisterPasskeyAsync) + .Api("auth_passkeys_register", "Register a passkey from a WebAuthn attestation (ES256, user verification required)", Tag) + .Returns(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409) + .Requires(Capability.Authenticated); + guarded.MapDelete("/auth/passkeys/{id}", DeletePasskeyAsync) + .Api("auth_passkeys_delete", "Remove one of your own passkeys", Tag) + .Returns(StatusCodes.Status204NoContent).ProducesProblems(401, 404) + .Requires(Capability.Authenticated); + guarded.MapGet("/auth/elevation", ListElevation) + .Api("auth_elevation_list", "The elevation windows this session has open (sensitive actions need one)", Tag) + .Returns>().ProducesProblems(401) + .Requires(Capability.Authenticated); + guarded.MapPost("/auth/elevation/options", ElevationOptionsAsync) + .Api("auth_elevation_options", "A challenge for opening an elevation window with a passkey touch", Tag) + .Returns().ProducesProblems(400, 401, 403, 429) + .Requires(Capability.Authenticated); + guarded.MapPost("/auth/elevation", ElevateAsync) + .Api("auth_elevate", "Open an elevation window from a passkey assertion (enroll 15 min, revoke 5 min, credential one use within 60 s)", Tag) + .Returns().ProducesProblems(400, 401, 403, 429) + .Requires(Capability.Authenticated); + guarded.MapDelete("/auth/elevation", EndElevation) + .Api("auth_elevation_end", "Close all elevation windows of this session", Tag) + .Returns(StatusCodes.Status204NoContent).ProducesProblems(401) + .Requires(Capability.Authenticated); guarded.MapGet("/auth/me", Me) .Api("auth_me", "The current identity", Tag) - .Returns().ProducesProblems(401); + .Returns().ProducesProblems(401) + .Requires(Capability.Authenticated); } - private static async Task LoginAsync(HttpContext http, LoginRequest request, AuthService auth, LoginRateLimiter limiter, BackendOptions options) + private static async Task LoginAsync(HttpContext http, LoginRequest request, AuthService auth, AuditService audit, LoginRateLimiter limiter, BackendOptions options) { - string source = http.Connection.RemoteIpAddress?.ToString() ?? "unknown"; + string source = http.ClientAddress(); if (!limiter.TryAcquire(source, out int retryAfter)) { return Problems.RetryAfter(http, new FleetError(MoShiroErrors.TooManyRequests, "too many sign-in attempts"), retryAfter); } Result result = await auth.LoginAsync(request.Username ?? "", request.Password ?? "", http.RequestAborted); + string remote = source; if (!result.TryGet(out LoginResult? login, out FleetError? error)) { + await audit.RecordAsync(null, request.Username ?? "", AuthMethods.Password, "auth.login", null, null, remote, false, http.RequestAborted); return Problems.From(http, error); } - // 跨域的前端(return_session)不用 Cookie:会话令牌放在响应里,之后用 Bearer 带上 - bool returnSession = request.ReturnSession == true; + await audit.RecordAsync(login.UserId, login.Username, AuthMethods.Password, "auth.login", null, null, remote, true, http.RequestAborted); + return IssueSession(http, login, request.ReturnSession == true, options); + } + + /// 发放会话:Cookie(同源前端)或在响应里返回会话令牌(跨域前端的 return_session)。密码登录和 passkey 登录共用。 + private static Microsoft.AspNetCore.Http.HttpResults.JsonHttpResult IssueSession(HttpContext http, LoginResult login, bool returnSession, BackendOptions options) + { if (!returnSession) { http.Response.Cookies.Append( @@ -65,8 +115,193 @@ public static class AuthEndpoints return TypedResults.Json(new LoginResponse(login.UserId, login.Username, login.CsrfToken, Mappers.Time(login.ExpiresAtMs), returnSession ? login.SessionToken : null), ApiJsonContext.Default.LoginResponse); } - private static async Task LogoutAsync(HttpContext http, AuthService auth) + /// 这次请求的 WebAuthn 依赖方:配置优先,其次面板对外地址,最后是请求自己的主机。 + internal static PasskeyContext PasskeyContextOf(HttpContext http, BackendOptions options) { + string own = $"{http.Request.Scheme}://{http.Request.Host}"; + Uri? configured = Uri.TryCreate(options.PublicUrl, UriKind.Absolute, out Uri? uri) ? uri : null; + string rpId = !string.IsNullOrWhiteSpace(options.WebAuthnRpId) ? options.WebAuthnRpId.Trim() : configured?.Host ?? http.Request.Host.Host; + IEnumerable origins = !string.IsNullOrWhiteSpace(options.WebAuthnOrigins) + ? options.WebAuthnOrigins.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Select(o => o.TrimEnd('/')) + : [configured is null ? own : configured.GetLeftPart(UriPartial.Authority)]; + return new PasskeyContext(rpId, new HashSet(origins, StringComparer.Ordinal)); + } + + /// 限流。 把「取挑战」和「提交登录」分成两个桶:页面加载取挑战不该占用登录失败的额度。 + private static IResult Limited(HttpContext http, LoginRateLimiter limiter, out bool ok, string scope = "", int? limit = null) + { + string source = scope + (http.ClientAddress()); + ok = limiter.TryAcquire(source, out int retryAfter, limit); + return ok ? TypedResults.NoContent() : Problems.RetryAfter(http, new FleetError(MoShiroErrors.TooManyRequests, "too many sign-in attempts"), retryAfter); + } + + /// 取挑战只是页面加载和点按钮,不是猜密码:额度比登录失败宽松,挑战总量另有上限(淘汰最旧的)。 + private const int PasskeyOptionsPerMinute = 60; + + private static IResult PasskeyLoginOptions(HttpContext http, PasskeyService passkeys, LoginRateLimiter limiter, BackendOptions options) + { + IResult limit = Limited(http, limiter, out bool ok, "passkey-options:", limit: PasskeyOptionsPerMinute); + if (!ok) + { + return limit; + } + + RequestOptions o = passkeys.BeginLogin(PasskeyContextOf(http, options)); + http.Response.Headers.CacheControl = "no-store"; + return TypedResults.Json(new PasskeyRequestOptionsDto(o.Challenge, o.RpId, o.TimeoutMs), ApiJsonContext.Default.PasskeyRequestOptionsDto); + } + + private static async Task PasskeyLoginAsync(HttpContext http, PasskeyLoginRequest request, PasskeyService passkeys, AuditService audit, LoginRateLimiter limiter, BackendOptions options) + { + IResult limit = Limited(http, limiter, out bool ok); + if (!ok) + { + return limit; + } + + Result result = await passkeys.LoginAsync( + new PasskeyAssertion(request.CredentialId ?? "", request.AuthenticatorData ?? "", request.ClientDataJson ?? "", request.Signature ?? ""), PasskeyContextOf(http, options), http.RequestAborted); + string? remote = http.ClientAddress(); + if (!result.TryGet(out LoginResult? login, out FleetError? error)) + { + await audit.RecordAsync(null, "?", AuthMethods.Passkey, "auth.login", null, null, remote, false, http.RequestAborted); + return Problems.From(http, error); + } + + await audit.RecordAsync(login.UserId, login.Username, AuthMethods.Passkey, "auth.login", null, null, remote, true, http.RequestAborted); + return IssueSession(http, login, request.ReturnSession == true, options); + } + + private static async Task ListPasskeysAsync(HttpContext http, PasskeyService passkeys) + { + string userId = http.GetPrincipal().UserId ?? ""; + IReadOnlyList list = userId.Length == 0 ? [] : await passkeys.ListAsync(userId, http.RequestAborted); + return TypedResults.Json(new PageDto([.. list.Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoPasskeyDto); + } + + /// 已经有 passkey 的用户再加一个,要先用已有的触碰一次;第一个 passkey(还没有可触碰的)不要求。 + private static async Task CredentialGateAsync(HttpContext http, PasskeyService passkeys, bool consume) + { + string? userId = http.GetPrincipal().UserId; + return userId is null || (await passkeys.ListAsync(userId, http.RequestAborted)).Count == 0 ? null : ElevationGate.Require(http, ElevationClass.Credential, consume); + } + + private static async Task PasskeyCreationOptionsAsync(HttpContext http, PasskeyService passkeys, BackendOptions options) + { + if (await CredentialGateAsync(http, passkeys, consume: false) is { } needed) + { + return needed; + } + + Result result = await passkeys.BeginRegistrationAsync(http.GetPrincipal(), PasskeyContextOf(http, options), http.RequestAborted); + if (!result.TryGet(out CreationOptions? o, out FleetError? error)) + { + return Problems.From(http, error); + } + + http.Response.Headers.CacheControl = "no-store"; + return TypedResults.Json(new PasskeyCreationOptionsDto(o.Challenge, o.RpId, o.RpName, o.UserId, o.UserName, o.ExcludeCredentials, o.TimeoutMs), ApiJsonContext.Default.PasskeyCreationOptionsDto); + } + + private static async Task RegisterPasskeyAsync(HttpContext http, RegisterPasskeyRequest request, PasskeyService passkeys, BackendOptions options) + { + if (await CredentialGateAsync(http, passkeys, consume: true) is { } needed) + { + return needed; + } + + Result result = await passkeys.RegisterAsync( + http.GetPrincipal(), new PasskeyRegistration(request.Name ?? "", request.CredentialId ?? "", request.AttestationObject ?? "", request.ClientDataJson ?? "", request.Transports), PasskeyContextOf(http, options), http.RequestAborted); + return result.TryGet(out PasskeyRecord? record, out FleetError? error) + ? TypedResults.Json(Mappers.ToDto(record), ApiJsonContext.Default.PasskeyDto, statusCode: StatusCodes.Status201Created) + : Problems.From(http, error); + } + + private static async Task DeletePasskeyAsync(HttpContext http, PasskeyService passkeys, string id) + { + if (ElevationGate.Require(http, ElevationClass.Credential, consume: true) is { } needed) + { + return needed; + } + + return http.GetPrincipal().UserId is { } userId && await passkeys.DeleteAsync(userId, id, http.RequestAborted) + ? TypedResults.NoContent() + : Problems.Create(http, MoShiroErrors.NotFound, "no such passkey"); + } + + private static Microsoft.AspNetCore.Http.HttpResults.JsonHttpResult> ListElevation(HttpContext http, ElevationService elevation) + { + IReadOnlyList open = http.GetPrincipal().SessionId is { } session ? elevation.Active(session) : []; + return TypedResults.Json(new PageDto([.. open.Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoElevationDto); + } + + private static async Task ElevationOptionsAsync(HttpContext http, ElevationRequest request, PasskeyService passkeys, LoginRateLimiter limiter, BackendOptions options) + { + IResult limit = Limited(http, limiter, out bool ok, "elevation-options:", limit: PasskeyOptionsPerMinute); + if (!ok) + { + return limit; + } + + if (!Elevation.TryParse(request.Class, out ElevationClass elevation)) + { + return Problems.From(http, ValidationError.Single("class", "must be enroll, revoke or credential")); + } + + Result result = await passkeys.BeginElevationAsync(http.GetPrincipal(), elevation, PasskeyContextOf(http, options), http.RequestAborted); + if (!result.TryGet(out RequestOptions? o, out FleetError? error)) + { + return Problems.From(http, error); + } + + http.Response.Headers.CacheControl = "no-store"; + return TypedResults.Json(new PasskeyRequestOptionsDto(o.Challenge, o.RpId, o.TimeoutMs, o.AllowCredentials), ApiJsonContext.Default.PasskeyRequestOptionsDto); + } + + private static async Task ElevateAsync(HttpContext http, ElevationAssertionRequest request, PasskeyService passkeys, AuditService audit, LoginRateLimiter limiter, BackendOptions options) + { + IResult limit = Limited(http, limiter, out bool ok, "elevation:"); + if (!ok) + { + return limit; + } + + if (!Elevation.TryParse(request.Class, out ElevationClass elevation)) + { + return Problems.From(http, ValidationError.Single("class", "must be enroll, revoke or credential")); + } + + Principal principal = http.GetPrincipal(); + Result result = await passkeys.ElevateAsync( + principal, elevation, new PasskeyAssertion(request.CredentialId ?? "", request.AuthenticatorData ?? "", request.ClientDataJson ?? "", request.Signature ?? ""), PasskeyContextOf(http, options), http.RequestAborted); + if (!result.TryGet(out ElevationWindow? window, out FleetError? error)) + { + await audit.RecordAsync(principal, "auth.elevate", null, Elevation.IdOf(elevation), http.ClientAddress(), false, http.RequestAborted); + return Problems.From(http, error); + } + + http.SetDetail(Elevation.IdOf(elevation)); + http.Response.Headers.CacheControl = "no-store"; + return TypedResults.Json(Mappers.ToDto(window), ApiJsonContext.Default.ElevationDto); + } + + private static Microsoft.AspNetCore.Http.HttpResults.NoContent EndElevation(HttpContext http, ElevationService elevation) + { + if (http.GetPrincipal().SessionId is { } session) + { + elevation.End(session); + } + + return TypedResults.NoContent(); + } + + private static async Task LogoutAsync(HttpContext http, AuthService auth, ElevationService elevation) + { + if (http.GetPrincipal().SessionId is { } own) + { + elevation.End(own); + } + string? bearer = PrincipalResolver.BearerOf(http); if (bearer is not null) { @@ -85,7 +320,7 @@ public static class AuthEndpoints { Principal principal = http.GetPrincipal(); return TypedResults.Json( - new MeDto(principal.Kind.ToString().ToLowerInvariant(), principal.Scope.ToString().ToLowerInvariant(), 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/BuildEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/BuildEndpoints.cs index 652f721..501c6b5 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/BuildEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Admin/BuildEndpoints.cs @@ -17,17 +17,23 @@ public static class BuildEndpoints { group.MapGet("/build-info", GetAsync) .Api("build_info_get", "Version, tag, commit, build time and update state of the backend and every agent and probe", Tag) - .Returns().ProducesProblems(401, 403); + .Returns().ProducesProblems(401, 403) + .Requires(Capability.UpdateRead); group.MapGet("/releases", ListReleasesAsync) - .Api("releases_list", "The latest signed release manifest of each component", Tag).Returns>().ProducesProblems(401, 403); + .Api("releases_list", "The latest signed release manifest of each component", Tag).Returns>().ProducesProblems(401, 403) + .Requires(Capability.UpdateRead); group.MapPost("/nodes/{id}/update", RequestUpdateAsync) - .Api("nodes_update", "Ask an online agent or probe to install the release it has been offered", Tag).Returns(StatusCodes.Status204NoContent).ProducesProblems(401, 403, 409); + .Api("nodes_update", "Ask an online agent or probe to install the release it has been offered", Tag).Returns(StatusCodes.Status204NoContent).ProducesProblems(401, 403, 409) + .Requires(Capability.UpdateConfirm); group.MapGet("/backend/update", BackendUpdateAsync) - .Api("backend_update_get", "Current channel and host-verified backend update", Tag).Returns().ProducesProblems(401, 403); + .Api("backend_update_get", "Current channel and host-verified backend update", Tag).Returns().ProducesProblems(401, 403) + .Requires(Capability.UpdateRead); group.MapPost("/backend/update", ConfirmBackendUpdateAsync) - .Api("backend_update_confirm", "Confirm one verified backend release before the host restarts it", Tag).Returns(StatusCodes.Status204NoContent).ProducesProblems(400, 401, 403, 412); + .Api("backend_update_confirm", "Confirm one verified backend release before the host restarts it", Tag).Returns(StatusCodes.Status204NoContent).ProducesProblems(400, 401, 403, 412) + .Requires(Capability.UpdateConfirm); group.MapGet("/backend/update/live", LiveBackendUpdateAsync) - .Api("backend_update_live", "Server-sent backend update notifications", Tag).Produces(StatusCodes.Status200OK, contentType: "text/event-stream").ProducesProblems(401, 403); + .Api("backend_update_live", "Server-sent backend update notifications", Tag).Produces(StatusCodes.Status200OK, contentType: "text/event-stream").ProducesProblems(401, 403) + .Requires(Capability.UpdateRead); } /// 登记发布清单:挂在 下,除了管理员,CI 用的 release 令牌也能调用(它只能做这一件事)。 @@ -35,7 +41,8 @@ public static class BuildEndpoints { group.MapPost("/releases", PublishReleaseAsync) .Api("releases_publish", "Register a CI-signed release manifest; online nodes get it right away (their updaters verify the signature). Admin or release tokens", Tag) - .Returns(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409); + .Returns(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409) + .Requires(Capability.ReleasePublish); } private static async Task GetAsync(HttpContext http, NodeInfoService nodes, ReleaseService releases, BackendUpdates backendUpdates, CancellationToken ct) diff --git a/backend/src/MoShiro.Backend.Api/Admin/DataEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/DataEndpoints.cs index 3dda979..a7c5181 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/DataEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Admin/DataEndpoints.cs @@ -16,14 +16,18 @@ public static class DataEndpoints public static void Map(RouteGroupBuilder group) { group.MapGet("/raw/chunks", ListChunksAsync) - .Api("raw_chunks_list", "Raw chunk metadata", Tag).Returns>().ProducesProblems(400, 401, 403); + .Api("raw_chunks_list", "Raw chunk metadata", Tag).Returns>().ProducesProblems(400, 401, 403) + .Requires(Capability.DataRead); group.MapGet("/raw/chunks/{chunk_id}", DownloadChunkAsync) .Api("raw_chunks_get", "Download a raw chunk (application/vnd.moshiro.rawchunk)", Tag) - .Produces(StatusCodes.Status200OK, contentType: "application/vnd.moshiro.rawchunk").ProducesProblems(401, 403, 404, 410); + .Produces(StatusCodes.Status200OK, contentType: "application/vnd.moshiro.rawchunk").ProducesProblems(401, 403, 404, 410) + .Requires(Capability.DataRead); group.MapGet("/events", ListEventsAsync) - .Api("events_list", "Agent events", Tag).Returns>().ProducesProblems(400, 401, 403); + .Api("events_list", "Agent events", Tag).Returns>().ProducesProblems(400, 401, 403) + .Requires(Capability.EventsRead); group.MapGet("/analysis/recompute", RecomputeAsync) - .Api("analysis_recompute_get", "Progress of the metric backfill", Tag).Returns().ProducesProblems(401, 403); + .Api("analysis_recompute_get", "Progress of the metric backfill", Tag).Returns().ProducesProblems(401, 403) + .Requires(Capability.DataWrite); } private static async Task ListChunksAsync( diff --git a/backend/src/MoShiro.Backend.Api/Admin/LinkEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/LinkEndpoints.cs index 7ee75d6..f459f46 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/LinkEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Admin/LinkEndpoints.cs @@ -15,9 +15,11 @@ public static class LinkEndpoints public static void Map(RouteGroupBuilder group) { group.MapGet("/links", ListAsync) - .Api("links_list", "Every probe/agent pair: whether it is linked, the manual override and whether it is connected", Tag).Returns().ProducesProblems(401, 403); + .Api("links_list", "Every probe/agent pair: whether it is linked, the manual override and whether it is connected", Tag).Returns().ProducesProblems(401, 403) + .Requires(Capability.FleetRead); group.MapPut("/links/{probe_id}/{agent_id}", SetAsync) - .Api("links_set", "Force a link on or off, or give it back to the automatic setting (enabled: null)", Tag).Returns().ProducesProblems(400, 401, 403, 404); + .Api("links_set", "Force a link on or off, or give it back to the automatic setting (enabled: null)", Tag).Returns().ProducesProblems(400, 401, 403, 404) + .Requires(Capability.FleetWrite); } private static async Task ListAsync(AgentService agents, ProbeService probes, LinkService links, CancellationToken ct) diff --git a/backend/src/MoShiro.Backend.Api/Admin/SystemEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/SystemEndpoints.cs index 8c67d36..3597853 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/SystemEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Admin/SystemEndpoints.cs @@ -18,33 +18,59 @@ public static class SystemEndpoints public static void Map(RouteGroupBuilder group) { group.MapGet("/api-tokens", ListTokensAsync) - .Api("api_tokens_list", "API tokens", TokensTag).Returns>().ProducesProblems(401, 403); + .Api("api_tokens_list", "API tokens", TokensTag).Returns>().ProducesProblems(401, 403) + .Requires(Capability.KeysRead); group.MapPost("/api-tokens", CreateTokenAsync) - .Api("api_tokens_create", "Create an API token (scope read or admin); the plaintext is returned only once", TokensTag) - .Returns(StatusCodes.Status201Created).ProducesProblems(400, 401, 403); + .Api("api_tokens_create", "Create an API token with the chosen capabilities (a subset of your own); the plaintext is returned only once", TokensTag) + .Returns(StatusCodes.Status201Created).ProducesProblems(400, 401, 403) + .Requires(Capability.KeysManage); group.MapDelete("/api-tokens/{id}", RevokeTokenAsync) - .Api("api_tokens_revoke", "Revoke an API token", TokensTag).Returns(StatusCodes.Status204NoContent).ProducesProblems(401, 403); + .Api("api_tokens_revoke", "Revoke an API token", TokensTag).Returns(StatusCodes.Status204NoContent).ProducesProblems(401, 403) + .Requires(Capability.KeysManage); + + group.MapGet("/capabilities", CapabilitiesAsync) + .Api("capabilities_list", "The capability catalog: groups, which ones change trust, which are reserved, and the presets", TokensTag) + .Returns().ProducesProblems(401, 403) + .Requires(Capability.KeysRead); group.MapGet("/settings", ListSettingsAsync) - .Api("settings_list", "Site settings and retention periods", SettingsTag).Returns>().ProducesProblems(401, 403); + .Api("settings_list", "Site settings and retention periods", SettingsTag).Returns>().ProducesProblems(401, 403) + .Requires(Capability.SettingsRead); group.MapPatch("/settings", PatchSettingsAsync) .Accepts>("application/merge-patch+json", "application/json") - .Api("settings_update", "Change settings (merge-patch)", SettingsTag).Returns>().ProducesProblems(400, 401, 403); + .Api("settings_update", "Change settings (merge-patch)", SettingsTag).Returns>().ProducesProblems(400, 401, 403) + .Requires(Capability.SettingsWrite); group.MapGet("/users", ListUsersAsync) - .Api("users_list", "Panel users", UsersTag).Returns>().ProducesProblems(401, 403); + .Api("users_list", "Panel users", UsersTag).Returns>().ProducesProblems(401, 403) + .Requires(Capability.AccountsRead); group.MapPost("/users", CreateUserAsync) - .Api("users_create", "Create a panel user", UsersTag).Returns(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409); + .Api("users_create", "Create a panel user", UsersTag).Returns(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409) + .Requires(Capability.AccountsManage); group.MapPatch("/users/{id}", ChangePasswordAsync) - .Api("users_update", "Change a user's password", UsersTag).Returns(StatusCodes.Status204NoContent).ProducesProblems(400, 401, 403); + .Api("users_update", "Change a user's password", UsersTag).Returns(StatusCodes.Status204NoContent).ProducesProblems(400, 401, 403) + .Requires(Capability.AccountsManage); } + private static Microsoft.AspNetCore.Http.HttpResults.JsonHttpResult CapabilitiesAsync() => + TypedResults.Json( + new CapabilityCatalogDto( + [.. CapabilityCatalog.Groups.Select(g => new CapabilityGroupDto(g, [.. CapabilityCatalog.All.Where(c => c.Group == g).Select(c => new CapabilityDto(c.Id, c.Trust, c.Reserved, c.Implies))]))], + [.. CapabilityCatalog.Presets.Select(p => new CapabilityPresetDto(p.Id, p.Capabilities))]), + ApiJsonContext.Default.CapabilityCatalogDto); + private static async Task ListTokensAsync(AuthService auth, CancellationToken ct) => TypedResults.Json(new PageDto([.. (await auth.ListApiTokensAsync(ct)).Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoApiTokenDto); private static async Task CreateTokenAsync(HttpContext http, AuthService auth, CreateApiTokenRequest request) { - Result result = await auth.CreateApiTokenAsync(request.Name ?? "", request.Scope ?? "", http.RequestAborted); + if (CapabilityCatalog.Expand(request.Capabilities ?? []).Any(c => CapabilityCatalog.Find(c)?.Trust == true) && ElevationGate.Require(http, ElevationClass.Credential, consume: true) is { } needed) + { + return needed; // 带信任类能力的 API key 等于把信任操作交给一份密钥:创建它要用 passkey 签一次 + } + + http.SetDetail($"{request.Name}: {string.Join(',', request.Capabilities ?? [])}"); + Result result = await auth.CreateApiTokenAsync(request.Name ?? "", request.Capabilities ?? [], request.ExpiresInDays, http.GetPrincipal(), http.RequestAborted); if (!result.TryGet(out CreatedApiToken? created, out FleetError? error)) { return Problems.From(http, error); diff --git a/backend/src/MoShiro.Backend.Api/Admin/TaskEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/TaskEndpoints.cs index f6fc4a1..c5b4cc2 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/TaskEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Admin/TaskEndpoints.cs @@ -16,26 +16,34 @@ public static class TaskEndpoints public static void Map(RouteGroupBuilder group) { group.MapGet("/tasks", ListAsync) - .Api("tasks_list", "Tasks: line templates, per-agent overrides and target tasks", TasksTag).Returns>().ProducesProblems(401, 403); + .Api("tasks_list", "Tasks: line templates, per-agent overrides and target tasks", TasksTag).Returns>().ProducesProblems(401, 403) + .Requires(Capability.TasksRead); group.MapPost("/tasks", CreateAsync) .Api("tasks_create", "Create a task; params are validated against the kind", TasksTag) - .Returns(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 404); + .Returns(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 404) + .Requires(Capability.TasksWrite); group.MapGet("/tasks/{id}", GetAsync) - .Api("tasks_get", "One task", TasksTag).Returns().ProducesProblems(401, 403, 404); + .Api("tasks_get", "One task", TasksTag).Returns().ProducesProblems(401, 403, 404) + .Requires(Capability.TasksRead); group.MapPatch("/tasks/{id}", PatchAsync) .Accepts("application/merge-patch+json", "application/json") .Api("tasks_update", "Change params, schedule, target or enabled (merge-patch, If-Match supported)", TasksTag) - .Returns().ProducesProblems(400, 401, 403, 404, 412); + .Returns().ProducesProblems(400, 401, 403, 404, 412) + .Requires(Capability.TasksWrite); group.MapDelete("/tasks/{id}", DeleteAsync) - .Api("tasks_delete", "Delete a task", TasksTag).Returns(StatusCodes.Status204NoContent).ProducesProblems(401, 403); + .Api("tasks_delete", "Delete a task", TasksTag).Returns(StatusCodes.Status204NoContent).ProducesProblems(401, 403) + .Requires(Capability.TasksWrite); group.MapPost("/lines/{line_id}/tests", StartTestAsync) .Api("tests_start", "Start an on-demand test on a line", TestsTag) - .Returns(StatusCodes.Status202Accepted).ProducesProblems(400, 401, 403, 404, 409); + .Returns(StatusCodes.Status202Accepted).ProducesProblems(400, 401, 403, 404, 409) + .Requires(Capability.TasksWrite); group.MapGet("/lines/{line_id}/tests", ListTestsAsync) - .Api("tests_list", "Recent tests of a line", TestsTag).Returns>().ProducesProblems(401, 403); + .Api("tests_list", "Recent tests of a line", TestsTag).Returns>().ProducesProblems(401, 403) + .Requires(Capability.TasksRead); group.MapPost("/tests/{id}/cancel", CancelAsync) - .Api("tests_cancel", "Cancel a running test", TestsTag).Returns().ProducesProblems(401, 403, 404); + .Api("tests_cancel", "Cancel a running test", TestsTag).Returns().ProducesProblems(401, 403, 404) + .Requires(Capability.TasksWrite); } private static async Task ListAsync(TaskService tasks, string? agent_id, string? scope, CancellationToken ct) diff --git a/backend/src/MoShiro.Backend.Api/Admin/TrustEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/TrustEndpoints.cs new file mode 100644 index 0000000..1d8cdf1 --- /dev/null +++ b/backend/src/MoShiro.Backend.Api/Admin/TrustEndpoints.cs @@ -0,0 +1,79 @@ +using Fleet.Core; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using MoShiro.Backend.Application; +using MoShiro.Protocol; + +namespace MoShiro.Backend.Api; + +/// +/// 信任根(docs/access.md 第 7 节):操作员集合由 passkey 签名,节点从安装命令里固定的创世指纹出发验证它; +/// 带操作员委托的加入码也在这里创建——委托是 passkey 对「这个码」签的,面板自己签不出来。 +/// +public static class TrustEndpoints +{ + private const string Tag = "admin-trust"; + + public static void Map(RouteGroupBuilder guarded) + { + guarded.MapGet("/trust", StateAsync) + .Api("trust_get", "The operator trust root: genesis fingerprint, version, whether it matches the registered passkeys, and the next version to sign", Tag) + .Returns().ProducesProblems(401, 403) + .Requires(Capability.FleetRead); + guarded.MapPost("/trust", CommitAsync) + .Api("trust_commit", "Store the next operator set, signed with a passkey (the first one sets the genesis and needs a credential window)", Tag) + .Returns(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409) + .Requires(Capability.AccountsManage) + .ElevationExempt(); + } + + 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"; + 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) + { + PasskeyContext context = AuthEndpoints.PasskeyContextOf(http, options); + // 第一次(创世)是整个信任根的起点:除了 passkey 签名,还要凭证窗口,避免只拿到一个密码会话的人抢先立根 + if ((await trust.StateAsync(context, http.RequestAborted)).Initialized is false && ElevationGate.Require(http, ElevationClass.Credential, consume: true) is { } needed) + { + return needed; + } + + Result result = await trust.CommitAsync(signed, context, http.RequestAborted); + if (!result.TryGet(out TrustState? state, out FleetError? error)) + { + return Problems.From(http, error); + } + + await agents.AfterAgentSetChangedAsync(http.RequestAborted); // 新的操作员集合要随名册下发给节点 + http.SetDetail($"operator set v{state.Version}"); + return TypedResults.Json(Mappers.ToDto(state), ApiJsonContext.Default.TrustStateDto, statusCode: StatusCodes.Status201Created); + } +} diff --git a/backend/src/MoShiro.Backend.Api/ApiModule.cs b/backend/src/MoShiro.Backend.Api/ApiModule.cs index 6cb4d31..f064f0e 100644 --- a/backend/src/MoShiro.Backend.Api/ApiModule.cs +++ b/backend/src/MoShiro.Backend.Api/ApiModule.cs @@ -18,6 +18,7 @@ public static class ApiModule public static IServiceCollection AddMoShiroApi(this IServiceCollection services) { services.AddSingleton(); + services.AddSingleton(); services.Configure(options => { JsonSerializerOptions json = options.SerializerOptions; @@ -103,13 +104,15 @@ public static class ApiModule RouteGroupBuilder admin = app.MapGroup("/api/admin"); AuthEndpoints.MapLogin(admin); - BuildEndpoints.MapPublish(admin.MapGroup(string.Empty).AddEndpointFilter(ReleaseGuard.InvokeAsync)); + BuildEndpoints.MapPublish(admin.MapGroup(string.Empty).AddEndpointFilter(CapabilityGuard.InvokeAsync)); - RouteGroupBuilder guarded = admin.MapGroup(string.Empty).AddEndpointFilter(AdminGuard.InvokeAsync); + RouteGroupBuilder guarded = admin.MapGroup(string.Empty).AddEndpointFilter(CapabilityGuard.InvokeAsync); AuthEndpoints.MapSession(guarded); AgentEndpoints.Map(guarded); TaskEndpoints.Map(guarded); DataEndpoints.Map(guarded); + AuditEndpoints.Map(guarded); + TrustEndpoints.Map(guarded); SystemEndpoints.Map(guarded); BuildEndpoints.Map(guarded); LinkEndpoints.Map(guarded); diff --git a/backend/src/MoShiro.Backend.Api/Common/ClientAddress.cs b/backend/src/MoShiro.Backend.Api/Common/ClientAddress.cs new file mode 100644 index 0000000..bf61a94 --- /dev/null +++ b/backend/src/MoShiro.Backend.Api/Common/ClientAddress.cs @@ -0,0 +1,80 @@ +using System.Net; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using MoShiro.Backend.Application; + +namespace MoShiro.Backend.Api; + +/// +/// 请求的真实来源地址。后端前面通常有反向代理(Cloudflare、frp、Caddy),套接字上看到的是最近一跳; +/// 只有配置了 ,并且连进来的正是其中之一,才相信转发头, +/// 且从右往左跳过所有可信代理,取第一个不可信的地址(客户端自己伪造的在最左边,不会被采信)。 +/// 没配置就一律用套接字地址:宁可限流和审计里是代理的地址,也不让客户端自己报地址。 +/// +public sealed class ClientAddressResolver +{ + private readonly IPNetwork[] _trusted; + private readonly string _header; + + public ClientAddressResolver(BackendOptions options) + { + _header = string.IsNullOrWhiteSpace(options.TrustedProxyHeader) ? "X-Forwarded-For" : options.TrustedProxyHeader.Trim(); + _trusted = [.. (options.TrustedProxies ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Select(ParseNetwork)]; + } + + private static IPNetwork ParseNetwork(string text) + { + if (IPNetwork.TryParse(text, out IPNetwork network)) + { + return network; + } + + if (IPAddress.TryParse(text, out IPAddress? single)) + { + return new IPNetwork(single, single.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork ? 32 : 128); + } + + throw new InvalidOperationException($"TrustedProxies: '{text}' is not an IP address or CIDR range"); + } + + private bool IsTrusted(IPAddress address) + { + IPAddress normalized = address.IsIPv4MappedToIPv6 ? address.MapToIPv4() : address; + return _trusted.Any(n => n.Contains(normalized)); + } + + public string Resolve(HttpContext http) + { + IPAddress? remote = http.Connection.RemoteIpAddress; + if (remote is null) + { + return "unknown"; + } + + if (_trusted.Length == 0 || !IsTrusted(remote)) + { + return remote.ToString(); + } + + string[] chain = [.. http.Request.Headers[_header].SelectMany(v => (v ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))]; + for (int i = chain.Length - 1; i >= 0; i--) + { + if (!IPAddress.TryParse(chain[i], out IPAddress? hop)) + { + break; // 不是地址就不信这条头 + } + + if (!IsTrusted(hop)) + { + return hop.ToString(); + } + } + + return remote.ToString(); + } +} + +public static class ClientAddressExtensions +{ + public static string ClientAddress(this HttpContext http) => http.RequestServices.GetRequiredService().Resolve(http); +} diff --git a/backend/src/MoShiro.Backend.Api/Common/EndpointConventions.cs b/backend/src/MoShiro.Backend.Api/Common/EndpointConventions.cs index f749fd0..8def48a 100644 --- a/backend/src/MoShiro.Backend.Api/Common/EndpointConventions.cs +++ b/backend/src/MoShiro.Backend.Api/Common/EndpointConventions.cs @@ -9,6 +9,24 @@ public static class EndpointConventions public static RouteHandlerBuilder Api(this RouteHandlerBuilder builder, string operationId, string summary, string tag) => builder.WithName(operationId).WithSummary(summary).WithTags(tag); + /// 声明端点需要的能力(授权的唯一依据);OpenAPI 里带上 x-moshiro-capability。 + public static RouteHandlerBuilder Requires(this RouteHandlerBuilder builder, string capability) => + builder.WithMetadata(new CapabilityRequirement(capability)).AddOpenApiOperationTransformer((operation, _, _) => + { + operation.Extensions ??= new Dictionary(); + operation.Extensions["x-moshiro-capability"] = new Microsoft.OpenApi.JsonNodeExtension(System.Text.Json.Nodes.JsonValue.Create(capability)); + return Task.CompletedTask; + }); + + /// + /// 这个端点自己要求 passkey 签名(请求里带着断言),比提权窗口更强,所以不再要求窗口:守卫跳过信任类能力的提权检查。 + /// + public static RouteHandlerBuilder ElevationExempt(this RouteHandlerBuilder builder) => builder.WithMetadata(new ElevationExemptMarker()); + + /// 匿名可访问的管理端点(登录):显式声明,对账测试才能区分「忘了声明」。 + public static RouteHandlerBuilder AllowsAnonymous(this RouteHandlerBuilder builder) => + builder.WithMetadata(new CapabilityRequirement(null)); + public static RouteHandlerBuilder Returns(this RouteHandlerBuilder builder, int status = StatusCodes.Status200OK) => builder.Produces(status); diff --git a/backend/src/MoShiro.Backend.Api/Common/Guards.cs b/backend/src/MoShiro.Backend.Api/Common/Guards.cs index 0be49b8..3af9919 100644 --- a/backend/src/MoShiro.Backend.Api/Common/Guards.cs +++ b/backend/src/MoShiro.Backend.Api/Common/Guards.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using MoShiro.Backend.Application; using MoShiro.Protocol; @@ -53,17 +54,32 @@ public static class PrincipalResolver } } -/// 管理层守卫:需要 admin 作用域;Cookie 会话的非 GET 请求必须带 X-CSRF-Token。 -public static class AdminGuard +/// +/// 端点声明自己需要的能力()。Capability == null 表示匿名可访问(登录)。 +/// 没有声明的 /api/admin 端点一律拒绝(并有对账测试保证不存在)。 +/// +public sealed record CapabilityRequirement(string? Capability); + +/// 端点自己验 passkey 签名(见 )。 +public sealed record ElevationExemptMarker; + +/// 管理层守卫:按端点声明的能力授权;Cookie 会话的非 GET 请求必须带 X-CSRF-Token。 +public static class CapabilityGuard { public static async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) { HttpContext http = context.HttpContext; - Principal principal = http.GetPrincipal(); - if (!principal.IsAdmin) + CapabilityRequirement? requirement = http.GetEndpoint()?.Metadata.GetMetadata(); + if (requirement is null) { - return principal.Scope != ApiScope.None - ? Problems.Create(http, MoShiroErrors.Forbidden, "the token scope does not allow this operation") + return Problems.Create(http, MoShiroErrors.Forbidden, "this endpoint has no capability declaration"); + } + + Principal principal = http.GetPrincipal(); + if (requirement.Capability is { } capability && !principal.Has(capability)) + { + return principal.Kind != PrincipalKind.Anonymous + ? Problems.Create(http, MoShiroErrors.Forbidden, "the credential lacks the capability: " + capability) : Problems.Create(http, MoShiroErrors.Unauthorized, "authentication is required"); } @@ -76,15 +92,50 @@ public static class AdminGuard } } + // 信任类的操作(加节点、吊销、改凭证):登录用户还要有这一种的提权窗口;API key 自己带着能力,不经过这里 + if (requirement.Capability is { } trusted && Elevation.For(trusted) is { } elevation && http.GetEndpoint()?.Metadata.GetMetadata() is null && ElevationGate.Require(http, elevation, consume: true) is { } needed) + { + return needed; + } + + if (principal.Kind != PrincipalKind.Anonymous && !HttpMethods.IsGet(http.Request.Method) && !HttpMethods.IsHead(http.Request.Method) && !HttpMethods.IsOptions(http.Request.Method)) + { + // 写操作都记审计:响应发完之后才知道结果,所以放在 OnCompleted 里 + string action = http.GetEndpoint()?.Metadata.GetMetadata()?.EndpointName ?? http.Request.Path.Value ?? ""; + if (action.EndsWith("_options", StringComparison.Ordinal)) + { + return await next(context); // 取挑战不是操作 + } + + string? target = http.Request.RouteValues.Count == 0 ? null : string.Join(' ', http.Request.RouteValues.Where(v => v.Value is not null).Select(v => v.Key + "=" + v.Value)); + http.Response.OnCompleted(() => + { + http.Items.TryGetValue(AuditEndpoints.DetailItem, out object? detail); + return http.RequestServices.GetRequiredService() + .RecordAsync(principal, action, target, detail as string, http.ClientAddress(), http.Response.StatusCode < 400).AsTask(); + }); + } + return await next(context); } } -/// 登记发布清单的守卫:管理员(会话要带 CSRF)或 release 令牌;release 令牌只能做这一件事。 -public static class ReleaseGuard +/// 提权检查:登录用户(含用 return_session 拿到的 Bearer 会话)要有对应种类的有效窗口,API key 不需要。 +public static class ElevationGate { - public static async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) => - context.HttpContext.GetPrincipal().Scope == ApiScope.Release ? await next(context) : await AdminGuard.InvokeAsync(context, next); + /// 没问题返回 null;否则 403 elevation_required,detail 是缺的窗口种类,前端据此触碰 passkey 后重试。 + public static IResult? Require(HttpContext http, ElevationClass elevation, bool consume) + { + Principal principal = http.GetPrincipal(); + if (principal.UserId is null) + { + return null; + } + + return principal.SessionId is { } session && http.RequestServices.GetRequiredService().Check(session, elevation, consume) + ? null + : Problems.Create(http, MoShiroErrors.ElevationRequired, Elevation.IdOf(elevation)); + } } /// 展示层守卫:站点公开时匿名可读;私有时需要登录或只读令牌。 @@ -93,7 +144,7 @@ public static class PublicGuard public static async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) { HttpContext http = context.HttpContext; - if (!http.GetPrincipal().CanRead && !await http.RequestServices.GetRequiredService().IsSitePublicAsync(http.RequestAborted)) + if (!http.GetPrincipal().Has(Capability.PublicRead) && !await http.RequestServices.GetRequiredService().IsSitePublicAsync(http.RequestAborted)) { return Problems.Create(http, MoShiroErrors.SitePrivate, "this site is private; sign in or use a read-only token"); } @@ -102,5 +153,5 @@ public static class PublicGuard } /// 被隐藏(public_visible = false)的线路只对已认证的访问者可见。 - public static bool CanSeeHidden(HttpContext http) => http.GetPrincipal().CanRead; + public static bool CanSeeHidden(HttpContext http) => http.GetPrincipal().Has(Capability.PublicRead); } diff --git a/backend/src/MoShiro.Backend.Api/Common/Mappers.cs b/backend/src/MoShiro.Backend.Api/Common/Mappers.cs index 0a7f60b..b751f06 100644 --- a/backend/src/MoShiro.Backend.Api/Common/Mappers.cs +++ b/backend/src/MoShiro.Backend.Api/Common/Mappers.cs @@ -41,7 +41,7 @@ public static class Mappers public static ProbeDto ToDto(ProbeView v) => new( v.Probe.Id, v.Probe.Name, Time(v.Probe.CreatedAtMs), Time(v.Probe.RevokedAtMs), v.Revoked, v.Online, - [.. v.Connections.Select(c => new ProbeConnectionDto(c.AgentId.Value, c.AgentName, c.AgentOnline, c.Version, c.Remote, Time(c.SinceMs), c.IpVersion))], ProbeIpv6Of(v.Probe)); + [.. v.Connections.Select(c => new ProbeConnectionDto(c.AgentId.Value, c.AgentName, c.AgentOnline, c.Version, c.Remote, Time(c.SinceMs), c.IpVersion))], ProbeIpv6Of(v.Probe), v.Probe.AddedBy); public static SeriesDto ToDto(SeriesResult r, bool live) => new( @@ -103,7 +103,7 @@ public static class Mappers : null; return new AgentDto( a.Id.Value, a.Name, StatusName(a.Status), view.Online, view.ProbeConnected, [.. view.Probes.Select(ToDto)], view.Host is null ? null : ToDto(view.Host), a.PublicHost, a.ProbeTcpPort, a.ProbeUdpPorts, a.Labels, a.PublicVisible, - a.Kinds, a.AgentVersion, limits, a.TaskVersion, a.AppliedTaskVersion, rejected, heartbeat, view.EndpointChanges, Time(a.CreatedAtMs), Time(a.LastSeenAtMs), a.Ipv6Host, a.Ipv6Supported, Ipv6Of(view), !a.PublicHostManual, !a.Ipv6HostManual); + a.Kinds, a.AgentVersion, limits, a.TaskVersion, a.AppliedTaskVersion, rejected, heartbeat, view.EndpointChanges, Time(a.CreatedAtMs), Time(a.LastSeenAtMs), a.Ipv6Host, a.Ipv6Supported, Ipv6Of(view), !a.PublicHostManual, !a.Ipv6HostManual, a.AddedBy); } public static Ipv6DiagnosticDto Ipv6Of(AgentView view) @@ -154,9 +154,17 @@ 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)); + 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 ApiTokenDto ToDto(ApiTokenRecord t) => new(t.Id, t.Name, t.Scope, Time(t.CreatedAtMs), Time(t.LastUsedAtMs), Time(t.RevokedAtMs)); + 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)); + + public static AuditDto ToDto(AuditRecord a) => new(a.Id, Time(a.AtMs), a.Actor, a.Method, a.Action, a.Target, a.Detail, a.Remote, a.Success); + + public static PasskeyDto ToDto(PasskeyRecord p) => new(p.Id, p.Name, Time(p.CreatedAtMs), Time(p.LastUsedAtMs), p.Transports); + + public static ApiTokenDto ToDto(ApiTokenRecord t) => new(t.Id, t.Name, t.Capabilities, Time(t.CreatedAtMs), Time(t.LastUsedAtMs), Time(t.ExpiresAtMs), Time(t.RevokedAtMs)); public static RawChunkMetaDto ToDto(RawChunkMeta m) => new(m.ChunkId.ToString(), m.AgentId.Value, m.Series, m.Kind, m.TaskId?.Value, m.TestId?.Value, m.Phase, Time(m.StartTs), m.DurationMs, m.Partial, m.Length); diff --git a/backend/src/MoShiro.Backend.Api/Common/Problems.cs b/backend/src/MoShiro.Backend.Api/Common/Problems.cs index 87576e9..790d86d 100644 --- a/backend/src/MoShiro.Backend.Api/Common/Problems.cs +++ b/backend/src/MoShiro.Backend.Api/Common/Problems.cs @@ -46,7 +46,7 @@ public static class ErrorCodeHttpMap Add(400, MoShiroErrors.ValidationFailed, MoShiroErrors.BudgetExceeded, FleetErrors.InvalidParams); Add(401, MoShiroErrors.Unauthorized, MoShiroErrors.CsrfFailed, FleetErrors.Unauthorized); - Add(403, MoShiroErrors.Forbidden, MoShiroErrors.SitePrivate, FleetErrors.Forbidden, FleetErrors.ProofInvalid); + Add(403, MoShiroErrors.Forbidden, MoShiroErrors.SitePrivate, MoShiroErrors.ElevationRequired, FleetErrors.Forbidden, FleetErrors.ProofInvalid); Add( 404, MoShiroErrors.NotFound, MoShiroErrors.AgentNotFound, MoShiroErrors.ProbeNotFound, MoShiroErrors.TaskNotFound, MoShiroErrors.TestNotFound, MoShiroErrors.ChunkNotFound, MoShiroErrors.TokenNotFound, MoShiroErrors.UnknownKind, MoShiroErrors.UnknownPlan, FleetErrors.NotFound); diff --git a/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs b/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs index fa639aa..0ee5bf1 100644 --- a/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs +++ b/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs @@ -1,3 +1,4 @@ +using Fleet.Core; using System.Text.Json; namespace MoShiro.Backend.Api; @@ -5,9 +6,30 @@ namespace MoShiro.Backend.Api; /// return_session:跨域的前端用——不设 Cookie,把会话令牌放在响应里,之后用 Authorization: Bearer 带上。 public sealed record LoginRequest(string Username, string Password, bool? ReturnSession); +/// 浏览器 navigator.credentials.create 要的参数;二进制字段都是 base64url。 +public sealed record PasskeyCreationOptionsDto(string Challenge, string RpId, string RpName, string UserId, string UserName, IReadOnlyList ExcludeCredentials, int TimeoutMs); + +/// 浏览器 navigator.credentials.get 要的参数(可发现凭证:不需要先输用户名)。 +/// 只有提权的挑战会带:这个用户自己的 passkey 的凭证 id。 +public sealed record PasskeyRequestOptionsDto(string Challenge, string RpId, int TimeoutMs, IReadOnlyList? AllowCredentials = null); + +/// enroll(加节点)、revoke(吊销和删除)、credential(改凭证,用一次就没有)。 +public sealed record ElevationRequest(string Class); + +public sealed record ElevationAssertionRequest(string Class, string CredentialId, string AuthenticatorData, string ClientDataJson, string Signature); + +public sealed record ElevationDto(string Class, DateTimeOffset ExpiresAt); + +public sealed record RegisterPasskeyRequest(string Name, string CredentialId, string AttestationObject, string ClientDataJson, IReadOnlyList? Transports); + +public sealed record PasskeyLoginRequest(string CredentialId, string AuthenticatorData, string ClientDataJson, string Signature, bool? ReturnSession); + +public sealed record PasskeyDto(string Id, string Name, DateTimeOffset CreatedAt, DateTimeOffset? LastUsedAt, IReadOnlyList Transports); + public sealed record LoginResponse(string UserId, string Username, string CsrfToken, DateTimeOffset ExpiresAt, string? SessionToken); -public sealed record MeDto(string Kind, string Scope, 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); @@ -42,7 +64,8 @@ public sealed record AgentDto( bool Ipv6Supported = false, Ipv6DiagnosticDto? Ipv6 = null, bool PublicHostAuto = true, - bool Ipv6HostAuto = true); + bool Ipv6HostAuto = true, + string? AddedBy = null); public sealed record Ipv6DiagnosticDto(bool? AddressConfigured, bool SocketSupported, bool? Listening, bool? HasGlobalAddress, bool? HasDefaultRoute, string State, string NetworkScope = "unknown", bool? HostHasGlobalAddress = null, bool? HostHasDefaultRoute = null); @@ -52,13 +75,24 @@ 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"); +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); +public sealed record TrustProposalDto(string Statement, int Version, string Challenge); + +/// 创世版本的指纹:安装命令里固定到节点上的就是它。还没建立信任根时为空。 +/// 当前生效的集合和已注册的 passkey、域名一致;不一致(比如新加了 passkey)就要签一个新版本。 +/// 下一个要签的版本(challenge 就是 WebAuthn 的挑战)。 +/// 当前操作员集合里的 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); /// Agent 眼中连着的一个 Probe(Agent 上报)。 @@ -67,7 +101,7 @@ public sealed record ConnectedProbeDto(string ProbeId, string? Version, string? /// 某个 Probe 现在连着的一个 Agent。 public sealed record ProbeConnectionDto(string AgentId, string AgentName, bool AgentOnline, string? Version, string? Remote, DateTimeOffset ConnectedSince, string IpVersion = "ipv4"); -public sealed record ProbeDto(string Id, string Name, DateTimeOffset CreatedAt, DateTimeOffset? RevokedAt, bool Revoked, bool Online, IReadOnlyList Connections, Ipv6DiagnosticDto? Ipv6 = null); +public sealed record ProbeDto(string Id, string Name, DateTimeOffset CreatedAt, DateTimeOffset? RevokedAt, bool Revoked, bool Online, IReadOnlyList Connections, Ipv6DiagnosticDto? Ipv6 = null, string? AddedBy = null); /// 一对(Probe,Agent)的线路开关:linked 是最终结果,override 是手动设置(null = 跟随自动连接),connected 是现在有没有连着。 public sealed record LinkDto(string ProbeId, string ProbeName, string AgentId, string AgentName, bool Linked, bool? Override, bool Connected); @@ -117,9 +151,19 @@ public sealed record RawChunkMetaDto( bool Partial, int Bytes); -public sealed record ApiTokenDto(string Id, string Name, string Scope, DateTimeOffset CreatedAt, DateTimeOffset? LastUsedAt, DateTimeOffset? RevokedAt); +public sealed record ApiTokenDto(string Id, string Name, IReadOnlyList Capabilities, DateTimeOffset CreatedAt, DateTimeOffset? LastUsedAt, DateTimeOffset? ExpiresAt, DateTimeOffset? RevokedAt); -public sealed record CreateApiTokenRequest(string Name, string Scope); +/// 能力列表(见 /api/admin/capabilities);写蕴含读,只能是创建者自己拥有能力的子集。 +/// 有效天数;缺省不过期。 +public sealed record CreateApiTokenRequest(string Name, IReadOnlyList Capabilities, int? ExpiresInDays = null); + +public sealed record CapabilityDto(string Id, bool Trust, bool Reserved, IReadOnlyList Implies); + +public sealed record CapabilityGroupDto(string Id, IReadOnlyList Capabilities); + +public sealed record CapabilityPresetDto(string Id, IReadOnlyList Capabilities); + +public sealed record CapabilityCatalogDto(IReadOnlyList Groups, IReadOnlyList Presets); public sealed record CreatedApiTokenDto(ApiTokenDto Token, string Plaintext); diff --git a/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs b/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs index 53f9803..82e8b9b 100644 --- a/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs +++ b/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs @@ -70,9 +70,23 @@ namespace MoShiro.Backend.Api; [JsonSerializable(typeof(RawChunkMetaDto))] [JsonSerializable(typeof(PageDto))] [JsonSerializable(typeof(PageDto))] +[JsonSerializable(typeof(PageDto))] [JsonSerializable(typeof(ApiTokenDto))] [JsonSerializable(typeof(PageDto))] [JsonSerializable(typeof(CreateApiTokenRequest))] +[JsonSerializable(typeof(CapabilityCatalogDto))] +[JsonSerializable(typeof(PasskeyCreationOptionsDto))] +[JsonSerializable(typeof(PasskeyRequestOptionsDto))] +[JsonSerializable(typeof(RegisterPasskeyRequest))] +[JsonSerializable(typeof(PasskeyLoginRequest))] +[JsonSerializable(typeof(Fleet.Core.SignedStatementDto), TypeInfoPropertyName = "FleetSignedStatement")] +[JsonSerializable(typeof(TrustStateDto))] +[JsonSerializable(typeof(ElevationRequest))] +[JsonSerializable(typeof(ElevationAssertionRequest))] +[JsonSerializable(typeof(ElevationDto))] +[JsonSerializable(typeof(PageDto))] +[JsonSerializable(typeof(PasskeyDto))] +[JsonSerializable(typeof(PageDto))] [JsonSerializable(typeof(CreatedApiTokenDto))] [JsonSerializable(typeof(PageDto))] [JsonSerializable(typeof(Dictionary), TypeInfoPropertyName = "SettingsPatch")] diff --git a/backend/src/MoShiro.Backend.Api/Contracts/PublicDtos.cs b/backend/src/MoShiro.Backend.Api/Contracts/PublicDtos.cs index 7698bb5..5033e40 100644 --- a/backend/src/MoShiro.Backend.Api/Contracts/PublicDtos.cs +++ b/backend/src/MoShiro.Backend.Api/Contracts/PublicDtos.cs @@ -10,6 +10,8 @@ public sealed record EdgePartialDto(bool First, bool Last); public sealed record GapDto(DateTimeOffset From, DateTimeOffset To, string Reason); +public sealed record AuditDto(long Id, DateTimeOffset At, string Actor, string Method, string Action, string? Target, string? Detail, string? Remote, bool Success); + public sealed record EventDto(long Id, string? AgentId, string Kind, DateTimeOffset At, string? Detail); public sealed record TestSummaryDto(string Id, string Plan, string Status, DateTimeOffset RequestedAt, DateTimeOffset? FinishedAt); diff --git a/backend/src/MoShiro.Backend.Api/InstallScripts.cs b/backend/src/MoShiro.Backend.Api/InstallScripts.cs index 35d427f..6417fbe 100644 --- a/backend/src/MoShiro.Backend.Api/InstallScripts.cs +++ b/backend/src/MoShiro.Backend.Api/InstallScripts.cs @@ -1,23 +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 { - public static string Render(string resource, InstallTarget target) + public const string Sh = "install.sh"; + public const string Ps1 = "install.ps1"; + + /// 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("@@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/MoShiro.Backend.Api.csproj b/backend/src/MoShiro.Backend.Api/MoShiro.Backend.Api.csproj index fdf3d8b..3ef7001 100644 --- a/backend/src/MoShiro.Backend.Api/MoShiro.Backend.Api.csproj +++ b/backend/src/MoShiro.Backend.Api/MoShiro.Backend.Api.csproj @@ -9,5 +9,6 @@ + diff --git a/backend/src/MoShiro.Backend.Api/OpsEndpoints.cs b/backend/src/MoShiro.Backend.Api/OpsEndpoints.cs index 9c8d766..5799778 100644 --- a/backend/src/MoShiro.Backend.Api/OpsEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/OpsEndpoints.cs @@ -17,32 +17,45 @@ public static class OpsEndpoints .Api("health_get", "SQLite and the raw data directory are both writable", Tag) .Returns().ProducesProblems(503); app.MapGet("/metrics", MetricsAsync) - .Api("metrics_get", "Prometheus metrics (admin token)", Tag) + .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(); - 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) + // 脚本要装的版本:CI 签名的发布清单(原样的字节)和签名。脚本用内置的发布公钥验签,再按清单里的 digest 拉镜像。 + app.MapGet("/install/manifest", (HttpContext http, JoinTokenService tokens, ReleaseService releases, BackendOptions options, string? code) => ManifestAsync(http, tokens, releases, options, code, signature: false)) + .Api("install_manifest", "The signed release manifest of the component an install code is for (exact signed bytes)", Tag) + .Produces(StatusCodes.Status200OK, contentType: "application/json").ProducesProblems(404); + 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) => 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 InstallAsync(HttpContext http, JoinTokenService tokens, BackendOptions options, string? code, string resource, string contentType) + private static async Task ManifestAsync(HttpContext http, JoinTokenService tokens, ReleaseService releases, BackendOptions options, string? code, bool signature) { 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) + ReleaseOffer? offer = target is null ? null : await releases.GetAsync(target.Role, http.RequestAborted); + if (offer is null) { - return Problems.Create(http, MoShiroErrors.NotFound, "the install code is invalid, expired or already used"); + return Problems.Create(http, MoShiroErrors.NotFound, "no signed release is registered for this install code"); } - string script = InstallScripts.Render(resource, target); http.Response.Headers.CacheControl = "no-store"; - return TypedResults.Text(script, contentType + "; charset=utf-8"); + return signature ? TypedResults.Text(offer.Signature, "text/plain; charset=utf-8") : TypedResults.Text(offer.Manifest, "application/json; charset=utf-8"); + } + + private static Microsoft.AspNetCore.Http.HttpResults.ContentHttpResult Static(HttpContext http, string resource, string contentType) + { + http.Response.Headers.CacheControl = "no-store"; + return TypedResults.Text(InstallScripts.Script(resource), contentType + "; charset=utf-8"); } private static async Task HealthAsync(HttpContext http, OpsService ops) @@ -57,11 +70,11 @@ public static class OpsEndpoints private static async Task MetricsAsync(HttpContext http, OpsService ops) { Principal principal = http.GetPrincipal(); - if (!principal.IsAdmin) + if (!principal.Has(Capability.OpsMetrics)) { - return principal.CanRead - ? Problems.Create(http, MoShiroErrors.Forbidden, "an admin token is required") - : Problems.Create(http, MoShiroErrors.Unauthorized, "an admin token is required"); + return principal.Kind != PrincipalKind.Anonymous + ? Problems.Create(http, MoShiroErrors.Forbidden, "the ops.metrics capability is required") + : Problems.Create(http, MoShiroErrors.Unauthorized, "authentication is required"); } return TypedResults.Text(await ops.RenderPrometheusAsync(http.RequestAborted), "text/plain; version=0.0.4"); diff --git a/backend/src/MoShiro.Backend.Api/Public/LiveEndpoints.cs b/backend/src/MoShiro.Backend.Api/Public/LiveEndpoints.cs index 49ccad4..476656a 100644 --- a/backend/src/MoShiro.Backend.Api/Public/LiveEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Public/LiveEndpoints.cs @@ -70,7 +70,7 @@ public static class LiveEndpoints } } - string client = http.Connection.RemoteIpAddress?.ToString() ?? "unknown"; + string client = http.ClientAddress(); if (!limiter.TryAcquire(client)) { return Problems.Create(http, MoShiroErrors.TooManyStreams, $"at most {options.MaxSsePerClient} live connections per client"); diff --git a/backend/src/MoShiro.Backend.Application/Admin/JoinTokenService.cs b/backend/src/MoShiro.Backend.Application/Admin/JoinTokenService.cs index bf60a76..05c40b6 100644 --- a/backend/src/MoShiro.Backend.Application/Admin/JoinTokenService.cs +++ b/backend/src/MoShiro.Backend.Application/Admin/JoinTokenService.cs @@ -1,13 +1,21 @@ using Fleet.Core; +using Fleet.Identity; +using MoShiro.Protocol; namespace MoShiro.Backend.Application; /// agent(默认)或 probe:用这个令牌注册出来的是哪种节点。 -public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList? Labels, bool AutoApprove, int MaxUses, int ExpiresInHours, string Role = JoinTokenService.AgentRole); +public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList? Labels, bool AutoApprove, int MaxUses, int ExpiresInHours, string Role = JoinTokenService.AgentRole, string? CreatedBy = null); /// 各个系统上的一行安装命令: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); /// 安装脚本里要填的四个值。 @@ -35,7 +43,7 @@ public sealed class JoinTokenService _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)) @@ -43,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) @@ -72,14 +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, 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))); + } + + /// + /// 一行安装命令:先下载静态脚本、核对 SHA-256,一致才执行;面板地址、角色、一次性码、信任根指纹和属主都走环境变量。 + /// 里面没有任何私钥,码泄露最多让别人注册出一个节点。 + /// + public static InstallCommands Commands(string baseUrl, string role, string code, InstallPlan plan) + { + 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 2f1e150..c513461 100644 --- a/backend/src/MoShiro.Backend.Application/Agents/AgentService.cs +++ b/backend/src/MoShiro.Backend.Application/Agents/AgentService.cs @@ -24,9 +24,11 @@ public sealed partial class AgentService private readonly NodeInfoService _nodes; private readonly LinkService _links; + private readonly TrustService _trust; - public AgentService(IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, TaskPlanner planner, ISettingsStore settings, NodeInfoService nodes, LinkService links) + public AgentService(IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, TaskPlanner planner, ISettingsStore settings, NodeInfoService nodes, LinkService links, TrustService trust) { + _trust = trust; _links = links; _nodes = nodes; _agents = agents; @@ -172,6 +174,9 @@ public sealed partial class AgentService }); } + IReadOnlyList chain = await _trust.ChainAsync(ct); + result.TrustChain = chain.Count == 0 ? null : [.. chain]; + return result; } diff --git a/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs b/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs index df33d2b..0dbb58e 100644 --- a/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs +++ b/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs @@ -35,6 +35,13 @@ public static class ApplicationServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/backend/src/MoShiro.Backend.Application/Audit/AuditService.cs b/backend/src/MoShiro.Backend.Application/Audit/AuditService.cs new file mode 100644 index 0000000..36c9b47 --- /dev/null +++ b/backend/src/MoShiro.Backend.Application/Audit/AuditService.cs @@ -0,0 +1,60 @@ +using Microsoft.Extensions.Logging; + +namespace MoShiro.Backend.Application; + +/// +/// 审计日志里的一行:谁(,用户名或 key:令牌名)、怎么登录的()、做了什么(, +/// 管理接口的 operationId,或 auth.login 这类登录事件)、对谁()、结果如何。 +/// +public sealed record AuditRecord(long Id, long AtMs, string? UserId, string Actor, string Method, string Action, string? Target, string? Detail, string? Remote, bool Success); + +public sealed record AuditQuery(long? BeforeId, int Limit, string? Actor, string? Action); + +/// 审计日志只追加:没有修改和单条删除,只按保留期整体清理。 +public sealed partial class AuditService +{ + /// 保留一年。审计记录的是谁动过信任相关的东西,比监测数据留得久。 + public const int RetentionDays = 365; + + private readonly IAuditStore _store; + private readonly TimeProvider _time; + private readonly ILogger _logger; + + public AuditService(IAuditStore store, TimeProvider time, ILogger logger) + { + _store = store; + _time = time; + _logger = logger; + } + + /// 记一条。写不进去只记日志,不能因为审计失败而让已经完成的操作报错。 + public async ValueTask RecordAsync(Principal principal, string action, string? target, string? detail, string? remote, bool success, CancellationToken ct = default) => + await RecordAsync(principal.UserId, principal.Label, MethodOf(principal), action, target, detail, remote, success, ct); + + public async ValueTask RecordAsync(string? userId, string actor, string method, string action, string? target, string? detail, string? remote, bool success, CancellationToken ct = default) + { + try + { + await _store.AddAsync(new AuditRecord(0, _time.GetUtcNow().ToUnixTimeMilliseconds(), userId, actor, method, action, Clip(target, 200), Clip(detail, 500), remote, success), ct); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + LogFailed(ex, action); + } + } + + public ValueTask> ListAsync(AuditQuery query, CancellationToken ct) => _store.ListAsync(query, ct); + + public ValueTask PruneAsync(long nowMs, CancellationToken ct) => _store.DeleteOlderThanAsync(nowMs - (RetentionDays * 86_400_000L), ct); + + /// 登录方式:密码、passkey,或 API key。 + public static string MethodOf(Principal principal) => + principal.Kind == PrincipalKind.Anonymous ? "anonymous" + : principal.Kind == PrincipalKind.Token && principal.UserId is null ? "key" + : principal.AuthMethod.Length > 0 ? principal.AuthMethod : AuthMethods.Password; + + private static string? Clip(string? text, int max) => text is { } t && t.Length > max ? t[..max] : text; + + [LoggerMessage(Level = LogLevel.Warning, Message = "could not write the audit record for {Action}")] + private partial void LogFailed(Exception exception, string action); +} diff --git a/backend/src/MoShiro.Backend.Application/Auth/AuthModels.cs b/backend/src/MoShiro.Backend.Application/Auth/AuthModels.cs index 9f438c5..73623a1 100644 --- a/backend/src/MoShiro.Backend.Application/Auth/AuthModels.cs +++ b/backend/src/MoShiro.Backend.Application/Auth/AuthModels.cs @@ -7,24 +7,25 @@ public enum PrincipalKind Token, } -/// 访问级别:作用域为 read 的令牌只读,admin 可以管理,release 只能登记发布清单(给 CI 用,什么都读不了)。 -public enum ApiScope +/// +/// 已认证的主体:会话或 API key,带着它拥有的能力集合。授权只看 。 +/// +public sealed record Principal(PrincipalKind Kind, IReadOnlySet Capabilities, string? UserId, string? Username, string? CsrfToken, string? TokenId) { - None = 0, - Release = 1, - Read = 2, - Admin = 3, -} + /// 会话是怎么登录的():只有 passkey 登录的会话以后能提权。API key 为空。 + public string AuthMethod { get; init; } = ""; -public sealed record Principal(PrincipalKind Kind, ApiScope Scope, string? UserId, string? Username, string? CsrfToken, string? TokenId) -{ - public static Principal Anonymous { get; } = new(PrincipalKind.Anonymous, ApiScope.None, null, null, null, null); + /// 会话的标识(令牌哈希的十六进制):提权窗口按它记。API key 为空。 + public string? SessionId { get; init; } - public bool CanRead => Scope >= ApiScope.Read; + /// 审计和归属里显示的名字:用户名,或者 key:令牌名。 + public string Label => Kind == PrincipalKind.Token && UserId is null ? "key:" + (Username ?? TokenId) : Username ?? "anonymous"; - public bool IsAdmin => Scope >= ApiScope.Admin; + public static Principal Anonymous { get; } = new(PrincipalKind.Anonymous, new HashSet(), null, null, null, null); - public bool CanPublishRelease => Scope is ApiScope.Release or ApiScope.Admin; + public bool Has(string capability) => capability == Capability.Authenticated ? Kind != PrincipalKind.Anonymous : Capabilities.Contains(capability); + + public static IReadOnlySet SetOf(IEnumerable capabilities) => new HashSet(capabilities, StringComparer.Ordinal); } public sealed record LoginResult(string SessionToken, string CsrfToken, long ExpiresAtMs, string UserId, string Username); diff --git a/backend/src/MoShiro.Backend.Application/Auth/AuthService.cs b/backend/src/MoShiro.Backend.Application/Auth/AuthService.cs index cfc1ea2..a03a70e 100644 --- a/backend/src/MoShiro.Backend.Application/Auth/AuthService.cs +++ b/backend/src/MoShiro.Backend.Application/Auth/AuthService.cs @@ -37,11 +37,22 @@ public sealed partial class AuthService return Result.Fail(MoShiroErrors.Unauthorized, "invalid credentials"); } + return await CreateSessionAsync(user, AuthMethods.Password, ct); + } + + /// 已经确认了用户身份(例如 passkey 断言通过)之后建立会话。 + public async ValueTask> CreateSessionForAsync(string userId, string method, CancellationToken ct) => + await _store.GetUserAsync(userId, ct) is { } user + ? await CreateSessionAsync(user, method, ct) + : Result.Fail(MoShiroErrors.Unauthorized, "invalid credentials"); + + private async ValueTask> CreateSessionAsync(UserRecord user, string method, CancellationToken ct) + { byte[] token = RandomNumberGenerator.GetBytes(32); byte[] csrf = RandomNumberGenerator.GetBytes(32); long now = _time.GetUtcNow().ToUnixTimeMilliseconds(); long expires = now + (long)_options.SessionLifetime.TotalMilliseconds; - await _store.CreateSessionAsync(new SessionRecord(SHA256.HashData(token), user.Id, csrf, now, expires), ct); + await _store.CreateSessionAsync(new SessionRecord(SHA256.HashData(token), user.Id, csrf, now, expires, method), ct); await _store.DeleteExpiredSessionsAsync(now, ct); return Result.Ok(new LoginResult(Base64Url.Encode(token), Base64Url.Encode(csrf), expires, user.Id, user.Username)); } @@ -60,25 +71,24 @@ public sealed partial class AuthService } UserRecord? user = await _store.GetUserAsync(session.UserId, ct); - return user is null ? null : new Principal(PrincipalKind.Session, ApiScope.Admin, user.Id, user.Username, Base64Url.Encode(session.CsrfToken), null); + 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) { ApiTokenRecord? token = await _store.FindApiTokenAsync(TokenHash.Sha256(bearer), ct); - if (token is null || token.RevokedAtMs is not null) + long now = _time.GetUtcNow().ToUnixTimeMilliseconds(); + if (token is null || token.RevokedAtMs is not null || (token.ExpiresAtMs is { } expires && expires <= now)) { return null; } - long now = _time.GetUtcNow().ToUnixTimeMilliseconds(); if (token.LastUsedAtMs is not { } last || now - last >= TokenTouchIntervalMs) { await _store.TouchApiTokenAsync(token.Id, now, ct); } - ApiScope scope = token.Scope switch { "admin" => ApiScope.Admin, "release" => ApiScope.Release, _ => ApiScope.Read }; - return new Principal(PrincipalKind.Token, scope, null, token.Name, null, token.Id); + return new Principal(PrincipalKind.Token, Principal.SetOf(token.Capabilities), null, token.Name, null, token.Id); } public async ValueTask LogoutAsync(string sessionToken, CancellationToken ct) @@ -128,20 +138,48 @@ public sealed partial class AuthService public ValueTask> ListUsersAsync(CancellationToken ct) => _store.ListUsersAsync(ct); - public async ValueTask> CreateApiTokenAsync(string name, string scope, CancellationToken ct) + /// + /// 创建 API key:能力必须都在目录里,并且是创建者自己拥有能力的子集(不能越权);写蕴含读,结果稳定排序。 + /// 为空表示不过期。 + /// + public async ValueTask> CreateApiTokenAsync(string name, IReadOnlyList capabilities, int? expiresInDays, Principal creator, CancellationToken ct) { + var errors = new Dictionary(); if (name.Length is 0 or > 64) { - return ValidationError.Fail("name", "must be 1-64 characters"); + errors["name"] = "must be 1-64 characters"; } - if (scope is not ("read" or "admin" or "release")) + string[] unknown = [.. capabilities.Where(c => !CapabilityCatalog.IsKnown(c))]; + if (capabilities.Count == 0) { - return ValidationError.Fail("scope", "must be read, admin or release"); + errors["capabilities"] = "choose at least one capability"; + } + else if (unknown.Length > 0) + { + errors["capabilities"] = "unknown capability: " + string.Join(", ", unknown); + } + + if (expiresInDays is < 1 or > 3650) + { + errors["expires_in_days"] = "must be between 1 and 3650"; + } + + IReadOnlyList effective = CapabilityCatalog.Expand(capabilities.Where(CapabilityCatalog.IsKnown)); + string[] beyond = [.. effective.Where(c => !creator.Has(c))]; + if (beyond.Length > 0) + { + errors["capabilities"] = "you cannot grant what you do not have: " + string.Join(", ", beyond); + } + + if (errors.Count > 0) + { + return ValidationError.Fail(errors); } string plaintext = TokenHash.NewToken("mos"); - var record = new ApiTokenRecord(Ulid.NewPrefixed("tok", _time), name, scope, TokenHash.Sha256(plaintext), _time.GetUtcNow().ToUnixTimeMilliseconds(), null, null); + long now = _time.GetUtcNow().ToUnixTimeMilliseconds(); + var record = new ApiTokenRecord(Ulid.NewPrefixed("tok", _time), name, effective, TokenHash.Sha256(plaintext), now, null, expiresInDays is { } days ? now + (days * 86_400_000L) : null, null); await _store.CreateApiTokenAsync(record, ct); return Result.Ok(new CreatedApiToken(record, plaintext)); } diff --git a/backend/src/MoShiro.Backend.Application/Auth/Capabilities.cs b/backend/src/MoShiro.Backend.Application/Auth/Capabilities.cs new file mode 100644 index 0000000..503a3a2 --- /dev/null +++ b/backend/src/MoShiro.Backend.Application/Auth/Capabilities.cs @@ -0,0 +1,143 @@ +namespace MoShiro.Backend.Application; + +/// +/// 能力(capability):一项具体的权限。端点声明自己需要哪一项, 是唯一的判断入口, +/// 会话、API key 都走它。设计见 docs/access.md 第 2 节。 +/// +public static class Capability +{ + /// 任何已认证的主体(会话或 API key);只有登出、查看自己这类端点用它。 + public const string Authenticated = "authenticated"; + + public const string PublicRead = "public.read"; + + public const string FleetRead = "fleet.read"; + public const string FleetWrite = "fleet.write"; + public const string FleetEnroll = "fleet.enroll"; + public const string FleetRevoke = "fleet.revoke"; + + public const string TasksRead = "tasks.read"; + public const string TasksWrite = "tasks.write"; + + public const string DataRead = "data.read"; + public const string DataWrite = "data.write"; + + public const string SettingsRead = "settings.read"; + public const string SettingsWrite = "settings.write"; + + public const string UpdateRead = "update.read"; + public const string UpdateConfirm = "update.confirm"; + public const string ReleasePublish = "release.publish"; + + public const string KeysRead = "keys.read"; + public const string KeysManage = "keys.manage"; + + public const string AccountsRead = "accounts.read"; + public const string AccountsManage = "accounts.manage"; + + public const string AuditRead = "audit.read"; + public const string EventsRead = "events.read"; + public const string OpsMetrics = "ops.metrics"; + + // 预留:外部 webhook 回调和数据拉取(docs/access.md 5.4),目录里先存在,实现放后面 + public const string DataExport = "data.export"; + public const string WebhooksRead = "webhooks.read"; + public const string WebhooksManage = "webhooks.manage"; +} + +/// 一项能力的元数据。Trust:改变信任的能力(加节点、吊销、账户),后续会要求 passkey 签名;Reserved:还没有对应的功能。 +public sealed record CapabilityInfo(string Id, string Group, bool Trust = false, bool Reserved = false, params string[] Implies); + +public sealed record CapabilityPreset(string Id, IReadOnlyList Capabilities); + +/// 能力目录:分组、依赖(写蕴含读)、预设,以及创建 API key 时的校验。 +public static class CapabilityCatalog +{ + public static IReadOnlyList All { get; } = + [ + new(Capability.PublicRead, "public"), + + new(Capability.FleetRead, "fleet"), + new(Capability.FleetWrite, "fleet", false, false, Capability.FleetRead), + new(Capability.FleetEnroll, "fleet", false, false, Capability.FleetRead), + new(Capability.FleetRevoke, "fleet", true, false, Capability.FleetRead), + + new(Capability.TasksRead, "tasks"), + new(Capability.TasksWrite, "tasks", false, false, Capability.TasksRead), + + new(Capability.DataRead, "data"), + new(Capability.DataWrite, "data", false, false, Capability.DataRead), + new(Capability.DataExport, "data", false, true, Capability.DataRead), + + new(Capability.SettingsRead, "settings"), + new(Capability.SettingsWrite, "settings", false, false, Capability.SettingsRead), + + new(Capability.UpdateRead, "update"), + new(Capability.UpdateConfirm, "update", false, false, Capability.UpdateRead), + new(Capability.ReleasePublish, "update"), + + new(Capability.KeysRead, "keys"), + new(Capability.KeysManage, "keys", false, false, Capability.KeysRead), + + new(Capability.AccountsRead, "accounts"), + new(Capability.AccountsManage, "accounts", true, false, Capability.AccountsRead), + + new(Capability.EventsRead, "ops"), + new(Capability.AuditRead, "ops"), + new(Capability.OpsMetrics, "ops"), + + new(Capability.WebhooksRead, "integrations", false, true), + new(Capability.WebhooksManage, "integrations", false, true, Capability.WebhooksRead), + ]; + + private static readonly Dictionary s_byId = All.ToDictionary(c => c.Id, StringComparer.Ordinal); + + public static IReadOnlyList Groups { get; } = [.. All.Select(c => c.Group).Distinct()]; + + public static IReadOnlyList Presets { get; } = + [ + new("read-only", [.. All.Where(c => !c.Reserved && c.Id.EndsWith(".read", StringComparison.Ordinal)).Select(c => c.Id)]), + new("operations", Expand([Capability.PublicRead, Capability.FleetWrite, Capability.TasksWrite, Capability.DataWrite, Capability.SettingsWrite, Capability.UpdateConfirm, Capability.EventsRead, Capability.AuditRead])), + new("enroll", Expand([Capability.FleetEnroll])), + new("ci-release", [Capability.ReleasePublish]), + new("all", [.. All.Select(c => c.Id)]), + ]; + + public static bool IsKnown(string id) => s_byId.ContainsKey(id); + + public static CapabilityInfo? Find(string id) => s_byId.GetValueOrDefault(id); + + /// 补上蕴含的能力(写蕴含读),去重并排序,结果稳定。 + public static IReadOnlyList Expand(IEnumerable ids) + { + var result = new SortedSet(StringComparer.Ordinal); + foreach (string id in ids) + { + Add(result, id); + } + + return [.. result]; + } + + private static void Add(SortedSet set, string id) + { + if (id is Capability.Authenticated || !set.Add(id) || !s_byId.TryGetValue(id, out CapabilityInfo? info)) + { + return; + } + + foreach (string implied in info.Implies) + { + Add(set, implied); + } + } + + /// 登录会话的能力:全部,包括信任类;信任类的操作还要有效的提权窗口()。 + 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 new file mode 100644 index 0000000..de6c7c1 --- /dev/null +++ b/backend/src/MoShiro.Backend.Application/Auth/ElevationService.cs @@ -0,0 +1,135 @@ +namespace MoShiro.Backend.Application; + +/// +/// 提权窗口的种类(docs/access.md 第 4 节):加节点(15 分钟)、吊销和删除(5 分钟)、改凭证(60 秒,用一次就没有)。 +/// 窗口属于会话,不属于用户:换一台设备、一个浏览器就要重新触碰 passkey。 +/// +public enum ElevationClass +{ + Enroll, + Revoke, + Credential, +} + +public sealed record ElevationWindow(ElevationClass Class, long ExpiresAtMs); + +public static class Elevation +{ + public static string IdOf(ElevationClass elevation) => elevation switch + { + ElevationClass.Enroll => "enroll", + ElevationClass.Revoke => "revoke", + _ => "credential", + }; + + public static bool TryParse(string? id, out ElevationClass elevation) + { + (bool ok, elevation) = id switch + { + "enroll" => (true, ElevationClass.Enroll), + "revoke" => (true, ElevationClass.Revoke), + "credential" => (true, ElevationClass.Credential), + _ => (false, default), + }; + return ok; + } + + /// 端点要求的能力需要哪一种窗口;不是信任类的能力返回 null。 + public static ElevationClass? For(string capability) => capability switch + { + Capability.FleetRevoke => ElevationClass.Revoke, + Capability.AccountsManage => ElevationClass.Credential, + _ => null, + }; +} + +/// +/// 会话的提权窗口,只在内存里:重启之后需要重新触碰 passkey,这正是想要的。API key 不经过这里(它自己带着能力,创建时已经签过)。 +/// +public sealed class ElevationService +{ + private readonly BackendOptions _options; + private readonly TimeProvider _time; + private readonly object _gate = new(); + private readonly Dictionary> _windows = []; + + public ElevationService(BackendOptions options, TimeProvider time) + { + _options = options; + _time = time; + } + + public TimeSpan LifetimeOf(ElevationClass elevation) => elevation switch + { + ElevationClass.Enroll => TimeSpan.FromMinutes(_options.ElevationEnrollMinutes), + ElevationClass.Revoke => TimeSpan.FromMinutes(_options.ElevationRevokeMinutes), + _ => TimeSpan.FromSeconds(_options.ElevationCredentialSeconds), + }; + + /// 开窗口;已经有的窗口重新计时。返回到期时间。 + public long Grant(string sessionId, ElevationClass elevation) + { + long now = _time.GetUtcNow().ToUnixTimeMilliseconds(); + long expires = now + (long)LifetimeOf(elevation).TotalMilliseconds; + lock (_gate) + { + Prune(now); + if (!_windows.TryGetValue(sessionId, out Dictionary? own)) + { + _windows[sessionId] = own = []; + } + + own[elevation] = expires; + } + + return expires; + } + + /// 窗口是否有效。 且是凭证类:同时用掉(一次有效)。 + public bool Check(string sessionId, ElevationClass elevation, bool consume) + { + long now = _time.GetUtcNow().ToUnixTimeMilliseconds(); + lock (_gate) + { + if (!_windows.TryGetValue(sessionId, out Dictionary? own) || !own.TryGetValue(elevation, out long expires) || expires <= now) + { + return false; + } + + if (consume && elevation == ElevationClass.Credential) + { + own.Remove(elevation); + } + + return true; + } + } + + public IReadOnlyList Active(string sessionId) + { + long now = _time.GetUtcNow().ToUnixTimeMilliseconds(); + lock (_gate) + { + return _windows.TryGetValue(sessionId, out Dictionary? own) + ? [.. own.Where(w => w.Value > now).OrderBy(w => w.Key).Select(w => new ElevationWindow(w.Key, w.Value))] + : []; + } + } + + /// 主动结束全部窗口(点「结束」或退出登录)。 + public void End(string sessionId) + { + lock (_gate) + { + _windows.Remove(sessionId); + } + } + + private void Prune(long now) + { + foreach (string session in _windows.Where(p => p.Value.All(w => w.Value <= now)).Select(p => p.Key).ToList()) + { + _windows.Remove(session); + } + } +} diff --git a/backend/src/MoShiro.Backend.Application/Auth/LoginRateLimiter.cs b/backend/src/MoShiro.Backend.Application/Auth/LoginRateLimiter.cs index ca7fede..7b77e49 100644 --- a/backend/src/MoShiro.Backend.Application/Auth/LoginRateLimiter.cs +++ b/backend/src/MoShiro.Backend.Application/Auth/LoginRateLimiter.cs @@ -15,8 +15,8 @@ public sealed class LoginRateLimiter _time = time; } - /// 登记一次尝试;超限时返回 false 与建议的重试等待秒数。 - public bool TryAcquire(string source, out int retryAfterSeconds) + /// 登记一次尝试;超限时返回 false 与建议的重试等待秒数。 缺省是登录失败的额度(每分钟 次)。 + public bool TryAcquire(string source, out int retryAfterSeconds, int? limit = null) { long now = _time.GetUtcNow().ToUnixTimeMilliseconds(); Queue queue = _attempts.GetOrAdd(source, _ => new Queue()); @@ -27,7 +27,7 @@ public sealed class LoginRateLimiter queue.Dequeue(); } - if (queue.Count >= _options.LoginAttemptsPerMinute) + if (queue.Count >= (limit ?? _options.LoginAttemptsPerMinute)) { retryAfterSeconds = (int)Math.Max(1, (60_000 - (now - queue.Peek()) + 999) / 1000); return false; diff --git a/backend/src/MoShiro.Backend.Application/Auth/PasskeyService.cs b/backend/src/MoShiro.Backend.Application/Auth/PasskeyService.cs new file mode 100644 index 0000000..9dce41a --- /dev/null +++ b/backend/src/MoShiro.Backend.Application/Auth/PasskeyService.cs @@ -0,0 +1,251 @@ +using System.Security.Cryptography; +using System.Text; +using Fleet.Core; +using Fleet.Identity; +using MoShiro.Protocol; + +namespace MoShiro.Backend.Application; + +public sealed record PasskeyRecord(string Id, string UserId, string Name, byte[] PublicKeySpki, long SignCount, IReadOnlyList Transports, long CreatedAtMs, long? LastUsedAtMs); + +/// 这次请求的 WebAuthn 依赖方:RpId 是域名,Origins 是浏览器会填进 clientDataJSON.origin 的来源。 +public sealed record PasskeyContext(string RpId, IReadOnlySet Origins); + +public sealed record CreationOptions(string Challenge, string RpId, string RpName, string UserId, string UserName, IReadOnlyList ExcludeCredentials, int TimeoutMs); + +public sealed record RequestOptions(string Challenge, string RpId, int TimeoutMs, IReadOnlyList? AllowCredentials = null); + +public sealed record PasskeyRegistration(string Name, string CredentialId, string AttestationObject, string ClientDataJson, IReadOnlyList? Transports); + +public sealed record PasskeyAssertion(string CredentialId, string AuthenticatorData, string ClientDataJson, string Signature); + +/// +/// passkey 的注册与登录(WebAuthn,ES256,attestation 当 none)。挑战只在内存里,一次有效、5 分钟过期; +/// 登录是「可发现凭证」:不需要先输用户名,凭证 id 找到用户。验证断言的纯函数在 (节点以后也用)。 +/// 设计见 docs/access.md 第 3 节。 +/// +public sealed class PasskeyService +{ + private const int ChallengeBytes = 32; + private const int MaxPasskeysPerUser = 10; + private const int MaxPendingChallenges = 4096; + private static readonly TimeSpan s_challengeLifetime = TimeSpan.FromMinutes(5); + + private readonly IAuthStore _store; + private readonly AuthService _auth; + private readonly ElevationService _elevation; + private readonly TimeProvider _time; + private readonly object _gate = new(); + private readonly Dictionary _challenges = []; + + private sealed record PendingChallenge(string Purpose, string? UserId, long ExpiresAtMs); + + private const string RegisterPurpose = "register"; + private const string LoginPurpose = "login"; + + private static string ElevatePurpose(ElevationClass elevation) => "elevate:" + Elevation.IdOf(elevation); + + public PasskeyService(IAuthStore store, AuthService auth, ElevationService elevation, TimeProvider time) + { + _store = store; + _auth = auth; + _elevation = elevation; + _time = time; + } + + public async ValueTask> BeginRegistrationAsync(Principal principal, PasskeyContext context, CancellationToken ct) + { + if (principal.UserId is not { } userId || principal.Username is not { } username) + { + return Result.Fail(MoShiroErrors.Forbidden, "passkeys belong to a signed-in user, not to an API token"); + } + + IReadOnlyList existing = await _store.ListPasskeysAsync(userId, ct); + if (existing.Count >= MaxPasskeysPerUser) + { + return ValidationError.Fail("passkeys", $"at most {MaxPasskeysPerUser} passkeys per user"); + } + + string challenge = Issue(RegisterPurpose, userId); + return Result.Ok(new CreationOptions(challenge, context.RpId, "MoShiro", Base64Url.Encode(Encoding.UTF8.GetBytes(userId)), username, [.. existing.Select(p => p.Id)], 60_000)); + } + + public async ValueTask> RegisterAsync(Principal principal, PasskeyRegistration request, PasskeyContext context, CancellationToken ct) + { + if (principal.UserId is not { } userId) + { + return Result.Fail(MoShiroErrors.Forbidden, "passkeys belong to a signed-in user, not to an API token"); + } + + string name = request.Name.Trim(); + if (name.Length is 0 or > 64) + { + return ValidationError.Fail("name", "must be 1-64 characters"); + } + + if (!TryDecode(request.AttestationObject, out byte[] attestation) || !TryDecode(request.ClientDataJson, out byte[] clientData) || !TryDecode(request.CredentialId, out byte[] id)) + { + return ValidationError.Fail("credential", "malformed registration"); + } + + // 挑战在 clientDataJSON 里:先取出来,一次有效 + if (!TryTakeChallenge(clientData, RegisterPurpose, userId, out byte[] challenge)) + { + return Result.Fail(MoShiroErrors.ValidationFailed, "the registration challenge is unknown, expired or already used"); + } + + RegisteredCredential? credential = WebAuthnRegistration.Verify(attestation, clientData, context.RpId, challenge, context.Origins.Contains, requireUserVerification: true); + if (credential is null || !credential.CredentialId.AsSpan().SequenceEqual(id)) + { + return Result.Fail(MoShiroErrors.ValidationFailed, "the registration could not be verified (ES256 with user verification is required)"); + } + + string credentialId = Base64Url.Encode(credential.CredentialId); + var record = new PasskeyRecord(credentialId, userId, name, credential.PublicKeySpki, credential.SignCount, request.Transports?.Take(8).ToArray() ?? [], _time.GetUtcNow().ToUnixTimeMilliseconds(), null); + return await _store.AddPasskeyAsync(record, ct) + ? Result.Ok(record) + : Result.Fail(MoShiroErrors.NameConflict, "this passkey is already registered"); + } + + public RequestOptions BeginLogin(PasskeyContext context) => new(Issue(LoginPurpose, null), context.RpId, 60_000); + + /// 验证断言并建立会话。失败一律 unauthorized,不区分原因。 + public async ValueTask> LoginAsync(PasskeyAssertion request, PasskeyContext context, CancellationToken ct) + { + PasskeyRecord? passkey = await VerifyAsync(request, context, LoginPurpose, null, ct); + return passkey is null + ? Result.Fail(MoShiroErrors.Unauthorized, "invalid credentials") + : await _auth.CreateSessionForAsync(passkey.UserId, AuthMethods.Passkey, ct); + } + + /// 提权的挑战:绑定用户和窗口种类,并列出这个用户的 passkey 让浏览器只选它们。没有 passkey 的用户没法提权。 + public async ValueTask> BeginElevationAsync(Principal principal, ElevationClass elevation, PasskeyContext context, CancellationToken ct) + { + if (principal.UserId is not { } userId || principal.SessionId is null) + { + return Result.Fail(MoShiroErrors.Forbidden, "only a signed-in user can elevate; API keys carry their capabilities already"); + } + + IReadOnlyList own = await _store.ListPasskeysAsync(userId, ct); + if (own.Count == 0) + { + return Result.Fail(MoShiroErrors.ValidationFailed, "register a passkey first (Users, My passkeys)"); + } + + return Result.Ok(new RequestOptions(Issue(ElevatePurpose(elevation), userId), context.RpId, 60_000, [.. own.Select(p => p.Id)])); + } + + /// 验证提权的断言(必须是这个用户自己的 passkey)并给这个会话开窗口。失败一律 unauthorized。 + public async ValueTask> ElevateAsync(Principal principal, ElevationClass elevation, PasskeyAssertion request, PasskeyContext context, CancellationToken ct) + { + if (principal.UserId is not { } userId || principal.SessionId is not { } sessionId) + { + return Result.Fail(MoShiroErrors.Forbidden, "only a signed-in user can elevate; API keys carry their capabilities already"); + } + + PasskeyRecord? passkey = await VerifyAsync(request, context, ElevatePurpose(elevation), userId, ct); + return passkey is null || passkey.UserId != userId + ? Result.Fail(MoShiroErrors.Unauthorized, "invalid credentials") + : Result.Ok(new ElevationWindow(elevation, _elevation.Grant(sessionId, elevation))); + } + + /// 验证一份断言:挑战(一次有效、种类和用户对得上)、签名、计数器;通过则记下使用并返回那个 passkey。 + private async ValueTask VerifyAsync(PasskeyAssertion request, PasskeyContext context, string purpose, string? userId, CancellationToken ct) + { + if (!TryDecode(request.CredentialId, out _) || !TryDecode(request.AuthenticatorData, out byte[] authData) || !TryDecode(request.ClientDataJson, out byte[] clientData) || !TryDecode(request.Signature, out byte[] signature)) + { + return null; + } + + if (!TryTakeChallenge(clientData, purpose, userId, out byte[] challenge)) + { + return null; + } + + PasskeyRecord? passkey = await _store.FindPasskeyAsync(request.CredentialId, ct); + if (passkey is null || !WebAuthnAssertion.Verify(passkey.PublicKeySpki, authData, clientData, signature, context.RpId, challenge, context.Origins.Contains, requireUserVerification: true, out uint signCount)) + { + return null; + } + + // 计数器只增不减;很多同步的 passkey 一直报 0,两边都是 0 时不检查 + if ((signCount != 0 || passkey.SignCount != 0) && signCount <= passkey.SignCount) + { + return null; + } + + await _store.TouchPasskeyAsync(passkey.Id, signCount, _time.GetUtcNow().ToUnixTimeMilliseconds(), ct); + return passkey; + } + + public ValueTask> ListAsync(string userId, CancellationToken ct) => _store.ListPasskeysAsync(userId, ct); + + public ValueTask DeleteAsync(string userId, string passkeyId, CancellationToken ct) => _store.DeletePasskeyAsync(userId, passkeyId, ct); + + private string Issue(string purpose, string? userId) + { + byte[] challenge = RandomNumberGenerator.GetBytes(ChallengeBytes); + string key = Base64Url.Encode(challenge); + long now = _time.GetUtcNow().ToUnixTimeMilliseconds(); + lock (_gate) + { + if (_challenges.Count >= MaxPendingChallenges) + { + foreach (string stale in _challenges.Where(p => p.Value.ExpiresAtMs <= now).Select(p => p.Key).ToList()) + { + _challenges.Remove(stale); + } + + if (_challenges.Count >= MaxPendingChallenges) + { + _challenges.Remove(_challenges.OrderBy(p => p.Value.ExpiresAtMs).First().Key); // 淘汰最旧的 + } + } + + _challenges[key] = new PendingChallenge(purpose, userId, now + (long)s_challengeLifetime.TotalMilliseconds); + } + + return key; + } + + /// 从 clientDataJSON 取出 challenge,按它找到并移除(一次有效)。 + private bool TryTakeChallenge(byte[] clientDataJson, string purpose, string? userId, out byte[] challenge) + { + challenge = []; + string? text; + try + { + using var document = System.Text.Json.JsonDocument.Parse(clientDataJson); + text = document.RootElement.GetProperty("challenge").GetString(); + } + catch (Exception ex) when (ex is System.Text.Json.JsonException or KeyNotFoundException or InvalidOperationException) + { + return false; + } + + if (text is null || !TryDecode(text, out byte[] raw)) + { + return false; + } + + long now = _time.GetUtcNow().ToUnixTimeMilliseconds(); + lock (_gate) + { + if (!_challenges.Remove(Base64Url.Encode(raw), out PendingChallenge? pending) || pending.ExpiresAtMs <= now || pending.Purpose != purpose || pending.UserId != userId) + { + return false; + } + } + + challenge = raw; + return true; + } + + private static bool TryDecode(string text, out byte[] data) => Base64Url.TryDecode(text, out data) && data.Length > 0; +} + +public static class AuthMethods +{ + public const string Password = "password"; + public const string Passkey = "passkey"; +} 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 new file mode 100644 index 0000000..db0ebff --- /dev/null +++ b/backend/src/MoShiro.Backend.Application/Auth/TrustService.cs @@ -0,0 +1,141 @@ +using System.Security.Cryptography; +using System.Text; +using Fleet.Core; +using Fleet.Identity; +using Fleet.Server; +using MoShiro.Protocol; + +namespace MoShiro.Backend.Application; + +/// 要操作员用 passkey 签的下一版操作员集合:Challenge 是语句的 SHA-256(base64url),也就是 WebAuthn 的挑战。 +public sealed record TrustProposal(string Statement, int Version, string Challenge); + +/// InSync:当前生效的集合和现有的 passkey、域名是否一致;不一致就需要签一个新版本。 +/// 这个面板的服务器身份密钥的指纹;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 一侧:保存操作员集合的链、给操作员出下一版的语句、验证他签回来的版本。 +/// Backend 不持有任何签名的能力:每一版都要操作员的 passkey 触碰;节点从安装命令里固定的创世指纹出发自己验链, +/// 所以 Backend 在这里的验证只是不收垃圾,不是信任的来源。设计见 docs/access.md 第 7 节。 +/// +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, ServerIdentityService server, TimeProvider time) + { + _server = server; + _store = store; + _auth = auth; + _time = time; + } + + public void Dispose() => _gate.Dispose(); + + public ValueTask> ChainAsync(CancellationToken ct) => _store.ListAsync(ct); + + /// 当前生效的操作员集合(从创世验过的链);还没有建立信任根返回 null。 + public async ValueTask CurrentAsync(CancellationToken ct) + { + IReadOnlyList chain = await _store.ListAsync(ct); + return chain.Count == 0 ? null : OperatorTrust.VerifyChain(OperatorTrust.Fingerprint(chain[0].Statement), [.. chain.Select(c => c.ToSigned())]); + } + + public async ValueTask StateAsync(PasskeyContext context, CancellationToken ct) + { + IReadOnlyList chain = await _store.ListAsync(ct); + OperatorSet? current = chain.Count == 0 ? null : OperatorTrust.VerifyChain(OperatorTrust.Fingerprint(chain[0].Statement), [.. chain.Select(c => c.ToSigned())]); + 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, 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) + { + IReadOnlyList passkeys = await _auth.ListAllPasskeysAsync(ct); + if (passkeys.Count == 0) + { + return null; + } + + var next = new OperatorSet( + (current?.Version ?? 0) + 1, + 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))], + [_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.Servers ?? []).Select(Convert.ToBase64String).Order(StringComparer.Ordinal).SequenceEqual((b.Servers ?? []).Select(Convert.ToBase64String).Order(StringComparer.Ordinal)); + + /// + /// 收下操作员签回来的一版(创世,或上一版里的凭证签的更新):语句里的每把凭证都要是已注册的 passkey、域名对得上、链验得过。 + /// 谁能调用由端点的能力和提权窗口把关;这里不看调用者。 + /// + public async ValueTask> CommitAsync(SignedStatementDto signed, PasskeyContext context, CancellationToken ct) + { + await _gate.WaitAsync(ct); + try + { + IReadOnlyList chain = await _store.ListAsync(ct); + OperatorSet? set = OperatorTrust.ParseSet(signed.Statement); + if (set is null) + { + return ValidationError.Fail("statement", "is not an operator set"); + } + + OperatorSet? current = chain.Count == 0 ? null : OperatorTrust.VerifyChain(OperatorTrust.Fingerprint(chain[0].Statement), [.. chain.Select(c => c.ToSigned())]); + if (set.Version != (current?.Version ?? 0) + 1) + { + return Result.Fail(MoShiroErrors.NameConflict, $"the operator set is at version {current?.Version ?? 0}; sign version {(current?.Version ?? 0) + 1}"); + } + + if (set.RpId != context.RpId || set.Origins.Any(o => !context.Origins.Contains(o))) + { + 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)))) + { + return ValidationError.Fail("statement", "lists a passkey that is not registered on this panel"); + } + + List extended = [.. chain.Select(c => c.ToSigned()), signed.ToSigned()]; + string genesis = OperatorTrust.Fingerprint(extended[0].Statement); + if (OperatorTrust.VerifyChain(genesis, extended) is not { } verified || verified.Version != set.Version) + { + return Result.Fail(MoShiroErrors.Forbidden, "the operator set is not signed by a passkey of the previous version"); + } + + if (!await _store.AddAsync(set.Version, signed, _time.GetUtcNow().ToUnixTimeMilliseconds(), ct)) + { + return Result.Fail(MoShiroErrors.NameConflict, "this operator set version already exists"); + } + } + finally + { + _gate.Release(); + } + + return Result.Ok(await StateAsync(context, ct)); + } +} diff --git a/backend/src/MoShiro.Backend.Application/Auth/WebAuthnRegistration.cs b/backend/src/MoShiro.Backend.Application/Auth/WebAuthnRegistration.cs new file mode 100644 index 0000000..a85021b --- /dev/null +++ b/backend/src/MoShiro.Backend.Application/Auth/WebAuthnRegistration.cs @@ -0,0 +1,177 @@ +using System.Security.Cryptography; +using System.Text; +using Fleet.Identity; + +namespace MoShiro.Backend.Application; + +/// 注册成功后要保存的凭证。 +public sealed record RegisteredCredential(byte[] CredentialId, byte[] PublicKeySpki, uint SignCount); + +/// +/// 解析并校验 WebAuthn 注册(navigator.credentials.create 的结果)。只接受 ES256;attestation 一律当 none 处理: +/// 不验设备证明,信任浏览器的实现(docs/access.md)。CBOR 解析只在 Backend 里,节点不带。 +/// +public static class WebAuthnRegistration +{ + public static RegisteredCredential? Verify( + ReadOnlySpan attestationObject, ReadOnlySpan clientDataJson, string rpId, ReadOnlySpan expectedChallenge, + Func originAllowed, bool requireUserVerification) + { + if (!WebAuthnAssertion.ClientDataValid(clientDataJson, "webauthn.create", expectedChallenge, originAllowed)) + { + return null; + } + + try + { + if (new MiniCbor(attestationObject.ToArray()).ReadValue() is not Dictionary attestation + || attestation.GetValueOrDefault("authData") is not byte[] authData || authData.Length < 55) + { + return null; + } + + if (!CryptographicOperations.FixedTimeEquals(authData.AsSpan(0, 32), SHA256.HashData(Encoding.UTF8.GetBytes(rpId)))) + { + return null; + } + + byte flags = authData[32]; + const byte attestedData = 0x40; + if ((flags & WebAuthnAssertion.FlagUserPresent) == 0 || (flags & attestedData) == 0 || (requireUserVerification && (flags & WebAuthnAssertion.FlagUserVerified) == 0)) + { + return null; + } + + uint signCount = (uint)((authData[33] << 24) | (authData[34] << 16) | (authData[35] << 8) | authData[36]); + int idLength = (authData[53] << 8) | authData[54]; + if (idLength is 0 or > 1023 || authData.Length < 55 + idLength) + { + return null; + } + + byte[] credentialId = authData[55..(55 + idLength)]; + var reader = new MiniCbor(authData[(55 + idLength)..]); + if (reader.ReadValue() is not Dictionary cose + || cose.GetValueOrDefault(1L) is not long kty || kty != 2 + || cose.GetValueOrDefault(3L) is not long alg || alg != -7 + || cose.GetValueOrDefault(-1L) is not long curve || curve != 1 + || cose.GetValueOrDefault(-2L) is not byte[] { Length: 32 } x + || cose.GetValueOrDefault(-3L) is not byte[] { Length: 32 } y) + { + return null; + } + + using ECDsa key = ECDsa.Create(new ECParameters { Curve = ECCurve.NamedCurves.nistP256, Q = new ECPoint { X = x, Y = y } }); + return new RegisteredCredential(credentialId, key.ExportSubjectPublicKeyInfo(), signCount); + } + catch (Exception ex) when (ex is InvalidDataException or CryptographicException or ArgumentException or IndexOutOfRangeException) + { + return null; + } + } +} + +/// 够用的 CBOR 读取器(RFC 8949):整数、字节串、文本串、数组、映射、布尔和 null。输入有上限,嵌套有深度限制。 +internal sealed class MiniCbor +{ + private const int MaxDepth = 8; + private const int MaxItems = 64; + + private readonly byte[] _data; + private int _position; + + public MiniCbor(byte[] data) + { + _data = data; + } + + public object? ReadValue(int depth = 0) + { + if (depth > MaxDepth || _position >= _data.Length) + { + throw new InvalidDataException("cbor too deep or truncated"); + } + + byte initial = _data[_position++]; + int major = initial >> 5; + long argument = ReadArgument(initial & 0x1F); + switch (major) + { + case 0: + return argument; + case 1: + return -1 - argument; + case 2: + return Take(argument); + case 3: + return Encoding.UTF8.GetString(Take(argument)); + case 4: + { + CheckCount(argument); + var list = new List(); + for (long i = 0; i < argument; i++) + { + list.Add(ReadValue(depth + 1)); + } + + return list; + } + + case 5: + { + CheckCount(argument); + var map = new Dictionary(); + for (long i = 0; i < argument; i++) + { + object key = ReadValue(depth + 1) ?? throw new InvalidDataException("null map key"); + map[key] = ReadValue(depth + 1)!; + } + + return map; + } + + case 7: + return initial switch { 0xF4 => false, 0xF5 => true, 0xF6 => null, _ => throw new InvalidDataException("unsupported simple value") }; + default: + throw new InvalidDataException("unsupported cbor type"); + } + } + + private long ReadArgument(int info) + { + if (info < 24) + { + return info; + } + + int length = info switch { 24 => 1, 25 => 2, 26 => 4, 27 => 8, _ => throw new InvalidDataException("indefinite lengths are not supported") }; + byte[] bytes = Take(length); + long value = 0; + foreach (byte b in bytes) + { + value = (value << 8) | b; + } + + return value < 0 ? throw new InvalidDataException("argument too large") : value; + } + + private byte[] Take(long length) + { + if (length < 0 || length > _data.Length - _position) + { + throw new InvalidDataException("cbor truncated"); + } + + byte[] result = _data[_position..(_position + (int)length)]; + _position += (int)length; + return result; + } + + private static void CheckCount(long count) + { + if (count > MaxItems) + { + throw new InvalidDataException("cbor collection too large"); + } + } +} diff --git a/backend/src/MoShiro.Backend.Application/BackendOptions.cs b/backend/src/MoShiro.Backend.Application/BackendOptions.cs index 27e8cf0..1b1c1d6 100644 --- a/backend/src/MoShiro.Backend.Application/BackendOptions.cs +++ b/backend/src/MoShiro.Backend.Application/BackendOptions.cs @@ -26,6 +26,21 @@ public sealed class BackendOptions public int Pbkdf2Iterations { get; set; } = 600_000; + /// WebAuthn 的依赖方域名;缺省取 的主机,没有就取请求的主机。passkey 绑定在这个域名上,改了会让已注册的 passkey 失效。 + public string? WebAuthnRpId { get; set; } + + /// 允许的来源(逗号分隔,如 https://panel.example.com);缺省是 的来源,没有就取请求自己的来源。 + public string? WebAuthnOrigins { get; set; } + + /// 提权窗口的长度(passkey 触碰一次之后):加节点类的分钟数。 + public int ElevationEnrollMinutes { get; set; } = 15; + + /// 吊销和删除类的分钟数。 + public int ElevationRevokeMinutes { get; set; } = 5; + + /// 改凭证类(用一次就没有)的秒数。 + public int ElevationCredentialSeconds { get; set; } = 60; + /// 会话 Cookie 带 Secure 标记(只提供 HTTPS)。本机用 HTTP 调试时才关闭。 public bool SecureCookies { get; set; } = true; @@ -35,6 +50,15 @@ public sealed class BackendOptions /// public string? CorsOrigins { get; set; } + /// + /// 可信的反向代理(逗号分隔的 IP 或 CIDR,如 127.0.0.1,172.16.0.0/12)。只有从这些地址连进来的请求,才采信 + /// 里的客户端地址(用于登录限流、审计和 SSE 限制);空 = 不信任任何转发头,用套接字地址。 + /// + public string? TrustedProxies { get; set; } + + /// 携带客户端地址的头,缺省 X-Forwarded-For(Cloudflare 单独的 CF-Connecting-IP 也可以填这里)。 + public string? TrustedProxyHeader { get; set; } + public TimeSpan SessionLifetime { get; set; } = TimeSpan.FromHours(12); public int LoginAttemptsPerMinute { get; set; } = 10; diff --git a/backend/src/MoShiro.Backend.Application/Models/AgentModels.cs b/backend/src/MoShiro.Backend.Application/Models/AgentModels.cs index 25a38af..33ef79e 100644 --- a/backend/src/MoShiro.Backend.Application/Models/AgentModels.cs +++ b/backend/src/MoShiro.Backend.Application/Models/AgentModels.cs @@ -29,7 +29,8 @@ public sealed record AgentRecord( bool PublicHostManual = false, bool Ipv6HostManual = false, IReadOnlyList? ReportedAddresses = null, - byte[]? TlsPublicKey = null); + byte[]? TlsPublicKey = null, + string? AddedBy = null); /// 管理员可以修改的字段(merge-patch 语义:为 null 的字段不改)。 public sealed class AgentPatch @@ -76,10 +77,11 @@ public sealed record JoinTokenInfo( long ExpiresAtMs, long? RevokedAtMs, long CreatedAtMs, - string Kind = ""); + string Kind = "", + 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); +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/IAuthStore.cs b/backend/src/MoShiro.Backend.Application/Ports/IAuthStore.cs index 235dcf3..5212f36 100644 --- a/backend/src/MoShiro.Backend.Application/Ports/IAuthStore.cs +++ b/backend/src/MoShiro.Backend.Application/Ports/IAuthStore.cs @@ -2,9 +2,9 @@ namespace MoShiro.Backend.Application; public sealed record UserRecord(string Id, string Username, byte[] PasswordHash, byte[] PasswordSalt, int Iterations, long CreatedAtMs); -public sealed record SessionRecord(byte[] IdSha256, string UserId, byte[] CsrfToken, long CreatedAtMs, long ExpiresAtMs); +public sealed record SessionRecord(byte[] IdSha256, string UserId, byte[] CsrfToken, long CreatedAtMs, long ExpiresAtMs, string AuthMethod = AuthMethods.Password); -public sealed record ApiTokenRecord(string Id, string Name, string Scope, byte[] TokenSha256, long CreatedAtMs, long? LastUsedAtMs, long? RevokedAtMs); +public sealed record ApiTokenRecord(string Id, string Name, IReadOnlyList Capabilities, byte[] TokenSha256, long CreatedAtMs, long? LastUsedAtMs, long? ExpiresAtMs, long? RevokedAtMs); public interface IAuthStore { @@ -31,6 +31,21 @@ public interface IAuthStore ValueTask CreateApiTokenAsync(ApiTokenRecord token, CancellationToken ct); + /// 凭证 id 已经存在时返回 false。 + ValueTask AddPasskeyAsync(PasskeyRecord passkey, CancellationToken ct); + + ValueTask FindPasskeyAsync(string credentialId, CancellationToken ct); + + ValueTask> ListPasskeysAsync(string userId, CancellationToken ct); + + /// 所有用户的 passkey(操作员集合从这里取)。 + ValueTask> ListAllPasskeysAsync(CancellationToken ct); + + /// 只能删自己的。 + ValueTask DeletePasskeyAsync(string userId, string credentialId, CancellationToken ct); + + ValueTask TouchPasskeyAsync(string credentialId, long signCount, long nowMs, CancellationToken ct); + ValueTask FindApiTokenAsync(byte[] tokenSha256, CancellationToken ct); ValueTask> ListApiTokensAsync(CancellationToken ct); diff --git a/backend/src/MoShiro.Backend.Application/Ports/StorePorts.cs b/backend/src/MoShiro.Backend.Application/Ports/StorePorts.cs index ce18a42..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, 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); @@ -144,6 +144,24 @@ public interface IEventStore ValueTask DeleteOlderThanAsync(long cutoffMs, CancellationToken ct); } +/// 操作员集合的链(passkey 签的版本,按版本号递增)。 +public interface IOperatorSetStore +{ + ValueTask> ListAsync(CancellationToken ct); + + /// 追加一个版本;版本号已经有了返回 false。 + ValueTask AddAsync(int version, SignedStatementDto signed, long nowMs, CancellationToken ct); +} + +public interface IAuditStore +{ + ValueTask AddAsync(AuditRecord record, CancellationToken ct); + + ValueTask> ListAsync(AuditQuery query, CancellationToken ct); + + ValueTask DeleteOlderThanAsync(long cutoffMs, CancellationToken ct); +} + /// 主机资源占用的历史:每个 Agent 每分钟一个样本。 public interface IHostStore { diff --git a/backend/src/MoShiro.Backend.Application/Retention/RetentionService.cs b/backend/src/MoShiro.Backend.Application/Retention/RetentionService.cs index 85448fb..81966b3 100644 --- a/backend/src/MoShiro.Backend.Application/Retention/RetentionService.cs +++ b/backend/src/MoShiro.Backend.Application/Retention/RetentionService.cs @@ -12,19 +12,21 @@ public sealed partial class RetentionService : BackgroundService private readonly IMetricStore _metrics; private readonly IEventStore _events; private readonly IAuthStore _auth; + private readonly AuditService _audit; private readonly IHostStore _hosts; private readonly SettingsService _settings; private readonly LoginRateLimiter _limiter; private readonly TimeProvider _time; private readonly ILogger _logger; - public RetentionService(IRawStore raw, IMetricStore metrics, IEventStore events, IHostStore hosts, IAuthStore auth, SettingsService settings, LoginRateLimiter limiter, TimeProvider time, ILogger logger) + public RetentionService(IRawStore raw, IMetricStore metrics, IEventStore events, IHostStore hosts, IAuthStore auth, AuditService audit, SettingsService settings, LoginRateLimiter limiter, TimeProvider time, ILogger logger) { _raw = raw; _metrics = metrics; _events = events; _hosts = hosts; _auth = auth; + _audit = audit; _settings = settings; _limiter = limiter; _time = time; @@ -69,6 +71,7 @@ public sealed partial class RetentionService : BackgroundService await _events.DeleteOlderThanAsync(nowMs - (await _settings.GetIntAsync(SettingsService.EventDays, ct) * Steps.Day), ct); await _hosts.DeleteOlderThanAsync(nowMs - (await _settings.GetIntAsync(SettingsService.HostDays, ct) * Steps.Day), ct); await _auth.DeleteExpiredSessionsAsync(nowMs, ct); + await _audit.PruneAsync(nowMs, ct); _limiter.Prune(); } 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 58cdf6c..dfe7cc0 100644 --- a/backend/src/MoShiro.Backend.Application/Tasks/TaskPlanner.cs +++ b/backend/src/MoShiro.Backend.Application/Tasks/TaskPlanner.cs @@ -21,10 +21,12 @@ public sealed class TaskPlanner : IDisposable private readonly IProbeStore _probes; private readonly ISettingsStore _settings; private readonly LinkService _links; + private readonly TrustService _trust; private readonly SemaphoreSlim _gate = new(1, 1); - public TaskPlanner(ITaskStore tasks, IAgentStore agents, IProbeStore probes, ISettingsStore settings, LinkService links) + public TaskPlanner(ITaskStore tasks, IAgentStore agents, IProbeStore probes, ISettingsStore settings, LinkService links, TrustService trust) { + _trust = trust; _links = links; _tasks = tasks; _agents = agents; @@ -65,6 +67,9 @@ public sealed class TaskPlanner : IDisposable document.Peers.Add(new PeerSpec { ProbeId = new ProbeId(probe.Id), PublicKey = Convert.ToBase64String(probe.PeerPublicKey!) }); } + IReadOnlyList chain = await _trust.ChainAsync(ct); + document.TrustChain = chain.Count == 0 ? null : [.. chain]; + string hash = Convert.ToHexStringLower(SHA256.HashData(JsonSerializer.SerializeToUtf8Bytes(document, MoShiroJsonContext.Default.TaskSet))); await _gate.WaitAsync(ct); diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs index eb95d9d..ba8a7c5 100644 --- a/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs +++ b/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs @@ -5,7 +5,7 @@ namespace MoShiro.Backend.Storage.Sqlite; /// 表结构。版本记录在 PRAGMA user_version;以后的迁移是按版本号追加的内置 SQL。 internal static class Schema { - public const int CurrentVersion = 10; + public const int CurrentVersion = 13; public static void Migrate(SqliteConnection connection) { @@ -140,6 +140,87 @@ internal static class Schema connection.Exec("PRAGMA user_version = 10"); transaction.Commit(); } + + if (version < 11) + { + // 权限从三个作用域改成细粒度的能力。破坏性变更,不做兼容:旧的 API 令牌全部作废,需要重新创建 + using SqliteTransaction transaction = connection.BeginTransaction(); + connection.Exec("DROP TABLE api_tokens"); + connection.Exec(""" + CREATE TABLE api_tokens ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + capabilities TEXT NOT NULL, + token_sha256 BLOB NOT NULL UNIQUE, + created_at INTEGER NOT NULL, + last_used_at INTEGER, + expires_at INTEGER, + revoked_at INTEGER + ) + """); + connection.Exec("PRAGMA user_version = 11"); + transaction.Commit(); + } + + if (version < 12) + { + // passkey 登录:每个用户可以注册若干 passkey(凭证公钥 SPKI、签名计数器);会话记录是怎么登录的,只有 passkey 登录的会话以后能提权 + using SqliteTransaction transaction = connection.BeginTransaction(); + connection.Exec(""" + CREATE TABLE passkeys ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + public_key BLOB NOT NULL, + sign_count INTEGER NOT NULL DEFAULT 0, + transports TEXT NOT NULL DEFAULT '[]', + created_at INTEGER NOT NULL, + last_used_at INTEGER + ) + """); + connection.Exec("CREATE INDEX ix_passkeys_user ON passkeys (user_id)"); + connection.Exec("ALTER TABLE sessions ADD COLUMN auth_method TEXT NOT NULL DEFAULT 'password'"); + connection.Exec("PRAGMA user_version = 12"); + transaction.Commit(); + } + + if (version < 13) + { + // 归属:加入码是谁创建的,节点是用哪个加入码(也就是谁)加进来的;审计日志 + using SqliteTransaction transaction = connection.BeginTransaction(); + connection.Exec("ALTER TABLE join_tokens ADD COLUMN created_by TEXT"); + connection.Exec("ALTER TABLE agents ADD COLUMN added_by TEXT"); + connection.Exec("ALTER TABLE probes ADD COLUMN added_by TEXT"); + connection.Exec(""" + CREATE TABLE audit_log ( + id INTEGER PRIMARY KEY, + at INTEGER NOT NULL, + user_id TEXT, + actor TEXT NOT NULL, + method TEXT NOT NULL, + action TEXT NOT NULL, + target TEXT, + detail TEXT, + remote TEXT, + success INTEGER NOT NULL + ) + """); + connection.Exec("CREATE INDEX ix_audit_time ON audit_log (at)"); + connection.Exec( + """ + CREATE TABLE operator_sets ( + version INTEGER PRIMARY KEY, + statement TEXT NOT NULL, + credential_id TEXT NOT NULL, + authenticator_data TEXT NOT NULL, + client_data_json TEXT NOT NULL, + signature TEXT NOT NULL, + created_at INTEGER NOT NULL + ) + """); + 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 2b4e11f..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"; + "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 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)), + "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) VALUES (@id, @name, @status, @pubkey, @spki, @labels, @meta, @version, @now)", + "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)); + ("@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) VALUES (@id, @name, @pubkey, @peer, @now)", - ("@id", node.Id.Value), ("@name", node.Name), ("@pubkey", node.PublicKey), ("@peer", peerKey), ("@now", node.NowMs)); + "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)); } @@ -267,7 +267,8 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore r.GetInt64(22) != 0, r.GetInt64(23) != 0, StorageJson.Strings(r.GetString(24)), - r.NullableBytes(25)); + r.NullableBytes(25), + 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/SqliteAuditStore.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAuditStore.cs new file mode 100644 index 0000000..cbeb7d7 --- /dev/null +++ b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAuditStore.cs @@ -0,0 +1,33 @@ +using MoShiro.Backend.Application; + +namespace MoShiro.Backend.Storage.Sqlite; + +public sealed class SqliteAuditStore : IAuditStore +{ + private readonly SqliteDatabase _db; + + public SqliteAuditStore(SqliteDatabase db) + { + _db = db; + } + + public ValueTask AddAsync(AuditRecord record, CancellationToken ct) => + _db.ExecuteAsync( + c => c.Exec( + "INSERT INTO audit_log (at, user_id, actor, method, action, target, detail, remote, success) VALUES (@at, @uid, @actor, @method, @action, @target, @detail, @remote, @ok)", + ("@at", record.AtMs), ("@uid", record.UserId), ("@actor", record.Actor), ("@method", record.Method), ("@action", record.Action), + ("@target", record.Target), ("@detail", record.Detail), ("@remote", record.Remote), ("@ok", record.Success ? 1 : 0)), + ct); + + public ValueTask> ListAsync(AuditQuery query, CancellationToken ct) => + _db.ReadAsync>( + c => c.Query( + "SELECT id, at, user_id, actor, method, action, target, detail, remote, success FROM audit_log " + + "WHERE (@before IS NULL OR id < @before) AND (@actor IS NULL OR actor = @actor) AND (@action IS NULL OR action = @action) ORDER BY id DESC LIMIT @limit", + r => new AuditRecord(r.GetInt64(0), r.GetInt64(1), r.NullableString(2), r.GetString(3), r.GetString(4), r.GetString(5), r.NullableString(6), r.NullableString(7), r.NullableString(8), r.GetInt64(9) != 0), + ("@before", query.BeforeId), ("@actor", query.Actor), ("@action", query.Action), ("@limit", query.Limit)), + ct); + + public ValueTask DeleteOlderThanAsync(long cutoffMs, CancellationToken ct) => + _db.WriteAsync(c => c.Exec("DELETE FROM audit_log WHERE at < @cutoff", ("@cutoff", cutoffMs)), ct); +} diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAuthStore.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAuthStore.cs index b9a7092..7b1a46f 100644 --- a/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAuthStore.cs +++ b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAuthStore.cs @@ -6,7 +6,7 @@ namespace MoShiro.Backend.Storage.Sqlite; public sealed class SqliteAuthStore : IAuthStore { private const string UserColumns = "id, username, password_hash, password_salt, iterations, created_at"; - private const string TokenColumns = "id, name, scope, token_sha256, created_at, last_used_at, revoked_at"; + private const string TokenColumns = "id, name, capabilities, token_sha256, created_at, last_used_at, expires_at, revoked_at"; private readonly SqliteDatabase _db; @@ -46,13 +46,13 @@ public sealed class SqliteAuthStore : IAuthStore public ValueTask CreateSessionAsync(SessionRecord session, CancellationToken ct) => _db.ExecuteAsync( c => c.Exec( - "INSERT INTO sessions (id_sha256, user_id, csrf_token, created_at, expires_at) VALUES (@id, @u, @csrf, @c, @e)", - ("@id", session.IdSha256), ("@u", session.UserId), ("@csrf", session.CsrfToken), ("@c", session.CreatedAtMs), ("@e", session.ExpiresAtMs)), + "INSERT INTO sessions (id_sha256, user_id, csrf_token, created_at, expires_at, auth_method) VALUES (@id, @u, @csrf, @c, @e, @m)", + ("@id", session.IdSha256), ("@u", session.UserId), ("@csrf", session.CsrfToken), ("@c", session.CreatedAtMs), ("@e", session.ExpiresAtMs), ("@m", session.AuthMethod)), ct); public ValueTask GetSessionAsync(byte[] idSha256, CancellationToken ct) => _db.ReadAsync( - c => c.QueryOne("SELECT id_sha256, user_id, csrf_token, created_at, expires_at FROM sessions WHERE id_sha256 = @id", r => new SessionRecord(r.Bytes(0), r.GetString(1), r.Bytes(2), r.GetInt64(3), r.GetInt64(4)), ("@id", idSha256)), + c => c.QueryOne("SELECT id_sha256, user_id, csrf_token, created_at, expires_at, auth_method FROM sessions WHERE id_sha256 = @id", r => new SessionRecord(r.Bytes(0), r.GetString(1), r.Bytes(2), r.GetInt64(3), r.GetInt64(4), r.GetString(5)), ("@id", idSha256)), ct); public ValueTask DeleteSessionAsync(byte[] idSha256, CancellationToken ct) => @@ -64,10 +64,34 @@ public sealed class SqliteAuthStore : IAuthStore public ValueTask CreateApiTokenAsync(ApiTokenRecord token, CancellationToken ct) => _db.ExecuteAsync( c => c.Exec( - "INSERT INTO api_tokens (id, name, scope, token_sha256, created_at) VALUES (@id, @n, @s, @h, @at)", - ("@id", token.Id), ("@n", token.Name), ("@s", token.Scope), ("@h", token.TokenSha256), ("@at", token.CreatedAtMs)), + "INSERT INTO api_tokens (id, name, capabilities, token_sha256, created_at, expires_at) VALUES (@id, @n, @caps, @h, @at, @exp)", + ("@id", token.Id), ("@n", token.Name), ("@caps", StorageJson.ToJson(token.Capabilities)), ("@h", token.TokenSha256), ("@at", token.CreatedAtMs), ("@exp", token.ExpiresAtMs)), ct); + public ValueTask AddPasskeyAsync(PasskeyRecord passkey, CancellationToken ct) => + _db.WriteAsync( + c => c.Scalar("SELECT COUNT(*) FROM passkeys WHERE id = @id", ("@id", passkey.Id)) > 0 + ? false + : c.Exec( + "INSERT INTO passkeys (id, user_id, name, public_key, sign_count, transports, created_at) VALUES (@id, @u, @n, @k, @s, @t, @at)", + ("@id", passkey.Id), ("@u", passkey.UserId), ("@n", passkey.Name), ("@k", passkey.PublicKeySpki), ("@s", passkey.SignCount), ("@t", StorageJson.ToJson(passkey.Transports)), ("@at", passkey.CreatedAtMs)) == 1, + ct); + + public ValueTask FindPasskeyAsync(string credentialId, CancellationToken ct) => + _db.ReadAsync(c => c.QueryOne($"SELECT {PasskeyColumns} FROM passkeys WHERE id = @id", ReadPasskey, ("@id", credentialId)), ct); + + public ValueTask> ListPasskeysAsync(string userId, CancellationToken ct) => + _db.ReadAsync>(c => c.Query($"SELECT {PasskeyColumns} FROM passkeys WHERE user_id = @u ORDER BY created_at, id", ReadPasskey, ("@u", userId)), ct); + + public ValueTask> ListAllPasskeysAsync(CancellationToken ct) => + _db.ReadAsync>(c => c.Query($"SELECT {PasskeyColumns} FROM passkeys ORDER BY created_at, id", ReadPasskey), ct); + + public ValueTask DeletePasskeyAsync(string userId, string credentialId, CancellationToken ct) => + _db.WriteAsync(c => c.Exec("DELETE FROM passkeys WHERE id = @id AND user_id = @u", ("@id", credentialId), ("@u", userId)) == 1, ct); + + public ValueTask TouchPasskeyAsync(string credentialId, long signCount, long nowMs, CancellationToken ct) => + _db.ExecuteAsync(c => c.Exec("UPDATE passkeys SET sign_count = @s, last_used_at = @now WHERE id = @id", ("@id", credentialId), ("@s", signCount), ("@now", nowMs)), ct); + public ValueTask FindApiTokenAsync(byte[] tokenSha256, CancellationToken ct) => _db.ReadAsync(c => c.QueryOne($"SELECT {TokenColumns} FROM api_tokens WHERE token_sha256 = @h", ReadToken, ("@h", tokenSha256)), ct); @@ -80,7 +104,11 @@ public sealed class SqliteAuthStore : IAuthStore public ValueTask TouchApiTokenAsync(string id, long nowMs, CancellationToken ct) => _db.ExecuteAsync(c => c.Exec("UPDATE api_tokens SET last_used_at = @now WHERE id = @id", ("@id", id), ("@now", nowMs)), ct); + private const string PasskeyColumns = "id, user_id, name, public_key, sign_count, transports, created_at, last_used_at"; + + private static PasskeyRecord ReadPasskey(SqliteDataReader r) => new(r.GetString(0), r.GetString(1), r.GetString(2), r.Bytes(3), r.GetInt64(4), StorageJson.Strings(r.GetString(5)), r.GetInt64(6), r.NullableLong(7)); + private static UserRecord ReadUser(SqliteDataReader r) => new(r.GetString(0), r.GetString(1), r.Bytes(2), r.Bytes(3), r.GetInt32(4), r.GetInt64(5)); - private static ApiTokenRecord ReadToken(SqliteDataReader r) => new(r.GetString(0), r.GetString(1), r.GetString(2), r.Bytes(3), r.GetInt64(4), r.NullableLong(5), r.NullableLong(6)); + private static ApiTokenRecord ReadToken(SqliteDataReader r) => new(r.GetString(0), r.GetString(1), StorageJson.Strings(r.GetString(2)), r.Bytes(3), r.GetInt64(4), r.NullableLong(5), r.NullableLong(6), r.NullableLong(7)); } diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteConfigStores.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteConfigStores.cs index 40cf6b3..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, 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) VALUES (@id, @h, @note, @labels, @auto, @kind, @max, @exp, @now)", + "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)); - return new JoinTokenInfo(id, note, labels, autoApprove, maxUses, 0, expiresAtMs, null, nowMs, kind); + ("@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 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))), + "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 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)), + "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 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))), + 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 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)), ("@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/SqliteOperatorSetStore.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteOperatorSetStore.cs new file mode 100644 index 0000000..5aa0dbd --- /dev/null +++ b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteOperatorSetStore.cs @@ -0,0 +1,28 @@ +using Fleet.Core; +using MoShiro.Backend.Application; + +namespace MoShiro.Backend.Storage.Sqlite; + +public sealed class SqliteOperatorSetStore : IOperatorSetStore +{ + private readonly SqliteDatabase _db; + + public SqliteOperatorSetStore(SqliteDatabase db) + { + _db = db; + } + + public ValueTask> ListAsync(CancellationToken ct) => + _db.ReadAsync>( + c => c.Query( + "SELECT statement, credential_id, authenticator_data, client_data_json, signature FROM operator_sets ORDER BY version", + r => new SignedStatementDto { Statement = r.GetString(0), CredentialId = r.GetString(1), AuthenticatorData = r.GetString(2), ClientDataJson = r.GetString(3), Signature = r.GetString(4) }), + ct); + + public ValueTask AddAsync(int version, SignedStatementDto signed, long nowMs, CancellationToken ct) => + _db.WriteAsync( + c => c.Exec( + "INSERT OR IGNORE INTO operator_sets (version, statement, credential_id, authenticator_data, client_data_json, signature, created_at) VALUES (@v, @s, @c, @a, @d, @g, @at)", + ("@v", version), ("@s", signed.Statement), ("@c", signed.CredentialId), ("@a", signed.AuthenticatorData), ("@d", signed.ClientDataJson), ("@g", signed.Signature), ("@at", nowMs)) == 1, + ct); +} diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/StorageServiceCollectionExtensions.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/StorageServiceCollectionExtensions.cs index bf05c7f..5370a80 100644 --- a/backend/src/MoShiro.Backend.Storage.Sqlite/StorageServiceCollectionExtensions.cs +++ b/backend/src/MoShiro.Backend.Storage.Sqlite/StorageServiceCollectionExtensions.cs @@ -22,6 +22,8 @@ public static class StorageServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/backend/tests/MoShiro.Backend.Tests/AgentHarness.cs b/backend/tests/MoShiro.Backend.Tests/AgentHarness.cs index cd63e15..2a4854b 100644 --- a/backend/tests/MoShiro.Backend.Tests/AgentHarness.cs +++ b/backend/tests/MoShiro.Backend.Tests/AgentHarness.cs @@ -41,12 +41,14 @@ public sealed class AgentHarness : IAsyncDisposable public byte[] TlsPublicKey => _identity.Certificate.PublicKey.ExportSubjectPublicKeyInfo(); - public static async Task StartAsync(BackendFixture backend, Action? configure = null, string? name = 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 = 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); + Result enrolled = await EnrollmentClient.EnrollAsync( + 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/AotSmokeTests.cs b/backend/tests/MoShiro.Backend.Tests/AotSmokeTests.cs index 708f428..d07c39c 100644 --- a/backend/tests/MoShiro.Backend.Tests/AotSmokeTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/AotSmokeTests.cs @@ -27,7 +27,7 @@ public sealed class AotSmokeTests login.EnsureSuccessStatusCode(); string cookie = login.Headers.GetValues("Set-Cookie").Single().Split(';')[0]; string csrf = JsonNode.Parse(await login.Content.ReadAsStringAsync())!["csrf_token"]!.GetValue(); - using var tokenRequest = new HttpRequestMessage(HttpMethod.Post, "/api/admin/api-tokens") { Content = BackendFixture.Json(new JsonObject { ["name"] = "aot", ["scope"] = "admin" }) }; + using var tokenRequest = new HttpRequestMessage(HttpMethod.Post, "/api/admin/api-tokens") { Content = BackendFixture.Json(new JsonObject { ["name"] = "aot", ["capabilities"] = BackendFixture.AllCapabilities() }) }; tokenRequest.Headers.Add("Cookie", cookie); tokenRequest.Headers.Add("X-CSRF-Token", csrf); using HttpResponseMessage tokenResponse = await http.SendAsync(tokenRequest); diff --git a/backend/tests/MoShiro.Backend.Tests/ApiBasicsTests.cs b/backend/tests/MoShiro.Backend.Tests/ApiBasicsTests.cs index d6bbc84..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,9 +76,9 @@ 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/join-tokens") { Content = BackendFixture.Json(new JsonObject()) }; + 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); using HttpResponseMessage created = await client.SendAsync(create); Assert.Equal(HttpStatusCode.Created, created.StatusCode); @@ -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]; @@ -104,19 +109,19 @@ public sealed class ApiBasicsTests using HttpResponseMessage me = await client.SendAsync(get); Assert.Equal(HttpStatusCode.OK, me.StatusCode); - using var withoutCsrf = new HttpRequestMessage(HttpMethod.Post, "/api/admin/join-tokens") { Content = BackendFixture.Json(new JsonObject()) }; + using var withoutCsrf = new HttpRequestMessage(HttpMethod.Post, "/api/admin/api-tokens") { Content = BackendFixture.Json(new JsonObject { ["name"] = "session-write", ["capabilities"] = new JsonArray("fleet.read") }) }; withoutCsrf.Headers.Add("Cookie", cookie); using HttpResponseMessage rejected = await client.SendAsync(withoutCsrf); Assert.Equal(HttpStatusCode.Unauthorized, rejected.StatusCode); Assert.Equal("csrf_failed", JsonNode.Parse(await rejected.Content.ReadAsStringAsync())!["code"]!.GetValue()); - using var wrongCsrf = new HttpRequestMessage(HttpMethod.Post, "/api/admin/join-tokens") { Content = BackendFixture.Json(new JsonObject()) }; + using var wrongCsrf = new HttpRequestMessage(HttpMethod.Post, "/api/admin/api-tokens") { Content = BackendFixture.Json(new JsonObject { ["name"] = "session-write", ["capabilities"] = new JsonArray("fleet.read") }) }; wrongCsrf.Headers.Add("Cookie", cookie); wrongCsrf.Headers.Add("X-CSRF-Token", csrf + "x"); using HttpResponseMessage wrong = await client.SendAsync(wrongCsrf); Assert.Equal(HttpStatusCode.Unauthorized, wrong.StatusCode); - using var good = new HttpRequestMessage(HttpMethod.Post, "/api/admin/join-tokens") { Content = BackendFixture.Json(new JsonObject()) }; + using var good = new HttpRequestMessage(HttpMethod.Post, "/api/admin/api-tokens") { Content = BackendFixture.Json(new JsonObject { ["name"] = "session-write", ["capabilities"] = new JsonArray("fleet.read") }) }; good.Headers.Add("Cookie", cookie); good.Headers.Add("X-CSRF-Token", csrf); using HttpResponseMessage created = await client.SendAsync(good); @@ -124,9 +129,9 @@ public sealed class ApiBasicsTests } [Fact] - public async Task ReadScopedToken_CanReadButNotManage() + public async Task PublicReadToken_CanReadThePublicApiButNothingUnderAdmin() { - using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/api-tokens", BackendFixture.Json(new JsonObject { ["name"] = "viewer", ["scope"] = "read" })); + using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/api-tokens", BackendFixture.Json(new JsonObject { ["name"] = "viewer", ["capabilities"] = BackendFixture.Capabilities("public.read") })); Assert.Equal(HttpStatusCode.Created, created.StatusCode); Assert.Equal("no-store", created.Headers.CacheControl!.ToString()); string token = JsonNode.Parse(await created.Content.ReadAsStringAsync())!["plaintext"]!.GetValue(); @@ -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/AuditTests.cs b/backend/tests/MoShiro.Backend.Tests/AuditTests.cs new file mode 100644 index 0000000..9d1f5ec --- /dev/null +++ b/backend/tests/MoShiro.Backend.Tests/AuditTests.cs @@ -0,0 +1,68 @@ +using System.Net; +using System.Text.Json.Nodes; + +namespace MoShiro.Backend.Tests; + +/// 审计日志:写操作、登录成败都会记一行;谁做的(用户名或 key:令牌名)、结果、细节。 +[Collection("probe-e2e")] +public sealed class AuditTests +{ + private readonly BackendFixture _backend; + + public AuditTests(BackendFixture backend) + { + _backend = backend; + } + + /// 审计在响应发完之后才写,轮询一下。 + private async Task FindAsync(string query, Func match) + { + for (int i = 0; i < 50; i++) + { + JsonNode page = JsonNode.Parse(await _backend.Admin.GetStringAsync("/api/admin/audit?" + query))!; + if (page["items"]!.AsArray().FirstOrDefault(x => x is not null && match(x)) is { } found) + { + return found; + } + + await Task.Delay(100); + } + + return null; + } + + [Fact] + public async Task WriteActions_AreRecorded_WithWhoAndTheDetail() + { + string note = "audit-" + Guid.NewGuid().ToString("N")[..8]; + using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/join-tokens", BackendFixture.Json(new JsonObject { ["role"] = "probe", ["note"] = note })); + Assert.Equal(HttpStatusCode.Created, created.StatusCode); + + JsonNode? row = await FindAsync("limit=100", x => x["detail"]?.GetValue().Contains(note, StringComparison.Ordinal) == true); + Assert.NotNull(row); + Assert.StartsWith("key:", row["actor"]!.GetValue(), StringComparison.Ordinal); + Assert.Equal("key", row["method"]!.GetValue()); + Assert.True(row["success"]!.GetValue()); + Assert.False(string.IsNullOrEmpty(row["action"]!.GetValue())); + } + + [Fact] + public async Task FailedSignIn_IsRecorded_WithTheAttemptedName_AndWithoutThePassword() + { + string name = "nobody-" + Guid.NewGuid().ToString("N")[..8]; + using HttpResponseMessage denied = await _backend.Anonymous.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = name, ["password"] = "secret-pw-123" })); + Assert.Equal(HttpStatusCode.Unauthorized, denied.StatusCode); + + JsonNode? row = await FindAsync("action=auth.login&limit=100", x => x["actor"]!.GetValue() == name); + Assert.NotNull(row); + Assert.False(row["success"]!.GetValue()); + Assert.DoesNotContain("secret-pw-123", row.ToJsonString(), StringComparison.Ordinal); + } + + [Fact] + public async Task Audit_NeedsItsOwnCapability() + { + using HttpResponseMessage response = await _backend.Anonymous.GetAsync("/api/admin/audit"); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } +} diff --git a/backend/tests/MoShiro.Backend.Tests/BackendFixture.cs b/backend/tests/MoShiro.Backend.Tests/BackendFixture.cs index e0c8675..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)}"; @@ -173,6 +213,11 @@ public class BackendFixture : IAsyncLifetime return JsonNode.Parse(text)!; } + /// 目录里的全部能力("all" 预设)。 + public static JsonArray AllCapabilities() => Capabilities(CapabilityCatalog.Presets.Single(p => p.Id == "all").Capabilities.ToArray()); + + public static JsonArray Capabilities(params string[] ids) => [.. ids.Select(id => (JsonNode?)JsonValue.Create(id))]; + public static StringContent Json(JsonNode node, string mediaType = "application/json") => new(node.ToJsonString(), Encoding.UTF8, mediaType); @@ -228,19 +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]; + session.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", JsonNode.Parse(await login.Content.ReadAsStringAsync())!["session_token"]!.GetValue()); - using var request = new HttpRequestMessage(HttpMethod.Post, "/api/admin/api-tokens") { Content = Json(new JsonObject { ["name"] = "tests", ["scope"] = "admin" }) }; - request.Headers.Add("Cookie", cookie); - request.Headers.Add("X-CSRF-Token", body["csrf_token"]!.GetValue()); - using HttpResponseMessage created = await session.SendAsync(request); + string origin = BaseAddress.GetLeftPart(UriPartial.Authority); + using var passkey = new SoftwarePasskey(BaseAddress.Host, origin); + 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("fixture", challenge))); + registered.EnsureSuccessStatusCode(); + } + + // 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 strong.PostAsync("/api/admin/auth/elevation", Json(assertion)); + elevated.EnsureSuccessStatusCode(); + } + + 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 new file mode 100644 index 0000000..118d1f7 --- /dev/null +++ b/backend/tests/MoShiro.Backend.Tests/CapabilityTests.cs @@ -0,0 +1,132 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text.Json.Nodes; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using MoShiro.Backend.Api; +using MoShiro.Backend.Application; + +namespace MoShiro.Backend.Tests; + +/// 能力模型(docs/access.md 第 2 节):每个管理端点声明能力;密钥只能拿到自己拥有能力的子集;写蕴含读。 +[Collection("probe-e2e")] +public sealed class CapabilityTests +{ + private readonly BackendFixture _backend; + + public CapabilityTests(BackendFixture backend) + { + _backend = backend; + } + + [Fact] + public void EveryAdminEndpoint_DeclaresACapabilityThatIsInTheCatalog() + { + RouteEndpoint[] endpoints = [.. _backend.Services.GetServices().SelectMany(s => s.Endpoints).OfType() + .Where(e => e.RoutePattern.RawText?.StartsWith("/api/admin", StringComparison.Ordinal) == true)]; + Assert.NotEmpty(endpoints); + foreach (RouteEndpoint endpoint in endpoints) + { + CapabilityRequirement? requirement = endpoint.Metadata.GetMetadata(); + Assert.True(requirement is not null, $"{endpoint.RoutePattern.RawText} does not declare a capability"); + if (requirement.Capability is { } capability) + { + Assert.True(capability == Capability.Authenticated || CapabilityCatalog.IsKnown(capability), $"{endpoint.RoutePattern.RawText} declares the unknown capability {capability}"); + } + } + + // 只有登录(密码和 passkey)是匿名的 + Assert.Equal(["/api/admin/auth/login", "/api/admin/auth/passkey/login", "/api/admin/auth/passkey/options"], endpoints.Where(e => e.Metadata.GetMetadata()!.Capability is null).Select(e => e.RoutePattern.RawText!).Distinct().Order(StringComparer.Ordinal).ToArray()); + } + + [Fact] + public void Catalog_ExpandsWritesToReads_AndPresetsAreConsistent() + { + Assert.Equal([Capability.FleetRead, Capability.FleetWrite], CapabilityCatalog.Expand([Capability.FleetWrite])); + Assert.Contains(Capability.FleetRead, CapabilityCatalog.Expand([Capability.FleetEnroll])); + foreach (CapabilityPreset preset in CapabilityCatalog.Presets) + { + Assert.All(preset.Capabilities, id => Assert.True(CapabilityCatalog.IsKnown(id), $"{preset.Id}: {id}")); + Assert.Equal(CapabilityCatalog.Expand(preset.Capabilities).ToArray(), preset.Capabilities.Order(StringComparer.Ordinal).ToArray()); // 预设已经包含了蕴含的能力 + } + + Assert.All(CapabilityCatalog.All.Where(c => c.Id.EndsWith(".write", StringComparison.Ordinal)), c => Assert.Contains(c.Id[..^"write".Length] + "read", c.Implies)); + } + + private async Task KeyAsync(params string[] capabilities) + { + using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/api-tokens", BackendFixture.Json(new JsonObject { ["name"] = _backend.UniqueName("cap"), ["capabilities"] = BackendFixture.Capabilities(capabilities) })); + string body = await created.Content.ReadAsStringAsync(); + Assert.True(created.StatusCode == HttpStatusCode.Created, body); + HttpClient client = _backend.NewClient(); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", JsonNode.Parse(body)!["plaintext"]!.GetValue()); + return client; + } + + [Fact] + public async Task AKey_CanOnlyDoWhatItsCapabilitiesAllow_AndWriteImpliesRead() + { + using HttpClient reader = await KeyAsync(Capability.FleetRead); + using HttpClient writer = await KeyAsync(Capability.FleetWrite); // 写蕴含读 + using HttpClient settings = await KeyAsync(Capability.SettingsRead); + + Assert.Equal(HttpStatusCode.OK, (await reader.GetAsync("/api/admin/agents")).StatusCode); + Assert.Equal(HttpStatusCode.OK, (await writer.GetAsync("/api/admin/agents")).StatusCode); + Assert.Equal(HttpStatusCode.Forbidden, (await settings.GetAsync("/api/admin/agents")).StatusCode); + Assert.Equal(HttpStatusCode.Forbidden, (await reader.GetAsync("/api/admin/settings")).StatusCode); + Assert.Equal(HttpStatusCode.Forbidden, (await reader.GetAsync("/api/admin/raw/chunks")).StatusCode); + + // 读不能写:fleet.read 的 key 不能生成加入代码,也不能创建 key + using HttpResponseMessage enroll = await reader.PostAsync("/api/admin/join-tokens", BackendFixture.Json(new JsonObject { ["role"] = "agent" })); + Assert.Equal(HttpStatusCode.Forbidden, enroll.StatusCode); + using HttpResponseMessage keys = await reader.PostAsync("/api/admin/api-tokens", BackendFixture.Json(new JsonObject { ["name"] = "x", ["capabilities"] = BackendFixture.Capabilities(Capability.FleetRead) })); + Assert.Equal(HttpStatusCode.Forbidden, keys.StatusCode); + + // 没有凭证是 401,凭证不够是 403 + Assert.Equal(HttpStatusCode.Unauthorized, (await _backend.Anonymous.GetAsync("/api/admin/agents")).StatusCode); + // /auth/me 列出实际拥有的能力(包含蕴含的) + JsonNode me = JsonNode.Parse(await (await writer.GetAsync("/api/admin/auth/me")).Content.ReadAsStringAsync())!; + Assert.Equal([Capability.FleetRead, Capability.FleetWrite], me["capabilities"]!.AsArray().Select(c => c!.GetValue()).ToArray()); + } + + [Fact] + public async Task AKey_CannotGrantWhatItDoesNotHave_ButCanGrantASubset() + { + using HttpClient manager = await KeyAsync(Capability.KeysManage, Capability.FleetRead); // keys.manage 蕴含 keys.read + using HttpResponseMessage beyond = await manager.PostAsync("/api/admin/api-tokens", BackendFixture.Json(new JsonObject { ["name"] = "x", ["capabilities"] = BackendFixture.Capabilities(Capability.FleetRead, Capability.FleetEnroll) })); + Assert.Equal(HttpStatusCode.BadRequest, beyond.StatusCode); + Assert.Contains("fleet.enroll", await beyond.Content.ReadAsStringAsync(), StringComparison.Ordinal); + + using HttpResponseMessage subset = await manager.PostAsync("/api/admin/api-tokens", BackendFixture.Json(new JsonObject { ["name"] = "sub", ["capabilities"] = BackendFixture.Capabilities(Capability.FleetRead) })); + Assert.Equal(HttpStatusCode.Created, subset.StatusCode); + + using HttpResponseMessage unknown = await _backend.Admin.PostAsync("/api/admin/api-tokens", BackendFixture.Json(new JsonObject { ["name"] = "x", ["capabilities"] = BackendFixture.Capabilities("fleet.nope") })); + Assert.Equal(HttpStatusCode.BadRequest, unknown.StatusCode); + using HttpResponseMessage empty = await _backend.Admin.PostAsync("/api/admin/api-tokens", BackendFixture.Json(new JsonObject { ["name"] = "x", ["capabilities"] = new JsonArray() })); + Assert.Equal(HttpStatusCode.BadRequest, empty.StatusCode); + } + + [Fact] + public async Task AKey_HasAnExpiry_AndTheCatalogEndpointDescribesEverything() + { + using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/api-tokens", BackendFixture.Json(new JsonObject { ["name"] = "short", ["capabilities"] = BackendFixture.Capabilities(Capability.FleetRead), ["expires_in_days"] = 1 })); + JsonNode body = JsonNode.Parse(await created.Content.ReadAsStringAsync())!; + Assert.NotNull(body["token"]!["expires_at"]); + using HttpResponseMessage bad = await _backend.Admin.PostAsync("/api/admin/api-tokens", BackendFixture.Json(new JsonObject { ["name"] = "x", ["capabilities"] = BackendFixture.Capabilities(Capability.FleetRead), ["expires_in_days"] = 0 })); + Assert.Equal(HttpStatusCode.BadRequest, bad.StatusCode); + + JsonNode catalog = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/capabilities"); + 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.FleetRevoke && c["trust"]!.GetValue()); + } + + [Fact] + public async Task ReleaseKey_CanOnlyPublishReleases() + { + using HttpClient ci = await KeyAsync(Capability.ReleasePublish); + Assert.Equal(HttpStatusCode.Forbidden, (await ci.GetAsync("/api/admin/agents")).StatusCode); + Assert.Equal(HttpStatusCode.Forbidden, (await ci.GetAsync("/api/admin/backend/update")).StatusCode); + } +} diff --git a/backend/tests/MoShiro.Backend.Tests/ClientAddressTests.cs b/backend/tests/MoShiro.Backend.Tests/ClientAddressTests.cs new file mode 100644 index 0000000..6e785d8 --- /dev/null +++ b/backend/tests/MoShiro.Backend.Tests/ClientAddressTests.cs @@ -0,0 +1,51 @@ +using System.Net; +using Microsoft.AspNetCore.Http; +using MoShiro.Backend.Api; +using MoShiro.Backend.Application; + +namespace MoShiro.Backend.Tests; + +/// 真实来源地址:只有从可信代理连进来的请求才采信转发头,而且取最右边第一个不可信的地址。 +public sealed class ClientAddressTests +{ + private static string Resolve(string? trusted, string remote, string? forwarded, string? header = null) + { + var resolver = new ClientAddressResolver(new BackendOptions { TrustedProxies = trusted, TrustedProxyHeader = header }); + var http = new DefaultHttpContext(); + http.Connection.RemoteIpAddress = IPAddress.Parse(remote); + if (forwarded is not null) + { + http.Request.Headers[header ?? "X-Forwarded-For"] = forwarded; + } + + return resolver.Resolve(http); + } + + [Fact] + public void WithoutTrustedProxies_TheSocketAddressIsUsed_AndTheHeaderIsIgnored() => + Assert.Equal("203.0.113.9", Resolve(null, "203.0.113.9", "1.2.3.4")); + + [Fact] + public void FromATrustedProxy_TheForwardedClientIsUsed() => + Assert.Equal("198.51.100.7", Resolve("10.0.0.0/8", "10.1.2.3", "198.51.100.7")); + + [Fact] + public void FromAnUntrustedPeer_TheHeaderIsIgnored() => + Assert.Equal("203.0.113.9", Resolve("10.0.0.0/8", "203.0.113.9", "1.2.3.4")); + + [Fact] + public void TheChainIsWalkedFromTheRight_SoAClientCannotForgeItsAddress() => + Assert.Equal("198.51.100.7", Resolve("10.0.0.0/8,127.0.0.1", "10.1.2.3", "6.6.6.6, 198.51.100.7, 10.9.9.9")); + + [Fact] + public void AGarbageHeader_FallsBackToTheSocketAddress() => + Assert.Equal("10.1.2.3", Resolve("10.0.0.0/8", "10.1.2.3", "not-an-ip")); + + [Fact] + public void ASingleAddressAndACustomHeaderWork() => + Assert.Equal("198.51.100.7", Resolve("127.0.0.1", "127.0.0.1", "198.51.100.7", "CF-Connecting-IP")); + + [Fact] + public void AMalformedTrustedProxyEntry_FailsAtStartup() => + Assert.Throws(() => new ClientAddressResolver(new BackendOptions { TrustedProxies = "banana" })); +} diff --git a/backend/tests/MoShiro.Backend.Tests/ElevationTests.cs b/backend/tests/MoShiro.Backend.Tests/ElevationTests.cs new file mode 100644 index 0000000..d34c93b --- /dev/null +++ b/backend/tests/MoShiro.Backend.Tests/ElevationTests.cs @@ -0,0 +1,201 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text.Json.Nodes; + +namespace MoShiro.Backend.Tests; + +/// 提权窗口:信任类操作(加节点、吊销、改凭证)要在登录之外再触碰一次 passkey;API key 自带能力不受影响。设计见 docs/access.md 第 4 节。 +[Collection("probe-e2e")] +public sealed class ElevationTests +{ + private const string Password = "correct horse battery"; + + private readonly BackendFixture _backend; + + public ElevationTests(BackendFixture backend) + { + _backend = backend; + } + + private string Origin => _backend.BaseAddress.GetLeftPart(UriPartial.Authority); + + private string RpId => _backend.BaseAddress.Host; + + /// 新建一个用户并用密码登录(会话令牌走 Bearer)。每个测试用自己的用户,互不影响。 + private async Task NewSessionAsync() + { + string name = _backend.UniqueName("elev"); + using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/users", BackendFixture.Json(new JsonObject { ["username"] = name, ["password"] = Password })); + Assert.Equal(HttpStatusCode.Created, created.StatusCode); + HttpClient client = _backend.NewClient(); + using HttpResponseMessage login = await client.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = name, ["password"] = Password, ["return_session"] = true })); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", JsonNode.Parse(await login.Content.ReadAsStringAsync())!["session_token"]!.GetValue()); + 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); + using HttpResponseMessage options = await session.PostAsync("/api/admin/auth/passkeys/options", null); + Assert.True(options.IsSuccessStatusCode, await options.Content.ReadAsStringAsync()); + string challenge = JsonNode.Parse(await options.Content.ReadAsStringAsync())!["challenge"]!.GetValue(); + using HttpResponseMessage registered = await session.PostAsync("/api/admin/auth/passkeys", BackendFixture.Json(passkey.Register(name, challenge))); + Assert.True(registered.StatusCode == HttpStatusCode.Created, await registered.Content.ReadAsStringAsync()); + return passkey; + } + + private async Task ElevateAsync(HttpClient session, SoftwarePasskey passkey, string cls, string? optionsFor = null) + { + using HttpResponseMessage options = await session.PostAsync("/api/admin/auth/elevation/options", BackendFixture.Json(new JsonObject { ["class"] = optionsFor ?? cls })); + Assert.True(options.IsSuccessStatusCode, await options.Content.ReadAsStringAsync()); + JsonNode body = JsonNode.Parse(await options.Content.ReadAsStringAsync())!; + Assert.Contains(passkey.Id, body["allow_credentials"]!.AsArray().Select(x => x!.GetValue())); // 浏览器只会让用户选自己的 passkey + JsonObject assertion = passkey.Assert(body["challenge"]!.GetValue()); + assertion["class"] = cls; + return await session.PostAsync("/api/admin/auth/elevation", BackendFixture.Json(assertion)); + } + + private static async Task ProblemCodeAsync(HttpResponseMessage response) => JsonNode.Parse(await response.Content.ReadAsStringAsync())?["code"]?.GetValue(); + + /// 需要「吊销」窗口的操作(删除节点):有窗口时返回 204(删除是幂等的),没有窗口返回 403 elevation_required。 + private static Task Gated(HttpClient client) => client.DeleteAsync("/api/admin/agents/agt_nobody"); + + [Fact] + public async Task EnrollingNeedsAWindow_ThatOnlyAPasskeyTouchOpens() + { + (HttpClient session, SoftwarePasskey passkey) = await StrongAsync(); + using HttpClient sessionScope = session; + using SoftwarePasskey passkeyScope = passkey; + + using HttpResponseMessage denied = await Gated(session); + Assert.Equal(HttpStatusCode.Forbidden, denied.StatusCode); + Assert.Equal("elevation_required", await ProblemCodeAsync(denied)); + Assert.Contains("revoke", await denied.Content.ReadAsStringAsync(), StringComparison.Ordinal); + + using HttpResponseMessage elevated = await ElevateAsync(session, passkey, "revoke"); + Assert.Equal(HttpStatusCode.OK, elevated.StatusCode); + Assert.Equal(HttpStatusCode.NoContent, (await Gated(session)).StatusCode); // 有窗口:到了业务层(删除是幂等的) + Assert.Equal(HttpStatusCode.NoContent, (await Gated(session)).StatusCode); // 窗口内不用再触碰 + + // 窗口只管它那一种 + 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 allowed = await Gated(_backend.Admin); + Assert.Equal(HttpStatusCode.NoContent, allowed.StatusCode); // API key 自带能力,不经过提权窗口 + } + + [Fact] + public async Task TheWindowCanBeListed_AndEndedEarly() + { + (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, "revoke")).Dispose(); + JsonArray open = JsonNode.Parse(await session.GetStringAsync("/api/admin/auth/elevation"))!["items"]!.AsArray(); + 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 Gated(session)).StatusCode); + } + + [Fact] + public async Task ACredentialWindow_IsSingleUse() + { + (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 })); + Assert.Equal(HttpStatusCode.Created, first.StatusCode); + using HttpResponseMessage second = await session.PostAsync("/api/admin/users", BackendFixture.Json(new JsonObject { ["username"] = _backend.UniqueName("made"), ["password"] = Password })); + Assert.Equal("elevation_required", await ProblemCodeAsync(second)); + } + + [Fact] + public async Task OnlyYourOwnPasskey_WithTheRightChallengeAndKind_OpensAWindow() + { + (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" })); + string challenge = JsonNode.Parse(await options.Content.ReadAsStringAsync())!["challenge"]!.GetValue(); + JsonObject foreign = theirPasskey.Assert(challenge); + foreign["class"] = "enroll"; + Assert.Equal(HttpStatusCode.Unauthorized, (await mine.PostAsync("/api/admin/auth/elevation", BackendFixture.Json(foreign))).StatusCode); + + // 挑战是给 revoke 要的,提交成 enroll:不行 + using HttpResponseMessage wrongKind = await ElevateAsync(mine, myPasskey, "enroll", optionsFor: "revoke"); + Assert.Equal(HttpStatusCode.Unauthorized, wrongKind.StatusCode); + + // 挑战只能用一次 + using HttpResponseMessage options2 = await mine.PostAsync("/api/admin/auth/elevation/options", BackendFixture.Json(new JsonObject { ["class"] = "enroll" })); + string challenge2 = JsonNode.Parse(await options2.Content.ReadAsStringAsync())!["challenge"]!.GetValue(); + JsonObject once = myPasskey.Assert(challenge2); + once["class"] = "enroll"; + Assert.Equal(HttpStatusCode.OK, (await mine.PostAsync("/api/admin/auth/elevation", BackendFixture.Json(once))).StatusCode); + Assert.Equal(HttpStatusCode.Unauthorized, (await mine.PostAsync("/api/admin/auth/elevation", BackendFixture.Json(once))).StatusCode); + + // 另一个用户的会话没有因此提权 + Assert.Equal(HttpStatusCode.Forbidden, (await Gated(theirs)).StatusCode); + } + + [Fact] + public async Task WithoutAPasskey_ThereIsNothingToTouch_AndTheFirstPasskeyNeedsNoWindow() + { + using HttpClient session = await NewSessionAsync(); + using HttpResponseMessage none = await session.PostAsync("/api/admin/auth/elevation/options", BackendFixture.Json(new JsonObject { ["class"] = "enroll" })); + Assert.Equal(HttpStatusCode.BadRequest, none.StatusCode); + + using SoftwarePasskey first = await RegisterAsync(session, "first"); // 第一个:不要求提权 + using HttpResponseMessage second = await session.PostAsync("/api/admin/auth/passkeys/options", null); + Assert.Equal("elevation_required", await ProblemCodeAsync(second)); // 第二个起要先用已有的触碰 + (await ElevateAsync(session, first, "credential")).Dispose(); + using SoftwarePasskey added = await RegisterAsync(session, "second"); + + // 删除也要窗口 + (await ElevateAsync(session, first, "credential")).Dispose(); + Assert.Equal(HttpStatusCode.NoContent, (await session.DeleteAsync("/api/admin/auth/passkeys/" + added.Id)).StatusCode); + Assert.Equal(HttpStatusCode.Forbidden, (await session.DeleteAsync("/api/admin/auth/passkeys/" + first.Id)).StatusCode); + } + + [Fact] + public async Task AKeyThatCarriesTrust_NeedsAPasskeySignatureToCreate() + { + (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); + using HttpResponseMessage denied = await session.PostAsync("/api/admin/api-tokens", BackendFixture.Json(trusted)); + Assert.Equal("elevation_required", await ProblemCodeAsync(denied)); + (await ElevateAsync(session, passkey, "credential")).Dispose(); + Assert.Equal(HttpStatusCode.Created, (await session.PostAsync("/api/admin/api-tokens", BackendFixture.Json(trusted))).StatusCode); + } + + [Fact] + public async Task ListingUsers_NeedsOnlyTheReadCapability() + { + (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 e7c21c6..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,53 +34,97 @@ 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 Script_IsNotServedForUnknownRevokedOrUsedUpCodes() + public async Task TheScripts_AreStatic_TheSameBytesForEveryone_AndNeedNoCode() { - foreach (string path in new[] { "/install", "/install?code=", "/install?code=jt_nope", "/install.ps1?code=jt_nope", "/install.sh?code=jt_nope" }) + 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" })); + Assert.True(published.StatusCode is HttpStatusCode.Created or HttpStatusCode.Conflict, await published.Content.ReadAsStringAsync()); + + string code = (await CreateAsync("probe"))["plaintext"]!.GetValue(); + 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()); + 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())); + } + + [Fact] + public async Task TheManifest_IsNotServedForUnknownRevokedOrUsedUpCodes() + { + 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("agent"); + 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?code=" + revoked["plaintext"]!.GetValue()); + 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(); - 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); + using HttpResponseMessage after = await _backend.Anonymous.GetAsync("/install/manifest?code=" + code); Assert.Equal(HttpStatusCode.NotFound, after.StatusCode); } + + [Fact] + public async Task Codes_RecordWhoCreatedThem() + { + JsonNode created = await CreateAsync("agent"); + Assert.StartsWith("key:", created["token"]!["created_by"]!.GetValue(), StringComparison.Ordinal); // Admin 客户端是 API key + } } diff --git a/backend/tests/MoShiro.Backend.Tests/OpenApiConformanceTests.cs b/backend/tests/MoShiro.Backend.Tests/OpenApiConformanceTests.cs index d976acc..cc2b24a 100644 --- a/backend/tests/MoShiro.Backend.Tests/OpenApiConformanceTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/OpenApiConformanceTests.cs @@ -53,7 +53,7 @@ public sealed partial class OpenApiConformanceTests foreach ((string path, _) in doc["paths"]!.AsObject()) { string[] prefixes = ["/api/public/", "/api/admin/", "/api/agent/v1/"]; - string[] roots = ["/healthz", "/metrics", "/install", "/install.ps1"]; + string[] roots = ["/healthz", "/metrics", "/install", "/install.ps1", "/install/manifest", "/install/manifest.sig"]; Assert.True(prefixes.Any(p => path.StartsWith(p, StringComparison.Ordinal)) || roots.Contains(path), $"{path} is outside the documented layers"); foreach (string segment in path.Split('/', StringSplitOptions.RemoveEmptyEntries)) { @@ -122,7 +122,7 @@ public sealed partial class OpenApiConformanceTests public async Task EveryAdminOperation_DocumentsItsAuthenticationFailures() { JsonObject doc = await DocumentAsync(); - foreach ((string path, string method, JsonObject operation) in Operations(doc).Where(o => o.Path.StartsWith("/api/admin/", StringComparison.Ordinal) && o.Path != "/api/admin/auth/login")) + foreach ((string path, string method, JsonObject operation) in Operations(doc).Where(o => o.Path.StartsWith("/api/admin/", StringComparison.Ordinal) && o.Path is not ("/api/admin/auth/login" or "/api/admin/auth/passkey/login" or "/api/admin/auth/passkey/options"))) { JsonObject responses = operation["responses"]!.AsObject(); Assert.True(responses.ContainsKey("401"), $"{method} {path} must document 401"); diff --git a/backend/tests/MoShiro.Backend.Tests/OperationsAndSecurityTests.cs b/backend/tests/MoShiro.Backend.Tests/OperationsAndSecurityTests.cs index 5fdd240..fb1e8ed 100644 --- a/backend/tests/MoShiro.Backend.Tests/OperationsAndSecurityTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/OperationsAndSecurityTests.cs @@ -284,7 +284,7 @@ public sealed class OperationsAndSecurityTests [Fact] public async Task ApiTokens_CanBeRevoked_AndTheirPlaintextIsNeverListed() { - using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/api-tokens", BackendFixture.Json(new JsonObject { ["name"] = "temp", ["scope"] = "read" })); + using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/api-tokens", BackendFixture.Json(new JsonObject { ["name"] = "temp", ["capabilities"] = BackendFixture.Capabilities("public.read") })); JsonNode body = JsonNode.Parse(await created.Content.ReadAsStringAsync())!; string plaintext = body["plaintext"]!.GetValue(); string id = body["token"]!["id"]!.GetValue(); diff --git a/backend/tests/MoShiro.Backend.Tests/PasskeyTests.cs b/backend/tests/MoShiro.Backend.Tests/PasskeyTests.cs new file mode 100644 index 0000000..2d85ec8 --- /dev/null +++ b/backend/tests/MoShiro.Backend.Tests/PasskeyTests.cs @@ -0,0 +1,281 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json.Nodes; +using Fleet.Core; + +namespace MoShiro.Backend.Tests; + +/// 软件实现的 WebAuthn 认证器(ES256),用来在测试里走完整的注册和登录。 +public sealed class SoftwarePasskey : IDisposable +{ + private readonly ECDsa _key = ECDsa.Create(ECCurve.NamedCurves.nistP256); + private uint _counter; + + public SoftwarePasskey(string rpId, string origin) + { + RpId = rpId; + Origin = origin; + CredentialId = RandomNumberGenerator.GetBytes(32); + } + + public string RpId { get; } + + public string Origin { get; } + + public byte[] CredentialId { get; } + + public string Id => Base64Url.Encode(CredentialId); + + public JsonObject Register(string name, string challenge, byte flags = 0x45, string? origin = null, string? rpId = null) + { + ECParameters p = _key.ExportParameters(false); + byte[] cose = [.. MapHead(5), .. Int(1), .. Int(2), .. Int(3), .. Int(-7), .. Int(-1), .. Int(1), .. Int(-2), .. Bytes(p.Q.X!), .. Int(-3), .. Bytes(p.Q.Y!)]; + byte[] authData = [.. SHA256.HashData(Encoding.UTF8.GetBytes(rpId ?? RpId)), flags, .. Counter(_counter), .. new byte[16], (byte)(CredentialId.Length >> 8), (byte)CredentialId.Length, .. CredentialId, .. cose]; + byte[] attestation = [.. MapHead(3), .. Text("fmt"), .. Text("none"), .. Text("attStmt"), .. MapHead(0), .. Text("authData"), .. Bytes(authData)]; + return new JsonObject + { + ["name"] = name, + ["credential_id"] = Id, + ["attestation_object"] = Base64Url.Encode(attestation), + ["client_data_json"] = Base64Url.Encode(ClientData("webauthn.create", challenge, origin ?? Origin)), + ["transports"] = new JsonArray("internal"), + }; + } + + public JsonObject Assert(string challenge, uint? counter = null, byte flags = 0x05, string? origin = null, string? rpId = null, bool tamper = false) + { + _counter = counter ?? _counter + 1; + byte[] authData = [.. SHA256.HashData(Encoding.UTF8.GetBytes(rpId ?? RpId)), flags, .. Counter(_counter)]; + byte[] clientData = ClientData("webauthn.get", challenge, origin ?? Origin); + byte[] signature = _key.SignData([.. authData, .. SHA256.HashData(clientData)], HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence); + if (tamper) + { + signature[^1] ^= 0x01; + } + + return new JsonObject + { + ["credential_id"] = Id, + ["authenticator_data"] = Base64Url.Encode(authData), + ["client_data_json"] = Base64Url.Encode(clientData), + ["signature"] = Base64Url.Encode(signature), + ["return_session"] = true, + }; + } + + public void Dispose() => _key.Dispose(); + + private static byte[] ClientData(string type, string challenge, string origin) => + Encoding.UTF8.GetBytes($"{{\"type\":\"{type}\",\"challenge\":\"{challenge}\",\"origin\":\"{origin}\",\"crossOrigin\":false}}"); + + private static byte[] Counter(uint value) => [(byte)(value >> 24), (byte)(value >> 16), (byte)(value >> 8), (byte)value]; + + // 最小的 CBOR 编码器:只写测试需要的几种 + private static byte[] Head(int major, int length) => + length < 24 ? [(byte)((major << 5) | length)] : length < 256 ? [(byte)((major << 5) | 24), (byte)length] : [(byte)((major << 5) | 25), (byte)(length >> 8), (byte)length]; + + private static byte[] MapHead(int count) => Head(5, count); + + private static byte[] Int(int value) => value >= 0 ? Head(0, value) : Head(1, -1 - value); + + private static byte[] Bytes(byte[] data) => [.. Head(2, data.Length), .. data]; + + private static byte[] Text(string text) => [.. Head(3, Encoding.UTF8.GetByteCount(text)), .. Encoding.UTF8.GetBytes(text)]; +} + +/// passkey 登录:注册、登录、拒绝各种错误的断言、只能管理自己的 passkey。设计见 docs/access.md 第 3 节。 +[Collection("probe-e2e")] +public sealed class PasskeyTests +{ + private readonly BackendFixture _backend; + + public PasskeyTests(BackendFixture backend) + { + _backend = backend; + } + + private string Origin => _backend.BaseAddress.GetLeftPart(UriPartial.Authority); + + private string RpId => _backend.BaseAddress.Host; + + private async Task PasswordSessionAsync(string user, string password) + { + HttpClient client = _backend.NewClient(); + using HttpResponseMessage login = await client.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = user, ["password"] = password, ["return_session"] = true })); + string body = await login.Content.ReadAsStringAsync(); + Assert.True(login.IsSuccessStatusCode, body); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", JsonNode.Parse(body)!["session_token"]!.GetValue()); + return client; + } + + private string _user = ""; + + /// 每个测试用自己新建的用户:管理员已经有 fixture 的 passkey,再加就要提权,而这里测的是第一个 passkey 的流程。 + private async Task FreshSessionAsync() + { + _user = _backend.UniqueName("pk"); + using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/users", BackendFixture.Json(new JsonObject { ["username"] = _user, ["password"] = "correct horse battery" })); + Assert.Equal(HttpStatusCode.Created, created.StatusCode); + return await PasswordSessionAsync(_user, "correct horse battery"); + } + + /// 用已有的 passkey 触碰一次,开凭证窗口(删除 passkey 需要)。 + private static async Task ElevateAsync(HttpClient session, SoftwarePasskey passkey) + { + using HttpResponseMessage options = await session.PostAsync("/api/admin/auth/elevation/options", BackendFixture.Json(new JsonObject { ["class"] = "credential" })); + JsonObject assertion = passkey.Assert((await JsonOf(options))["challenge"]!.GetValue()); + assertion["class"] = "credential"; + using HttpResponseMessage elevated = await session.PostAsync("/api/admin/auth/elevation", BackendFixture.Json(assertion)); + Assert.Equal(HttpStatusCode.OK, elevated.StatusCode); + } + + private static async Task JsonOf(HttpResponseMessage response) => JsonNode.Parse(await response.Content.ReadAsStringAsync())!; + + private async Task RegistrationChallengeAsync(HttpClient session) + { + using HttpResponseMessage options = await session.PostAsync("/api/admin/auth/passkeys/options", null); + JsonNode body = await JsonOf(options); + Assert.Equal(HttpStatusCode.OK, options.StatusCode); + Assert.Equal(RpId, body["rp_id"]!.GetValue()); + return body["challenge"]!.GetValue(); + } + + private async Task LoginChallengeAsync() + { + using HttpResponseMessage options = await _backend.Anonymous.PostAsync("/api/admin/auth/passkey/options", null); + Assert.Equal(HttpStatusCode.OK, options.StatusCode); + return (await JsonOf(options))["challenge"]!.GetValue(); + } + + private async Task RegisterAsync(HttpClient session, string name = "laptop") + { + var passkey = new SoftwarePasskey(RpId, Origin); + using HttpResponseMessage registered = await session.PostAsync("/api/admin/auth/passkeys", BackendFixture.Json(passkey.Register(name, await RegistrationChallengeAsync(session)))); + Assert.True(registered.StatusCode == HttpStatusCode.Created, await registered.Content.ReadAsStringAsync()); + return passkey; + } + + private async Task LoginStatusAsync(JsonObject assertion) + { + using HttpResponseMessage response = await _backend.Anonymous.PostAsync("/api/admin/auth/passkey/login", BackendFixture.Json(assertion)); + return response.StatusCode; + } + + [Fact] + public async Task APasskey_CanBeRegistered_ThenSignsInWithoutAUsername_AndTheSessionRemembersHowItWasMade() + { + using HttpClient session = await FreshSessionAsync(); + using SoftwarePasskey passkey = await RegisterAsync(session); + + JsonNode list = await JsonOf(await session.GetAsync("/api/admin/auth/passkeys")); + Assert.Contains(list["items"]!.AsArray(), p => p!["id"]!.GetValue() == passkey.Id && p["name"]!.GetValue() == "laptop"); + + using HttpResponseMessage login = await _backend.Anonymous.PostAsync("/api/admin/auth/passkey/login", BackendFixture.Json(passkey.Assert(await LoginChallengeAsync()))); + JsonNode body = await JsonOf(login); + Assert.Equal(HttpStatusCode.OK, login.StatusCode); + Assert.Equal(_user, body["username"]!.GetValue()); + Assert.NotNull(body["session_token"]); + Assert.Equal("no-store", login.Headers.CacheControl?.ToString()); + + // 拿这个会话令牌就是登录状态,能力和密码登录一样 + using var signedIn = new HttpClient { BaseAddress = _backend.BaseAddress }; + signedIn.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", body["session_token"]!.GetValue()); + JsonNode me = await JsonOf(await signedIn.GetAsync("/api/admin/auth/me")); + Assert.Equal(_user, me["username"]!.GetValue()); + Assert.Contains(me["capabilities"]!.AsArray(), c => c!.GetValue() == "fleet.read"); + } + + [Fact] + public async Task Assertions_AreRejected_WhenTheChallengeOriginRpOrSignatureIsWrong_OrTheChallengeIsReused() + { + using HttpClient session = await FreshSessionAsync(); + using SoftwarePasskey passkey = await RegisterAsync(session); + + Assert.Equal(HttpStatusCode.Unauthorized, await LoginStatusAsync(passkey.Assert("bm90LWEtY2hhbGxlbmdl"))); // 没发过的挑战 + Assert.Equal(HttpStatusCode.Unauthorized, await LoginStatusAsync(passkey.Assert(await LoginChallengeAsync(), origin: "https://evil.example"))); + Assert.Equal(HttpStatusCode.Unauthorized, await LoginStatusAsync(passkey.Assert(await LoginChallengeAsync(), rpId: "evil.example"))); + Assert.Equal(HttpStatusCode.Unauthorized, await LoginStatusAsync(passkey.Assert(await LoginChallengeAsync(), tamper: true))); + Assert.Equal(HttpStatusCode.Unauthorized, await LoginStatusAsync(passkey.Assert(await LoginChallengeAsync(), flags: 0x01))); // 没有用户验证(UV) + + // 挑战只能用一次:同一份断言重放 + string challenge = await LoginChallengeAsync(); + JsonObject assertion = passkey.Assert(challenge); + Assert.Equal(HttpStatusCode.OK, await LoginStatusAsync(assertion)); + Assert.Equal(HttpStatusCode.Unauthorized, await LoginStatusAsync(assertion)); + + // 凭证不存在 + using var stranger = new SoftwarePasskey(RpId, Origin); + Assert.Equal(HttpStatusCode.Unauthorized, await LoginStatusAsync(stranger.Assert(await LoginChallengeAsync()))); + // 缺字段不会崩 + Assert.Equal(HttpStatusCode.Unauthorized, await LoginStatusAsync(new JsonObject())); + } + + [Fact] + public async Task TheSignCounter_OnlyMovesForward_WhenTheAuthenticatorUsesOne() + { + using HttpClient session = await FreshSessionAsync(); + using SoftwarePasskey passkey = await RegisterAsync(session); + + Assert.Equal(HttpStatusCode.OK, await LoginStatusAsync(passkey.Assert(await LoginChallengeAsync(), counter: 5))); + Assert.Equal(HttpStatusCode.Unauthorized, await LoginStatusAsync(passkey.Assert(await LoginChallengeAsync(), counter: 5))); // 克隆或回放 + Assert.Equal(HttpStatusCode.Unauthorized, await LoginStatusAsync(passkey.Assert(await LoginChallengeAsync(), counter: 3))); + Assert.Equal(HttpStatusCode.OK, await LoginStatusAsync(passkey.Assert(await LoginChallengeAsync(), counter: 6))); + } + + [Fact] + public async Task Registration_NeedsASignedInUser_AValidAttestation_AndAFreshChallenge() + { + // API 令牌不是用户:没有 passkey 可言 + using HttpResponseMessage asToken = await _backend.Admin.PostAsync("/api/admin/auth/passkeys/options", null); + Assert.Equal(HttpStatusCode.Forbidden, asToken.StatusCode); + Assert.Equal(HttpStatusCode.Unauthorized, (await _backend.Anonymous.PostAsync("/api/admin/auth/passkeys/options", null)).StatusCode); + + using HttpClient session = await FreshSessionAsync(); + using var passkey = new SoftwarePasskey(RpId, Origin); + async Task TryAsync(JsonObject body) + { + using HttpResponseMessage response = await session.PostAsync("/api/admin/auth/passkeys", BackendFixture.Json(body)); + return response.StatusCode; + } + + Assert.Equal(HttpStatusCode.BadRequest, await TryAsync(passkey.Register("x", "bm90LWEtY2hhbGxlbmdl"))); // 没发过的挑战 + Assert.Equal(HttpStatusCode.BadRequest, await TryAsync(passkey.Register("x", await RegistrationChallengeAsync(session), origin: "https://evil.example"))); + Assert.Equal(HttpStatusCode.BadRequest, await TryAsync(passkey.Register("x", await RegistrationChallengeAsync(session), rpId: "evil.example"))); + Assert.Equal(HttpStatusCode.BadRequest, await TryAsync(passkey.Register("x", await RegistrationChallengeAsync(session), flags: 0x41))); // 没有 UV + Assert.Equal(HttpStatusCode.BadRequest, await TryAsync(passkey.Register("x", await RegistrationChallengeAsync(session), flags: 0x05))); // 没有凭证数据 + Assert.Equal(HttpStatusCode.BadRequest, await TryAsync(passkey.Register(" ", await RegistrationChallengeAsync(session)))); // 名字为空 + Assert.Equal(HttpStatusCode.BadRequest, await TryAsync(new JsonObject { ["name"] = "x", ["credential_id"] = "AA", ["attestation_object"] = "AAAA", ["client_data_json"] = "AAAA" })); // 垃圾 + + // 正常注册,同一把凭证不能注册两次,也不能用登录用的挑战注册 + string challenge = await RegistrationChallengeAsync(session); + Assert.Equal(HttpStatusCode.Created, await TryAsync(passkey.Register("ok", challenge))); + // 已经有 passkey 了,再注册要先触碰一次(凭证窗口,用一次就没有) + await ElevateAsync(session, passkey); + Assert.Equal(HttpStatusCode.BadRequest, await TryAsync(passkey.Register("again", challenge))); // 挑战只能用一次 + await ElevateAsync(session, passkey); + Assert.Equal(HttpStatusCode.Conflict, await TryAsync(passkey.Register("again", await RegistrationChallengeAsync(session)))); + await ElevateAsync(session, passkey); + Assert.Equal(HttpStatusCode.BadRequest, await TryAsync(new SoftwarePasskey(RpId, Origin).Register("x", await LoginChallengeAsync()))); + } + + [Fact] + public async Task APasskey_CanBeDeleted_OnlyByItsOwner_AndThenNoLongerSignsIn() + { + using HttpClient admin = await FreshSessionAsync(); + using SoftwarePasskey passkey = await RegisterAsync(admin, "phone"); + + string other = _backend.UniqueName("user"); + using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/users", BackendFixture.Json(new JsonObject { ["username"] = other, ["password"] = "correct horse battery" })); + Assert.Equal(HttpStatusCode.Created, created.StatusCode); + using HttpClient intruder = await PasswordSessionAsync(other, "correct horse battery"); + Assert.Equal(HttpStatusCode.Forbidden, (await intruder.DeleteAsync("/api/admin/auth/passkeys/" + passkey.Id)).StatusCode); // 没有窗口,连试都不行 + Assert.DoesNotContain((await JsonOf(await intruder.GetAsync("/api/admin/auth/passkeys")))["items"]!.AsArray(), p => p!["id"]!.GetValue() == passkey.Id); + + Assert.Equal(HttpStatusCode.Forbidden, (await admin.DeleteAsync("/api/admin/auth/passkeys/" + passkey.Id)).StatusCode); // 删除要先触碰 + await ElevateAsync(admin, passkey); + Assert.Equal(HttpStatusCode.NoContent, (await admin.DeleteAsync("/api/admin/auth/passkeys/" + passkey.Id)).StatusCode); + Assert.Equal(HttpStatusCode.Unauthorized, await LoginStatusAsync(passkey.Assert(await LoginChallengeAsync()))); + } +} diff --git a/backend/tests/MoShiro.Backend.Tests/StorageTests.cs b/backend/tests/MoShiro.Backend.Tests/StorageTests.cs index fa02e7f..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, 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, 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 new file mode 100644 index 0000000..8a240a1 --- /dev/null +++ b/backend/tests/MoShiro.Backend.Tests/TrustTests.cs @@ -0,0 +1,308 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Runtime.CompilerServices; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json.Nodes; +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)签操作员集合;安装命令里带着创世指纹和属主(都是公开值),节点注册时验证面板的集合并固定在本地。 +/// 设计见 docs/access.md 第 7 节。 +/// +[Collection("probe-e2e")] +public sealed class TrustTests +{ + private static readonly ConditionalWeakTable s_operators = []; + private static readonly SemaphoreSlim s_gate = new(1, 1); + + private readonly BackendFixture _backend; + + public TrustTests(BackendFixture backend) + { + _backend = backend; + } + + /// 一个已经建立了信任根的操作员:自己的用户、passkey、会话,以及创世指纹。 + private sealed record Operator(HttpClient Session, SoftwarePasskey Passkey, string Genesis); + + private async Task OperatorAsync() + { + await s_gate.WaitAsync(); + try + { + if (s_operators.TryGetValue(_backend, out Operator? existing)) + { + return existing; + } + + // 操作员:有 passkey、用 passkey 登录的会话(密码会话什么能力都没有) + (HttpClient session, SoftwarePasskey passkey, _) = await _backend.CreateStrongSessionAsync("operator"); + + // 创世要凭证窗口(立根的人要先证明自己),再用 passkey 签集合本身 + await ElevateAsync(session, passkey); + JsonNode state = JsonNode.Parse(await session.GetStringAsync("/api/admin/trust"))!; + Assert.False(state["initialized"]!.GetValue()); + string statement = state["proposal"]!["statement"]!.GetValue(); + Assert.Equal(Base64UrlOf(statement), state["proposal"]!["challenge"]!.GetValue()); + using HttpResponseMessage genesis = await session.PostAsync("/api/admin/trust", BackendFixture.Json(Sign(passkey, statement))); + Assert.True(genesis.StatusCode == HttpStatusCode.Created, await genesis.Content.ReadAsStringAsync()); + string fingerprint = JsonNode.Parse(await genesis.Content.ReadAsStringAsync())!["genesis"]!.GetValue(); + Assert.Equal(OperatorTrust.Fingerprint(statement), fingerprint); + + var made = new Operator(session, passkey, fingerprint); + s_operators.Add(_backend, made); + return made; + } + finally + { + s_gate.Release(); + } + } + + private static string Base64UrlOf(string statement) => Fleet.Core.Base64Url.Encode(SHA256.HashData(Encoding.UTF8.GetBytes(statement))); + + /// passkey 对语句签名(挑战是语句的 SHA-256),拼成接口要的断言对象。 + private static JsonObject Sign(SoftwarePasskey passkey, string statement) + { + JsonObject assertion = passkey.Assert(Base64UrlOf(statement)); + return new JsonObject + { + ["statement"] = statement, + ["credential_id"] = assertion["credential_id"]!.DeepClone(), + ["authenticator_data"] = assertion["authenticator_data"]!.DeepClone(), + ["client_data_json"] = assertion["client_data_json"]!.DeepClone(), + ["signature"] = assertion["signature"]!.DeepClone(), + }; + } + + private static async Task ElevateAsync(HttpClient session, SoftwarePasskey passkey) + { + using HttpResponseMessage options = await session.PostAsync("/api/admin/auth/elevation/options", BackendFixture.Json(new JsonObject { ["class"] = "credential" })); + JsonObject assertion = passkey.Assert(JsonNode.Parse(await options.Content.ReadAsStringAsync())!["challenge"]!.GetValue()); + assertion["class"] = "credential"; + using HttpResponseMessage elevated = await session.PostAsync("/api/admin/auth/elevation", BackendFixture.Json(assertion)); + Assert.Equal(HttpStatusCode.OK, elevated.StatusCode); + } + + /// 创建一个加入码(普通 API key 就行,不需要 passkey),返回响应里的码和三条安装命令。 + private async Task<(string Code, JsonNode Commands, HttpStatusCode Status)> CreateCodeAsync(string role = "probe", string[]? controllers = null) + { + 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 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, code, _backend.UniqueName("node"), directory, "0.0.0-test", null, TimeProvider.System, default, genesis, controllers); + } + + [Fact] + public async Task TheInstallCommand_CarriesOnlyPublicValues_AndChecksTheScriptHashFirst() + { + Operator op = await OperatorAsync(); + (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(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.Pinned); + Assert.Equal(op.Genesis, anchor.Genesis); + Assert.Equal([op.Passkey.Id], anchor.Controllers); + Assert.True(anchor.Version >= 1); + } + + [Fact] + public async Task ANodeWithAWrongPin_RefusesBeforeSendingAnything_AndTheCodeStaysUsable() + { + Operator op = await OperatorAsync(); + (string code, _, _) = await CreateCodeAsync(); + + 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 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 APlainCode_StillWorksWithoutAPin_ButTheNodeIsNotPinned() + { + (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 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); + + // 账号下多了一把 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(); + + // 不是操作员签的(新来的 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 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] + public async Task TheGenesis_IsSetOnce_AndNeedsTheOperatorsSignature() + { + Operator op = await OperatorAsync(); + JsonNode state = JsonNode.Parse(await op.Session.GetStringAsync("/api/admin/trust"))!; + Assert.True(state["initialized"]!.GetValue()); + Assert.Equal(op.Genesis, state["genesis"]!.GetValue()); + + // 语句被换掉,签名对不上:不收 + 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); + + // 匿名没有可能立根或改根 + using HttpResponseMessage anonymous = await _backend.Anonymous.PostAsync("/api/admin/trust", BackendFixture.Json(tampered)); + Assert.Equal(HttpStatusCode.Unauthorized, anonymous.StatusCode); + } + + [Fact] + public async Task APasswordSession_CanOnlyRegisterAPasskey_AndCreatingACodeNeedsNoPasskeyTouch() + { + // 密码登录的会话没有任何能力:连看节点列表都不行,只能注册 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); + + // 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 TheServerKey_IsSignedIntoTheOperatorSet_AndOnlyThatServerPassesTheNodesCheck() + { + Operator op = await OperatorAsync(); + 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 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); // 集合里登记了服务器密钥:节点要求服务器证明自己 + + // 真服务器的证明过得了节点的校验;任何别的密钥(冒充者)、或者换了临时公钥的都过不了 + 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) + { + // 已经同步:用当前集合的内容拼一个没有服务器密钥的下一版 + (_, 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/backend/tests/MoShiro.Backend.Tests/UpdateTests.cs b/backend/tests/MoShiro.Backend.Tests/UpdateTests.cs index 7b41051..113cee2 100644 --- a/backend/tests/MoShiro.Backend.Tests/UpdateTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/UpdateTests.cs @@ -75,9 +75,9 @@ public sealed class UpdateTests [Fact] public async Task ReleaseToken_CanOnlyRegisterReleases_AndAReadTokenCannotEvenDoThat() { - async Task NewTokenAsync(string scope) + async Task NewTokenAsync(params string[] capabilities) { - using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/api-tokens", BackendFixture.Json(new JsonObject { ["name"] = _backend.UniqueName("ci"), ["scope"] = scope })); + using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/api-tokens", BackendFixture.Json(new JsonObject { ["name"] = _backend.UniqueName("ci"), ["capabilities"] = BackendFixture.Capabilities(capabilities) })); Assert.Equal(HttpStatusCode.Created, created.StatusCode); return JsonNode.Parse(await created.Content.ReadAsStringAsync())!["plaintext"]!.GetValue(); } @@ -90,8 +90,8 @@ public sealed class UpdateTests } long seq = NextSeq(); - using HttpClient release = Client(await NewTokenAsync("release")); - using HttpClient read = Client(await NewTokenAsync("read")); + using HttpClient release = Client(await NewTokenAsync("release.publish")); + using HttpClient read = Client(await NewTokenAsync("public.read", "fleet.read")); var body = BackendFixture.Json(new JsonObject { ["manifest"] = Manifest("probe", seq), ["signature"] = "MEUC" }); using HttpResponseMessage forbidden = await read.PostAsync("/api/admin/releases", BackendFixture.Json(new JsonObject { ["manifest"] = Manifest("probe", seq), ["signature"] = "MEUC" })); 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 new file mode 100644 index 0000000..b70ae3a --- /dev/null +++ b/docs/access.md @@ -0,0 +1,483 @@ +# 访问控制与入网信任链 + +这份文档定义**谁能做什么**(账户、角色、能力、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. 名词 + +| 名词 | 含义 | +| --- | --- | +| 账户 | 一个人。有角色、若干登录方式、若干 passkey | +| 角色 | `anonymous`、`admin`、`owner`,决定能力的**上限** | +| 能力(capability) | 一项具体权限,如 `fleet.write`。端点声明自己需要哪项能力 | +| 会话 | 登录得到的状态,只带 L1 能力 | +| 提权窗口 | 用 passkey 触控换来的、有时限的信任类能力(类似 sudo) | +| 操作员集合(OpSet) | 节点固定的、带版本的 passkey 公钥列表,每个带角色和限制 | +| 委托(Grant) | passkey 签名授权一把临时密钥,在范围、时间内代表它签名 | +| API key | 一串种子;`enroll` 类的 key 创建时由 passkey 签出一份委托 | +| 入网券(Ticket) | 委托的密钥签的「允许一个持有 K_code 的节点入网」 | +| 属主(controllers) | 一个节点绑定的一组 passkey:只有它们能授权这个节点上的 extension(web ssh 等)。管理员没有这份权力,见第 8 节 | + +## 2. 角色与能力 + +### 2.1 能力表 + +| 组 | 能力 | 说明 | 信任类 | +| --- | --- | --- | --- | +| 展示 | `public.read` | 看隐藏线路;站点私有时看展示页 | | +| 节点 | `fleet.read` | 看 Agent、Probe、线路、加入代码 | | +| | `fleet.write` | 改名、标签、可见性、手填地址、线路开关、断开会话 | | +| | `fleet.enroll` | 生成加入代码(签入网券);可以吊销**自己加的**节点 | 是 | +| | `fleet.revoke` | 吊销或删除任意节点 | 是 | +| 任务 | `tasks.read` · `tasks.write` | 任务模板、覆盖、发起测试 | | +| 数据 | `data.read` · `data.write` | 原始块、导出;清理 | | +| 设置 | `settings.read` · `settings.write` | 站点设置、保留策略、重启计划 | | +| 更新 | `update.read` · `update.confirm` | 看 Backend 更新、确认更新 | | +| | `release.publish` | CI 登记发布清单(只此一件事) | | +| 令牌 | `keys.read` · `keys.manage` | 列出、创建、撤销 API key | | +| 账户 | `accounts.manage` | 账户、passkey、操作员集合 | 是 | +| 运维 | `audit.read` · `ops.metrics` | 审计日志;`/metrics` | | +| 集成(预留) | `events.read` · `data.export` | 拉取事件流和批量数据,见 5.4 | | +| | `webhooks.read` · `webhooks.manage` | 外部回调订阅,见 5.4 | | + +- **Backend 更新确认是 L1**:更新清单由 CI 的发布密钥签名,`seq` 防回滚,真正的门是发布密钥,不是确认这一步。 +- **一个能力只在一个地方声明**:`Capability` 常量(`MoShiro.Protocol`),端点用 `.Requires(Capability.X)`;`Principal.Has(capability)` 是唯一的判断入口,会话、API key、委托都走它。 +- **对账测试**:所有 `/api/admin` 端点必须声明能力,没声明测试就失败;OpenAPI 的每个操作带 `x-moshiro-capability`。 + +### 2.2 角色 + +| 角色 | 能力 | +| --- | --- | +| `anonymous` | 只有 `public.read`,且站点公开时才有。不是账户 | +| `admin` | 除 `accounts.manage` 外的全部;信任类(`fleet.enroll`、`fleet.revoke`)要提权 | +| `owner` | 全部;`accounts.manage` 每次都要当场触控 | + +- 角色是上限:能力是否可用取决于角色和当前窗口。 +- **不能越权**:创建 API key 时勾选的能力必须是创建者当前拥有能力的子集;带信任类能力的 key 创建时要 passkey 签一次。 +- 角色写在操作员集合里,**节点自己执行**:admin 凭证签的入网券有效;只有 owner 凭证签的操作员集合更新才有效。被攻破的 Backend 给自己造一个 admin 账户也加不进节点。 + +## 3. 登录与会话 + +| 登录方式 | 能得到 | 说明 | +| --- | --- | --- | +| passkey | L1 会话,并且可以提权 | WebAuthn,只支持 ES256,attestation 选 `none` | +| 密码 | L1 会话 | 保留用于首次引导和兜底;PBKDF2 | +| OIDC | L1 会话 | 账户绑定 `iss + sub`,登录时按它找账户,不自动建账户。放在 passkey 之后做 | + +- 密码和 OIDC **永远不能提权**:节点验证不了 IdP 的断言,也不该让 IdP 或密码进入信任域。 +- **引导**:初始管理员用密码登录,注册第一个 passkey;这是唯一一次不需要提权的注册。之后新增 passkey 都要提权,并写进操作员集合(见第 7 节)。 +- 建议每个账户至少注册两个 passkey(手机加硬件密钥)。 +- **离线恢复密钥**:创建 owner 时同时生成一把离线的 ECDSA 恢复密钥,作为 `type=ecdsa`、`recovery=true` 的 owner 凭证写进操作员集合;私钥打印或放进密码管理器,平时不用。节点强制它**只能签操作员集合更新**,不能签委托、入网券和吊销表,所以即使被偷走,也加不了节点;它的用处是 passkey 全部丢失时签一版新的操作员集合、重新加 passkey,节点不用重装。 + +### 3.1 passkey 登录的实现(已完成) + +- 流程:登录页「使用 Passkey 登录」→ `POST /api/admin/auth/passkey/options` 取挑战 → 浏览器 `navigator.credentials.get`(可发现凭证,不用输用户名)→ `POST /api/admin/auth/passkey/login` 提交断言 → 建立会话(Cookie,或跨域前端用 `return_session`)。注册:已登录用户在「系统 → 用户 → 我的 Passkey」添加,`POST /api/admin/auth/passkeys/options` 加 `POST /api/admin/auth/passkeys`;每个用户最多 10 个。 +- 校验:挑战在内存里,一次有效、5 分钟过期,并且区分注册和登录、绑定用户;`rpIdHash`、UP 和 UV 标志、`clientDataJSON` 的 type、challenge、origin、ES256 签名;签名计数器只增不减(两边都是 0 的同步型 passkey 不检查)。登录失败一律 401,不区分原因,和密码登录共用限流。 +- 验证断言的纯函数是 `Fleet.Identity.WebAuthnAssertion`(只用 BCL,节点以后验证 passkey 签出的委托也用它);注册里的 CBOR 和 attestation 解析在 Backend(`WebAuthnRegistration`),attestation 一律当 `none`,节点不带 CBOR。 +- 依赖方(RP):`MoShiro__WebAuthnRpId`(缺省取 `PublicUrl` 的主机,没有就取请求的主机)和 `MoShiro__WebAuthnOrigins`(缺省取 `PublicUrl` 的来源)。**passkey 绑定在这个域名上,改了会让已注册的 passkey 失效。**只有和后端同站的页面能用 passkey 登录,管理端在「添加其他后端」(跨域)时不显示这个入口。 +- 会话记录登录方式(`password` 或 `passkey`,`sessions.auth_method`),只有 passkey 登录的会话以后能提权;API key 不是用户,不能注册 passkey。 +- 测试:`SoftwarePasskey` 在测试里模拟认证器走完整的注册和登录,覆盖错误的挑战、来源、RP、签名、缺少 UV、挑战重放、计数器回退、只能管理自己的 passkey;管理端用 Chromium 的 WebAuthn 虚拟认证器验证过注册和登录。 + +## 4. 提权窗口(信任类能力各管各的) + +| 窗口 | 覆盖 | 默认时长 | 行为 | +| --- | --- | --- | --- | +| 接入 | `fleet.enroll` | 15 分钟(5–60 可调) | 窗口内连续生成代码不再弹 passkey | +| 吊销 | `fleet.revoke` | 5 分钟 | 破坏性操作,窗口更短 | +| 凭证 | `accounts.manage`、创建带信任类能力的 key | 无窗口 | 每次都要当场触控 | + +- 窗口互不覆盖:开了接入窗口不能吊销节点。 +- 「用到才提权」:页面照常打开,点信任类操作时没有窗口才弹 passkey,通过后接着做原来的动作。 +- 管理端顶部显示生效的窗口和剩余时间,每个窗口有「立即结束」。 +- 接入窗口的实现:一次触控让 passkey 签一份委托(见第 7 节),授权浏览器里生成的不可导出临时密钥 D;窗口内用 D 签入网券,到期自动失效。 +- **默认值待确认**(15 分钟、5 分钟)。 + +## 5. API key + +### 5.1 类型 + +| 类型 | 创建时要不要 passkey | 能力 | +| --- | --- | --- | +| 普通 key | 不要 | 勾选的非信任类能力(只读、运维、CI 发布等),只能是创建者能力的子集 | +| `enroll` key | **要,签一次** | 勾选的非信任类能力 + `fleet.enroll`(可吊销自己加的节点) | + +- 现有令牌迁移(破坏性):`read` → 全部 `*.read`;`admin` → 全部非信任类能力;`release` → `release.publish`。迁移后,旧的 admin 令牌不能再生成加入代码,需要换成 `enroll` key。 +- 每把 key 可设:有效期、可选的 IP 白名单(CIDR,**待确认**)、`enroll` 类的数量上限和单券 TTL 上限;预留:资源范围、速率和数据配额(见 5.4)。 + +### 5.2 `enroll` key:自动化添加节点 + +**当前实现:它就是一把带 `fleet.enroll` 的普通 API key**,用 `POST /api/admin/join-tokens` 创建加入码,响应里直接给出安装命令(带创世指纹、属主和脚本哈希,没有任何私钥)。因为创建码不是信任动作,所以不需要 passkey,也不需要提权窗口;CI 和脚本照常工作。信任在两处:命令里的创世指纹和属主由面板生成,节点注册时固定;以后改属主和集合要操作员 passkey 签名。 + +早先设计过的「委托密钥」(passkey 签一次委托,API key 自己签入网券,节点验证整条链)属于第 7.1~7.3 节的完整设计,当前没有采用,见第 0 节的取舍。 + +### 5.3 创建界面 + +- 名称、有效期、可选 IP 白名单;`enroll` 类多出「最多加几个节点」「单券有效期」。 +- 能力用分组复选框:全局**全选**(带半选状态)、**组内全选**、**反选**、搜索;预设按钮一键套用:只读、运维、接入、CI 发布、全部;超出创建者能力的项灰色不可选,需要 passkey 的项带标记(⚿),勾选后创建时提示。 +- 创建后只显示一次 key,同时列出它拥有的能力和过期时间。 +- 列表:API key、加入代码、节点、审计日志都支持多选和全选做批量操作(撤销、吊销、删除、筛选)。API key 列表显示能力数、最后使用时间、过期时间。 + +### 5.4 预留:外部 webhook 回调与数据拉取 + +后面要让外部系统能**订阅事件、触发回调、拉数据**。接口和权限现在先留,实现放后面;全部发生在 Backend 里,**节点零开销**。 + +- **能力(已列入能力表,标预留)**: + - `events.read`:拉取事件流,游标分页(`GET /api/admin/events?after=`),适合外部系统轮询。 + - `data.export`:按线路和时间范围批量拉数据,有限速和配额;`data.read` 仍然是交互式查询。 + - `webhooks.read` · `webhooks.manage`:创建、修改、删除外部回调订阅,以及手动触发一次测试回调(`POST /api/admin/webhooks/{id}/test`)。 + 能力在目录里先存在,旧 key 以后可以直接带上它们,不需要再迁移。 +- **key 上预留的字段**: + - `resource_filter {agent_ids?, labels?}`:限制这把 key 能看到哪些节点、线路;事件和 webhook 同样受它限制。 + - `rate_limit`(每分钟请求数)和 `quota`(每天拉取的行数或字节数):超出返回 429 带 `Retry-After`,计入审计。 +- **webhook 的形状**:订阅 `{id, url, events[], secret, 创建者(key 或账户), enabled}`。事件:节点上线或下线、线路告警阈值、安全事件(节点准入、吊销、key 创建、提权)等。投递是 `POST` JSON,头带 `X-MoShiro-Signature: sha256=`、时间戳和投递 id;至少一次投递、指数退避重试、接收方按投递 id 幂等。 +- **订阅受创建者能力约束**:只能订阅创建者自己读得到的事件,不能借 webhook 越权。 +- **SSRF 约束**(TODO 里已有):URL 解析后检查地址,只放行公网地址;不跟随重定向到内网;连接和响应有超时和大小上限;`secret` 只在创建时显示一次。 +- 这些都属于 L1:改的是 Backend 的集成配置,不改变节点信任;但每次增删订阅和改 URL 都进审计日志。 + +## 6. 归属与审计 + +- 账户表:`id`、名字、角色、登录方式、passkey;加入代码记 `created_by`,节点记 `added_by`;管理页的 Agent、Probe 详情显示「由 xxx 于 xxx 添加」。 +- 审计日志 `(时间, 账户, 凭证 id, 动作, 对象, 详情)`:创建或撤销 key、加入代码、吊销、删除、手填地址、确认更新、提权、因能力不足被拒绝的请求。保留 365 天(同事件表)。 +- **可验证的一层**:委托里写进签名它的 passkey 凭证 id,入网券和节点绑定带着它,节点在 hello 里报告「由哪个凭证准入」。「谁加的」不只是 Backend 的一面之词。 + +## 7. 入网信任链(完整设计,当前没有采用;当前实现见第 0 节) + +> 第 7.1~7.3 节描述的 Grant、Ticket、Binding、成员证明和节点验证在代码里**不存在**。要不要叠加回来,取决于你要不要防「被攻破的 Backend 往名册里塞节点」;操作员集合和创世固定已经在,叠加时不用重装节点。 + +### 7.1 文档 + +```text + ① 操作员集合 OpSet {version, creds:[{id, type=webauthn|ecdsa, 公钥, 角色, 账户, recovery?, max_ticket_ttl, 作用域}]} + 签名人:owner 的 passkey;创世版本由安装时固定的第一个 owner 公钥自证 + ② 委托 Grant {grant_id, 委托公钥 D 或 P, 作用域, 额度, not_before, not_after, 签发凭证 id} + 签名人:passkey(WebAuthn 断言,challenge = SHA256(Grant)) + ③ 入网券 Ticket {ticket_id, K_code 公钥, 角色, controllers, issued_at, expires_at, grant_id} + 签名人:委托私钥。生成加入代码时签,用户那时不用在场 + ④ 节点绑定 Binding {node_id, 角色, 节点密钥公钥, 对等面公钥, ticket_id} (controllers 随 Ticket 一起被签名,节点绑定引用它) + 签名人:K_code 私钥。它就在安装命令里,节点注册时用一次 + ⑤ 吊销表 RL {version, issued_at, 被吊销的节点公钥指纹[]} + 签名人:passkey 当场签;`enroll` 委托的密钥只能签针对「自己加的节点」的条目(节点校验条目指向的 Binding 属于这份委托) +``` + +- 安装命令里的代码就是 `K_code` 的种子;Backend 的一次性加入令牌由它派生,所以命令仍然是一行。 +- 所有签名带用途前缀(域隔离),规范化编码;格式不协商、不降级。 + +### 7.2 节点验证(本地,Backend 不参与信任) + +节点固定创世 owner 公钥。链随期望状态下发(Agent 的 `peers`、Probe 的 `probe_agents` 里每个成员带着自己的链),节点在**状态同步时**验,每个成员验一次、结果进成员表;对等握手(敲门加现有的对端认证)完全不变,没有额外字节和运算。Agent 接纳 Probe、Probe 接纳 Agent,各自验同一条链: + +1. OpSet:从创世公钥起,每个版本由 owner 凭证签、版本递增;节点持久化见过的最大版本。 +2. Grant:签发凭证在 OpSet 里且角色够;WebAuthn 断言有效(`rpIdHash`、UP/UV 标志、`clientData.type = webauthn.get`、`challenge = SHA256(Grant)`、ES256 验签);当前时间在有效期内。 +3. Ticket:由 Grant 的委托公钥签;`issued_at` 落在 Grant 窗口内;`expires_at − issued_at ≤ max_ticket_ttl`(来自 OpSet 里签发凭证的限制);节点当前时间 ≤ `expires_at`。 +4. Binding:由 Ticket 的 `K_code` 签;节点密钥和对等面公钥与这个成员条目里的一致;角色匹配(Probe 只能是 probe)。握手时对端证明自己持有这把对等面密钥,用的是现有的认证。 +5. RL:指纹不在最新吊销表里(持久化见过的最大版本)。 + +通过后记进本地成员表(持久化,离线也认),同时进入敲门的名册(`KnockGate` 的 `peers`);没通过的成员条目直接丢弃并计数,不回详细原因。Probe 只拨公网地址(拒绝私网、回环、链路本地),解决「被攻破的 Backend 让 Probe 探测客户内网」。 + +### 7.3 能强制什么,不能强制什么 + +- 节点能强制:谁是成员(链)、时间(Grant 窗口、Ticket 过期、单券 TTL 上限)、角色、吊销。 +- 节点强制不了:数量上限(它不知道别的节点)。由签名人执行(浏览器或 Backend 的 key 路径)。 + +### 7.4 现状的缺口:节点没有任何东西可以用来识别 Backend + +今天的链路(已核对代码): + +```text + 安装 curl https://<面板>/install?code=jt_… | bash 信任 HTTPS 证书,第一次联系即信任(TOFU) + 注册 Agent 生成 node.key 和 tls.pfx,用 jt_ 码把公钥交给 Backend + 连接 Backend 发挑战,Agent 用 node.key 签名 只有「Agent 向 Backend 证明自己」 + 反方向 Agent 验证 Backend?没有。只靠 wss 的 WebPKI 证书;节点本地只存 node_id、面板地址、自己的证书指纹 +``` + +后果:Backend 的身份就是「能让 Agent 连上的、证书有效的服务器」。DNS 或前置代理被攻破、被签发了该域名的证书、或 Backend 机器被拿下,攻击者都等于 Backend,可以改 Agent 的 `peers` 名单(把自己的 Probe 公钥放进去)、下发任务、读全部数据。「换一个 Backend 公钥配自己的私钥」在这里甚至不需要,因为节点从来没有存过 Backend 的公钥。 + +要求:信任根必须是操作员的 passkey 公钥,在节点**第一次安装时固定**,之后 Backend 无法替换: + +- 节点收到的所有「改信任」的内容(成员名单、授权、吊销表)都必须带操作员的签名,节点自己用固定下来的创世公钥验证。Backend 只能转发,签不出新的。 +- Backend 被冒充也只能转发操作员签过的东西:已有的授权可以重放(受有效期、吊销表约束),新授权签不出来。 +- 弱点:创世公钥指纹放在安装命令里,命令本身要从可信渠道拿到(管理端复制)。改了命令的人可以换指纹;缓解见第 15 节(静态签名脚本、按 digest 拉镜像),并让管理端展示指纹供核对。 + +### 7.5 第一个落地切片(重启、改配置的票和 webssh 先不做,只留接口) + +| 步 | 内容 | 验证 | +|---|---|---| +| a | 创世:第一次用 passkey 登记操作员公钥(OpSet v1,自证);安装命令带上指纹 | 命令里的指纹和管理端展示一致 | +| b | Grant:生成加入码时由 passkey 签(`challenge = SHA256(Grant)`);Agent 和 Probe 注册时验签,通过才保存操作员公钥 | 没有签名或签名不对的码,节点拒绝注册 | +| c | 成员名单带签名:Agent 的 `peers`、Probe 的 `probe_agents` 每项带自己的链,节点在状态同步时验,不过就丢弃并计数 | 冒充的 Backend 往名单里加 Probe 公钥,节点不接纳 | +| d | 数据加密(PRF)、`enroll` key、Ticket 作为后续,接口先留在 Grant 里(`controllers`、数据公钥字段) | | + +不在这个切片里:重启和改配置不需要签名的票;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 还没做,字段也先固定下来,之后不用重装节点。 + +```text + 属主 controllers = 一组 passkey 凭证 [{credential_id, sign_pubkey(ES256), enc_pubkey?}] + 默认:添加者(签入网券的那个账户)的全部 passkey + 添加节点时可以从已有的 passkey 里选(含全选):允许为该节点绑定一组 passkey,而不是只绑一个人的一把 + 写在哪:入网券里(由添加者的委托签名),节点绑定引用它;节点本地持久化,随成员表一起保存 + + extension 的授权(每次调用) + 操作员签名的一次性能力令牌 {节点 ID, extension, 命令哈希, not_before, not_after, nonce} + 签名必须是属主里某把 passkey 的 WebAuthn 断言(challenge = 令牌哈希),节点用自己存的属主公钥验 + —— 不是「操作员集合里任意 admin/owner 的 passkey」,而是「这个节点的属主」 + + 谁能做什么 + 属主 启用或使用 extension;增删属主(需要现有属主中的一把签名的 ControllerUpdate) + admin / owner 只能删除节点或卸载 extension(吊销表,照常当场 passkey);读不到、用不了 + Backend 只搬运。交互式会话的内容端到端加密(浏览器 ↔ extension),Backend 看不到明文 +``` + +要点: + +- **删除和使用分开**:吊销表没有「使用」的含义;账户角色(owner、admin)不赋予任何 extension 权力。owner 想用某个节点的 web ssh,也得是那个节点的属主。 +- **改属主不经过 Backend 的账户系统**:`ControllerUpdate {node_id, version, controllers}` 由现有属主的 passkey 签名,节点持久化见过的最大版本;账户被删除、角色被改都改不了某个节点的属主。 +- **属主是 passkey 凭证,不是账户**:账户只是 Backend 里给人看的标签;节点只认公钥。一个人丢了全部属主 passkey,这个节点上的 extension 就没人能用了,要用就得重装节点(和第 3 节的恢复一致),所以添加时建议绑两把以上。 +- 属主字段在**步骤 4(入网链)**就写进 Ticket;extension 本身、ControllerUpdate 和能力令牌放在阶段六。 + +### 8.0 范围:这一节先固定协议和要求,extension 本身先不做 + +- **web ssh、文件、抓包等 extension 现在都不实现**,Agent 和 Probe 一样(Probe 是节点,同样要在本地执行下面的要求)。这里只把「谁来强制」和「怎么证明」定下来,以后实现时不改信任链,也不用重装节点。 +- **已经固定在节点本地的字段**:`controllers`(允许授权本节点 extension 会话的 passkey 凭证 id,必须是操作员集合里的凭证)由安装命令带来,节点注册时保存在 `trust.json`,Backend 之后改不了;从集合里去掉的凭证不再有效。默认是操作员集合里的全部凭证,空 = 不允许任何 extension 会话。接收者名单(`recipients`)等做加密时再加。节点还没有 extension,所以现在只是被固定和保存,不起别的作用。 + +### 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 断言(能力令牌) + ──────────── 能力令牌 + e 公钥 ───────▶ 转发 ─────────────────────────▶ ③ 验:断言来自本节点属主 + + 令牌新鲜、nonce 未用 + ◀── 节点的会话公钥(节点身份密钥签名)── 转发 ◀────────────────────────── ④ 节点生成临时密钥 n,签名后返回 + ⑤ 双方 ECDH(e, n) → HKDF(绑定两边公钥、令牌哈希、用途标签)→ 会话密钥 + ⑥ 之后双向都是 AEAD 密文(ChaCha20-Poly1305 或 AES-GCM,BCL 自带),每帧带序号, + 序号进 AAD,重放、乱序、丢帧、篡改都会解密失败 +``` + +- 终端输出等**从节点流向属主的数据**,只能被拿着会话私钥 e 的浏览器解开;Backend 没有 e,也没有节点的 n,算不出会话密钥。 +- 临时密钥带来前向保密:长期的 passkey 只用来**签名授权**,不参与解密,所以以后 passkey 泄露也解不开历史会话。 +- Backend 篡改密文、重放、乱序:AEAD 校验失败,会话立即断开并计数。Backend 仍然能做的:拒绝服务、延迟、丢包,以及看到元数据(谁连哪台节点、什么时候、流量大小)。 +- **静态数据(以后的抓包文件、会话录像)**需要事后解密,不能只靠临时密钥:属主记录里的 `enc_pubkey`(P-256 ECDH 公钥)就是为此预留的,数据用它封装(ECIES:临时密钥 + HKDF + AEAD)。私钥有两种来源,按设备能力选: + 1. **WebAuthn PRF 扩展**:由 passkey 派生私钥种子(浏览器用 `prf` 取 32 字节,经 HKDF 得到 P-256 私钥);私钥不落盘,每次要解密时再触控一次。设备不支持 PRF 就退到下一种。 + 2. **浏览器里的不可导出密钥**:添加属主时在这台浏览器生成,只能在这台浏览器用,丢了就解不开。 + 属主记录里没有 `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. 流程 + +| 流程 | 步骤 | +| --- | --- | +| 引导 | 初始 owner 密码登录 → 注册第一个 passkey → 一行安装命令带创世公钥 → 节点首次注册时固定(和加入代码同一信任通道) | +| 加节点(人) | 接入窗口内点「添加」→ 浏览器用 D 签入网券 → 生成命令(含 K_code 种子)→ 用户一行安装 → 节点用 K_code 签 Binding → Backend 存好并随 `peers`、`probe_agents` 下发。人不在场也行(券在有效期内);过期就重新生成 | +| 加节点(自动化) | `enroll` key 调 `POST /api/admin/join-tokens` → Backend 用种子现算签名密钥签券 → 其余同上 | +| 吊销 | 吊销窗口内当场 passkey 签 RL → 全部节点持久化最大版本 → 被吊销的节点连不进来,旧表复活不了它。`enroll` key 可以吊销自己加的节点 | +| 增删 passkey、换角色 | owner 当场触控签新 OpSet 版本 → 节点自己同步,不用重装 | +| 恢复 | passkey 全丢:用离线恢复密钥签新的操作员集合,重新加 passkey(节点不用重装)。恢复密钥也丢了才重装节点并换创世公钥(部署脚本 `--operator-key`) | +| 弱信任(开发、实验室、升级过渡) | 节点安装时显式选择**不固定操作员**,行为回到「信任 Backend」;管理页标「弱信任」。这是显式降级,不会静默发生 | + +## 10. 存储 + +- Backend:`accounts`、`credentials`(passkey 公钥、计数器)、`sessions`、`api_keys`(认证值哈希、能力、委托、IP 白名单、有效期、最后使用)、`grants`、`tickets`、`opsets`、`revocations`、`audit_log`;`join_tokens`、`agents`、`probes` 加归属列和 Binding。 +- 节点本地(`ConfigStore`):创世公钥、见过的最大 OpSet 和 RL 版本、成员表(含准入凭证 id)。 + +## 11. 落点(不新增项目) + +| 机制 | 落点 | +| --- | --- | +| WebAuthn 断言、OpSet、Grant、Ticket、Binding、RL 的编码和验签(纯函数,只用 BCL 的 ECDSA) | `Fleet.Identity` | +| `trust` 状态类型(OpSet + RL)、成员条目带链、同步时验证、成员表持久化 | `Fleet.Link` 的 `StateHandler`(对等握手不变) | +| 能力常量、`Principal.Has`、端点 `.Requires`、对账测试 | `MoShiro.Protocol`、`Backend.Api` | +| 账户、passkey 注册与登录、会话分级、提权窗口、审计、API key | `Backend.Application`、`Storage.Sqlite`、`Backend.Api` | +| 管理端:passkey 登录与注册、提权状态条、API key 创建界面(预设、全选)、审计页 | `web/admin` | +| 安装命令带创世公钥和 `K_code`;`fleet-enroll` 签 Binding | `packaging/install`、`Fleet.Enrollment.Client` | + +## 12. 实施顺序(每步可独立验证和推送) + +1. **能力模型与守卫**:`Capability`、`Principal.Has`、端点声明、对账测试、API key 创建界面(预设、全选、能力子集校验)。不改节点。**已实现**(破坏性:旧令牌作废,数据库迁移 v11 重建 `api_tokens`)。登录会话在步骤 2 之前拥有全部能力(包括信任类),信任类的 passkey 要求从步骤 2 起强制。IP 白名单推迟:本部署经 Cloudflare、frp 和 Caddy,后端看到的来源地址是代理,需要先有「可信代理」配置,才做得对。`events.read` 对应现有的 `GET /api/admin/events`,不是预留。 +2. **账户、passkey、会话分级、审计**:WebAuthn 注册与登录、提权窗口(接入、吊销、凭证三类)、审计日志、归属列和详情页显示。**已实现**:passkey 登录(第 3.1 节)、提权窗口(接入 15 分钟、吊销 5 分钟、凭证类 60 秒用一次;窗口属于会话,在内存里,API key 不受影响;创建带信任类能力的 key 和增删 passkey 要凭证窗口,第一个 passkey 例外)、审计日志(`GET /api/admin/audit`,写操作和登录自动记录,保留 365 天)、归属(加入代码的创建者、节点的添加者)。 +3. **操作员集合与创世公钥固定**(落地切片见第 7.5 节):`fleet-enroll` 和安装命令改造、弱信任开关、离线恢复密钥的创建与导出。 +4. **入网链**:Grant、Ticket(含属主 `controllers`)、Binding 的签发和节点验证(验证一次、缓存结果);成员条目随期望状态下发、节点在状态同步时验证;Probe 只拨公网地址;`enroll` key;节点信任状态上报和管理页显示(第 14 节)。添加节点的界面可以选属主(默认添加者的全部 passkey)。 +5. **吊销表与操作员集合更新**;自动化夹具(测试里的软件 ECDSA 凭证);**安装脚本的供应链**(第 15 节:静态签名脚本、按 digest 拉镜像)。 + +实现状态:能力模型、passkey 登录、提权窗口、审计、操作员集合与创世固定、服务器身份和安全链路、静态安装脚本和哈希核对、属主固定都已实现(第 0 节);成员证明(第 7.1~7.3 节)没有采用;吊销表、任务和配置的操作员签名、节点自证构建、extension 属主的实际强制还没有做。 + +## 13. 局限 + +- 数量上限只能由签名人执行,节点只强制时间;窗口过期后设备被攻破的人,最多还能多签有效期内的入网券(受节点时钟约束)。 +- 节点的时钟被攻破或严重偏差会影响 Ticket 过期判断。 +- WebAuthn 只验 ES256 和 attestation = `none`,不验设备证明。 +- passkey 和恢复密钥都丢失才需要重装节点;某个节点的属主 passkey 全部丢失,这个节点上的 extension 就没人能授权。 +- `enroll` key 是持有者凭证:谁拿到种子 S,谁就能在它的范围和有效期内加节点,必须按密码对待。 + +## 14. 节点信任状态上报 + +Backend 被篡改时,你需要一个**不经过 Backend 账户系统**的信号。节点把自己的信任状态报给面板,面板显示对比: + +- 节点在 `hello`、状态变化时和每 5 分钟(不进每次心跳)上报:创世公钥指纹(前 8 字节)、见过的操作员集合版本和吊销表版本、成员数、是否弱信任、各原因的拒绝计数。每次不到 100 字节。 +- 面板的节点列表显示一个信任状态:**正常**、**版本落后**(节点的操作员集合或吊销表版本低于面板持有的)、**弱信任**、**时钟偏差过大**(超过入网券 TTL 的一半,或超过 60 秒;面板用 `hello` 的 `NowUtcMs` 算)、**拒绝计数上升**、**超过 10 分钟没有上报**。 +- 这些是节点经认证的链路上报的,被攻破的 Backend 伪造不了,但可以压住不显示——所以「长时间没有上报」本身也是红色,面板同时显示 Backend 自己持有的版本和节点上报的版本,对不上就是信号。 +- 被攻破的节点可以说谎,但它说不了别的节点的状态。 + +## 15. 安装脚本的供应链 + +一行安装要执行 Backend 吐出的脚本、拉仓库里的镜像。Backend 或仓库被攻破,就能在新节点上执行任意命令。收束办法,**优先级最高的是第一跳的哈希核对**: + +**① 脚本是静态产物,哈希固定(优先做)** +- 不在服务端做字符串替换。CI 构建时算出 `install.sh`、`install.ps1` 的 SHA-256,写进发布清单(发布密钥签名),同时公布在 Gitea 发布页——**这条渠道独立于 Backend**,这是哈希核对有意义的前提:哈希若和脚本从同一处来,攻击者两个一起换就行。 +- 参数(代码、面板地址)通过命令行传入,代码也因此不再出现在 URL 和反向代理日志里。 +- 管理端给的命令把「下载、核对、执行」合成一行,哈希核对不通过就不执行: + +```text +curl -fsSL https://panel/install -o /tmp/moshiro-install.sh && echo " /tmp/moshiro-install.sh" | sha256sum -c - && sh /tmp/moshiro-install.sh '' +``` + + macOS 用 `shasum -a 256 -c`,Windows 用 `Get-FileHash` 比较,不通过就 `throw`。命令里的 `` 由 Backend 填入,所以管理端同时标出「与 Gitea 发布页的 SHA-256 一致」并提供链接;严格的人对照一次即可(脚本是静态的,哈希在一个版本内不变,只需要核对一次)。 +- 进一步:操作员确认这个版本的发布时(`update.confirm`,要 passkey),把脚本哈希一起签进去,Backend 被攻破也改不了命令里的哈希;节点和安装命令里的创世公钥指纹(第 7.5 节)来自同一次签名。 + +**② 镜像按 digest 拉取,二进制按 SHA-256 校验** +- 脚本向面板取已签名的清单(和更新用的是同一份,发布密钥签名、`seq` 防回滚),用**脚本里内置的发布公钥**验签,再按清单里的 digest 拉镜像(`repo@sha256:…`);原生二进制则核对清单里的 SHA-256 再安装。Backend 改不了 digest,最多让节点装到一个旧的合法版本。 +- 发布公钥因此既在脚本里,也编进 Agent 和 Probe 二进制(一个常量,来自 `packaging/update/release-key.pub.pem`),节点之后的更新由节点自己验,不依赖宿主机更新器和 Backend 转发的内容。发布密钥轮换时,新公钥必须由旧密钥签名的清单带下来,否则节点不认。 +- 更新建议要求**双签**:CI 的发布签名,加操作员 passkey 对清单摘要的批准签名。只攻破 CI 或只攻破 Backend 都不够。 + +**③ 其余** +- 角色(Agent 还是 Probe)、镜像仓库地址仍由代码决定,但镜像身份由签名清单决定。 +- Windows 版同理,另外可以加 Authenticode 签名。 +- 今天的现状:一行安装不装更新器,也不带发布公钥,节点自己不验任何签名;这一节是要补的缺口。 + +## 16. 效率(运行时开销与日常摩擦) + +目标机器是 **1 核 1 GB 的小鸡**,甚至路由器。原则:**重活留在 Backend 和浏览器,节点只做最小的验证**;节点不增加线程、定时器、依赖,数据路径上不增加任何东西。 + +**节点的预算(实现时当成验收标准)** + +| 项 | 预算 | 怎么做到 | +| --- | --- | --- | +| 数据路径 | **零增量**:UDP 数据面、测量、对等握手、敲门都不变 | 链随期望状态下发,在状态同步时验,不在握手里验(见 7.2) | +| 常驻内存 | 增量 ≤ 1 MB | 成员表上限 256 条(每条几百字节)、操作员集合上限 64 个凭证;不缓存原始链,只存验过的结果和哈希 | +| 二进制 | 增量 ≤ 150 KB | 只用 BCL 的 ECDSA、HKDF、SHA-256;WebAuthn 只验断言(固定结构的 `authenticatorData`、对 `clientDataJSON` 只比三个字段),**不带通用 CBOR 解析**;注册和 attestation 解析只在 Backend 和浏览器里 | +| CPU | 每个成员一次完整验证(约 4–6 次 ECDSA 验签),小核心(Cortex-A53、MIPS 级)上 ≤ 100 毫秒;不重复验 | 只在状态同步时做;文档版本或成员链哈希没变就跳过;操作员集合或吊销表版本变化时,只重验受影响的成员 | +| 线程和定时器 | **不新增** | 信任状态上报搭现有心跳计数(每 N 次心跳带一次,约 5 分钟);验证在已有的状态处理里串行完成 | +| 流量 | 链约 2 KB,只在成员首次出现或变化时随期望状态下发;上报每次 < 100 字节 | 同步时带成员标识(哈希),未变化的不重发 | +| 输入上限 | 单条链 ≤ 8 KB、成员 ≤ 256、凭证 ≤ 64、单次同步最多验 N 个成员 | 超限在做任何密码学运算**之前**拒绝;Backend 也只下发它自己预先验过的链(挡掉垃圾,节点仍然独立验证) | + +**把预算变成测试** + +- `Fleet.Tests`:链验证耗时断言(CI 机器上每个成员 ≤ 20 毫秒)、输入上限的拒绝测试、同一份状态重复同步不重复验证(计数断言)。 +- 现有 `ArchitectureTests` 继续禁止节点引用新包;加一条检查:节点项目不引用 WebAuthn/CBOR 之类的库。 +- CI 记录节点二进制大小和启动后的常驻内存,增量超出预算就失败。 + +**Backend 侧** + +- WebAuthn 验证只发生在登录、提权、创建 key;API 请求认证是一次哈希查表,主体缓存 30 秒并在撤销时主动失效。 +- `enroll` key 签一张券是一次 HKDF 加一次 ECDSA 签名(不到 1 毫秒)。 +- 审计日志的写入并进 SQLite 单写入者的批量提交,不拖慢摄取路径。 +- webhook 投递、数据拉取都在 Backend 里完成(见 5.4),对节点零开销。 + +**日常摩擦** + +- 进页面不要求 passkey,信任类操作发生时才提权;窗口内连续操作不再弹。 +- 批量操作一次触控:批量吊销是一张吊销表一次签名,批量添加是窗口内连续签券;列表都支持全选。 +- 自动化走 `enroll` key,不需要人在场;其余自动化走普通 key。 +- 离线恢复密钥(见第 3 节)避免因为 passkey 丢失而重装节点。 + +## 17. 待确认 + +1. 窗口默认值:接入 15 分钟、吊销 5 分钟、凭证类不设窗口。 +2. API key 的 IP 白名单要不要做(倾向做,放在第 1 步)。 +3. 迁移后,现有 admin 令牌不能生成加入代码,需要触控创建 `enroll` key 来替换。 +4. `owner` 与 `admin` 之外要不要加只读的认证角色(目前用 `read` 能力的 API key 满足)。 +5. 属主的默认值:添加者的全部 passkey,还是只有签这次入网券的那一把;添加时最多允许绑定几把。 +6. `ControllerUpdate` 要几把属主签名(一把还是多数)。 +7. 静态数据的私钥来源:优先 WebAuthn PRF,不支持时是否接受「浏览器里的不可导出密钥」(只在这台浏览器能用)。 +8. 会话 AEAD 选 ChaCha20-Poly1305 还是 AES-GCM(两者 BCL 都有;小机器没有 AES 硬件指令时前者更快)。 +9. 信任状态上报的阈值:时钟偏差红线(入网券 TTL 的一半与 60 秒取小)、「长时间没有上报」是 10 分钟,是否合适。 +10. 安装脚本的哈希在哪里公布(Gitea 发布页之外还要不要放 DNS TXT 或另一个独立渠道)。 +11. 节点预算的数字(常驻内存 ≤ 1 MB、二进制 ≤ 150 KB、每个成员验证 ≤ 100 毫秒、成员上限 256)是否合适;超出预算时优先砍什么(比如先去掉操作员集合的 64 个凭证上限换成更小的)。 +12. webhook 和数据拉取的配额默认值(每分钟请求数、每天行数)和事件清单。 diff --git a/docs/api.md b/docs/api.md index 11e0909..7756dfc 100644 --- a/docs/api.md +++ b/docs/api.md @@ -15,7 +15,7 @@ ```text /api/public/* 只读。站点设为公开时匿名可访问,设为私有时需要登录或只读令牌; public_visible = false 的 Agent 对匿名访客隐藏 -/api/admin/* 管理。需要 Cookie 会话(含 CSRF 校验)或 admin 作用域的 API 令牌(`POST /api/admin/releases` 也接受只能登记发布清单的 release 令牌) +/api/admin/* 管理。需要 Cookie 会话(含 CSRF 校验)或带有对应**能力**的 API 令牌;每个端点声明自己需要的一项能力(OpenAPI 里的 `x-moshiro-capability`),完整目录见 `GET /api/admin/capabilities`,设计见 [access.md](access.md) /api/agent/v1/* 节点专用:注册(使用加入令牌)和 RPC(使用签名认证) /healthz /metrics /openapi/v1.json /install /install.ps1 ``` @@ -140,7 +140,10 @@ | GET | `/api/admin/raw/chunks/{chunk_id}` | 下载原始块(`application/vnd.moshiro.rawchunk`);过期返回 410 `raw_expired` | | GET | `/api/admin/events?agent_id=&kind=&from=&to=` | 事件 | | GET | `/api/admin/analysis/recompute` | 指标重算进度 | -| GET/POST/DELETE | `/api/admin/api-tokens` | 作用域为 `read`、`admin` 或 `release`(只能登记发布清单,给 CI 用,什么都读不了);明文只显示一次 | +| GET/POST/DELETE | `/api/admin/api-tokens` | 创建时勾选能力(`capabilities`,只能是创建者自己拥有能力的子集,写蕴含读)和可选的有效期(`expires_in_days`);明文只显示一次 | +| POST | `/api/admin/auth/passkey/options`、`/api/admin/auth/passkey/login` | 匿名(限流)。passkey 登录:先取挑战,再提交断言;响应同密码登录 | +| GET/POST/DELETE | `/api/admin/auth/passkeys`、`/auth/passkeys/options`、`/auth/passkeys/{id}` | 已登录用户管理自己的 passkey:列出、取注册挑战、注册、删除(API key 不能用) | +| GET | `/api/admin/capabilities` | 能力目录:分组、哪些改变信任、哪些是预留、依赖关系和预设(只读、运维、接入、CI 发布、全部) | | GET/PATCH | `/api/admin/settings` | 站点公开或私有、是否对访客显示构建信息(`site.show_build`)、保留期、节点定时重启(`fleet.restart_enabled`、`fleet.restart_hour`、`fleet.restart_minute`、`fleet.restart_utc_offset_minutes`,默认开、04:30、UTC+8;改完立即通知在线节点)等 | | GET | `/api/admin/build-info` | 后端和每个 Agent、Probe 的版本、tag、提交、构建时间、系统,以及节点宿主机更新器报告的更新状态(管理端「系统 → 版本」) | | GET/POST | `/api/admin/releases` | CI 签名的发布清单:POST 登记(`{manifest, signature}`,面板不验签、原样转给在线的节点和之后连上的节点),GET 看每个组件最新的一份 | @@ -178,7 +181,7 @@ | --- | --- | | 400 | `validation_failed`、`budget_exceeded` | | 401 | `unauthorized`、`csrf_failed` | -| 403 | `forbidden`(令牌作用域不足)、`site_private` | +| 403 | `forbidden`(凭证缺少端点需要的能力)、`site_private` | | 404 | `agent_not_found`、`task_not_found`、`test_not_found`、`chunk_not_found`、`unknown_kind`、`unknown_plan` | | 409 | `name_conflict`、`probe_offline`、`test_busy`、`agent_not_active` | | 410 | `raw_expired`、`join_token_expired` | diff --git a/docs/deployment.md b/docs/deployment.md index 0347125..f534dd0 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -232,6 +232,8 @@ sudo sh deploy-node.sh probe --image-repo registry.oeo.one/moshiro/moshiro-probe sudo sh deploy-node.sh backend --channel dev --admin-password '<密码>' --public-url https://<面板域名> # 其他环境变量用 --env 原样写入(只在首次创建 backend.env 时生效):本地开发前端要连这个面板时加 # --env MoShiro__CorsOrigins=http://localhost:5173,http://localhost:5174 --env MoShiro__SecureCookies=false +# 面板前面有反向代理(Caddy、frp、Cloudflare)时,登录限流和审计看到的是代理的地址:把代理的地址写进可信代理,才会采信转发头里的客户端地址 +# --env MoShiro__TrustedProxies=127.0.0.1,172.16.0.0/12 (头默认 X-Forwarded-For,可用 MoShiro__TrustedProxyHeader 改成 CF-Connecting-IP) # 之后更新:不带参数(旧版 Probe 的 probe.json 没有 fleet 段,加上 --server --join --name 重新运行即可,旧文件备份为 probe.json.old) sudo sh deploy-node.sh agent ``` @@ -305,6 +307,15 @@ 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。管理端仍能看到可更新、更新中、已回滚和无效清单状态。纯二进制更新器尚未实现。 +**换发布密钥**(泄露或定期轮换,不用重装节点): + +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 签名更新 Backend 也会收到 CI 发布的签名清单,但只提示管理端,不会自动重启。管理员确认精确的清单序号后,宿主更新器才会备份 Backend 数据 volume、按清单 digest 拉取镜像、重启服务并检查 `/healthz`;失败时恢复旧镜像和数据库备份。确认和发布通过同一个锁串行处理,避免确认过期版本。 diff --git a/docs/enrollment.md b/docs/enrollment.md index 614e3d3..512c7d3 100644 --- a/docs/enrollment.md +++ b/docs/enrollment.md @@ -9,7 +9,7 @@ | Agent | Fleet 节点身份(ECDSA P-256)+ 面向 Probe 的 TLS 证书 | `fleet-enroll` | 公钥、SPKI 指纹 | | Probe | Fleet 节点身份(连面板)+ 另一把独立的密钥(连 Agent,放在 `tls.pfx`) | `fleet-enroll`,和 Agent 一样用加入令牌(`role=probe`);可以有多个 Probe,每个独立吊销 | 两把公钥 | | 面板用户 | 账号 + 密码(PBKDF2-SHA256) | 面板 | 密码哈希 | -| API 令牌 | 随机高熵令牌,作用域为 `read` 或 `admin` | 面板 | SHA-256 | +| API 令牌 | 随机高熵令牌,带一组能力(见 [access.md](access.md)) | 面板 | SHA-256 | | 发布签名 | ECDSA P-256 发布密钥 | CI(Woodpecker 密钥 `release_signing_key`)或离线环境 | 只在节点宿主机的更新器里配置公钥(容器节点是 `moshiro-update`,见 [deployment.md](deployment.md);纯二进制节点的更新是待办),**面板不保存私钥,也不验签** | ## 2. Agent 接入流程 diff --git a/docs/security.md b/docs/security.md index 6c9795a..19a3a42 100644 --- a/docs/security.md +++ b/docs/security.md @@ -71,45 +71,28 @@ Agent、Backend、Probe 之间互相不信任。这份文档只回答三个问 不能做的:加入新节点、改地址让 Probe 去探内网、塞恶意更新、冒充节点。 ``` -## 5. 名册(roster) +## 5. 入网信任链 -操作员签名的舰队成员表,节点本地固定操作员的公钥,只采用验签通过的名册。 +下文和其他章节里的「名册」指节点本地验过链的成员表。**详细设计见 [access.md](access.md)**(账户、角色、能力、API key、提权窗口、签名链、流程和实施顺序)。这里只留结论: -```text - 管理端(管理员) Backend Agent / Probe - ┌──────────────┐ ①提交变更 ┌─────────┐ ┌──────────────┐ - │批准节点、改地址│───────────────▶│只存、转发│─② 名册 + 签名─▶ │本地固定操作员 │ - │或吊销 │◀─③ 挑战(哈希)─│ │ │公钥;验签通过 │ - │触控或确认 │ ④ 签名 │ │ │且 version 更大│ - │ │───────────────▶│ │ │才采用并持久化 │ - └──────────────┘ └─────────┘ └──────────────┘ -``` - -内容:`{version, expires_at?, 成员: [{id, 角色, 公钥(SPKI), Agent 的公网地址、端口和 TLS 指纹}]}`。 - -- Agent 只接受名册里的 Probe;Probe 只拨名册里的 Agent,并且只用名册里写的地址(缺口 G3、G6 因此消失,不需要另外的拨号白名单)。 -- **不可回放**:节点持久化见过的最大 `version`,低于它的名册一律拒绝,所以 Backend 无法拿旧名册复活已吊销的节点。 -- 可选的 `expires_at` 防止 Backend 截留新名册(冻结攻击)。 -- 每次改动成员或地址都要操作员签名一次:批准新节点(加入令牌的自动批准只让它连上,进名册仍要签)、公网地址变了、吊销。 -- 任务集、测试请求这类内容不进名册:它们由 Backend 计算,被节点本地硬上限兜住。 +- 名册不再是「每次变更都要操作员签一整张表」,而是一条可离线验证的链:节点固定创世 owner 公钥 → 操作员集合(带版本,owner 签)→ 委托(passkey 签,限定范围和时间)→ 入网券(委托的密钥签)→ 节点绑定(节点注册时用安装命令里的 `K_code` 签)。加上 passkey 当场签的吊销表。 +- Agent 只接纳链验得过的 Probe,Probe 只拨链验得过的 Agent,并且只拨公网地址(缺口 G3、G6 因此消失,不需要另外的拨号白名单)。 +- **不可回放**:节点持久化见过的最大操作员集合版本和吊销表版本,Backend 无法拿旧文档复活已吊销的节点。 +- 人在场时一次 passkey 触控换一个有时限的提权窗口,窗口内加节点不再确认;自动化用创建时经 passkey 签名的 `enroll` API key。被攻破的 Backend 在窗口和 key 过期后,一个节点都加不进来。 ## 6. 操作员凭证 -节点本地配置里固定一串操作员凭证,每条带类型,验签代码按类型分支;任一条通过即可,所以丢了一个设备不会被锁死,换凭证是改节点本地配置(重新运行部署脚本)。 +操作员凭证就是 passkey(WebAuthn,ES256,attestation 选 `none`)。节点本地固定创世 owner 公钥,其余凭证靠 owner 签名的操作员集合同步,不用重装节点;每条凭证带角色(`owner`、`admin`)和限制(例如单张入网券的最长有效期),由节点自己执行。 -| 类型 | 适合 | 节点怎么验 | 强度 | -| --- | --- | --- | --- | -| `webauthn` | 有通行密钥的设备 | 固定凭证公钥,验 `authenticatorData ‖ SHA256(clientDataJSON)` 的 ECDSA 签名,检查 `challenge` 等于名册哈希;注册时 attestation 选 `none`,信任浏览器实现,不验证设备证明 | 最强,每次改动需要本人触控 | -| `ecdsa` | 没有通行密钥的设备 | 管理端用 WebCrypto 生成不可导出的 P-256 密钥,存在受信任的 Chromium 里,直接对名册哈希签名 | 中:本机被入侵或页面被 XSS 时可被静默调用 | -| `oidc` | 完全不方便装密钥的设备 | 授权请求的 `nonce` 填名册哈希,节点验 ID Token 的签名(固定 IdP 的签名公钥)、`aud`、`sub`、`nonce` 和新鲜度 | 较弱:IdP(例如 Gitea)被攻破就等于操作员被攻破;节点没有 HTTP 客户端,IdP 公钥只能固定在本地,换密钥要改配置 | - -三种在节点上都只用 BCL 的 ECDSA 或 RSA 验签,不增加依赖和资源。 +- 登录可以用密码、passkey 或 OIDC,但**只有 passkey 能提权**:节点验证不了 IdP 的断言,也不该让密码和 IdP 进入信任域。OIDC 只是账户的另一种登录方式(绑定 `iss + sub`)。 +- 自动化凭证:`enroll` API key 创建时由 passkey 签一份委托;CI 和测试用软件 ECDSA 凭证,写进测试的操作员集合。 +- 恢复:passkey 全部丢失时,重装节点并换创世公钥;所以每个账户至少注册两个 passkey。 ## 7. 各类下行消息怎么处置 | 下行内容 | 授权 | 被攻破的 Backend 最多能做什么 | | --- | --- | --- | -| 舰队成员、对端公钥、Agent 地址 | **操作员签名**(名册) | 什么都改不了 | +| 舰队成员、对端公钥 | **操作员签名**的入网链和吊销表 | 什么都改不了;Agent 地址由 Backend 下发,但 Probe 只拨公网地址 | | 更新清单 | 发布密钥(已有) | 不能伪造;能转发旧的合法清单,`seq` 防回滚 | | 任务集、测试请求 | Backend 计算;节点本地硬上限兜底(已有) | 只能在名册成员之间、本地速率和端口上限内发测量流量 | | 重启计划(`moshiro.schedule`) | Backend;节点本地启动后 10 分钟内不触发 | 最多让节点每 10 分钟重启一次 | @@ -251,13 +234,14 @@ Agent 和 Probe 的核心永远是纯采集器。**所有敏感能力**(exec ``` - **独立进程,不是插件**:extension 是单独的二进制和镜像,和核心之间**没有进程内接口**,核心不加载任何外部代码、不用反射和动态加载,所以核心的 NativeAOT 和「不能 exec」都不受影响。extension 自己用什么技术实现都行(可以是 NativeAOT,也可以不是)。 +- **授权只认节点的属主**:每次调用的能力令牌必须由这个节点绑定的属主 passkey 签名,账户角色(owner、admin)不赋予权力;管理员只能删除节点或卸载 extension,读不到、用不了;交互式会话端到端加密。属主的设计见 [access.md](access.md) 第 8 节。 - **独立身份**:extension 有自己的密钥、自己向 Backend 注册,在名册里是单独的成员(角色 `extension`,记录它依附的节点 ID)。核心被攻破碰不到 extension 的密钥,反过来也一样。 - **独立发布**:沿用签名更新的流程,`component` 不同(例如 `ext-ssh`),独立的镜像、独立的清单;装不装、升不升级都不影响核心的更新。 - **独立命名空间**:extension 的方法只能是 `ext.<名字>.*`,同样进方向声明表;核心的声明表里没有 `ext.*`,核心收到一律拒绝。extension 只响应自己命名空间的方法。 - **安装像装一个选项**:部署脚本加一个选项(例如 `--extension ssh`)拉起对应容器并写好配置;管理端在节点详情里出现对应入口。卸载就是停掉那个容器。 - **本地显式启用**:extension 的本地配置里有 `enabled`、允许的操作员凭证、允许的命令集或目标范围(例如允许登录的用户、允许访问的路径),默认关闭,没有配置就什么都不做。 - **每次调用都要授权**:操作员签名的一次性能力令牌,`{节点 ID, extension, 命令哈希, not_before, not_after, nonce}`,有效期以秒计;extension 用本地固定的操作员凭证验签。被攻破的 Backend 不能启用 extension,也伪造不了一次调用。 -- **交互式会话的内容对 Backend 不透明**(例如 web ssh):浏览器和 extension 在能力令牌携带的公钥基础上协商一次性会话密钥,Backend 只转发密文,不应该能读到终端里的内容。具体做法还没定,见第 14 节。 +- **交互式会话的内容对 Backend 不透明**(例如 web ssh):浏览器和 extension 在能力令牌携带的公钥基础上协商一次性会话密钥,Backend 只转发密文,不应该能读到终端里的内容。做法见 [access.md](access.md) 第 8.2 节:属主 passkey 授权、临时 ECDH 加 AEAD,静态数据用属主的 `enc_pubkey` 封装。 - **不做的**:核心里的任何 exec、文件写、加载 extension;用 extension 绕过核心的本地硬上限(extension 有它自己的上限,核心的不放宽)。 ## 12. 实施顺序 @@ -266,7 +250,7 @@ Agent 和 Probe 的核心永远是纯采集器。**所有敏感能力**(exec 零 协议方向声明表 + 收发校验 + 对账测试 G7,第 8 节;同时把 NodeConnection 按职责拆开(第 13.2 节) 已完成,不破坏 一 Probe 自己生成密钥 + 加入令牌注册 + 密钥按用途拆开 G2、G11 已完成,破坏性:旧 probe.json 失效 二 预握手敲门(等级一) G4;先基于现有的对端公钥列表,名册出来后换成名册 已完成,破坏性:旧 Probe 被拒绝 - 三 操作员凭证 + 名册 + 节点验签与持久化 G1 的大部分、G3、G6;webauthn 和 ecdsa 先做,oidc 随后 破坏性:节点要固定操作员公钥并重新注册 + 三 访问控制 + 入网信任链 G1 的大部分、G3、G6;拆成 5 步,见 [access.md](access.md) 第 12 节:能力模型与 API key → 账户与 passkey → 操作员集合 → 入网链 → 吊销 破坏性:节点要固定创世公钥并重新注册;旧 admin 令牌不能再生成加入代码 四 测量数据的可信 + 入库配额和输入校验 + CSP G5、G8、G9、G10、T5(第 9.1、9.2 节) 破坏性:块格式加签名 五 Backend 应用层身份固定 + 下行消息签名 G1 的剩余部分(链路劫持) 破坏性 六 extension 框架 独立身份和名册条目、`ext.*` 命名空间、能力令牌、部署脚本的 --extension 选项(先不做具体的 ssh) @@ -316,13 +300,13 @@ Agent 和 Probe 的核心永远是纯采集器。**所有敏感能力**(exec ## 14. 还没定的事 -1. 操作员凭证第一批做 `webauthn` 和 `ecdsa`,`oidc` 放在名册稳定之后,是否这样;还是 `oidc` 一起做。 -2. 敲门先做等级一,等级二先记 TODO,是否这样。 -3. 严格程度:节点没有固定操作员公钥就拒绝一切 Backend 给的对端和地址(推荐),还是保留一个过渡期兼容旧行为。 -4. 管理员域(T4)的审计日志、令牌过期、危险操作二次确认:还没有核对现状,也没有设计。 -5. 节点面和外部面是否拆成两个监听端口(8.1 的目标):拆的话部署文档、Caddy 和隧道的配置都要跟着改。 -6. extension 的交互式会话要不要做到「Backend 只转发密文」(端到端加密),还是先做成 Backend 可见明文、以后再加。 -7. extension 的第一个具体实现做不做 web ssh,放在哪个阶段。 -8. 块摘要固定 SHA-256,不预留算法字段(不协商、不降级,见第 9.4 节)。 -9. Agent 的 `max_total_mbps` 默认值调到多少(第 9.5 节),比如 100。 -10. 块的签名放进格式的哪个位置、是否影响现有 `MSRC` 容器版本(第 9.1 节)。 +访问控制与入网信任链的细节、待确认的默认值见 [access.md](access.md) 第 17 节。这里只列其余的: + +1. 敲门先做等级一,等级二先记 TODO(已按此实现等级一)。 +2. 严格程度:节点没有固定操作员公钥就拒绝一切 Backend 给的对端(默认),弱信任模式是安装时的显式选择(见 access.md 第 8 节)。 +3. 节点面和外部面是否拆成两个监听端口(8.1 的目标):拆的话部署文档、Caddy 和隧道的配置都要跟着改。 +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。 +8. 块的签名放进格式的哪个位置、是否影响现有 `MSRC` 容器版本(第 9.1 节)。 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 5c43dea..ba945c2 100644 --- a/packaging/install/install.ps1 +++ b/packaging/install/install.ps1 @@ -1,13 +1,51 @@ -# MoShiro 节点一键安装(Windows,Agent 和 Probe 共用同一份)。面板在 /install.ps1?code=… 生成这份脚本,把下面四个值填好: -# irm 'https://panel/install.ps1?code=jt_…' | iex +# 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 '' + $spki = [Convert]::FromBase64String($b64.Trim()) + $xy = $spki[($spki.Length - 64)..($spki.Length - 1)] + $blob = [byte[]]@(0x45, 0x43, 0x53, 0x31, 0x20, 0, 0, 0) + $xy + $i = 2 + if ($derSignature[1] -band 0x80) { $i = 2 + ($derSignature[1] -band 0x7f) } + $parts = @() + for ($n = 0; $n -lt 2; $n++) { + $len = $derSignature[$i + 1] + $part = [byte[]]$derSignature[($i + 2)..($i + 1 + $len)] + while ($part.Length -gt 32 -and $part[0] -eq 0) { $part = [byte[]]$part[1..($part.Length - 1)] } + if ($part.Length -gt 32) { return $false } + $parts += , ([byte[]](New-Object byte[] (32 - $part.Length)) + $part) + $i += 2 + $len + } + $key = [Security.Cryptography.CngKey]::Import($blob, [Security.Cryptography.CngKeyBlobFormat]::EccPublicBlob) + try { + $ecdsa = New-Object Security.Cryptography.ECDsaCng($key) + return $ecdsa.VerifyData($data, [byte[]]($parts[0] + $parts[1]), [Security.Cryptography.HashAlgorithmName]::SHA256) + } finally { $key.Dispose() } +} + 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 = @' +-----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._-]', '-' @@ -34,6 +72,34 @@ function Install-MoShiro { return } + # 验发布清单:签名对不上、组件不对、digest 格式不对都直接拒绝,不装 + $client = New-Object System.Net.WebClient + $manifest = $null + try { + $manifest = $client.DownloadData("$server/install/manifest?code=$code") + $signature = [Convert]::FromBase64String(($client.DownloadString("$server/install/manifest.sig?code=$code")).Trim()) + } catch { + $manifest = $null + } + if ($null -eq $manifest) { + 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" + } else { + if (-not (Test-MoShiroSignature $manifest $signature $releaseKey)) { + Write-Error 'the release signature does not verify against the built-in release key; refusing to install' + return + } + $release = [Text.Encoding]::UTF8.GetString($manifest) | ConvertFrom-Json + if ($release.component -ne $role) { Write-Error "the signed release is for '$($release.component)', not '$role'"; return } + if ($release.digest -notmatch '^sha256:[0-9a-f]{64}$') { Write-Error 'the signed release has a malformed digest'; return } + if ($release.image -notmatch '^[A-Za-z0-9._/:-]+$') { Write-Error 'the signed release has a malformed image name'; return } + $image = "$($release.image)@$($release.digest)" + Write-Host "verified release $($release.version) ($($release.tag)): $image" + } + Write-Host "pulling $image" docker pull $image | Out-Null if ($LASTEXITCODE -ne 0) { @@ -46,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 1a012ac..2a5659b 100644 --- a/packaging/install/install.sh +++ b/packaging/install/install.sh @@ -1,18 +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_NAME=节点名字(名字被占用时用)、MOSHIRO_ENGINE=docker|podman、MOSHIRO_DRY_RUN=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@@' +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 @@ -39,6 +54,42 @@ volume="moshiro-$ROLE-config" linux=0 [ "$(uname -s)" = Linux ] && linux=1 +if [ "${MOSHIRO_DRY_RUN:-}" != 1 ] && eng inspect "$container" >/dev/null 2>&1 /dev/null /dev/null /dev/null " + say "warning: installing $IMAGE without signature verification" + return 0 + fi + command -v openssl >/dev/null 2>&1 || die "openssl is required to verify the release signature" + printf '%s\n' "$RELEASE_KEY" > "$tmp/key.pem" + openssl base64 -d -A -in "$tmp/sig.b64" -out "$tmp/sig.bin" 2>/dev/null || die "the release signature is not valid base64" + openssl dgst -sha256 -verify "$tmp/key.pem" -signature "$tmp/sig.bin" "$tmp/manifest" >/dev/null 2>&1 \ + || die "the release signature does not verify against the built-in release key; refusing to install" + field() { sed -n "s/.*\"$1\":\"\([^\"]*\)\".*/\1/p" "$tmp/manifest" | head -n 1; } + [ "$(field component)" = "$ROLE" ] || die "the signed release is for '$(field component)', not '$ROLE'" + repo="$(field image)" + digest="$(field digest)" + case "$digest" in sha256:????????????????????????????????????????????????????????????????) ;; *) die "the signed release has a malformed digest" ;; esac + case "$repo" in ''|*[!A-Za-z0-9._/:-]*) die "the signed release has a malformed image name" ;; esac + IMAGE="$repo@$digest" + say "verified release $(field version) ($(field tag)): $IMAGE" +} +resolve_image + restart=unless-stopped [ "$engine" = podman ] && restart=always set -- run -d --name "$container" --restart "$restart" \ @@ -57,20 +108,18 @@ 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 $*" exit 0 fi -if eng inspect "$container" >/dev/null 2>&1 /dev/null /dev/null "$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 64f14c3..ffdf817 100644 --- a/web/admin/src/App.tsx +++ b/web/admin/src/App.tsx @@ -1,10 +1,15 @@ -import { FlaskConical, ListChecks, LogOut, Plus, Radar, Server, Settings } from 'lucide-react' -import { useState, type FormEvent } from 'react' +import { FlaskConical, KeyRound, ListChecks, LogOut, Plus, Radar, Server, Settings } from 'lucide-react' +import { useEffect, useRef, useState, type FormEvent } from 'react' import { NavLink, Navigate, Route, Routes } from 'react-router-dom' +import { ApiError } from '@shared/api/client' import { activateSource, getBase, listSources } from '@shared/api/source' -import { describeError, Field, SourceButton, Spinner, ThemeToggle } from '@shared/ui' +import { conditionalSupported, passkeySupported } from '@shared/api/webauthn' +import { describeError, Field, Segmented, SourceButton, Spinner, ThemeToggle } from '@shared/ui' 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' @@ -12,9 +17,9 @@ import { System } from './pages/System' import { Tasks } from './pages/Tasks' import { Tests } from './pages/Tests' -/** 登录页:左边选后端(这个页面自己所在的,或保存过的,或添加一个),右边都是用户名 + 密码。 */ +/** 登录页:左边选后端(这个页面自己所在的,或保存过的,或添加一个),右边选登录方式:账号密码,或者 Passkey(只有页面自己所在的后端有)。 */ function Login() { - const { login } = useAuth() + const { login, loginWithPasskey } = useAuth() const active = getBase() const saved = listSources() const [adding, setAdding] = useState(false) @@ -47,6 +52,91 @@ function Login() { } } + // passkey 绑定在后端的域名上:只有页面自己所在的后端能用,别的后端(跨域)不显示 + const canPasskey = passkeySupported() && !adding && !active + // 登录方式:记住上次用的;浏览器不支持或者是别的后端就只有账号密码 + const [method, setMethod] = useState<'password' | 'passkey'>(() => { + try { + return localStorage.getItem('moshiro.login_method') === 'passkey' ? 'passkey' : 'password' + } catch { + return 'password' + } + }) + const pickMethod = (next: 'password' | 'passkey') => { + setMethod(next) + setError('') + try { + localStorage.setItem('moshiro.login_method', next) + } catch { + // 记不住也只是下次回到默认 + } + } + const usingPasskey = canPasskey && method === 'passkey' + // 自动填充只在「账号密码」页有用(它依附在用户名输入框上) + const autofillActive = canPasskey && method === 'password' + // 重新开始自动填充登录(点按钮的弹窗会顶掉它,取消之后要重新挂上) + const [round, setRound] = useState(0) + const retry = () => { + if (retryOnFocus.current) { + retryOnFocus.current = false + setRound((n) => n + 1) + } + } + const conditional = useRef(null) // 正在挂着的自动填充请求:点按钮之前要先取消,浏览器同一时间只允许一个 WebAuthn 请求 + const retryOnFocus = useRef(false) // 服务器拒绝了自动填充选的 passkey:等用户再点输入框时才重新开始,不自己循环 + const usePasskey = async () => { + setBusy(true) + setError('') + conditional.current?.abort() + await new Promise((resolve) => setTimeout(resolve, 0)) + try { + await loginWithPasskey() + } catch (err) { + // 用户取消(NotAllowedError)不当成错误 + if (!(err instanceof DOMException && err.name === 'NotAllowedError')) setError(describeError(err)) + } finally { + setBusy(false) + setRound((n) => n + 1) + } + } + + // passkey 自动填充:点用户名输入框,浏览器会把保存的 passkey(Chrome 里是 Google 密码管理工具)列在下拉里,选一个就直接登录。 + // 挑战 5 分钟过期,所以每 4 分钟换一个 + useEffect(() => { + if (!autofillActive) return undefined + const abort = new AbortController() + conditional.current = abort + let timer: ReturnType | undefined + let expired = false // 只有「挑战到期」才重新开始;组件卸载、依赖变化时的取消不能再触发重来(否则会形成循环) + void conditionalSupported().then(async (ok) => { + if (abort.signal.aborted) return + if (!ok) return + timer = setTimeout(() => { + expired = true + abort.abort() + }, 4 * 60_000) + try { + await loginWithPasskey({ conditional: true, signal: abort.signal }) + } catch (err) { + if (timer) clearTimeout(timer) + if (abort.signal.aborted) { + if (expired) setRound((n) => n + 1) + return + } + // 取消、被弹窗顶掉、浏览器不允许都不是错误;服务端拒绝(含限流)才显示 + if (!(err instanceof DOMException)) { + retryOnFocus.current = true + setError(err instanceof ApiError && err.status === 401 ? '这个 Passkey 在服务器上已经不存在或已失效(可能被删除了),请换一个,或者用密码登录' : describeError(err)) + } + } + }) + return () => { + abort.abort() + if (timer) clearTimeout(timer) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [autofillActive, round]) + const rows = [{ base: '', label: location.host }, ...saved.map((x) => ({ base: x.base, label: x.base.replace(/^https?:\/\//i, '') }))] const title = adding ? '添加后端' : active ? active.replace(/^https?:\/\//i, '') : location.origin @@ -91,21 +181,46 @@ function Login() {

{title}

- {adding ? ( - - setUrl(e.target.value)} placeholder="例如 https://你的域名" autoComplete="off" autoFocus required /> - + {canPasskey ? ( + ) : null} - - setUsername(e.target.value)} autoComplete="username" autoFocus={!adding} required /> - - - setPassword(e.target.value)} autoComplete="current-password" required /> - - {error ?
{error}
: null} - + {usingPasskey ? ( + <> + {error ?
{error}
: null} + +

+ 还没有 Passkey?先用「账号密码」登录,到「系统 → 用户 → 我的 Passkey」添加。 +

+ + ) : ( + <> + {adding ? ( + + setUrl(e.target.value)} placeholder="例如 https://你的域名" autoComplete="off" autoFocus required /> + + ) : null} + + setUsername(e.target.value)} autoComplete={autofillActive ? 'username webauthn' : 'username'} autoFocus={!adding} required onFocus={retry} onClick={retry} /> + + + setPassword(e.target.value)} autoComplete="current-password" required /> + + {error ?
{error}
: null} + + + )}
@@ -128,6 +243,8 @@ export function App() { ) } if (!me) return + // 密码登录的会话没有任何能力:只给它添加 Passkey 的页面 + if (me.kind === 'session' && me.auth_method === 'password') return return (
@@ -147,13 +264,14 @@ export function App() {
))}
+
{(me.username ?? 'A').slice(0, 1).toUpperCase()}
{me.username ?? 'API 令牌'}
- {me.scope} + {me.kind === 'token' ? `API 令牌 · ${me.capabilities.length} 项能力` : '管理员'}
@@ -164,6 +282,7 @@ export function App() {
+ } /> } /> diff --git a/web/admin/src/BackendUpdateNotice.tsx b/web/admin/src/BackendUpdateNotice.tsx index f56006f..4c20f9d 100644 --- a/web/admin/src/BackendUpdateNotice.tsx +++ b/web/admin/src/BackendUpdateNotice.tsx @@ -16,7 +16,7 @@ export function BackendUpdateNotice() { const { busy, run } = useAction() useEffect(() => { - if (me?.scope !== 'admin') return + if (!me?.capabilities.includes('update.read')) return let alive = true const receive = (value: BackendUpdateDto) => { if (!alive) return @@ -27,7 +27,7 @@ export function BackendUpdateNotice() { const source = openLive('/api/admin/backend/update/live') source.addEventListener('backend_update', (event) => receive(JSON.parse(event.data) as BackendUpdateDto)) return () => { alive = false; source.close() } - }, [me?.scope]) + }, [me?.capabilities]) useEffect(() => { if (!requested || !update) return diff --git a/web/admin/src/ElevationBar.tsx b/web/admin/src/ElevationBar.tsx new file mode 100644 index 0000000..d86b3ec --- /dev/null +++ b/web/admin/src/ElevationBar.tsx @@ -0,0 +1,77 @@ +import { ShieldCheck } from 'lucide-react' +import { useCallback, useEffect, useState } from 'react' +import { api, ApiError, setElevationHandler } from '@shared/api/client' +import type { ElevationDto, Page } from '@shared/api/types' +import { ELEVATED_EVENT, elevate, passkeySupported, type ElevationClass } from '@shared/api/webauthn' +import { useAuth } from './auth' + +const NAMES: Record = { enroll: '添加节点', revoke: '吊销', credential: '改凭证' } + +function remaining(until: string, now: number): string { + const seconds = Math.max(0, Math.round((Date.parse(until) - now) / 1000)) + return seconds >= 60 ? `${Math.ceil(seconds / 60)} 分钟` : `${seconds} 秒` +} + +/** + * 敏感操作要先触碰 passkey:这里注册全局的处理函数(API 返回 `elevation_required` 时自动弹出触碰,成功后重试原请求), + * 并在侧栏显示当前会话开着的提权窗口和剩余时间,可以提前结束。 + */ +export function ElevationBar() { + const { me } = useAuth() + const [windows, setWindows] = useState([]) + const [now, setNow] = useState(() => Date.now()) + const signedIn = !!me?.user_id + + const refresh = useCallback(() => { + if (!signedIn) return + api + .get>('/api/admin/auth/elevation') + .then((page) => setWindows(page.items)) + .catch(() => undefined) + }, [signedIn]) + + useEffect(() => { + if (!signedIn || !passkeySupported()) return undefined + setElevationHandler(async (kind) => { + try { + return await elevate(kind as ElevationClass) + } catch (e) { + if (e instanceof ApiError && e.status === 400) throw new Error('先添加一个 Passkey 才能做这个操作(系统 → 用户 → 我的 Passkey)') + throw e + } + }) + refresh() + window.addEventListener(ELEVATED_EVENT, refresh) + const tick = setInterval(() => setNow(Date.now()), 1000) + return () => { + setElevationHandler(null) + window.removeEventListener(ELEVATED_EVENT, refresh) + clearInterval(tick) + } + }, [signedIn, refresh]) + + const open = windows.filter((w) => Date.parse(w.expires_at) > now) + if (open.length === 0) return null + return ( +
+
+ + 已用 Passkey 确认 + +
+ {open.map((w) => ( +
+ {NAMES[w.class] ?? w.class}:还剩 {remaining(w.expires_at, now)} +
+ ))} +
+ ) +} diff --git a/web/admin/src/PasskeyNotice.tsx b/web/admin/src/PasskeyNotice.tsx new file mode 100644 index 0000000..b17c18f --- /dev/null +++ b/web/admin/src/PasskeyNotice.tsx @@ -0,0 +1,92 @@ +import { KeyRound } 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 { Dialog } from '@shared/ui' +import { useAuth } from './auth' +import { PasskeyAddForm } from './pages/PasskeyAdd' + +const SNOOZE_KEY = 'moshiro.passkey_nag_until' +const SNOOZE_MS = 7 * 24 * 3600 * 1000 + +function snoozed(): boolean { + try { + return Number(localStorage.getItem(SNOOZE_KEY) ?? 0) > Date.now() + } catch { + return false + } +} + +/** + * 登录之后,如果这个用户还没有 Passkey,提醒他添加:Passkey 登录不用输密码,而且只有 passkey 登录的会话以后能做加节点这类信任操作。 + * 只对页面所在的后端提醒(passkey 绑定在后端的域名上);「稍后提醒」7 天内不再出现。 + */ +export function PasskeyNotice() { + const { me } = useAuth() + const [missing, setMissing] = useState(false) + const [adding, setAdding] = useState(false) + + useEffect(() => { + if (!me?.user_id || getBase() || !passkeySupported() || snoozed()) return + let alive = true + api + .get>('/api/admin/auth/passkeys') + .then((list) => alive && setMissing(list.items.length === 0)) + .catch(() => undefined) + return () => { + alive = false + } + }, [me?.user_id]) + + if (!missing) return null + const later = () => { + try { + localStorage.setItem(SNOOZE_KEY, String(Date.now() + SNOOZE_MS)) + } catch { + // 存不了也只是下次还会提醒 + } + setMissing(false) + } + return ( +
+
+ + + 建议添加 Passkey:用指纹、人脸或安全密钥登录,不用输密码;以后加节点这类敏感操作也需要它。 + + + + + +
+ {adding ? ( + setAdding(false)} + actions={ + + } + > +

+ 建议至少注册两个(比如这台电脑和手机),其中一个丢了还能登录。也可以随时在「系统 → 用户 → 我的 Passkey」里管理。 +

+ { + setAdding(false) + setMissing(false) + }} + /> +
+ ) : null} +
+ ) +} 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/auth.tsx b/web/admin/src/auth.tsx index 7c00a0e..ba53ec9 100644 --- a/web/admin/src/auth.tsx +++ b/web/admin/src/auth.tsx @@ -2,12 +2,15 @@ import { createContext, useCallback, useContext, useEffect, useState, type React import { api, ApiError, loginTo, setCsrf } from '@shared/api/client' import { getBase, normalizeBase, setSource, setToken } from '@shared/api/source' import type { LoginResponse, MeDto } from '@shared/api/types' +import { signInWithPasskey } from '@shared/api/webauthn' interface Auth { me: MeDto | null ready: boolean /** 用户名密码登录。`base` 是新添加的后端地址(登录成功才保存);不给就登录当前连着的后端。 */ login: (username: string, password: string, base?: string) => Promise + /** passkey 登录(只对页面所在的后端;passkey 绑定在后端的域名上)。 */ + loginWithPasskey: (opts?: { conditional?: boolean; signal?: AbortSignal }) => Promise logout: () => Promise } @@ -48,7 +51,14 @@ export function AuthProvider({ children }: { children: ReactNode }) { } const r = await api.post('/api/admin/auth/login', { username, password }) setCsrf(r.csrf_token) - setMe({ kind: 'user', scope: 'admin', user_id: r.user_id, username: r.username, csrf_token: r.csrf_token }) + // 登录响应没有能力列表:登录后取一次当前身份 + setMe(await api.get('/api/admin/auth/me')) + }, []) + + const loginWithPasskey = useCallback(async (opts?: { conditional?: boolean; signal?: AbortSignal }) => { + const r = await signInWithPasskey(opts) + setCsrf(r.csrf_token) + setMe(await api.get('/api/admin/auth/me')) }, []) const logout = useCallback(async () => { @@ -63,7 +73,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { } }, []) - return {children} + return {children} } export function useAuth(): Auth { diff --git a/web/admin/src/pages/AgentDetail.tsx b/web/admin/src/pages/AgentDetail.tsx index 0ebdc4c..aa2d444 100644 --- a/web/admin/src/pages/AgentDetail.tsx +++ b/web/admin/src/pages/AgentDetail.tsx @@ -264,6 +264,12 @@ export function AgentDetail() {
{a.public_visible ? '是' : '否'}
注册时间
{fmtTime(a.created_at)}
+ {a.added_by ? ( + <> +
添加者
+
{a.added_by}
+ + ) : null}
最后在线
{fmtTime(a.last_seen_at)}
标签
diff --git a/web/admin/src/pages/Agents.tsx b/web/admin/src/pages/Agents.tsx index b8b7673..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))} /> +
+ + + +

创建于 {fmtTime(p.created_at)} + {p.added_by ? `,由 ${p.added_by} 添加` : ''} {p.revoked_at ? `,吊销于 ${fmtTime(p.revoked_at)}` : ''}

{p.connections.length > 0 ? ( diff --git a/web/admin/src/pages/System.tsx b/web/admin/src/pages/System.tsx index 60a088a..ce9f27d 100644 --- a/web/admin/src/pages/System.tsx +++ b/web/admin/src/pages/System.tsx @@ -1,14 +1,17 @@ import { Download, KeyRound, Plus, Trash2 } from 'lucide-react' -import { useEffect, useState, type ReactNode } from 'react' +import { useEffect, useMemo, useState, type ReactNode } from 'react' import { api } from '@shared/api/client' -import type { AgentDto, ApiTokenDto, BuildInfoDto, NodeUpdateDto, ReleaseDto, CreatedApiTokenDto, EventDto, Page, ProbeDto, RawChunkMetaDto, RecomputeDto, SettingDto, UserDto } from '@shared/api/types' +import type { AgentDto, ApiTokenDto, BuildInfoDto, CapabilityCatalogDto, MeDto, PasskeyDto, NodeUpdateDto, ReleaseDto, CreatedApiTokenDto, AuditDto, EventDto, Page, ProbeDto, RawChunkMetaDto, RecomputeDto, SettingDto, UserDto } from '@shared/api/types' import { fmtBuiltAt, frontendBuild } from '@shared/buildInfo' import { fmtNum, fmtTime } from '@shared/format' import { translateSetting } from '@shared/i18n' import { useQuery } from '@shared/hooks' import { Chip, Confirm, CopyButton, Dialog, Empty, ErrorNotice, Field, Segmented, Spinner, useAction } from '@shared/ui' +import { CapabilityPicker, capabilityName } from './CapabilityPicker' +import { PasskeyAddForm } from './PasskeyAdd' +import { Trust } from './Trust' -type Tab = 'settings' | 'versions' | 'tokens' | 'users' | 'events' | 'raw' | 'analysis' +type Tab = 'settings' | 'versions' | 'tokens' | 'users' | 'trust' | 'audit' | 'events' | 'raw' | 'analysis' const RESTART_KEYS = ['fleet.restart_enabled', 'fleet.restart_hour', 'fleet.restart_minute', 'fleet.restart_utc_offset_minutes'] @@ -304,47 +307,93 @@ function Versions() { function Tokens() { const tokens = useQuery(() => api.get>('/api/admin/api-tokens'), []) + const catalog = useQuery(() => api.get('/api/admin/capabilities'), []) + const me = useQuery(() => api.get('/api/admin/auth/me'), []) const { run } = useAction() const [creating, setCreating] = useState(false) const [name, setName] = useState('') - const [scope, setScope] = useState<'read' | 'admin' | 'release'>('read') + const [days, setDays] = useState(null) + const [caps, setCaps] = useState>(new Set()) const [created, setCreated] = useState() - const [revoking, setRevoking] = useState() + const [revoking, setRevoking] = useState() + const [picked, setPicked] = useState>(new Set()) + const items = tokens.data?.items ?? [] + const active = items.filter((t) => !t.revoked_at) + const allPicked = active.length > 0 && active.every((t) => picked.has(t.id)) + const mine = useMemo(() => new Set(me.data?.capabilities ?? []), [me.data]) + const trust = useMemo(() => new Set((catalog.data?.groups ?? []).flatMap((g) => g.capabilities.filter((c) => c.trust).map((c) => c.id))), [catalog.data]) + const open = () => { + setName('') + setDays(null) + setCaps(new Set()) + setCreating(true) + } return (
- 给脚本和其他程序用的令牌:read 只能读展示数据,admin 可以调用全部管理接口。 - + 给脚本和其他程序用的令牌:按需勾选能力,只能授予自己拥有的能力。 + + {picked.size > 0 ? ( + + ) : null} + +
- {(tokens.data?.items ?? []).length === 0 ? ( + {items.length === 0 ? ( 没有 API 令牌 ) : ( + - + + - {tokens.data?.items.map((t) => ( + {items.map((t) => ( - + + +
+ setPicked(e.target.checked ? new Set(active.map((t) => t.id)) : new Set())} /> + 名称权限能力过期 创建 最后使用
{t.name} - {t.scope} + { + const next = new Set(picked) + if (e.target.checked) next.add(t.id) + else next.delete(t.id) + setPicked(next) + }} + /> {t.name} + trust.has(c)) ? 'warn' : ''}>{t.capabilities.length} 项{' '} + + {t.capabilities.slice(0, 2).map(capabilityName).join('、')} + {t.capabilities.length > 2 ? '…' : ''} + + {t.expires_at ? fmtTime(t.expires_at) : '不过期'} {fmtTime(t.created_at)} {t.revoked_at ? 已吊销 : fmtTime(t.last_used_at)} {!t.revoked_at ? ( - @@ -358,6 +407,7 @@ function Tokens() { {creating ? ( setCreating(false)} actions={ <> @@ -367,16 +417,15 @@ function Tokens() { ) : null} {created ? ( - setCreated(undefined)} actions={}> + setCreated(undefined)} + actions={ + + } + >
令牌明文只显示这一次,请现在就复制。
{created.plaintext}
+

+ {created.token.capabilities.length} 项能力:{created.token.capabilities.map(capabilityName).join('、')}。{created.token.expires_at ? `${fmtTime(created.token.expires_at)} 过期。` : '不会过期。'} +

) : null} {revoking ? ( - setRevoking(undefined)} onConfirm={async () => { await run(() => api.del(`/api/admin/api-tokens/${revoking.id}`), '已吊销'); tokens.reload() }}> - 使用「{revoking.name}」的程序会立刻失去访问权限。 + setRevoking(undefined)} + onConfirm={async () => { + for (const t of revoking) await run(() => api.del(`/api/admin/api-tokens/${t.id}`), `已吊销 ${t.name}`) + setPicked(new Set()) + tokens.reload() + }} + > + 使用 {revoking.map((t) => `「${t.name}」`).join('、')} 的程序会立刻失去访问权限。 + + ) : null} + + ) +} + +function Passkeys() { + const list = useQuery(() => api.get>('/api/admin/auth/passkeys'), []) + const { run } = useAction() + const [removing, setRemoving] = useState() + const items = list.data?.items ?? [] + return ( +
+
+ 我的 Passkey + 用指纹、人脸或安全密钥登录,不用输密码。建议至少注册两个(比如手机和安全密钥)。 +
+ + {items.length === 0 ? ( + 还没有 Passkey + ) : ( + + + + + + + + + + {items.map((p) => ( + + + + + + + ))} + +
名称创建最后使用 +
+ {p.name} {p.transports.includes('internal') ? 本机 : p.transports.length > 0 ? {p.transports.join('、')} : null} + {fmtTime(p.created_at)}{p.last_used_at ? fmtTime(p.last_used_at) : '从未'} + +
+ )} + + {removing ? ( + setRemoving(undefined)} onConfirm={async () => { await run(() => api.del(`/api/admin/auth/passkeys/${removing.id}`), '已删除'); list.reload() }}> + 「{removing.name}」删除之后不能再用来登录。如果这是你唯一的 Passkey,仍然可以用密码登录。 ) : null}
@@ -423,6 +558,8 @@ function Users() { const [username, setUsername] = useState('') const [password, setPassword] = useState('') return ( + <> +
能登录管理端的账号。 @@ -497,6 +634,56 @@ function Users() {
) : null} + + ) +} + +/** 审计:谁在什么时候做了什么。写操作、登录成败都在里面,保留一年。 */ +function Audit() { + const [actor, setActor] = useState('') + const [action, setAction] = useState('') + const audit = useQuery(() => api.get>('/api/admin/audit', { actor: actor || undefined, action: action || undefined, limit: 100 }), [actor, action], 10_000) + return ( +
+
+ setActor(e.target.value)} /> + setAction(e.target.value)} /> + {audit.loading && !audit.data ? : null} +
+ + {(audit.data?.items ?? []).length === 0 ? ( + 没有记录 + ) : ( + + + + + + + + + + + + + + {audit.data?.items.map((a) => ( + + + + + + + + + + ))} + +
时间操作者方式操作对象 / 细节来源结果
{fmtTime(a.at)}{a.actor}{a.method === 'key' ? 'API 令牌' : a.method === 'passkey' ? 'Passkey' : '密码'}{a.action}{[a.target, a.detail].filter(Boolean).join(' · ')}{a.remote ?? ''} + {a.success ? '成功' : '失败'} +
+ )} +
) } @@ -647,6 +834,8 @@ export function System() { { value: 'versions' as const, label: '版本' }, { value: 'tokens' as const, label: 'API 令牌' }, { value: 'users' as const, label: '用户' }, + { value: 'trust' as const, label: '信任' }, + { value: 'audit' as const, label: '审计' }, { value: 'events' as const, label: '事件' }, { value: 'raw' as const, label: '原始数据' }, { value: 'analysis' as const, label: '分析' }, @@ -654,7 +843,7 @@ export function System() { value={tab} onChange={setTab} /> - {tab === 'settings' ? : tab === 'versions' ? : tab === 'tokens' ? : tab === 'users' ? : tab === 'events' ? : tab === 'raw' ? : } + {tab === 'settings' ? : tab === 'versions' ? : tab === 'tokens' ? : tab === 'users' ? : tab === 'trust' ? : tab === 'audit' ? : tab === 'events' ? : tab === 'raw' ? : } ) 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/client.ts b/web/shared/api/client.ts index 04d459d..7d416a6 100644 --- a/web/shared/api/client.ts +++ b/web/shared/api/client.ts @@ -36,7 +36,29 @@ export function withQuery(path: string, query?: Query): string { return text ? `${path}?${text}` : path } +/** + * 敏感操作(加节点、吊销、改凭证)需要先触碰 passkey 开一个提权窗口:后端返回 403 `elevation_required`(detail 是窗口种类)时, + * 调用已注册的处理函数(弹出 passkey 触碰),成功后把原请求重试一次。 + */ +let elevationHandler: ((elevation: string) => Promise) | null = null + +export function setElevationHandler(handler: ((elevation: string) => Promise) | null) { + elevationHandler = handler +} + async function request(method: string, path: string, body?: unknown, init?: RequestInit): Promise { + try { + return await send(method, path, body, init) + } catch (e) { + if (e instanceof ApiError && e.code === 'elevation_required' && elevationHandler) { + if (await elevationHandler(e.detail)) return send(method, path, body, init) + throw new ApiError(403, e.code, '这个操作需要先用 Passkey 确认,已取消') + } + throw e + } +} + +async function send(method: string, path: string, body?: unknown, init?: RequestInit): Promise { const headers: Record = { Accept: 'application/json', ...authHeaders() } if (body !== undefined) headers['Content-Type'] = method === 'PATCH' ? 'application/merge-patch+json' : 'application/json' if (method !== 'GET' && csrfToken) headers['X-CSRF-Token'] = csrfToken diff --git a/web/shared/api/schema.ts b/web/shared/api/schema.ts index 5d6add3..db73696 100644 --- a/web/shared/api/schema.ts +++ b/web/shared/api/schema.ts @@ -327,6 +327,40 @@ export interface paths { patch?: never; trace?: never; }; + "/api/admin/auth/passkey/options": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** A challenge for signing in with a passkey (discoverable credential, no username needed) */ + post: operations["auth_passkey_options"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/auth/passkey/login": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Sign in with a passkey assertion; same response as the password sign-in */ + post: operations["auth_passkey_login"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/admin/releases": { parameters: { query?: never; @@ -362,6 +396,94 @@ export interface paths { patch?: never; trace?: never; }; + "/api/admin/auth/passkeys": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** The signed-in user's passkeys */ + get: operations["auth_passkeys_list"]; + put?: never; + /** Register a passkey from a WebAuthn attestation (ES256, user verification required) */ + post: operations["auth_passkeys_register"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/auth/passkeys/options": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** A challenge for registering a new passkey (the user must be signed in) */ + post: operations["auth_passkeys_options"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/auth/passkeys/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Remove one of your own passkeys */ + delete: operations["auth_passkeys_delete"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/auth/elevation": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** The elevation windows this session has open (sensitive actions need one) */ + get: operations["auth_elevation_list"]; + put?: never; + /** Open an elevation window from a passkey assertion (enroll 15 min, revoke 5 min, credential one use within 60 s) */ + post: operations["auth_elevate"]; + /** Close all elevation windows of this session */ + delete: operations["auth_elevation_end"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/auth/elevation/options": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** A challenge for opening an elevation window with a passkey touch */ + post: operations["auth_elevation_options"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/admin/auth/me": { parameters: { query?: never; @@ -728,6 +850,41 @@ export interface paths { patch?: never; trace?: never; }; + "/api/admin/audit": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Audit log of admin actions, newest first */ + get: operations["audit_list"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + 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; @@ -738,7 +895,7 @@ export interface paths { /** API tokens */ get: operations["api_tokens_list"]; put?: never; - /** Create an API token (scope read or admin); the plaintext is returned only once */ + /** Create an API token with the chosen capabilities (a subset of your own); the plaintext is returned only once */ post: operations["api_tokens_create"]; delete?: never; options?: never; @@ -763,6 +920,23 @@ export interface paths { patch?: never; trace?: never; }; + "/api/admin/capabilities": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** The capability catalog: groups, which ones change trust, which are reserved, and the presets */ + get: operations["capabilities_list"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/admin/settings": { parameters: { query?: never; @@ -943,7 +1117,7 @@ export interface paths { path?: never; cookie?: never; }; - /** Prometheus metrics (admin token) */ + /** Prometheus metrics (requires ops.metrics) */ get: operations["metrics_get"]; put?: never; post?: never; @@ -960,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; @@ -970,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; @@ -977,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; @@ -1004,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 { @@ -1050,6 +1275,7 @@ export interface components { public_host_auto: boolean; /** @default true */ ipv6_host_auto: boolean; + added_by?: null | string; }; AgentIpv6Dto: { agent_id: string; @@ -1069,14 +1295,29 @@ export interface components { ApiTokenDto: { id: string; name: string; - scope: string; + capabilities: string[]; /** Format: date-time */ created_at: unknown; /** Format: date-time */ last_used_at: unknown; /** Format: date-time */ + expires_at: unknown; + /** Format: date-time */ revoked_at: unknown; }; + AuditDto: { + /** Format: int64 */ + id: number | string; + /** Format: date-time */ + at: unknown; + actor: string; + method: string; + action: string; + target: null | string; + detail: null | string; + remote: null | string; + success: boolean; + }; BackendUpdateDto: { channel: string; enabled: boolean; @@ -1104,6 +1345,24 @@ export interface components { nodes: components["schemas"]["NodeBuildDto"][]; backend_update?: null | components["schemas"]["BackendUpdateDto"]; }; + CapabilityCatalogDto: { + groups: components["schemas"]["CapabilityGroupDto"][]; + presets: components["schemas"]["CapabilityPresetDto"][]; + }; + CapabilityDto: { + id: string; + trust: boolean; + reserved: boolean; + implies: string[]; + }; + CapabilityGroupDto: { + id: string; + capabilities: components["schemas"]["CapabilityDto"][]; + }; + CapabilityPresetDto: { + id: string; + capabilities: string[]; + }; ChangePasswordRequest: { password: string; }; @@ -1146,7 +1405,9 @@ export interface components { }; CreateApiTokenRequest: { name: string; - scope: string; + capabilities: string[]; + /** Format: int32 */ + expires_in_days?: null | number | string; }; CreateJoinTokenRequest: { note: null | string; @@ -1157,6 +1418,7 @@ export interface components { /** Format: int32 */ expires_in_hours: null | number | string; role?: null | string; + controllers?: null | string[]; }; CreateTaskRequest: { scope: string; @@ -1214,6 +1476,21 @@ export interface components { first: boolean; last: boolean; }; + ElevationAssertionRequest: { + class: string; + credential_id: string; + authenticator_data: string; + client_data_json: string; + signature: string; + }; + ElevationDto: { + class: string; + /** Format: date-time */ + expires_at: unknown; + }; + ElevationRequest: { + class: string; + }; EventDto: { /** Format: int64 */ id: number | string; @@ -1341,6 +1618,7 @@ export interface components { created_at: unknown; /** @default agent */ role: string; + created_by?: null | string; }; JsonElement: unknown; KindDto: { @@ -1407,10 +1685,11 @@ export interface components { }; MeDto: { kind: string; - scope: string; + capabilities: string[]; user_id: null | string; username: null | string; csrf_token: null | string; + auth_method?: null | string; }; MetricDto: { id: string; @@ -1507,6 +1786,14 @@ export interface components { items: components["schemas"]["ApiTokenDto"][]; next_cursor: null | string; }; + PageDtoOfAuditDto: { + items: components["schemas"]["AuditDto"][]; + next_cursor: null | string; + }; + PageDtoOfElevationDto: { + items: components["schemas"]["ElevationDto"][]; + next_cursor: null | string; + }; PageDtoOfEventDto: { items: components["schemas"]["EventDto"][]; next_cursor: null | string; @@ -1535,6 +1822,10 @@ export interface components { items: components["schemas"]["NodeInfoDto"][]; next_cursor: null | string; }; + PageDtoOfPasskeyDto: { + items: components["schemas"]["PasskeyDto"][]; + next_cursor: null | string; + }; PageDtoOfPlanDto: { items: components["schemas"]["PlanDto"][]; next_cursor: null | string; @@ -1583,6 +1874,39 @@ export interface components { values: null | string[]; description: string; }; + PasskeyCreationOptionsDto: { + challenge: string; + rp_id: string; + rp_name: string; + user_id: string; + user_name: string; + exclude_credentials: string[]; + /** Format: int32 */ + timeout_ms: number | string; + }; + PasskeyDto: { + id: string; + name: string; + /** Format: date-time */ + created_at: unknown; + /** Format: date-time */ + last_used_at: unknown; + transports: string[]; + }; + PasskeyLoginRequest: { + credential_id: string; + authenticator_data: string; + client_data_json: string; + signature: string; + return_session: null | boolean; + }; + PasskeyRequestOptionsDto: { + challenge: string; + rp_id: string; + /** Format: int32 */ + timeout_ms: number | string; + allow_credentials?: null | string[]; + }; PatchProbeRequest: { name: null | string; }; @@ -1631,6 +1955,7 @@ export interface components { online: boolean; connections: components["schemas"]["ProbeConnectionDto"][]; ipv6?: null | components["schemas"]["Ipv6DiagnosticDto"]; + added_by?: null | string; }; ProblemDto: { type: string; @@ -1679,6 +2004,13 @@ export interface components { /** Format: int32 */ queue_length: number | string; }; + RegisterPasskeyRequest: { + name: string; + credential_id: string; + attestation_object: string; + client_data_json: string; + transports: null | string[]; + }; RejectedDto: { id: string; reason: string; @@ -1738,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; @@ -1824,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; @@ -2705,6 +3070,86 @@ export interface operations { }; }; }; + auth_passkey_options: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PasskeyRequestOptionsDto"]; + }; + }; + /** @description Too Many Requests */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + auth_passkey_login: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PasskeyLoginRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["LoginResponse"]; + }; + }; + /** @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 Too Many Requests */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; releases_list: { parameters: { query?: never; @@ -2832,6 +3277,360 @@ export interface operations { }; }; }; + auth_passkeys_list: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PageDtoOfPasskeyDto"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + auth_passkeys_register: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RegisterPasskeyRequest"]; + }; + }; + responses: { + /** @description Created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PasskeyDto"]; + }; + }; + /** @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"]; + }; + }; + }; + }; + auth_passkeys_options: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PasskeyCreationOptionsDto"]; + }; + }; + /** @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"]; + }; + }; + }; + }; + auth_passkeys_delete: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description No Content */ + 204: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + auth_elevation_list: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PageDtoOfElevationDto"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + auth_elevate: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ElevationAssertionRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ElevationDto"]; + }; + }; + /** @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 Too Many Requests */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + auth_elevation_end: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description No Content */ + 204: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + auth_elevation_options: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ElevationRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PasskeyRequestOptionsDto"]; + }; + }; + /** @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 Too Many Requests */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; auth_me: { parameters: { query?: never; @@ -4356,6 +5155,156 @@ export interface operations { }; }; }; + audit_list: { + parameters: { + query?: { + actor?: string; + action?: string; + limit?: number | string; + cursor?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PageDtoOfAuditDto"]; + }; + }; + /** @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"]; + }; + }; + }; + }; + 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; @@ -4485,6 +5434,44 @@ export interface operations { }; }; }; + capabilities_list: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CapabilityCatalogDto"]; + }; + }; + /** @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"]; + }; + }; + }; + }; settings_list: { parameters: { query?: never; @@ -5114,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; @@ -5144,9 +6178,7 @@ export interface operations { }; install_ps1: { parameters: { - query?: { - code?: string; - }; + query?: never; header?: never; path?: never; cookie?: never; @@ -5160,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: { @@ -5189,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 c56c1fb..27d44c7 100644 --- a/web/shared/api/types.ts +++ b/web/shared/api/types.ts @@ -39,14 +39,22 @@ export type JoinTokenDto = Narrow export type CreatedJoinTokenDto = Narrow export type ApiTokenDto = Narrow 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 export type SettingDto = Narrow export type UserDto = Narrow export type EventDto = Narrow +export type AuditDto = Narrow export type GapDto = Narrow export type RecomputeDto = Narrow export type RawChunkMetaDto = Narrow export type LoginResponse = Narrow -export type MeDto = Narrow export interface Page { items: T[] diff --git a/web/shared/api/webauthn.ts b/web/shared/api/webauthn.ts new file mode 100644 index 0000000..1c1e591 --- /dev/null +++ b/web/shared/api/webauthn.ts @@ -0,0 +1,150 @@ +import { api } from './client' +import type { LoginResponse, PasskeyCreationOptionsDto, PasskeyDto, PasskeyRequestOptionsDto, SignedStatementDto } from './types' + +/** 浏览器是否支持在输入框的自动填充里列出 passkey(conditional UI)。 */ +export async function conditionalSupported(): Promise { + try { + return passkeySupported() && (await PublicKeyCredential.isConditionalMediationAvailable?.()) === true + } catch { + return false + } +} + +/** + * passkey 保存在哪里:浏览器和系统决定具体用谁的密码管理器,网站只能给提示。 + * `device`:此设备或浏览器账户(Chrome 里是 Google 密码管理工具,也可以是 Windows Hello、iCloud 钥匙串); + * `external`:安全密钥,或者用手机扫码。 + */ +export type PasskeyPlace = 'device' | 'external' + +/** 浏览器是否支持 WebAuthn(passkey)。 */ +export function passkeySupported(): boolean { + return typeof window !== 'undefined' && !!window.PublicKeyCredential && !!navigator.credentials +} + +function toBytes(text: string): Uint8Array { + const padded = text.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(text.length / 4) * 4, '=') + const binary = atob(padded) + const out = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i) + return out +} + +function toText(buffer: ArrayBuffer): string { + let binary = '' + for (const byte of new Uint8Array(buffer)) binary += String.fromCharCode(byte) + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +/** + * 用 passkey 登录(可发现凭证:不需要用户名)。服务端设了会话 Cookie;返回登录响应。 + * passkey 绑定在后端的域名上:只有和后端同站的页面能用,所以其他后端(跨域)不显示这个入口。 + */ +export async function signInWithPasskey(opts: { conditional?: boolean; signal?: AbortSignal } = {}): Promise { + const options = await api.post('/api/admin/auth/passkey/options') + const credential = (await navigator.credentials.get({ + // conditional:不弹窗,浏览器在用户名输入框的自动填充里列出已保存的 passkey(Chrome 里就是 Google 密码管理工具里的) + mediation: opts.conditional ? 'conditional' : undefined, + signal: opts.signal, + publicKey: { challenge: toBytes(options.challenge), rpId: options.rp_id, timeout: opts.conditional ? undefined : options.timeout_ms, userVerification: 'required' }, + })) as PublicKeyCredential | null + if (!credential) throw new Error('没有选择 passkey') + const response = credential.response as AuthenticatorAssertionResponse + return api.post('/api/admin/auth/passkey/login', { + credential_id: credential.id, + authenticator_data: toText(response.authenticatorData), + client_data_json: toText(response.clientDataJSON), + signature: toText(response.signature), + }) +} + +/** + * 用 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' + +export const ELEVATED_EVENT = 'moshiro:elevated' + +/** + * 用已有的 passkey 触碰一次,给当前会话开一个提权窗口。用户取消返回 false;没有 passkey 等服务端拒绝的情况抛 ApiError。 + * 浏览器只会让用户选自己的 passkey(服务端给了 allow_credentials)。 + */ +export async function elevate(elevation: ElevationClass): Promise { + const options = await api.post('/api/admin/auth/elevation/options', { class: elevation }) + let credential: PublicKeyCredential | null + try { + credential = (await navigator.credentials.get({ + publicKey: { + challenge: toBytes(options.challenge), + rpId: options.rp_id, + timeout: options.timeout_ms, + userVerification: 'required', + allowCredentials: (options.allow_credentials ?? []).map((id) => ({ type: 'public-key' as const, id: toBytes(id) })), + }, + })) as PublicKeyCredential | null + } catch (e) { + if (e instanceof DOMException && (e.name === 'NotAllowedError' || e.name === 'AbortError')) return false + throw e + } + if (!credential) return false + const response = credential.response as AuthenticatorAssertionResponse + await api.post('/api/admin/auth/elevation', { + class: elevation, + credential_id: credential.id, + authenticator_data: toText(response.authenticatorData), + client_data_json: toText(response.clientDataJSON), + signature: toText(response.signature), + }) + window.dispatchEvent(new Event(ELEVATED_EVENT)) + return true +} + +/** 给当前登录的用户注册一个新 passkey(要求用户验证,ES256)。 */ +export async function registerPasskey(name: string, place: PasskeyPlace = 'device'): Promise { + const options = await api.post('/api/admin/auth/passkeys/options') + const credential = (await navigator.credentials.create({ + publicKey: { + challenge: toBytes(options.challenge), + rp: { id: options.rp_id, name: options.rp_name }, + user: { id: toBytes(options.user_id), name: options.user_name, displayName: options.user_name }, + pubKeyCredParams: [{ type: 'public-key', alg: -7 }], + excludeCredentials: options.exclude_credentials.map((id) => ({ type: 'public-key' as const, id: toBytes(id) })), + authenticatorSelection: { authenticatorAttachment: place === 'device' ? 'platform' : 'cross-platform', residentKey: 'required', requireResidentKey: true, userVerification: 'required' }, + attestation: 'none', + timeout: options.timeout_ms, + // 给浏览器的提示(Chrome 128+):优先用设备自带的(含 Google 密码管理工具),或者安全密钥和手机 + hints: place === 'device' ? ['client-device'] : ['security-key', 'hybrid'], + } as PublicKeyCredentialCreationOptions, + })) as PublicKeyCredential | null + if (!credential) throw new Error('没有创建 passkey') + const response = credential.response as AuthenticatorAttestationResponse + return api.post('/api/admin/auth/passkeys', { + name, + credential_id: credential.id, + attestation_object: toText(response.attestationObject), + client_data_json: toText(response.clientDataJSON), + transports: response.getTransports?.() ?? [], + }) +} diff --git a/web/shared/theme/base.css b/web/shared/theme/base.css index 45e7146..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; @@ -504,6 +565,15 @@ textarea { box-shadow: 0 4px 16px color-mix(in srgb, var(--app-shadow), transparent 75%); } +.toast-title { + font-weight: 700; + margin-bottom: 2px; +} + +.toast { + cursor: pointer; +} + .toast.bad { background: var(--app-bad); color: var(--app-card); diff --git a/web/shared/ui/index.tsx b/web/shared/ui/index.tsx index c6d453e..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}
, @@ -104,24 +104,33 @@ interface ToastItem { id: number text: string bad: boolean + title?: string } -const ToastContext = createContext<(text: string, bad?: boolean) => void>(() => undefined) +/** 提示的可选项:`title` 是加粗的标题行,`ms` 是停留的毫秒数(缺省成功 3 秒、失败 6 秒)。点一下提示可以提前关掉。 */ +export interface ToastOptions { + title?: string + ms?: number +} + +const ToastContext = createContext<(text: string, bad?: boolean, options?: ToastOptions) => void>(() => undefined) export function ToastProvider({ children }: { children: ReactNode }) { const [items, setItems] = useState([]) const nextId = useRef(1) - const push = useCallback((text: string, bad = false) => { + const push = useCallback((text: string, bad = false, options?: ToastOptions) => { const id = nextId.current++ - setItems((list) => [...list, { id, text, bad }]) - window.setTimeout(() => setItems((list) => list.filter((t) => t.id !== id)), bad ? 6000 : 3000) + setItems((list) => [...list, { id, text, bad, title: options?.title }]) + window.setTimeout(() => setItems((list) => list.filter((t) => t.id !== id)), options?.ms ?? (bad ? 6000 : 3000)) }, []) + const dismiss = (id: number) => setItems((list) => list.filter((t) => t.id !== id)) return ( {children}
{items.map((t) => ( -
+
dismiss(t.id)} title="点一下关闭"> + {t.title ?
{t.title}
: null} {t.text}
))}