Files
MoShiro/backend/fleet/src/Fleet.Identity/NodeIdentity.cs
T
justme a2f88a344b
ci/woodpecker/tag/gitea-release Pipeline is pending
ci/woodpecker/tag/linux-release Pipeline failed
ci/woodpecker/tag/windows-release Pipeline was canceled
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]
2026-10-08 03:21:44 +08:00

95 lines
3.4 KiB
C#

using System.Security.Cryptography;
using System.Text;
namespace Fleet.Identity;
/// <summary>签名用途前缀:同一份签名不能在不同场景下复用。产品可以追加自己的用途,但不能占用 fleet 保留的这些。</summary>
public static class SignaturePurpose
{
public const string Auth = "auth";
/// <summary>安全链路的认证:签名同时覆盖服务器和节点的临时公钥,把这次认证和这条加密链路绑在一起。</summary>
public const string AuthSecure = "auth2";
public const string Peer = "peer";
public const string Enroll = "enroll";
}
public static class SignatureString
{
public static string Build(string purpose, params string[] fields) =>
"fleet|v1|" + purpose + "|" + string.Join('|', fields);
}
/// <summary>节点身份:ECDSA P-256 密钥对。服务端只保存公钥(SPKI)。</summary>
public sealed class NodeIdentity : IDisposable
{
private readonly ECDsa _key;
private NodeIdentity(ECDsa key)
{
_key = key;
PublicKeySpki = key.ExportSubjectPublicKeyInfo();
}
public byte[] PublicKeySpki { get; }
public static NodeIdentity Generate() => new(ECDsa.Create(ECCurve.NamedCurves.nistP256));
public static NodeIdentity FromPkcs8(ReadOnlySpan<byte> pkcs8)
{
ECDsa key = ECDsa.Create();
key.ImportPkcs8PrivateKey(pkcs8, out _);
return new NodeIdentity(key);
}
public byte[] ExportPkcs8() => _key.ExportPkcs8PrivateKey();
/// <summary>与对端公钥(SPKI)做静态 ECDH,返回原始共享秘密。只用于派生对称密钥(敲门),必须再经 KDF,不能直接当密钥用。</summary>
public byte[] AgreeWith(ReadOnlySpan<byte> peerSpki)
{
using ECDiffieHellman own = ECDiffieHellman.Create();
own.ImportPkcs8PrivateKey(_key.ExportPkcs8PrivateKey(), out _);
using ECDiffieHellman peer = ECDiffieHellman.Create();
peer.ImportSubjectPublicKeyInfo(peerSpki, out _);
return own.DeriveRawSecretAgreement(peer.PublicKey);
}
public byte[] Sign(string purpose, params string[] fields) =>
_key.SignData(Encoding.UTF8.GetBytes(SignatureString.Build(purpose, fields)), HashAlgorithmName.SHA256,
DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
/// <summary>验证签名;公钥或签名格式不合法时返回 false,不抛异常。</summary>
public static bool Verify(ReadOnlySpan<byte> publicKeySpki, ReadOnlySpan<byte> signature, string purpose, params string[] fields)
{
try
{
using ECDsa key = ECDsa.Create();
key.ImportSubjectPublicKeyInfo(publicKeySpki, out _);
return key.VerifyData(Encoding.UTF8.GetBytes(SignatureString.Build(purpose, fields)), signature,
HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
}
catch (CryptographicException)
{
return false;
}
}
public static bool IsValidPublicKey(ReadOnlySpan<byte> publicKeySpki)
{
try
{
using ECDsa key = ECDsa.Create();
key.ImportSubjectPublicKeyInfo(publicKeySpki, out _);
return key.KeySize == 256;
}
catch (CryptographicException)
{
return false;
}
}
public static byte[] Fingerprint(ReadOnlySpan<byte> spki) => SHA256.HashData(spki);
public void Dispose() => _key.Dispose();
}