Files
MoShiro/backend/src/MoShiro.Backend.Application/Auth/TrustService.cs
T
justme e014a19554
ci/woodpecker/push/main-amd64-build Pipeline was successful
docs: reorganize into design, security, api, ops and dev; drop stale designs; refresh the diagrams [CI]
- Group docs by category with an index (docs/README.md); fix every link,
  code comment and the protocol conformance test that read docs/protocol.md.
- access.md and security.md rewritten to what is implemented: removed the
  unadopted member-proof chain, roles and recovery key that do not exist,
  the old implementation order and the budgets; gaps now carry a status.
- enrollment, deployment, api: the install flow is the static, hash-checked
  script with environment variables; no default admin.
- README and architecture: new topology and update-flow diagrams (operator
  passkey, CI signing, registry feed, auth and trust modules).
2026-10-08 19:37:56 +08:00

159 lines
9.7 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Security.Cryptography;
using System.Text;
using Fleet.Core;
using Fleet.Identity;
using Fleet.Server;
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, 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>
public sealed record TrustState(bool Initialized, string? Genesis, int Version, bool InSync, IReadOnlyList<SignedStatementDto> Chain, TrustProposal? Proposal, IReadOnlyList<string>? Operators = null, string ServerKey = "", bool ServerKeyListed = false, IReadOnlyList<NodeOwner>? OperatorDetails = null);
/// <summary>
/// 信任根的 Backend 一侧:保存操作员集合的链、给操作员出下一版的语句、验证他签回来的版本。
/// Backend <b>不持有任何签名的能力</b>:每一版都要操作员的 passkey 触碰;节点从安装命令里固定的创世指纹出发自己验链,
/// 所以 Backend 在这里的验证只是不收垃圾,不是信任的来源。设计见 docs/security/access.md 第 8 节。
/// </summary>
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<IReadOnlyList<SignedStatementDto>> ChainAsync(CancellationToken ct) => _store.ListAsync(ct);
/// <summary>当前生效的操作员集合(从创世验过的链);还没有建立信任根返回 null。</summary>
public async ValueTask<OperatorSet?> CurrentAsync(CancellationToken ct)
{
IReadOnlyList<SignedStatementDto> 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<TrustState> StateAsync(PasskeyContext context, CancellationToken ct)
{
IReadOnlyList<SignedStatementDto> 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<NodeOwner>();
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<TrustProposal?> ProposeAsync(IReadOnlyList<SignedStatementDto> chain, OperatorSet? current, PasskeyContext context, CancellationToken ct)
{
IReadOnlyList<PasskeyRecord> 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<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) =>
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));
/// <summary>
/// 收下操作员签回来的一版(创世,或上一版里的凭证签的更新):语句里的每把凭证都要是已注册的 passkey、域名对得上、链验得过。
/// 谁能调用由端点的能力把关;这里不看调用者,信任来自节点自己验证的 passkey 签名。
/// </summary>
public async ValueTask<Result<TrustState>> CommitAsync(SignedStatementDto signed, PasskeyContext context, CancellationToken ct)
{
await _gate.WaitAsync(ct);
try
{
IReadOnlyList<SignedStatementDto> chain = await _store.ListAsync(ct);
OperatorSet? set = OperatorTrust.ParseSet(signed.Statement);
if (set is null)
{
return ValidationError.Fail<TrustState>("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<TrustState>.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<TrustState>("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<TrustState>("statement", "must list this panel's server key");
}
IReadOnlyList<PasskeyRecord> 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<TrustState>("statement", "lists a passkey that is not registered on this panel");
}
List<SignedStatement> 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<TrustState>.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<TrustState>.Fail(MoShiroErrors.NameConflict, "this operator set version already exists");
}
}
finally
{
_gate.Release();
}
return Result<TrustState>.Ok(await StateAsync(context, ct));
}
}