Skip to content

Add SA specific implementation#19

Open
migueltorresvalls wants to merge 10 commits into
mainfrom
sa-profile
Open

Add SA specific implementation#19
migueltorresvalls wants to merge 10 commits into
mainfrom
sa-profile

Conversation

@migueltorresvalls
Copy link
Copy Markdown

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

  • ECDSA DER format: new ECDSARawDER bool — when true, ECDSA signatures keep raw ASN.1 DER encoding instead of the default W3C concatenated r||s format. No breaking changes to Certificate.Sign().
  • XPath filter transforms: DocumentTransforms config field with XPath support, replacing the hardcoded enveloped-signature transform (defaults preserve existing behavior).
  • Pre-hash transforms: PreHashTransforms callback for arbitrary byte transforms before canonicalization.
  • Sign document digest: SignDocumentDigest flag signs the first Reference DigestValue instead of canonicalized SignedInfo.
  • Hex-encoded digests: base64(hex(hash)) encoding via HexEncodeDigests flag.
  • PEM fingerprint: FingerprintPEM() + HashPEMText flag to hash base64 PEM text instead of DER bytes.
  • NewCertificate() constructor: create a Certificate from a parsed x509.Certificate + crypto.Signer without PKCS12.

Test plan

  • Existing unit tests updated and passing
  • ZATCA onboarding validated end-to-end via apps/gov-sa

@migueltorresvalls migueltorresvalls changed the title Sa profile Add SA specific implementation May 6, 2026
@migueltorresvalls migueltorresvalls requested a review from pmenendz May 6, 2026 17:42
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/zatca with 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 NewCertificate constructor to build a Certificate from an x509.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.

Comment thread certificates.go

// NewCertificate creates a Certificate from a parsed x509.Certificate and
// a crypto.Signer
func NewCertificate(cert *x509.Certificate, key crypto.Signer) (*Certificate, error) {
Comment thread certificates.go
Comment on lines 128 to 145
// 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)
}
Comment thread xades.go
Comment on lines 160 to +164
certHash := s.opts.xadesConfig.SigningCertificateHash
fingerprint, err := cert.Fingerprint(certHash)
if s.opts.xadesConfig.HashPEMText {
fingerprint, err = cert.FingerprintPEM(certHash)
}
Comment thread digest.go
Comment on lines +24 to +37
// 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
}
Comment thread profiles/zatca/zatca.go
Comment on lines +58 to +98
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()
}
Copy link
Copy Markdown
Contributor

@pmenendz pmenendz left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good. I am just concerned with the ECDSAFormatDER. Feels too specific or something that could be known from the key

Comment thread options.go
KeyInfoCanonicalizer dsig.Canonicalizer
SignedInfoCanonicalizer dsig.Canonicalizer
SignedInfoHash crypto.Hash
ECDSAFormatDER bool
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't this be known from the private key?

Comment thread certificates.go
// 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) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants