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

@@ -33,6 +33,12 @@ public sealed class ElevationTests
return client;
}
private async Task<(HttpClient, SoftwarePasskey)> StrongAsync()
{
(HttpClient client, SoftwarePasskey passkey, _) = await _backend.CreateStrongSessionAsync("elev");
return (client, passkey);
}
private async Task<SoftwarePasskey> RegisterAsync(HttpClient session, string name = "key")
{
var passkey = new SoftwarePasskey(RpId, Origin);
@@ -57,57 +63,61 @@ public sealed class ElevationTests
private static async Task<string?> ProblemCodeAsync(HttpResponseMessage response) => JsonNode.Parse(await response.Content.ReadAsStringAsync())?["code"]?.GetValue<string>();
private static StringContent NewJoinCode() => BackendFixture.Json(new JsonObject { ["role"] = "agent" });
/// <summary>需要「吊销」窗口的操作(删除节点):有窗口时返回 204(删除是幂等的),没有窗口返回 403 elevation_required。</summary>
private static Task<HttpResponseMessage> Gated(HttpClient client) => client.DeleteAsync("/api/admin/agents/agt_nobody");
[Fact]
public async Task EnrollingNeedsAWindow_ThatOnlyAPasskeyTouchOpens()
{
using HttpClient session = await NewSessionAsync();
using SoftwarePasskey passkey = await RegisterAsync(session);
(HttpClient session, SoftwarePasskey passkey) = await StrongAsync();
using HttpClient sessionScope = session;
using SoftwarePasskey passkeyScope = passkey;
using HttpResponseMessage denied = await session.PostAsync("/api/admin/join-tokens", NewJoinCode());
using HttpResponseMessage denied = await Gated(session);
Assert.Equal(HttpStatusCode.Forbidden, denied.StatusCode);
Assert.Equal("elevation_required", await ProblemCodeAsync(denied));
Assert.Contains("enroll", await denied.Content.ReadAsStringAsync(), StringComparison.Ordinal);
Assert.Contains("revoke", await denied.Content.ReadAsStringAsync(), StringComparison.Ordinal);
using HttpResponseMessage elevated = await ElevateAsync(session, passkey, "enroll");
using HttpResponseMessage elevated = await ElevateAsync(session, passkey, "revoke");
Assert.Equal(HttpStatusCode.OK, elevated.StatusCode);
Assert.Equal(HttpStatusCode.Created, (await session.PostAsync("/api/admin/join-tokens", NewJoinCode())).StatusCode);
Assert.Equal(HttpStatusCode.Created, (await session.PostAsync("/api/admin/join-tokens", NewJoinCode())).StatusCode); // 窗口内不用再触碰
Assert.Equal(HttpStatusCode.NoContent, (await Gated(session)).StatusCode); // 有窗口:到了业务层(删除是幂等的)
Assert.Equal(HttpStatusCode.NoContent, (await Gated(session)).StatusCode); // 窗口内不用再触碰
// 窗口只管它那一种
using HttpResponseMessage revoke = await session.DeleteAsync("/api/admin/agents/agt_nobody");
Assert.Equal("elevation_required", await ProblemCodeAsync(revoke));
using HttpResponseMessage credential = await session.PostAsync("/api/admin/users", BackendFixture.Json(new JsonObject { ["username"] = _backend.UniqueName("x"), ["password"] = Password }));
Assert.Equal("elevation_required", await ProblemCodeAsync(credential));
}
[Fact]
public async Task ApiKeys_CarryTheirCapabilities_AndSkipTheWindow()
{
using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/join-tokens", NewJoinCode());
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
using HttpResponseMessage allowed = await Gated(_backend.Admin);
Assert.Equal(HttpStatusCode.NoContent, allowed.StatusCode); // API key 自带能力,不经过提权窗口
}
[Fact]
public async Task TheWindowCanBeListed_AndEndedEarly()
{
using HttpClient session = await NewSessionAsync();
using SoftwarePasskey passkey = await RegisterAsync(session);
(HttpClient session, SoftwarePasskey passkey) = await StrongAsync();
using HttpClient sessionScope = session;
using SoftwarePasskey passkeyScope = passkey;
Assert.Empty(JsonNode.Parse(await session.GetStringAsync("/api/admin/auth/elevation"))!["items"]!.AsArray());
(await ElevateAsync(session, passkey, "enroll")).Dispose();
(await ElevateAsync(session, passkey, "revoke")).Dispose();
JsonArray open = JsonNode.Parse(await session.GetStringAsync("/api/admin/auth/elevation"))!["items"]!.AsArray();
Assert.Equal("enroll", Assert.Single(open)!["class"]!.GetValue<string>());
Assert.Equal("revoke", Assert.Single(open)!["class"]!.GetValue<string>());
Assert.Equal(HttpStatusCode.NoContent, (await session.DeleteAsync("/api/admin/auth/elevation")).StatusCode);
Assert.Empty(JsonNode.Parse(await session.GetStringAsync("/api/admin/auth/elevation"))!["items"]!.AsArray());
Assert.Equal(HttpStatusCode.Forbidden, (await session.PostAsync("/api/admin/join-tokens", NewJoinCode())).StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await Gated(session)).StatusCode);
}
[Fact]
public async Task ACredentialWindow_IsSingleUse()
{
using HttpClient session = await NewSessionAsync();
using SoftwarePasskey passkey = await RegisterAsync(session);
(HttpClient session, SoftwarePasskey passkey) = await StrongAsync();
using HttpClient sessionScope = session;
using SoftwarePasskey passkeyScope = passkey;
(await ElevateAsync(session, passkey, "credential")).Dispose();
using HttpResponseMessage first = await session.PostAsync("/api/admin/users", BackendFixture.Json(new JsonObject { ["username"] = _backend.UniqueName("made"), ["password"] = Password }));
@@ -119,10 +129,8 @@ public sealed class ElevationTests
[Fact]
public async Task OnlyYourOwnPasskey_WithTheRightChallengeAndKind_OpensAWindow()
{
using HttpClient mine = await NewSessionAsync();
using SoftwarePasskey myPasskey = await RegisterAsync(mine);
using HttpClient theirs = await NewSessionAsync();
using SoftwarePasskey theirPasskey = await RegisterAsync(theirs);
(HttpClient mine, SoftwarePasskey myPasskey) = await StrongAsync();
(HttpClient theirs, SoftwarePasskey theirPasskey) = await StrongAsync();
// 别人的 passkey 签了我的挑战:不行(也不在 allow_credentials 里)
using HttpResponseMessage options = await mine.PostAsync("/api/admin/auth/elevation/options", BackendFixture.Json(new JsonObject { ["class"] = "enroll" }));
@@ -144,7 +152,7 @@ public sealed class ElevationTests
Assert.Equal(HttpStatusCode.Unauthorized, (await mine.PostAsync("/api/admin/auth/elevation", BackendFixture.Json(once))).StatusCode);
// 另一个用户的会话没有因此提权
Assert.Equal(HttpStatusCode.Forbidden, (await theirs.PostAsync("/api/admin/join-tokens", NewJoinCode())).StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await Gated(theirs)).StatusCode);
}
[Fact]
@@ -169,9 +177,10 @@ public sealed class ElevationTests
[Fact]
public async Task AKeyThatCarriesTrust_NeedsAPasskeySignatureToCreate()
{
using HttpClient session = await NewSessionAsync();
using SoftwarePasskey passkey = await RegisterAsync(session);
var trusted = new JsonObject { ["name"] = _backend.UniqueName("ci"), ["capabilities"] = new JsonArray("fleet.enroll") };
(HttpClient session, SoftwarePasskey passkey) = await StrongAsync();
using HttpClient sessionScope = session;
using SoftwarePasskey passkeyScope = passkey;
var trusted = new JsonObject { ["name"] = _backend.UniqueName("ci"), ["capabilities"] = new JsonArray("fleet.revoke") };
var harmless = new JsonObject { ["name"] = _backend.UniqueName("ro"), ["capabilities"] = new JsonArray("fleet.read") };
Assert.Equal(HttpStatusCode.Created, (await session.PostAsync("/api/admin/api-tokens", BackendFixture.Json(harmless))).StatusCode);
@@ -184,7 +193,9 @@ public sealed class ElevationTests
[Fact]
public async Task ListingUsers_NeedsOnlyTheReadCapability()
{
using HttpClient session = await NewSessionAsync();
(HttpClient session, SoftwarePasskey passkey) = await StrongAsync();
using HttpClient sessionScope = session;
using SoftwarePasskey passkeyScope = passkey;
Assert.Equal(HttpStatusCode.OK, (await session.GetAsync("/api/admin/users")).StatusCode); // 看列表不用提权
}
}