using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using System.Text; using System.Text.RegularExpressions; using Lskj.CommandKernel; using Newtonsoft.Json; using Newtonsoft.Json.Linq; namespace Lskj.AgentBridge { public interface IBusinessAdapterReadinessAttestor { BusinessAdapterReadiness Attest( string workflow, string moduleCode, BusinessAdapterReadiness databaseReadiness); } public interface IAcceptanceSignatureVerifier { bool Verify(string certificateThumbprint, byte[] content, byte[] signature); } /// /// 只信任 Windows TrustedPeople 中按 SHA-1 thumbprint 精确匹配、当前有效的 /// RSA CSP 证书。验收签名使用 RSA-SHA256,不读取私钥。 /// public sealed class WindowsTrustedPeopleSignatureVerifier : IAcceptanceSignatureVerifier { public bool Verify(string certificateThumbprint, byte[] content, byte[] signature) { try { string thumbprint = NormalizeThumbprint(certificateThumbprint); if (thumbprint == null || content == null || signature == null) return false; X509Certificate2 certificate = Find(StoreLocation.CurrentUser, thumbprint) ?? Find(StoreLocation.LocalMachine, thumbprint); if (certificate == null || DateTime.Now < certificate.NotBefore || DateTime.Now > certificate.NotAfter) return false; object publicKey = typeof(PublicKey).GetProperty("Key") .GetValue(certificate.PublicKey, null); RSACryptoServiceProvider rsa = publicKey as RSACryptoServiceProvider; if (rsa == null) return false; using (SHA256 sha = SHA256.Create()) { byte[] hash = sha.ComputeHash(content); return rsa.VerifyHash( hash, CryptoConfig.MapNameToOID("SHA256"), signature); } } catch { return false; } } private static X509Certificate2 Find(StoreLocation location, string thumbprint) { X509Store store = null; try { // X509Store did not implement IDisposable in .NET Framework 4.0. // Close explicitly so this source remains compatible with the legacy ERP target. store = new X509Store(StoreName.TrustedPeople, location); store.Open(OpenFlags.ReadOnly | OpenFlags.OpenExistingOnly); X509Certificate2Collection matches = store.Certificates.Find( X509FindType.FindByThumbprint, thumbprint, false); return matches.Count == 1 ? matches[0] : null; } catch { return null; } finally { if (store != null) store.Close(); } } internal static string NormalizeThumbprint(string value) { string normalized = Regex.Replace(value ?? string.Empty, @"\s+", string.Empty) .ToUpperInvariant(); return Regex.IsMatch(normalized, @"^[A-F0-9]{40}$") ? normalized : null; } } public sealed class BusinessAcceptanceEvidence { public string ContentSha256 { get; set; } public string CertificateThumbprint { get; set; } public string Workflow { get; set; } public string ModuleCode { get; set; } public string AccountBook { get; set; } public string SubSystemId { get; set; } public string AdapterId { get; set; } public string AdapterVersion { get; set; } public string EvidenceId { get; set; } public string RuntimeConfigurationSha256 { get; set; } public string CustomerProfileSha256 { get; set; } public string FieldMappingSha256 { get; set; } public string ReadContractEvidenceSha256 { get; set; } public string WriteIntegrationEvidenceSha256 { get; set; } public DateTime IssuedAtUtc { get; set; } public DateTime ExpiresAtUtc { get; set; } public string ValidatedBy { get; set; } public bool CustomerConfigurationValidated { get; set; } public bool ParameterizedReadQueriesVerified { get; set; } public bool TransactionalWriteVerified { get; set; } public bool PersistentIdempotencyVerified { get; set; } public bool PermissionRecheckVerified { get; set; } public bool WindowsIntegrationVerified { get; set; } public bool CriticalCatalogRuntimeRecheckVerified { get; set; } public bool SignatureVerified { get; set; } } public sealed class FileBusinessAdapterReadinessAttestor : IBusinessAdapterReadinessAttestor { private const int MaximumRuntimeConfigurationBytes = 64 * 1024; private readonly string _path; private readonly IAcceptanceSignatureVerifier _signatureVerifier; private readonly ISystemClock _clock; private readonly string _runtimeConfigurationSha256; private readonly IRuntimeCustomerProfileVerifier _customerProfileVerifier; private readonly string _runtimeConfigurationPath; public FileBusinessAdapterReadinessAttestor( string path, IAcceptanceSignatureVerifier signatureVerifier, ISystemClock clock, string runtimeConfigurationSha256, IRuntimeCustomerProfileVerifier customerProfileVerifier) : this( path, signatureVerifier, clock, runtimeConfigurationSha256, customerProfileVerifier, null) { } /// /// 运行时每次就绪检查都重新读取业务适配器配置文件并核对哈希。 /// 旧的五参数构造函数保留给离线调用方;生产注册必须传入配置文件路径。 /// public FileBusinessAdapterReadinessAttestor( string path, IAcceptanceSignatureVerifier signatureVerifier, ISystemClock clock, string runtimeConfigurationSha256, IRuntimeCustomerProfileVerifier customerProfileVerifier, string runtimeConfigurationPath) { if (string.IsNullOrWhiteSpace(path)) throw new ArgumentException("验收证据路径不能为空。", "path"); if (signatureVerifier == null) throw new ArgumentNullException("signatureVerifier"); if (clock == null) throw new ArgumentNullException("clock"); if (!CommandInputFingerprint.IsValid(runtimeConfigurationSha256)) throw new ArgumentException( "运行时业务配置哈希必须是 SHA-256。", "runtimeConfigurationSha256"); if (customerProfileVerifier == null) throw new ArgumentNullException("customerProfileVerifier"); _path = Path.GetFullPath(path); _signatureVerifier = signatureVerifier; _clock = clock; _runtimeConfigurationSha256 = runtimeConfigurationSha256.ToLowerInvariant(); _customerProfileVerifier = customerProfileVerifier; if (!string.IsNullOrWhiteSpace(runtimeConfigurationPath)) { try { _runtimeConfigurationPath = Path.GetFullPath(runtimeConfigurationPath); } catch { throw new ArgumentException( "运行时业务配置路径无效。", "runtimeConfigurationPath"); } } } public BusinessAdapterReadiness Attest( string workflow, string moduleCode, BusinessAdapterReadiness readiness) { if (readiness == null) throw Invalid("数据库没有返回适配器就绪证据。"); readiness.AcceptanceManifestVerified = false; readiness.AcceptanceSignatureVerified = false; VerifyRuntimeConfiguration(); BusinessAcceptanceEvidence evidence = BusinessAcceptanceEvidenceVerifier.VerifyFile( _path, _signatureVerifier, _clock.UtcNow); if (!Same(evidence.Workflow, workflow) || !string.Equals( evidence.ModuleCode, moduleCode, StringComparison.Ordinal) || !Same(evidence.AccountBook, readiness.AccountBook) || !Same(evidence.SubSystemId, readiness.SubSystemId) || !Same(evidence.AdapterId, readiness.AdapterId) || !Same(evidence.AdapterVersion, readiness.AdapterVersion) || !Same(evidence.EvidenceId, readiness.EvidenceId) || !string.Equals( evidence.RuntimeConfigurationSha256, _runtimeConfigurationSha256, StringComparison.Ordinal) || !string.Equals(evidence.ContentSha256, readiness.EvidenceSha256, StringComparison.Ordinal) || !Same(evidence.ValidatedBy, readiness.ValidatedBy) || Math.Abs((evidence.IssuedAtUtc - readiness.ValidatedAtUtc).TotalSeconds) > 1 || evidence.CustomerConfigurationValidated != readiness.CustomerConfigurationValidated || evidence.ParameterizedReadQueriesVerified != readiness.ParameterizedReadQueriesVerified || evidence.TransactionalWriteVerified != readiness.TransactionalWriteVerified || evidence.PersistentIdempotencyVerified != readiness.PersistentIdempotencyVerified || evidence.PermissionRecheckVerified != readiness.PermissionRecheckVerified || evidence.WindowsIntegrationVerified != readiness.WindowsIntegrationVerified) throw Invalid("验收清单与数据库就绪证据或当前 ERP 作用域不一致。"); _customerProfileVerifier.Verify( evidence.CustomerProfileSha256, workflow, evidence.ModuleCode, evidence.FieldMappingSha256, evidence.ReadContractEvidenceSha256, evidence.WriteIntegrationEvidenceSha256); readiness.AcceptanceManifestVerified = true; readiness.AcceptanceSignatureVerified = evidence.SignatureVerified; return readiness; } private void VerifyRuntimeConfiguration() { // 离线验证器没有配置路径时仍可只验证签名清单;生产注册总是传入路径。 if (string.IsNullOrWhiteSpace(_runtimeConfigurationPath)) return; byte[] bytes; try { FileInfo file = new FileInfo(_runtimeConfigurationPath); if (!file.Exists || file.Length <= 0 || file.Length > MaximumRuntimeConfigurationBytes || (file.Attributes & (FileAttributes.Directory | FileAttributes.Device | FileAttributes.ReparsePoint)) != 0) throw RuntimeConfigurationUnavailable(); using (FileStream stream = new FileStream( _runtimeConfigurationPath, FileMode.Open, FileAccess.Read, FileShare.Read)) { if (stream.Length <= 0 || stream.Length > MaximumRuntimeConfigurationBytes) throw RuntimeConfigurationUnavailable(); bytes = new byte[(int)stream.Length]; int offset = 0; while (offset < bytes.Length) { int count = stream.Read(bytes, offset, bytes.Length - offset); if (count <= 0) throw RuntimeConfigurationUnavailable(); offset += count; } // FileShare.Read prevents a normal concurrent writer on Windows; // this extra read also detects growth during the bounded read. if (stream.ReadByte() != -1) throw RuntimeConfigurationUnavailable(); } } catch (CommandKernelException) { throw; } catch { throw RuntimeConfigurationUnavailable(); } string actual = Sha256(bytes); if (!string.Equals( actual, _runtimeConfigurationSha256, StringComparison.Ordinal)) throw new CommandKernelException( "runtime_configuration_changed", "运行中的业务适配器配置已变化,写命令保持禁用。", 6); } private static byte[] EmptyBytes() { return new byte[0]; } private static string Sha256(byte[] value) { using (SHA256 sha = SHA256.Create()) { byte[] hash = sha.ComputeHash(value ?? EmptyBytes()); StringBuilder result = new StringBuilder(hash.Length * 2); foreach (byte item in hash) result.Append(item.ToString("x2")); return result.ToString(); } } private static CommandKernelException RuntimeConfigurationUnavailable() { return new CommandKernelException( "runtime_configuration_unavailable", "运行中的业务适配器配置无法读取,写命令保持禁用。", 6); } private static bool Same(string left, string right) { return string.Equals(left ?? string.Empty, right ?? string.Empty, StringComparison.OrdinalIgnoreCase); } private static CommandKernelException Invalid(string message) { return new CommandKernelException("acceptance_evidence_mismatch", message, 6); } } public static class BusinessAcceptanceEvidenceVerifier { private const int MaximumBytes = 256 * 1024; private static readonly Regex SafeCode = new Regex( @"^[a-z0-9_.\-]{1,128}$", RegexOptions.Compiled | RegexOptions.CultureInvariant); private static readonly Regex SafeModule = new Regex( @"^[A-Za-z0-9_.:\-]{1,64}$", RegexOptions.Compiled | RegexOptions.CultureInvariant); private static readonly Regex SafeEvidenceId = new Regex( @"^[A-Za-z0-9_.:\-]{8,128}$", RegexOptions.Compiled | RegexOptions.CultureInvariant); public static BusinessAcceptanceEvidence VerifyFile( string path, IAcceptanceSignatureVerifier signatureVerifier, DateTime nowUtc) { if (signatureVerifier == null) throw new ArgumentNullException("signatureVerifier"); JObject root = LoadStrict(path); EnsureExact(root, "schemaVersion", "contentSha256", "signatureAlgorithm", "certificateThumbprint", "signatureBase64", "content"); if (root.Properties().Count() != 6 || RequiredString(root, "schemaVersion", 1, 16) != "1.1" || RequiredString(root, "signatureAlgorithm", 1, 32) != "rsa-sha256") throw Invalid("验收清单顶层结构、版本或签名算法无效。"); string contentHash = RequiredHash(root, "contentSha256"); string thumbprint = RequiredString(root, "certificateThumbprint", 40, 64); if (WindowsTrustedPeopleSignatureVerifier.NormalizeThumbprint(thumbprint) == null) throw Invalid("验收清单证书指纹无效。"); byte[] signature; try { signature = Convert.FromBase64String( RequiredString(root, "signatureBase64", 32, 2048)); } catch { throw Invalid("验收清单签名不是有效 Base64。"); } JObject content = RequiredObject(root, "content"); EnsureExact(content, "packageType", "workflow", "moduleCode", "erpScope", "adapterId", "adapterVersion", "evidenceId", "runtimeConfigurationSha256", "customerProfileSha256", "fieldMappingSha256", "readContractEvidenceSha256", "writeIntegrationEvidenceSha256", "requirements", "issuedAtUtc", "expiresAtUtc", "validatedBy", "note"); if (content.Properties().Count() != 17 || RequiredString(content, "packageType", 1, 64) != "workflow_write_acceptance_evidence") throw Invalid("验收清单内容类型或结构无效。"); string canonical = content.ToString(Formatting.None); if (!string.Equals(contentHash, Sha256(canonical), StringComparison.Ordinal)) throw Invalid("验收清单内容哈希不一致。"); if (!signatureVerifier.Verify( thumbprint, Encoding.UTF8.GetBytes(canonical), signature)) throw Invalid("验收清单签名未通过 TrustedPeople 证书验证。"); string workflow = RequiredString(content, "workflow", 1, 32); string moduleCode = RequiredString(content, "moduleCode", 1, 64); if ((workflow != "purchase" && workflow != "leave") || !SafeModule.IsMatch(moduleCode)) throw Invalid("验收清单工作流或模块编号无效。"); JObject scope = RequiredObject(content, "erpScope"); EnsureExact(scope, "accountBook", "subSystemId"); if (scope.Properties().Count() != 2) throw Invalid("验收清单 ERP 作用域结构无效。"); string accountBook = RequiredString(scope, "accountBook", 1, 128); string subSystemId = RequiredString(scope, "subSystemId", 1, 128); string adapterId = RequiredSafeCode(content, "adapterId"); string adapterVersion = RequiredSafeCode(content, "adapterVersion"); string evidenceId = RequiredString(content, "evidenceId", 8, 128); if (!SafeEvidenceId.IsMatch(evidenceId)) throw Invalid("验收清单 evidenceId 无效。"); string runtimeConfigurationHash = RequiredHash( content, "runtimeConfigurationSha256"); string customerProfileHash = RequiredHash( content, "customerProfileSha256"); string fieldHash = RequiredHash(content, "fieldMappingSha256"); string readHash = RequiredHash(content, "readContractEvidenceSha256"); string writeHash = RequiredHash(content, "writeIntegrationEvidenceSha256"); JObject requirements = RequiredObject(content, "requirements"); EnsureExact(requirements, "customerConfigurationValidated", "parameterizedReadQueriesVerified", "transactionalWriteVerified", "persistentIdempotencyVerified", "permissionRecheckVerified", "windowsIntegrationVerified", "criticalCatalogRuntimeRecheckVerified"); if (requirements.Properties().Count() != 7) throw Invalid("验收清单 requirements 结构无效。"); bool customerConfiguration = RequiredTrue(requirements, "customerConfigurationValidated"); bool parameterizedReads = RequiredTrue(requirements, "parameterizedReadQueriesVerified"); bool transactionalWrite = RequiredTrue(requirements, "transactionalWriteVerified"); bool persistentIdempotency = RequiredTrue(requirements, "persistentIdempotencyVerified"); bool permissionRecheck = RequiredTrue(requirements, "permissionRecheckVerified"); bool windowsIntegration = RequiredTrue(requirements, "windowsIntegrationVerified"); bool criticalCatalogRuntimeRecheck = RequiredTrue( requirements, "criticalCatalogRuntimeRecheckVerified"); DateTime issuedAt = RequiredUtc(content, "issuedAtUtc"); DateTime expiresAt = RequiredUtc(content, "expiresAtUtc"); nowUtc = nowUtc.Kind == DateTimeKind.Utc ? nowUtc : nowUtc.ToUniversalTime(); if (issuedAt > nowUtc.AddMinutes(5) || expiresAt <= nowUtc || expiresAt <= issuedAt || expiresAt - issuedAt > TimeSpan.FromDays(366)) throw Invalid("验收清单签发时间、有效期或生命周期无效。"); string validatedBy = RequiredString(content, "validatedBy", 1, 128); RequiredString(content, "note", 1, 500); return new BusinessAcceptanceEvidence { ContentSha256 = contentHash, CertificateThumbprint = WindowsTrustedPeopleSignatureVerifier.NormalizeThumbprint(thumbprint), Workflow = workflow, ModuleCode = moduleCode, AccountBook = accountBook, SubSystemId = subSystemId, AdapterId = adapterId, AdapterVersion = adapterVersion, EvidenceId = evidenceId, RuntimeConfigurationSha256 = runtimeConfigurationHash, CustomerProfileSha256 = customerProfileHash, FieldMappingSha256 = fieldHash, ReadContractEvidenceSha256 = readHash, WriteIntegrationEvidenceSha256 = writeHash, IssuedAtUtc = issuedAt, ExpiresAtUtc = expiresAt, ValidatedBy = validatedBy, CustomerConfigurationValidated = customerConfiguration, ParameterizedReadQueriesVerified = parameterizedReads, TransactionalWriteVerified = transactionalWrite, PersistentIdempotencyVerified = persistentIdempotency, PermissionRecheckVerified = permissionRecheck, WindowsIntegrationVerified = windowsIntegration, CriticalCatalogRuntimeRecheckVerified = criticalCatalogRuntimeRecheck, SignatureVerified = true }; } private static JObject LoadStrict(string path) { if (string.IsNullOrWhiteSpace(path)) throw Invalid("验收清单路径不能为空。"); try { FileInfo file = new FileInfo(Path.GetFullPath(path)); if (!file.Exists || file.Length <= 0 || file.Length > MaximumBytes || (file.Attributes & FileAttributes.ReparsePoint) != 0) throw Invalid("验收清单不存在、为空、超过 256 KB 或是链接文件。"); using (FileStream stream = new FileStream( file.FullName, FileMode.Open, FileAccess.Read, FileShare.Read)) using (StreamReader text = new StreamReader( stream, new UTF8Encoding(false, true), true, 4096)) using (RejectCommentsJsonReader json = new RejectCommentsJsonReader(text)) { json.DateParseHandling = DateParseHandling.None; JObject value = JObject.Load(json, new JsonLoadSettings { DuplicatePropertyNameHandling = DuplicatePropertyNameHandling.Error, CommentHandling = CommentHandling.Ignore, LineInfoHandling = LineInfoHandling.Ignore }); if (json.Read()) throw Invalid("验收清单包含多个 JSON 根值。"); return value; } } catch (CommandKernelException) { throw; } catch { throw Invalid("验收清单不是严格 UTF-8 JSON 对象。"); } } private static JObject RequiredObject(JObject source, string name) { JObject value = source[name] as JObject; if (value == null) throw Invalid(name + " 必须是对象。"); return value; } private static string RequiredString(JObject source, string name, int min, int max) { JToken token = source[name]; if (token == null || token.Type != JTokenType.String) throw Invalid(name + " 必须是字符串。"); string value = token.Value(); if (value == null || value.Length < min || value.Length > max || value.Any(char.IsControl)) throw Invalid(name + " 字符串格式无效。"); return value; } private static string RequiredHash(JObject source, string name) { string value = RequiredString(source, name, 64, 64); if (!CommandInputFingerprint.IsValid(value)) throw Invalid(name + " 必须是小写 SHA-256。"); return value; } private static string RequiredSafeCode(JObject source, string name) { string value = RequiredString(source, name, 1, 128); if (!SafeCode.IsMatch(value)) throw Invalid(name + " 格式无效。"); return value; } private static bool RequiredTrue(JObject source, string name) { JToken value = source[name]; if (value == null || value.Type != JTokenType.Boolean || !value.Value()) throw Invalid(name + " 必须明确为 true。"); return true; } private static DateTime RequiredUtc(JObject source, string name) { DateTime value; if (!DateTime.TryParse( RequiredString(source, name, 1, 64), System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.RoundtripKind, out value) || value.Kind != DateTimeKind.Utc) throw Invalid(name + " 必须是 UTC ISO 8601 时间。"); return value; } private static void EnsureExact(JObject source, params string[] allowed) { ISet names = new HashSet(allowed, StringComparer.Ordinal); JProperty unknown = source.Properties() .FirstOrDefault(item => !names.Contains(item.Name)); if (unknown != null) throw Invalid("验收清单包含未知字段:" + unknown.Name); } internal static string Sha256(string value) { using (SHA256 sha = SHA256.Create()) { byte[] hash = sha.ComputeHash(Encoding.UTF8.GetBytes(value ?? string.Empty)); StringBuilder result = new StringBuilder(hash.Length * 2); foreach (byte item in hash) result.Append(item.ToString("x2")); return result.ToString(); } } private static CommandKernelException Invalid(string message) { return new CommandKernelException("acceptance_evidence_invalid", message, 6); } private sealed class RejectCommentsJsonReader : JsonTextReader { public RejectCommentsJsonReader(TextReader reader) : base(reader) { } public override bool Read() { bool result = base.Read(); if (result && TokenType == JsonToken.Comment) throw Invalid("验收清单禁止 JSON 注释。"); return result; } } } }