feat: operator trust root: passkey-signed operator set, signed join codes, nodes verify and pin the chain at enrollment [CI SKIP]

This commit is contained in:
justme committed 2026-10-07 23:19:59 +08:00
1 parent 321cacac32
commit fc2bbdcc6d
35 files changed
+1374 -40

No files matched your search

@@ -0,0 +1,130 @@
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);
/// <summary><c>InSync</c>:当前生效的集合和现有的 passkey、域名是否一致;不一致就需要签一个新版本。</summary>
public sealed record TrustState(bool Initialized, string? Genesis, int Version, bool InSync, IReadOnlyList<SignedStatementDto> Chain, TrustProposal? Proposal);
/// <summary>
/// 信任根的 Backend 一侧:保存操作员集合的链、给操作员出下一版的语句、验证他签回来的版本。
/// Backend <b>不持有任何签名的能力</b>:每一版都要操作员的 passkey 触碰;节点从安装命令里固定的创世指纹出发自己验链,
/// 所以 Backend 在这里的验证只是不收垃圾,不是信任的来源。设计见 docs/access.md 第 7 节。
/// </summary>
public sealed class TrustService : IOperatorTrustSource, IDisposable
{
private readonly IOperatorSetStore _store;
private readonly IAuthStore _auth;
private readonly TimeProvider _time;
private readonly SemaphoreSlim _gate = new(1, 1);
public TrustService(IOperatorSetStore store, IAuthStore auth, TimeProvider time)
{
_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);
return new TrustState(current is not null, chain.Count == 0 ? null : OperatorTrust.Fingerprint(chain[0].Statement), current?.Version ?? 0, inSync, chain, inSync ? null : proposal);
}
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))]);
string statement = OperatorTrust.Encode(next);
return new TrustProposal(statement, next.Version, Base64Url.Encode(SHA256.HashData(Encoding.UTF8.GetBytes(statement))));
}
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));
/// <summary>
/// 收下操作员签回来的一版(创世,或上一版里的凭证签的更新):语句里的每把凭证都要是已注册的 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");
}
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));
}
}