From 4106f831dc490d542fac626abc0330bfc07f8b51 Mon Sep 17 00:00:00 2001 From: JustMe Date: Thu, 8 Oct 2026 04:57:28 +0800 Subject: [PATCH] feat!: drop the backend-side elevation windows; password and passkey sessions share capabilities, passkeys guard what nodes verify; the trust page lists added and removed passkeys [CI] --- backend/openapi/v1.json | 317 +++--------------- .../shared/MoShiro.Protocol/MoShiroErrors.cs | 1 - .../Admin/AuditEndpoints.cs | 2 +- .../Admin/AuthEndpoints.cs | 111 +----- .../Admin/SystemEndpoints.cs | 5 - .../Admin/TrustEndpoints.cs | 11 +- .../Common/EndpointConventions.cs | 5 - .../src/MoShiro.Backend.Api/Common/Guards.cs | 27 -- .../src/MoShiro.Backend.Api/Common/Mappers.cs | 3 +- .../MoShiro.Backend.Api/Common/Problems.cs | 2 +- .../Contracts/AdminDtos.cs | 18 +- .../Contracts/ApiJsonContext.cs | 5 +- .../ApplicationServiceCollectionExtensions.cs | 1 - .../Auth/AuthModels.cs | 4 +- .../Auth/AuthService.cs | 2 +- .../Auth/Capabilities.cs | 12 +- .../Auth/ElevationService.cs | 135 -------- .../Auth/PasskeyService.cs | 37 +- .../Auth/TrustService.cs | 14 +- .../BackendOptions.cs | 9 - .../MoShiro.Backend.Tests/BackendFixture.cs | 11 +- .../MoShiro.Backend.Tests/ElevationTests.cs | 201 ----------- .../MoShiro.Backend.Tests/PasskeyTests.cs | 20 +- .../tests/MoShiro.Backend.Tests/TrustTests.cs | 44 ++- docs/access.md | 59 ++-- docs/security.md | 6 +- web/admin/src/App.tsx | 5 - web/admin/src/ElevationBar.tsx | 77 ----- web/admin/src/PasskeyOnly.tsx | 57 ---- web/admin/src/pages/Trust.tsx | 48 ++- web/shared/api/client.ts | 22 -- web/shared/api/schema.ts | 244 +------------- web/shared/api/types.ts | 1 - web/shared/api/webauthn.ts | 39 --- 34 files changed, 191 insertions(+), 1364 deletions(-) delete mode 100644 backend/src/MoShiro.Backend.Application/Auth/ElevationService.cs delete mode 100644 backend/tests/MoShiro.Backend.Tests/ElevationTests.cs delete mode 100644 web/admin/src/ElevationBar.tsx delete mode 100644 web/admin/src/PasskeyOnly.tsx diff --git a/backend/openapi/v1.json b/backend/openapi/v1.json index 27696c5..2036968 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": { @@ -6229,59 +6027,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", @@ -7632,27 +7377,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 +8201,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 +9015,10 @@ "required": [ "statement", "version", - "challenge" + "challenge", + "added", + "removed", + "server_key_changed" ], "type": "object", "properties": { @@ -9289,6 +9035,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/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/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/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..cfcf5f3 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)就要签一个新版本。 diff --git a/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs b/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs index 82e8b9b..3b87089 100644 --- a/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs +++ b/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs @@ -81,10 +81,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.Application/ApplicationServiceCollectionExtensions.cs b/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs index 0dbb58e..dfb1799 100644 --- a/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs +++ b/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs @@ -41,7 +41,6 @@ public static class ApplicationServiceCollectionExtensions 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..ac5fb94 100644 --- a/backend/src/MoShiro.Backend.Application/BackendOptions.cs +++ b/backend/src/MoShiro.Backend.Application/BackendOptions.cs @@ -32,15 +32,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/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/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/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/docs/access.md b/docs/access.md index b70ae3a..597623d 100644 --- a/docs/access.md +++ b/docs/access.md @@ -26,7 +26,8 @@ 节点和 Backend 之间:集合里登记了服务器密钥之后,节点的认证挑战要求服务器用它签名;两边各出一把临时 ECDH 密钥, 认证签名覆盖双方的临时公钥,之后控制链路每一帧都是 AES-256-GCM(每个方向一把密钥、严格递增的计数器)。 所以 TLS 在 Cloudflare 或被冒充的证书那里终止,中间人没有服务器密钥,既冒充不了服务器,也读不了、改不了这条链路 - 密码登录的会话没有任何能力,只能注册 passkey;有能力的会话来自 passkey 登录;信任类操作要提权窗口(passkey 触碰) + 密码登录和 passkey 登录的会话拿同样的能力,授权只看能力;passkey 只用在节点自己会验证的事上(现在是操作员集合的签名,以后是 extension 会话和加密数据)。 + Backend 自己拦自己没有意义,所以没有提权窗口 ``` **发布密钥可以换**:见 `docs/deployment.md` 的「换发布密钥」;回滚已经由清单的 `seq` 防住(更新器只收比已安装更大的)。 @@ -41,7 +42,6 @@ | 角色 | `anonymous`、`admin`、`owner`,决定能力的**上限** | | 能力(capability) | 一项具体权限,如 `fleet.write`。端点声明自己需要哪项能力 | | 会话 | 登录得到的状态,只带 L1 能力 | -| 提权窗口 | 用 passkey 触控换来的、有时限的信任类能力(类似 sudo) | | 操作员集合(OpSet) | 节点固定的、带版本的 passkey 公钥列表,每个带角色和限制 | | 委托(Grant) | passkey 签名授权一把临时密钥,在范围、时间内代表它签名 | | API key | 一串种子;`enroll` 类的 key 创建时由 passkey 签出一份委托 | @@ -79,10 +79,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 +90,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 +105,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 +136,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 +159,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 +167,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 +248,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 +374,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 +455,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 满足)。 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/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/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..25f1124 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; @@ -1493,21 +1457,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; @@ -1807,10 +1756,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 +1931,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 +2136,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 +3424,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; diff --git a/web/shared/api/types.ts b/web/shared/api/types.ts index 27d44c7..3c09900 100644 --- a/web/shared/api/types.ts +++ b/web/shared/api/types.ts @@ -41,7 +41,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')