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]

This commit is contained in:
justme committed 2026-10-08 04:57:28 +08:00
1 parent f704c66dd9
commit 4106f831dc
34 files changed
+191 -1364

No files matched your search

@@ -41,7 +41,6 @@ public static class ApplicationServiceCollectionExtensions
services.AddSingleton<Fleet.Server.IServerIdentity>(sp => sp.GetRequiredService<ServerIdentityService>());
services.AddSingleton<TrustService>();
services.AddSingleton<Fleet.Server.IOperatorTrustSource>(sp => sp.GetRequiredService<TrustService>());
services.AddSingleton<ElevationService>();
services.AddSingleton<SettingsService>();
services.AddSingleton<ReleaseService>();
services.AddSingleton<ScheduleService>();
@@ -12,10 +12,10 @@ public enum PrincipalKind
/// </summary>
public sealed record Principal(PrincipalKind Kind, IReadOnlySet<string> Capabilities, string? UserId, string? Username, string? CsrfToken, string? TokenId)
{
/// <summary>会话是怎么登录的(<see cref="AuthMethods"/>):只有 passkey 登录的会话以后能提权。API key 为空。</summary>
/// <summary>会话是怎么登录的(<see cref="AuthMethods"/>):登录方式只用于审计和显示;敏感操作靠提权窗口(触碰 passkey)。API key 为空。</summary>
public string AuthMethod { get; init; } = "";
/// <summary>会话的标识(令牌哈希的十六进制):提权窗口按它记。API key 为空。</summary>
/// <summary>会话的标识(令牌哈希的十六进制):API key 为空。</summary>
public string? SessionId { get; init; }
/// <summary>审计和归属里显示的名字:用户名,或者 <c>key:令牌名</c>。</summary>
@@ -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<Principal?> AuthenticateTokenAsync(string bearer, CancellationToken ct)
@@ -45,7 +45,7 @@ public static class Capability
public const string WebhooksManage = "webhooks.manage";
}
/// <summary>一项能力的元数据。<c>Trust</c>:改变信任的能力(加节点、吊销、账户),后续会要求 passkey 签名;<c>Reserved</c>:还没有对应的功能。</summary>
/// <summary>一项能力的元数据。<c>Trust</c>:改变信任或有破坏性的能力(吊销、账户),界面上特别标出,授权与其他能力一样靠 API key 或登录会话;真正由 passkey 签名保护的是操作员集合,那是节点自己验证的;<c>Reserved</c>:还没有对应的功能。</summary>
public sealed record CapabilityInfo(string Id, string Group, bool Trust = false, bool Reserved = false, params string[] Implies);
public sealed record CapabilityPreset(string Id, IReadOnlyList<string> Capabilities);
@@ -132,12 +132,10 @@ public static class CapabilityCatalog
}
}
/// <summary>登录会话的能力:全部,包括信任类;信任类的操作还要有效的提权窗口(<see cref="ElevationService"/>)。</summary>
/// <summary>
/// 登录会话的能力:全部,包括信任类,密码登录和 passkey 登录一样。区别在于敏感操作:信任类的操作还要有效的提权窗口(<see cref="ElevationService"/>),
/// 窗口要当场触碰 passkey 才能开,所以密码泄露的人做不了这些,日常管理却不需要每次触碰。
/// </summary>
public static IReadOnlyList<string> ForSession() => [.. All.Select(c => c.Id)];
/// <summary>
/// 按登录方式给会话能力:passkey 登录的拿全部;<b>密码登录的什么能力都没有</b>,只能做「已登录」就能做的事——
/// 看自己是谁、注册自己的第一个 passkey。管理员要先用密码进来加一个 passkey,之后用 passkey 登录才能管事。
/// </summary>
public static IReadOnlyList<string> ForSession(string authMethod) => authMethod == AuthMethods.Passkey ? ForSession() : [];
}
@@ -1,135 +0,0 @@
namespace MoShiro.Backend.Application;
/// <summary>
/// 提权窗口的种类(docs/access.md 第 4 节):加节点(15 分钟)、吊销和删除(5 分钟)、改凭证(60 秒,用一次就没有)。
/// 窗口属于会话,不属于用户:换一台设备、一个浏览器就要重新触碰 passkey。
/// </summary>
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;
}
/// <summary>端点要求的能力需要哪一种窗口;不是信任类的能力返回 null。</summary>
public static ElevationClass? For(string capability) => capability switch
{
Capability.FleetRevoke => ElevationClass.Revoke,
Capability.AccountsManage => ElevationClass.Credential,
_ => null,
};
}
/// <summary>
/// 会话的提权窗口,只在内存里:重启之后需要重新触碰 passkey,这正是想要的。API key 不经过这里(它自己带着能力,创建时已经签过)。
/// </summary>
public sealed class ElevationService
{
private readonly BackendOptions _options;
private readonly TimeProvider _time;
private readonly object _gate = new();
private readonly Dictionary<string, Dictionary<ElevationClass, long>> _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),
};
/// <summary>开窗口;已经有的窗口重新计时。返回到期时间。</summary>
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<ElevationClass, long>? own))
{
_windows[sessionId] = own = [];
}
own[elevation] = expires;
}
return expires;
}
/// <summary>窗口是否有效。<paramref name="consume"/> 且是凭证类:同时用掉(一次有效)。</summary>
public bool Check(string sessionId, ElevationClass elevation, bool consume)
{
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
lock (_gate)
{
if (!_windows.TryGetValue(sessionId, out Dictionary<ElevationClass, long>? own) || !own.TryGetValue(elevation, out long expires) || expires <= now)
{
return false;
}
if (consume && elevation == ElevationClass.Credential)
{
own.Remove(elevation);
}
return true;
}
}
public IReadOnlyList<ElevationWindow> Active(string sessionId)
{
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
lock (_gate)
{
return _windows.TryGetValue(sessionId, out Dictionary<ElevationClass, long>? own)
? [.. own.Where(w => w.Value > now).OrderBy(w => w.Key).Select(w => new ElevationWindow(w.Key, w.Value))]
: [];
}
}
/// <summary>主动结束全部窗口(点「结束」或退出登录)。</summary>
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);
}
}
}
@@ -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<string, PendingChallenge> _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);
}
/// <summary>提权的挑战:绑定用户和窗口种类,并列出这个用户的 passkey 让浏览器只选它们。没有 passkey 的用户没法提权。</summary>
public async ValueTask<Result<RequestOptions>> BeginElevationAsync(Principal principal, ElevationClass elevation, PasskeyContext context, CancellationToken ct)
{
if (principal.UserId is not { } userId || principal.SessionId is null)
{
return Result<RequestOptions>.Fail(MoShiroErrors.Forbidden, "only a signed-in user can elevate; API keys carry their capabilities already");
}
IReadOnlyList<PasskeyRecord> own = await _store.ListPasskeysAsync(userId, ct);
if (own.Count == 0)
{
return Result<RequestOptions>.Fail(MoShiroErrors.ValidationFailed, "register a passkey first (Users, My passkeys)");
}
return Result<RequestOptions>.Ok(new RequestOptions(Issue(ElevatePurpose(elevation), userId), context.RpId, 60_000, [.. own.Select(p => p.Id)]));
}
/// <summary>验证提权的断言(必须是这个用户自己的 passkey)并给这个会话开窗口。失败一律 unauthorized。</summary>
public async ValueTask<Result<ElevationWindow>> ElevateAsync(Principal principal, ElevationClass elevation, PasskeyAssertion request, PasskeyContext context, CancellationToken ct)
{
if (principal.UserId is not { } userId || principal.SessionId is not { } sessionId)
{
return Result<ElevationWindow>.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<ElevationWindow>.Fail(MoShiroErrors.Unauthorized, "invalid credentials")
: Result<ElevationWindow>.Ok(new ElevationWindow(elevation, _elevation.Grant(sessionId, elevation)));
}
/// <summary>验证一份断言:挑战(一次有效、种类和用户对得上)、签名、计数器;通过则记下使用并返回那个 passkey。</summary>
private async ValueTask<PasskeyRecord?> VerifyAsync(PasskeyAssertion request, PasskeyContext context, string purpose, string? userId, CancellationToken ct)
{
@@ -8,7 +8,10 @@ using MoShiro.Protocol;
namespace MoShiro.Backend.Application;
/// <summary>要操作员用 passkey 签的下一版操作员集合:<c>Challenge</c> 是语句的 SHA-256(base64url),也就是 WebAuthn 的挑战。</summary>
public sealed record TrustProposal(string Statement, int Version, string Challenge);
public sealed record TrustProposal(string Statement, int Version, string Challenge, IReadOnlyList<ProposedCredential> Added, IReadOnlyList<string> Removed, bool ServerKeyChanged);
/// <summary>下一版里新增的 passkey:签名的人要看清楚是不是自己加的,攻击者往账号里塞一把 passkey 再等人签名,是这个流程唯一的风险。</summary>
public sealed record ProposedCredential(string Id, string Name, long CreatedAtMs);
/// <summary><c>InSync</c>:当前生效的集合和现有的 passkey、域名是否一致;不一致就需要签一个新版本。</summary>
/// <param name="ServerKey">这个面板的服务器身份密钥的指纹;<c>ServerKeyListed</c>:当前生效的操作员集合里已经登记了它(节点据此认证服务器、加密链路)。</param>
@@ -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<string> before = [.. current?.Keys.Select(k => k.CredentialId) ?? []];
HashSet<string> 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
/// <summary>
/// 收下操作员签回来的一版(创世,或上一版里的凭证签的更新):语句里的每把凭证都要是已注册的 passkey、域名对得上、链验得过。
/// 谁能调用由端点的能力和提权窗口把关;这里不看调用者。
/// 谁能调用由端点的能力把关;这里不看调用者,信任来自节点自己验证的 passkey 签名。
/// </summary>
public async ValueTask<Result<TrustState>> CommitAsync(SignedStatementDto signed, PasskeyContext context, CancellationToken ct)
{
@@ -32,15 +32,6 @@ public sealed class BackendOptions
/// <summary>允许的来源(逗号分隔,如 <c>https://panel.example.com</c>);缺省是 <see cref="PublicUrl"/> 的来源,没有就取请求自己的来源。</summary>
public string? WebAuthnOrigins { get; set; }
/// <summary>提权窗口的长度(passkey 触碰一次之后):加节点类的分钟数。</summary>
public int ElevationEnrollMinutes { get; set; } = 15;
/// <summary>吊销和删除类的分钟数。</summary>
public int ElevationRevokeMinutes { get; set; } = 5;
/// <summary>改凭证类(用一次就没有)的秒数。</summary>
public int ElevationCredentialSeconds { get; set; } = 60;
/// <summary>会话 Cookie 带 Secure 标记(只提供 HTTPS)。本机用 HTTP 调试时才关闭。</summary>
public bool SecureCookies { get; set; } = true;