signedPropertiesHashInput static method
Derives the exact byte string ZATCA hashes for
<ds:Reference URI="#xadesSignedProperties"> from the
<xades:SignedProperties> element as it appears in the submitted
invoice.
ZATCA does not hash the element verbatim, and it does not hash a strictly canonical form either. It hashes the element with exactly two differences from the document text - and getting either one wrong is invisible locally (the XML is well formed, the signature verifies) but makes the gateway reject every invoice with:
signed-properties-hashing: Invalid signed properties hashing
The two differences:
- every
ds:*element carriesxmlns:ds, because the reference starts at<xades:SignedProperties>and the inherited declaration has to be rendered at each first point of use inside it; - empty elements are self-closed (
<ds:DigestMethod .../>), not expanded (<ds:DigestMethod ...></ds:DigestMethod>).
Indentation, attribute order and text content are byte-identical to the document - which is why this derives from the document instead of rebuilding it. Keeping a second hand-written copy of the template for hashing is what shipped 0.8.0 and 0.8.1 broken: the copies drifted, the digest was computed over a string that was never sent, and every invoice was rejected. There is now one template, and this is the only way to get the string that goes with it.
Implementation
static String signedPropertiesHashInput(String signedPropertiesInDocument) {
if (signedPropertiesInDocument.contains('xmlns:ds')) {
throw StateError(
'The <xades:SignedProperties> written into the invoice must not declare '
'xmlns:ds - it inherits the prefix from <ds:Signature>. Declaring it '
'there makes the submitted element disagree with the string ZATCA '
'hashes, and the gateway rejects the invoice with '
'"signed-properties-hashing: Invalid signed properties hashing".',
);
}
var hashInput = signedPropertiesInDocument;
for (final element in _signedPropertiesDsElements) {
hashInput = hashInput.replaceAll(
'<$element',
'<$element$_dsNamespaceDeclaration',
);
}
// Self-close the empty elements, the way the hashed form has them.
return hashInput.replaceAllMapped(
RegExp(r'<(ds:[A-Za-z0-9]+)([^>]*)></\1>'),
(match) => '<${match[1]}${match[2]}/>',
);
}