8. Certificates
8.1 Signing Process — Full Chain
Invoice XML → XadesSignedXml.ComputeSignature() (XmlDsigC14N11Transform + XPath transforms
stripping ext:UBLExtensions/cac:Signature/QR node) → SHA-256 DigestValue = invoice hash
→ ecdsa-sha256 XML-DSig signature over the canonicalized XML
→ CryptoHelper.GenerateDigitalSignature (BouncyCastle) re-signs the hash,
producing the QR "cryptographic stamp" (tag 7)
This is a real, working ECDSA-secp256k1 implementation matching ZATCA's Phase 2 spec — not a stub.
8.2 Private Key Handling
- The EC private key (secp256k1) is generated by
ECKeyPairHelper, PEM-encoded, and stored inFatooraSecretSettings.PrivateKey— a plainstringproperty. - Persisted as plaintext:
AppSettingService.SetAppSetting<T>writes the value with no encryption call anywhere in the write path. The private key sits in theAppSetting.ValueSQL column, or inappsettings.json(EGSConfigSettings.PrivateKey) whenUseStaticKeysis enabled — either way, unencrypted at rest. See Chapter 7 — Security. - At runtime, the key is re-imported into an
ECDsaobject from the plaintext DB value on each signing operation (ecdsa.ImportFromPem(...)) — it is not sourced from a hardware security module, key vault, or any secret-manager abstraction. - Logging: as of Phase 0.2,
X509CertificateService.GenerateCSR()no longer logs the private key via Serilog destructuring (both forks) — see Chapter 7 — Security § 7.3.
8.3 Certificate Storage
X509CertifcateSettings.Certificate(decoded certificate bytes as a string) andSerialNumber/IssuedOn/ExpiresOn/EncryptionPasswordare persisted the same way as the private key — plaintext inAppSetting.Value, no encryption.- At runtime, the certificate (without its private key, per .NET's
X509Storesemantics for how it's added) is cached in the Windows certificate store (X509Store(StoreName.My, StoreLocation.CurrentUser)) — a process/machine-local cache, not itself a persistence mechanism. ComplianceCsidSettings/ProductionCsidSettings(BinarySecurityToken,Secret— the credentials ZATCA issues after onboarding, used for Basic-Auth on every subsequent API call) are stored the same unencrypted way.
8.4 Certificate Loading
Loaded exclusively via IEInvoicingAppSettingService.GetAppSetting<T>() (cached via IEasyCachingProvider),
then ECDsa.ImportFromPem(...)/X509Certificate2 construction from the retrieved plaintext string. No
file-based, HSM-based, or Key-Vault-based loading path exists.
8.5 Two Documented Code-Level Anomalies
These are in the certificate code, not the signing math itself:
- Curve identifier inconsistency: key generation correctly specifies
SecObjectIdentifiers.SecP256k1(ECKeyPairHelper.cs), butX509CertificateService.csseedsECDsa.Create(ECCurve.NamedCurves.nistP256)(a different curve, P-256) at two call sites beforeImportFromPemimmediately overwrites it with the real secp256k1 key. Functionally masked by the import, but wrong as written — a maintenance trap for anyone reading or modifying this code without noticing the import overwrites the seed. Classified Low severity, not addressed in Phase 0.2. - CSR field OID divergence between forks: the core SDK uses a custom OID (
2.5.4.26) for theLocationAddressfield in the CSR subject; the Tenancy fork uses the standardX509Name.L(Locality) OID instead. This is compliance-relevant — ZATCA's CSR subject-field spec is exact, and a wrong OID could cause CSR rejection or misclassification. Classified Medium severity, not addressed in Phase 0.2 (out of that phase's scope).
8.6 Certificate Renewal
No scheduled/automatic renewal exists — this flow is manually triggered via the onboarding controller with a
fresh OTP; there is no job that watches ExpiresOn and proactively renews. See
Chapter 12 — Known Limitations.
