feat!: drop the backend-side elevation windows; password and passkey sessions share capabilities, passkeys guard what nodes verify; the trust page lists added and removed passkeys [CI]

This commit is contained in:
justme committed 2026-10-08 04:57:28 +08:00
1 parent f704c66dd9
commit 4106f831dc
34 files changed
+191 -1364

No files matched your search

@@ -43,11 +43,10 @@ public sealed class TrustTests
return existing;
}
// 操作员:有 passkey、用 passkey 登录的会话(密码会话什么能力都没有)
// 操作员:有 passkey、用 passkey 登录的会话
(HttpClient session, SoftwarePasskey passkey, _) = await _backend.CreateStrongSessionAsync("operator");
// 创世要凭证窗口(立根的人要先证明自己),再用 passkey 签集合本身
await ElevateAsync(session, passkey);
// 创世:用 passkey 对集合本身签名(节点据此验链)
JsonNode state = JsonNode.Parse(await session.GetStringAsync("/api/admin/trust"))!;
Assert.False(state["initialized"]!.GetValue<bool>());
string statement = state["proposal"]!["statement"]!.GetValue<string>();
@@ -83,15 +82,6 @@ public sealed class TrustTests
};
}
private static async Task ElevateAsync(HttpClient session, SoftwarePasskey passkey)
{
using HttpResponseMessage options = await session.PostAsync("/api/admin/auth/elevation/options", BackendFixture.Json(new JsonObject { ["class"] = "credential" }));
JsonObject assertion = passkey.Assert(JsonNode.Parse(await options.Content.ReadAsStringAsync())!["challenge"]!.GetValue<string>());
assertion["class"] = "credential";
using HttpResponseMessage elevated = await session.PostAsync("/api/admin/auth/elevation", BackendFixture.Json(assertion));
Assert.Equal(HttpStatusCode.OK, elevated.StatusCode);
}
/// <summary>创建一个加入码(普通 API key 就行,不需要 passkey),返回响应里的码和三条安装命令。</summary>
private async Task<(string Code, JsonNode Commands, HttpStatusCode Status)> CreateCodeAsync(string role = "probe", string[]? controllers = null)
{
@@ -235,9 +225,9 @@ public sealed class TrustTests
}
[Fact]
public async Task APasswordSession_CanOnlyRegisterAPasskey_AndCreatingACodeNeedsNoPasskeyTouch()
public async Task APasswordSession_ManagesAsUsual_AndCreatingACodeNeedsNoPasskey()
{
// 密码登录的会话没有任何能力:连看节点列表都不行,只能注册 passkey
// 密码登录保留:日常管理(看列表、创建加入码)不需要 passkey;passkey 只用在节点自己会验证的事上(操作员集合的签名)
string name = _backend.UniqueName("pwonly");
using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/users", BackendFixture.Json(new JsonObject { ["username"] = name, ["password"] = "correct horse battery" }));
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
@@ -245,14 +235,14 @@ public sealed class TrustTests
using HttpResponseMessage login = await password.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = name, ["password"] = "correct horse battery", ["return_session"] = true }));
password.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", JsonNode.Parse(await login.Content.ReadAsStringAsync())!["session_token"]!.GetValue<string>());
JsonNode me = JsonNode.Parse(await password.GetStringAsync("/api/admin/auth/me"))!;
Assert.Empty(me["capabilities"]!.AsArray());
Assert.NotEmpty(me["capabilities"]!.AsArray());
Assert.Equal("password", me["auth_method"]!.GetValue<string>());
Assert.Equal(HttpStatusCode.Forbidden, (await password.GetAsync("/api/admin/agents")).StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await password.PostAsync("/api/admin/join-tokens", BackendFixture.Json(new JsonObject { ["role"] = "agent" }))).StatusCode);
Assert.Equal(HttpStatusCode.OK, (await password.GetAsync("/api/admin/agents")).StatusCode);
Assert.Equal(HttpStatusCode.Created, (await password.PostAsync("/api/admin/join-tokens", BackendFixture.Json(new JsonObject { ["role"] = "agent" }))).StatusCode);
using HttpResponseMessage options = await password.PostAsync("/api/admin/auth/passkeys/options", null);
Assert.Equal(HttpStatusCode.OK, options.StatusCode);
// passkey 登录的会话创建码:不需要提权窗口、不需要再触碰(创建码不是信任动作,信任在属主固定里)
// passkey 登录的会话创建码:同样不需要再触碰(创建码不是信任动作,信任在属主固定里)
(HttpClient strong, SoftwarePasskey passkey, _) = await _backend.CreateStrongSessionAsync("creator");
using HttpClient strongScope = strong;
using SoftwarePasskey passkeyScope = passkey;
@@ -260,6 +250,24 @@ public sealed class TrustTests
Assert.Equal(HttpStatusCode.Created, code.StatusCode);
}
[Fact]
public async Task TheProposal_ListsEveryNewPasskey_SoTheSignerSeesWhatTheyAreAuthorizing()
{
// 往账号里塞一把 passkey 再等人签名是这个流程唯一的风险:提案要明确列出新增的凭证
Operator op = await OperatorAsync();
(HttpClient other, SoftwarePasskey added, _) = await _backend.CreateStrongSessionAsync("added");
using HttpClient otherScope = other;
using SoftwarePasskey addedScope = added;
JsonNode proposal = JsonNode.Parse(await op.Session.GetStringAsync("/api/admin/trust"))!["proposal"]!;
JsonArray list = proposal["added"]!.AsArray();
Assert.Contains(list, a => a!["id"]!.GetValue<string>() == added.Id);
Assert.All(list, a => Assert.False(string.IsNullOrWhiteSpace(a!["name"]!.GetValue<string>())));
Assert.DoesNotContain(list, a => a!["id"]!.GetValue<string>() == op.Passkey.Id);
Assert.Empty(proposal["removed"]!.AsArray());
Assert.False(proposal["server_key_changed"]!.GetValue<bool>());
}
[Fact]
public async Task TheServerKey_IsSignedIntoTheOperatorSet_AndOnlyThatServerPassesTheNodesCheck()
{