323 lines
15 KiB
C#
323 lines
15 KiB
C#
using System.Buffers.Text;
|
||
using System.Globalization;
|
||
using System.Security.Cryptography;
|
||
using System.Text;
|
||
|
||
namespace Fleet.Identity;
|
||
|
||
/// <summary>passkey(WebAuthn)对一段文本签出的断言:<c>challenge = SHA-256(Statement 的 UTF-8)</c>。四个字段都是 base64url。</summary>
|
||
public sealed record SignedStatement(string Statement, string CredentialId, string AuthenticatorData, string ClientDataJson, string Signature);
|
||
|
||
/// <summary>一个操作员凭证:passkey 的凭证 id 和公钥(SPKI)。</summary>
|
||
public sealed record OperatorKey(string CredentialId, byte[] PublicKeySpki);
|
||
|
||
/// <summary>
|
||
/// 操作员集合的一个版本。<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,
|
||
}
|
||
|
||
/// <summary>
|
||
/// 信任根:操作员(passkey)→ 操作员集合 → 委托 → 绑定 → 节点公钥。设计见 docs/access.md 第 7 节。
|
||
/// 全是纯函数,只用 BCL,节点和 Backend 共用:节点用它确认「对端是我的操作员批准的」,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();
|
||
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<OperatorKey>();
|
||
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);
|
||
}
|
||
|
||
// ───────────────────────── 指纹与验证 ─────────────────────────
|
||
|
||
/// <summary>一段语句的指纹(SHA-256,小写十六进制)。创世版本的指纹就是安装命令里固定的那个。</summary>
|
||
public static string Fingerprint(string statement) => Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(statement)));
|
||
|
||
/// <summary>
|
||
/// 验证操作员集合的链:第一项是创世版本(指纹等于 <paramref name="pinnedGenesis"/>,自签),其后每一项由上一版里的凭证签名、版本加一、<c>prev</c> 指向上一版的指纹、域名不变。
|
||
/// 返回最后一版;不对返回 null。
|
||
/// </summary>
|
||
public static OperatorSet? VerifyChain(string pinnedGenesis, IReadOnlyList<SignedStatement> 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;
|
||
}
|
||
|
||
/// <summary>断言是不是这个集合里某把凭证对 <c>Statement</c> 签的(要求用户验证;挑战是语句的 SHA-256)。</summary>
|
||
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 _);
|
||
}
|
||
|
||
/// <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 = "";
|
||
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;
|
||
}
|
||
}
|