feat!: genesis-pinned operator set, server identity with an encrypted control link, key-less join codes; release key continuity
ci/woodpecker/tag/gitea-release Pipeline is pending
ci/woodpecker/tag/linux-release Pipeline failed
ci/woodpecker/tag/windows-release Pipeline was canceled

Join codes need no passkey touch and carry only public values (genesis
fingerprint, owner credentials, installer hashes). Nodes pin the genesis and
owners at enrollment and only follow passkey-signed operator set versions.
The panel's server key is listed in the set; pinned nodes require its
signature and run the control link under AES-256-GCM. Password sessions carry
no capabilities. The updater adopts a next_key from a signed manifest.

Bump version to 0.2.0. [CI SKIP]
This commit is contained in:
justme committed 2026-10-08 03:21:44 +08:00
1 parent 9bb6841dce
commit a2f88a344b
89 files changed
+2145 -1195

No files matched your search

@@ -10,6 +10,12 @@ public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList<string>?
/// <summary>各个系统上的一行安装命令:Linux 用 bash、macOS 用 zsh、Windows 用 PowerShell。命令里只有一次性代码,节点名字默认取主机名;Agent 和 Probe 是同一份脚本,角色由代码决定。</summary>
public sealed record InstallCommands(string Linux, string Macos, string Windows);
/// <summary>
/// 安装命令里除了一次性的码,还带的公开值:信任根的创世指纹(节点把它固定下来)、属主(允许授权这个节点 extension 的 passkey 凭证 id)、
/// 和面板提供的静态安装脚本的 SHA-256(命令先核对哈希再执行)。没有建立信任根时 <c>Genesis</c> 为空,节点不固定信任根。
/// </summary>
public sealed record InstallPlan(string? Genesis, IReadOnlyList<string> Controllers, string ScriptShSha256, string ScriptPs1Sha256);
public sealed record CreatedJoinToken(JoinTokenInfo Info, string Plaintext, InstallCommands InstallCommands);
/// <summary>安装脚本里要填的四个值。</summary>
@@ -27,19 +33,17 @@ 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, TrustService trust, BackendOptions options, TimeProvider time)
public JoinTokenService(IJoinTokenStore store, BackendOptions options, TimeProvider time)
{
_store = store;
_trust = trust;
_options = options;
_time = time;
}
public async ValueTask<Result<CreatedJoinToken>> CreateAsync(CreateJoinTokenRequest request, string? requestBaseUrl, CancellationToken ct)
public async ValueTask<Result<CreatedJoinToken>> CreateAsync(CreateJoinTokenRequest request, string? requestBaseUrl, InstallPlan plan, CancellationToken ct)
{
var errors = new Dictionary<string, string>();
if (request.Role is not (AgentRole or ProbeRole))
@@ -47,9 +51,9 @@ public sealed class JoinTokenService
errors["role"] = "must be agent or probe";
}
if (request.MaxUses is < 1 or > 1000)
if (request.MaxUses is < 1 or > 100_000)
{
errors["max_uses"] = "must be between 1 and 1000";
errors["max_uses"] = "must be between 1 and 100000";
}
if (request.ExpiresInHours is < 1 or > 24 * 365)
@@ -76,79 +80,25 @@ 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, null, ct);
request.MaxUses, now + (request.ExpiresInHours * 3_600_000L), now, request.CreatedBy, ct);
string baseUrl = BaseUrl(requestBaseUrl);
string query = $"{baseUrl}/install?code={plaintext}";
return Result<CreatedJoinToken>.Ok(new CreatedJoinToken(info, plaintext, new InstallCommands(
$"curl -fsSL '{query}' | bash",
$"curl -fsSL '{query}' | zsh",
$"irm '{baseUrl}/install.ps1?code={plaintext}' | iex")));
return Result<CreatedJoinToken>.Ok(new CreatedJoinToken(info, plaintext, Commands(BaseUrl(requestBaseUrl), request.Role, plaintext, plan)));
}
/// <summary>
/// 用操作员 passkey 签的委托创建加入码:码本身(认证串)由浏览器用种子派生,面板只拿到它的哈希,推不出种子,
/// 所以面板自己没法为这个码签绑定。有效期、角色、次数都以签名的语句为准。
/// 一行安装命令:先下载静态脚本、核对 SHA-256,一致才执行;面板地址、角色、一次性码、信任根指纹和属主都走环境变量。
/// 里面没有任何私钥,码泄露最多让别人注册出一个节点。
/// </summary>
public async ValueTask<Result<JoinTokenInfo>> CreateSignedAsync(SignedStatementDto signedGrant, string? note, IReadOnlyList<string>? labels, bool autoApprove, string? createdBy, CancellationToken ct)
public static InstallCommands Commands(string baseUrl, string role, string code, InstallPlan plan)
{
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));
string pin = plan.Genesis is null ? "" : $" MOSHIRO_GENESIS='{plan.Genesis}' MOSHIRO_CONTROLLERS='{string.Join(',', plan.Controllers)}'";
string env = $"MOSHIRO_SERVER='{baseUrl}' MOSHIRO_ROLE='{role}' MOSHIRO_CODE='{code}'{pin}";
string psPin = plan.Genesis is null ? "" : $"$env:MOSHIRO_GENESIS='{plan.Genesis}'; $env:MOSHIRO_CONTROLLERS='{string.Join(',', plan.Controllers)}'; ";
return new InstallCommands(
$"f=$(mktemp) && curl -fsSL '{baseUrl}/install' -o \"$f\" && echo \"{plan.ScriptShSha256} $f\" | sha256sum -c - >/dev/null && {env} sh \"$f\"",
$"f=$(mktemp) && curl -fsSL '{baseUrl}/install' -o \"$f\" && echo \"{plan.ScriptShSha256} $f\" | shasum -a 256 -c - >/dev/null && {env} sh \"$f\"",
$"$f = Join-Path $env:TEMP 'moshiro-install.ps1'; irm '{baseUrl}/install.ps1' -OutFile $f; if ((Get-FileHash $f -Algorithm SHA256).Hash -ne '{plan.ScriptPs1Sha256}') {{ throw 'the install script does not match its published hash' }}; "
+ $"$env:MOSHIRO_SERVER='{baseUrl}'; $env:MOSHIRO_ROLE='{role}'; $env:MOSHIRO_CODE='{code}'; {psPin}iex (Get-Content -Raw $f)");
}
private string BaseUrl(string? requestBaseUrl) => (_options.PublicUrl ?? requestBaseUrl ?? "https://panel.example.com").TrimEnd('/');