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>