using System.Security.Cryptography; using System.Text; using Fleet.Core; using Fleet.Identity; using Fleet.Server; using MoShiro.Protocol; namespace MoShiro.Backend.Application; /// 要操作员用 passkey 签的下一版操作员集合:Challenge 是语句的 SHA-256(base64url),也就是 WebAuthn 的挑战。 public sealed record TrustProposal(string Statement, int Version, string Challenge, IReadOnlyList Added, IReadOnlyList Removed, bool ServerKeyChanged); /// 下一版里新增的 passkey:签名的人要看清楚是不是自己加的,攻击者往账号里塞一把 passkey 再等人签名,是这个流程唯一的风险。 public sealed record ProposedCredential(string Id, string Name, long CreatedAtMs); /// InSync:当前生效的集合和现有的 passkey、域名是否一致;不一致就需要签一个新版本。 /// 这个面板的服务器身份密钥的指纹;ServerKeyListed:当前生效的操作员集合里已经登记了它(节点据此认证服务器、加密链路)。 public sealed record TrustState(bool Initialized, string? Genesis, int Version, bool InSync, IReadOnlyList Chain, TrustProposal? Proposal, IReadOnlyList? Operators = null, string ServerKey = "", bool ServerKeyListed = false, IReadOnlyList? OperatorDetails = null); /// /// 信任根的 Backend 一侧:保存操作员集合的链、给操作员出下一版的语句、验证他签回来的版本。 /// Backend 不持有任何签名的能力:每一版都要操作员的 passkey 触碰;节点从安装命令里固定的创世指纹出发自己验链, /// 所以 Backend 在这里的验证只是不收垃圾,不是信任的来源。设计见 docs/security/access.md 第 8 节。 /// public sealed class TrustService : IOperatorTrustSource, IDisposable { private readonly IOperatorSetStore _store; private readonly IAuthStore _auth; private readonly ServerIdentityService _server; private readonly TimeProvider _time; private readonly SemaphoreSlim _gate = new(1, 1); public TrustService(IOperatorSetStore store, IAuthStore auth, ServerIdentityService server, TimeProvider time) { _server = server; _store = store; _auth = auth; _time = time; } public void Dispose() => _gate.Dispose(); public ValueTask> ChainAsync(CancellationToken ct) => _store.ListAsync(ct); /// 当前生效的操作员集合(从创世验过的链);还没有建立信任根返回 null。 public async ValueTask CurrentAsync(CancellationToken ct) { IReadOnlyList chain = await _store.ListAsync(ct); return chain.Count == 0 ? null : OperatorTrust.VerifyChain(OperatorTrust.Fingerprint(chain[0].Statement), [.. chain.Select(c => c.ToSigned())]); } public async ValueTask StateAsync(PasskeyContext context, CancellationToken ct) { IReadOnlyList chain = await _store.ListAsync(ct); OperatorSet? current = chain.Count == 0 ? null : OperatorTrust.VerifyChain(OperatorTrust.Fingerprint(chain[0].Statement), [.. chain.Select(c => c.ToSigned())]); TrustProposal? proposal = await ProposeAsync(chain, current, context, ct); OperatorSet? proposed = proposal is null ? null : OperatorTrust.ParseSet(proposal.Statement); bool inSync = current is not null && proposed is not null && Same(current, proposed); // 每个操作员是哪个账户的哪把 passkey:添加节点时选属主、信任页都要显示 var details = new List(); foreach (OperatorKey key in current?.Keys ?? []) { PasskeyRecord? passkey = await _auth.FindPasskeyAsync(key.CredentialId, ct); string? account = passkey is null ? null : (await _auth.GetUserAsync(passkey.UserId, ct))?.Username; details.Add(new NodeOwner(key.CredentialId, passkey?.Name, account)); } return new TrustState(current is not null, chain.Count == 0 ? null : OperatorTrust.Fingerprint(chain[0].Statement), current?.Version ?? 0, inSync, chain, inSync ? null : proposal, current is null ? [] : [.. current.Keys.Select(k => k.CredentialId)], _server.Fingerprint, current?.Servers?.Any(k => k.AsSpan().SequenceEqual(_server.PublicKeySpki)) == true, details); } private async ValueTask ProposeAsync(IReadOnlyList chain, OperatorSet? current, PasskeyContext context, CancellationToken ct) { IReadOnlyList passkeys = await _auth.ListAllPasskeysAsync(ct); if (passkeys.Count == 0) { return null; } var next = new OperatorSet( (current?.Version ?? 0) + 1, chain.Count == 0 ? OperatorTrust.Genesis : OperatorTrust.Fingerprint(chain[^1].Statement), context.RpId, [.. context.Origins.Order(StringComparer.Ordinal)], [.. passkeys.Select(p => new OperatorKey(p.Id, p.PublicKeySpki))], [_server.PublicKeySpki]); string statement = OperatorTrust.Encode(next); 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) => a.RpId == b.RpId && a.Origins.Order(StringComparer.Ordinal).SequenceEqual(b.Origins.Order(StringComparer.Ordinal)) && a.Keys.Select(k => k.CredentialId).Order(StringComparer.Ordinal).SequenceEqual(b.Keys.Select(k => k.CredentialId).Order(StringComparer.Ordinal)) && (a.Servers ?? []).Select(Convert.ToBase64String).Order(StringComparer.Ordinal).SequenceEqual((b.Servers ?? []).Select(Convert.ToBase64String).Order(StringComparer.Ordinal)); /// /// 收下操作员签回来的一版(创世,或上一版里的凭证签的更新):语句里的每把凭证都要是已注册的 passkey、域名对得上、链验得过。 /// 谁能调用由端点的能力把关;这里不看调用者,信任来自节点自己验证的 passkey 签名。 /// public async ValueTask> CommitAsync(SignedStatementDto signed, PasskeyContext context, CancellationToken ct) { await _gate.WaitAsync(ct); try { IReadOnlyList chain = await _store.ListAsync(ct); OperatorSet? set = OperatorTrust.ParseSet(signed.Statement); if (set is null) { return ValidationError.Fail("statement", "is not an operator set"); } OperatorSet? current = chain.Count == 0 ? null : OperatorTrust.VerifyChain(OperatorTrust.Fingerprint(chain[0].Statement), [.. chain.Select(c => c.ToSigned())]); if (set.Version != (current?.Version ?? 0) + 1) { return Result.Fail(MoShiroErrors.NameConflict, $"the operator set is at version {current?.Version ?? 0}; sign version {(current?.Version ?? 0) + 1}"); } if (set.RpId != context.RpId || set.Origins.Any(o => !context.Origins.Contains(o))) { return ValidationError.Fail("statement", "the relying party or origins do not match this panel"); } if (set.Servers is not { Count: > 0 } || !set.Servers.Any(k => k.AsSpan().SequenceEqual(_server.PublicKeySpki))) { // 集合里没有这个面板的服务器密钥:签下去之后固定了它的节点就认不出这个面板了 return ValidationError.Fail("statement", "must list this panel's server key"); } IReadOnlyList registered = await _auth.ListAllPasskeysAsync(ct); if (set.Keys.Any(k => !registered.Any(p => p.Id == k.CredentialId && p.PublicKeySpki.AsSpan().SequenceEqual(k.PublicKeySpki)))) { return ValidationError.Fail("statement", "lists a passkey that is not registered on this panel"); } List extended = [.. chain.Select(c => c.ToSigned()), signed.ToSigned()]; string genesis = OperatorTrust.Fingerprint(extended[0].Statement); if (OperatorTrust.VerifyChain(genesis, extended) is not { } verified || verified.Version != set.Version) { return Result.Fail(MoShiroErrors.Forbidden, "the operator set is not signed by a passkey of the previous version"); } if (!await _store.AddAsync(set.Version, signed, _time.GetUtcNow().ToUnixTimeMilliseconds(), ct)) { return Result.Fail(MoShiroErrors.NameConflict, "this operator set version already exists"); } } finally { _gate.Release(); } return Result.Ok(await StateAsync(context, ct)); } }