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]
309 lines
18 KiB
C#
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); // 签下去会让固定了它的节点认不出这个面板
|
|
}
|
|
}
|