Files
MoShiro/backend/tests/MoShiro.Backend.Tests/ElevationTests.cs
T
justme a2f88a344b
ci/woodpecker/tag/gitea-release Pipeline is pending
ci/woodpecker/tag/linux-release Pipeline failed
ci/woodpecker/tag/windows-release Pipeline was canceled
feat!: genesis-pinned operator set, server identity with an encrypted control link, key-less join codes; release key continuity
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]
2026-10-08 03:21:44 +08:00

202 lines
12 KiB
C#

using System.Net;
using System.Net.Http.Headers;
using System.Text.Json.Nodes;
namespace MoShiro.Backend.Tests;
/// <summary>提权窗口:信任类操作(加节点、吊销、改凭证)要在登录之外再触碰一次 passkey;API key 自带能力不受影响。设计见 docs/access.md 第 4 节。</summary>
[Collection("probe-e2e")]
public sealed class ElevationTests
{
private const string Password = "correct horse battery";
private readonly BackendFixture _backend;
public ElevationTests(BackendFixture backend)
{
_backend = backend;
}
private string Origin => _backend.BaseAddress.GetLeftPart(UriPartial.Authority);
private string RpId => _backend.BaseAddress.Host;
/// <summary>新建一个用户并用密码登录(会话令牌走 Bearer)。每个测试用自己的用户,互不影响。</summary>
private async Task<HttpClient> NewSessionAsync()
{
string name = _backend.UniqueName("elev");
using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/users", BackendFixture.Json(new JsonObject { ["username"] = name, ["password"] = Password }));
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
HttpClient client = _backend.NewClient();
using HttpResponseMessage login = await client.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = name, ["password"] = Password, ["return_session"] = true }));
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", JsonNode.Parse(await login.Content.ReadAsStringAsync())!["session_token"]!.GetValue<string>());
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);
using HttpResponseMessage options = await session.PostAsync("/api/admin/auth/passkeys/options", null);
Assert.True(options.IsSuccessStatusCode, await options.Content.ReadAsStringAsync());
string challenge = JsonNode.Parse(await options.Content.ReadAsStringAsync())!["challenge"]!.GetValue<string>();
using HttpResponseMessage registered = await session.PostAsync("/api/admin/auth/passkeys", BackendFixture.Json(passkey.Register(name, challenge)));
Assert.True(registered.StatusCode == HttpStatusCode.Created, await registered.Content.ReadAsStringAsync());
return passkey;
}
private async Task<HttpResponseMessage> ElevateAsync(HttpClient session, SoftwarePasskey passkey, string cls, string? optionsFor = null)
{
using HttpResponseMessage options = await session.PostAsync("/api/admin/auth/elevation/options", BackendFixture.Json(new JsonObject { ["class"] = optionsFor ?? cls }));
Assert.True(options.IsSuccessStatusCode, await options.Content.ReadAsStringAsync());
JsonNode body = JsonNode.Parse(await options.Content.ReadAsStringAsync())!;
Assert.Contains(passkey.Id, body["allow_credentials"]!.AsArray().Select(x => x!.GetValue<string>())); // 浏览器只会让用户选自己的 passkey
JsonObject assertion = passkey.Assert(body["challenge"]!.GetValue<string>());
assertion["class"] = cls;
return await session.PostAsync("/api/admin/auth/elevation", BackendFixture.Json(assertion));
}
private static async Task<string?> ProblemCodeAsync(HttpResponseMessage response) => JsonNode.Parse(await response.Content.ReadAsStringAsync())?["code"]?.GetValue<string>();
/// <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()
{
(HttpClient session, SoftwarePasskey passkey) = await StrongAsync();
using HttpClient sessionScope = session;
using SoftwarePasskey passkeyScope = passkey;
using HttpResponseMessage denied = await Gated(session);
Assert.Equal(HttpStatusCode.Forbidden, denied.StatusCode);
Assert.Equal("elevation_required", await ProblemCodeAsync(denied));
Assert.Contains("revoke", await denied.Content.ReadAsStringAsync(), StringComparison.Ordinal);
using HttpResponseMessage elevated = await ElevateAsync(session, passkey, "revoke");
Assert.Equal(HttpStatusCode.OK, elevated.StatusCode);
Assert.Equal(HttpStatusCode.NoContent, (await Gated(session)).StatusCode); // 有窗口:到了业务层(删除是幂等的)
Assert.Equal(HttpStatusCode.NoContent, (await Gated(session)).StatusCode); // 窗口内不用再触碰
// 窗口只管它那一种
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 allowed = await Gated(_backend.Admin);
Assert.Equal(HttpStatusCode.NoContent, allowed.StatusCode); // API key 自带能力,不经过提权窗口
}
[Fact]
public async Task TheWindowCanBeListed_AndEndedEarly()
{
(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, "revoke")).Dispose();
JsonArray open = JsonNode.Parse(await session.GetStringAsync("/api/admin/auth/elevation"))!["items"]!.AsArray();
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 Gated(session)).StatusCode);
}
[Fact]
public async Task ACredentialWindow_IsSingleUse()
{
(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 }));
Assert.Equal(HttpStatusCode.Created, first.StatusCode);
using HttpResponseMessage second = await session.PostAsync("/api/admin/users", BackendFixture.Json(new JsonObject { ["username"] = _backend.UniqueName("made"), ["password"] = Password }));
Assert.Equal("elevation_required", await ProblemCodeAsync(second));
}
[Fact]
public async Task OnlyYourOwnPasskey_WithTheRightChallengeAndKind_OpensAWindow()
{
(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" }));
string challenge = JsonNode.Parse(await options.Content.ReadAsStringAsync())!["challenge"]!.GetValue<string>();
JsonObject foreign = theirPasskey.Assert(challenge);
foreign["class"] = "enroll";
Assert.Equal(HttpStatusCode.Unauthorized, (await mine.PostAsync("/api/admin/auth/elevation", BackendFixture.Json(foreign))).StatusCode);
// 挑战是给 revoke 要的,提交成 enroll:不行
using HttpResponseMessage wrongKind = await ElevateAsync(mine, myPasskey, "enroll", optionsFor: "revoke");
Assert.Equal(HttpStatusCode.Unauthorized, wrongKind.StatusCode);
// 挑战只能用一次
using HttpResponseMessage options2 = await mine.PostAsync("/api/admin/auth/elevation/options", BackendFixture.Json(new JsonObject { ["class"] = "enroll" }));
string challenge2 = JsonNode.Parse(await options2.Content.ReadAsStringAsync())!["challenge"]!.GetValue<string>();
JsonObject once = myPasskey.Assert(challenge2);
once["class"] = "enroll";
Assert.Equal(HttpStatusCode.OK, (await mine.PostAsync("/api/admin/auth/elevation", BackendFixture.Json(once))).StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized, (await mine.PostAsync("/api/admin/auth/elevation", BackendFixture.Json(once))).StatusCode);
// 另一个用户的会话没有因此提权
Assert.Equal(HttpStatusCode.Forbidden, (await Gated(theirs)).StatusCode);
}
[Fact]
public async Task WithoutAPasskey_ThereIsNothingToTouch_AndTheFirstPasskeyNeedsNoWindow()
{
using HttpClient session = await NewSessionAsync();
using HttpResponseMessage none = await session.PostAsync("/api/admin/auth/elevation/options", BackendFixture.Json(new JsonObject { ["class"] = "enroll" }));
Assert.Equal(HttpStatusCode.BadRequest, none.StatusCode);
using SoftwarePasskey first = await RegisterAsync(session, "first"); // 第一个:不要求提权
using HttpResponseMessage second = await session.PostAsync("/api/admin/auth/passkeys/options", null);
Assert.Equal("elevation_required", await ProblemCodeAsync(second)); // 第二个起要先用已有的触碰
(await ElevateAsync(session, first, "credential")).Dispose();
using SoftwarePasskey added = await RegisterAsync(session, "second");
// 删除也要窗口
(await ElevateAsync(session, first, "credential")).Dispose();
Assert.Equal(HttpStatusCode.NoContent, (await session.DeleteAsync("/api/admin/auth/passkeys/" + added.Id)).StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await session.DeleteAsync("/api/admin/auth/passkeys/" + first.Id)).StatusCode);
}
[Fact]
public async Task AKeyThatCarriesTrust_NeedsAPasskeySignatureToCreate()
{
(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);
using HttpResponseMessage denied = await session.PostAsync("/api/admin/api-tokens", BackendFixture.Json(trusted));
Assert.Equal("elevation_required", await ProblemCodeAsync(denied));
(await ElevateAsync(session, passkey, "credential")).Dispose();
Assert.Equal(HttpStatusCode.Created, (await session.PostAsync("/api/admin/api-tokens", BackendFixture.Json(trusted))).StatusCode);
}
[Fact]
public async Task ListingUsers_NeedsOnlyTheReadCapability()
{
(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); // 看列表不用提权
}
}