diff --git a/backend/agent/src/MoShiro.Agent/AgentApp.cs b/backend/agent/src/MoShiro.Agent/AgentApp.cs index 2c23bd6..83c674d 100644 --- a/backend/agent/src/MoShiro.Agent/AgentApp.cs +++ b/backend/agent/src/MoShiro.Agent/AgentApp.cs @@ -47,6 +47,7 @@ public sealed class AgentApp : IDisposable _ownedRestart = restart is null ? new RestartController() : null; _restart = restart ?? _ownedRestart!; _update = new UpdateBridge(dataDirectory, new ConfigStore(dataDirectory).Write, log); + NodeBinary.Prime(); // 正在运行的二进制的哈希:先在后台算好,握手时用 _config = config; _identity = identity; _time = time; @@ -276,6 +277,7 @@ public sealed class AgentApp : IDisposable Server = new Uri(_config.Fleet.Server), Identity = _identity.Identity, ServerVerifier = _trust, + Attestation = _update.Attest, NodeId = _identity.NodeId, NodeVersion = Version, Capabilities = [MoShiroCapabilities.Base, MoShiroCapabilities.KindsPrefix + string.Join(',', BlockCatalog.AgentKinds)], diff --git a/backend/fleet/src/Fleet.Core/BinaryAttestation.cs b/backend/fleet/src/Fleet.Core/BinaryAttestation.cs new file mode 100644 index 0000000..0657435 --- /dev/null +++ b/backend/fleet/src/Fleet.Core/BinaryAttestation.cs @@ -0,0 +1,43 @@ +using System.Security.Cryptography; + +namespace Fleet.Core; + +/// +/// 节点握手时自述的「我跑的是哪个二进制」:正在运行的可执行文件的 SHA-256,以及(容器节点)宿主机更新器验过的 CI 签名清单和签名。 +/// 服务端在节点第一次握手成功的那一刻把哈希绑定到这个节点,之后哈希对不上就不让它继续运行。 +/// +public sealed record BinaryAttestation(string Sha256, string? Manifest, string? Signature); + +/// +/// 正在运行的二进制的哈希。Linux 上读 /proc/self/exe:它指向进程启动时映射的那个映像,磁盘上的文件之后被换掉也不影响, +/// 所以「运行期间被替换、重启后换了别的」的情况下一次握手就会对不上。算一次,之后缓存。 +/// +public static class NodeBinary +{ + private static readonly Lazy s_hash = new(Compute); + + /// 小写十六进制;读不到可执行文件时为 null(这样的节点不做绑定)。 + public static string? Sha256Hex => s_hash.Value; + + /// 在后台先算好,免得第一次握手等它。 + public static void Prime() => _ = Task.Run(() => s_hash.Value); + + private static string? Compute() + { + try + { + string? path = OperatingSystem.IsLinux() && File.Exists("/proc/self/exe") ? "/proc/self/exe" : Environment.ProcessPath; + if (path is null) + { + return null; + } + + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete, 1 << 16); + return Convert.ToHexStringLower(SHA256.HashData(stream)); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return null; + } + } +} diff --git a/backend/fleet/src/Fleet.Core/ErrorCode.cs b/backend/fleet/src/Fleet.Core/ErrorCode.cs index b80e184..f91cfdd 100644 --- a/backend/fleet/src/Fleet.Core/ErrorCode.cs +++ b/backend/fleet/src/Fleet.Core/ErrorCode.cs @@ -27,4 +27,6 @@ public static class FleetErrors public static readonly ErrorCode JoinTokenExpired = new("fleet_join_token_expired"); public static readonly ErrorCode ProofInvalid = new("fleet_proof_invalid"); public static readonly ErrorCode FrameTooLarge = new("fleet_frame_too_large"); + /// 节点现在跑的二进制和它第一次握手时绑定的不一致,也不在 CI 签名的更新清单里。 + public static readonly ErrorCode BinaryMismatch = new("fleet_binary_mismatch"); } diff --git a/backend/fleet/src/Fleet.Link/FleetLink.cs b/backend/fleet/src/Fleet.Link/FleetLink.cs index e31649d..e152272 100644 --- a/backend/fleet/src/Fleet.Link/FleetLink.cs +++ b/backend/fleet/src/Fleet.Link/FleetLink.cs @@ -72,6 +72,9 @@ public sealed class FleetLinkOptions /// 服务器必须在挑战里证明自己,之后这条链路用应用层加密;否则按旧的方式(没有固定信任根的节点)。 /// public IServerVerifier? ServerVerifier { get; init; } + + /// 握手时自述正在运行的二进制(哈希和 CI 签名的清单);没有就不上报(旧行为)。 + public Func? Attestation { get; init; } } /// @@ -223,6 +226,16 @@ public sealed class FleetLink } /// 挑战 → 签名 → 认证结果。失败返回原因;服务端禁用该节点时不再重试。 + private void Attest(AuthParams auth) + { + if (_options.Attestation?.Invoke() is { } attestation) + { + auth.Binary = attestation.Sha256; + auth.Release = attestation.Manifest; + auth.ReleaseSignature = attestation.Signature; + } + } + private async Task AuthenticateAsync(SecureTransport transport, CancellationToken ct) { using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15), _time); @@ -257,6 +270,7 @@ public sealed class FleetLink Signature = Convert.ToBase64String(_options.Identity.Sign(SignaturePurpose.AuthSecure, _options.NodeId.Value, nonce.Nonce, serverEphemeralText, nodeEphemeralText)), NodeEphemeral = nodeEphemeralText, }; + Attest(authParams); 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)); @@ -264,6 +278,7 @@ public sealed class FleetLink else { authParams = new AuthParams { NodeId = _options.NodeId.Value, Signature = Convert.ToBase64String(_options.Identity.Sign(SignaturePurpose.Auth, _options.NodeId.Value, nonce.Nonce)) }; + Attest(authParams); 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); } diff --git a/backend/fleet/src/Fleet.Rpc/FleetMethods.cs b/backend/fleet/src/Fleet.Rpc/FleetMethods.cs index a1c5e61..3e185b6 100644 --- a/backend/fleet/src/Fleet.Rpc/FleetMethods.cs +++ b/backend/fleet/src/Fleet.Rpc/FleetMethods.cs @@ -75,6 +75,17 @@ public sealed class AuthParams /// 节点的临时 ECDH 公钥(SPKI,base64):有它就是安全链路,签名覆盖双方的临时公钥,认证结果起所有帧都是加密的。 [JsonPropertyName("node_eph")] public string? NodeEphemeral { get; set; } + + /// 正在运行的二进制的 SHA-256(小写十六进制):服务端在第一次握手时绑定,之后核对。旧节点没有这个字段。 + [JsonPropertyName("binary")] + public string? Binary { get; set; } + + /// 安装这个二进制时宿主机更新器验过的 CI 签名清单(原文)和签名(base64):服务端用 CI 公钥验过,才相信它列出的官方哈希。 + [JsonPropertyName("release")] + public string? Release { get; set; } + + [JsonPropertyName("release_sig")] + public string? ReleaseSignature { get; set; } } public sealed class AuthResult diff --git a/backend/fleet/src/Fleet.Server.Abstractions/ServerContracts.cs b/backend/fleet/src/Fleet.Server.Abstractions/ServerContracts.cs index bb8e4be..51505aa 100644 --- a/backend/fleet/src/Fleet.Server.Abstractions/ServerContracts.cs +++ b/backend/fleet/src/Fleet.Server.Abstractions/ServerContracts.cs @@ -19,6 +19,21 @@ public interface IServerIdentity } /// 操作员集合的链(passkey 签的,创世在前)。没有初始化信任根时是空的。节点注册时用它确认「这个面板确实是我的操作员的」。 +/// 节点握手时自述的二进制(旧节点没有)。 +public sealed record BinaryEvidence(string? Sha256, string? Manifest, string? Signature); + +/// 是否让这个节点继续:不放行时 Reason 会原样告诉节点(这是它自己的信息,不是秘密)。 +public sealed record BinaryDecision(bool Accept, string Reason = ""); + +/// +/// 服务端对节点二进制的把关:节点第一次握手成功的那一刻绑定它的二进制哈希,之后哈希对不上、又不在 CI 签名的更新清单里,就不让它继续运行。 +/// 没有注册实现时不做任何检查。 +/// +public interface IBinaryAttestor +{ + ValueTask CheckAsync(NodeRecord node, BinaryEvidence evidence, CancellationToken ct); +} + public interface IOperatorTrustSource { ValueTask> ChainAsync(CancellationToken ct); diff --git a/backend/fleet/src/Fleet.Server.Link/FleetLinkHost.cs b/backend/fleet/src/Fleet.Server.Link/FleetLinkHost.cs index d4d4b82..776702c 100644 --- a/backend/fleet/src/Fleet.Server.Link/FleetLinkHost.cs +++ b/backend/fleet/src/Fleet.Server.Link/FleetLinkHost.cs @@ -24,9 +24,11 @@ public sealed class FleetLinkHost : IFleetConnections, IStateNotifier IEnumerable inboxes, IEnumerable sinks, FleetRouter router, - IServerIdentity? serverIdentity = null) + IServerIdentity? serverIdentity = null, + IBinaryAttestor? binaryAttestor = null) { ServerIdentity = serverIdentity; + BinaryAttestor = binaryAttestor; Store = store; Options = options; Time = time; @@ -43,6 +45,9 @@ public sealed class FleetLinkHost : IFleetConnections, IStateNotifier /// 服务器的身份密钥;没有时节点连接用旧的明文认证。 internal IServerIdentity? ServerIdentity { get; } + /// 节点二进制的把关;没有时不检查。 + internal IBinaryAttestor? BinaryAttestor { 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 0c99b75..e360d94 100644 --- a/backend/fleet/src/Fleet.Server.Link/FleetServerBuilder.cs +++ b/backend/fleet/src/Fleet.Server.Link/FleetServerBuilder.cs @@ -110,7 +110,8 @@ public static class FleetServerExtensions provider.GetServices(), provider.GetServices(), provider.GetRequiredService(), - provider.GetService())); + provider.GetService(), + 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 57c87a8..1c4b393 100644 --- a/backend/fleet/src/Fleet.Server.Link/NodeConnection.Auth.cs +++ b/backend/fleet/src/Fleet.Server.Link/NodeConnection.Auth.cs @@ -88,6 +88,18 @@ internal sealed partial class NodeConnection return false; } + // 二进制:第一次握手成功的这一刻绑定,之后哈希对不上又不在 CI 签名的更新清单里,就不让它继续运行 + if (_host.BinaryAttestor is { } attestor) + { + BinaryDecision decision = await attestor.CheckAsync(node, new BinaryEvidence(auth!.Binary, auth.Release, auth.ReleaseSignature), ct); + if (!decision.Accept) + { + await SendAsync(RpcMessage.Failure(message.Id, new FleetError(FleetErrors.BinaryMismatch, decision.Reason)), ct); + await RejectAsync(null, "binary mismatch: " + decision.Reason, ct); + return false; + } + } + _session = new NodeSessionInfo(node.Id, _host.Time.GetUtcNow().ToUnixTimeMilliseconds(), node.NodeVersion, [], null); await SendAsync( RpcMessage.Success(message.Id.Value, new AuthResult { Status = StatusName(node.Status) }, FleetRpcContext.Default.AuthResult), diff --git a/backend/openapi/v1.json b/backend/openapi/v1.json index 27696c5..060bc21 100644 --- a/backend/openapi/v1.json +++ b/backend/openapi/v1.json @@ -1625,208 +1625,6 @@ "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": { "get": { "tags": [ @@ -3932,7 +3730,7 @@ "tags": [ "admin-trust" ], - "summary": "Store the next operator set, signed with a passkey (the first one sets the genesis and needs a credential window)", + "summary": "Store the next operator set, signed with a passkey (the first one sets the genesis)", "operationId": "trust_commit", "requestBody": { "content": { @@ -4584,6 +4382,56 @@ "x-moshiro-capability": "update.confirm" } }, + "/api/admin/nodes/{id}/binary": { + "delete": { + "tags": [ + "admin-build" + ], + "summary": "Accept the node's current binary: clear its binding so the next handshake binds it again", + "operationId": "nodes_binary_clear", + "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" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + }, + "x-moshiro-capability": "fleet.write" + } + }, "/api/admin/backend/update": { "get": { "tags": [ @@ -6229,59 +6077,6 @@ } } }, - "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", @@ -7254,6 +7049,45 @@ } } }, + "NodeBinaryDto": { + "required": [ + "sha256", + "signed", + "seq", + "bound_at", + "violation", + "violation_at" + ], + "type": "object", + "properties": { + "sha256": { + "type": "string" + }, + "signed": { + "type": "boolean" + }, + "seq": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + }, + "bound_at": { + "format": "date-time" + }, + "violation": { + "type": [ + "null", + "string" + ] + }, + "violation_at": { + "format": "date-time" + } + } + }, "NodeBuildDto": { "required": [ "id", @@ -7325,6 +7159,16 @@ "$ref": "#/components/schemas/NodeUpdateDto" } ] + }, + "binary": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/NodeBinaryDto" + } + ] } } }, @@ -7632,27 +7476,6 @@ } } }, - "PageDtoOfElevationDto": { - "required": [ - "items", - "next_cursor" - ], - "type": "object", - "properties": { - "items": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ElevationDto" - } - }, - "next_cursor": { - "type": [ - "null", - "string" - ] - } - } - }, "PageDtoOfEventDto": { "required": [ "items", @@ -8477,6 +8300,25 @@ } } }, + "ProposedCredentialDto": { + "required": [ + "id", + "name", + "created_at" + ], + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "created_at": { + "format": "date-time" + } + } + }, "PublishReleaseRequest": { "required": [ "manifest", @@ -9272,7 +9114,10 @@ "required": [ "statement", "version", - "challenge" + "challenge", + "added", + "removed", + "server_key_changed" ], "type": "object", "properties": { @@ -9289,6 +9134,21 @@ }, "challenge": { "type": "string" + }, + "added": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProposedCredentialDto" + } + }, + "removed": { + "type": "array", + "items": { + "type": "string" + } + }, + "server_key_changed": { + "type": "boolean" } } }, diff --git a/backend/probe/src/MoShiro.Probe/ProbeApp.cs b/backend/probe/src/MoShiro.Probe/ProbeApp.cs index a5cfe26..2bd1e75 100644 --- a/backend/probe/src/MoShiro.Probe/ProbeApp.cs +++ b/backend/probe/src/MoShiro.Probe/ProbeApp.cs @@ -33,11 +33,13 @@ public sealed class ProbeApp : IDisposable directory.Changed += () => _changed.Release(); var update = new UpdateBridge(configDirectory, new ConfigStore(configDirectory).Write, log); + NodeBinary.Prime(); // 正在运行的二进制的哈希:先在后台算好,握手时用 var options = new FleetLinkOptions { Server = new Uri(file.Fleet.Server), Identity = identity.Identity, ServerVerifier = trust, + Attestation = update.Attest, NodeId = identity.NodeId, NodeVersion = Version, Capabilities = [MoShiroCapabilities.Base], diff --git a/backend/shared/MoShiro.Measurement/Update/UpdateBridge.cs b/backend/shared/MoShiro.Measurement/Update/UpdateBridge.cs index 236088c..ba4feef 100644 --- a/backend/shared/MoShiro.Measurement/Update/UpdateBridge.cs +++ b/backend/shared/MoShiro.Measurement/Update/UpdateBridge.cs @@ -96,5 +96,34 @@ public sealed class UpdateBridge } } + /// + /// 握手时自述的二进制:正在运行的可执行文件的哈希,加上宿主机更新器验过的、安装这个版本时用的 CI 签名清单(更新器写在只读的 update-status/)。 + /// 容器节点以外(没有更新器)没有清单,只有哈希;读不到可执行文件时返回 null(不做绑定)。 + /// + public BinaryAttestation? Attest() + { + if (NodeBinary.Sha256Hex is not { } hash) + { + return null; + } + + string? manifest = ReadSmall("installed.manifest", MaxManifestBytes); + string? signature = ReadSmall("installed.sig", MaxSignatureBytes); + return manifest is not null && signature is not null ? new BinaryAttestation(hash, manifest, signature) : new BinaryAttestation(hash, null, null); + } + + private string? ReadSmall(string name, int maxBytes) + { + try + { + string path = Path.Combine(Path.GetDirectoryName(_statusFile)!, name); + return File.Exists(path) && new FileInfo(path).Length <= maxBytes ? File.ReadAllText(path, Encoding.UTF8) : null; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return null; + } + } + private static bool Same(string path, byte[] content) => File.Exists(path) && File.ReadAllBytes(path).AsSpan().SequenceEqual(content); } diff --git a/backend/shared/MoShiro.Protocol/MoShiroErrors.cs b/backend/shared/MoShiro.Protocol/MoShiroErrors.cs index 0cd40dc..91c7259 100644 --- a/backend/shared/MoShiro.Protocol/MoShiroErrors.cs +++ b/backend/shared/MoShiro.Protocol/MoShiroErrors.cs @@ -11,7 +11,6 @@ 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/src/MoShiro.Backend.Api/Admin/AuditEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/AuditEndpoints.cs index fc4180d..2786e38 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/AuditEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Admin/AuditEndpoints.cs @@ -6,7 +6,7 @@ using MoShiro.Protocol; namespace MoShiro.Backend.Api; -/// 审计日志:管理接口里每个写操作、登录和提权都会记一行,这里只读。 +/// 审计日志:管理接口里每个写操作、登录都会记一行,这里只读。 public static class AuditEndpoints { private const string Tag = "admin-audit"; diff --git a/backend/src/MoShiro.Backend.Api/Admin/AuthEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/AuthEndpoints.cs index fb13212..f54390e 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/AuthEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Admin/AuthEndpoints.cs @@ -51,22 +51,6 @@ public static class AuthEndpoints .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) @@ -179,20 +163,8 @@ public static class AuthEndpoints 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)) { @@ -205,11 +177,6 @@ public static class AuthEndpoints 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) @@ -219,89 +186,13 @@ public static class AuthEndpoints 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) + private static async Task LogoutAsync(HttpContext http, AuthService auth) { - 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) { diff --git a/backend/src/MoShiro.Backend.Api/Admin/BuildEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/BuildEndpoints.cs index 2419be6..92b1785 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/BuildEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Admin/BuildEndpoints.cs @@ -25,6 +25,9 @@ public static class BuildEndpoints 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) .Requires(Capability.UpdateConfirm); + group.MapDelete("/nodes/{id}/binary", ClearBinaryAsync) + .Api("nodes_binary_clear", "Accept the node's current binary: clear its binding so the next handshake binds it again", Tag).Returns(StatusCodes.Status204NoContent).ProducesProblems(401, 403) + .Requires(Capability.FleetWrite); group.MapGet("/backend/update", BackendUpdateAsync) .Api("backend_update_get", "Current channel and host-verified backend update", Tag).Returns().ProducesProblems(401, 403) .Requires(Capability.UpdateRead); @@ -70,12 +73,13 @@ public static class BuildEndpoints return await PublishReleaseAsync(http, releases, request); } - private static async Task GetAsync(HttpContext http, NodeInfoService nodes, ReleaseService releases, BackendUpdates backendUpdates, CancellationToken ct) + private static async Task GetAsync(HttpContext http, NodeInfoService nodes, ReleaseService releases, BackendUpdates backendUpdates, BinaryBindingService bindings, CancellationToken ct) { + Dictionary bound = (await bindings.ListAsync(ct)).ToDictionary(b => b.NodeId, StringComparer.Ordinal); var items = (await nodes.ListAsync(ct)) .OrderBy(n => n.Kind, StringComparer.Ordinal) .ThenBy(n => n.NodeId, StringComparer.Ordinal) - .Select(n => new NodeBuildDto(n.NodeId, n.Kind, n.Os, n.OsName, n.Arch, n.Version, n.Tag, n.Commit, n.BuiltAt, ToDto(releases.StatusOf(new NodeId(n.NodeId))))) + .Select(n => new NodeBuildDto(n.NodeId, n.Kind, n.Os, n.OsName, n.Arch, n.Version, n.Tag, n.Commit, n.BuiltAt, ToDto(releases.StatusOf(new NodeId(n.NodeId))), bound.TryGetValue(n.NodeId, out NodeBinding? binding) ? ToDto(binding) : null)) .ToList(); http.Response.Headers.CacheControl = "no-store"; return TypedResults.Json(new BuildInfoDto(NodeEndpoints.Backend(withCommit: true), items, ToDto(await backendUpdates.GetAsync(ct))), ApiJsonContext.Default.BuildInfoDto); @@ -133,6 +137,17 @@ public static class BuildEndpoints } } + private static NodeBinaryDto ToDto(NodeBinding b) => + new(b.Sha256, b.Signed, b.Seq, DateTimeOffset.FromUnixTimeMilliseconds(b.BoundAtMs), b.Violation, b.ViolationAtMs is { } at ? DateTimeOffset.FromUnixTimeMilliseconds(at) : null); + + /// 管理员确认新的二进制是自己要的:清除绑定,节点下一次握手重新绑定(没有绑定可清也返回 204,幂等)。 + private static async Task ClearBinaryAsync(HttpContext http, BinaryBindingService bindings, string id) + { + http.SetDetail("binary binding cleared"); + await bindings.ClearAsync(id, http.RequestAborted); + return TypedResults.NoContent(); + } + private static NodeUpdateDto? ToDto(UpdateStatus? status) => status is null ? null : new NodeUpdateDto(status.State, status.Current, status.Available?.Tag, status.Available?.Version, status.Error, status.At > 0 ? DateTimeOffset.FromUnixTimeSeconds(status.At) : null); } diff --git a/backend/src/MoShiro.Backend.Api/Admin/SystemEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/SystemEndpoints.cs index 3597853..03ac1db 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/SystemEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Admin/SystemEndpoints.cs @@ -64,11 +64,6 @@ public static class SystemEndpoints private static async Task CreateTokenAsync(HttpContext http, AuthService auth, CreateApiTokenRequest request) { - 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)) diff --git a/backend/src/MoShiro.Backend.Api/Admin/TrustEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/TrustEndpoints.cs index 1d8cdf1..036916c 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/TrustEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Admin/TrustEndpoints.cs @@ -22,10 +22,9 @@ public static class TrustEndpoints .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) + .Api("trust_commit", "Store the next operator set, signed with a passkey (the first one sets the genesis)", Tag) .Returns(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409) - .Requires(Capability.AccountsManage) - .ElevationExempt(); + .Requires(Capability.AccountsManage); } private static async Task StateAsync(HttpContext http, TrustService trust, ReleaseService releases, BackendOptions options) @@ -60,12 +59,6 @@ public static class TrustEndpoints 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)) { diff --git a/backend/src/MoShiro.Backend.Api/ApiModule.cs b/backend/src/MoShiro.Backend.Api/ApiModule.cs index 1711bce..e17cc71 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.AddSingleton(); services.Configure(options => { diff --git a/backend/src/MoShiro.Backend.Api/Common/EndpointConventions.cs b/backend/src/MoShiro.Backend.Api/Common/EndpointConventions.cs index 8def48a..aec509b 100644 --- a/backend/src/MoShiro.Backend.Api/Common/EndpointConventions.cs +++ b/backend/src/MoShiro.Backend.Api/Common/EndpointConventions.cs @@ -18,11 +18,6 @@ public static class EndpointConventions 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)); diff --git a/backend/src/MoShiro.Backend.Api/Common/Guards.cs b/backend/src/MoShiro.Backend.Api/Common/Guards.cs index 3af9919..0dde342 100644 --- a/backend/src/MoShiro.Backend.Api/Common/Guards.cs +++ b/backend/src/MoShiro.Backend.Api/Common/Guards.cs @@ -60,9 +60,6 @@ public static class PrincipalResolver /// public sealed record CapabilityRequirement(string? Capability); -/// 端点自己验 passkey 签名(见 )。 -public sealed record ElevationExemptMarker; - /// 管理层守卫:按端点声明的能力授权;Cookie 会话的非 GET 请求必须带 X-CSRF-Token。 public static class CapabilityGuard { @@ -92,12 +89,6 @@ public static class CapabilityGuard } } - // 信任类的操作(加节点、吊销、改凭证):登录用户还要有这一种的提权窗口;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 里 @@ -120,24 +111,6 @@ public static class CapabilityGuard } } -/// 提权检查:登录用户(含用 return_session 拿到的 Bearer 会话)要有对应种类的有效窗口,API key 不需要。 -public static class ElevationGate -{ - /// 没问题返回 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)); - } -} - /// 展示层守卫:站点公开时匿名可读;私有时需要登录或只读令牌。 public static class PublicGuard { diff --git a/backend/src/MoShiro.Backend.Api/Common/Mappers.cs b/backend/src/MoShiro.Backend.Api/Common/Mappers.cs index b751f06..c29f8ff 100644 --- a/backend/src/MoShiro.Backend.Api/Common/Mappers.cs +++ b/backend/src/MoShiro.Backend.Api/Common/Mappers.cs @@ -156,9 +156,8 @@ public static class Mappers public static JoinTokenDto ToDto(JoinTokenInfo t) => new(t.Id, t.Note, t.Labels, t.AutoApprove, t.MaxUses, t.Uses, Time(t.ExpiresAtMs), Time(t.RevokedAtMs), Time(t.CreatedAtMs), JoinTokenService.RoleOf(t.Kind), t.CreatedBy); - 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 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, [.. s.Proposal.Added.Select(a => new ProposedCredentialDto(a.Id, a.Name, Time(a.CreatedAtMs)))], s.Proposal.Removed, s.Proposal.ServerKeyChanged), 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); diff --git a/backend/src/MoShiro.Backend.Api/Common/Problems.cs b/backend/src/MoShiro.Backend.Api/Common/Problems.cs index 790d86d..87576e9 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, MoShiroErrors.ElevationRequired, FleetErrors.Forbidden, FleetErrors.ProofInvalid); + Add(403, MoShiroErrors.Forbidden, MoShiroErrors.SitePrivate, 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 0ee5bf1..dc12c2c 100644 --- a/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs +++ b/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs @@ -10,16 +10,10 @@ public sealed record LoginRequest(string Username, string Password, bool? Return public sealed record PasskeyCreationOptionsDto(string Challenge, string RpId, string RpName, string UserId, string UserName, IReadOnlyList ExcludeCredentials, int TimeoutMs); /// 浏览器 navigator.credentials.get 要的参数(可发现凭证:不需要先输用户名)。 -/// 只有提权的挑战会带:这个用户自己的 passkey 的凭证 id。 +/// 列出时限定可选的 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); @@ -28,7 +22,7 @@ public sealed record PasskeyDto(string Id, string Name, DateTimeOffset CreatedAt public sealed record LoginResponse(string UserId, string Username, string CsrfToken, DateTimeOffset ExpiresAt, string? SessionToken); -/// 会话是怎么登录的:password(没有任何能力,只能注册 passkey)或 passkey;API 令牌为空。 +/// 会话是怎么登录的:password 或 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); @@ -83,7 +77,13 @@ public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList? /// 令牌明文只在创建的响应里出现一次。 public sealed record InstallCommandsDto(string Linux, string Macos, string Windows); -public sealed record TrustProposalDto(string Statement, int Version, string Challenge); +/// 下一版里新增的 passkey。 +public sealed record ProposedCredentialDto(string Id, string Name, DateTimeOffset CreatedAt); + +/// 相对当前生效的集合新增的 passkey;签名之前必须看一眼是不是自己加的。 +/// 相对当前生效的集合移除的 passkey 凭证 id。 +/// 服务器密钥有变化(换了新的)。 +public sealed record TrustProposalDto(string Statement, int Version, string Challenge, IReadOnlyList Added, IReadOnlyList Removed, bool ServerKeyChanged); /// 创世版本的指纹:安装命令里固定到节点上的就是它。还没建立信任根时为空。 /// 当前生效的集合和已注册的 passkey、域名一致;不一致(比如新加了 passkey)就要签一个新版本。 @@ -178,7 +178,10 @@ public sealed record ChangePasswordRequest(string Password); public sealed record RecomputeDto(string State, IReadOnlyList Metrics, long WindowsDone, long WindowsTotal, DateTimeOffset? From, DateTimeOffset? To, int QueueLength); /// 管理端看的构建信息:每个节点自报的版本、tag、提交和构建时间,以及系统。 -public sealed record NodeBuildDto(string Id, string Kind, string? Os, string? OsName, string? Arch, string? Version, string? Tag, string? Commit, string? BuiltAt, NodeUpdateDto? Update = null); +/// 节点二进制的绑定:第一次握手成功时绑定的 SHA-256。Signed 表示它出现在一份 CI 签名的清单里(否则只是首次握手时记下的);Violation 是最近一次不符的原因,下一次握手符合了就清掉。 +public sealed record NodeBinaryDto(string Sha256, bool Signed, long Seq, DateTimeOffset BoundAt, string? Violation, DateTimeOffset? ViolationAt); + +public sealed record NodeBuildDto(string Id, string Kind, string? Os, string? OsName, string? Arch, string? Version, string? Tag, string? Commit, string? BuiltAt, NodeUpdateDto? Update = null, NodeBinaryDto? Binary = null); /// 节点宿主机上的更新器报告的状态(up_to_date、available、applying、failed、rolled_back、invalid);没装更新器的节点没有这个字段。 public sealed record NodeUpdateDto(string State, string? CurrentDigest, string? AvailableTag, string? AvailableVersion, string? Error, DateTimeOffset? At); diff --git a/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs b/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs index 82e8b9b..334e91c 100644 --- a/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs +++ b/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs @@ -47,6 +47,7 @@ namespace MoShiro.Backend.Api; [JsonSerializable(typeof(PublishReleaseRequest))] [JsonSerializable(typeof(AboutDto))] [JsonSerializable(typeof(NodeBuildDto))] +[JsonSerializable(typeof(NodeBinaryDto))] [JsonSerializable(typeof(BuildInfoDto))] [JsonSerializable(typeof(BackendUpdateDto))] [JsonSerializable(typeof(ConfirmBackendUpdateRequest))] @@ -81,10 +82,7 @@ namespace MoShiro.Backend.Api; [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(ProposedCredentialDto))] [JsonSerializable(typeof(PasskeyDto))] [JsonSerializable(typeof(PageDto))] [JsonSerializable(typeof(CreatedApiTokenDto))] diff --git a/backend/src/MoShiro.Backend.Api/EmbeddedReleaseKey.cs b/backend/src/MoShiro.Backend.Api/EmbeddedReleaseKey.cs new file mode 100644 index 0000000..414b747 --- /dev/null +++ b/backend/src/MoShiro.Backend.Api/EmbeddedReleaseKey.cs @@ -0,0 +1,9 @@ +using MoShiro.Backend.Application; + +namespace MoShiro.Backend.Api; + +/// 内嵌在程序里的 CI 发布公钥(packaging/update/release-key.pub.pem)。 +public sealed class EmbeddedReleaseKey : IReleaseKey +{ + public string Pem => InstallScripts.ReleaseKey(); +} diff --git a/backend/src/MoShiro.Backend.Application/Agents/AgentService.cs b/backend/src/MoShiro.Backend.Application/Agents/AgentService.cs index c513461..925236d 100644 --- a/backend/src/MoShiro.Backend.Application/Agents/AgentService.cs +++ b/backend/src/MoShiro.Backend.Application/Agents/AgentService.cs @@ -26,8 +26,11 @@ public sealed partial class AgentService private readonly LinkService _links; private readonly TrustService _trust; - public AgentService(IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, TaskPlanner planner, ISettingsStore settings, NodeInfoService nodes, LinkService links, TrustService trust) + private readonly BinaryBindingService _bindings; + + public AgentService(BinaryBindingService bindings, IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, TaskPlanner planner, ISettingsStore settings, NodeInfoService nodes, LinkService links, TrustService trust) { + _bindings = bindings; _trust = trust; _links = links; _nodes = nodes; @@ -147,6 +150,7 @@ public sealed partial class AgentService await _gateway.DisconnectAsync(id, "deleted", ct); await _agents.DeleteAsync(id, ct); await _nodes.ForgetAsync(id.Value, ct); + await _bindings.ForgetAsync(id.Value, ct); await _links.ForgetNodeAsync(id.Value, ct); _runtime.Remove(id); await AfterAgentSetChangedAsync(ct); diff --git a/backend/src/MoShiro.Backend.Application/Agents/BinaryBindingService.cs b/backend/src/MoShiro.Backend.Application/Agents/BinaryBindingService.cs new file mode 100644 index 0000000..ceaf08c --- /dev/null +++ b/backend/src/MoShiro.Backend.Application/Agents/BinaryBindingService.cs @@ -0,0 +1,166 @@ +using System.Text.Json; +using Fleet.Core; +using Fleet.Server; + +namespace MoShiro.Backend.Application; + +/// CI 发布公钥(PEM):验节点握手时带来的签名清单。由 Api 层提供(它就是内嵌在程序里的那一把)。 +public interface IReleaseKey +{ + string Pem { get; } +} + +/// +/// 节点二进制的绑定:节点第一次握手成功的那一刻,把它正在运行的二进制的 SHA-256 和这个节点绑在一起;之后哈希对不上,就不让它继续运行。 +/// +/// 合法的升级:新哈希出现在一份 CI 签名的清单里(节点握手时带着宿主机更新器验过的清单,这里用内嵌的 CI 公钥再验一遍, +/// 所以不依赖面板有没有收到过这次发布),并且清单的 seq 不低于绑定时的——不许回退到更旧的版本。其余情况(文件被换掉、降级、没人签过的二进制、 +/// 手工替换了二进制的非容器节点)都算不符;管理员确认过之后可以清除绑定,下一次握手重新绑定。 +/// +/// +/// 边界:哈希是节点自己报的。一个被换进来、故意撒谎的恶意二进制可以照抄原来的哈希;这一层抓的是文件被换、版本被改、降级和起了不对的二进制。 +/// 容器节点要堵住撒谎,需要宿主机更新器背书(容器之外),见 docs/access.md。 +/// +/// +public sealed class BinaryBindingService : IBinaryAttestor +{ + public const string Enforce = "enforce"; + public const string Warn = "warn"; + public const string Off = "off"; + + private readonly INodeBindingStore _store; + private readonly IReleaseKey _key; + private readonly BackendOptions _options; + private readonly TimeProvider _time; + + public BinaryBindingService(INodeBindingStore store, IReleaseKey key, BackendOptions options, TimeProvider time) + { + _store = store; + _key = key; + _options = options; + _time = time; + } + + public ValueTask> ListAsync(CancellationToken ct) => _store.ListAsync(ct); + + /// 管理员确认新的二进制是自己要的(比如手工升级了非容器节点):清除绑定,下一次握手重新绑定。 + public ValueTask ClearAsync(string nodeId, CancellationToken ct) => _store.DeleteAsync(nodeId, ct); + + /// 节点删除之后顺手清掉它的绑定。 + public async ValueTask ForgetAsync(string nodeId, CancellationToken ct) => await _store.DeleteAsync(nodeId, ct); + + public async ValueTask CheckAsync(NodeRecord node, BinaryEvidence evidence, CancellationToken ct) + { + string policy = _options.BinaryPolicy; + if (policy == Off || ReleaseService.ComponentOf(node.Id) is not { } component) + { + return new BinaryDecision(true); + } + + long now = _time.GetUtcNow().ToUnixTimeMilliseconds(); + NodeBinding? binding = await _store.GetAsync(node.Id.Value, ct); + + if (evidence.Sha256 is null) + { + // 旧节点不上报:还没绑定过就放行(未绑定);绑定过的不许退回去,否则换个旧版本就绕过了 + return binding is null ? new BinaryDecision(true) : await ViolationAsync(binding, "the node stopped reporting its binary hash", policy, now, ct); + } + + string hash = evidence.Sha256; + if (!IsHash(hash)) + { + return new BinaryDecision(policy != Enforce, "the reported binary hash is malformed"); + } + + Official? official = VerifyManifest(evidence, component); + bool listed = official is not null && official.Binaries.Contains(hash); + + if (binding is null) + { + if (official is not null && !listed) + { + // 带着一份有效的签名清单,自己的哈希却不在里面:它不是这份清单对应的官方二进制 + return new BinaryDecision(policy != Enforce, "this binary is not in the CI-signed manifest it was installed from"); + } + + await _store.UpsertAsync(new NodeBinding(node.Id.Value, hash, official?.Seq ?? 0, listed, now, null, null), ct); + return new BinaryDecision(true); + } + + if (binding.Sha256 == hash) + { + if (listed && (official!.Seq > binding.Seq || !binding.Signed || binding.Violation is not null)) + { + await _store.UpsertAsync(binding with { Seq = Math.Max(binding.Seq, official!.Seq), Signed = true, Violation = null, ViolationAtMs = null }, ct); + } + else if (binding.Violation is not null) + { + await _store.UpsertAsync(binding with { Violation = null, ViolationAtMs = null }, ct); + } + + return new BinaryDecision(true); + } + + // 哈希变了:必须是一份不低于绑定时 seq 的 CI 签名清单里的官方二进制(合法升级),否则不符 + if (listed && official!.Seq >= binding.Seq) + { + await _store.UpsertAsync(new NodeBinding(node.Id.Value, hash, official.Seq, true, now, null, null), ct); + return new BinaryDecision(true); + } + + return await ViolationAsync(binding, $"the running binary changed (bound {Short(binding.Sha256)}, now {Short(hash)}) and is not in a newer CI-signed manifest", policy, now, ct); + } + + private async ValueTask ViolationAsync(NodeBinding binding, string reason, string policy, long now, CancellationToken ct) + { + await _store.UpsertAsync(binding with { Violation = reason, ViolationAtMs = now }, ct); + return new BinaryDecision(policy != Enforce, reason); + } + + private sealed record Official(long Seq, HashSet Binaries); + + /// 用内嵌的 CI 公钥验清单;验过且是这个组件的才返回它列出的官方二进制哈希。 + private Official? VerifyManifest(BinaryEvidence evidence, string component) + { + if (evidence.Manifest is not { Length: > 0 and <= ReleaseService.MaxManifestBytes } manifest + || evidence.Signature is not { Length: > 0 and <= ReleaseService.MaxSignatureBytes } signature + || !ReleaseSignature.Verify(_key.Pem, manifest, signature)) + { + return null; + } + + try + { + using JsonDocument document = JsonDocument.Parse(manifest); + JsonElement root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object + || !root.TryGetProperty("component", out JsonElement c) || c.GetString() != component + || !root.TryGetProperty("seq", out JsonElement s) || !s.TryGetInt64(out long seq) || seq <= 0) + { + return null; + } + + var binaries = new HashSet(StringComparer.Ordinal); + if (root.TryGetProperty("binaries", out JsonElement list) && list.ValueKind == JsonValueKind.String) + { + foreach (string item in (list.GetString() ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + if (IsHash(item)) + { + binaries.Add(item); + } + } + } + + return new Official(seq, binaries); + } + catch (JsonException) + { + return null; + } + } + + private static bool IsHash(string value) => value.Length == 64 && value.All(ch => ch is (>= '0' and <= '9') or (>= 'a' and <= 'f')); + + private static string Short(string hash) => hash.Length > 12 ? hash[..12] : hash; +} diff --git a/backend/src/MoShiro.Backend.Application/Agents/ProbeService.cs b/backend/src/MoShiro.Backend.Application/Agents/ProbeService.cs index 4f87aea..cc9853a 100644 --- a/backend/src/MoShiro.Backend.Application/Agents/ProbeService.cs +++ b/backend/src/MoShiro.Backend.Application/Agents/ProbeService.cs @@ -29,8 +29,11 @@ public sealed partial class ProbeService private readonly NodeInfoService _nodes; private readonly LinkService _links; - public ProbeService(IProbeStore probes, IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, TimeProvider time, NodeInfoService nodes, LinkService links) + private readonly BinaryBindingService _bindings; + + public ProbeService(BinaryBindingService bindings, IProbeStore probes, IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, TimeProvider time, NodeInfoService nodes, LinkService links) { + _bindings = bindings; _links = links; _nodes = nodes; _probes = probes; @@ -94,6 +97,7 @@ public sealed partial class ProbeService if (deleted) { await _nodes.ForgetAsync(id, ct); + await _bindings.ForgetAsync(id, ct); await _links.ForgetNodeAsync(id, ct); _gateway.NotifyAllTaskSetsChanged(); } diff --git a/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs b/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs index 0dbb58e..d8f69ec 100644 --- a/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs +++ b/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs @@ -38,10 +38,11 @@ public static class ApplicationServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); 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/Auth/AuthModels.cs b/backend/src/MoShiro.Backend.Application/Auth/AuthModels.cs index 73623a1..b7cb434 100644 --- a/backend/src/MoShiro.Backend.Application/Auth/AuthModels.cs +++ b/backend/src/MoShiro.Backend.Application/Auth/AuthModels.cs @@ -12,10 +12,10 @@ public enum PrincipalKind /// public sealed record Principal(PrincipalKind Kind, IReadOnlySet Capabilities, string? UserId, string? Username, string? CsrfToken, string? TokenId) { - /// 会话是怎么登录的():只有 passkey 登录的会话以后能提权。API key 为空。 + /// 会话是怎么登录的():登录方式只用于审计和显示;敏感操作靠提权窗口(触碰 passkey)。API key 为空。 public string AuthMethod { get; init; } = ""; - /// 会话的标识(令牌哈希的十六进制):提权窗口按它记。API key 为空。 + /// 会话的标识(令牌哈希的十六进制):API key 为空。 public string? SessionId { get; init; } /// 审计和归属里显示的名字:用户名,或者 key:令牌名。 diff --git a/backend/src/MoShiro.Backend.Application/Auth/AuthService.cs b/backend/src/MoShiro.Backend.Application/Auth/AuthService.cs index a03a70e..458f717 100644 --- a/backend/src/MoShiro.Backend.Application/Auth/AuthService.cs +++ b/backend/src/MoShiro.Backend.Application/Auth/AuthService.cs @@ -71,7 +71,7 @@ public sealed partial class AuthService } UserRecord? user = await _store.GetUserAsync(session.UserId, ct); - return user is null ? null : new Principal(PrincipalKind.Session, Principal.SetOf(CapabilityCatalog.ForSession(session.AuthMethod)), user.Id, user.Username, Base64Url.Encode(session.CsrfToken), null) { AuthMethod = session.AuthMethod, SessionId = Convert.ToHexString(session.IdSha256) }; + return user is null ? null : new Principal(PrincipalKind.Session, Principal.SetOf(CapabilityCatalog.ForSession()), user.Id, user.Username, Base64Url.Encode(session.CsrfToken), null) { AuthMethod = session.AuthMethod, SessionId = Convert.ToHexString(session.IdSha256) }; } public async ValueTask AuthenticateTokenAsync(string bearer, CancellationToken ct) diff --git a/backend/src/MoShiro.Backend.Application/Auth/Capabilities.cs b/backend/src/MoShiro.Backend.Application/Auth/Capabilities.cs index 503a3a2..c93544f 100644 --- a/backend/src/MoShiro.Backend.Application/Auth/Capabilities.cs +++ b/backend/src/MoShiro.Backend.Application/Auth/Capabilities.cs @@ -45,7 +45,7 @@ public static class Capability public const string WebhooksManage = "webhooks.manage"; } -/// 一项能力的元数据。Trust:改变信任的能力(加节点、吊销、账户),后续会要求 passkey 签名;Reserved:还没有对应的功能。 +/// 一项能力的元数据。Trust:改变信任或有破坏性的能力(吊销、账户),界面上特别标出,授权与其他能力一样靠 API key 或登录会话;真正由 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); @@ -132,12 +132,10 @@ public static class CapabilityCatalog } } - /// 登录会话的能力:全部,包括信任类;信任类的操作还要有效的提权窗口()。 + /// + /// 登录会话的能力:全部,包括信任类,密码登录和 passkey 登录一样。区别在于敏感操作:信任类的操作还要有效的提权窗口(), + /// 窗口要当场触碰 passkey 才能开,所以密码泄露的人做不了这些,日常管理却不需要每次触碰。 + /// 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 deleted file mode 100644 index de6c7c1..0000000 --- a/backend/src/MoShiro.Backend.Application/Auth/ElevationService.cs +++ /dev/null @@ -1,135 +0,0 @@ -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/PasskeyService.cs b/backend/src/MoShiro.Backend.Application/Auth/PasskeyService.cs index 9dce41a..c058494 100644 --- a/backend/src/MoShiro.Backend.Application/Auth/PasskeyService.cs +++ b/backend/src/MoShiro.Backend.Application/Auth/PasskeyService.cs @@ -33,7 +33,6 @@ public sealed class PasskeyService 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 = []; @@ -43,13 +42,10 @@ public sealed class PasskeyService 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) + public PasskeyService(IAuthStore store, AuthService auth, TimeProvider time) { _store = store; _auth = auth; - _elevation = elevation; _time = time; } @@ -118,37 +114,6 @@ public sealed class PasskeyService : 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) { diff --git a/backend/src/MoShiro.Backend.Application/Auth/TrustService.cs b/backend/src/MoShiro.Backend.Application/Auth/TrustService.cs index db0ebff..21d6003 100644 --- a/backend/src/MoShiro.Backend.Application/Auth/TrustService.cs +++ b/backend/src/MoShiro.Backend.Application/Auth/TrustService.cs @@ -8,7 +8,10 @@ using MoShiro.Protocol; namespace MoShiro.Backend.Application; /// 要操作员用 passkey 签的下一版操作员集合:Challenge 是语句的 SHA-256(base64url),也就是 WebAuthn 的挑战。 -public sealed record TrustProposal(string Statement, int Version, string Challenge); +public sealed record TrustProposal(string Statement, int Version, string Challenge, IReadOnlyList Added, IReadOnlyList Removed, bool ServerKeyChanged); + +/// 下一版里新增的 passkey:签名的人要看清楚是不是自己加的,攻击者往账号里塞一把 passkey 再等人签名,是这个流程唯一的风险。 +public sealed record ProposedCredential(string Id, string Name, long CreatedAtMs); /// InSync:当前生效的集合和现有的 passkey、域名是否一致;不一致就需要签一个新版本。 /// 这个面板的服务器身份密钥的指纹;ServerKeyListed:当前生效的操作员集合里已经登记了它(节点据此认证服务器、加密链路)。 @@ -72,7 +75,12 @@ public sealed class TrustService : IOperatorTrustSource, IDisposable [.. 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)))); + HashSet before = [.. current?.Keys.Select(k => k.CredentialId) ?? []]; + HashSet after = [.. next.Keys.Select(k => k.CredentialId)]; + ProposedCredential[] added = [.. passkeys.Where(p => !before.Contains(p.Id)).Select(p => new ProposedCredential(p.Id, p.Name, p.CreatedAtMs))]; + string[] removed = [.. before.Where(id => !after.Contains(id)).Order(StringComparer.Ordinal)]; + bool serverChanged = current is not null && !(current.Servers ?? []).Select(Convert.ToBase64String).Order(StringComparer.Ordinal).SequenceEqual((next.Servers ?? []).Select(Convert.ToBase64String).Order(StringComparer.Ordinal)); + return new TrustProposal(statement, next.Version, Base64Url.Encode(SHA256.HashData(Encoding.UTF8.GetBytes(statement))), added, removed, serverChanged); } private static bool Same(OperatorSet a, OperatorSet b) => @@ -82,7 +90,7 @@ public sealed class TrustService : IOperatorTrustSource, IDisposable /// /// 收下操作员签回来的一版(创世,或上一版里的凭证签的更新):语句里的每把凭证都要是已注册的 passkey、域名对得上、链验得过。 - /// 谁能调用由端点的能力和提权窗口把关;这里不看调用者。 + /// 谁能调用由端点的能力把关;这里不看调用者,信任来自节点自己验证的 passkey 签名。 /// public async ValueTask> CommitAsync(SignedStatementDto signed, PasskeyContext context, CancellationToken ct) { diff --git a/backend/src/MoShiro.Backend.Application/BackendOptions.cs b/backend/src/MoShiro.Backend.Application/BackendOptions.cs index 1b1c1d6..99d564c 100644 --- a/backend/src/MoShiro.Backend.Application/BackendOptions.cs +++ b/backend/src/MoShiro.Backend.Application/BackendOptions.cs @@ -21,6 +21,11 @@ public sealed class BackendOptions /// 宿主机更新器的只读状态目录;留空使用数据目录下的 update-status/。 public string? BackendUpdateStatusDirectory { get; set; } + /// + /// 节点二进制绑定的策略:enforce(默认)二进制和绑定不符、又不在 CI 签名的更新清单里就拒绝连接;warn 只记录不拒绝;off 不检查。 + /// + public string BinaryPolicy { get; set; } = "enforce"; + /// 面板的对外地址(用于生成安装命令)。 public string? PublicUrl { get; set; } @@ -32,15 +37,6 @@ public sealed class BackendOptions /// 允许的来源(逗号分隔,如 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; diff --git a/backend/src/MoShiro.Backend.Application/Ports/StorePorts.cs b/backend/src/MoShiro.Backend.Application/Ports/StorePorts.cs index 7f1e666..7824097 100644 --- a/backend/src/MoShiro.Backend.Application/Ports/StorePorts.cs +++ b/backend/src/MoShiro.Backend.Application/Ports/StorePorts.cs @@ -144,6 +144,20 @@ public interface IEventStore ValueTask DeleteOlderThanAsync(long cutoffMs, CancellationToken ct); } +/// 节点的二进制绑定:第一次握手成功时绑定的 SHA-256,对应的签名清单 seq(没有签名清单时为 0),以及最近一次不符的原因。 +public sealed record NodeBinding(string NodeId, string Sha256, long Seq, bool Signed, long BoundAtMs, string? Violation, long? ViolationAtMs); + +public interface INodeBindingStore +{ + ValueTask GetAsync(string nodeId, CancellationToken ct); + + ValueTask> ListAsync(CancellationToken ct); + + ValueTask UpsertAsync(NodeBinding binding, CancellationToken ct); + + ValueTask DeleteAsync(string nodeId, CancellationToken ct); +} + /// 操作员集合的链(passkey 签的版本,按版本号递增)。 public interface IOperatorSetStore { diff --git a/backend/src/MoShiro.Backend.Application/Updates/ReleaseService.cs b/backend/src/MoShiro.Backend.Application/Updates/ReleaseService.cs index e0f773f..75658c6 100644 --- a/backend/src/MoShiro.Backend.Application/Updates/ReleaseService.cs +++ b/backend/src/MoShiro.Backend.Application/Updates/ReleaseService.cs @@ -145,7 +145,7 @@ public sealed partial class ReleaseService : IDisposable private IEnumerable OnlineOf(string component) => _gateway.OnlineNodes.Where(n => ComponentOf(n) == component); - private static string? ComponentOf(NodeId node) => + internal static string? ComponentOf(NodeId node) => node.Value.StartsWith(AgentIds.Prefix, StringComparison.Ordinal) ? NodeKinds.Agent : node.Value.StartsWith(ProbeId.Prefix, StringComparison.Ordinal) ? NodeKinds.Probe : null; diff --git a/backend/src/MoShiro.Backend.Api/Admin/ReleaseSignature.cs b/backend/src/MoShiro.Backend.Application/Updates/ReleaseSignature.cs similarity index 96% rename from backend/src/MoShiro.Backend.Api/Admin/ReleaseSignature.cs rename to backend/src/MoShiro.Backend.Application/Updates/ReleaseSignature.cs index 9934ed7..b50b302 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/ReleaseSignature.cs +++ b/backend/src/MoShiro.Backend.Application/Updates/ReleaseSignature.cs @@ -1,7 +1,7 @@ using System.Security.Cryptography; using System.Text; -namespace MoShiro.Backend.Api; +namespace MoShiro.Backend.Application; /// /// 发布清单的签名校验:用 Backend 内嵌的 CI 发布公钥(packaging/update/release-key.pub.pem)验 ECDSA P-256 / SHA-256(DER)签名,签名对象是清单的原始字节。 diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs index ba8a7c5..ac26db0 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 = 13; + public const int CurrentVersion = 14; public static void Migrate(SqliteConnection connection) { @@ -221,6 +221,26 @@ internal static class Schema connection.Exec("PRAGMA user_version = 13"); transaction.Commit(); } + + if (version < 14) + { + // 节点的二进制绑定:第一次握手成功时绑定正在运行的二进制的 SHA-256 + using SqliteTransaction transaction = connection.BeginTransaction(); + connection.Exec( + """ + CREATE TABLE node_bindings ( + node_id TEXT PRIMARY KEY, + sha256 TEXT NOT NULL, + seq INTEGER NOT NULL, + signed INTEGER NOT NULL, + bound_at INTEGER NOT NULL, + violation TEXT, + violation_at INTEGER + ) + """); + connection.Exec("PRAGMA user_version = 14"); + transaction.Commit(); + } } /// diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteNodeBindingStore.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteNodeBindingStore.cs new file mode 100644 index 0000000..e4bb333 --- /dev/null +++ b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteNodeBindingStore.cs @@ -0,0 +1,35 @@ +using MoShiro.Backend.Application; + +namespace MoShiro.Backend.Storage.Sqlite; + +public sealed class SqliteNodeBindingStore : INodeBindingStore +{ + private const string Columns = "node_id, sha256, seq, signed, bound_at, violation, violation_at"; + + private readonly SqliteDatabase _db; + + public SqliteNodeBindingStore(SqliteDatabase db) + { + _db = db; + } + + public ValueTask GetAsync(string nodeId, CancellationToken ct) => + _db.ReadAsync(c => c.QueryOne($"SELECT {Columns} FROM node_bindings WHERE node_id = @id", Read, ("@id", nodeId)), ct); + + public ValueTask> ListAsync(CancellationToken ct) => + _db.ReadAsync>(c => c.Query($"SELECT {Columns} FROM node_bindings ORDER BY node_id", Read), ct); + + public async ValueTask UpsertAsync(NodeBinding binding, CancellationToken ct) => + await _db.WriteAsync( + c => c.Exec( + $"INSERT INTO node_bindings ({Columns}) VALUES (@id, @sha, @seq, @signed, @at, @violation, @violation_at) " + + "ON CONFLICT(node_id) DO UPDATE SET sha256 = excluded.sha256, seq = excluded.seq, signed = excluded.signed, bound_at = excluded.bound_at, violation = excluded.violation, violation_at = excluded.violation_at", + ("@id", binding.NodeId), ("@sha", binding.Sha256), ("@seq", binding.Seq), ("@signed", binding.Signed ? 1 : 0), ("@at", binding.BoundAtMs), ("@violation", binding.Violation), ("@violation_at", binding.ViolationAtMs)), + ct); + + public ValueTask DeleteAsync(string nodeId, CancellationToken ct) => + _db.WriteAsync(c => c.Exec("DELETE FROM node_bindings WHERE node_id = @id", ("@id", nodeId)) > 0, ct); + + private static NodeBinding Read(Microsoft.Data.Sqlite.SqliteDataReader r) => + new(r.GetString(0), r.GetString(1), r.GetInt64(2), r.GetInt64(3) != 0, r.GetInt64(4), r.NullableString(5), r.NullableLong(6)); +} diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/StorageServiceCollectionExtensions.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/StorageServiceCollectionExtensions.cs index 5370a80..569b93e 100644 --- a/backend/src/MoShiro.Backend.Storage.Sqlite/StorageServiceCollectionExtensions.cs +++ b/backend/src/MoShiro.Backend.Storage.Sqlite/StorageServiceCollectionExtensions.cs @@ -24,6 +24,7 @@ public static class StorageServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/backend/tests/MoShiro.Backend.Tests/BackendFixture.cs b/backend/tests/MoShiro.Backend.Tests/BackendFixture.cs index d7f4a9f..c36ce48 100644 --- a/backend/tests/MoShiro.Backend.Tests/BackendFixture.cs +++ b/backend/tests/MoShiro.Backend.Tests/BackendFixture.cs @@ -275,7 +275,7 @@ public class BackendFixture : IAsyncLifetime /// /// 管理员的全能力 API key:和线上一样的路径——密码登录的会话没有任何能力,只能注册 passkey; - /// 注册之后用 passkey 登录才有能力,再触碰一次开凭证窗口,才能创建带信任类能力的 key。 + /// 注册之后用 passkey 登录(要签操作员集合等节点会验证的事时才需要 passkey)。 /// private async Task CreateAdminTokenAsync() { @@ -306,15 +306,6 @@ public class BackendFixture : IAsyncLifetime 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/BinaryBindingTests.cs b/backend/tests/MoShiro.Backend.Tests/BinaryBindingTests.cs new file mode 100644 index 0000000..f5b034f --- /dev/null +++ b/backend/tests/MoShiro.Backend.Tests/BinaryBindingTests.cs @@ -0,0 +1,267 @@ +using System.Net; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json.Nodes; +using Fleet.Core; +using Fleet.Server; +using MoShiro.Backend.Application; +using MoShiro.Protocol; + +namespace MoShiro.Backend.Tests; + +/// +/// 节点二进制绑定:第一次握手成功的那一刻绑定正在运行的二进制的哈希,之后对不上又不在 CI 签名的更新清单里,就不让它继续运行。 +/// 官方哈希集合来自 CI 签名的清单(节点握手时带着),所以被攻破的 Backend 伪造不了,也不依赖面板有没有收到过这次发布。 +/// +public sealed class BinaryBindingTests : IDisposable +{ + private const string A = "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1"; + private const string B = "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2"; + private const string C = "c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3"; + + private readonly ECDsa _ci = ECDsa.Create(ECCurve.NamedCurves.nistP256); + private readonly ECDsa _other = ECDsa.Create(ECCurve.NamedCurves.nistP256); + private readonly MemoryStore _store = new(); + private readonly NodeRecord _agent = Node(AgentIds.Prefix + "_test1"); + + public void Dispose() + { + _ci.Dispose(); + _other.Dispose(); + } + + private static string Hash(string seed) => seed[..2] + new string(seed[1], 62); + + private static NodeRecord Node(string id) => new(new NodeId(id), "n", NodeStatus.Active, [1], [2], [], null, null, 0, null); + + private BinaryBindingService Service(string policy = "enforce") => + new(_store, new TestKey(_ci), new BackendOptions { BinaryPolicy = policy }, TimeProvider.System); + + private sealed class TestKey(ECDsa key) : IReleaseKey + { + public string Pem => key.ExportSubjectPublicKeyInfoPem(); + } + + private static string Sign(ECDsa key, string manifest) => + Convert.ToBase64String(key.SignData(Encoding.UTF8.GetBytes(manifest), HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence)); + + /// 节点握手时带的证据:哈希,加上(可选)某把密钥签名的清单。 + private BinaryEvidence Evidence(string hash, long? seq = null, string[]? binaries = null, string component = "agent", ECDsa? signer = null) + { + if (seq is null) + { + return new BinaryEvidence(hash, null, null); + } + + string manifest = $"{{\"schema\":1,\"component\":\"{component}\",\"seq\":{seq},\"binaries\":\"{string.Join(',', binaries ?? [hash])}\"}}"; + return new BinaryEvidence(hash, manifest, Sign(signer ?? _ci, manifest)); + } + + private static string H(char c) => new(c, 64); + + [Fact] + public async Task TheFirstHandshakeBinds_AndTheSameBinaryKeepsWorking() + { + BinaryBindingService service = Service(); + + Assert.True((await service.CheckAsync(_agent, Evidence(H('a')), default)).Accept); + Assert.Equal(H('a'), (await _store.GetAsync(_agent.Id.Value, default))!.Sha256); + Assert.True((await service.CheckAsync(_agent, Evidence(H('a')), default)).Accept); + } + + [Fact] + public async Task AReplacedBinary_IsRefused_AndTheReasonIsKept() + { + BinaryBindingService service = Service(); + await service.CheckAsync(_agent, Evidence(H('a')), default); + + BinaryDecision decision = await service.CheckAsync(_agent, Evidence(H('b')), default); + + Assert.False(decision.Accept); + Assert.Contains("changed", decision.Reason, StringComparison.Ordinal); + NodeBinding bound = (await _store.GetAsync(_agent.Id.Value, default))!; + Assert.Equal(H('a'), bound.Sha256); // 绑定不会因为一次不符而被改写 + Assert.NotNull(bound.Violation); + + // 原来的二进制回来了:恢复,原因清掉 + Assert.True((await service.CheckAsync(_agent, Evidence(H('a')), default)).Accept); + Assert.Null((await _store.GetAsync(_agent.Id.Value, default))!.Violation); + } + + [Fact] + public async Task AnUpgrade_NeedsACiSignedManifestThatListsTheNewBinary() + { + BinaryBindingService service = Service(); + await service.CheckAsync(_agent, Evidence(H('a'), seq: 100, binaries: [H('a')]), default); + + // 新二进制在一份更新的 CI 签名清单里:合法升级,绑定跟着改 + Assert.True((await service.CheckAsync(_agent, Evidence(H('b'), seq: 200, binaries: [H('b')]), default)).Accept); + NodeBinding upgraded = (await _store.GetAsync(_agent.Id.Value, default))!; + Assert.Equal(H('b'), upgraded.Sha256); + Assert.Equal(200, upgraded.Seq); + Assert.True(upgraded.Signed); + + // 退回旧版本(清单 seq 更低):降级,拒绝 + Assert.False((await service.CheckAsync(_agent, Evidence(H('a'), seq: 100, binaries: [H('a')]), default)).Accept); + // 同一份清单里的另一个官方二进制(比如另一个平台的构建)不算降级 + Assert.True((await service.CheckAsync(_agent, Evidence(H('c'), seq: 200, binaries: [H('b'), H('c')]), default)).Accept); + } + + [Fact] + public async Task AManifestSignedByAnotherKey_OrForAnotherComponent_ProvesNothing() + { + BinaryBindingService service = Service(); + await service.CheckAsync(_agent, Evidence(H('a')), default); + + Assert.False((await service.CheckAsync(_agent, Evidence(H('b'), seq: 500, binaries: [H('b')], signer: _other), default)).Accept); + Assert.False((await service.CheckAsync(_agent, Evidence(H('b'), seq: 500, binaries: [H('b')], component: "probe"), default)).Accept); + Assert.Equal(H('a'), (await _store.GetAsync(_agent.Id.Value, default))!.Sha256); + } + + [Fact] + public async Task ASignedManifestThatDoesNotListTheRunningBinary_IsRefusedAtTheFirstHandshake() + { + BinaryBindingService service = Service(); + + BinaryDecision decision = await service.CheckAsync(_agent, Evidence(H('a'), seq: 100, binaries: [H('b')]), default); + + Assert.False(decision.Accept); + Assert.Null(await _store.GetAsync(_agent.Id.Value, default)); + } + + [Fact] + public async Task AnUnsignedBinding_BecomesSignedWhenTheManifestArrivesLater() + { + // 容器节点第一次握手时更新器可能还没写出清单:先按首次握手绑定,清单到了再标成已签名 + BinaryBindingService service = Service(); + await service.CheckAsync(_agent, Evidence(H('a')), default); + Assert.False((await _store.GetAsync(_agent.Id.Value, default))!.Signed); + + Assert.True((await service.CheckAsync(_agent, Evidence(H('a'), seq: 100, binaries: [H('a')]), default)).Accept); + + NodeBinding bound = (await _store.GetAsync(_agent.Id.Value, default))!; + Assert.True(bound.Signed); + Assert.Equal(100, bound.Seq); + } + + [Fact] + public async Task ALegacyNode_IsTolerated_ButCannotGoBackOnceBound() + { + BinaryBindingService service = Service(); + NodeRecord legacy = Node(ProbeId.Prefix + "_old"); + + Assert.True((await service.CheckAsync(legacy, new BinaryEvidence(null, null, null), default)).Accept); + Assert.Null(await _store.GetAsync(legacy.Id.Value, default)); + + await service.CheckAsync(_agent, Evidence(H('a')), default); + Assert.False((await service.CheckAsync(_agent, new BinaryEvidence(null, null, null), default)).Accept); // 绑定过的不许退回不上报 + } + + [Fact] + public async Task MalformedHashes_AreRefused() + { + BinaryBindingService service = Service(); + + Assert.False((await service.CheckAsync(_agent, new BinaryEvidence("not-a-hash", null, null), default)).Accept); + Assert.False((await service.CheckAsync(_agent, new BinaryEvidence(new string('A', 64), null, null), default)).Accept); // 要小写十六进制 + Assert.Null(await _store.GetAsync(_agent.Id.Value, default)); + } + + [Fact] + public async Task TheAdministratorCanAcceptANewBinary_ByClearingTheBinding() + { + BinaryBindingService service = Service(); + await service.CheckAsync(_agent, Evidence(H('a')), default); + Assert.False((await service.CheckAsync(_agent, Evidence(H('b')), default)).Accept); + + Assert.True(await service.ClearAsync(_agent.Id.Value, default)); + + Assert.True((await service.CheckAsync(_agent, Evidence(H('b')), default)).Accept); + Assert.Equal(H('b'), (await _store.GetAsync(_agent.Id.Value, default))!.Sha256); + } + + [Fact] + public async Task WarnRecordsButDoesNotRefuse_AndOffDoesNothing() + { + BinaryBindingService warn = Service("warn"); + await warn.CheckAsync(_agent, Evidence(H('a')), default); + BinaryDecision decision = await warn.CheckAsync(_agent, Evidence(H('b')), default); + Assert.True(decision.Accept); + Assert.NotNull((await _store.GetAsync(_agent.Id.Value, default))!.Violation); + + BinaryBindingService off = Service("off"); + NodeRecord other = Node(AgentIds.Prefix + "_test2"); + Assert.True((await off.CheckAsync(other, Evidence(H('a')), default)).Accept); + Assert.Null(await _store.GetAsync(other.Id.Value, default)); + } + + [Fact] + public async Task EachNodeHasItsOwnBinding() + { + BinaryBindingService service = Service(); + NodeRecord second = Node(AgentIds.Prefix + "_test3"); + + await service.CheckAsync(_agent, Evidence(H('a')), default); + await service.CheckAsync(second, Evidence(H('b')), default); + + Assert.False((await service.CheckAsync(_agent, Evidence(H('b')), default)).Accept); // 别的节点的二进制,不是自己绑定的 + Assert.True((await service.CheckAsync(second, Evidence(H('b')), default)).Accept); + } + + private sealed class MemoryStore : INodeBindingStore + { + private readonly Dictionary _rows = []; + + public ValueTask GetAsync(string nodeId, CancellationToken ct) => new(_rows.GetValueOrDefault(nodeId)); + + public ValueTask> ListAsync(CancellationToken ct) => new([.. _rows.Values]); + + public ValueTask UpsertAsync(NodeBinding binding, CancellationToken ct) + { + _rows[binding.NodeId] = binding; + return ValueTask.CompletedTask; + } + + public ValueTask DeleteAsync(string nodeId, CancellationToken ct) => new(_rows.Remove(nodeId)); + } +} + +/// 真实的 Agent 在进程里连上 Backend:第一次握手就绑定了它正在运行的二进制,管理端看得到,也可以清除。 +[Collection("probe-e2e")] +public sealed class BinaryBindingEndToEndTests +{ + private readonly BackendFixture _backend; + + public BinaryBindingEndToEndTests(BackendFixture backend) + { + _backend = backend; + } + + [Fact] + public async Task ARealAgent_IsBoundOnItsFirstHandshake_AndTheAdministratorCanClearIt() + { + await using AgentHarness agent = await AgentHarness.StartAsync(_backend); + string? expected = NodeBinary.Sha256Hex; + Assert.NotNull(expected); + + JsonNode? binary = null; + await BackendFixture.EventuallyAsync( + async () => + { + JsonNode info = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/build-info"); + binary = info["nodes"]!.AsArray().FirstOrDefault(n => n!["id"]!.GetValue() == agent.Id)?["binary"]; + return binary is not null; + }, + TimeSpan.FromSeconds(30), + "the agent is bound on its first handshake"); + + Assert.Equal(expected, binary!["sha256"]!.GetValue()); + Assert.False(binary["signed"]!.GetValue()); // 测试里的 Agent 没有更新器,也就没有 CI 签名的清单 + Assert.Null(binary["violation"]); + + using HttpResponseMessage cleared = await _backend.Admin.DeleteAsync("/api/admin/nodes/" + agent.Id + "/binary"); + Assert.Equal(HttpStatusCode.NoContent, cleared.StatusCode); + JsonNode after = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/build-info"); + Assert.Null(after["nodes"]!.AsArray().First(n => n!["id"]!.GetValue() == agent.Id)!["binary"]); + } +} diff --git a/backend/tests/MoShiro.Backend.Tests/ElevationTests.cs b/backend/tests/MoShiro.Backend.Tests/ElevationTests.cs deleted file mode 100644 index d34c93b..0000000 --- a/backend/tests/MoShiro.Backend.Tests/ElevationTests.cs +++ /dev/null @@ -1,201 +0,0 @@ -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/PasskeyTests.cs b/backend/tests/MoShiro.Backend.Tests/PasskeyTests.cs index 2d85ec8..bf07b66 100644 --- a/backend/tests/MoShiro.Backend.Tests/PasskeyTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/PasskeyTests.cs @@ -112,7 +112,7 @@ public sealed class PasskeyTests private string _user = ""; - /// 每个测试用自己新建的用户:管理员已经有 fixture 的 passkey,再加就要提权,而这里测的是第一个 passkey 的流程。 + /// 每个测试用自己新建的用户:管理员已经有 fixture 的 passkey,而这里测的是第一个 passkey 的流程。 private async Task FreshSessionAsync() { _user = _backend.UniqueName("pk"); @@ -121,16 +121,6 @@ public sealed class PasskeyTests 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) @@ -251,12 +241,8 @@ public sealed class PasskeyTests // 正常注册,同一把凭证不能注册两次,也不能用登录用的挑战注册 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()))); } @@ -270,11 +256,9 @@ public sealed class PasskeyTests 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.Equal(HttpStatusCode.NotFound, (await intruder.DeleteAsync("/api/admin/auth/passkeys/" + passkey.Id)).StatusCode); // 别人的 passkey:看不见,也删不了 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/ReleaseOfferTests.cs b/backend/tests/MoShiro.Backend.Tests/ReleaseOfferTests.cs index d9a7a88..63e0605 100644 --- a/backend/tests/MoShiro.Backend.Tests/ReleaseOfferTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/ReleaseOfferTests.cs @@ -3,6 +3,7 @@ using System.Security.Cryptography; using System.Text; using System.Text.Json.Nodes; using MoShiro.Backend.Api; +using MoShiro.Backend.Application; namespace MoShiro.Backend.Tests; diff --git a/backend/tests/MoShiro.Backend.Tests/TrustTests.cs b/backend/tests/MoShiro.Backend.Tests/TrustTests.cs index 8a240a1..4f7d1dc 100644 --- a/backend/tests/MoShiro.Backend.Tests/TrustTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/TrustTests.cs @@ -43,11 +43,10 @@ public sealed class TrustTests return existing; } - // 操作员:有 passkey、用 passkey 登录的会话(密码会话什么能力都没有) + // 操作员:有 passkey、用 passkey 登录的会话 (HttpClient session, SoftwarePasskey passkey, _) = await _backend.CreateStrongSessionAsync("operator"); - // 创世要凭证窗口(立根的人要先证明自己),再用 passkey 签集合本身 - await ElevateAsync(session, passkey); + // 创世:用 passkey 对集合本身签名(节点据此验链) JsonNode state = JsonNode.Parse(await session.GetStringAsync("/api/admin/trust"))!; Assert.False(state["initialized"]!.GetValue()); string statement = state["proposal"]!["statement"]!.GetValue(); @@ -83,15 +82,6 @@ public sealed class TrustTests }; } - 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) { @@ -235,9 +225,9 @@ public sealed class TrustTests } [Fact] - public async Task APasswordSession_CanOnlyRegisterAPasskey_AndCreatingACodeNeedsNoPasskeyTouch() + public async Task APasswordSession_ManagesAsUsual_AndCreatingACodeNeedsNoPasskey() { - // 密码登录的会话没有任何能力:连看节点列表都不行,只能注册 passkey + // 密码登录保留:日常管理(看列表、创建加入码)不需要 passkey;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); @@ -245,14 +235,14 @@ public sealed class TrustTests 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.NotEmpty(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); + Assert.Equal(HttpStatusCode.OK, (await password.GetAsync("/api/admin/agents")).StatusCode); + Assert.Equal(HttpStatusCode.Created, (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 登录的会话创建码:不需要提权窗口、不需要再触碰(创建码不是信任动作,信任在属主固定里) + // passkey 登录的会话创建码:同样不需要再触碰(创建码不是信任动作,信任在属主固定里) (HttpClient strong, SoftwarePasskey passkey, _) = await _backend.CreateStrongSessionAsync("creator"); using HttpClient strongScope = strong; using SoftwarePasskey passkeyScope = passkey; @@ -260,6 +250,24 @@ public sealed class TrustTests Assert.Equal(HttpStatusCode.Created, code.StatusCode); } + [Fact] + public async Task TheProposal_ListsEveryNewPasskey_SoTheSignerSeesWhatTheyAreAuthorizing() + { + // 往账号里塞一把 passkey 再等人签名是这个流程唯一的风险:提案要明确列出新增的凭证 + Operator op = await OperatorAsync(); + (HttpClient other, SoftwarePasskey added, _) = await _backend.CreateStrongSessionAsync("added"); + using HttpClient otherScope = other; + using SoftwarePasskey addedScope = added; + + JsonNode proposal = JsonNode.Parse(await op.Session.GetStringAsync("/api/admin/trust"))!["proposal"]!; + JsonArray list = proposal["added"]!.AsArray(); + Assert.Contains(list, a => a!["id"]!.GetValue() == added.Id); + Assert.All(list, a => Assert.False(string.IsNullOrWhiteSpace(a!["name"]!.GetValue()))); + Assert.DoesNotContain(list, a => a!["id"]!.GetValue() == op.Passkey.Id); + Assert.Empty(proposal["removed"]!.AsArray()); + Assert.False(proposal["server_key_changed"]!.GetValue()); + } + [Fact] public async Task TheServerKey_IsSignedIntoTheOperatorSet_AndOnlyThatServerPassesTheNodesCheck() { diff --git a/ci/binary-hash.sh b/ci/binary-hash.sh new file mode 100644 index 0000000..a95b64f --- /dev/null +++ b/ci/binary-hash.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# 镜像里某个可执行文件的 SHA-256:ci/binary-hash.sh <镜像> <镜像里的路径> +# 节点握手时上报的就是它正在运行的那个文件的哈希,CI 把这个哈希写进签名的清单(binaries),Backend 据此认「官方二进制」。 +# 先试在镜像里运行 sha256sum(同架构),不行再挂载镜像文件系统直接读。 +set -euo pipefail +image="${1:?usage: binary-hash.sh }"; path="${2:?path in the image}" +container="$(buildah from "$image")" +trap 'buildah rm "$container" >/dev/null 2>&1 || true' EXIT +if out="$(buildah run "$container" -- sha256sum "$path" 2>/dev/null)" && [[ "$out" =~ ^([0-9a-f]{64})[[:space:]] ]]; then + echo "${BASH_REMATCH[1]}" + exit 0 +fi +buildah unshare bash -c ' + set -euo pipefail + mount="$(buildah mount "$1")" + sha256sum "$mount$2" | cut -d" " -f1 + buildah umount "$1" >/dev/null +' _ "$container" "$path" diff --git a/ci/build-main-amd64.sh b/ci/build-main-amd64.sh index 2e8e5c0..855a77c 100644 --- a/ci/build-main-amd64.sh +++ b/ci/build-main-amd64.sh @@ -54,6 +54,11 @@ buildah bud --layers --network host --tls-verify=true --arch amd64 \ -f "$context/Containerfile" \ -t "$local_image" "$context" +# 镜像里节点二进制的哈希:写进签名的清单(binaries),Backend 凭它认「官方二进制」,节点握手时上报的就是它正在运行的这个文件的哈希 +binary_sha256="$(bash "$root/ci/binary-hash.sh" "$local_image" "/usr/local/bin/moshiro-$component")" +[[ "$binary_sha256" =~ ^[0-9a-f]{64}$ ]] || { echo "could not hash the $component binary in the image" >&2; exit 1; } +echo "$component binary sha256: $binary_sha256" + logged_in=false for attempt in 1 2 3 4; do if buildah login --tls-verify=true --username "$ZOT_USERNAME" --password-stdin "$registry" <<< "$ZOT_SECRET" >/dev/null; then @@ -83,14 +88,8 @@ for tag in latest "$dev_tag"; do echo "pushed development image $repository:$tag" done -# 发布清单:给镜像的 digest 签名(私钥只在 CI 密钥里),再登记到面板;面板把它原样转给在线的节点,节点宿主机上的更新器用本机的公钥验签后才会更新。 +# 发布清单:给镜像的 digest 签名(私钥只在 CI 密钥里),放进镜像仓库(<仓库>-manifest:dev),面板在线时再登记一份加速。 # 没有配置签名密钥(例如 fork)就只构建镜像、不发清单。 if [[ -n "${RELEASE_SIGNING_KEY:-}" ]]; then - digest="$(cat "$work/$component.digest")" - release_dir="$work/$component-release" - bash "$root/ci/release-manifest.sh" "$component" "$repository" "$digest" "$dev_tag" dev "$release_dir" - if [[ -n "${RELEASE_API_URL:-}" ]]; then - python3 -c 'import json,sys; print(json.dumps({"manifest": open(sys.argv[1]).read(), "signature": open(sys.argv[2]).read()}))' "$release_dir/manifest.json" "$release_dir/manifest.sig" | curl --fail-with-body --silent --show-error --retry 3 -X POST "${RELEASE_API_URL%/}/api/release/offer" -H 'Content-Type: application/json' --data-binary @- >/dev/null - echo "registered the signed $component release with the panel" - fi + RELEASE_BINARIES="$binary_sha256" bash "$root/ci/publish-release-offer.sh" "$component" "$repository" "$(cat "$work/$component.digest")" "$dev_tag" dev "$work/$component-release" fi diff --git a/ci/publish-manifest.sh b/ci/publish-manifest.sh new file mode 100644 index 0000000..2291323 --- /dev/null +++ b/ci/publish-manifest.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# 把签名清单放进镜像仓库:ci/publish-manifest.sh <镜像仓库> <通道> <清单目录> +# 清单目录里是 release-manifest.sh 产出的 manifest.json 和 manifest.sig。这里把它们装进一个没有任何文件层的小镜像的标签里, +# 推到 <镜像仓库>-manifest:<通道>(例如 registry/moshiro-dev/moshiro-backend-manifest:dev)。 +# 宿主机更新器和 deploy-node.sh 直接从仓库取这个镜像、用本机的发布公钥验签,所以更新不依赖面板在线,也不依赖 Release 页面; +# 仓库只是存放处,被篡改只会让验签失败。需要登录过仓库(buildah login),或设置 ZOT_USERNAME / ZOT_SECRET。 +set -euo pipefail + +repository="${1:?usage: publish-manifest.sh }" +channel="${2:?channel}" +dir="${3:?manifest dir}" +[[ "$repository" =~ ^[a-z0-9./_:-]+$ && "$channel" =~ ^[a-z0-9._-]+$ ]] || { echo "unexpected characters in repository or channel" >&2; exit 2; } +[[ -s "$dir/manifest.json" && -s "$dir/manifest.sig" ]] || { echo "$dir needs manifest.json and manifest.sig" >&2; exit 2; } + +ref="${repository}-manifest:${channel}" +local_name="localhost/moshiro-manifest-build:$$" +container="$(buildah from scratch)" +trap 'buildah rm "$container" >/dev/null 2>&1 || true; buildah rmi "$local_name" >/dev/null 2>&1 || true' EXIT +manifest_b64="$(base64 < "$dir/manifest.json" | tr -d '\n')" +signature="$(tr -d '\n' < "$dir/manifest.sig")" +buildah config \ + --label "org.moshiro.manifest=$manifest_b64" \ + --label "org.moshiro.signature=$signature" \ + --label "org.opencontainers.image.title=MoShiro signed release manifest ($channel)" \ + "$container" +buildah commit --format oci "$container" "$local_name" >/dev/null + +creds=() +if [[ -n "${ZOT_USERNAME:-}" && -n "${ZOT_SECRET:-}" ]]; then creds=(--creds "$ZOT_USERNAME:$ZOT_SECRET"); fi +for attempt in 1 2 3; do + if buildah push --tls-verify=true ${creds[@]+"${creds[@]}"} "$local_name" "docker://$ref" >/dev/null; then + echo "published the signed manifest at $ref" + exit 0 + fi + if [[ "$attempt" -lt 3 ]]; then echo "manifest push attempt $attempt failed; retrying in 5 seconds" >&2; sleep 5; fi +done +echo "could not publish the manifest at $ref" >&2 +exit 1 diff --git a/ci/publish-release-offer.sh b/ci/publish-release-offer.sh index a4eac75..782af59 100644 --- a/ci/publish-release-offer.sh +++ b/ci/publish-release-offer.sh @@ -1,24 +1,24 @@ #!/usr/bin/env bash -# CI 发布 webhook:镜像已推送成功后,签名并登记清单(面板验 CI 签名,不需要令牌)。面板不会因此自动重启 Backend。 +# CI 发布:镜像已推送成功后,签名清单,并发布出去。ci/publish-release-offer.sh <组件> <镜像仓库> <通道> <输出目录> +# 1. 清单放进镜像仓库(<镜像仓库>-manifest:<通道>):更新器和首次部署都从这里取,不依赖面板,必须成功; +# 2. 面板在线时再登记一份(/api/release/offer,面板验 CI 签名,不需要令牌)让在线的节点更快收到:只是加速,失败只警告,不让构建失败。 +# 面板不会因此自动重启 Backend。 set -euo pipefail component="${1:?component}"; repository="${2:?repository}"; digest="${3:?digest}"; tag="${4:?tag}"; channel="${5:?channel}"; outdir="${6:?outdir}" [[ -n "${RELEASE_SIGNING_KEY:-}" ]] || { echo "release signing is not configured; no update offer published"; exit 0; } root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" bash "$root/ci/release-manifest.sh" "$component" "$repository" "$digest" "$tag" "$channel" "$outdir" +bash "$root/ci/publish-manifest.sh" "$repository" "$channel" "$outdir" + if [[ -n "${RELEASE_API_URL:-}" ]]; then python3 -c 'import json,sys; print(json.dumps({"manifest": open(sys.argv[1]).read(), "signature": open(sys.argv[2]).read()}))' \ "$outdir/manifest.json" "$outdir/manifest.sig" > "$outdir/payload.json" response="$outdir/response.json" - status="$(curl --silent --show-error --retry 3 -o "$response" -w '%{http_code}' -X POST "${RELEASE_API_URL%/}/api/release/offer" \ -H 'Content-Type: application/json' --data-binary "@$outdir/payload.json")" - if [[ "$status" != 2* ]]; then - if [[ "$component" == backend && "$status" == 400 ]] && grep -Fq 'manifest needs a known component' "$response"; then - echo "the panel does not support Backend update offers yet; bootstrap it once before future Backend offers" - else - echo "release offer registration failed (HTTP $status)" >&2 - cat "$response" >&2 - exit 1 - fi + status="$(curl --silent --show-error --retry 3 --max-time 30 -o "$response" -w '%{http_code}' -X POST "${RELEASE_API_URL%/}/api/release/offer" \ + -H 'Content-Type: application/json' --data-binary "@$outdir/payload.json" 2>/dev/null || true)" + if [[ "$status" == 2* ]]; then + echo "also registered the signed $component release with the panel ($channel)" else - echo "registered the signed $component release with the panel ($channel)" + echo "warning: the panel did not take the $component offer (HTTP ${status:-none}); updaters still get it from the registry" >&2 fi fi diff --git a/ci/release-manifest.sh b/ci/release-manifest.sh index 2594838..843b952 100644 --- a/ci/release-manifest.sh +++ b/ci/release-manifest.sh @@ -28,9 +28,16 @@ if [[ -n "${RELEASE_NEXT_KEY:-}" ]]; then [[ -n "$next_key" ]] || { echo "RELEASE_NEXT_KEY is not a public key" >&2; exit 2; } fi +# 官方二进制的哈希(逗号分隔的小写十六进制):节点握手时上报正在运行的二进制的哈希,Backend 用这份签名的清单认「官方」 +binaries="" +if [[ -n "${RELEASE_BINARIES:-}" ]]; then + [[ "$RELEASE_BINARIES" =~ ^[0-9a-f]{64}(,[0-9a-f]{64})*$ ]] || { echo "RELEASE_BINARIES must be comma-separated sha256 hashes" >&2; exit 2; } + binaries=",\"binaries\":\"$RELEASE_BINARIES\"" +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,"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" +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%s}' \ + "$component" "$channel" "$repository" "$digest" "$tag" "$version" "$commit" "$built_at" "$seq" "$install_sh" "$install_ps1" "${next_key:+,\"next_key\":\"$next_key\"}" "$binaries" > "$outdir/manifest.json" key="$(mktemp)" trap 'rm -f "$key" "$outdir/manifest.sig.bin"' EXIT diff --git a/docs/access.md b/docs/access.md index b70ae3a..d87f67b 100644 --- a/docs/access.md +++ b/docs/access.md @@ -26,12 +26,15 @@ 节点和 Backend 之间:集合里登记了服务器密钥之后,节点的认证挑战要求服务器用它签名;两边各出一把临时 ECDH 密钥, 认证签名覆盖双方的临时公钥,之后控制链路每一帧都是 AES-256-GCM(每个方向一把密钥、严格递增的计数器)。 所以 TLS 在 Cloudflare 或被冒充的证书那里终止,中间人没有服务器密钥,既冒充不了服务器,也读不了、改不了这条链路 - 密码登录的会话没有任何能力,只能注册 passkey;有能力的会话来自 passkey 登录;信任类操作要提权窗口(passkey 触碰) + 密码登录和 passkey 登录的会话拿同样的能力,授权只看能力;passkey 只用在节点自己会验证的事上(现在是操作员集合的签名,以后是 extension 会话和加密数据)。 + Backend 自己拦自己没有意义,所以没有提权窗口 ``` **发布密钥可以换**:见 `docs/deployment.md` 的「换发布密钥」;回滚已经由清单的 `seq` 防住(更新器只收比已安装更大的)。 -**还没有的**:吊销表;任务和配置内容的操作员签名;节点自证跑的是官方构建;extension 本身和属主的 `controllers`、接收者的实际加密(只是被固定在节点本地,没有行为)。 +**节点二进制绑定**(第 16 节):节点第一次握手成功的那一刻,Backend 把它正在运行的二进制的 SHA-256 绑定到这个节点;之后哈希对不上、又不在 CI 签名的更新清单里,就拒绝连接。 + +**还没有的**:吊销表;任务和配置内容的操作员签名;节点向别人**证明**自己没有撒谎(二进制绑定抓的是文件被换、降级、起了不对的二进制,抓不到故意照抄哈希的恶意二进制,见第 16 节);extension 本身和属主的 `controllers`、接收者的实际加密(只是被固定在节点本地,没有行为)。 ## 1. 名词 @@ -41,7 +44,6 @@ | 角色 | `anonymous`、`admin`、`owner`,决定能力的**上限** | | 能力(capability) | 一项具体权限,如 `fleet.write`。端点声明自己需要哪项能力 | | 会话 | 登录得到的状态,只带 L1 能力 | -| 提权窗口 | 用 passkey 触控换来的、有时限的信任类能力(类似 sudo) | | 操作员集合(OpSet) | 节点固定的、带版本的 passkey 公钥列表,每个带角色和限制 | | 委托(Grant) | passkey 签名授权一把临时密钥,在范围、时间内代表它签名 | | API key | 一串种子;`enroll` 类的 key 创建时由 passkey 签出一份委托 | @@ -79,10 +81,10 @@ | 角色 | 能力 | | --- | --- | | `anonymous` | 只有 `public.read`,且站点公开时才有。不是账户 | -| `admin` | 除 `accounts.manage` 外的全部;信任类(`fleet.enroll`、`fleet.revoke`)要提权 | +| `admin` | 除 `accounts.manage` 外的全部 | | `owner` | 全部;`accounts.manage` 每次都要当场触控 | -- 角色是上限:能力是否可用取决于角色和当前窗口。 +- 角色是上限:能力是否可用取决于角色。 - **不能越权**:创建 API key 时勾选的能力必须是创建者当前拥有能力的子集;带信任类能力的 key 创建时要 passkey 签一次。 - 角色写在操作员集合里,**节点自己执行**:admin 凭证签的入网券有效;只有 owner 凭证签的操作员集合更新才有效。被攻破的 Backend 给自己造一个 admin 账户也加不进节点。 @@ -90,12 +92,12 @@ | 登录方式 | 能得到 | 说明 | | --- | --- | --- | -| passkey | L1 会话,并且可以提权 | WebAuthn,只支持 ES256,attestation 选 `none` | +| passkey | L1 会话;还能给操作员集合签名(节点验证) | WebAuthn,只支持 ES256,attestation 选 `none` | | 密码 | L1 会话 | 保留用于首次引导和兜底;PBKDF2 | | OIDC | L1 会话 | 账户绑定 `iss + sub`,登录时按它找账户,不自动建账户。放在 passkey 之后做 | -- 密码和 OIDC **永远不能提权**:节点验证不了 IdP 的断言,也不该让 IdP 或密码进入信任域。 -- **引导**:初始管理员用密码登录,注册第一个 passkey;这是唯一一次不需要提权的注册。之后新增 passkey 都要提权,并写进操作员集合(见第 7 节)。 +- 密码和 OIDC 会话可以管理(有能力),但**进不了信任域**:节点验证不了 IdP 的断言,所以它们改不了操作员集合。 +- **引导**:初始管理员用密码登录,注册第一个 passkey,再在「系统 → 信任」用它给操作员集合签名建立信任根。之后新增的 passkey 不会自动成为操作员:要由现有操作员在信任页看清「新增了哪些」再签新版本,节点才认(见第 7 节)。 - 建议每个账户至少注册两个 passkey(手机加硬件密钥)。 - **离线恢复密钥**:创建 owner 时同时生成一把离线的 ECDSA 恢复密钥,作为 `type=ecdsa`、`recovery=true` 的 owner 凭证写进操作员集合;私钥打印或放进密码管理器,平时不用。节点强制它**只能签操作员集合更新**,不能签委托、入网券和吊销表,所以即使被偷走,也加不了节点;它的用处是 passkey 全部丢失时签一版新的操作员集合、重新加 passkey,节点不用重装。 @@ -105,22 +107,22 @@ - 校验:挑战在内存里,一次有效、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。 +- 会话记录登录方式(`password` 或 `passkey`,`sessions.auth_method`),只用于审计和显示;API key 不是用户,不能注册 passkey。 - 测试:`SoftwarePasskey` 在测试里模拟认证器走完整的注册和登录,覆盖错误的挑战、来源、RP、签名、缺少 UV、挑战重放、计数器回退、只能管理自己的 passkey;管理端用 Chromium 的 WebAuthn 虚拟认证器验证过注册和登录。 -## 4. 提权窗口(信任类能力各管各的) +## 4. passkey 用在哪里(没有提权窗口) -| 窗口 | 覆盖 | 默认时长 | 行为 | -| --- | --- | --- | --- | -| 接入 | `fleet.enroll` | 15 分钟(5–60 可调) | 窗口内连续生成代码不再弹 passkey | -| 吊销 | `fleet.revoke` | 5 分钟 | 破坏性操作,窗口更短 | -| 凭证 | `accounts.manage`、创建带信任类能力的 key | 无窗口 | 每次都要当场触控 | +原则:**passkey 不是 Backend 用来拦操作的一道门**(Backend 被攻破时,它自己做的拦截也会被一起绕过),而是节点或密钥持有者需要它才能验证、解密的东西。 -- 窗口互不覆盖:开了接入窗口不能吊销节点。 -- 「用到才提权」:页面照常打开,点信任类操作时没有窗口才弹 passkey,通过后接着做原来的动作。 -- 管理端顶部显示生效的窗口和剩余时间,每个窗口有「立即结束」。 -- 接入窗口的实现:一次触控让 passkey 签一份委托(见第 7 节),授权浏览器里生成的不可导出临时密钥 D;窗口内用 D 签入网券,到期自动失效。 -- **默认值待确认**(15 分钟、5 分钟)。 +| 事 | 谁验证 | 要 passkey | +| --- | --- | --- | +| 建立、更新操作员集合(信任根) | **节点**验链 | 是(现在唯一做了的) | +| extension 会话(web ssh 等) | **节点**验 passkey 签名的临时密钥 | 是(还没做) | +| 读加密存储的数据 | 私钥持有者 | 是(还没做) | +| 吊销或删除节点、账号、API key、加入码、设置 | 只有 Backend | 否,只看能力 | + +- 这些「只有 Backend」的事靠能力限权:API key 只带需要的能力,登录会话按角色。密码泄露的后果是这些能力被滥用,改不了信任根,也用不了以后的 extension。 +- 唯一的风险是往账号里塞一把 passkey 再等人签名:信任页的提案会列出**新增和移除的凭证**,签名之前要看一眼是不是自己加的;签了才算授权。 ## 5. API key @@ -136,7 +138,7 @@ ### 5.2 `enroll` key:自动化添加节点 -**当前实现:它就是一把带 `fleet.enroll` 的普通 API key**,用 `POST /api/admin/join-tokens` 创建加入码,响应里直接给出安装命令(带创世指纹、属主和脚本哈希,没有任何私钥)。因为创建码不是信任动作,所以不需要 passkey,也不需要提权窗口;CI 和脚本照常工作。信任在两处:命令里的创世指纹和属主由面板生成,节点注册时固定;以后改属主和集合要操作员 passkey 签名。 +**当前实现:它就是一把带 `fleet.enroll` 的普通 API key**,用 `POST /api/admin/join-tokens` 创建加入码,响应里直接给出安装命令(带创世指纹、属主和脚本哈希,没有任何私钥)。因为创建码不是信任动作,所以不需要 passkey;CI 和脚本照常工作。信任在两处:命令里的创世指纹和属主由面板生成,节点注册时固定;以后改属主和集合要操作员 passkey 签名。 早先设计过的「委托密钥」(passkey 签一次委托,API key 自己签入网券,节点验证整条链)属于第 7.1~7.3 节的完整设计,当前没有采用,见第 0 节的取舍。 @@ -159,7 +161,7 @@ - **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 的形状**:订阅 `{id, url, events[], secret, 创建者(key 或账户), enabled}`。事件:节点上线或下线、线路告警阈值、安全事件(节点准入、吊销、key 创建)等。投递是 `POST` JSON,头带 `X-MoShiro-Signature: sha256=`、时间戳和投递 id;至少一次投递、指数退避重试、接收方按投递 id 幂等。 - **订阅受创建者能力约束**:只能订阅创建者自己读得到的事件,不能借 webhook 越权。 - **SSRF 约束**(TODO 里已有):URL 解析后检查地址,只放行公网地址;不跟随重定向到内网;连接和响应有超时和大小上限;`secret` 只在创建时显示一次。 - 这些都属于 L1:改的是 Backend 的集成配置,不改变节点信任;但每次增删订阅和改 URL 都进审计日志。 @@ -167,7 +169,7 @@ ## 6. 归属与审计 - 账户表:`id`、名字、角色、登录方式、passkey;加入代码记 `created_by`,节点记 `added_by`;管理页的 Agent、Probe 详情显示「由 xxx 于 xxx 添加」。 -- 审计日志 `(时间, 账户, 凭证 id, 动作, 对象, 详情)`:创建或撤销 key、加入代码、吊销、删除、手填地址、确认更新、提权、因能力不足被拒绝的请求。保留 365 天(同事件表)。 +- 审计日志 `(时间, 账户, 凭证 id, 动作, 对象, 详情)`:创建或撤销 key、加入代码、吊销、删除、手填地址、确认更新、因能力不足被拒绝的请求。保留 365 天(同事件表)。 - **可验证的一层**:委托里写进签名它的 passkey 凭证 id,入网券和节点绑定带着它,节点在 hello 里报告「由哪个凭证准入」。「谁加的」不只是 Backend 的一面之词。 ## 7. 入网信任链(完整设计,当前没有采用;当前实现见第 0 节) @@ -248,7 +250,7 @@ - **服务器身份和安全链路**:`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 登录的会话能力相同**;创建加入码需要 `fleet.enroll`,没有提权窗口。passkey 只用在节点自己会验证的事上。 - **现状的缺口**:没有吊销表;删除一把操作员 passkey 只是让它不能再签新版本;任务和配置没有操作员签名;节点没有自证构建;服务器密钥换新要靠签一个新集合版本,忘了签会让固定了集合的节点认不出面板(fail closed)。 ## 8. 节点属主与 extension 授权(预留) @@ -374,19 +376,19 @@ web ssh 这类会话要不要加密、要不要验证,**由 Agent(和 Probe | 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` | +| 账户、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 天)、归属(加入代码的创建者、节点的添加者)。 +2. **账户、passkey、会话分级、审计**:WebAuthn 注册与登录、审计日志、归属列和详情页显示。**已实现**:passkey 登录(第 3.1 节)、审计日志(`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 属主的实际强制还没有做。 +实现状态:能力模型、passkey 登录、审计、操作员集合与创世固定、服务器身份和安全链路、静态安装脚本和哈希核对、属主固定都已实现(第 0 节);成员证明(第 7.1~7.3 节)没有采用;吊销表、任务和配置的操作员签名、节点自证构建、extension 属主的实际强制还没有做。 ## 13. 局限 @@ -455,21 +457,20 @@ curl -fsSL https://panel/install -o /tmp/moshiro-install.sh && echo " / **Backend 侧** -- WebAuthn 验证只发生在登录、提权、创建 key;API 请求认证是一次哈希查表,主体缓存 30 秒并在撤销时主动失效。 +- WebAuthn 验证只发生在登录;API 请求认证是一次哈希查表,主体缓存 30 秒并在撤销时主动失效。 - `enroll` key 签一张券是一次 HKDF 加一次 ECDSA 签名(不到 1 毫秒)。 - 审计日志的写入并进 SQLite 单写入者的批量提交,不拖慢摄取路径。 - webhook 投递、数据拉取都在 Backend 里完成(见 5.4),对节点零开销。 **日常摩擦** -- 进页面不要求 passkey,信任类操作发生时才提权;窗口内连续操作不再弹。 +- 进页面不要求 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 满足)。 @@ -481,3 +482,26 @@ curl -fsSL https://panel/install -o /tmp/moshiro-install.sh && echo " / 10. 安装脚本的哈希在哪里公布(Gitea 发布页之外还要不要放 DNS TXT 或另一个独立渠道)。 11. 节点预算的数字(常驻内存 ≤ 1 MB、二进制 ≤ 150 KB、每个成员验证 ≤ 100 毫秒、成员上限 256)是否合适;超出预算时优先砍什么(比如先去掉操作员集合的 64 个凭证上限换成更小的)。 12. webhook 和数据拉取的配额默认值(每分钟请求数、每天行数)和事件清单。 + +## 16. 节点二进制绑定 + +目的:被换掉的、版本被改的、降级的二进制,不能继续当这个节点运行。 + +```text + CI 构建:算出镜像里节点二进制的 SHA-256,写进签名的清单(binaries) + 宿主机更新器:把验过的清单和签名写进节点只读挂载的 update-status/(installed.manifest、installed.sig) + 节点握手(auth 里):binary = 正在运行的二进制的哈希(Linux 读 /proc/self/exe,是进程启动时映射的那个映像) + release、release_sig = 上面那份清单和签名 + Backend: + ① 第一次握手成功 → 绑定 (节点, 哈希, 清单 seq) + ② 以后每次握手:哈希和绑定一致 → 放行 + ③ 不一致 → 必须是一份 CI 签名清单(用内嵌的发布公钥验)里列出的官方二进制,并且清单 seq 不低于绑定时的(合法升级,改绑); + 否则拒绝连接,节点页标红「不符」。不许降级。 +``` + +- **清单是节点带来的**,Backend 自己验签,所以不依赖面板有没有收到过这次发布;被攻破的 Backend 也加不进「官方」哈希(没有 CI 私钥)。 +- **旧节点**(不上报哈希):没绑定过就放行(显示未绑定);绑定过的不许退回不上报。 +- **没有更新器的节点**(二进制安装)没有清单,首次握手按上报的哈希绑定(显示「首次绑定」);手工升级之后哈希变了,会被拒绝,管理员在「系统 → 版本」点「接受当前二进制」清除绑定,下一次连接重新绑定。 +- **策略** `MoShiro__BinaryPolicy`:`enforce`(默认)拒绝;`warn` 只记录不拒绝;`off` 不检查。 +- **边界**:哈希是节点自己报的。一个被换进来、故意撒谎的恶意二进制可以照抄原来的哈希,Backend 分辨不出——上报者就是被怀疑的对象。要堵这一点,必须让容器之外的东西背书:宿主机更新器用安装时绑定的密钥对「当前运行的镜像 digest + 挑战」签名,由 agent 转发;容器里的进程被换掉也伪造不了。这一步还没做。 + diff --git a/docs/api.md b/docs/api.md index 49c167e..5b64664 100644 --- a/docs/api.md +++ b/docs/api.md @@ -150,6 +150,7 @@ | POST | `/api/release/offer` | CI 登记发布清单的入口,**不需要令牌**:`{manifest, signature}` 的签名必须能用 Backend 内嵌的发布公钥验过(ECDSA P-256 / SHA-256,DER),否则 403;`seq` 必须比已登记的新,否则 409。登记不依赖面板里的任何凭证,清库、重装、还没有 passkey 时照常工作 | | GET | `/api/admin/links` | 每一对(Probe,Agent)的线路开关:`linked`(最终结果)、`override`(手动设置,`null` 跟随自动连接)、`connected`,以及 `auto_link` | | PUT | `/api/admin/links/{probe_id}/{agent_id}` | `{enabled: true|false|null}`:手动强制连或不连,`null` 取消手动设置;改完立即重新下发 | +| DELETE | `/api/admin/nodes/{id}/binary` | 接受节点当前的二进制:清除它的二进制绑定,下一次握手重新绑定(需要 `fleet.write`;没有绑定也返回 204)。`GET /api/admin/build-info` 的每个节点带 `binary`(`sha256`、`signed`、`seq`、`bound_at`、`violation`) | | POST | `/api/admin/nodes/{id}/update` | 请求一个在线的 Agent 或 Probe 更新到它收到的清单(节点不在线返回 409) | | GET/POST/PATCH | `/api/admin/users` | 首次启动时通过命令行或环境变量创建初始管理员 | diff --git a/docs/deployment.md b/docs/deployment.md index 1daba5e..94d996c 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -318,6 +318,21 @@ sh packaging/update/install-updater.sh agent --user \ 一行安装:页面给的命令先下载静态安装脚本(`/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 节。 +## 签名清单放在镜像仓库里(更新和首次部署都不依赖面板) + +CI 给每个镜像的 digest 签名之后,除了(面板在线时)登记给面板,**一定会把签名清单推到镜像仓库**:`<镜像仓库>-manifest:<通道>`,例如 `registry.oeo.one/moshiro-dev/moshiro-backend-manifest:dev`、`registry.oeo.one/moshiro/moshiro-backend-manifest:release`。它是一个没有任何文件层的小镜像,清单和签名放在标签 `org.moshiro.manifest`(base64)和 `org.moshiro.signature` 里。`dev` 通道每次 `[CI]` 构建都会更新,`release` 通道只在打 tag 时更新,所以不发 Release 的构建照样能被更新到。 + +```text + 信任锚点:仓库里的发布公钥 packaging/update/release-key.pub.pem(随安装脚本和 deploy-node.sh 分发) + 取清单的人:宿主机更新器(定时)、deploy-node.sh(首次部署):pull 清单镜像 → 读标签 → 本机公钥验签 → 按清单里的 digest 拉真正的镜像 + 仓库、网络、面板都不需要可信:被改只会让验签失败;面板不在线、清库、没有 CI 都不影响 +``` + +- **更新器**:配置里 `FEED=auto`(默认)时先看仓库里的清单,验签通过才用;取不到或验不过就用面板放进信箱的那份。`MANIFEST_REF` 可以指定清单镜像,`FEED=off` 关掉;通道取配置里的 `CHANNEL`,没有就按仓库路径推断(`moshiro-dev` → dev,`moshiro` → release)。仓库里的清单验签失败而信箱里又没有东西时,状态会报「无效清单」而不是悄悄当作没有更新。 +- **首次部署**:`deploy-node.sh` 默认就走这条路,不需要面板,也不用手动指定镜像(Backend 第一次部署时本来就没有面板)。`--tag` 指定标签时不验签并有警告;`--image` 直接用你给的引用。 +- **别人自己部署后端,什么都不用改**:仓库对匿名读开放(`moshiro/*` 和各 `*-manifest` 仓库),然后 `sudo sh deploy-node.sh backend --channel release --public-url https://<自己的域名>`,只是配置,不改代码、不需要自己的 CI;passkey 绑定在自己的域名上。自己 fork 并自建镜像的人要换自己的发布公钥和仓库地址(`--image-repo`)并重新构建,这是 fork 的正常代价。 +- 测试:`bash packaging/update/tests/feed-test.sh`(假的 podman + 真的 openssl,覆盖篡改、别的密钥、旧 seq、换钥、仓库不可用回退、首次部署的验签)。 + ## Backend 签名更新 Backend 也会收到 CI 发布的签名清单,但只提示管理端,不会自动重启。管理员确认精确的清单序号后,宿主更新器才会备份 Backend 数据 volume、按清单 digest 拉取镜像、重启服务并检查 `/healthz`;失败时恢复旧镜像和数据库备份。确认和发布通过同一个锁串行处理,避免确认过期版本。 diff --git a/docs/security.md b/docs/security.md index 19a3a42..2b740b5 100644 --- a/docs/security.md +++ b/docs/security.md @@ -73,18 +73,18 @@ Agent、Backend、Probe 之间互相不信任。这份文档只回答三个问 ## 5. 入网信任链 -下文和其他章节里的「名册」指节点本地验过链的成员表。**详细设计见 [access.md](access.md)**(账户、角色、能力、API key、提权窗口、签名链、流程和实施顺序)。这里只留结论: +下文和其他章节里的「名册」指节点本地验过链的成员表。**详细设计见 [access.md](access.md)**(账户、角色、能力、API key、签名链、流程和实施顺序)。这里只留结论: - 名册不再是「每次变更都要操作员签一整张表」,而是一条可离线验证的链:节点固定创世 owner 公钥 → 操作员集合(带版本,owner 签)→ 委托(passkey 签,限定范围和时间)→ 入网券(委托的密钥签)→ 节点绑定(节点注册时用安装命令里的 `K_code` 签)。加上 passkey 当场签的吊销表。 - Agent 只接纳链验得过的 Probe,Probe 只拨链验得过的 Agent,并且只拨公网地址(缺口 G3、G6 因此消失,不需要另外的拨号白名单)。 - **不可回放**:节点持久化见过的最大操作员集合版本和吊销表版本,Backend 无法拿旧文档复活已吊销的节点。 -- 人在场时一次 passkey 触控换一个有时限的提权窗口,窗口内加节点不再确认;自动化用创建时经 passkey 签名的 `enroll` API key。被攻破的 Backend 在窗口和 key 过期后,一个节点都加不进来。 +- 加节点不需要 passkey:创建加入码是普通操作,命令里只有公开的值(创世指纹、属主、脚本哈希);自动化用带 `fleet.enroll` 的 API key。信任来自节点注册时固定的创世指纹和属主。 ## 6. 操作员凭证 操作员凭证就是 passkey(WebAuthn,ES256,attestation 选 `none`)。节点本地固定创世 owner 公钥,其余凭证靠 owner 签名的操作员集合同步,不用重装节点;每条凭证带角色(`owner`、`admin`)和限制(例如单张入网券的最长有效期),由节点自己执行。 -- 登录可以用密码、passkey 或 OIDC,但**只有 passkey 能提权**:节点验证不了 IdP 的断言,也不该让密码和 IdP 进入信任域。OIDC 只是账户的另一种登录方式(绑定 `iss + sub`)。 +- 登录可以用密码、passkey 或 OIDC,会话能力相同;但**只有 passkey 能给操作员集合签名**:节点验证不了 IdP 的断言,也不该让密码和 IdP 进入信任域。OIDC 只是账户的另一种登录方式(绑定 `iss + sub`)。 - 自动化凭证:`enroll` API key 创建时由 passkey 签一份委托;CI 和测试用软件 ECDSA 凭证,写进测试的操作员集合。 - 恢复:passkey 全部丢失时,重装节点并换创世公钥;所以每个账户至少注册两个 passkey。 diff --git a/packaging/container/deploy-node.sh b/packaging/container/deploy-node.sh index c31817b..3b59676 100644 --- a/packaging/container/deploy-node.sh +++ b/packaging/container/deploy-node.sh @@ -14,7 +14,7 @@ set -eu usage() { - echo "usage: deploy-node.sh [--engine podman|docker] [--image-repo R] [--tag T] [--image REF]" >&2 + echo "usage: deploy-node.sh [--engine podman|docker] [--image-repo R] [--channel dev|release] [--tag T] [--image REF]" >&2 echo " [--server URL --join TOKEN --name NAME] [--policy auto|manual] [--authfile F]" >&2 echo " backend: [--channel dev|release] [--port N] [--admin-user U] [--admin-password P] [--public-url URL] [--env KEY=VALUE]..." >&2 exit 2 @@ -22,7 +22,7 @@ usage() { ROLE="${1:-}"; shift || true case "$ROLE" in agent|probe|backend) ;; *) usage ;; esac -ENGINE=""; REPO=""; TAG=latest; IMAGE=""; SERVER=""; JOIN=""; NAME=""; POLICY=""; AUTHFILE=""; CHANNEL=""; PORT=""; ADMIN_USER=""; ADMIN_PASSWORD=""; PUBLIC_URL=""; EXTRA_ENV="" +ENGINE=""; REPO=""; TAG=""; IMAGE=""; SERVER=""; JOIN=""; NAME=""; POLICY=""; AUTHFILE=""; CHANNEL=""; PORT=""; ADMIN_USER=""; ADMIN_PASSWORD=""; PUBLIC_URL=""; EXTRA_ENV="" while [ $# -gt 0 ]; do case "$1" in --engine) ENGINE="$2"; shift 2 ;; @@ -82,13 +82,50 @@ for f in "$PACKAGING/systemd/$UNIT_SRC" "$PACKAGING/update/install-updater.sh" " [ -r "$f" ] || { echo "missing $f (keep deploy-node.sh next to the packaging/ directory)" >&2; exit 1; } done -# 1. 镜像:固定到 digest,后面的单元、辅助容器都用同一个引用 +# 1. 镜像:固定到 digest,后面的单元、辅助容器都用同一个引用。 +# 默认从仓库里的签名清单取(<仓库>-manifest:<通道>,CI 发布的),用随脚本带的发布公钥验签,按清单里的 digest 固定—— +# 仓库和网络都不需要可信,也不需要面板在线(Backend 第一次部署时还没有面板)。 +# --tag 指定标签就不验签(拉那个标签再固定 digest);--image 直接用你给的引用。 +if [ "$ENGINE" = docker ] && [ -n "$AUTHFILE" ]; then export DOCKER_CONFIG="$(dirname "$AUTHFILE")"; fi +pull_quiet() { + if [ "$ENGINE" = podman ] && [ -n "$AUTHFILE" ]; then "$ENGINE" pull -q --authfile "$AUTHFILE" "$1" >/dev/null + else "$ENGINE" pull -q "$1" >/dev/null; fi +} +resolve_verified() { + channel="$CHANNEL" + if [ -z "$channel" ]; then + case "$REPO" in */moshiro-dev/*) channel=dev ;; */moshiro/*) channel=release ;; esac + fi + [ -n "$channel" ] || { echo "cannot tell the release channel from $REPO: pass --channel dev|release, or --tag to skip verification" >&2; return 1; } + command -v openssl >/dev/null 2>&1 || { echo "openssl is required to verify the release signature (or pass --tag to skip verification)" >&2; return 1; } + ref="$REPO-manifest:$channel" + pull_quiet "$ref" || { echo "cannot pull the signed manifest $ref: is the repository readable (--authfile)? Or pass --tag to skip verification" >&2; return 1; } + labels="$("$ENGINE" image inspect --format '{{index .Config.Labels "org.moshiro.manifest"}}|{{index .Config.Labels "org.moshiro.signature"}}' "$ref")" || return 1 + vtmp="$(mktemp -d)" + printf '%s' "${labels%%|*}" | base64 -d > "$vtmp/manifest" 2>/dev/null && printf '%s' "${labels#*|}" | base64 -d > "$vtmp/sig" 2>/dev/null \ + || { rm -rf "$vtmp"; echo "the manifest in $ref is not readable" >&2; return 1; } + if ! openssl dgst -sha256 -verify "$PACKAGING/update/release-key.pub.pem" -signature "$vtmp/sig" "$vtmp/manifest" >/dev/null 2>&1; then + rm -rf "$vtmp"; echo "the signature of the manifest in $ref does not match the bundled release key; refusing to install" >&2; return 1 + fi + mfield() { sed -n 's/.*"'"$1"'":"\([^"]*\)".*/\1/p' "$vtmp/manifest" | head -n 1; } + vdigest="$(mfield digest)"; vcomponent="$(mfield component)"; vimage="$(mfield image)"; vtag="$(mfield tag)" + rm -rf "$vtmp" + [ "$vcomponent" = "$ROLE" ] && [ "$vimage" = "$REPO" ] || { echo "the manifest is for $vcomponent / $vimage, not $ROLE / $REPO; refusing to install" >&2; return 1; } + case "$vdigest" in sha256:*) ;; *) echo "the manifest has no usable digest" >&2; return 1 ;; esac + [ "${#vdigest}" -eq 71 ] && [ -z "$(printf '%s' "${vdigest#sha256:}" | tr -d '0-9a-f')" ] || { echo "the manifest digest is malformed" >&2; return 1; } + IMAGE="$REPO@$vdigest" + pull_quiet "$IMAGE" || { echo "cannot pull $IMAGE" >&2; return 1; } + echo "verified release $vtag (CI-signed manifest, $ref)" +} if [ -z "$IMAGE" ]; then - if [ "$ENGINE" = docker ] && [ -n "$AUTHFILE" ]; then export DOCKER_CONFIG="$(dirname "$AUTHFILE")"; fi - if [ "$ENGINE" = podman ] && [ -n "$AUTHFILE" ]; then "$ENGINE" pull -q --authfile "$AUTHFILE" "$REPO:$TAG" >/dev/null - else "$ENGINE" pull -q "$REPO:$TAG" >/dev/null; fi - if [ "$ENGINE" = podman ]; then DIGEST="$("$ENGINE" image inspect --format '{{.Digest}}' "$REPO:$TAG")"; IMAGE="$REPO@$DIGEST" - else IMAGE="$("$ENGINE" image inspect --format '{{index .RepoDigests 0}}' "$REPO:$TAG")"; fi + if [ -z "$TAG" ]; then + resolve_verified || exit 1 + else + pull_quiet "$REPO:$TAG" + if [ "$ENGINE" = podman ]; then DIGEST="$("$ENGINE" image inspect --format '{{.Digest}}' "$REPO:$TAG")"; IMAGE="$REPO@$DIGEST" + else IMAGE="$("$ENGINE" image inspect --format '{{index .RepoDigests 0}}' "$REPO:$TAG")"; fi + echo "warning: $REPO:$TAG was not signature-checked (--tag)" >&2 + fi fi echo "image: $IMAGE" diff --git a/packaging/update/moshiro-update.sh b/packaging/update/moshiro-update.sh index 218daab..3b68697 100644 --- a/packaging/update/moshiro-update.sh +++ b/packaging/update/moshiro-update.sh @@ -88,8 +88,49 @@ trap 'rm -rf "$WORK"' EXIT OFFER_MANIFEST="$WORK/offer.manifest" OFFER_SIG="$WORK/offer.sig" -# 把业务进程放的清单和签名复制到临时目录;不是普通文件(缺失、符号链接)就当作没有 +# 直接从镜像仓库取清单(不依赖面板,也不依赖 Release):CI 把签名清单放在 -manifest:<通道> 这个没有文件层的小镜像的标签里。 +# 通道取配置里的 CHANNEL;没有的话按仓库路径推断(moshiro-dev → dev,moshiro → release)。MANIFEST_REF 可以直接指定;FEED=off 关掉。 +# 取到的只是「候选」:之后照常用本机公钥验签,仓库被改了也只会验签失败。 +feed_ref() { + if [ -n "${MANIFEST_REF:-}" ]; then printf '%s' "$MANIFEST_REF"; return 0; fi + channel="$CHANNEL" + if [ -z "$channel" ]; then + case "$IMAGE_REPO" in */moshiro-dev/*) channel=dev ;; */moshiro/*) channel=release ;; esac + fi + [ -n "$channel" ] || return 1 + printf '%s-manifest:%s' "$IMAGE_REPO" "$channel" +} + +fetch_feed() { + [ "${FEED:-auto}" != off ] || return 1 + ref="$(feed_ref)" || return 1 + pull_image "$ref" >/dev/null 2>&1 || return 1 + labels="$("$ENGINE" image inspect --format '{{index .Config.Labels "org.moshiro.manifest"}}|{{index .Config.Labels "org.moshiro.signature"}}' "$ref" 2>/dev/null)" || return 1 + manifest_b64="${labels%%|*}"; signature="${labels#*|}" + [ -n "$manifest_b64" ] && [ -n "$signature" ] || return 1 + printf '%s' "$manifest_b64" | base64 -d > "$OFFER_MANIFEST" 2>/dev/null || return 1 + printf '%s' "$signature" > "$OFFER_SIG" +} + +# 仓库里的清单优先(验签通过才用);取不到或验不过就用面板放进信箱的那份 fetch_offer() { + rm -f "$OFFER_MANIFEST" "$OFFER_SIG" + feed_error="" + if fetch_feed; then + if verify_offer; then return 0; fi + feed_error="$ERR" + cp "$OFFER_MANIFEST" "$WORK/feed.manifest"; cp "$OFFER_SIG" "$WORK/feed.sig" + fi + fetch_mailbox + # 信箱里没有东西,而仓库里的清单没通过(验证失败,或者就是已经装的这一份):留下仓库的那份,让后面照常报出原因, + # 而不是悄悄当作没有更新;「已经装的这一份」也要留下,因为要把它交给节点握手时带给 Backend + if [ -n "$feed_error" ] && [ ! -s "$OFFER_MANIFEST" ]; then + cp "$WORK/feed.manifest" "$OFFER_MANIFEST"; cp "$WORK/feed.sig" "$OFFER_SIG" + fi +} + +# 把业务进程放的清单和签名复制到临时目录;不是普通文件(缺失、符号链接)就当作没有 +fetch_mailbox() { rm -f "$OFFER_MANIFEST" "$OFFER_SIG" for name in offer.manifest offer.sig; do if [ -n "$DATA_VOLUME" ]; then @@ -124,6 +165,36 @@ write_status() { # state available_json error fi } +# 节点握手时要带给 Backend 的「我是从哪份 CI 签名清单安装的」:Backend 用 CI 公钥验过,才相信清单里列的官方二进制哈希。 +# 写在节点只读挂载的状态目录里(节点自己改不了),清单在前、签名在后;内容没变就不写(保护路由器的闪存)。 +write_installed() { + if cmp -s "$OFFER_MANIFEST" "$STATE_DIR/$COMPONENT.installed.manifest" 2>/dev/null && cmp -s "$OFFER_SIG" "$STATE_DIR/$COMPONENT.installed.sig" 2>/dev/null; then + return 0 + fi + for pair in "installed.manifest:$OFFER_MANIFEST" "installed.sig:$OFFER_SIG"; do + name="${pair%%:*}"; src="${pair#*:}" + if [ -n "$DATA_VOLUME" ]; then + "$ENGINE" run --pull=never --rm -i --user 0 --network none --read-only --cap-drop ALL --cap-add DAC_OVERRIDE --security-opt no-new-privileges \ + -v "$STATUS_VOLUME:/status" --entrypoint /bin/sh "$HELPER_IMAGE" \ + -c 'tmp=$(mktemp /status/.installed.XXXXXX); cat > "$tmp"; chmod 644 "$tmp"; mv -f "$tmp" "/status/$1"' sh "$name" < "$src" || return 0 + else + destination="$(mktemp "$STATUS_DIR/.installed.XXXXXX")" + cp "$src" "$destination" + chmod 644 "$destination" + mv -f "$destination" "$STATUS_DIR/$name" + fi + done + cp "$OFFER_MANIFEST" "$STATE_DIR/$COMPONENT.installed.manifest" + cp "$OFFER_SIG" "$STATE_DIR/$COMPONENT.installed.sig" +} + +# 第一次部署(deploy-node.sh 已经验过签并按 digest 固定)时还没有安装记录:清单的 digest 就是正在跑的这个镜像,就记下来,不当作「有更新」 +seed_installed() { + [ ! -r "$INSTALLED" ] || return 0 + running="$(sed -n 's/^IMAGE=.*@\(sha256:[0-9a-f]*\)$/\1/p' "$ENV_FILE" 2>/dev/null | head -n 1)" + if [ -n "$running" ] && [ "$running" = "$M_DIGEST" ]; then printf '%s\n%s\n' "$M_SEQ" "$M_DIGEST" > "$INSTALLED"; fi +} + current_digest() { [ -r "$INSTALLED" ] && sed -n 2p "$INSTALLED" || echo ""; } installed_seq() { [ -r "$INSTALLED" ] && sed -n 1p "$INSTALLED" || echo 0; } @@ -210,13 +281,19 @@ do_check() { if ! verify_offer; then # 没有新清单,或者清单已经安装过:不算错误,只是没有更新 case "$ERR" in - "no offer"|"stale manifest"*) write_status up_to_date null "" ;; + "no offer") write_status up_to_date null "" ;; + "stale manifest"*) + # 就是已经装的这一份(同一个 seq、同一个 digest):交给节点握手时带给 Backend + if [ "$M_SEQ" = "$(installed_seq)" ] && [ "$M_DIGEST" = "$(current_digest)" ]; then write_installed; fi + write_status up_to_date null "" ;; *) write_status invalid null "$ERR"; echo "offer rejected: $ERR" >&2 ;; esac return 0 fi adopt_next_key + seed_installed if [ "$M_DIGEST" = "$(current_digest)" ]; then + write_installed write_status up_to_date null "" return 0 fi @@ -262,6 +339,7 @@ do_apply() { fi if [ "$healthy" = true ] && service_ctl is-active --quiet "$UNIT" && [ "$before" = "$after" ]; then printf '%s\n%s\n' "$M_SEQ" "$M_DIGEST" > "$INSTALLED" + write_installed write_status up_to_date null "" echo "updated to $M_TAG" return 0 diff --git a/packaging/update/tests/feed-test.sh b/packaging/update/tests/feed-test.sh new file mode 100644 index 0000000..808e41a --- /dev/null +++ b/packaging/update/tests/feed-test.sh @@ -0,0 +1,132 @@ +#!/usr/bin/env bash +# 更新器从镜像仓库取签名清单的测试:用一个假的 podman(只实现 pull 和 image inspect)和真的 openssl, +# 不需要容器引擎、网络或 root。运行:bash packaging/update/tests/feed-test.sh +# 覆盖:正常取到、仓库里的清单被篡改、用别的密钥签的、旧的 seq、换钥(next_key)、仓库不可用时回退到信箱、FEED=off。 +set -eu +HERE="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$HERE/../../.." && pwd)" +T="$(mktemp -d)" +trap 'rm -rf "$T"' EXIT +mkdir -p "$T/bin" "$T/feed" "$T/data/update" "$T/state" "$T/conf" +fail=0 + +# 假的 flock 和 podman:feed 目录下有 <引用>/labels 文件("manifest_b64|signature")就算拉得到 +printf '#!/bin/sh\nexit 0\n' > "$T/bin/flock" +cat > "$T/bin/podman" <<'STUB' +#!/bin/sh +dir="$FAKE_FEED" +case "$1" in + pull) for last; do :; done; key="$(printf '%s' "$last" | tr '/:@' '___')"; [ -f "$dir/$key" ] ;; + image) for last; do :; done; key="$(printf '%s' "$last" | tr '/:@' '___')"; cat "$dir/$key" ;; + *) exit 1 ;; +esac +STUB +chmod +x "$T/bin/flock" "$T/bin/podman" +export PATH="$T/bin:$PATH" FAKE_FEED="$T/feed" MOSHIRO_UPDATE_CONF_DIR="$T/conf" + +for k in a b c; do + openssl ecparam -name prime256v1 -genkey -noout -out "$T/$k.pem" 2>/dev/null + openssl ec -in "$T/$k.pem" -pubout -out "$T/$k.pub" 2>/dev/null +done +REPO=registry.example/moshiro-dev/moshiro-agent +REF_KEY="$(printf '%s' "$REPO-manifest:dev" | tr '/:@' '___')" +cat > "$T/conf/agent.conf" < [next 公钥]:写出仓库里的清单标签,和 CI 的 release-manifest.sh + publish-manifest.sh 产出同样的格式 +publish() { + local next="" + if [ -n "${3:-}" ]; then next=",\"next_key\":\"$(openssl pkey -pubin -in "$3" -outform DER | base64 -w0)\""; fi + printf '{"schema":1,"component":"agent","channel":"dev","image":"%s","digest":"%s","tag":"dev-%s","version":"0.2.0","commit":"x","built_at":"t","seq":%s%s}' "$REPO" "$DIGEST" "$2" "$2" "$next" > "$T/m.json" + openssl dgst -sha256 -sign "$1" -out "$T/s.bin" "$T/m.json" + printf '%s|%s' "$(base64 -w0 "$T/m.json")" "$(base64 -w0 "$T/s.bin")" > "$T/feed/$REF_KEY" +} +status() { sed -n 's/.*"state":"\([^"]*\)".*"error":"\([^"]*\)".*/\1|\2/p' "$T/data/update-status/status.json"; } +check() { sh "$ROOT/packaging/update/moshiro-update.sh" check agent >/dev/null 2>&1 || true; status; } +expect() { # 名称 期望的状态前缀 实际 + if [[ "$3" == "$2"* ]]; then echo "ok $1 ($3)"; else echo "FAIL $1: expected $2*, got '$3'"; fail=1; fi +} + +publish "$T/a.pem" 10 +expect "valid manifest from the registry" "available|" "$(check)" + +publish "$T/a.pem" 10; sed -i 's/^\(.\{40\}\)./\1X/' "$T/feed/$REF_KEY" +expect "tampered manifest is rejected, not ignored" "invalid|" "$(check)" + +publish "$T/c.pem" 11 +expect "manifest signed by another key" "invalid|signature check failed" "$(check)" + +printf '%s' "$DIGEST" > "$T/state/agent.installed.tmp"; printf '99999\n%s\n' "$DIGEST" > "$T/state/agent.installed" +publish "$T/a.pem" 12 +expect "stale seq is just up to date" "up_to_date|" "$(check)" +rm -f "$T/state/agent.installed" + +publish "$T/a.pem" 20 "$T/b.pub" +expect "manifest carrying next_key" "available|" "$(check)" +publish "$T/a.pem" 21 +expect "old key no longer accepted after rotation" "invalid|signature check failed" "$(check)" +publish "$T/b.pem" 22 +expect "new key accepted" "available|" "$(check)" + +rm -f "$T/feed/$REF_KEY" +expect "registry unavailable and empty mailbox" "up_to_date|" "$(check)" + +# 信箱回退:仓库不可用,面板放进信箱的清单照样能用(用新钥签) +printf '{"schema":1,"component":"agent","channel":"dev","image":"%s","digest":"%s","tag":"dev-30","version":"0.2.0","commit":"x","built_at":"t","seq":30}' "$REPO" "$DIGEST" > "$T/data/update/offer.manifest" +openssl dgst -sha256 -sign "$T/b.pem" -out "$T/s.bin" "$T/data/update/offer.manifest" +base64 -w0 "$T/s.bin" > "$T/data/update/offer.sig" +expect "mailbox still works without the registry" "available|" "$(check)" + +# FEED=off:仓库里有更新的清单也不看 +publish "$T/b.pem" 40 +echo "FEED=off" >> "$T/conf/agent.conf" +out="$(check)" +if grep -q '"seq":40' "$T/data/update-status/status.json"; then echo "FAIL FEED=off still read the registry"; fail=1; else echo "ok FEED=off ignores the registry ($out)"; fi + +# --- deploy-node.sh 的首次安装:从仓库取清单、用随脚本带的发布公钥验签,再按清单里的 digest 固定镜像 +mkdir -p "$T/pkg/update" +cp "$T/b.pub" "$T/pkg/update/release-key.pub.pem" +sed -n '/^pull_quiet() {/,/^if \[ -z "\$IMAGE" \]; then/p' "$ROOT/packaging/container/deploy-node.sh" | sed '$d' > "$T/resolve.sh" +resolve() { # 签名私钥 组件 镜像仓库 + rm -f "$T/feed"/* + printf '{"schema":1,"component":"%s","channel":"dev","image":"%s","digest":"%s","tag":"dev-1","version":"0.2.0","commit":"x","built_at":"t","seq":1}' "$2" "$3" "$DIGEST" > "$T/m.json" + openssl dgst -sha256 -sign "$1" -out "$T/s.bin" "$T/m.json" + printf '%s|%s' "$(base64 -w0 "$T/m.json")" "$(base64 -w0 "$T/s.bin")" > "$T/feed/$(printf '%s' "$REPO-manifest:dev" | tr '/:@' '___')" + : > "$T/feed/$(printf '%s' "$REPO@$DIGEST" | tr '/:@' '___')" + ( ENGINE=podman AUTHFILE="" REPO="$REPO" CHANNEL="" ROLE=agent PACKAGING="$T/pkg" IMAGE="" + . "$T/resolve.sh" + resolve_verified >/dev/null 2>&1 && printf '%s' "$IMAGE" || printf 'REFUSED' ) +} +expect "first install: signed manifest pins the digest" "$REPO@$DIGEST" "$(resolve "$T/b.pem" agent "$REPO")" +expect "first install: wrong signing key is refused" "REFUSED" "$(resolve "$T/c.pem" agent "$REPO")" +expect "first install: manifest for another component is refused" "REFUSED" "$(resolve "$T/b.pem" probe "$REPO")" +expect "first install: manifest naming another repository is refused" "REFUSED" "$(resolve "$T/b.pem" agent "registry.evil/moshiro-dev/moshiro-agent")" + +# --- 第一次部署:正在跑的镜像就是清单里的这个 digest → 记成已安装(不当作有更新),并把验过的清单交给节点握手时带上 +sed -i '/^FEED=off/d' "$T/conf/agent.conf" +rm -f "$T/state/agent.installed" "$T/state/agent.installed.manifest" "$T/state/agent.installed.sig" "$T/data/update-status/installed."* "$T/data/update/offer."* +printf 'IMAGE=%s@%s\n' "$REPO" "$DIGEST" > "$T/env" +publish "$T/b.pem" 50 +expect "first deploy: the running image is already up to date" "up_to_date|" "$(check)" +if [ "$(sed -n 2p "$T/state/agent.installed" 2>/dev/null)" = "$DIGEST" ]; then echo "ok first deploy: install record seeded from the running image"; else echo "FAIL first deploy: no install record"; fail=1; fi +label="$(cat "$T/feed/$REF_KEY")" +if [ "$(cat "$T/data/update-status/installed.manifest" 2>/dev/null | base64 -w0)" = "${label%%|*}" ] && [ "$(cat "$T/data/update-status/installed.sig" 2>/dev/null)" = "${label#*|}" ]; then + echo "ok the verified manifest is left for the node to present at its handshake" +else + echo "FAIL installed.manifest / installed.sig missing or different"; fail=1 +fi +# 老版本的更新器没写过:同一份(seq 相同)下次检查补上 +rm -f "$T/data/update-status/installed."* "$T/state/agent.installed.manifest" "$T/state/agent.installed.sig" +expect "already installed: still up to date" "up_to_date|" "$(check)" +if [ -s "$T/data/update-status/installed.manifest" ] && [ -s "$T/data/update-status/installed.sig" ]; then echo "ok a missing copy is written again for the node"; else echo "FAIL the copy was not written again"; fail=1; fi + +exit "$fail" diff --git a/web/admin/src/App.tsx b/web/admin/src/App.tsx index e605773..a068711 100644 --- a/web/admin/src/App.tsx +++ b/web/admin/src/App.tsx @@ -7,9 +7,7 @@ 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' @@ -244,8 +242,6 @@ export function App() { } if (!me) return // 密码登录的会话没有任何能力:只给它添加 Passkey 的页面 - // 以数据源方式(会话令牌)连别的后端时 kind 是 token,所以只看登录方式 - if (me.auth_method === 'password') return return (
@@ -265,7 +261,6 @@ export function App() {
))}
-
{(me.username ?? 'A').slice(0, 1).toUpperCase()} diff --git a/web/admin/src/ElevationBar.tsx b/web/admin/src/ElevationBar.tsx deleted file mode 100644 index d86b3ec..0000000 --- a/web/admin/src/ElevationBar.tsx +++ /dev/null @@ -1,77 +0,0 @@ -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/PasskeyOnly.tsx b/web/admin/src/PasskeyOnly.tsx deleted file mode 100644 index d556827..0000000 --- a/web/admin/src/PasskeyOnly.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { KeyRound, LogOut } from 'lucide-react' -import { useEffect, useState } from 'react' -import { api } from '@shared/api/client' -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, []) - - return ( -
-
-
-

MoShiro

-

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

-
-
用密码登录的会话不能管理任何东西,只能添加 Passkey。添加之后退出,用 Passkey 重新登录。
- {!passkeySupported() ? ( -
这个浏览器不支持 Passkey(需要 HTTPS 或 localhost)。
- ) : count === 0 || count === null ? ( -
-
- - 添加你的第一个 Passkey -
- -
- ) : ( -
你已经有 {count} 个 Passkey。退出后用 Passkey 登录即可。
- )} -
- -
-
-
- ) -} diff --git a/web/admin/src/pages/System.tsx b/web/admin/src/pages/System.tsx index ce9f27d..e84f8fd 100644 --- a/web/admin/src/pages/System.tsx +++ b/web/admin/src/pages/System.tsx @@ -1,7 +1,7 @@ import { Download, KeyRound, Plus, Trash2 } from 'lucide-react' import { useEffect, useMemo, useState, type ReactNode } from 'react' import { api } from '@shared/api/client' -import type { AgentDto, ApiTokenDto, BuildInfoDto, CapabilityCatalogDto, MeDto, PasskeyDto, NodeUpdateDto, ReleaseDto, CreatedApiTokenDto, AuditDto, EventDto, Page, ProbeDto, RawChunkMetaDto, RecomputeDto, SettingDto, UserDto } from '@shared/api/types' +import type { AgentDto, ApiTokenDto, BuildInfoDto, NodeBinaryDto, 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' @@ -191,6 +191,7 @@ function BuildRow({ name, kind, version, tag, commit, builtAt, platform, update {fmtBuiltAt(builtAt) || dash} {platform || dash} {update ?? dash} + {dash} ) } @@ -205,6 +206,26 @@ const UPDATE_STATE: Record void }) { + if (!binary) return 未绑定 + const short = binary.sha256.slice(0, 10) + return ( + + {binary.violation ? 不符 : {binary.signed ? '已签名' : '首次绑定'}} + {short} + {binary.violation ? ( + + ) : null} + + ) +} + function UpdateCell({ update, busy, onUpdate }: { update?: NodeUpdateDto | null; busy: boolean; onUpdate: () => void }) { if (!update) return — const s = UPDATE_STATE[update.state] ?? { text: update.state, tone: '' as const } @@ -271,6 +292,7 @@ function Versions() { 构建时间 系统 更新 + 二进制 @@ -292,6 +314,9 @@ function Versions() { requestUpdate([n.id])} /> + + void run(async () => { await api.del(`/api/admin/nodes/${n.id}/binary`); info.reload() }, '已清除绑定,节点下一次连接时重新绑定')} /> + ))} diff --git a/web/admin/src/pages/Trust.tsx b/web/admin/src/pages/Trust.tsx index ef3401c..687fa07 100644 --- a/web/admin/src/pages/Trust.tsx +++ b/web/admin/src/pages/Trust.tsx @@ -5,6 +5,46 @@ import { signStatement } from '@shared/api/webauthn' import { useQuery } from '@shared/hooks' import { CopyButton, ErrorNotice, Spinner, useAction, useToast } from '@shared/ui' +type Proposal = NonNullable + +/** + * 要签名的这一版相对当前生效的集合有什么变化。签名之前必须看一眼:往账号里加一把 passkey 再等人签名,是这个流程唯一的风险—— + * 签了就等于授权它成为操作员(节点会认它)。 + */ +function Changes({ proposal }: { proposal: Proposal }) { + const { added, removed, server_key_changed: serverChanged } = proposal + if (added.length === 0 && removed.length === 0 && !serverChanged) return null + return ( +
+ {added.length > 0 ? ( +
+ 新增的操作员 Passkey(签名后节点会认它): +
    + {added.map((a) => ( +
  • + {a.name} 创建于 {new Date(a.created_at).toLocaleString()} · {a.id.slice(0, 12)}… +
  • + ))} +
+
+ ) : null} + {removed.length > 0 ? ( +
+ 移除的操作员 Passkey: +
    + {removed.map((id) => ( +
  • + {id.slice(0, 12)}… +
  • + ))} +
+
+ ) : null} + {serverChanged ?
服务器身份密钥有变化。
: null} +
+ ) +} + /** * 信任根:你的 Passkey 是整个系统的根。节点安装时把下面这个指纹和属主(passkey)固定在本地,之后只认从它出发、由你的 Passkey 签过名的 * 操作员集合,面板自己改不了,也签不出新的版本。 @@ -41,10 +81,11 @@ export function Trust() {

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

{state.proposal ? ( -
+
+ @@ -89,8 +130,9 @@ export function Trust() { ) : (
- 账号下的 Passkey(或站点域名)和生效的操作员集合不一致,比如新添加或删除了 Passkey。需要用一把现有操作员的 Passkey签一个新版本,节点才会采用。 + 账号下的 Passkey(或站点域名)和生效的操作员集合不一致。需要用一把现有操作员的 Passkey签一个新版本,节点才会采用。
+ {state.proposal ? : null}
注意:从集合里去掉一把 Passkey 之后,由它批准过的节点会被固定了信任根的对端拒绝。
diff --git a/web/shared/api/client.ts b/web/shared/api/client.ts index 7d416a6..04d459d 100644 --- a/web/shared/api/client.ts +++ b/web/shared/api/client.ts @@ -36,29 +36,7 @@ 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 585d36c..90c4d80 100644 --- a/web/shared/api/schema.ts +++ b/web/shared/api/schema.ts @@ -448,42 +448,6 @@ export interface paths { 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; @@ -877,7 +841,7 @@ export interface paths { /** 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) */ + /** Store the next operator set, signed with a passkey (the first one sets the genesis) */ post: operations["trust_commit"]; delete?: never; options?: never; @@ -1024,6 +988,23 @@ export interface paths { patch?: never; trace?: never; }; + "/api/admin/nodes/{id}/binary": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Accept the node's current binary: clear its binding so the next handshake binds it again */ + delete: operations["nodes_binary_clear"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/admin/backend/update": { parameters: { query?: never; @@ -1493,21 +1474,6 @@ 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; @@ -1731,6 +1697,17 @@ export interface components { version: number | string; needs: string[]; }; + NodeBinaryDto: { + sha256: string; + signed: boolean; + /** Format: int64 */ + seq: number | string; + /** Format: date-time */ + bound_at: unknown; + violation: null | string; + /** Format: date-time */ + violation_at: unknown; + }; NodeBuildDto: { id: string; kind: string; @@ -1742,6 +1719,7 @@ export interface components { commit: null | string; built_at: null | string; update?: null | components["schemas"]["NodeUpdateDto"]; + binary?: null | components["schemas"]["NodeBinaryDto"]; }; NodeInfoDto: { id: string; @@ -1807,10 +1785,6 @@ export interface components { 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; @@ -1986,6 +1960,12 @@ export interface components { [key: string]: string[]; }; }; + ProposedCredentialDto: { + id: string; + name: string; + /** Format: date-time */ + created_at: unknown; + }; PublishReleaseRequest: { manifest: null | string; signature: null | string; @@ -2185,6 +2165,9 @@ export interface components { /** Format: int32 */ version: number | string; challenge: string; + added: components["schemas"]["ProposedCredentialDto"][]; + removed: string[]; + server_key_changed: boolean; }; TrustStateDto: { initialized: boolean; @@ -3470,184 +3453,6 @@ export interface operations { }; }; }; - 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; @@ -5817,6 +5622,46 @@ export interface operations { }; }; }; + nodes_binary_clear: { + 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 Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; backend_update_get: { parameters: { query?: never; diff --git a/web/shared/api/types.ts b/web/shared/api/types.ts index 27d44c7..ed42e81 100644 --- a/web/shared/api/types.ts +++ b/web/shared/api/types.ts @@ -19,6 +19,7 @@ export type BuildInfoDto = Narrow export type BackendUpdateDto = Narrow export type NodeBuildDto = Narrow export type NodeUpdateDto = Narrow +export type NodeBinaryDto = Narrow export type ReleaseDto = Narrow export type LinkDto = Narrow export type LinkMatrixDto = Narrow @@ -41,7 +42,6 @@ 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 diff --git a/web/shared/api/webauthn.ts b/web/shared/api/webauthn.ts index 1c1e591..066a5cd 100644 --- a/web/shared/api/webauthn.ts +++ b/web/shared/api/webauthn.ts @@ -82,45 +82,6 @@ export async function signStatement(statement: string): 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')