using System.Buffers.Text; using System.Globalization; using System.Security.Cryptography; using System.Text; namespace Fleet.Identity; /// passkey(WebAuthn)对一段文本签出的断言:challenge = SHA-256(Statement 的 UTF-8)。四个字段都是 base64url。 public sealed record SignedStatement(string Statement, string CredentialId, string AuthenticatorData, string ClientDataJson, string Signature); /// 一个操作员凭证:passkey 的凭证 id 和公钥(SPKI)。 public sealed record OperatorKey(string CredentialId, byte[] PublicKeySpki); /// /// 操作员集合的一个版本。RpId 和 Origins 也在里面:节点验 WebAuthn 断言要用,而它们被操作员的签名覆盖,Backend 改不了。 /// 创世版本(1)由它自己的某把凭证签名,指纹()在安装时固定到节点;之后每个版本由上一版里的凭证签名。 /// public sealed record OperatorSet(int Version, string Previous, string RpId, IReadOnlyList Origins, IReadOnlyList Keys); /// 委托:操作员授权了一个加入码。CodeKey 是这个码的公钥(SPKI),码的种子是对应的私钥,只在安装命令里。 public sealed record Grant(string Role, string AuthHash, byte[] CodeKey, long NotBeforeMs, long NotAfterMs, int Uses); /// 节点绑定:用码的私钥签的,把节点的两把公钥和这个码连起来。AtMs 必须落在委托的有效期内。 public sealed record Binding(string Name, string Role, byte[] NodePublicKey, byte[] PeerPublicKey, string AuthHash, long AtMs); /// 一个成员带的证明:委托(passkey 签)加绑定(码的私钥签)。 public sealed record MemberProof(SignedStatement Grant, string BindingStatement, string BindingSignature); public enum TrustFailure { None, Malformed, BrokenChain, GrantNotSigned, GrantExpired, BindingInvalid, KeyMismatch, RoleMismatch, } /// /// 信任根:操作员(passkey)→ 操作员集合 → 委托 → 绑定 → 节点公钥。设计见 docs/access.md 第 7 节。 /// 全是纯函数,只用 BCL,节点和 Backend 共用:节点用它确认「对端是我的操作员批准的」,Backend 用它拒绝没有合法绑定的注册。 /// 语句是 key=value 的文本,第一行是带版本的标题;签名覆盖整段文本,规范化靠固定的字段顺序和严格的解析。 /// 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(); text.Append(SetHeader).Append('\n') .Append("version=").Append(set.Version.ToString(CultureInfo.InvariantCulture)).Append('\n') .Append("prev=").Append(set.Previous).Append('\n') .Append("rp=").Append(set.RpId).Append('\n') .Append("origins=").Append(string.Join(',', set.Origins)); foreach (OperatorKey key in set.Keys) { text.Append("\nkey=").Append(key.CredentialId).Append(':').Append(Convert.ToBase64String(key.PublicKeySpki)); } 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)); // ───────────────────────── 解析(严格:多一行、少一行、顺序不对都算坏) ───────────────────────── public static OperatorSet? ParseSet(string text) { string[] lines = text.Split('\n'); if (lines.Length < 6 || lines[0] != SetHeader || !TryValue(lines[1], "version", out string version) || !int.TryParse(version, NumberStyles.None, CultureInfo.InvariantCulture, out int number) || number < 1 || !TryValue(lines[2], "prev", out string previous) || !TryValue(lines[3], "rp", out string rp) || rp.Length == 0 || !TryValue(lines[4], "origins", out string origins) || origins.Length == 0) { return null; } var keys = new List(); 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)) { 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); } 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); } // ───────────────────────── 指纹与验证 ───────────────────────── /// 一段语句的指纹(SHA-256,小写十六进制)。创世版本的指纹就是安装命令里固定的那个。 public static string Fingerprint(string statement) => Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(statement))); /// /// 验证操作员集合的链:第一项是创世版本(指纹等于 ,自签),其后每一项由上一版里的凭证签名、版本加一、prev 指向上一版的指纹、域名不变。 /// 返回最后一版;不对返回 null。 /// public static OperatorSet? VerifyChain(string pinnedGenesis, IReadOnlyList chain) { if (chain.Count == 0 || !string.Equals(Fingerprint(chain[0].Statement), pinnedGenesis, StringComparison.OrdinalIgnoreCase)) { return null; } OperatorSet? current = null; string? previousFingerprint = null; foreach (SignedStatement link in chain) { OperatorSet? next = ParseSet(link.Statement); if (next is null) { return null; } if (current is null) { if (next.Version != 1 || !VerifyOperator(link, next)) { return null; } } else if (next.Version != current.Version + 1 || next.Previous != previousFingerprint || next.RpId != current.RpId || !VerifyOperator(link, current)) { return null; } current = next; previousFingerprint = Fingerprint(link.Statement); } return current; } /// 断言是不是这个集合里某把凭证对 Statement 签的(要求用户验证;挑战是语句的 SHA-256)。 public static bool VerifyOperator(SignedStatement signed, OperatorSet set) { OperatorKey? key = set.Keys.FirstOrDefault(k => k.CredentialId == signed.CredentialId); if (key is null || !TryDecode(signed.AuthenticatorData, out byte[] authData) || !TryDecode(signed.ClientDataJson, out byte[] clientData) || !TryDecode(signed.Signature, out byte[] signature)) { return false; } return WebAuthnAssertion.Verify(key.PublicKeySpki, authData, clientData, signature, set.RpId, SHA256.HashData(Encoding.UTF8.GetBytes(signed.Statement)), set.Origins.Contains, requireUserVerification: true, out _); } /// /// 验证一个成员:委托由当前操作员集合里的凭证签(并且在有效期内),绑定由委托里的码公钥签, /// 绑定里的节点公钥、对等面公钥和角色对得上调用方手里的这个成员。 /// public static TrustFailure VerifyMember(OperatorSet current, MemberProof proof, ReadOnlySpan nodePublicKey, ReadOnlySpan 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; } } // ───────────────────────── 加入码的种子 ───────────────────────── /// /// 种子(32 字节,就是码的 P-256 私钥)派生出交给 Backend 的认证串:Backend 只会看到它的哈希,推不回种子, /// 所以 Backend 没法替这个码签绑定。HKDF-SHA256,空盐,用途标签在 info 里;浏览器端的 WebCrypto 用同样的参数。 /// public static string AuthToken(ReadOnlySpan seed) => "ja_" + Base64Url.EncodeToString(Derive(seed)); private static byte[] Derive(ReadOnlySpan seed) { byte[] output = new byte[32]; HKDF.DeriveKey(HashAlgorithmName.SHA256, seed, output, ReadOnlySpan.Empty, Encoding.UTF8.GetBytes(AuthLabel)); return output; } public static string AuthHash(string authToken) => Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(authToken))); /// 用种子和委托里的码公钥还原码的签名密钥(公钥从已验证的委托里来,种子里只有私钥标量)。 public static ECDsa? CodeSigningKey(ReadOnlySpan seed, ReadOnlySpan 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 = ""; if (!line.StartsWith(key + "=", StringComparison.Ordinal)) { return false; } value = line[(key.Length + 1)..]; 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 { data = Convert.FromBase64String(text); return data.Length > 0; } catch (FormatException) { data = []; return false; } } private static bool TryDecode(string text, out byte[] data) { data = []; return Base64Url.IsValid(text) && (data = Base64Url.DecodeFromChars(text)).Length > 0; } }