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;
}
}
}