using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; using EntitlementBLL.Common; using EntitlementBLL.Interfaces; using EntitlementBLL.Options; using EntitlementDAL.DTOs; using EntitlementDAL.Interfaces; using GB5Shared.GenerateAutoNumber; using GB5Shared.QueryExecutor; using GB5Shared.Telemetry; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using static GB5Shared.GB5Constant.Constant; namespace EntitlementBLL.Implementations; /// /// ECDSA P-256 bundle signing per the plan: canonicalize the payload JSON (sorted keys, no /// whitespace, excluding "sig") → SHA-256 → sign with the private key → base64 DER → "sig". /// On issue: insert a new MENTITLEMENTBUNDLE row and set ISCURRENT=0 on prior rows for that /// client. Verify: rehash, verify with the public key for "kid", reject if VALIDUNTIL < now. /// public class EntitlementBundleService : IEntitlementBundleService { private readonly IEntitlementService _EntitlementService; private readonly IBundleDAL _BundleDAL; private readonly AutoNumber _AutoNumber; private readonly IQueryExecutor _QueryExecutor; private readonly IEntitlementLoginFactory _LoginFactory; private readonly EcdsaKeyOptions _KeyOptions; private readonly ILogger _Logger; // Dev-only ephemeral fallback key — generated once per process when no PrivateKeyBase64 is // configured. NEVER suitable for production (keys are lost on restart, invalidating every // previously issued bundle) — clearly logged as such the first time it's used. private static readonly Lazy _DevFallbackKey = new(() => ECDsa.Create(ECCurve.NamedCurves.nistP256)); private static readonly JsonSerializerOptions _SerializeOptions = new() { WriteIndented = false }; public EntitlementBundleService( IEntitlementService entitlementService, IBundleDAL bundleDAL, AutoNumber autoNumber, IQueryExecutor queryExecutor, IEntitlementLoginFactory loginFactory, IOptions keyOptions, ILogger logger) { _EntitlementService = entitlementService; _BundleDAL = bundleDAL; _AutoNumber = autoNumber; _QueryExecutor = queryExecutor; _LoginFactory = loginFactory; _KeyOptions = keyOptions.Value; _Logger = logger; } /// Resolves the signing key. is true only when the returned /// instance was freshly created here (from configured key material) and the caller is /// responsible for disposing it — the shared dev-fallback instance is process-lifetime and /// must NEVER be disposed by a caller. private (ECDsa Key, bool Owned) ResolveSigningKey() { if (!string.IsNullOrWhiteSpace(_KeyOptions.PrivateKeyBase64)) { var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256); ecdsa.ImportPkcs8PrivateKey(Convert.FromBase64String(_KeyOptions.PrivateKeyBase64), out _); return (ecdsa, true); } _Logger.LogWarning( "Entitlement:EcdsaKey:PrivateKeyBase64 is not configured — using a process-lifetime " + "ephemeral dev key (KeyId {KeyId}). Bundles signed with this key become unverifiable " + "after a process restart. Configure a real key before production use.", _KeyOptions.KeyId); return (_DevFallbackKey.Value, false); } /// Same ownership contract as ResolveSigningKey. private (ECDsa Key, bool Owned) ResolveVerificationKey() { if (!string.IsNullOrWhiteSpace(_KeyOptions.PublicKeyBase64)) { var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256); ecdsa.ImportSubjectPublicKeyInfo(Convert.FromBase64String(_KeyOptions.PublicKeyBase64), out _); return (ecdsa, true); } return ResolveSigningKey(); } /// Sorted-keys, no-whitespace JSON canonicalization — deterministic regardless of /// property declaration order, required so sign/verify always hash the identical bytes. internal static string Canonicalize(EntitlementBundleDto payload) { var json = JsonSerializer.Serialize(payload, _SerializeOptions); var node = JsonNode.Parse(json) ?? throw new InvalidOperationException("Bundle payload serialized to null."); return SortNode(node).ToJsonString(_SerializeOptions); } private static JsonNode SortNode(JsonNode node) { switch (node) { case JsonObject obj: var sortedObj = new JsonObject(); foreach (var key in obj.Select(kv => kv.Key).OrderBy(k => k, StringComparer.Ordinal)) { var child = obj[key]; sortedObj[key] = child is null ? null : SortNode(child.DeepClone()); } return sortedObj; case JsonArray arr: var sortedArr = new JsonArray(); foreach (var item in arr) sortedArr.Add(item is null ? null : SortNode(item.DeepClone())); return sortedArr; default: return node.DeepClone(); } } public async Task IssueSignedBundleAsync(int clientId, CancellationToken ct) { var login = _LoginFactory.Create(clientId); GB5Trace.Step("resolve-bundle", new { clientId }); var payload = await _EntitlementService.GetResolvedBundleAsync(clientId, ct).ConfigureAwait(false); var canonical = Canonicalize(payload); var hash = SHA256.HashData(Encoding.UTF8.GetBytes(canonical)); var (signingKey, ownsSigningKey) = ResolveSigningKey(); byte[] derSignature; try { derSignature = signingKey.SignHash(hash, DSASignatureFormat.Rfc3279DerSequence); } finally { if (ownsSigningKey) signingKey.Dispose(); } var sig = Convert.ToBase64String(derSignature); var signed = new SignedBundleDto { Payload = payload, Kid = _KeyOptions.KeyId, Sig = sig }; GB5Trace.Step("issue-bundle", new { clientId, KeyId = _KeyOptions.KeyId }); var tx = await _QueryExecutor.BeginTransactionAsync(login).ConfigureAwait(false); try { await _BundleDAL.ExpirePriorCurrentAsync(clientId, login, tx, ct).ConfigureAwait(false); var auto = await _AutoNumber.GetNumberAsync(1, AUTONUMBERCONSTANT.ENTITLEMENTBUNDLE, login).ConfigureAwait(false); var now = DateTime.UtcNow; await _BundleDAL.SaveAsync(new BundleDTO { BundleId = auto.StartNumber, ClientId = clientId, BundleJson = canonical, Signature = sig, KeyId = _KeyOptions.KeyId, IsCurrent = 1, IssuedOn = now, ValidUntil = payload.ValidUntil, CreatedById = login.UserId, CreatedOn = now, ModifiedById = login.UserId, ModifiedOn = now }, login, tx, ct).ConfigureAwait(false); GB5Trace.Step("event-publish", new { EventTypeId = EventTypeConstant.SAVEENTITLEMENTBUNDLEEVENTTYPEID }); await _QueryExecutor.CommitAsync(tx).ConfigureAwait(false); } catch (Exception ex) { await _QueryExecutor.RollbackAsync(tx).ConfigureAwait(false); GB5Trace.MarkFailed("issue-bundle-failed", ex); throw; } return signed; } public bool VerifyBundle(SignedBundleDto bundle) { if (bundle is null) return false; if (bundle.Payload.ValidUntil < DateTime.UtcNow) return false; try { var canonical = Canonicalize(bundle.Payload); var hash = SHA256.HashData(Encoding.UTF8.GetBytes(canonical)); var signatureBytes = Convert.FromBase64String(bundle.Sig); var (verifyKey, ownsVerifyKey) = ResolveVerificationKey(); try { return verifyKey.VerifyHash(hash, signatureBytes, DSASignatureFormat.Rfc3279DerSequence); } finally { if (ownsVerifyKey) verifyKey.Dispose(); } } catch (Exception ex) when (ex is FormatException or CryptographicException) { return false; } } }