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

270 lines
16 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;
namespace MoShiro.Backend.Tests;
/// <summary>
/// 信任根:操作员(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);
private readonly BackendFixture _backend;
public TrustTests(BackendFixture backend)
{
_backend = backend;
}
/// <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();
try
{
if (s_operators.TryGetValue(_backend, out Operator? existing))
{
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 签集合本身
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>浏览器做的事:生成码的密钥,种子派生认证串,passkey 对委托签名,创建码。返回种子和认证串。</summary>
private async Task<(string Seed, string Auth, HttpStatusCode Status)> CreateSignedCodeAsync(Operator op, string role = "probe", SoftwarePasskey? signer = null, long? notAfter = 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);
}
private async Task<Result<EnrollmentResult>> EnrollAsync(string auth, string? seed, string? genesis, string role = "probe", 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);
}
[Fact]
public async Task ASignedCode_EnrollsANode_ThatPinsTheOperatorAndCarriesTheProof()
{
Operator op = await OperatorAsync();
(string seed, string auth, HttpStatusCode status) = await CreateSignedCodeAsync(op);
Assert.Equal(HttpStatusCode.Created, status);
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());
Assert.True(enrolled.Value.Pinned);
// 节点本地:固定的创世、验证过的链、自己的证明;强制模式下对端必须带证明
TrustAnchor anchor = TrustAnchor.Load(directory, NullNodeLog.Instance);
Assert.True(anchor.Enforcing);
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");
}
[Fact]
public async Task ANodeWithAWrongPin_RefusesBeforeSendingAnything_AndTheCodeStaysUsable()
{
Operator op = await OperatorAsync();
(string seed, string auth, _) = await CreateSignedCodeAsync(op);
Result<EnrollmentResult> wrong = await EnrollAsync(auth, seed, new string('0', 64));
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());
}
[Fact]
public async Task TheSeedIsNeededToEnroll_ACodeAloneNeverGetsANodeIn()
{
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);
}
[Fact]
public async Task ARoleMismatch_OrAnExpiredGrant_OrAForeignSigner_IsRefused()
{
Operator op = await OperatorAsync();
(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);
(_, _, HttpStatusCode expired) = await CreateSignedCodeAsync(op, notAfter: TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds() - 1000);
Assert.Equal(HttpStatusCode.BadRequest, expired);
using var stranger = new SoftwarePasskey(_backend.BaseAddress.Host, Origin); // 没注册的 passkey 签的委托
(_, _, HttpStatusCode foreign) = await CreateSignedCodeAsync(op, signer: stranger);
Assert.Equal(HttpStatusCode.Forbidden, foreign);
}
[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 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);
// 匿名和只有 API key 的调用也没有可能立根
using HttpResponseMessage anonymous = await _backend.Anonymous.PostAsync("/api/admin/trust", BackendFixture.Json(unsigned));
Assert.Equal(HttpStatusCode.Unauthorized, anonymous.StatusCode);
}
[Fact]
public async Task APinnedAgent_OnlyAcceptsProbesTheOperatorApproved_EvenIfThePanelListsOthers()
{
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));
// 面板里有一个没有操作员证明的 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");
}
[Fact]
public async Task APinnedProbe_OnlyConnectsToAgentsTheOperatorApproved()
{
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);
(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 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
{
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));
}
}