Files
lserp_cs_6.0/插件库/Lskj.AgentBridge/BusinessAcceptanceEvidence.cs
T
2026-08-14 14:28:28 +08:00

597 lines
28 KiB
C#

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);
}
/// <summary>
/// 只信任 Windows TrustedPeople 中按 SHA-1 thumbprint 精确匹配、当前有效的
/// RSA CSP 证书。验收签名使用 RSA-SHA256,不读取私钥。
/// </summary>
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)
{
}
/// <summary>
/// 运行时每次就绪检查都重新读取业务适配器配置文件并核对哈希。
/// 旧的五参数构造函数保留给离线调用方;生产注册必须传入配置文件路径。
/// </summary>
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<string>();
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<bool>())
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<string> names = new HashSet<string>(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;
}
}
}
}