From 401393f9f1f9d9956b2ac4d864c0fc924fb0b5c8 Mon Sep 17 00:00:00 2001 From: benma's agent Date: Fri, 2 Oct 2026 12:39:08 +0200 Subject: [PATCH] eth: preserve signed integer encodings EIP-712 intN values are sent as signed big-endian two's-complement bytes. The JavaScript-number path strips the leading zero supplied by the signed serializer, so e.g. int16(128) becomes -128 on the device. String and bigint inputs already preserve the sign correctly. Use bigIntToSignedBytesBE directly for all accepted input types, without stripping its sign byte. Add positive, negative and zero boundary cases that check identical encodings for number, string and bigint inputs. Add a simulator regression that signs int16(+128), int8(-128) and int8(0) and checks the signature against an independently calculated EIP-712 digest. This covers the signed values through the public API and firmware. Unsigned integer encoding and the firmware protocol are unchanged. No firmware version gate is needed. Related PRs: - Go: https://github.com/BitBoxSwiss/bitbox02-api-go/pull/189 - Rust: https://github.com/BitBoxSwiss/bitbox-api-rs/pull/135 --- CHANGELOG.md | 1 + src/internal/eth/eip712.ts | 7 ------- test/eth-eip712.test.ts | 15 +++++++++++++++ test/simulator-eth.test.ts | 33 +++++++++++++++++++++++++++++++++ 4 files changed, 49 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d348a85..04ec6c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## Unreleased +- Preserve the sign of positive EIP-712 signed integers supplied as JavaScript numbers. - Reset unfinished sessions when connecting to firmware v9.28.0 or newer, allowing host reconnects while the device remains powered on. diff --git a/src/internal/eth/eip712.ts b/src/internal/eth/eip712.ts index f985551..2357f9a 100644 --- a/src/internal/eth/eip712.ts +++ b/src/internal/eth/eip712.ts @@ -255,13 +255,6 @@ export function encodeValue( } case DataType.INT: { const n = parseIntValue(value); - if (typeof value === 'number') { - const bytes = bigIntToSignedBytesBE(n); - if (bytes.length > 0 && bytes[0] === 0x00) { - return bytes.subarray(1); - } - return bytes; - } return bigIntToSignedBytesBE(n); } case DataType.BOOL: { diff --git a/test/eth-eip712.test.ts b/test/eth-eip712.test.ts index 8057283..7eca418 100644 --- a/test/eth-eip712.test.ts +++ b/test/eth-eip712.test.ts @@ -163,6 +163,21 @@ describe('encodeValue', () => { ]); }); + it.each([ + [0, '00'], [1, '01'], [-1, 'ff'], + [127, '7f'], [128, '0080'], [129, '0081'], + [-127, '81'], [-128, '80'], [-129, 'ff7f'], + [255, '00ff'], [256, '0100'], [-255, 'ff01'], [-256, 'ff00'], + [32767, '7fff'], [32768, '008000'], + [-32768, '8000'], [-32769, 'ff7fff'], + [65535, '00ffff'], [65536, '010000'], + ] as const)('encodes signed %s consistently across input types', (value, expected) => { + const typ = parseType('int64', empty); + for (const input of [value, String(value), BigInt(value)]) { + expect(Buffer.from(encodeValue(typ, input)).toString('hex')).toBe(expected); + } + }); + it('encodes booleans', () => { expect(Array.from(encodeValue(parseType('bool', empty), false))).toEqual([0]); expect(Array.from(encodeValue(parseType('bool', empty), true))).toEqual([1]); diff --git a/test/simulator-eth.test.ts b/test/simulator-eth.test.ts index 22b99be..2fdcb6b 100644 --- a/test/simulator-eth.test.ts +++ b/test/simulator-eth.test.ts @@ -265,6 +265,39 @@ describe.skipIf(!ENABLED).sequential.each(simulatorCases())('simulator eth $name expect(bytesToHex(signatureBytes(sig1))).not.toBe(bytesToHex(signatureBytes(sig2))); }, 60_000); + it('ethSignTypedMessage preserves signed integer values', async () => { + const msg = { + types: { + EIP712Domain: [{ name: 'name', type: 'string' }], + SignedIntegers: [ + { name: 'positive', type: 'int16' }, + { name: 'negative', type: 'int8' }, + { name: 'zero', type: 'int8' }, + ], + }, + primaryType: 'SignedIntegers', + domain: { name: 'Signed integers' }, + message: { positive: 128, negative: -128, zero: 0 }, + }; + const sig = await paired!.ethSignTypedMessage(1n, ETH_KEYPATH, msg, true); + expect(signatureBytes(sig)).toHaveLength(65); + + // Compute the EIP-712 digest independently of the wire encoder. The device + // must sign +128, -128 and zero, not reinterpret or reject their sign bytes. + const domainHash = keccak_256(concatBytes( + keccak_256(utf8ToBytes('EIP712Domain(string name)')), + keccak_256(utf8ToBytes('Signed integers')), + )); + const messageHash = keccak_256(concatBytes( + keccak_256(utf8ToBytes('SignedIntegers(int16 positive,int8 negative,int8 zero)')), + concatBytes(new Uint8Array(31), new Uint8Array([0x80])), + concatBytes(new Uint8Array(31).fill(0xff), new Uint8Array([0x80])), + new Uint8Array(32), + )); + const digest = keccak_256(concatBytes(new Uint8Array([0x19, 0x01]), domainHash, messageHash)); + expectSignatureFromSimulatorAddress(digest, sig); + }, 60_000); + it.skipIf(!atLeast926)('ethSignTypedMessage with anti-klepto disabled is deterministic', async () => { const sig1 = await paired!.ethSignTypedMessage(1n, ETH_KEYPATH, EIP712_MSG, false); const sig2 = await paired!.ethSignTypedMessage(1n, ETH_KEYPATH, EIP712_MSG, false);