feat!: genesis-pinned operator set, server identity with an encrypted control link, key-less join codes; release key continuity
ci/woodpecker/tag/gitea-release Pipeline is pending
ci/woodpecker/tag/linux-release Pipeline failed
ci/woodpecker/tag/windows-release Pipeline was canceled

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]
This commit is contained in:
justme committed 2026-10-08 03:21:44 +08:00
1 parent 9bb6841dce
commit a2f88a344b
89 files changed
+2145 -1195

No files matched your search

+151 -112
View File
@@ -8,17 +8,18 @@ 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)签操作员集合和加入码的委托,节点从固定的创世指纹出发验证;码的种子面板看不到。
/// 信任根:操作员(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);
@@ -32,8 +33,6 @@ public sealed class TrustTests
/// <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();
@@ -44,20 +43,8 @@ public sealed class TrustTests
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、用 passkey 登录的会话(密码会话什么能力都没有)
(HttpClient session, SoftwarePasskey passkey, _) = await _backend.CreateStrongSessionAsync("operator");
// 创世要凭证窗口(立根的人要先证明自己),再用 passkey 签集合本身
await ElevateAsync(session, passkey);
@@ -105,98 +92,127 @@ public sealed class TrustTests
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)
/// <summary>创建一个加入码(普通 API key 就行,不需要 passkey),返回响应里的码和三条安装命令。</summary>
private async Task<(string Code, JsonNode Commands, HttpStatusCode Status)> CreateCodeAsync(string role = "probe", string[]? controllers = 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);
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 auth, string? seed, string? genesis, string role = "probe", string? directory = null)
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, auth, _backend.UniqueName("node"), directory, "0.0.0-test", null, TimeProvider.System, default, seed, genesis, role);
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 ASignedCode_EnrollsANode_ThatPinsTheOperatorAndCarriesTheProof()
public async Task TheInstallCommand_CarriesOnlyPublicValues_AndChecksTheScriptHashFirst()
{
Operator op = await OperatorAsync();
(string seed, string auth, HttpStatusCode status) = await CreateSignedCodeAsync(op);
(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(auth, seed, op.Genesis, directory: directory);
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.Enforcing);
Assert.True(anchor.Pinned);
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");
Assert.Equal([op.Passkey.Id], anchor.Controllers);
Assert.True(anchor.Version >= 1);
}
[Fact]
public async Task ANodeWithAWrongPin_RefusesBeforeSendingAnything_AndTheCodeStaysUsable()
{
Operator op = await OperatorAsync();
(string seed, string auth, _) = await CreateSignedCodeAsync(op);
(string code, _, _) = await CreateCodeAsync();
Result<EnrollmentResult> wrong = await EnrollAsync(auth, seed, new string('0', 64));
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> right = await EnrollAsync(auth, seed, op.Genesis); // 码没被消耗
Assert.True(right.IsSuccess, right.IsSuccess ? "" : right.Error.ToString());
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 TheSeedIsNeededToEnroll_ACodeAloneNeverGetsANodeIn()
public async Task APlainCode_StillWorksWithoutAPin_ButTheNodeIsNotPinned()
{
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);
(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 ARoleMismatch_OrAnExpiredGrant_OrAForeignSigner_IsRefused()
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);
(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);
// 账号下多了一把 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>();
(_, _, HttpStatusCode expired) = await CreateSignedCodeAsync(op, notAfter: TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds() - 1000);
Assert.Equal(HttpStatusCode.BadRequest, expired);
// 不是操作员签的(新来的 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 var stranger = new SoftwarePasskey(_backend.BaseAddress.Host, Origin); // 没注册的 passkey 签的委托
(_, _, HttpStatusCode foreign) = await CreateSignedCodeAsync(op, signer: stranger);
Assert.Equal(HttpStatusCode.Forbidden, foreign);
// 现有操作员签:收下,随名册下发给节点,节点从固定的创世验过之后采用
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]
@@ -207,63 +223,86 @@ public sealed class TrustTests
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);
// 语句被换掉,签名对不上:不收
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);
// 匿名和只有 API key 的调用也没有可能立根
using HttpResponseMessage anonymous = await _backend.Anonymous.PostAsync("/api/admin/trust", BackendFixture.Json(unsigned));
// 匿名没有可能立根或改根
using HttpResponseMessage anonymous = await _backend.Anonymous.PostAsync("/api/admin/trust", BackendFixture.Json(tampered));
Assert.Equal(HttpStatusCode.Unauthorized, anonymous.StatusCode);
}
[Fact]
public async Task APinnedAgent_OnlyAcceptsProbesTheOperatorApproved_EvenIfThePanelListsOthers()
public async Task APasswordSession_CanOnlyRegisterAPasskey_AndCreatingACodeNeedsNoPasskeyTouch()
{
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));
// 密码登录的会话没有任何能力:连看节点列表都不行,只能注册 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);
// 面板里有一个没有操作员证明的 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");
// 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 APinnedProbe_OnlyConnectsToAgentsTheOperatorApproved()
public async Task TheServerKey_IsSignedIntoTheOperatorSet_AndOnlyThatServerPassesTheNodesCheck()
{
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);
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 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 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); // 集合里登记了服务器密钥:节点要求服务器证明自己
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
// 真服务器的证明过得了节点的校验;任何别的密钥(冒充者)、或者换了临时公钥的都过不了
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)
{
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));
// 已经同步:用当前集合的内容拼一个没有服务器密钥的下一版
(_, 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); // 签下去会让固定了它的节点认不出这个面板
}
}