Skip to content

Commit 2bc7eef

Browse files
razortraxclaude
andauthored
🐛 extractSignature: read DER length instead of stripping trailing 00 pairs (#319)
The trailing-byte strip (replace(/(?:00|>)+$/)) cannot distinguish sign()'s zero-padding from a genuine signature whose last byte(s) happen to be 0x00 (~1/256 chance per signature), so it intermittently truncated valid DER and downstream parsers failed with "Too few bytes to read ASN.1 value." Read the total element length from the DER SEQUENCE header instead; fall back to the historical trailing-zero strip when the blob is not parseable DER. Fixes #317. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent ed3a144 commit 2bc7eef

2 files changed

Lines changed: 87 additions & 2 deletions

File tree

‎packages/utils/src/extractSignature.js‎

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,33 @@ const getSubstringIndex = (str, substring, n) => {
1111

1212
return index;
1313
};
14+
15+
/**
16+
* Total encoded length (header + content) of the DER element starting at buf[0].
17+
* Returns -1 if the buffer does not start with a parseable SEQUENCE header.
18+
* @param {Buffer} buf
19+
* @returns {number}
20+
*/
21+
const derTotalLength = (buf) => {
22+
if (buf.length < 2 || buf[0] !== 0x30) {
23+
return -1;
24+
}
25+
const lengthByte = buf[1];
26+
if ((lengthByte & 0x80) === 0) {
27+
// Short form: the byte is the content length itself.
28+
return 2 + lengthByte;
29+
}
30+
const numLengthOctets = lengthByte & 0x7f;
31+
if (numLengthOctets === 0 || buf.length < 2 + numLengthOctets) {
32+
return -1;
33+
}
34+
let contentLength = 0;
35+
for (let i = 0; i < numLengthOctets; i += 1) {
36+
contentLength = contentLength * 256 + buf[2 + i];
37+
}
38+
return 2 + numLengthOctets + contentLength;
39+
};
40+
1441
/**
1542
* Basic implementation of signature extraction.
1643
*
@@ -62,9 +89,21 @@ export const extractSignature = (pdf, signatureCount = 1) => {
6289

6390
const signatureHex = pdf.slice(ByteRange[0] + ByteRange[1] + 1, ByteRange[2])
6491
.toString('binary')
65-
.replace(/(?:00|>)+$/, '');
92+
.replace(/(?:>|\s)+$/, '');
93+
94+
// sign() right-pads the signature with zero bytes up to the placeholder
95+
// length. DER is self-describing, so read the real element length from the
96+
// SEQUENCE header instead of stripping trailing "00" pairs — a genuine
97+
// signature has a ~1/256 chance of ending in 0x00 itself, and the old
98+
// regex-based strip would eat those real bytes and corrupt the DER (#317).
99+
const padded = Buffer.from(signatureHex, 'hex');
100+
const totalLength = derTotalLength(padded);
101+
const signatureBuffer = totalLength > 0 && totalLength <= padded.length
102+
? padded.slice(0, totalLength)
103+
// Not parseable as DER — keep the historical trailing-zero strip.
104+
: Buffer.from(signatureHex.replace(/(?:00)+$/, ''), 'hex');
66105

67-
const signature = Buffer.from(signatureHex, 'hex').toString('binary');
106+
const signature = signatureBuffer.toString('binary');
68107

69108
return {
70109
ByteRange: matches.slice(1, 5).map(Number),

‎packages/utils/src/extractSignature.test.js‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,4 +43,50 @@ describe(extractSignature, () => {
4343
const extracted = extractSignature(signedPdf);
4444
expect(extracted).toMatchSnapshot();
4545
});
46+
47+
/**
48+
* Builds a minimal fake signed PDF: a self-consistent /ByteRange header,
49+
* a /Contents-style <hex> placeholder holding derBuffer + zero padding,
50+
* and a trailer.
51+
*/
52+
const makeFakeSignedPdf = (derBuffer, paddingBytes) => {
53+
const hex = derBuffer.toString('hex') + '00'.repeat(paddingBytes);
54+
const placeholder = `<${hex}>`;
55+
const trailer = 'trailer';
56+
const pad = (n) => String(n).padStart(10, '0');
57+
// Fixed-width numbers keep the header length independent of the values.
58+
const prefix = '%PDF-1.7\n';
59+
const headerFor = (b1, b2, b3) => `/ByteRange [0 ${pad(b1)} ${pad(b2)} ${pad(b3)}]`;
60+
const b1 = prefix.length + headerFor(0, 0, 0).length; // offset of '<'
61+
const b2 = b1 + placeholder.length; // just past '>'
62+
const header = headerFor(b1, b2, trailer.length);
63+
return Buffer.from(prefix + header + placeholder + trailer, 'binary');
64+
};
65+
66+
it('keeps a genuine trailing 0x00 byte of the signature (#317)', () => {
67+
// DER SEQUENCE whose real content ends in 0x00:
68+
// 30 05 (SEQUENCE, len 5) 04 03 aa bb 00 (OCTET STRING, len 3)
69+
const der = Buffer.from('30050403aabb00', 'hex');
70+
const extracted = extractSignature(makeFakeSignedPdf(der, 20));
71+
expect(Buffer.from(extracted.signature, 'binary')).toEqual(der);
72+
});
73+
74+
it('trims placeholder padding from a long-form-length DER ending in 0x00', () => {
75+
// SEQUENCE with long-form length: 30 82 01 06, then 262 content bytes
76+
// (a 258-byte OCTET STRING incl. its 4-byte header) ending in 0x00.
77+
const content = Buffer.concat([
78+
Buffer.from('04820102', 'hex'),
79+
Buffer.alloc(258, 0xab),
80+
]);
81+
content[content.length - 1] = 0x00;
82+
const der = Buffer.concat([Buffer.from('30820106', 'hex'), content]);
83+
const extracted = extractSignature(makeFakeSignedPdf(der, 40));
84+
expect(Buffer.from(extracted.signature, 'binary')).toEqual(der);
85+
});
86+
87+
it('extracts an unpadded signature unchanged', () => {
88+
const der = Buffer.from('30050403aabbcc', 'hex');
89+
const extracted = extractSignature(makeFakeSignedPdf(der, 0));
90+
expect(Buffer.from(extracted.signature, 'binary')).toEqual(der);
91+
});
4692
});

0 commit comments

Comments
 (0)