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));
}
}