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]
This commit is contained in:
1 parent
9bb6841dce
commit
a2f88a344b
89 files changed
+2145
-1195
No files matched your search
@@ -111,85 +111,13 @@ public sealed class OperatorTrustTests : IDisposable
|
||||
Assert.False(OperatorTrust.VerifyOperator(_bob.Sign("hello"), set)); // 不在集合里
|
||||
}
|
||||
|
||||
private sealed record Enrolled(OperatorSet Set, byte[] Seed, MemberProof Proof, byte[] NodeKey, byte[] PeerKey, string Auth);
|
||||
|
||||
private Enrolled Enroll(string role = "probe", long now = 1_000_000, long notBefore = 0, long notAfter = 2_000_000, TestAuthenticator? signer = null)
|
||||
{
|
||||
OperatorSet set = Genesis(_alice);
|
||||
using ECDsa code = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
byte[] seed = code.ExportParameters(true).D!;
|
||||
string auth = OperatorTrust.AuthToken(seed);
|
||||
var grant = new Grant(role, OperatorTrust.AuthHash(auth), code.ExportSubjectPublicKeyInfo(), notBefore, notAfter, 1);
|
||||
SignedStatement signedGrant = (signer ?? _alice).Sign(OperatorTrust.Encode(grant));
|
||||
|
||||
byte[] nodeKey = RandomNumberGenerator.GetBytes(91);
|
||||
byte[] peerKey = RandomNumberGenerator.GetBytes(91);
|
||||
using ECDsa? signing = OperatorTrust.CodeSigningKey(seed, grant.CodeKey);
|
||||
Assert.NotNull(signing);
|
||||
string signature = OperatorTrust.SignBinding(signing, new Binding("tokyo", role, nodeKey, peerKey, grant.AuthHash, now), out string statement);
|
||||
return new Enrolled(set, seed, new MemberProof(signedGrant, statement, signature), nodeKey, peerKey, auth);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AMember_VerifiesThroughTheWholeChain()
|
||||
{
|
||||
Enrolled e = Enroll();
|
||||
Assert.Equal(TrustFailure.None, OperatorTrust.VerifyMember(e.Set, e.Proof, e.NodeKey, e.PeerKey, "probe"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheSeed_IsNotRecoverableFromWhatTheBackendSees()
|
||||
{
|
||||
Enrolled e = Enroll();
|
||||
Assert.StartsWith("ja_", e.Auth, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(Base64Url.EncodeToString(e.Seed), e.Auth, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(Base64Url.EncodeToString(e.Seed), e.Proof.Grant.Statement + e.Proof.BindingStatement, StringComparison.Ordinal);
|
||||
Assert.Equal(e.Auth, OperatorTrust.AuthToken(e.Seed)); // 节点自己能算出同一个
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ABindingForOtherKeys_AnotherRole_OrAnExpiredGrant_IsRefused()
|
||||
{
|
||||
Enrolled e = Enroll();
|
||||
Assert.Equal(TrustFailure.KeyMismatch, OperatorTrust.VerifyMember(e.Set, e.Proof, RandomNumberGenerator.GetBytes(91), e.PeerKey, "probe")); // Backend 想把别的节点密钥挂在这个绑定上
|
||||
Assert.Equal(TrustFailure.KeyMismatch, OperatorTrust.VerifyMember(e.Set, e.Proof, e.NodeKey, RandomNumberGenerator.GetBytes(91), "probe"));
|
||||
Assert.Equal(TrustFailure.RoleMismatch, OperatorTrust.VerifyMember(e.Set, e.Proof, e.NodeKey, e.PeerKey, "agent")); // 一个 Probe 的绑定不能冒充 Agent
|
||||
|
||||
Enrolled late = Enroll(now: 3_000_000, notAfter: 2_000_000); // 有效期之后才签的绑定
|
||||
Assert.Equal(TrustFailure.GrantExpired, OperatorTrust.VerifyMember(late.Set, late.Proof, late.NodeKey, late.PeerKey, "probe"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AGrantNotSignedByTheOperator_OrABindingNotSignedByTheCodeKey_IsRefused()
|
||||
{
|
||||
using var stranger = new TestAuthenticator();
|
||||
Enrolled forged = Enroll(signer: stranger);
|
||||
Assert.Equal(TrustFailure.GrantNotSigned, OperatorTrust.VerifyMember(forged.Set, forged.Proof, forged.NodeKey, forged.PeerKey, "probe"));
|
||||
|
||||
// Backend 拿到了委托但没有种子:用自己的密钥签绑定
|
||||
Enrolled e = Enroll();
|
||||
using ECDsa backend = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
Binding stolen = OperatorTrust.ParseBinding(e.Proof.BindingStatement)!;
|
||||
string bogus = OperatorTrust.SignBinding(backend, stolen, out string statement);
|
||||
Assert.Equal(TrustFailure.BindingInvalid, OperatorTrust.VerifyMember(e.Set, new MemberProof(e.Proof.Grant, statement, bogus), e.NodeKey, e.PeerKey, "probe"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemovingAnOperatorKey_InvalidatesTheGrantsItSigned()
|
||||
{
|
||||
Enrolled e = Enroll();
|
||||
OperatorSet withoutAlice = Genesis(_bob) with { Version = 2, Previous = new string('a', 64) };
|
||||
Assert.Equal(TrustFailure.GrantNotSigned, OperatorTrust.VerifyMember(withoutAlice, e.Proof, e.NodeKey, e.PeerKey, "probe"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parsing_IsStrict()
|
||||
{
|
||||
Assert.Null(OperatorTrust.ParseGrant("moshiro-grant-v1\nrole=probe"));
|
||||
Assert.Null(OperatorTrust.ParseBinding("moshiro-binding-v1\nname=x"));
|
||||
Assert.Null(OperatorTrust.ParseSet("moshiro-opset-v2\nversion=1"));
|
||||
OperatorSet set = Genesis(_alice);
|
||||
Assert.Equal(set.Keys[0].CredentialId, OperatorTrust.ParseSet(OperatorTrust.Encode(set))!.Keys[0].CredentialId);
|
||||
Assert.Null(OperatorTrust.ParseSet(OperatorTrust.Encode(set with { Previous = "abc" }))); // 创世的 prev 必须是 0
|
||||
Assert.Null(OperatorTrust.ParseSet(OperatorTrust.Encode(set) + "\nnot-a-key"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
using System.Security.Cryptography;
|
||||
using Fleet.Identity;
|
||||
|
||||
namespace Fleet.Tests;
|
||||
|
||||
public sealed class SecureChannelTests
|
||||
{
|
||||
private const string Nonce = "n0nce-from-the-server";
|
||||
|
||||
private static (LinkCipher Node, LinkCipher Server) Pair(string nodeNonce = Nonce, string serverNonce = Nonce)
|
||||
{
|
||||
using var node = new EphemeralKey();
|
||||
using var server = new EphemeralKey();
|
||||
return (LinkCipher.ForNode(node, server.PublicKeySpki, nodeNonce), LinkCipher.ForServer(server, node.PublicKeySpki, serverNonce));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheServerProof_VerifiesOnlyForTheRightKey_Nonce_AndEphemeralKey()
|
||||
{
|
||||
using ECDsa serverKey = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
using ECDsa other = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
using var eph = new EphemeralKey();
|
||||
using var otherEph = new EphemeralKey();
|
||||
byte[] spki = serverKey.ExportSubjectPublicKeyInfo();
|
||||
byte[] proof = ServerHello.Sign(serverKey, Nonce, eph.PublicKeySpki);
|
||||
|
||||
Assert.True(ServerHello.Verify(spki, Nonce, eph.PublicKeySpki, proof));
|
||||
Assert.False(ServerHello.Verify(other.ExportSubjectPublicKeyInfo(), Nonce, eph.PublicKeySpki, proof)); // 别人的服务器密钥
|
||||
Assert.False(ServerHello.Verify(spki, "another-nonce", eph.PublicKeySpki, proof)); // 挪到别的挑战上
|
||||
Assert.False(ServerHello.Verify(spki, Nonce, otherEph.PublicKeySpki, proof)); // 中间人换了自己的临时公钥
|
||||
Assert.False(ServerHello.Verify(spki, Nonce, eph.PublicKeySpki, proof.AsSpan(0, proof.Length - 1)));
|
||||
Assert.False(ServerHello.Verify([1, 2, 3], Nonce, eph.PublicKeySpki, proof)); // 公钥不合法也不抛异常
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BothDirections_RoundTrip_AndKeepTheFrameKind()
|
||||
{
|
||||
(LinkCipher node, LinkCipher server) = Pair();
|
||||
using LinkCipher n = node;
|
||||
using LinkCipher s = server;
|
||||
|
||||
byte[] toServer = n.Seal(0, "hello server"u8);
|
||||
Assert.True(s.TryOpen(toServer, out byte kind, out byte[] data));
|
||||
Assert.Equal(0, kind);
|
||||
Assert.Equal("hello server"u8.ToArray(), data);
|
||||
|
||||
byte[] big = RandomNumberGenerator.GetBytes(300_000);
|
||||
byte[] toNode = s.Seal(1, big);
|
||||
Assert.True(n.TryOpen(toNode, out kind, out data));
|
||||
Assert.Equal(1, kind);
|
||||
Assert.Equal(big, data);
|
||||
Assert.Equal(big.Length + LinkCipher.Overhead + 1, toNode.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AReplayedReorderedTamperedOrReflectedFrame_IsRefused()
|
||||
{
|
||||
(LinkCipher node, LinkCipher server) = Pair();
|
||||
using LinkCipher n = node;
|
||||
using LinkCipher s = server;
|
||||
|
||||
byte[] first = n.Seal(0, "one"u8);
|
||||
byte[] second = n.Seal(0, "two"u8);
|
||||
Assert.False(s.TryOpen(second, out _, out _)); // 乱序(丢了第一帧)
|
||||
Assert.True(s.TryOpen(first, out _, out _));
|
||||
Assert.False(s.TryOpen(first, out _, out _)); // 重放
|
||||
Assert.True(s.TryOpen(second, out _, out _));
|
||||
|
||||
byte[] tampered = n.Seal(0, "three"u8);
|
||||
tampered[12] ^= 0x01;
|
||||
Assert.False(s.TryOpen(tampered, out _, out _)); // 改了一位
|
||||
|
||||
byte[] fromNode = n.Seal(0, "four"u8);
|
||||
(LinkCipher otherNode, LinkCipher otherServer) = Pair();
|
||||
using LinkCipher on = otherNode;
|
||||
using LinkCipher os = otherServer;
|
||||
Assert.False(os.TryOpen(fromNode, out _, out _)); // 另一条连接的密钥打不开
|
||||
|
||||
byte[] fromServer = s.Seal(0, "reflected"u8);
|
||||
Assert.False(s.TryOpen(fromServer, out _, out _)); // 方向不同:服务器发的东西不能原样塞回给服务器
|
||||
Assert.False(n.TryOpen([1, 2, 3], out _, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ADifferentNonce_GivesDifferentKeys()
|
||||
{
|
||||
(LinkCipher node, LinkCipher server) = Pair("nonce-a", "nonce-b"); // 两边对不上:没人能解开对方的东西
|
||||
using LinkCipher n = node;
|
||||
using LinkCipher s = server;
|
||||
Assert.False(s.TryOpen(n.Seal(0, "x"u8), out _, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnOperatorSet_CarriesTheServerKeys_ThroughTheChain()
|
||||
{
|
||||
using var alice = new TestAuthenticator();
|
||||
using ECDsa serverKey = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
byte[] spki = serverKey.ExportSubjectPublicKeyInfo();
|
||||
var set = new OperatorSet(1, OperatorTrust.Genesis, TestAuthenticator.RpId, [TestAuthenticator.Origin], [alice.Key], [spki]);
|
||||
string text = OperatorTrust.Encode(set);
|
||||
|
||||
OperatorSet parsed = OperatorTrust.ParseSet(text)!;
|
||||
Assert.Equal(spki, Assert.Single(parsed.Servers!));
|
||||
Assert.Equal(text, OperatorTrust.Encode(parsed)); // 编码和解析互逆,指纹稳定
|
||||
|
||||
SignedStatement genesis = alice.Sign(text);
|
||||
OperatorSet verified = OperatorTrust.VerifyChain(OperatorTrust.Fingerprint(text), [genesis])!;
|
||||
Assert.Equal(spki, Assert.Single(verified.Servers!));
|
||||
|
||||
// 服务器密钥只能写在凭证后面;顺序不对、内容不是 base64 都算坏
|
||||
Assert.Null(OperatorTrust.ParseSet(text + "\nkey=late:AAAA"));
|
||||
Assert.Null(OperatorTrust.ParseSet(text + "\nserver=***"));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user