Files
MoShiro/backend/tests/MoShiro.Backend.Tests/TrustTests.cs
T
justme a2f88a344b
ci/woodpecker/tag/gitea-release Pipeline is pending
ci/woodpecker/tag/linux-release Pipeline failed
ci/woodpecker/tag/windows-release Pipeline was canceled
feat!: genesis-pinned operator set, server identity with an encrypted control link, key-less join codes; release key continuity
Join codes need no passkey touch and carry only public values (genesis
fingerprint, owner credentials, installer hashes). Nodes pin the genesis and
owners at enrollment and only follow passkey-signed operator set versions.
The panel's server key is listed in the set; pinned nodes require its
signature and run the control link under AES-256-GCM. Password sessions carry
no capabilities. The updater adopts a next_key from a signed manifest.

Bump version to 0.2.0. [CI SKIP]
2026-10-08 03:21:44 +08:00

309 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 static readonly ConditionalWeakTable<BackendFixture, Operator> s_operators = [];
private static readonly SemaphoreSlim s_gate = new(1, 1);
private readonly BackendFixture _backend;
public TrustTests(BackendFixture backend)
{
_backend = backend;
}
/// <summary>一个已经建立了信任根的操作员:自己的用户、passkey、会话,以及创世指纹。</summary>
private sealed record Operator(HttpClient Session, SoftwarePasskey Passkey, string Genesis);
private async Task<Operator> OperatorAsync()
{
await s_gate.WaitAsync();
try
{
if (s_operators.TryGetValue(_backend, out Operator? existing))
{
return existing;
}
// 操作员:有 passkey、用 passkey 登录的会话(密码会话什么能力都没有)
(HttpClient session, SoftwarePasskey passkey, _) = await _backend.CreateStrongSessionAsync("operator");
// 创世要凭证窗口(立根的人要先证明自己),再用 passkey 签集合本身
await ElevateAsync(session, passkey);
JsonNode state = JsonNode.Parse(await session.GetStringAsync("/api/admin/trust"))!;
Assert.False(state["initialized"]!.GetValue<bool>());
string statement = state["proposal"]!["statement"]!.GetValue<string>();
Assert.Equal(Base64UrlOf(statement), state["proposal"]!["challenge"]!.GetValue<string>());
using HttpResponseMessage genesis = await session.PostAsync("/api/admin/trust", BackendFixture.Json(Sign(passkey, statement)));
Assert.True(genesis.StatusCode == HttpStatusCode.Created, await genesis.Content.ReadAsStringAsync());
string fingerprint = JsonNode.Parse(await genesis.Content.ReadAsStringAsync())!["genesis"]!.GetValue<string>();
Assert.Equal(OperatorTrust.Fingerprint(statement), fingerprint);
var made = new Operator(session, passkey, fingerprint);
s_operators.Add(_backend, made);
return made;
}
finally
{
s_gate.Release();
}
}
private static string Base64UrlOf(string statement) => Fleet.Core.Base64Url.Encode(SHA256.HashData(Encoding.UTF8.GetBytes(statement)));
/// <summary>passkey 对语句签名(挑战是语句的 SHA-256),拼成接口要的断言对象。</summary>
private static JsonObject Sign(SoftwarePasskey passkey, string statement)
{
JsonObject assertion = passkey.Assert(Base64UrlOf(statement));
return new JsonObject
{
["statement"] = statement,
["credential_id"] = assertion["credential_id"]!.DeepClone(),
["authenticator_data"] = assertion["authenticator_data"]!.DeepClone(),
["client_data_json"] = assertion["client_data_json"]!.DeepClone(),
["signature"] = assertion["signature"]!.DeepClone(),
};
}
private static async Task ElevateAsync(HttpClient session, SoftwarePasskey passkey)
{
using HttpResponseMessage options = await session.PostAsync("/api/admin/auth/elevation/options", BackendFixture.Json(new JsonObject { ["class"] = "credential" }));
JsonObject assertion = passkey.Assert(JsonNode.Parse(await options.Content.ReadAsStringAsync())!["challenge"]!.GetValue<string>());
assertion["class"] = "credential";
using HttpResponseMessage elevated = await session.PostAsync("/api/admin/auth/elevation", BackendFixture.Json(assertion));
Assert.Equal(HttpStatusCode.OK, elevated.StatusCode);
}
/// <summary>创建一个加入码(普通 API key 就行,不需要 passkey),返回响应里的码和三条安装命令。</summary>
private async Task<(string Code, JsonNode Commands, HttpStatusCode Status)> CreateCodeAsync(string role = "probe", string[]? controllers = null)
{
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_CanOnlyRegisterAPasskey_AndCreatingACodeNeedsNoPasskeyTouch()
{
// 密码登录的会话没有任何能力:连看节点列表都不行,只能注册 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);
// 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 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); // 签下去会让固定了它的节点认不出这个面板
}
}