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]
124 lines
6.4 KiB
C#
124 lines
6.4 KiB
C#
using System.Buffers.Text;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using Fleet.Identity;
|
|
|
|
namespace Fleet.Tests;
|
|
|
|
/// <summary>软件 passkey:只做断言(navigator.credentials.get 的结果),用来测信任链。</summary>
|
|
internal sealed class TestAuthenticator : IDisposable
|
|
{
|
|
public const string RpId = "panel.example.com";
|
|
public const string Origin = "https://panel.example.com";
|
|
|
|
private readonly ECDsa _key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
|
private uint _counter;
|
|
|
|
public string CredentialId { get; } = Base64Url.EncodeToString(RandomNumberGenerator.GetBytes(32));
|
|
|
|
public OperatorKey Key => new(CredentialId, _key.ExportSubjectPublicKeyInfo());
|
|
|
|
public SignedStatement Sign(string statement, byte flags = 0x05, string origin = Origin)
|
|
{
|
|
byte[] challenge = SHA256.HashData(Encoding.UTF8.GetBytes(statement));
|
|
_counter++;
|
|
byte[] authData = [.. SHA256.HashData(Encoding.UTF8.GetBytes(RpId)), flags, (byte)(_counter >> 24), (byte)(_counter >> 16), (byte)(_counter >> 8), (byte)_counter];
|
|
byte[] clientData = Encoding.UTF8.GetBytes($"{{\"type\":\"webauthn.get\",\"challenge\":\"{Base64Url.EncodeToString(challenge)}\",\"origin\":\"{origin}\",\"crossOrigin\":false}}");
|
|
byte[] signature = _key.SignData([.. authData, .. SHA256.HashData(clientData)], HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence);
|
|
return new SignedStatement(statement, CredentialId, Base64Url.EncodeToString(authData), Base64Url.EncodeToString(clientData), Base64Url.EncodeToString(signature));
|
|
}
|
|
|
|
public void Dispose() => _key.Dispose();
|
|
}
|
|
|
|
public sealed class OperatorTrustTests : IDisposable
|
|
{
|
|
private readonly TestAuthenticator _alice = new();
|
|
private readonly TestAuthenticator _bob = new();
|
|
|
|
public void Dispose()
|
|
{
|
|
_alice.Dispose();
|
|
_bob.Dispose();
|
|
}
|
|
|
|
private OperatorSet Genesis(params TestAuthenticator[] keys) =>
|
|
new(1, OperatorTrust.Genesis, TestAuthenticator.RpId, [TestAuthenticator.Origin], [.. keys.Select(k => k.Key)]);
|
|
|
|
private SignedStatement SignedGenesis(OperatorSet set, TestAuthenticator signer) => signer.Sign(OperatorTrust.Encode(set));
|
|
|
|
[Fact]
|
|
public void TheGenesis_IsPinnedByItsFingerprint_AndMustBeSelfSigned()
|
|
{
|
|
OperatorSet set = Genesis(_alice);
|
|
SignedStatement genesis = SignedGenesis(set, _alice);
|
|
string pin = OperatorTrust.Fingerprint(genesis.Statement);
|
|
|
|
Assert.NotNull(OperatorTrust.VerifyChain(pin, [genesis]));
|
|
Assert.Null(OperatorTrust.VerifyChain(new string('0', 64), [genesis])); // 指纹对不上:Backend 换了一份创世
|
|
Assert.Null(OperatorTrust.VerifyChain(pin, [SignedGenesis(set, _bob)])); // 不是集合里的凭证签的(也改不了指纹)
|
|
Assert.Null(OperatorTrust.VerifyChain(pin, []));
|
|
}
|
|
|
|
[Fact]
|
|
public void ABackendThatSwapsTheKeys_CannotMakeAChainTheNodeAccepts()
|
|
{
|
|
SignedStatement real = SignedGenesis(Genesis(_alice), _alice);
|
|
string pin = OperatorTrust.Fingerprint(real.Statement);
|
|
|
|
using var attacker = new TestAuthenticator();
|
|
SignedStatement forged = SignedGenesis(Genesis(attacker), attacker); // 自洽的假创世,但指纹不同
|
|
Assert.Null(OperatorTrust.VerifyChain(pin, [forged]));
|
|
|
|
// 在真创世后面接一个攻击者签的更新:上一版里没有他的凭证
|
|
var update = new OperatorSet(2, OperatorTrust.Fingerprint(real.Statement), TestAuthenticator.RpId, [TestAuthenticator.Origin], [_alice.Key, attacker.Key]);
|
|
Assert.Null(OperatorTrust.VerifyChain(pin, [real, attacker.Sign(OperatorTrust.Encode(update))]));
|
|
}
|
|
|
|
[Fact]
|
|
public void AnUpdate_NeedsAKeyOfThePreviousVersion_AndFollowsTheChain()
|
|
{
|
|
SignedStatement genesis = SignedGenesis(Genesis(_alice), _alice);
|
|
string pin = OperatorTrust.Fingerprint(genesis.Statement);
|
|
var second = new OperatorSet(2, OperatorTrust.Fingerprint(genesis.Statement), TestAuthenticator.RpId, [TestAuthenticator.Origin], [_alice.Key, _bob.Key]);
|
|
SignedStatement signedSecond = _alice.Sign(OperatorTrust.Encode(second));
|
|
|
|
OperatorSet? current = OperatorTrust.VerifyChain(pin, [genesis, signedSecond]);
|
|
Assert.Equal(2, current?.Version);
|
|
Assert.Equal(2, current?.Keys.Count);
|
|
|
|
// 新加的 bob 可以签下一版(上一版里有他)
|
|
var third = new OperatorSet(3, OperatorTrust.Fingerprint(signedSecond.Statement), TestAuthenticator.RpId, [TestAuthenticator.Origin], [_bob.Key]);
|
|
Assert.Equal(3, OperatorTrust.VerifyChain(pin, [genesis, signedSecond, _bob.Sign(OperatorTrust.Encode(third))])?.Version);
|
|
|
|
// 版本号跳了、prev 不对、域名换了:都不行
|
|
var skipped = second with { Version = 3 };
|
|
Assert.Null(OperatorTrust.VerifyChain(pin, [genesis, _alice.Sign(OperatorTrust.Encode(skipped))]));
|
|
var wrongPrev = second with { Previous = new string('a', 64) };
|
|
Assert.Null(OperatorTrust.VerifyChain(pin, [genesis, _alice.Sign(OperatorTrust.Encode(wrongPrev))]));
|
|
var otherRp = second with { RpId = "evil.example" };
|
|
Assert.Null(OperatorTrust.VerifyChain(pin, [genesis, _alice.Sign(OperatorTrust.Encode(otherRp))]));
|
|
}
|
|
|
|
[Fact]
|
|
public void AnAssertion_NeedsUserVerification_TheRightOrigin_AndTheExactStatement()
|
|
{
|
|
OperatorSet set = Genesis(_alice);
|
|
Assert.True(OperatorTrust.VerifyOperator(_alice.Sign("hello"), set));
|
|
Assert.False(OperatorTrust.VerifyOperator(_alice.Sign("hello", flags: 0x01), set)); // 没有用户验证
|
|
Assert.False(OperatorTrust.VerifyOperator(_alice.Sign("hello", origin: "https://evil.example"), set));
|
|
Assert.False(OperatorTrust.VerifyOperator(_alice.Sign("hello") with { Statement = "hellO" }, set)); // 语句被改
|
|
Assert.False(OperatorTrust.VerifyOperator(_bob.Sign("hello"), set)); // 不在集合里
|
|
}
|
|
|
|
[Fact]
|
|
public void Parsing_IsStrict()
|
|
{
|
|
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"));
|
|
}
|
|
}
|