Files
MoShiro/backend/tests/MoShiro.Backend.Tests/TrustTests.cs
T
justme ad5efc62ff agent: unify listening and dialing with directed mutual measurements
Replace Probe with Agent across node runtime, Fleet contracts, backend state, API and storage. Report incoming sessions and outgoing link health through each Agent uplink, and retain directed link controls and retry.

Update protocol fixtures and end-to-end coverage for the unified model; accept requested cancellation during the Fleet timeout test cleanup.
2026-10-21 11:55:36 +08:00

316 lines
19 KiB
C#

using System.Net;
using System.Net.Http.Headers;
using System.Runtime.CompilerServices;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Nodes;
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)签操作员集合;安装命令里带着创世指纹和属主(都是公开值),节点注册时验证面板的集合并固定在本地。
/// 设计见 docs/security/access.md 第 8 节。
/// </summary>
public sealed class TrustTests : IClassFixture<BackendFixture>
{
private readonly BackendFixture _backend;
public TrustTests(BackendFixture backend)
{
_backend = backend;
}
private Task<Operator> OperatorAsync() => SharedOperator.GetAsync(_backend);
private static string Base64UrlOf(string statement) => SharedOperator.Base64UrlOf(statement);
private static JsonObject Sign(SoftwarePasskey passkey, string statement) => SharedOperator.Sign(passkey, statement);
/// <summary>创建一个加入码(普通 API key 就行,不需要 passkey),返回响应里的码和三条安装命令。</summary>
private async Task<(string Code, JsonNode Commands, HttpStatusCode Status)> CreateCodeAsync(string role = "agent", string[]? controllers = null)
{
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 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 { Timeout = TimeSpan.FromSeconds(15) };
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 TheInstallCommand_CarriesOnlyPublicValues_AndChecksTheScriptHashFirst()
{
Operator op = await OperatorAsync();
(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);
string openwrt = commands["openwrt"]!.GetValue<string>();
string nativeScript = await _backend.Anonymous.GetStringAsync("/install/openwrt.sh");
string nativeSha = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(nativeScript)));
Assert.Contains($"echo \"{nativeSha} $f\" | sha256sum -c -", openwrt, StringComparison.Ordinal);
Assert.Contains($"MOSHIRO_CODE='{code}'", openwrt, StringComparison.Ordinal);
Assert.Contains($"MOSHIRO_GENESIS='{op.Genesis}'", openwrt, StringComparison.Ordinal);
Assert.Contains(op.Passkey.Id, openwrt, StringComparison.Ordinal);
Assert.Contains("MOSHIRO_CHANNEL='release'", openwrt, StringComparison.Ordinal);
Assert.EndsWith("sh \"$f\" install", openwrt, 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(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.Pinned);
Assert.Equal(op.Genesis, anchor.Genesis);
Assert.Equal([op.Passkey.Id], anchor.Controllers);
Assert.True(anchor.Version >= 1);
}
[Fact]
public async Task ANodeWithAWrongPin_RefusesBeforeSendingAnything_AndTheCodeStaysUsable()
{
Operator op = await OperatorAsync();
(string code, _, _) = await CreateCodeAsync();
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> 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 APlainCode_StillWorksWithoutAPin_ButTheNodeIsNotPinned()
{
(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 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]
[Trait("Category", "Slow")]
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);
// 账号下多了一把 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>();
// 不是操作员签的(新来的 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 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]
public async Task TheGenesis_IsSetOnce_AndNeedsTheOperatorsSignature()
{
Operator op = await OperatorAsync();
JsonNode state = JsonNode.Parse(await op.Session.GetStringAsync("/api/admin/trust"))!;
Assert.True(state["initialized"]!.GetValue<bool>());
Assert.Equal(op.Genesis, state["genesis"]!.GetValue<string>());
// 语句被换掉,签名对不上:不收
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);
// 匿名没有可能立根或改根
using HttpResponseMessage anonymous = await _backend.Anonymous.PostAsync("/api/admin/trust", BackendFixture.Json(tampered));
Assert.Equal(HttpStatusCode.Unauthorized, anonymous.StatusCode);
}
[Fact]
public async Task APasswordSession_ManagesAsUsual_AndCreatingACodeNeedsNoPasskey()
{
// 密码登录保留:日常管理(看列表、创建加入码)不需要 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);
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.NotEmpty(me["capabilities"]!.AsArray());
Assert.Equal("password", me["auth_method"]!.GetValue<string>());
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 登录的会话创建码:同样不需要再触碰(创建码不是信任动作,信任在属主固定里)
(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 TheTrustState_SaysWhichAccountsPasskeyEachOperatorIs()
{
// 添加节点时选属主、信任页都要显示「哪个账户的哪把 passkey」,不只是一串凭证 id
Operator op = await OperatorAsync();
JsonNode state = JsonNode.Parse(await op.Session.GetStringAsync("/api/admin/trust"))!;
JsonNode detail = state["operator_details"]!.AsArray().First(d => d!["credential_id"]!.GetValue<string>() == op.Passkey.Id)!;
Assert.False(string.IsNullOrEmpty(detail["passkey"]?.GetValue<string>()));
Assert.False(string.IsNullOrEmpty(detail["account"]?.GetValue<string>()));
Assert.Equal(state["operators"]!.AsArray().Count, state["operator_details"]!.AsArray().Count);
}
[Fact]
[Trait("Category", "Slow")]
public async Task APinnedAgent_ReportsItsOwner_AndThePanelFindsTheAccountAndPasskey()
{
// 宿主(属主)要求在节点这一侧:节点把安装时固定的属主报上来,面板顺着凭证 id 反查出它绑定在哪个账户的哪把 passkey 上
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]));
JsonNode? owner = null;
await BackendFixture.EventuallyAsync(
async () =>
{
JsonNode info = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/build-info");
owner = info["nodes"]!.AsArray().FirstOrDefault(n => n!["id"]!.GetValue<string>() == agent.Id)?["owners"]?.AsArray().FirstOrDefault();
return owner is not null;
},
TimeSpan.FromSeconds(30),
"the agent reports its owner in hello");
Assert.Equal(op.Passkey.Id, owner!["credential_id"]!.GetValue<string>());
Assert.False(string.IsNullOrEmpty(owner["passkey"]?.GetValue<string>()));
Assert.False(string.IsNullOrEmpty(owner["account"]?.GetValue<string>()));
}
[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()
{
Operator op = await OperatorAsync();
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 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); // 集合里登记了服务器密钥:节点要求服务器证明自己
// 真服务器的证明过得了节点的校验;任何别的密钥(冒充者)、或者换了临时公钥的都过不了
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)
{
// 已经同步:用当前集合的内容拼一个没有服务器密钥的下一版
(_, 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); // 签下去会让固定了它的节点认不出这个面板
}
}