Files
MoShiro/backend/tests/MoShiro.Backend.Tests/TrustTests.cs
T

306 lines
18 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/access.md 第 7 节。
/// </summary>
[Collection("probe-e2e")]
public sealed class TrustTests
{
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 = "probe", 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();
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);
}
[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]
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]
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); // 签下去会让固定了它的节点认不出这个面板
}
}