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

@@ -135,6 +135,46 @@ public class BackendFixture : IAsyncLifetime
Admin.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _adminToken);
}
/// <summary>新建一个用户(管理员用 API key 建),用密码登录一次注册好 passkey。之后用 <see cref="PasskeyLoginAsync"/> 登录才有能力。</summary>
public async Task<(string Username, SoftwarePasskey Passkey)> CreateUserWithPasskeyAsync(string prefix = "user")
{
string name = UniqueName(prefix);
using HttpResponseMessage created = await Admin.PostAsync("/api/admin/users", Json(new JsonObject { ["username"] = name, ["password"] = "correct horse battery" }));
created.EnsureSuccessStatusCode();
using HttpClient session = NewClient();
using HttpResponseMessage login = await session.PostAsync("/api/admin/auth/login", Json(new JsonObject { ["username"] = name, ["password"] = "correct horse battery", ["return_session"] = true }));
login.EnsureSuccessStatusCode();
session.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", JsonNode.Parse(await login.Content.ReadAsStringAsync())!["session_token"]!.GetValue<string>());
var passkey = new SoftwarePasskey(BaseAddress.Host, BaseAddress.GetLeftPart(UriPartial.Authority));
using HttpResponseMessage options = await session.PostAsync("/api/admin/auth/passkeys/options", null);
options.EnsureSuccessStatusCode();
string challenge = JsonNode.Parse(await options.Content.ReadAsStringAsync())!["challenge"]!.GetValue<string>();
using HttpResponseMessage registered = await session.PostAsync("/api/admin/auth/passkeys", Json(passkey.Register("key", challenge)));
registered.EnsureSuccessStatusCode();
return (name, passkey);
}
/// <summary>用 passkey 登录(可发现凭证,不用用户名);<paramref name="returnSession"/> 为真时不设 Cookie、把会话令牌放在响应里。</summary>
public async Task<HttpResponseMessage> PasskeyLoginAsync(HttpClient client, SoftwarePasskey passkey, bool returnSession = true)
{
using HttpResponseMessage options = await client.PostAsync("/api/admin/auth/passkey/options", null);
options.EnsureSuccessStatusCode();
JsonObject assertion = passkey.Assert(JsonNode.Parse(await options.Content.ReadAsStringAsync())!["challenge"]!.GetValue<string>());
assertion["return_session"] = returnSession;
return await client.PostAsync("/api/admin/auth/passkey/login", Json(assertion));
}
/// <summary>有能力的会话:新用户加 passkey,用 passkey 登录,会话令牌走 Bearer。</summary>
public async Task<(HttpClient Client, SoftwarePasskey Passkey, string Username)> CreateStrongSessionAsync(string prefix = "strong")
{
(string name, SoftwarePasskey passkey) = await CreateUserWithPasskeyAsync(prefix);
HttpClient client = NewClient();
using HttpResponseMessage login = await PasskeyLoginAsync(client, passkey);
login.EnsureSuccessStatusCode();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", JsonNode.Parse(await login.Content.ReadAsStringAsync())!["session_token"]!.GetValue<string>());
return (client, passkey, name);
}
public HttpClient NewClient() => new() { BaseAddress = BaseAddress };
public string UniqueName(string prefix) => $"{prefix}-{Interlocked.Increment(ref _counter)}";
@@ -233,45 +273,49 @@ public class BackendFixture : IAsyncLifetime
await App.DisposeAsync();
}
/// <summary>
/// 管理员的全能力 API key:和线上一样的路径——密码登录的会话没有任何能力,只能注册 passkey;
/// 注册之后用 passkey 登录才有能力,再触碰一次开凭证窗口,才能创建带信任类能力的 key。
/// </summary>
private async Task<string> CreateAdminTokenAsync()
{
using HttpClient session = NewClient();
using HttpResponseMessage login = await session.PostAsync(
"/api/admin/auth/login", Json(new JsonObject { ["username"] = AdminUser, ["password"] = AdminPassword }));
"/api/admin/auth/login", Json(new JsonObject { ["username"] = AdminUser, ["password"] = AdminPassword, ["return_session"] = true }));
login.EnsureSuccessStatusCode();
JsonNode body = JsonNode.Parse(await login.Content.ReadAsStringAsync())!;
string cookie = login.Headers.GetValues("Set-Cookie").First().Split(';')[0];
// 带信任类能力的 API key 要用 passkey 签一次才能创建(和线上一样):给管理员注册一个 passkey,触碰一次再创建
string csrf = body["csrf_token"]!.GetValue<string>();
async Task<HttpResponseMessage> SendAsync(string path, JsonObject? content)
{
using var request = new HttpRequestMessage(HttpMethod.Post, path) { Content = content is null ? null : Json(content) };
request.Headers.Add("Cookie", cookie);
request.Headers.Add("X-CSRF-Token", csrf);
return await session.SendAsync(request);
}
session.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", JsonNode.Parse(await login.Content.ReadAsStringAsync())!["session_token"]!.GetValue<string>());
string origin = BaseAddress.GetLeftPart(UriPartial.Authority);
using var passkey = new SoftwarePasskey(BaseAddress.Host, origin);
using (HttpResponseMessage options = await SendAsync("/api/admin/auth/passkeys/options", null))
using (HttpResponseMessage options = await session.PostAsync("/api/admin/auth/passkeys/options", null))
{
options.EnsureSuccessStatusCode();
string challenge = JsonNode.Parse(await options.Content.ReadAsStringAsync())!["challenge"]!.GetValue<string>();
using HttpResponseMessage registered = await SendAsync("/api/admin/auth/passkeys", passkey.Register("fixture", challenge));
using HttpResponseMessage registered = await session.PostAsync("/api/admin/auth/passkeys", Json(passkey.Register("fixture", challenge)));
registered.EnsureSuccessStatusCode();
}
using (HttpResponseMessage options = await SendAsync("/api/admin/auth/elevation/options", new JsonObject { ["class"] = "credential" }))
// passkey 登录:拿到有能力的会话(令牌走 Bearer)
using HttpClient strong = NewClient();
using (HttpResponseMessage options = await strong.PostAsync("/api/admin/auth/passkey/options", null))
{
options.EnsureSuccessStatusCode();
JsonObject assertion = passkey.Assert(JsonNode.Parse(await options.Content.ReadAsStringAsync())!["challenge"]!.GetValue<string>());
using HttpResponseMessage signedIn = await strong.PostAsync("/api/admin/auth/passkey/login", Json(assertion));
signedIn.EnsureSuccessStatusCode();
strong.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", JsonNode.Parse(await signedIn.Content.ReadAsStringAsync())!["session_token"]!.GetValue<string>());
}
using (HttpResponseMessage options = await strong.PostAsync("/api/admin/auth/elevation/options", Json(new JsonObject { ["class"] = "credential" })))
{
options.EnsureSuccessStatusCode();
JsonObject assertion = passkey.Assert(JsonNode.Parse(await options.Content.ReadAsStringAsync())!["challenge"]!.GetValue<string>());
assertion["class"] = "credential";
using HttpResponseMessage elevated = await SendAsync("/api/admin/auth/elevation", assertion);
using HttpResponseMessage elevated = await strong.PostAsync("/api/admin/auth/elevation", Json(assertion));
elevated.EnsureSuccessStatusCode();
}
using HttpResponseMessage created = await SendAsync("/api/admin/api-tokens", new JsonObject { ["name"] = "tests", ["capabilities"] = AllCapabilities() });
using HttpResponseMessage created = await strong.PostAsync("/api/admin/api-tokens", Json(new JsonObject { ["name"] = "tests", ["capabilities"] = AllCapabilities() }));
created.EnsureSuccessStatusCode();
return JsonNode.Parse(await created.Content.ReadAsStringAsync())!["plaintext"]!.GetValue<string>();
}