From d1f5763227b727debfad6bbff7f5f2d83c6f2559 Mon Sep 17 00:00:00 2001 From: Nayan Das Date: Thu, 29 Oct 2026 08:28:57 +0530 Subject: [PATCH] fix(sdk-coin-ada): stop using cbor's sync encode statics for CIP8 structures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `cbor`'s synchronous statics (`Encoder.encode`/`encodeOne`/`encodeCanonical`) only return the first chunk read from the encoder stream, so on Node.js 26 the encoded bytes are truncated to one byte and any subsequent decode fails with "unexpected end of input". That broke the CIP8 utils unit tests and the COSE encode/decode helpers as soon as Node.js 26 became a required CI matrix entry (WCN-3753). Encode by collecting the encoder's `data` events instead: that puts the stream in flowing mode, so chunks are released synchronously as the encoder writes. Draining buffered output with `read()` does not work — past the readable high-water mark `push()` returns false and the encoder's chunked `push()` chains short-circuit, truncating large values; the regression test added here (a 64 KB CIP8 payload, i.e. encoding above the encoder chunk size) fails under that implementation on Node 22.23.2, 24.15.0 and 26.12.1. Verified locally: @bitgo/sdk-coin-ada unit tests 195 passing (exit 0) on Node 22.23.2, 24.15.0 and Node 26.12.1, plus a COSE round-trip smoke test for messages up to 1 MB on all three versions. Ticket: CECHO-2580 --- .../src/lib/messages/cip8/utils.ts | 33 +++++++++++++++---- .../test/unit/messages/cip8/utils.ts | 20 +++++++++++ 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/modules/sdk-coin-ada/src/lib/messages/cip8/utils.ts b/modules/sdk-coin-ada/src/lib/messages/cip8/utils.ts index 04353738e12..919eed3dae6 100644 --- a/modules/sdk-coin-ada/src/lib/messages/cip8/utils.ts +++ b/modules/sdk-coin-ada/src/lib/messages/cip8/utils.ts @@ -7,6 +7,27 @@ export function bytesToHex(bytes: Uint8Array | Buffer): string { return Buffer.from(bytes).toString('hex'); } +/** + * Helper function to encode a value to CBOR bytes. + * + * Do not use `cbor`'s synchronous statics (`Encoder.encode`/`encodeOne`/`encodeCanonical`) here: + * they internally return a single `read()` chunk, which truncates the encoding on Node.js 26 + * (the resulting bytes then fail to decode with "unexpected end of input"). + * + * Keep the `data` listener: it puts the stream in flowing mode, so the encoder's chunks are + * delivered (and released) synchronously as it writes. Draining buffered output with `read()` + * instead does not work — once the readable buffer passes its high-water mark `push()` returns + * false and the encoder's chunked `push()` chains short-circuit, truncating large values. + */ +function encodeCbor(value: unknown): Buffer { + const encoder = new Encoder(); + const chunks: Buffer[] = []; + encoder.on('data', (chunk: Buffer) => chunks.push(chunk)); + encoder.write(value); + encoder.end(); + return Buffer.concat(chunks); +} + export interface CSLSigStructureOutput { sigStructureCborBytes: Uint8Array; protectedHeaderCborBytes: Uint8Array; @@ -33,7 +54,7 @@ export function createCSLSigStructure(addressCborBytes: Uint8Array, message: str const protectedHeaderMap = new Map(); protectedHeaderMap.set(1, -8); // Algorithm ID: EdDSA protectedHeaderMap.set('address', Buffer.from(addressCborBytes)); - const protectedHeaderCborBytes = Encoder.encode(protectedHeaderMap); + const protectedHeaderCborBytes = encodeCbor(protectedHeaderMap); // Sig_structure const sigStructureArray: any[] = [ @@ -42,7 +63,7 @@ export function createCSLSigStructure(addressCborBytes: Uint8Array, message: str Buffer.from([]), // Empty external_aad Buffer.from(payloadBytes), ]; - const sigStructureCborBytes = Encoder.encode(sigStructureArray); + const sigStructureCborBytes = encodeCbor(sigStructureArray); return { sigStructureCborBytes, protectedHeaderCborBytes, payloadBytes }; } @@ -63,9 +84,9 @@ export function constructCSLCoseObjects( Buffer.from(payloadBytes), Buffer.from(cslSignatureBytes), ]; - const finalCoseSign1CborBytes = Encoder.encode(coseSign1Array); + const finalCoseSign1CborBytes = encodeCbor(coseSign1Array); /* // directly encoding the coseSign1Array without prepending the 0xD2 tag. - * const coseSign1PayloadBytes = Encoder.encode(coseSign1Array); + * const coseSign1PayloadBytes = encodeCbor(coseSign1Array); * const coseSign1Tag = Buffer.from([0xD2]); // Tag 18 for COSE_Sign1 * const finalCoseSign1CborBytes = Buffer.concat([coseSign1Tag, coseSign1PayloadBytes]); */ @@ -77,14 +98,14 @@ export function constructCSLCoseObjects( coseKeyMap.set(3, -8); // alg: EdDSA coseKeyMap.set(-1, 6); // crv: Ed25519 coseKeyMap.set(-2, Buffer.from(paymentPubKey.as_bytes())); // x: public_key_bytes (Ed25519 public key) - const finalCoseKeyCborBytes = Encoder.encode(coseKeyMap); + const finalCoseKeyCborBytes = encodeCbor(coseKeyMap); const manualCoseKeyHex = bytesToHex(finalCoseKeyCborBytes); return { manualCoseSign1Hex, manualCoseKeyHex }; } export function coseObjectsOutputToBuffer(output: CSLCoseObjectsOutput): Buffer { - return Buffer.from(Encoder.encode(output)); + return encodeCbor(output); } export async function bufferToCoseObjectsOutput(buffer: Buffer): Promise { diff --git a/modules/sdk-coin-ada/test/unit/messages/cip8/utils.ts b/modules/sdk-coin-ada/test/unit/messages/cip8/utils.ts index 7a1c24982e7..a442f33ff16 100644 --- a/modules/sdk-coin-ada/test/unit/messages/cip8/utils.ts +++ b/modules/sdk-coin-ada/test/unit/messages/cip8/utils.ts @@ -113,5 +113,25 @@ describe('CIP8 Utils', function () { should.equal(reconstructedObjects.manualCoseSign1Hex, originalObjects.manualCoseSign1Hex); should.equal(reconstructedObjects.manualCoseKeyHex, originalObjects.manualCoseKeyHex); }); + + it('should round-trip a payload larger than the cbor encoder chunk size', async function () { + // A truncated encoding (only the first chunk) or a payload dropped by the encoder would + // either fail to decode or come back different, so the round-trip pins the encoding down. + const message = 'a'.repeat(64 * 1024); + const { protectedHeaderCborBytes, payloadBytes } = createCSLSigStructure(addressCborBytes, message); + + const originalObjects = constructCSLCoseObjects( + protectedHeaderCborBytes, + payloadBytes, + cip8TestResources.createTestSignature(message), + cip8TestResources.createTestPublicKey() + ); + + const buffer = coseObjectsOutputToBuffer(originalObjects); + const reconstructedObjects = await bufferToCoseObjectsOutput(buffer); + + should.equal(reconstructedObjects.manualCoseSign1Hex, originalObjects.manualCoseSign1Hex); + should.equal(reconstructedObjects.manualCoseKeyHex, originalObjects.manualCoseKeyHex); + }); }); });