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]
This commit is contained in:
1 parent
9bb6841dce
commit
a2f88a344b
89 files changed
+2145
-1195
No files matched your search
@@ -41,14 +41,14 @@ public sealed class AgentHarness : IAsyncDisposable
|
||||
|
||||
public byte[] TlsPublicKey => _identity.Certificate.PublicKey.ExportSubjectPublicKeyInfo();
|
||||
|
||||
/// <param name="signed">操作员签过委托的加入码(认证串、种子、创世指纹):节点按固定了创世的方式注册,名册里的 Probe 必须带操作员批准的证明。</param>
|
||||
public static async Task<AgentHarness> StartAsync(BackendFixture backend, Action<AgentConfig>? configure = null, string? name = null, (string Auth, string Seed, string Genesis)? signed = null)
|
||||
/// <param name="pin">带信任根固定的加入码(码、创世指纹、属主):节点按固定了创世和属主的方式注册。</param>
|
||||
public static async Task<AgentHarness> StartAsync(BackendFixture backend, Action<AgentConfig>? configure = null, string? name = null, (string Code, string Genesis, string[] Controllers)? pin = null)
|
||||
{
|
||||
string directory = Path.Combine(Path.GetTempPath(), "moshiro-agent-" + Guid.NewGuid().ToString("N"));
|
||||
string token = signed?.Auth ?? await backend.CreateJoinTokenAsync();
|
||||
string token = pin?.Code ?? await backend.CreateJoinTokenAsync();
|
||||
using var http = new HttpClient();
|
||||
Result<EnrollmentResult> enrolled = await EnrollmentClient.EnrollAsync(
|
||||
http, backend.BaseAddress, token, name ?? backend.UniqueName("agent"), directory, AgentApp.Version, null, TimeProvider.System, CancellationToken.None, signed?.Seed, signed?.Genesis, "agent");
|
||||
http, backend.BaseAddress, token, name ?? backend.UniqueName("agent"), directory, AgentApp.Version, null, TimeProvider.System, CancellationToken.None, pin?.Genesis, pin?.Controllers);
|
||||
Assert.True(enrolled.IsSuccess, enrolled.IsSuccess ? "" : enrolled.Error.ToString());
|
||||
|
||||
// 测试里的 Agent 只在回环上监听:手填 public_host(手动的优先于自动识别,Agent 上报本机真实地址也不会覆盖)
|
||||
|
||||
@@ -61,8 +61,11 @@ public sealed class ApiBasicsTests
|
||||
[Fact]
|
||||
public async Task Login_WithReturnSession_GivesABearerSession_WithoutACookie_AndNoCsrfNeeded()
|
||||
{
|
||||
// 密码登录的会话没有任何能力(只能注册 passkey);有能力的会话来自 passkey 登录
|
||||
(string username, SoftwarePasskey passkey) = await _backend.CreateUserWithPasskeyAsync();
|
||||
using SoftwarePasskey passkeyScope = passkey;
|
||||
using HttpClient client = _backend.NewClient();
|
||||
using HttpResponseMessage login = await client.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = BackendFixture.AdminUser, ["password"] = BackendFixture.AdminPassword, ["return_session"] = true }));
|
||||
using HttpResponseMessage login = await _backend.PasskeyLoginAsync(client, passkey);
|
||||
Assert.Equal(HttpStatusCode.OK, login.StatusCode);
|
||||
Assert.False(login.Headers.Contains("Set-Cookie"));
|
||||
string session = JsonNode.Parse(await login.Content.ReadAsStringAsync())!["session_token"]!.GetValue<string>();
|
||||
@@ -73,7 +76,7 @@ public sealed class ApiBasicsTests
|
||||
me.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", session);
|
||||
using HttpResponseMessage meResponse = await client.SendAsync(me);
|
||||
Assert.Equal(HttpStatusCode.OK, meResponse.StatusCode);
|
||||
Assert.Equal(BackendFixture.AdminUser, JsonNode.Parse(await meResponse.Content.ReadAsStringAsync())!["username"]!.GetValue<string>());
|
||||
Assert.Equal(username, JsonNode.Parse(await meResponse.Content.ReadAsStringAsync())!["username"]!.GetValue<string>());
|
||||
|
||||
using var create = new HttpRequestMessage(HttpMethod.Post, "/api/admin/api-tokens") { Content = BackendFixture.Json(new JsonObject { ["name"] = "session-write", ["capabilities"] = new JsonArray("fleet.read") }) };
|
||||
create.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", session);
|
||||
@@ -94,8 +97,10 @@ public sealed class ApiBasicsTests
|
||||
[Fact]
|
||||
public async Task SessionCookie_RequiresCsrfHeaderOnUnsafeMethods()
|
||||
{
|
||||
(_, SoftwarePasskey passkey) = await _backend.CreateUserWithPasskeyAsync();
|
||||
using SoftwarePasskey passkeyScope = passkey;
|
||||
using HttpClient client = _backend.NewClient();
|
||||
using HttpResponseMessage login = await client.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = BackendFixture.AdminUser, ["password"] = BackendFixture.AdminPassword }));
|
||||
using HttpResponseMessage login = await _backend.PasskeyLoginAsync(client, passkey, returnSession: false);
|
||||
string csrf = JsonNode.Parse(await login.Content.ReadAsStringAsync())!["csrf_token"]!.GetValue<string>();
|
||||
string cookie = login.Headers.GetValues("Set-Cookie").Single().Split(';')[0];
|
||||
|
||||
@@ -220,15 +225,6 @@ public sealed class ApiBasicsTests
|
||||
Assert.Contains("moshiro_agents_online", text);
|
||||
Assert.Contains("moshiro_analysis_queue_length", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InstallScripts_RequireAValidCode()
|
||||
{
|
||||
// 一行安装脚本凭一次性代码获取;没有代码或代码不能用是 404(完整行为见 InstallTests)
|
||||
using HttpResponseMessage response = await _backend.Anonymous.GetAsync("/install.sh");
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
Assert.Equal("not_found", JsonNode.Parse(await response.Content.ReadAsStringAsync())!["code"]!.GetValue<string>());
|
||||
}
|
||||
}
|
||||
|
||||
[CollectionDefinition("backend")]
|
||||
|
||||
@@ -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>();
|
||||
}
|
||||
|
||||
@@ -119,7 +119,7 @@ public sealed class CapabilityTests
|
||||
string[] ids = [.. catalog["groups"]!.AsArray().SelectMany(g => g!["capabilities"]!.AsArray()).Select(c => c!["id"]!.GetValue<string>())];
|
||||
Assert.Equal(CapabilityCatalog.All.Select(c => c.Id).Order(StringComparer.Ordinal).ToArray(), ids.Order(StringComparer.Ordinal).ToArray());
|
||||
Assert.Contains(catalog["presets"]!.AsArray(), p => p!["id"]!.GetValue<string>() == "all");
|
||||
Assert.Contains(catalog["groups"]!.AsArray().SelectMany(g => g!["capabilities"]!.AsArray()), c => c!["id"]!.GetValue<string>() == Capability.FleetEnroll && c["trust"]!.GetValue<bool>());
|
||||
Assert.Contains(catalog["groups"]!.AsArray().SelectMany(g => g!["capabilities"]!.AsArray()), c => c!["id"]!.GetValue<string>() == Capability.FleetRevoke && c["trust"]!.GetValue<bool>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -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); // 看列表不用提权
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,10 @@ using System.Text.Json.Nodes;
|
||||
|
||||
namespace MoShiro.Backend.Tests;
|
||||
|
||||
/// <summary>一行安装:Agent 和 Probe 共用同一份脚本,角色、网关、镜像由一次性代码决定,节点名字默认取主机名。</summary>
|
||||
/// <summary>
|
||||
/// 一行安装:Agent 和 Probe 共用同一份<b>静态</b>脚本(所有人拿到的字节都一样,所以哈希固定、可以先核对再执行),
|
||||
/// 面板地址、角色、一次性代码都走命令里的环境变量;脚本再向面板取 CI 签名的发布清单来验证镜像。
|
||||
/// </summary>
|
||||
[Collection("probe-e2e")]
|
||||
public sealed class InstallTests
|
||||
{
|
||||
@@ -23,7 +26,7 @@ public sealed class InstallTests
|
||||
[Theory]
|
||||
[InlineData("agent")]
|
||||
[InlineData("probe")]
|
||||
public async Task Commands_AreOneLiners_WithoutAPlaceholder_AndTheScriptIsFilledInForTheRole(string role)
|
||||
public async Task Commands_VerifyTheScriptHash_ThenRunItWithThePublicValues(string role)
|
||||
{
|
||||
JsonNode created = await CreateAsync(role);
|
||||
string code = created["plaintext"]!.GetValue<string>();
|
||||
@@ -31,33 +34,55 @@ public sealed class InstallTests
|
||||
string linux = commands["linux"]!.GetValue<string>();
|
||||
string macos = commands["macos"]!.GetValue<string>();
|
||||
string windows = commands["windows"]!.GetValue<string>();
|
||||
Assert.StartsWith("curl -fsSL '", linux, StringComparison.Ordinal);
|
||||
Assert.EndsWith("/install?code=" + code + "' | bash", linux, StringComparison.Ordinal);
|
||||
Assert.EndsWith("/install?code=" + code + "' | zsh", macos, StringComparison.Ordinal);
|
||||
Assert.StartsWith("irm '", windows, StringComparison.Ordinal);
|
||||
Assert.EndsWith("/install.ps1?code=" + code + "' | iex", windows, StringComparison.Ordinal);
|
||||
|
||||
Assert.StartsWith("f=$(mktemp) && curl -fsSL '", linux, StringComparison.Ordinal);
|
||||
Assert.Contains("/install' -o \"$f\" && echo \"", linux, StringComparison.Ordinal);
|
||||
Assert.Contains("sha256sum -c -", linux, StringComparison.Ordinal);
|
||||
Assert.Contains($"MOSHIRO_ROLE='{role}'", linux, StringComparison.Ordinal);
|
||||
Assert.Contains($"MOSHIRO_CODE='{code}'", linux, StringComparison.Ordinal);
|
||||
Assert.EndsWith("sh \"$f\"", linux, StringComparison.Ordinal);
|
||||
Assert.Contains("shasum -a 256 -c -", macos, StringComparison.Ordinal);
|
||||
Assert.StartsWith("$f = Join-Path $env:TEMP", windows, StringComparison.Ordinal);
|
||||
Assert.Contains($"$env:MOSHIRO_ROLE='{role}'", windows, StringComparison.Ordinal);
|
||||
Assert.EndsWith("iex (Get-Content -Raw $f)", windows, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("<", linux + macos + windows, StringComparison.Ordinal); // 没有要用户自己替换的占位符
|
||||
|
||||
// 脚本不需要登录(拿代码就是授权),不带缓存
|
||||
using HttpResponseMessage sh = await _backend.Anonymous.GetAsync("/install?code=" + code);
|
||||
string script = await sh.Content.ReadAsStringAsync();
|
||||
Assert.Equal(HttpStatusCode.OK, sh.StatusCode);
|
||||
Assert.Equal("no-store", sh.Headers.CacheControl?.ToString());
|
||||
Assert.Contains($"ROLE='{role}'", script, StringComparison.Ordinal);
|
||||
Assert.Contains($"CODE='{code}'", script, StringComparison.Ordinal);
|
||||
Assert.Contains($"/moshiro-{role}:latest'", script, StringComparison.Ordinal);
|
||||
Assert.Contains("SERVER='http", script, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("@@", script, StringComparison.Ordinal);
|
||||
|
||||
using HttpResponseMessage ps = await _backend.Anonymous.GetAsync("/install.ps1?code=" + code);
|
||||
string powershell = await ps.Content.ReadAsStringAsync();
|
||||
Assert.Equal(HttpStatusCode.OK, ps.StatusCode);
|
||||
Assert.Contains($"$role = '{role}'", powershell, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("@@", powershell, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheScript_GetsTheSignedManifestOfItsRole_ByteForByte_AndNothingWithoutAValidCode()
|
||||
public async Task TheScripts_AreStatic_TheSameBytesForEveryone_AndNeedNoCode()
|
||||
{
|
||||
foreach (string path in new[] { "/install", "/install.sh", "/install.ps1" })
|
||||
{
|
||||
using HttpResponseMessage first = await _backend.Anonymous.GetAsync(path);
|
||||
using HttpResponseMessage second = await _backend.Anonymous.GetAsync(path + "?code=jt_whatever");
|
||||
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
|
||||
Assert.Equal("no-store", first.Headers.CacheControl?.ToString());
|
||||
string body = await first.Content.ReadAsStringAsync();
|
||||
Assert.Equal(body, await second.Content.ReadAsStringAsync()); // 带不带码、带什么码,内容都一样
|
||||
Assert.DoesNotContain("@@", body, StringComparison.Ordinal);
|
||||
Assert.Contains("MOSHIRO_SERVER", body, StringComparison.Ordinal);
|
||||
Assert.Contains("MOSHIRO_ROLE", body, StringComparison.Ordinal);
|
||||
Assert.Contains("MOSHIRO_CODE", body, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
// 同一份字节:sh 的两个路径一致
|
||||
Assert.Equal(await _backend.Anonymous.GetStringAsync("/install"), await _backend.Anonymous.GetStringAsync("/install.sh"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheScripts_CarryTheReleaseKey_ThatTheBackendShips()
|
||||
{
|
||||
string key = MoShiro.Backend.Api.InstallScripts.ReleaseKey();
|
||||
Assert.StartsWith("-----BEGIN PUBLIC KEY-----", key, StringComparison.Ordinal);
|
||||
foreach (string path in new[] { "/install", "/install.ps1" })
|
||||
{
|
||||
string script = (await _backend.Anonymous.GetStringAsync(path)).Replace("\r\n", "\n", StringComparison.Ordinal);
|
||||
Assert.Contains(key.Replace("\r\n", "\n", StringComparison.Ordinal), script, StringComparison.Ordinal); // 脚本里内置的和面板发布的是同一把
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheScript_GetsTheSignedManifestOfItsRole_ByteForByte()
|
||||
{
|
||||
string manifest = """{"schema":1,"component":"probe","channel":"dev","image":"registry.example/moshiro-dev/moshiro-probe","digest":"sha256:0000000000000000000000000000000000000000000000000000000000000000","tag":"dev-1","version":"0.1.1","commit":"abc","built_at":"2026-10-06T00:00:00Z","seq":1}""";
|
||||
using HttpResponseMessage published = await _backend.Admin.PostAsync("/api/admin/releases", BackendFixture.Json(new JsonObject { ["manifest"] = manifest, ["signature"] = "MEUCIQDc" }));
|
||||
@@ -67,24 +92,33 @@ public sealed class InstallTests
|
||||
using HttpResponseMessage served = await _backend.Anonymous.GetAsync("/install/manifest?code=" + code);
|
||||
Assert.Equal(HttpStatusCode.OK, served.StatusCode);
|
||||
Assert.Equal("no-store", served.Headers.CacheControl?.ToString());
|
||||
string body = await served.Content.ReadAsStringAsync();
|
||||
Assert.Contains("\"component\":\"probe\"", body, StringComparison.Ordinal); // 签名对象是原始字节,面板一个字节也不改
|
||||
Assert.Contains("\"component\":\"probe\"", await served.Content.ReadAsStringAsync(), StringComparison.Ordinal); // 签名对象是原始字节,面板一个字节也不改
|
||||
using HttpResponseMessage signature = await _backend.Anonymous.GetAsync("/install/manifest.sig?code=" + code);
|
||||
Assert.Equal(HttpStatusCode.OK, signature.StatusCode);
|
||||
Assert.False(string.IsNullOrWhiteSpace(await signature.Content.ReadAsStringAsync()));
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await _backend.Anonymous.GetAsync("/install/manifest?code=jt_wrong")).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await _backend.Anonymous.GetAsync("/install/manifest.sig")).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheScript_CarriesTheReleaseKey_ToVerifyTheManifest()
|
||||
public async Task TheManifest_IsNotServedForUnknownRevokedOrUsedUpCodes()
|
||||
{
|
||||
string code = (await CreateAsync("agent"))["plaintext"]!.GetValue<string>();
|
||||
string script = await _backend.Anonymous.GetStringAsync("/install?code=" + code);
|
||||
Assert.Contains("-----BEGIN PUBLIC KEY-----", script, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("@@", script, StringComparison.Ordinal);
|
||||
Assert.Contains("install/manifest?code=", script, StringComparison.Ordinal);
|
||||
foreach (string path in new[] { "/install/manifest", "/install/manifest?code=", "/install/manifest?code=jt_nope", "/install/manifest.sig?code=jt_nope" })
|
||||
{
|
||||
using HttpResponseMessage response = await _backend.Anonymous.GetAsync(path);
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
JsonNode revoked = await CreateAsync("probe");
|
||||
using HttpResponseMessage delete = await _backend.Admin.DeleteAsync("/api/admin/join-tokens/" + revoked["token"]!["id"]!.GetValue<string>());
|
||||
Assert.True(delete.IsSuccessStatusCode);
|
||||
using HttpResponseMessage gone = await _backend.Anonymous.GetAsync("/install/manifest?code=" + revoked["plaintext"]!.GetValue<string>());
|
||||
Assert.Equal(HttpStatusCode.NotFound, gone.StatusCode);
|
||||
|
||||
// 用完了(一次性令牌已经注册过一个节点):脚本在动手之前就能从这里知道,给出明确的提示
|
||||
JsonNode used = await CreateAsync("probe");
|
||||
string code = used["plaintext"]!.GetValue<string>();
|
||||
await using (await MoShiro.TestSupport.AgentSimulator.EnrollAsync(_backend.Anonymous, code, _backend.UniqueName("used"))) { }
|
||||
using HttpResponseMessage after = await _backend.Anonymous.GetAsync("/install/manifest?code=" + code);
|
||||
Assert.Equal(HttpStatusCode.NotFound, after.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -93,29 +127,4 @@ public sealed class InstallTests
|
||||
JsonNode created = await CreateAsync("agent");
|
||||
Assert.StartsWith("key:", created["token"]!["created_by"]!.GetValue<string>(), StringComparison.Ordinal); // Admin 客户端是 API key
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Script_IsNotServedForUnknownRevokedOrUsedUpCodes()
|
||||
{
|
||||
foreach (string path in new[] { "/install", "/install?code=", "/install?code=jt_nope", "/install.ps1?code=jt_nope", "/install.sh?code=jt_nope" })
|
||||
{
|
||||
using HttpResponseMessage response = await _backend.Anonymous.GetAsync(path);
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
JsonNode revoked = await CreateAsync("agent");
|
||||
using HttpResponseMessage delete = await _backend.Admin.DeleteAsync("/api/admin/join-tokens/" + revoked["token"]!["id"]!.GetValue<string>());
|
||||
Assert.True(delete.IsSuccessStatusCode);
|
||||
using HttpResponseMessage gone = await _backend.Anonymous.GetAsync("/install?code=" + revoked["plaintext"]!.GetValue<string>());
|
||||
Assert.Equal(HttpStatusCode.NotFound, gone.StatusCode);
|
||||
|
||||
// 用完了(一次性令牌已经注册过一个节点)
|
||||
JsonNode used = await CreateAsync("probe");
|
||||
string code = used["plaintext"]!.GetValue<string>();
|
||||
using HttpResponseMessage before = await _backend.Anonymous.GetAsync("/install?code=" + code);
|
||||
Assert.Equal(HttpStatusCode.OK, before.StatusCode);
|
||||
await using (await MoShiro.TestSupport.AgentSimulator.EnrollAsync(_backend.Anonymous, code, _backend.UniqueName("used"))) { }
|
||||
using HttpResponseMessage after = await _backend.Anonymous.GetAsync("/install?code=" + code);
|
||||
Assert.Equal(HttpStatusCode.NotFound, after.StatusCode);
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ using Fleet.Core;
|
||||
namespace MoShiro.Backend.Tests;
|
||||
|
||||
/// <summary>软件实现的 WebAuthn 认证器(ES256),用来在测试里走完整的注册和登录。</summary>
|
||||
internal sealed class SoftwarePasskey : IDisposable
|
||||
public sealed class SoftwarePasskey : IDisposable
|
||||
{
|
||||
private readonly ECDsa _key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
private uint _counter;
|
||||
|
||||
@@ -302,7 +302,7 @@ public sealed class StorageTests : IDisposable
|
||||
var tokens = new SqliteJoinTokenStore(_db);
|
||||
var fleet = new SqliteAgentStore(_db);
|
||||
byte[] hash = TokenHash.Sha256("jt_race");
|
||||
JoinTokenInfo created = await tokens.CreateAsync("jtk_1", hash, "note", ["a"], true, "", maxUses: 3, expiresAtMs: 10_000, nowMs: 1_000, createdBy: "alice", grant: null, default);
|
||||
JoinTokenInfo created = await tokens.CreateAsync("jtk_1", hash, "note", ["a"], true, "", maxUses: 3, expiresAtMs: 10_000, nowMs: 1_000, createdBy: "alice", default);
|
||||
Assert.Equal(0, created.Uses);
|
||||
|
||||
bool[] results = await Task.WhenAll(Enumerable.Range(0, 20).Select(_ => Task.Run(async () => await fleet.TryConsumeJoinTokenAsync("jtk_1", 2_000, default))));
|
||||
@@ -310,7 +310,7 @@ public sealed class StorageTests : IDisposable
|
||||
Assert.Equal(3, (await fleet.FindJoinTokenAsync(hash, default))!.Uses);
|
||||
|
||||
Assert.False(await fleet.TryConsumeJoinTokenAsync("jtk_1", 2_000, default));
|
||||
JoinTokenInfo expiring = await tokens.CreateAsync("jtk_2", TokenHash.Sha256("jt_exp"), null, [], true, "", 5, expiresAtMs: 5_000, nowMs: 1_000, createdBy: null, grant: null, default);
|
||||
JoinTokenInfo expiring = await tokens.CreateAsync("jtk_2", TokenHash.Sha256("jt_exp"), null, [], true, "", 5, expiresAtMs: 5_000, nowMs: 1_000, createdBy: null, default);
|
||||
Assert.False(await fleet.TryConsumeJoinTokenAsync(expiring.Id, 5_000, default), "expired at the boundary");
|
||||
Assert.True(await tokens.RevokeAsync("jtk_2", 3_000, default));
|
||||
Assert.False(await tokens.RevokeAsync("jtk_2", 3_000, default));
|
||||
|
||||
@@ -8,17 +8,18 @@ using Fleet.Core;
|
||||
using Fleet.Enrollment.Client;
|
||||
using Fleet.Hosting;
|
||||
using Fleet.Identity;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using MoShiro.Backend.Application;
|
||||
|
||||
namespace MoShiro.Backend.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 信任根:操作员(passkey)签操作员集合和加入码的委托,节点从固定的创世指纹出发验证;码的种子面板看不到。
|
||||
/// 信任根:操作员(passkey)签操作员集合;安装命令里带着创世指纹和属主(都是公开值),节点注册时验证面板的集合并固定在本地。
|
||||
/// 设计见 docs/access.md 第 7 节。
|
||||
/// </summary>
|
||||
[Collection("probe-e2e")]
|
||||
public sealed class TrustTests
|
||||
{
|
||||
private const string Password = "correct horse battery";
|
||||
private static readonly ConditionalWeakTable<BackendFixture, Operator> s_operators = [];
|
||||
private static readonly SemaphoreSlim s_gate = new(1, 1);
|
||||
|
||||
@@ -32,8 +33,6 @@ public sealed class TrustTests
|
||||
/// <summary>一个已经建立了信任根的操作员:自己的用户、passkey、会话,以及创世指纹。</summary>
|
||||
private sealed record Operator(HttpClient Session, SoftwarePasskey Passkey, string Genesis);
|
||||
|
||||
private string Origin => _backend.BaseAddress.GetLeftPart(UriPartial.Authority);
|
||||
|
||||
private async Task<Operator> OperatorAsync()
|
||||
{
|
||||
await s_gate.WaitAsync();
|
||||
@@ -44,20 +43,8 @@ public sealed class TrustTests
|
||||
return existing;
|
||||
}
|
||||
|
||||
string name = _backend.UniqueName("operator");
|
||||
using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/users", BackendFixture.Json(new JsonObject { ["username"] = name, ["password"] = Password }));
|
||||
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
|
||||
var session = _backend.NewClient();
|
||||
using HttpResponseMessage login = await session.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = name, ["password"] = Password, ["return_session"] = true }));
|
||||
session.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", JsonNode.Parse(await login.Content.ReadAsStringAsync())!["session_token"]!.GetValue<string>());
|
||||
|
||||
var passkey = new SoftwarePasskey(_backend.BaseAddress.Host, Origin);
|
||||
using (HttpResponseMessage options = await session.PostAsync("/api/admin/auth/passkeys/options", null))
|
||||
{
|
||||
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("operator", challenge)));
|
||||
Assert.Equal(HttpStatusCode.Created, registered.StatusCode);
|
||||
}
|
||||
// 操作员:有 passkey、用 passkey 登录的会话(密码会话什么能力都没有)
|
||||
(HttpClient session, SoftwarePasskey passkey, _) = await _backend.CreateStrongSessionAsync("operator");
|
||||
|
||||
// 创世要凭证窗口(立根的人要先证明自己),再用 passkey 签集合本身
|
||||
await ElevateAsync(session, passkey);
|
||||
@@ -105,98 +92,127 @@ public sealed class TrustTests
|
||||
Assert.Equal(HttpStatusCode.OK, elevated.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>浏览器做的事:生成码的密钥,种子派生认证串,passkey 对委托签名,创建码。返回种子和认证串。</summary>
|
||||
private async Task<(string Seed, string Auth, HttpStatusCode Status)> CreateSignedCodeAsync(Operator op, string role = "probe", SoftwarePasskey? signer = null, long? notAfter = null)
|
||||
/// <summary>创建一个加入码(普通 API key 就行,不需要 passkey),返回响应里的码和三条安装命令。</summary>
|
||||
private async Task<(string Code, JsonNode Commands, HttpStatusCode Status)> CreateCodeAsync(string role = "probe", string[]? controllers = null)
|
||||
{
|
||||
using ECDsa code = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
byte[] seed = code.ExportParameters(true).D!;
|
||||
string auth = OperatorTrust.AuthToken(seed);
|
||||
long now = TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
string statement = OperatorTrust.Encode(new Grant(role, OperatorTrust.AuthHash(auth), code.ExportSubjectPublicKeyInfo(), now - 1000, notAfter ?? now + 3_600_000, 1));
|
||||
using HttpResponseMessage response = await op.Session.PostAsync("/api/admin/join-tokens/signed", BackendFixture.Json(new JsonObject { ["grant"] = Sign(signer ?? op.Passkey, statement), ["note"] = "trust test" }));
|
||||
return (Fleet.Core.Base64Url.Encode(seed), auth, response.StatusCode);
|
||||
var body = new JsonObject { ["role"] = role, ["note"] = "trust test" };
|
||||
if (controllers is not null)
|
||||
{
|
||||
body["controllers"] = new JsonArray([.. controllers.Select(c => (JsonNode?)c)]);
|
||||
}
|
||||
|
||||
using HttpResponseMessage response = await _backend.Admin.PostAsync("/api/admin/join-tokens", BackendFixture.Json(body));
|
||||
JsonNode result = JsonNode.Parse(await response.Content.ReadAsStringAsync())!;
|
||||
return response.StatusCode == HttpStatusCode.Created ? (result["plaintext"]!.GetValue<string>(), result["install_commands"]!, response.StatusCode) : ("", result, response.StatusCode);
|
||||
}
|
||||
|
||||
private async Task<Result<EnrollmentResult>> EnrollAsync(string auth, string? seed, string? genesis, string role = "probe", string? directory = null)
|
||||
private async Task<Result<EnrollmentResult>> EnrollAsync(string code, string? genesis, string[]? controllers, string? directory = null)
|
||||
{
|
||||
directory ??= Path.Combine(Path.GetTempPath(), "moshiro-trust-id-" + Guid.NewGuid().ToString("N"));
|
||||
using var http = new HttpClient();
|
||||
return await EnrollmentClient.EnrollAsync(http, _backend.BaseAddress, auth, _backend.UniqueName("node"), directory, "0.0.0-test", null, TimeProvider.System, default, seed, genesis, role);
|
||||
return await EnrollmentClient.EnrollAsync(http, _backend.BaseAddress, code, _backend.UniqueName("node"), directory, "0.0.0-test", null, TimeProvider.System, default, genesis, controllers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ASignedCode_EnrollsANode_ThatPinsTheOperatorAndCarriesTheProof()
|
||||
public async Task TheInstallCommand_CarriesOnlyPublicValues_AndChecksTheScriptHashFirst()
|
||||
{
|
||||
Operator op = await OperatorAsync();
|
||||
(string seed, string auth, HttpStatusCode status) = await CreateSignedCodeAsync(op);
|
||||
(string code, JsonNode commands, HttpStatusCode status) = await CreateCodeAsync();
|
||||
Assert.Equal(HttpStatusCode.Created, status);
|
||||
|
||||
string linux = commands["linux"]!.GetValue<string>();
|
||||
string sha = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(await _backend.Anonymous.GetStringAsync("/install"))));
|
||||
Assert.Contains($"echo \"{sha} $f\" | sha256sum -c -", linux, StringComparison.Ordinal); // 先核对哈希,一致才执行
|
||||
Assert.Contains($"MOSHIRO_CODE='{code}'", linux, StringComparison.Ordinal);
|
||||
Assert.Contains($"MOSHIRO_GENESIS='{op.Genesis}'", linux, StringComparison.Ordinal);
|
||||
Assert.Contains(op.Passkey.Id, linux, StringComparison.Ordinal); // 默认属主:操作员集合里的全部 passkey
|
||||
Assert.DoesNotContain("SEED", linux + commands["macos"] + commands["windows"], StringComparison.Ordinal); // 没有任何私钥在命令里
|
||||
Assert.Contains("shasum -a 256 -c -", commands["macos"]!.GetValue<string>(), StringComparison.Ordinal);
|
||||
string windowsSha = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(await _backend.Anonymous.GetStringAsync("/install.ps1"))));
|
||||
Assert.Contains($"-ne '{windowsSha}'", commands["windows"]!.GetValue<string>(), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ANode_PinsTheGenesisAndItsOwners_AtEnrollment()
|
||||
{
|
||||
Operator op = await OperatorAsync();
|
||||
(string code, _, _) = await CreateCodeAsync(controllers: [op.Passkey.Id]);
|
||||
|
||||
string directory = Path.Combine(Path.GetTempPath(), "moshiro-trust-id-" + Guid.NewGuid().ToString("N"));
|
||||
Result<EnrollmentResult> enrolled = await EnrollAsync(auth, seed, op.Genesis, directory: directory);
|
||||
Result<EnrollmentResult> enrolled = await EnrollAsync(code, op.Genesis, [op.Passkey.Id], directory);
|
||||
Assert.True(enrolled.IsSuccess, enrolled.IsSuccess ? "" : enrolled.Error.ToString());
|
||||
Assert.True(enrolled.Value.Pinned);
|
||||
|
||||
// 节点本地:固定的创世、验证过的链、自己的证明;强制模式下对端必须带证明
|
||||
TrustAnchor anchor = TrustAnchor.Load(directory, NullNodeLog.Instance);
|
||||
Assert.True(anchor.Enforcing);
|
||||
Assert.True(anchor.Pinned);
|
||||
Assert.Equal(op.Genesis, anchor.Genesis);
|
||||
using LoadedNodeIdentity identity = LoadedNodeIdentity.Load(directory);
|
||||
Assert.Equal(TrustFailure.None, anchor.VerifyPeer(anchor.OwnProof, identity.PeerIdentity.PublicKeySpki, "probe"));
|
||||
Assert.Equal(TrustFailure.Malformed, anchor.VerifyPeer(null, identity.PeerIdentity.PublicKeySpki, "probe"));
|
||||
Assert.NotEqual(TrustFailure.None, anchor.VerifyPeer(anchor.OwnProof, RandomNumberGenerator.GetBytes(91), "probe"));
|
||||
|
||||
// 面板上这个码标记为已签名
|
||||
JsonNode tokens = JsonNode.Parse(await _backend.Admin.GetStringAsync("/api/admin/join-tokens"))!;
|
||||
Assert.Contains(tokens["items"]!.AsArray(), t => t!["signed"]!.GetValue<bool>() && t["note"]?.GetValue<string>() == "trust test");
|
||||
Assert.Equal([op.Passkey.Id], anchor.Controllers);
|
||||
Assert.True(anchor.Version >= 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ANodeWithAWrongPin_RefusesBeforeSendingAnything_AndTheCodeStaysUsable()
|
||||
{
|
||||
Operator op = await OperatorAsync();
|
||||
(string seed, string auth, _) = await CreateSignedCodeAsync(op);
|
||||
(string code, _, _) = await CreateCodeAsync();
|
||||
|
||||
Result<EnrollmentResult> wrong = await EnrollAsync(auth, seed, new string('0', 64));
|
||||
Result<EnrollmentResult> wrong = await EnrollAsync(code, new string('0', 64), [op.Passkey.Id]);
|
||||
Assert.False(wrong.IsSuccess);
|
||||
Assert.Contains("genesis", wrong.Error.Message, StringComparison.Ordinal);
|
||||
|
||||
Result<EnrollmentResult> right = await EnrollAsync(auth, seed, op.Genesis); // 码没被消耗
|
||||
Assert.True(right.IsSuccess, right.IsSuccess ? "" : right.Error.ToString());
|
||||
Result<EnrollmentResult> unknownOwner = await EnrollAsync(code, op.Genesis, ["not-a-credential-of-the-set"]);
|
||||
Assert.False(unknownOwner.IsSuccess);
|
||||
Assert.Contains("owner", unknownOwner.Error.Message, StringComparison.Ordinal);
|
||||
|
||||
Assert.True((await EnrollAsync(code, op.Genesis, [op.Passkey.Id])).IsSuccess); // 前两次都在交出密钥之前被拒绝,码没有被消耗
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheSeedIsNeededToEnroll_ACodeAloneNeverGetsANodeIn()
|
||||
public async Task APlainCode_StillWorksWithoutAPin_ButTheNodeIsNotPinned()
|
||||
{
|
||||
Operator op = await OperatorAsync();
|
||||
(string seed, string auth, _) = await CreateSignedCodeAsync(op);
|
||||
|
||||
// 只有认证串(面板看得到的那部分):不带种子,旧客户端的流程,面板要求绑定
|
||||
Result<EnrollmentResult> withoutSeed = await EnrollAsync(auth, null, null);
|
||||
Assert.False(withoutSeed.IsSuccess);
|
||||
|
||||
// 种子不是这个码的:节点自己就不干
|
||||
(string otherSeed, _, _) = await CreateSignedCodeAsync(op);
|
||||
Result<EnrollmentResult> mismatched = await EnrollAsync(auth, otherSeed, op.Genesis);
|
||||
Assert.False(mismatched.IsSuccess);
|
||||
|
||||
Assert.True((await EnrollAsync(auth, seed, op.Genesis)).IsSuccess);
|
||||
(string code, _, _) = await CreateCodeAsync();
|
||||
string directory = Path.Combine(Path.GetTempPath(), "moshiro-trust-id-" + Guid.NewGuid().ToString("N"));
|
||||
Result<EnrollmentResult> enrolled = await EnrollAsync(code, null, null, directory);
|
||||
Assert.True(enrolled.IsSuccess);
|
||||
Assert.False(enrolled.Value.Pinned);
|
||||
Assert.False(TrustAnchor.Load(directory, NullNodeLog.Instance).Pinned);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ARoleMismatch_OrAnExpiredGrant_OrAForeignSigner_IsRefused()
|
||||
public async Task TheOwnersOfACode_MustBePasskeysOfTheOperatorSet()
|
||||
{
|
||||
await OperatorAsync();
|
||||
(_, JsonNode problem, HttpStatusCode status) = await CreateCodeAsync(controllers: ["not-in-the-set"]);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, status);
|
||||
Assert.Contains("controllers", problem.ToJsonString(), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task APinnedAgent_FollowsTheOperatorSet_WhenThePasskeysChange_AndOnlyWhenTheOperatorSigns()
|
||||
{
|
||||
Operator op = await OperatorAsync();
|
||||
(string code, _, _) = await CreateCodeAsync(role: "agent", controllers: [op.Passkey.Id]);
|
||||
await using AgentHarness agent = await AgentHarness.StartAsync(_backend, pin: (code, op.Genesis, [op.Passkey.Id]));
|
||||
Assert.True(agent.App.Link.IsSecure, "a pinned agent must be on the encrypted link (the server proved itself with the key in the operator set)");
|
||||
int before = agent.App.TrustVersion;
|
||||
Assert.True(before >= 1);
|
||||
|
||||
(string seed, string auth, _) = await CreateSignedCodeAsync(op, role: "agent");
|
||||
Result<EnrollmentResult> asProbe = await EnrollAsync(auth, seed, op.Genesis, role: "probe"); // 节点以为自己是 probe,委托是给 agent 的
|
||||
Assert.False(asProbe.IsSuccess);
|
||||
// 账号下多了一把 passkey:集合和 passkey 不一致,面板给出下一版要签的语句
|
||||
(_, SoftwarePasskey added) = await _backend.CreateUserWithPasskeyAsync("colleague");
|
||||
using SoftwarePasskey addedScope = added;
|
||||
JsonNode state = JsonNode.Parse(await op.Session.GetStringAsync("/api/admin/trust"))!;
|
||||
Assert.False(state["in_sync"]!.GetValue<bool>());
|
||||
string next = state["proposal"]!["statement"]!.GetValue<string>();
|
||||
|
||||
(_, _, HttpStatusCode expired) = await CreateSignedCodeAsync(op, notAfter: TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds() - 1000);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, expired);
|
||||
// 不是操作员签的(新来的 passkey 还不在上一版里):不收
|
||||
using HttpResponseMessage forged = await op.Session.PostAsync("/api/admin/trust", BackendFixture.Json(Sign(added, next)));
|
||||
Assert.True(forged.StatusCode is HttpStatusCode.Forbidden or HttpStatusCode.BadRequest, await forged.Content.ReadAsStringAsync());
|
||||
Assert.Equal(before, agent.App.TrustVersion);
|
||||
|
||||
using var stranger = new SoftwarePasskey(_backend.BaseAddress.Host, Origin); // 没注册的 passkey 签的委托
|
||||
(_, _, HttpStatusCode foreign) = await CreateSignedCodeAsync(op, signer: stranger);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, foreign);
|
||||
// 现有操作员签:收下,随名册下发给节点,节点从固定的创世验过之后采用
|
||||
using HttpResponseMessage committed = await op.Session.PostAsync("/api/admin/trust", BackendFixture.Json(Sign(op.Passkey, next)));
|
||||
Assert.Equal(HttpStatusCode.Created, committed.StatusCode);
|
||||
await BackendFixture.EventuallyAsync(() => Task.FromResult(agent.App.TrustVersion > before), because: "the pinned agent should adopt the operator-signed set");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -207,63 +223,86 @@ public sealed class TrustTests
|
||||
Assert.True(state["initialized"]!.GetValue<bool>());
|
||||
Assert.Equal(op.Genesis, state["genesis"]!.GetValue<string>());
|
||||
|
||||
// 没有操作员签名的语句:不收
|
||||
JsonObject unsigned = Sign(op.Passkey, "moshiro-opset-v1\nversion=2\nprev=" + op.Genesis + "\nrp=x\norigins=x\nkey=a:AAAA");
|
||||
unsigned["statement"] = "moshiro-opset-v1\nversion=2\nprev=" + op.Genesis + "\nrp=" + _backend.BaseAddress.Host + "\norigins=" + Origin + "\nkey=a:AAAA"; // 语句被换掉,签名对不上
|
||||
using HttpResponseMessage tampered = await op.Session.PostAsync("/api/admin/trust", BackendFixture.Json(unsigned));
|
||||
Assert.True(tampered.StatusCode is HttpStatusCode.BadRequest or HttpStatusCode.Forbidden or HttpStatusCode.Conflict);
|
||||
// 语句被换掉,签名对不上:不收
|
||||
JsonObject tampered = Sign(op.Passkey, "moshiro-opset-v1\nversion=99\nprev=x\nrp=x\norigins=x\nkey=a:AAAA");
|
||||
tampered["statement"] = "moshiro-opset-v1\nversion=2\nprev=" + op.Genesis + "\nrp=" + _backend.BaseAddress.Host + "\norigins=" + _backend.BaseAddress.GetLeftPart(UriPartial.Authority) + "\nkey=a:AAAA";
|
||||
using HttpResponseMessage rejected = await op.Session.PostAsync("/api/admin/trust", BackendFixture.Json(tampered));
|
||||
Assert.True(rejected.StatusCode is HttpStatusCode.BadRequest or HttpStatusCode.Forbidden or HttpStatusCode.Conflict);
|
||||
|
||||
// 匿名和只有 API key 的调用也没有可能立根
|
||||
using HttpResponseMessage anonymous = await _backend.Anonymous.PostAsync("/api/admin/trust", BackendFixture.Json(unsigned));
|
||||
// 匿名没有可能立根或改根
|
||||
using HttpResponseMessage anonymous = await _backend.Anonymous.PostAsync("/api/admin/trust", BackendFixture.Json(tampered));
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, anonymous.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task APinnedAgent_OnlyAcceptsProbesTheOperatorApproved_EvenIfThePanelListsOthers()
|
||||
public async Task APasswordSession_CanOnlyRegisterAPasskey_AndCreatingACodeNeedsNoPasskeyTouch()
|
||||
{
|
||||
Operator op = await OperatorAsync();
|
||||
(string agentSeed, string agentAuth, _) = await CreateSignedCodeAsync(op, role: "agent");
|
||||
await using AgentHarness agent = await AgentHarness.StartAsync(_backend, signed: (agentAuth, agentSeed, op.Genesis));
|
||||
// 密码登录的会话没有任何能力:连看节点列表都不行,只能注册 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);
|
||||
using HttpClient password = _backend.NewClient();
|
||||
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.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);
|
||||
using HttpResponseMessage options = await password.PostAsync("/api/admin/auth/passkeys/options", null);
|
||||
Assert.Equal(HttpStatusCode.OK, options.StatusCode);
|
||||
|
||||
// 面板里有一个没有操作员证明的 Probe(相当于面板自己塞进名册的),再有一个操作员批准的
|
||||
using EnrolledProbe plain = await _backend.CreateProbeAsync();
|
||||
(string seed, string auth, _) = await CreateSignedCodeAsync(op, role: "probe");
|
||||
string directory = Path.Combine(Path.GetTempPath(), "moshiro-trust-id-" + Guid.NewGuid().ToString("N"));
|
||||
Result<EnrollmentResult> enrolled = await EnrollAsync(auth, seed, op.Genesis, directory: directory);
|
||||
Assert.True(enrolled.IsSuccess, enrolled.IsSuccess ? "" : enrolled.Error.ToString());
|
||||
using LoadedNodeIdentity approved = LoadedNodeIdentity.Load(directory);
|
||||
|
||||
await BackendFixture.EventuallyAsync(
|
||||
() => Task.FromResult(agent.App.IsPeerKnown(enrolled.Value.NodeId, Convert.ToBase64String(approved.PeerIdentity.PublicKeySpki))),
|
||||
because: "the agent should accept the probe the operator approved");
|
||||
Assert.False(agent.App.IsPeerKnown(plain.ProbeId, Convert.ToBase64String(plain.Identity.PeerIdentity.PublicKeySpki)), "a probe without the operator's proof must not be accepted");
|
||||
// passkey 登录的会话创建码:不需要提权窗口、不需要再触碰(创建码不是信任动作,信任在属主固定里)
|
||||
(HttpClient strong, SoftwarePasskey passkey, _) = await _backend.CreateStrongSessionAsync("creator");
|
||||
using HttpClient strongScope = strong;
|
||||
using SoftwarePasskey passkeyScope = passkey;
|
||||
using HttpResponseMessage code = await strong.PostAsync("/api/admin/join-tokens", BackendFixture.Json(new JsonObject { ["role"] = "agent" }));
|
||||
Assert.Equal(HttpStatusCode.Created, code.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task APinnedProbe_OnlyConnectsToAgentsTheOperatorApproved()
|
||||
public async Task TheServerKey_IsSignedIntoTheOperatorSet_AndOnlyThatServerPassesTheNodesCheck()
|
||||
{
|
||||
Operator op = await OperatorAsync();
|
||||
(string probeSeed, string probeAuth, _) = await CreateSignedCodeAsync(op, role: "probe");
|
||||
string probeDirectory = Path.Combine(Path.GetTempPath(), "moshiro-trust-id-" + Guid.NewGuid().ToString("N"));
|
||||
Assert.True((await EnrollAsync(probeAuth, probeSeed, op.Genesis, directory: probeDirectory)).IsSuccess);
|
||||
JsonNode state = JsonNode.Parse(await op.Session.GetStringAsync("/api/admin/trust"))!;
|
||||
Assert.True(state["server_key_listed"]!.GetValue<bool>());
|
||||
Assert.Equal(64, state["server_key"]!.GetValue<string>().Length);
|
||||
|
||||
(string agentSeed, string agentAuth, _) = await CreateSignedCodeAsync(op, role: "agent");
|
||||
await using AgentHarness agent = await AgentHarness.StartAsync(_backend, signed: (agentAuth, agentSeed, op.Genesis));
|
||||
MemberProofDto? proof = TrustAnchor.Load(agent.DataDirectory, NullNodeLog.Instance).OwnProof;
|
||||
string key = Convert.ToBase64String(agent.TlsPublicKey);
|
||||
(string code, _, _) = await CreateCodeAsync(controllers: [op.Passkey.Id]);
|
||||
string directory = Path.Combine(Path.GetTempPath(), "moshiro-trust-id-" + Guid.NewGuid().ToString("N"));
|
||||
Assert.True((await EnrollAsync(code, op.Genesis, [op.Passkey.Id], directory)).IsSuccess);
|
||||
TrustAnchor anchor = TrustAnchor.Load(directory, NullNodeLog.Instance);
|
||||
Assert.True(anchor.Required); // 集合里登记了服务器密钥:节点要求服务器证明自己
|
||||
|
||||
string configDirectory = Path.Combine(Path.GetTempPath(), "moshiro-probe-cfg-" + Guid.NewGuid().ToString("N"));
|
||||
var directory = new MoShiro.Probe.AgentDirectory(new ConfigStore(configDirectory), configDirectory, NullNodeLog.Instance, TrustAnchor.Load(probeDirectory, NullNodeLog.Instance));
|
||||
directory.Apply(new MoShiro.Protocol.ProbeAgentList
|
||||
// 真服务器的证明过得了节点的校验;任何别的密钥(冒充者)、或者换了临时公钥的都过不了
|
||||
ServerIdentityService server = _backend.Services.GetRequiredService<ServerIdentityService>();
|
||||
using var ephemeral = new EphemeralKey();
|
||||
byte[] proof = server.Sign(ServerHello.Payload("nonce-1", ephemeral.PublicKeySpki));
|
||||
Assert.True(anchor.Verify("nonce-1", ephemeral.PublicKeySpki, proof));
|
||||
using ECDsa impostor = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
Assert.False(anchor.Verify("nonce-1", ephemeral.PublicKeySpki, ServerHello.Sign(impostor, "nonce-1", ephemeral.PublicKeySpki)));
|
||||
using var otherEphemeral = new EphemeralKey();
|
||||
Assert.False(anchor.Verify("nonce-1", otherEphemeral.PublicKeySpki, proof));
|
||||
Assert.False(anchor.Verify("nonce-2", ephemeral.PublicKeySpki, proof));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnOperatorSetWithoutThisPanelsServerKey_IsNotAccepted()
|
||||
{
|
||||
Operator op = await OperatorAsync();
|
||||
JsonNode state = JsonNode.Parse(await op.Session.GetStringAsync("/api/admin/trust"))!;
|
||||
string current = state["proposal"]?["statement"]?.GetValue<string>() ?? "";
|
||||
if (current.Length == 0)
|
||||
{
|
||||
Version = 1,
|
||||
Agents =
|
||||
[
|
||||
new MoShiro.Protocol.AgentEndpoint { AgentId = new NodeId("agt_approved"), Host = "127.0.0.1", TcpPort = 1, PublicKey = key, Proof = proof },
|
||||
new MoShiro.Protocol.AgentEndpoint { AgentId = new NodeId("agt_planted"), Host = "127.0.0.1", TcpPort = 2, PublicKey = key }, // 面板塞进来的:没有证明
|
||||
new MoShiro.Protocol.AgentEndpoint { AgentId = new NodeId("agt_swapped"), Host = "127.0.0.1", TcpPort = 3, PublicKey = Convert.ToBase64String(RandomNumberGenerator.GetBytes(91)), Proof = proof }, // 拿着别人的证明、换了自己的公钥
|
||||
],
|
||||
});
|
||||
Assert.Equal(["agt_approved"], directory.Agents.Select(a => a.AgentId));
|
||||
// 已经同步:用当前集合的内容拼一个没有服务器密钥的下一版
|
||||
(_, SoftwarePasskey extra) = await _backend.CreateUserWithPasskeyAsync("bystander");
|
||||
using SoftwarePasskey extraScope = extra;
|
||||
state = JsonNode.Parse(await op.Session.GetStringAsync("/api/admin/trust"))!;
|
||||
current = state["proposal"]!["statement"]!.GetValue<string>();
|
||||
}
|
||||
|
||||
string withoutServer = string.Join('\n', current.Split('\n').Where(l => !l.StartsWith("server=", StringComparison.Ordinal)));
|
||||
using HttpResponseMessage refused = await op.Session.PostAsync("/api/admin/trust", BackendFixture.Json(Sign(op.Passkey, withoutServer)));
|
||||
Assert.Equal(HttpStatusCode.BadRequest, refused.StatusCode); // 签下去会让固定了它的节点认不出这个面板
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user