Add SA specific implementation#19
Conversation
There was a problem hiding this comment.
Pull request overview
Adds a Saudi Arabia (ZATCA) signing profile and extends the library’s signing configuration surface to support ZATCA-specific deviations from standard W3C XML DSig/XAdES.
Changes:
- Added
profiles/zatcawith ZATCA-specific XMLDSig/XAdES configs, XPath-based document transforms, and a pre-hash transform hook. - Extended signing capabilities: configurable
DocumentTransforms,PreHashTransforms, ECDSA signature output format control, optional hex-encoded digests, and PEM-text certificate fingerprinting. - Introduced
NewCertificateconstructor to build aCertificatefrom anx509.Certificate+crypto.Signer(non-PKCS12).
Reviewed changes
Copilot reviewed 8 out of 9 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| xades.go | Adds HashPEMText behavior when computing XAdES signing certificate digest. |
| signature.go | Adds XPath transform support in transform serialization, pre-hash transforms hook, hex-digest option for SignedProperties, and passes ECDSA DER-format flag into signing. |
| profiles/zatca/zatca.go | New ZATCA profile providing preset XMLDSig/XAdES configs and ZATCA-specific pre-hash transforms. |
| options.go | Extends XMLDSig/XAdES config structs with new flags/hooks and defaults for DocumentTransforms. |
| digest.go | Adds digestBytesHex helper to support base64(hex(hash)) encoding. |
| certificates.go | Adds NewCertificate, adds FingerprintPEM, and extends Certificate.Sign to optionally return DER ECDSA signatures. |
| certificates_test.go | Updates tests to match the new Certificate.Sign signature. |
| go.mod / go.sum | Updates dependencies (notably gobl) and Go version metadata. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
|
||
| // NewCertificate creates a Certificate from a parsed x509.Certificate and | ||
| // a crypto.Signer | ||
| func NewCertificate(cert *x509.Certificate, key crypto.Signer) (*Certificate, error) { |
| // RSA and ECDSA certificates require different signing code | ||
| // (even though both implement crypto.Signer) | ||
| var signature []byte | ||
| var signingErr error | ||
| switch cert.privateKey.(type) { | ||
| case *rsa.PrivateKey: | ||
| signature, signingErr = cert.privateKey.Sign(rand.Reader, digest, hash) | ||
| case *ecdsa.PrivateKey: | ||
| // When using ECDSA, privateKey.Sign returns signature in DER format, but XML DSig | ||
| // requires the signature to be in the concatenated format (r || s) | ||
| signature, signingErr = signECDSA(cert.privateKey.(*ecdsa.PrivateKey), digest, hash) | ||
| if ecdsaFormatDER { | ||
| // Keep the raw DER encoding from privateKey.Sign (required by ZATCA). | ||
| signature, signingErr = cert.privateKey.Sign(rand.Reader, digest, hash) | ||
| } else { | ||
| // Convert DER to concatenated r||s (W3C XML DSig standard). | ||
| signature, signingErr = signECDSA(cert.privateKey.(*ecdsa.PrivateKey), digest, hash) | ||
| } | ||
| default: | ||
| return "", fmt.Errorf("unsupported key type: %T", cert.privateKey) | ||
| } |
| certHash := s.opts.xadesConfig.SigningCertificateHash | ||
| fingerprint, err := cert.Fingerprint(certHash) | ||
| if s.opts.xadesConfig.HashPEMText { | ||
| fingerprint, err = cert.FingerprintPEM(certHash) | ||
| } |
| // digestBytesHex creates a base64(hex(hash)) encoded digest. | ||
| func digestBytesHex(data []byte, hash crypto.Hash) (string, error) { | ||
| if !hash.Available() { | ||
| return "", fmt.Errorf("hash %v not available", hash) | ||
| } | ||
|
|
||
| hasher := hash.New() | ||
| if _, err := hasher.Write(data); err != nil { | ||
| return "", err | ||
| } | ||
|
|
||
| hexStr := hex.EncodeToString(hasher.Sum(nil)) | ||
| return base64.StdEncoding.EncodeToString([]byte(hexStr)), nil | ||
| } |
| func zatcaTimestampFormatter(t time.Time) string { | ||
| return t.UTC().Format("2006-01-02T15:04:05Z") | ||
| } | ||
|
|
||
| func zatcaPreHashTransforms(xmlData []byte) ([]byte, error) { | ||
| doc := etree.NewDocument() | ||
| doc.ReadSettings.PreserveCData = true | ||
| if err := doc.ReadFromBytes(xmlData); err != nil { | ||
| return nil, fmt.Errorf("parse xml: %w", err) | ||
| } | ||
|
|
||
| invoice := doc.Root() | ||
| if invoice == nil { | ||
| return xmlData, nil | ||
| } | ||
|
|
||
| // Insert residual newlines that match ZATCA's XSLT-based element stripping. | ||
|
|
||
| // 1. ext:UBLExtensions — first child element | ||
| if invoice.SelectElement("UBLExtensions") == nil { | ||
| if idx := indexOfFirstChildElement(invoice); idx >= 0 { | ||
| invoice.InsertChildAt(idx, &etree.CharData{Data: "\n "}) | ||
| } | ||
| } | ||
|
|
||
| // 2. QR AdditionalDocumentReference — after the last existing ref | ||
| if !hasQRRef(invoice) { | ||
| if idx := indexOfLastChildElementByTag(invoice, "AdditionalDocumentReference"); idx >= 0 { | ||
| invoice.InsertChildAt(idx+1, &etree.CharData{Data: "\n "}) | ||
| } | ||
| } | ||
|
|
||
| // 3. cac:Signature — before AccountingSupplierParty | ||
| if invoice.SelectElement("Signature") == nil { | ||
| if idx := indexOfChildElementByTag(invoice, "AccountingSupplierParty"); idx >= 0 { | ||
| invoice.InsertChildAt(idx, &etree.CharData{Data: "\n "}) | ||
| } | ||
| } | ||
|
|
||
| return doc.WriteToBytes() | ||
| } |
pmenendz
left a comment
There was a problem hiding this comment.
Looks good. I am just concerned with the ECDSAFormatDER. Feels too specific or something that could be known from the key
| KeyInfoCanonicalizer dsig.Canonicalizer | ||
| SignedInfoCanonicalizer dsig.Canonicalizer | ||
| SignedInfoHash crypto.Hash | ||
| ECDSAFormatDER bool |
There was a problem hiding this comment.
Can't this be known from the private key?
| // digest using the configured private key. For ECDSA keys, ecdsaFormat controls | ||
| // whether the signature is returned in concatenated r||s format (W3C XML DSig | ||
| // standard) or raw DER encoding (required by ZATCA). | ||
| func (cert *Certificate) Sign(data string, hash crypto.Hash, ecdsaFormatDER bool) (string, error) { |
There was a problem hiding this comment.
Feels too specific for me to include this argument here. Isn't there a way to know this directly from the certificate or the key?
There was a problem hiding this comment.
This isn't a property of the key or the certificate (all ecdsa keys and x509 certificates are serialized the same way). The difference here is the format that ZATCA expects the signature to be in. Can't really think of another way of knowing this
Add ZATCA (Saudi Arabia) signing profile
Adds
profiles/zatca/and the core extensions needed to produce ZATCA-compliant XML signatures. ZATCA diverges from standard W3C XML DSig in several ways that the existing config surface couldn't express.Core changes
ECDSARawDERbool — when true, ECDSA signatures keep raw ASN.1 DER encoding instead of the default W3C concatenated r||s format. No breaking changes toCertificate.Sign().DocumentTransformsconfig field withXPathsupport, replacing the hardcoded enveloped-signature transform (defaults preserve existing behavior).PreHashTransformscallback for arbitrary byte transforms before canonicalization.SignDocumentDigestflag signs the first Reference DigestValue instead of canonicalized SignedInfo.base64(hex(hash))encoding viaHexEncodeDigestsflag.FingerprintPEM()+HashPEMTextflag to hash base64 PEM text instead of DER bytes.NewCertificate()constructor: create aCertificatefrom a parsedx509.Certificate+crypto.Signerwithout PKCS12.Test plan
apps/gov-sa