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
@@ -15,45 +15,21 @@ public sealed record OperatorKey(string CredentialId, byte[] PublicKeySpki);
|
||||
/// 操作员集合的一个版本。<c>RpId</c> 和 <c>Origins</c> 也在里面:节点验 WebAuthn 断言要用,而它们被操作员的签名覆盖,Backend 改不了。
|
||||
/// 创世版本(1)由它自己的某把凭证签名,指纹(<see cref="OperatorTrust.Fingerprint"/>)在安装时固定到节点;之后每个版本由上一版里的凭证签名。
|
||||
/// </summary>
|
||||
public sealed record OperatorSet(int Version, string Previous, string RpId, IReadOnlyList<string> Origins, IReadOnlyList<OperatorKey> Keys);
|
||||
|
||||
/// <summary>委托:操作员授权了一个加入码。<c>CodeKey</c> 是这个码的公钥(SPKI),码的种子是对应的私钥,只在安装命令里。</summary>
|
||||
public sealed record Grant(string Role, string AuthHash, byte[] CodeKey, long NotBeforeMs, long NotAfterMs, int Uses);
|
||||
|
||||
/// <summary>节点绑定:用码的私钥签的,把节点的两把公钥和这个码连起来。<c>AtMs</c> 必须落在委托的有效期内。</summary>
|
||||
public sealed record Binding(string Name, string Role, byte[] NodePublicKey, byte[] PeerPublicKey, string AuthHash, long AtMs);
|
||||
|
||||
/// <summary>一个成员带的证明:委托(passkey 签)加绑定(码的私钥签)。</summary>
|
||||
public sealed record MemberProof(SignedStatement Grant, string BindingStatement, string BindingSignature);
|
||||
|
||||
public enum TrustFailure
|
||||
{
|
||||
None,
|
||||
Malformed,
|
||||
BrokenChain,
|
||||
GrantNotSigned,
|
||||
GrantExpired,
|
||||
BindingInvalid,
|
||||
KeyMismatch,
|
||||
RoleMismatch,
|
||||
}
|
||||
/// <param name="Servers">服务器密钥(SPKI):操作员授权的 Backend 签名密钥。集合里登记了服务器密钥之后,节点要求服务器在认证挑战里用它签名,
|
||||
/// 并在这条链路上用应用层加密,所以 DNS 或证书层面的冒充拿不出这把密钥就骗不了节点。空 = 还没有启用。</param>
|
||||
public sealed record OperatorSet(int Version, string Previous, string RpId, IReadOnlyList<string> Origins, IReadOnlyList<OperatorKey> Keys, IReadOnlyList<byte[]>? Servers = null);
|
||||
|
||||
/// <summary>
|
||||
/// 信任根:操作员(passkey)→ 操作员集合 → 委托 → 绑定 → 节点公钥。设计见 docs/access.md 第 7 节。
|
||||
/// 全是纯函数,只用 BCL,节点和 Backend 共用:节点用它确认「对端是我的操作员批准的」,Backend 用它拒绝没有合法绑定的注册。
|
||||
/// 语句是 <c>key=value</c> 的文本,第一行是带版本的标题;签名覆盖整段文本,规范化靠固定的字段顺序和严格的解析。
|
||||
/// 信任根:操作员(passkey)→ 操作员集合 → 节点本地固定的属主。设计见 docs/access.md 第 7 节。
|
||||
/// 安装命令里带着创世指纹和属主(passkey 的凭证 id):节点把它们固定在本地,之后只认从创世出发、由操作员 passkey 签过名的集合版本,
|
||||
/// 属主必须是集合里的凭证。Backend 能转发集合,但签不出新的版本,也改不了节点已经固定的东西。
|
||||
/// 全是纯函数,只用 BCL,节点和 Backend 共用。语句是 <c>key=value</c> 的文本,第一行是带版本的标题;签名覆盖整段文本。
|
||||
/// </summary>
|
||||
public static class OperatorTrust
|
||||
{
|
||||
public const string SetHeader = "moshiro-opset-v1";
|
||||
public const string GrantHeader = "moshiro-grant-v1";
|
||||
public const string BindingHeader = "moshiro-binding-v1";
|
||||
public const string Genesis = "0";
|
||||
|
||||
private const string AuthLabel = "moshiro-join-auth-v1";
|
||||
|
||||
// ───────────────────────── 编码 ─────────────────────────
|
||||
|
||||
public static string Encode(OperatorSet set)
|
||||
{
|
||||
var text = new StringBuilder();
|
||||
@@ -67,21 +43,15 @@ public static class OperatorTrust
|
||||
text.Append("\nkey=").Append(key.CredentialId).Append(':').Append(Convert.ToBase64String(key.PublicKeySpki));
|
||||
}
|
||||
|
||||
foreach (byte[] server in set.Servers ?? [])
|
||||
{
|
||||
text.Append("\nserver=").Append(Convert.ToBase64String(server));
|
||||
}
|
||||
|
||||
return text.ToString();
|
||||
}
|
||||
|
||||
public static string Encode(Grant grant) =>
|
||||
string.Join(
|
||||
'\n', GrantHeader, "role=" + grant.Role, "auth=" + grant.AuthHash, "key=" + Convert.ToBase64String(grant.CodeKey),
|
||||
"nb=" + grant.NotBeforeMs.ToString(CultureInfo.InvariantCulture), "na=" + grant.NotAfterMs.ToString(CultureInfo.InvariantCulture), "uses=" + grant.Uses.ToString(CultureInfo.InvariantCulture));
|
||||
|
||||
public static string Encode(Binding binding) =>
|
||||
string.Join(
|
||||
'\n', BindingHeader, "name=" + binding.Name, "role=" + binding.Role, "pub=" + Convert.ToBase64String(binding.NodePublicKey),
|
||||
"peer=" + Convert.ToBase64String(binding.PeerPublicKey), "auth=" + binding.AuthHash, "at=" + binding.AtMs.ToString(CultureInfo.InvariantCulture));
|
||||
|
||||
// ───────────────────────── 解析(严格:多一行、少一行、顺序不对都算坏) ─────────────────────────
|
||||
|
||||
/// <summary>严格解析:多一行、少一行、顺序不对都算坏。</summary>
|
||||
public static OperatorSet? ParseSet(string text)
|
||||
{
|
||||
string[] lines = text.Split('\n');
|
||||
@@ -92,46 +62,26 @@ public static class OperatorTrust
|
||||
}
|
||||
|
||||
var keys = new List<OperatorKey>();
|
||||
var servers = new List<byte[]>();
|
||||
foreach (string line in lines.Skip(5))
|
||||
{
|
||||
if (!TryValue(line, "key", out string value) || value.IndexOf(':', StringComparison.Ordinal) is not (> 0 and int split) || !TryBase64(value[(split + 1)..], out byte[] spki))
|
||||
if (servers.Count == 0 && TryValue(line, "key", out string value) && value.IndexOf(':', StringComparison.Ordinal) is > 0 and int split && TryBase64(value[(split + 1)..], out byte[] spki))
|
||||
{
|
||||
keys.Add(new OperatorKey(value[..split], spki));
|
||||
}
|
||||
else if (TryValue(line, "server", out string serverText) && TryBase64(serverText, out byte[] serverKey))
|
||||
{
|
||||
servers.Add(serverKey);
|
||||
}
|
||||
else
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
keys.Add(new OperatorKey(value[..split], spki));
|
||||
}
|
||||
|
||||
return keys.Count == 0 || (number == 1) != (previous == Genesis) ? null : new OperatorSet(number, previous, rp, origins.Split(','), keys);
|
||||
return keys.Count == 0 || (number == 1) != (previous == Genesis) ? null : new OperatorSet(number, previous, rp, origins.Split(','), keys, servers);
|
||||
}
|
||||
|
||||
public static Grant? ParseGrant(string text)
|
||||
{
|
||||
string[] lines = text.Split('\n');
|
||||
if (lines.Length != 7 || lines[0] != GrantHeader || !TryValue(lines[1], "role", out string role) || !TryValue(lines[2], "auth", out string auth) || !TryValue(lines[3], "key", out string key) || !TryBase64(key, out byte[] codeKey)
|
||||
|| !TryLong(lines[4], "nb", out long notBefore) || !TryLong(lines[5], "na", out long notAfter) || !TryValue(lines[6], "uses", out string uses) || !int.TryParse(uses, NumberStyles.None, CultureInfo.InvariantCulture, out int count)
|
||||
|| role.Length == 0 || auth.Length != 64)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Grant(role, auth, codeKey, notBefore, notAfter, count);
|
||||
}
|
||||
|
||||
public static Binding? ParseBinding(string text)
|
||||
{
|
||||
string[] lines = text.Split('\n');
|
||||
if (lines.Length != 7 || lines[0] != BindingHeader || !TryValue(lines[1], "name", out string name) || !TryValue(lines[2], "role", out string role) || !TryValue(lines[3], "pub", out string pub) || !TryBase64(pub, out byte[] node)
|
||||
|| !TryValue(lines[4], "peer", out string peer) || !TryBase64(peer, out byte[] peerKey) || !TryValue(lines[5], "auth", out string auth) || !TryLong(lines[6], "at", out long at) || auth.Length != 64)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Binding(name, role, node, peerKey, auth, at);
|
||||
}
|
||||
|
||||
// ───────────────────────── 指纹与验证 ─────────────────────────
|
||||
|
||||
/// <summary>一段语句的指纹(SHA-256,小写十六进制)。创世版本的指纹就是安装命令里固定的那个。</summary>
|
||||
public static string Fingerprint(string statement) => Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(statement)));
|
||||
|
||||
@@ -187,111 +137,6 @@ public static class OperatorTrust
|
||||
return WebAuthnAssertion.Verify(key.PublicKeySpki, authData, clientData, signature, set.RpId, SHA256.HashData(Encoding.UTF8.GetBytes(signed.Statement)), set.Origins.Contains, requireUserVerification: true, out _);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证一个对端:和 <see cref="VerifyMember"/> 一样,只是调用方手里只有它用来认证这个对端的那把密钥(对等面公钥),
|
||||
/// 所以只核对绑定里的对等面公钥;节点身份公钥由 Backend 在注册时核对。
|
||||
/// </summary>
|
||||
public static TrustFailure VerifyPeer(OperatorSet current, MemberProof proof, ReadOnlySpan<byte> peerPublicKey, string role)
|
||||
{
|
||||
Binding? binding = ParseBinding(proof.BindingStatement);
|
||||
return binding is null ? TrustFailure.Malformed : VerifyMember(current, proof, binding.NodePublicKey, peerPublicKey, role);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证一个成员:委托由当前操作员集合里的凭证签(并且在有效期内),绑定由委托里的码公钥签,
|
||||
/// 绑定里的节点公钥、对等面公钥和角色对得上调用方手里的这个成员。
|
||||
/// </summary>
|
||||
public static TrustFailure VerifyMember(OperatorSet current, MemberProof proof, ReadOnlySpan<byte> nodePublicKey, ReadOnlySpan<byte> peerPublicKey, string role)
|
||||
{
|
||||
Grant? grant = ParseGrant(proof.Grant.Statement);
|
||||
Binding? binding = ParseBinding(proof.BindingStatement);
|
||||
if (grant is null || binding is null || !TryDecode(proof.BindingSignature, out byte[] bindingSignature))
|
||||
{
|
||||
return TrustFailure.Malformed;
|
||||
}
|
||||
|
||||
if (!VerifyOperator(proof.Grant, current))
|
||||
{
|
||||
return TrustFailure.GrantNotSigned;
|
||||
}
|
||||
|
||||
if (binding.AtMs < grant.NotBeforeMs || binding.AtMs > grant.NotAfterMs)
|
||||
{
|
||||
return TrustFailure.GrantExpired;
|
||||
}
|
||||
|
||||
if (binding.AuthHash != grant.AuthHash || binding.Role != grant.Role)
|
||||
{
|
||||
return TrustFailure.KeyMismatch;
|
||||
}
|
||||
|
||||
if (!binding.NodePublicKey.AsSpan().SequenceEqual(nodePublicKey) || !binding.PeerPublicKey.AsSpan().SequenceEqual(peerPublicKey))
|
||||
{
|
||||
return TrustFailure.KeyMismatch;
|
||||
}
|
||||
|
||||
if (grant.Role != role)
|
||||
{
|
||||
return TrustFailure.RoleMismatch;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using ECDsa codeKey = ECDsa.Create();
|
||||
codeKey.ImportSubjectPublicKeyInfo(grant.CodeKey, out _);
|
||||
return codeKey.VerifyData(Encoding.UTF8.GetBytes(proof.BindingStatement), bindingSignature, HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence)
|
||||
? TrustFailure.None
|
||||
: TrustFailure.BindingInvalid;
|
||||
}
|
||||
catch (CryptographicException)
|
||||
{
|
||||
return TrustFailure.BindingInvalid;
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────── 加入码的种子 ─────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 种子(32 字节,就是码的 P-256 私钥)派生出交给 Backend 的认证串:Backend 只会看到它的哈希,推不回种子,
|
||||
/// 所以 Backend 没法替这个码签绑定。HKDF-SHA256,空盐,用途标签在 info 里;浏览器端的 WebCrypto 用同样的参数。
|
||||
/// </summary>
|
||||
public static string AuthToken(ReadOnlySpan<byte> seed) =>
|
||||
"ja_" + Base64Url.EncodeToString(Derive(seed));
|
||||
|
||||
private static byte[] Derive(ReadOnlySpan<byte> seed)
|
||||
{
|
||||
byte[] output = new byte[32];
|
||||
HKDF.DeriveKey(HashAlgorithmName.SHA256, seed, output, ReadOnlySpan<byte>.Empty, Encoding.UTF8.GetBytes(AuthLabel));
|
||||
return output;
|
||||
}
|
||||
|
||||
public static string AuthHash(string authToken) => Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(authToken)));
|
||||
|
||||
/// <summary>用种子和委托里的码公钥还原码的签名密钥(公钥从已验证的委托里来,种子里只有私钥标量)。</summary>
|
||||
public static ECDsa? CodeSigningKey(ReadOnlySpan<byte> seed, ReadOnlySpan<byte> codeKeySpki)
|
||||
{
|
||||
try
|
||||
{
|
||||
using ECDsa publicOnly = ECDsa.Create();
|
||||
publicOnly.ImportSubjectPublicKeyInfo(codeKeySpki, out _);
|
||||
ECParameters parameters = publicOnly.ExportParameters(false);
|
||||
parameters.D = seed.ToArray();
|
||||
return ECDsa.Create(parameters);
|
||||
}
|
||||
catch (CryptographicException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static string SignBinding(ECDsa codeKey, Binding binding, out string statement)
|
||||
{
|
||||
statement = Encode(binding);
|
||||
return Base64Url.EncodeToString(codeKey.SignData(Encoding.UTF8.GetBytes(statement), HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence));
|
||||
}
|
||||
|
||||
// ───────────────────────── 小工具 ─────────────────────────
|
||||
|
||||
private static bool TryValue(string line, string key, out string value)
|
||||
{
|
||||
value = "";
|
||||
@@ -304,12 +149,6 @@ public static class OperatorTrust
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryLong(string line, string key, out long value)
|
||||
{
|
||||
value = 0;
|
||||
return TryValue(line, key, out string text) && long.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out value);
|
||||
}
|
||||
|
||||
private static bool TryBase64(string text, out byte[] data)
|
||||
{
|
||||
try
|
||||
|
||||
Reference in new issue
Block a user