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:
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));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user