From 05d96e871dbc580977647d2da66fe78b6020336e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Mar 2026 03:28:54 +0000 Subject: [PATCH] fix: normalize RSA signature to modulus width to prevent short SignatureValue in XMLDSig Agent-Logs-Url: https://github.com/rikulo/xml-crypto/sessions/822e4c7c-2b12-4ac6-a99f-3ff2255791e0 Co-authored-by: scribetw <6398934+scribetw@users.noreply.github.com> --- lib/src/signed_xml.dart | 9 +++-- lib/src/utils.dart | 35 +++++++++++++++++++ test/utils_test.dart | 75 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 116 insertions(+), 3 deletions(-) diff --git a/lib/src/signed_xml.dart b/lib/src/signed_xml.dart index fbb9cc1..643adfb 100644 --- a/lib/src/signed_xml.dart +++ b/lib/src/signed_xml.dart @@ -1073,8 +1073,9 @@ class RSASHA1 implements SignatureAlgorithm { String getSignature(String xml, Uint8List signingKey, [CalculateSignatureCallback? callback]) { final rsa = RSAPrivateKey.fromPEM(utf8.decode(signingKey)); - final res = + final raw = rsa.signSsaPkcs1v15ToBase64(utf8.encode(xml), hasher: EmsaHasher.sha1); + final res = normalizeRsaSignatureBase64(raw, rsa.n); if (callback != null) callback(null, res); return res; } @@ -1102,8 +1103,9 @@ class RSASHA256 implements SignatureAlgorithm { String getSignature(String xml, Uint8List signingKey, [CalculateSignatureCallback? callback]) { final rsa = RSAPrivateKey.fromPEM(utf8.decode(signingKey)); - final res = rsa.signSsaPkcs1v15ToBase64(utf8.encode(xml), + final raw = rsa.signSsaPkcs1v15ToBase64(utf8.encode(xml), hasher: EmsaHasher.sha256); + final res = normalizeRsaSignatureBase64(raw, rsa.n); if (callback != null) callback(null, res); return res; } @@ -1131,8 +1133,9 @@ class RSASHA512 implements SignatureAlgorithm { String getSignature(String xml, Uint8List signingKey, [CalculateSignatureCallback? callback]) { final rsa = RSAPrivateKey.fromPEM(utf8.decode(signingKey)); - final res = rsa.signSsaPkcs1v15ToBase64(utf8.encode(xml), + final raw = rsa.signSsaPkcs1v15ToBase64(utf8.encode(xml), hasher: EmsaHasher.sha512); + final res = normalizeRsaSignatureBase64(raw, rsa.n); if (callback != null) callback(null, res); return res; } diff --git a/lib/src/utils.dart b/lib/src/utils.dart index 0b0d24a..b73b423 100644 --- a/lib/src/utils.dart +++ b/lib/src/utils.dart @@ -2,6 +2,9 @@ //History: Wed Feb 09 10:44:40 CST 2022 // Author: rudyhuang +import 'dart:convert'; +import 'dart:typed_data'; + import 'package:xml/xml.dart'; import 'package:xpath_selector_xml_parser/xpath_selector_xml_parser.dart'; @@ -84,3 +87,35 @@ XmlDocument parseFromString(String xml) => String normalizeLinebreaks(String xml) => xml.replaceAll(RegExp(r'\r\n'), '\n').replaceAll(RegExp(r'\r'), '\n'); + +/// Normalizes a Base64-encoded RSA signature to the expected modulus width. +/// +/// An RSA-PKCS#1 signature must be exactly `ceil(modulus.bitLength / 8)` bytes +/// long. Some implementations (e.g. the ninja library) may serialize the raw +/// RSA result [BigInt] without left-padding, dropping leading `0x00` bytes when +/// the integer value happens to be smaller than the modulus. This produces a +/// signature that is one (or more) bytes too short and fails XMLDSig +/// wire-format validation on the receiving end. +/// +/// This function decodes [base64Sig], left-pads the byte array with `0x00` +/// bytes when its length is less than the expected modulus width, and returns +/// the corrected Base64 string. A correctly-sized signature is returned +/// unchanged. A signature that is *longer* than the modulus width indicates a +/// serious upstream error and causes a [StateError] to be thrown. +String normalizeRsaSignatureBase64(String base64Sig, BigInt modulus) { + final expectedLen = (modulus.bitLength + 7) ~/ 8; + final sigBytes = base64Decode(base64Sig); + + if (sigBytes.length == expectedLen) return base64Sig; + + if (sigBytes.length > expectedLen) { + throw StateError( + 'RSA signature is ${sigBytes.length} bytes but modulus requires ' + '$expectedLen bytes'); + } + + // Left-pad with 0x00 bytes to reach the expected modulus width. + final padded = Uint8List(expectedLen) + ..setRange(expectedLen - sigBytes.length, expectedLen, sigBytes); + return base64Encode(padded); +} diff --git a/test/utils_test.dart b/test/utils_test.dart index ffb5ff2..b00ca81 100644 --- a/test/utils_test.dart +++ b/test/utils_test.dart @@ -2,6 +2,9 @@ //History: Wed Feb 09 11:30:56 CST 2022 // Author: rudyhuang +import 'dart:convert'; +import 'dart:typed_data'; + import 'package:test/test.dart'; import 'package:xml_crypto/src/utils.dart'; @@ -15,4 +18,76 @@ void main() { final result = encodeSpecialCharactersInText(''); expect(result, '<D&D value="done\tdone \n">'); }); + + group('normalizeRsaSignatureBase64', () { + // Modulus that represents a 2048-bit RSA key: expected signature = 256 bytes. + // BigInt.two.pow(2047) has bitLength == 2048, so expectedLen = (2048+7)~/8 = 256. + final modulus2048 = BigInt.two.pow(2047); + + test('returns the signature unchanged when it is already the correct length', () { + final bytes = Uint8List(256); + for (var i = 0; i < 256; i++) bytes[i] = i & 0xff; + final b64 = base64Encode(bytes); + expect(normalizeRsaSignatureBase64(b64, modulus2048), b64); + }); + + test('left-pads a one-byte-short signature with a leading 0x00', () { + // Build a 256-byte "expected" signature whose first byte is 0x00. + final expected = Uint8List(256); + expected[0] = 0x00; + for (var i = 1; i < 256; i++) expected[i] = i & 0xff; + final expectedB64 = base64Encode(expected); + + // Simulate the ninja bug: the leading 0x00 is dropped → 255-byte signature. + final short = expected.sublist(1); + final shortB64 = base64Encode(short); + expect(shortB64.length, 340); // 255 bytes → 340 Base64 chars (not 344) + + final normalized = normalizeRsaSignatureBase64(shortB64, modulus2048); + expect(normalized, expectedB64); + expect(normalized.length, 344); // 256 bytes → 344 Base64 chars + }); + + test('left-pads a two-byte-short signature with two leading 0x00 bytes', () { + final expected = Uint8List(256); + expected[0] = 0x00; + expected[1] = 0x00; + for (var i = 2; i < 256; i++) expected[i] = i & 0xff; + final expectedB64 = base64Encode(expected); + + final short = expected.sublist(2); // 254 bytes + final shortB64 = base64Encode(short); + + final normalized = normalizeRsaSignatureBase64(shortB64, modulus2048); + expect(normalized, expectedB64); + }); + + test('throws StateError when the signature is longer than the modulus width', () { + final bytes = Uint8List(257); // one byte too many for a 2048-bit key + final b64 = base64Encode(bytes); + expect( + () => normalizeRsaSignatureBase64(b64, modulus2048), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('257 bytes'), + )), + ); + }); + + test('works for a 1024-bit key (128-byte expected length)', () { + final modulus1024 = BigInt.two.pow(1023); // bitLength == 1024 + + final expected = Uint8List(128); + expected[0] = 0x00; + for (var i = 1; i < 128; i++) expected[i] = i & 0xff; + final expectedB64 = base64Encode(expected); + + final short = expected.sublist(1); // 127 bytes + final shortB64 = base64Encode(short); + + final normalized = normalizeRsaSignatureBase64(shortB64, modulus1024); + expect(normalized, expectedB64); + }); + }); } \ No newline at end of file