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

@@ -1,4 +1,6 @@
using Fleet.Core;
using Fleet.Identity;
using MoShiro.Protocol;
namespace MoShiro.Backend.Application;
@@ -25,12 +27,14 @@ public sealed class JoinTokenService
public static string RoleOf(string kind) => kind.Length == 0 ? AgentRole : kind;
private readonly IJoinTokenStore _store;
private readonly TrustService _trust;
private readonly BackendOptions _options;
private readonly TimeProvider _time;
public JoinTokenService(IJoinTokenStore store, BackendOptions options, TimeProvider time)
public JoinTokenService(IJoinTokenStore store, TrustService trust, BackendOptions options, TimeProvider time)
{
_store = store;
_trust = trust;
_options = options;
_time = time;
}
@@ -72,7 +76,7 @@ public sealed class JoinTokenService
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
JoinTokenInfo info = await _store.CreateAsync(
Ulid.NewPrefixed("jtk", _time), TokenHash.Sha256(plaintext), request.Note, request.Labels ?? [], request.AutoApprove, KindOf(request.Role),
request.MaxUses, now + (request.ExpiresInHours * 3_600_000L), now, request.CreatedBy, ct);
request.MaxUses, now + (request.ExpiresInHours * 3_600_000L), now, request.CreatedBy, null, ct);
string baseUrl = BaseUrl(requestBaseUrl);
string query = $"{baseUrl}/install?code={plaintext}";
@@ -82,6 +86,71 @@ public sealed class JoinTokenService
$"irm '{baseUrl}/install.ps1?code={plaintext}' | iex")));
}
/// <summary>
/// 用操作员 passkey 签的委托创建加入码:码本身(认证串)由浏览器用种子派生,面板只拿到它的哈希,推不出种子,
/// 所以面板自己没法为这个码签绑定。有效期、角色、次数都以签名的语句为准。
/// </summary>
public async ValueTask<Result<JoinTokenInfo>> CreateSignedAsync(SignedStatementDto signedGrant, string? note, IReadOnlyList<string>? labels, bool autoApprove, string? createdBy, CancellationToken ct)
{
OperatorSet? set = await _trust.CurrentAsync(ct);
if (set is null)
{
return Result<JoinTokenInfo>.Fail(MoShiroErrors.ValidationFailed, "set up the operator trust root first (System, Trust)");
}
Grant? grant = OperatorTrust.ParseGrant(signedGrant.Statement);
if (grant is null || !OperatorTrust.VerifyOperator(signedGrant.ToSigned(), set))
{
return Result<JoinTokenInfo>.Fail(MoShiroErrors.Forbidden, "the grant is not signed by an operator passkey");
}
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
var errors = new Dictionary<string, string>();
if (grant.Role is not (AgentRole or ProbeRole))
{
errors["role"] = "must be agent or probe";
}
if (grant.Uses is < 1 or > 1000)
{
errors["uses"] = "must be between 1 and 1000";
}
if (grant.NotAfterMs <= now || grant.NotAfterMs - now > 24 * 365 * 3_600_000L || grant.NotBeforeMs > now + 300_000)
{
errors["validity"] = "the grant must be valid now and for at most one year";
}
if (!System.Text.RegularExpressions.Regex.IsMatch(grant.AuthHash, "^[0-9a-f]{64}$"))
{
errors["auth"] = "must be a lowercase sha-256 hex";
}
if (labels is { } l && (l.Count > 16 || l.Any(x => x.Length is 0 or > 32)))
{
errors["labels"] = "at most 16 labels of 1-32 characters";
}
if (note is { Length: > 200 })
{
errors["note"] = "must be at most 200 characters";
}
if (errors.Count > 0)
{
return ValidationError.Fail<JoinTokenInfo>(errors);
}
byte[] hash = Convert.FromHexString(grant.AuthHash);
if (await _store.FindAsync(hash, ct) is not null)
{
return Result<JoinTokenInfo>.Fail(MoShiroErrors.NameConflict, "this code already exists");
}
return Result<JoinTokenInfo>.Ok(await _store.CreateAsync(
Ulid.NewPrefixed("jtk", _time), hash, note, labels ?? [], autoApprove, KindOf(grant.Role), grant.Uses, grant.NotAfterMs, now, createdBy, signedGrant, ct));
}
private string BaseUrl(string? requestBaseUrl) => (_options.PublicUrl ?? requestBaseUrl ?? "https://panel.example.com").TrimEnd('/');
/// <summary>
@@ -37,6 +37,8 @@ public static class ApplicationServiceCollectionExtensions
services.AddSingleton<AuthService>();
services.AddSingleton<PasskeyService>();
services.AddSingleton<AuditService>();
services.AddSingleton<TrustService>();
services.AddSingleton<Fleet.Server.IOperatorTrustSource>(sp => sp.GetRequiredService<TrustService>());
services.AddSingleton<ElevationService>();
services.AddSingleton<SettingsService>();
services.AddSingleton<ReleaseService>();
@@ -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));
}
}
@@ -30,7 +30,8 @@ public sealed record AgentRecord(
bool Ipv6HostManual = false,
IReadOnlyList<string>? ReportedAddresses = null,
byte[]? TlsPublicKey = null,
string? AddedBy = null);
string? AddedBy = null,
MemberProofDto? Proof = null);
/// <summary>管理员可以修改的字段(merge-patch 语义:为 null 的字段不改)。</summary>
public sealed class AgentPatch
@@ -78,10 +79,11 @@ public sealed record JoinTokenInfo(
long? RevokedAtMs,
long CreatedAtMs,
string Kind = "",
string? CreatedBy = null);
string? CreatedBy = null,
SignedStatementDto? Grant = null);
/// <summary>
/// <see cref="PublicKey"/> 是连 Backend 的身份公钥,<see cref="PeerPublicKey"/> 是对等面(连 Agent)的公钥,两把是节点自己生成的独立密钥;
/// Agent 的 <c>peers</c> 里放的是后者。
/// </summary>
public sealed record ProbeRecord(string Id, string Name, byte[] PublicKey, long CreatedAtMs, long? RevokedAtMs, string? Ipv6DiagnosticsJson = null, byte[]? PeerPublicKey = null, string? AddedBy = null);
public sealed record ProbeRecord(string Id, string Name, byte[] PublicKey, long CreatedAtMs, long? RevokedAtMs, string? Ipv6DiagnosticsJson = null, byte[]? PeerPublicKey = null, string? AddedBy = null, MemberProofDto? Proof = null);
@@ -38,6 +38,9 @@ public interface IAuthStore
ValueTask<IReadOnlyList<PasskeyRecord>> ListPasskeysAsync(string userId, CancellationToken ct);
/// <summary>所有用户的 passkey(操作员集合从这里取)。</summary>
ValueTask<IReadOnlyList<PasskeyRecord>> ListAllPasskeysAsync(CancellationToken ct);
/// <summary>只能删自己的。</summary>
ValueTask<bool> DeletePasskeyAsync(string userId, string credentialId, CancellationToken ct);
@@ -54,7 +54,7 @@ public interface IGeoLookup
public interface IJoinTokenStore
{
ValueTask<JoinTokenInfo> CreateAsync(string id, byte[] tokenSha256, string? note, IReadOnlyList<string> labels, bool autoApprove, string kind, int maxUses, long expiresAtMs, long nowMs, string? createdBy, CancellationToken ct);
ValueTask<JoinTokenInfo> CreateAsync(string id, byte[] tokenSha256, string? note, IReadOnlyList<string> labels, bool autoApprove, string kind, int maxUses, long expiresAtMs, long nowMs, string? createdBy, SignedStatementDto? grant, CancellationToken ct);
ValueTask<IReadOnlyList<JoinTokenInfo>> ListAsync(CancellationToken ct);
@@ -144,6 +144,15 @@ public interface IEventStore
ValueTask<int> DeleteOlderThanAsync(long cutoffMs, CancellationToken ct);
}
/// <summary>操作员集合的链(passkey 签的版本,按版本号递增)。</summary>
public interface IOperatorSetStore
{
ValueTask<IReadOnlyList<SignedStatementDto>> ListAsync(CancellationToken ct);
/// <summary>追加一个版本;版本号已经有了返回 false。</summary>
ValueTask<bool> AddAsync(int version, SignedStatementDto signed, long nowMs, CancellationToken ct);
}
public interface IAuditStore
{
ValueTask AddAsync(AuditRecord record, CancellationToken ct);