Skip to content

Commit 0ac965f

Browse files
authored
Merge pull request #290 from ethereumjs/fix-ecrecover
signature/address: support for high recovery and chain IDs
2 parents fe518cf + 46415c1 commit 0ac965f

File tree

9 files changed

+481
-92
lines changed

9 files changed

+481
-92
lines changed

.github/workflows/build.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ jobs:
2222

2323
- uses: actions/checkout@v2
2424
- run: npm install
25+
26+
- run: npm run lint
2527
- run: npm run test
2628

2729
- uses: codecov/codecov-action@v1

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
"docs:build": "npx typedoc --options typedoc.js",
1717
"lint": "ethereumjs-config-lint",
1818
"lint:fix": "ethereumjs-config-lint-fix",
19-
"test": "npm run lint && npm run test:node && npm run test:browser",
19+
"test": "npm run test:node && npm run test:browser",
2020
"test:browser": "karma start karma.conf.js",
2121
"test:node": "nyc --reporter=lcov mocha --require ts-node/register 'test/*.spec.ts'",
2222
"tsc": "ethereumjs-config-tsc"

src/account.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { KECCAK256_RLP, KECCAK256_NULL } from './constants'
66
import { zeros, bufferToHex, toBuffer } from './bytes'
77
import { keccak, keccak256, keccakFromString, rlphash } from './hash'
88
import { assertIsHexString, assertIsBuffer } from './helpers'
9-
import { BNLike, BufferLike, bnToRlp } from './types'
9+
import { BNLike, BufferLike, bnToRlp, toType, TypeOutput } from './types'
1010

1111
const {
1212
privateKeyVerify,
@@ -137,11 +137,15 @@ export const isValidAddress = function(hexAddress: string): boolean {
137137
* WARNING: Checksums with and without the chainId will differ. As of 2019-06-26, the most commonly
138138
* used variation in Ethereum was without the chainId. This may change in the future.
139139
*/
140-
export const toChecksumAddress = function(hexAddress: string, eip1191ChainId?: number): string {
140+
export const toChecksumAddress = function(hexAddress: string, eip1191ChainId?: BNLike): string {
141141
assertIsHexString(hexAddress)
142142
const address = stripHexPrefix(hexAddress).toLowerCase()
143143

144-
const prefix = eip1191ChainId !== undefined ? eip1191ChainId.toString() + '0x' : ''
144+
let prefix = ''
145+
if (eip1191ChainId) {
146+
const chainId = toType(eip1191ChainId, TypeOutput.BN)
147+
prefix = chainId.toString() + '0x'
148+
}
145149

146150
const hash = keccakFromString(prefix + address).toString('hex')
147151
let ret = '0x'
@@ -164,7 +168,7 @@ export const toChecksumAddress = function(hexAddress: string, eip1191ChainId?: n
164168
*/
165169
export const isValidChecksumAddress = function(
166170
hexAddress: string,
167-
eip1191ChainId?: number
171+
eip1191ChainId?: BNLike
168172
): boolean {
169173
return isValidAddress(hexAddress) && toChecksumAddress(hexAddress, eip1191ChainId) === hexAddress
170174
}

src/bytes.ts

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import BN from 'bn.js'
22
import { intToBuffer, stripHexPrefix, padToEven, isHexString, isHexPrefixed } from 'ethjs-util'
3-
import { TransformableToArray, TransformableToBuffer } from './types'
3+
import { PrefixedHexString, TransformableToArray, TransformableToBuffer } from './types'
44
import { assertIsBuffer, assertIsArray, assertIsHexString } from './helpers'
55

66
/**
@@ -105,24 +105,24 @@ export const unpadHexString = function(a: string): string {
105105
return stripZeros(a) as string
106106
}
107107

108+
export type ToBufferInputTypes =
109+
| PrefixedHexString
110+
| number
111+
| BN
112+
| Buffer
113+
| Uint8Array
114+
| number[]
115+
| TransformableToArray
116+
| TransformableToBuffer
117+
| null
118+
| undefined
119+
108120
/**
109121
* Attempts to turn a value into a `Buffer`.
110122
* Inputs supported: `Buffer`, `String`, `Number`, null/undefined, `BN` and other objects with a `toArray()` or `toBuffer()` method.
111123
* @param v the value
112124
*/
113-
export const toBuffer = function(
114-
v:
115-
| string
116-
| number
117-
| BN
118-
| Buffer
119-
| Uint8Array
120-
| number[]
121-
| TransformableToArray
122-
| TransformableToBuffer
123-
| null
124-
| undefined
125-
): Buffer {
125+
export const toBuffer = function(v: ToBufferInputTypes): Buffer {
126126
if (v === null || v === undefined) {
127127
return Buffer.allocUnsafe(0)
128128
}

src/signature.ts

Lines changed: 50 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,65 @@
1-
const { ecdsaSign, ecdsaRecover, publicKeyConvert } = require('ethereum-cryptography/secp256k1')
1+
import { ecdsaSign, ecdsaRecover, publicKeyConvert } from 'ethereum-cryptography/secp256k1'
22
import BN from 'bn.js'
33
import { toBuffer, setLengthLeft, bufferToHex, bufferToInt } from './bytes'
44
import { keccak } from './hash'
55
import { assertIsBuffer } from './helpers'
6+
import { BNLike, toType, TypeOutput } from './types'
67

78
export interface ECDSASignature {
89
v: number
910
r: Buffer
1011
s: Buffer
1112
}
1213

14+
export interface ECDSASignatureBuffer {
15+
v: Buffer
16+
r: Buffer
17+
s: Buffer
18+
}
19+
1320
/**
1421
* Returns the ECDSA signature of a message hash.
1522
*/
16-
export const ecsign = function(
17-
msgHash: Buffer,
18-
privateKey: Buffer,
19-
chainId?: number
20-
): ECDSASignature {
21-
const sig = ecdsaSign(msgHash, privateKey)
22-
const recovery: number = sig.recid
23-
24-
const ret = {
25-
r: Buffer.from(sig.signature.slice(0, 32)),
26-
s: Buffer.from(sig.signature.slice(32, 64)),
27-
v: chainId ? recovery + (chainId * 2 + 35) : recovery + 27
23+
export function ecsign(msgHash: Buffer, privateKey: Buffer, chainId?: number): ECDSASignature
24+
export function ecsign(msgHash: Buffer, privateKey: Buffer, chainId: BNLike): ECDSASignatureBuffer
25+
export function ecsign(msgHash: Buffer, privateKey: Buffer, chainId: any): any {
26+
const { signature, recid: recovery } = ecdsaSign(msgHash, privateKey)
27+
28+
const r = Buffer.from(signature.slice(0, 32))
29+
const s = Buffer.from(signature.slice(32, 64))
30+
31+
if (!chainId || typeof chainId === 'number') {
32+
// return legacy type ECDSASignature (deprecated in favor of ECDSASignatureBuffer to handle large chainIds)
33+
if (chainId && !Number.isSafeInteger(chainId)) {
34+
throw new Error(
35+
'The provided number is greater than MAX_SAFE_INTEGER (please use an alternative input type)'
36+
)
37+
}
38+
const v = chainId ? recovery + (chainId * 2 + 35) : recovery + 27
39+
return { r, s, v }
2840
}
2941

30-
return ret
42+
const chainIdBN = toType(chainId, TypeOutput.BN)
43+
const v = chainIdBN
44+
.muln(2)
45+
.addn(35)
46+
.addn(recovery)
47+
.toArrayLike(Buffer)
48+
return { r, s, v }
3149
}
3250

33-
function calculateSigRecovery(v: number, chainId?: number): number {
34-
return chainId ? v - (2 * chainId + 35) : v - 27
51+
function calculateSigRecovery(v: BNLike, chainId?: BNLike): BN {
52+
const vBN = toType(v, TypeOutput.BN)
53+
if (!chainId) {
54+
return vBN.subn(27)
55+
}
56+
const chainIdBN = toType(chainId, TypeOutput.BN)
57+
return vBN.sub(chainIdBN.muln(2).addn(35))
3558
}
3659

37-
function isValidSigRecovery(recovery: number): boolean {
38-
return recovery === 0 || recovery === 1
60+
function isValidSigRecovery(recovery: number | BN): boolean {
61+
const rec = new BN(recovery)
62+
return rec.eqn(0) || rec.eqn(1)
3963
}
4064

4165
/**
@@ -44,25 +68,25 @@ function isValidSigRecovery(recovery: number): boolean {
4468
*/
4569
export const ecrecover = function(
4670
msgHash: Buffer,
47-
v: number,
71+
v: BNLike,
4872
r: Buffer,
4973
s: Buffer,
50-
chainId?: number
74+
chainId?: BNLike
5175
): Buffer {
5276
const signature = Buffer.concat([setLengthLeft(r, 32), setLengthLeft(s, 32)], 64)
5377
const recovery = calculateSigRecovery(v, chainId)
5478
if (!isValidSigRecovery(recovery)) {
5579
throw new Error('Invalid signature v value')
5680
}
57-
const senderPubKey = ecdsaRecover(signature, recovery, msgHash)
81+
const senderPubKey = ecdsaRecover(signature, recovery.toNumber(), msgHash)
5882
return Buffer.from(publicKeyConvert(senderPubKey, false).slice(1))
5983
}
6084

6185
/**
6286
* Convert signature parameters into the format of `eth_sign` RPC method.
6387
* @returns Signature
6488
*/
65-
export const toRpcSig = function(v: number, r: Buffer, s: Buffer, chainId?: number): string {
89+
export const toRpcSig = function(v: BNLike, r: Buffer, s: Buffer, chainId?: BNLike): string {
6690
const recovery = calculateSigRecovery(v, chainId)
6791
if (!isValidSigRecovery(recovery)) {
6892
throw new Error('Invalid signature v value')
@@ -101,11 +125,11 @@ export const fromRpcSig = function(sig: string): ECDSASignature {
101125
* @param homesteadOrLater Indicates whether this is being used on either the homestead hardfork or a later one
102126
*/
103127
export const isValidSignature = function(
104-
v: number,
128+
v: BNLike,
105129
r: Buffer,
106130
s: Buffer,
107131
homesteadOrLater: boolean = true,
108-
chainId?: number
132+
chainId?: BNLike
109133
): boolean {
110134
const SECP256K1_N_DIV_2 = new BN(
111135
'7fffffffffffffffffffffffffffffff5d576e7357a4501ddfe92f46681b20a0',
@@ -121,8 +145,8 @@ export const isValidSignature = function(
121145
return false
122146
}
123147

124-
const rBN: BN = new BN(r)
125-
const sBN: BN = new BN(s)
148+
const rBN = new BN(r)
149+
const sBN = new BN(s)
126150

127151
if (rBN.isZero() || rBN.gt(SECP256K1_N) || sBN.isZero() || sBN.gt(SECP256K1_N)) {
128152
return false

src/types.ts

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import BN from 'bn.js'
2+
import { isHexString } from 'ethjs-util'
23
import { Address } from './address'
3-
import { unpadBuffer } from './bytes'
4+
import { unpadBuffer, toBuffer, ToBufferInputTypes } from './bytes'
45

56
/*
67
* A type that represents a BNLike input that can be converted to a BN.
78
*/
8-
export type BNLike = BN | string | number
9+
export type BNLike = BN | PrefixedHexString | number | Buffer
910

1011
/*
1112
* A type that represents a BufferLike input that can be converted to a Buffer.
@@ -28,7 +29,7 @@ export type PrefixedHexString = string
2829
* A type that represents an Address-like value.
2930
* To convert to address, use `new Address(toBuffer(value))`
3031
*/
31-
export type AddressLike = Address | Buffer | string
32+
export type AddressLike = Address | Buffer | PrefixedHexString
3233

3334
/*
3435
* A type that represents an object that has a `toArray()` method.
@@ -62,3 +63,58 @@ export function bnToRlp(value: BN): Buffer {
6263
// for compatibility with browserify and similar tools
6364
return unpadBuffer(value.toArrayLike(Buffer))
6465
}
66+
67+
/**
68+
* Type output options
69+
*/
70+
export enum TypeOutput {
71+
Number,
72+
BN,
73+
Buffer,
74+
PrefixedHexString
75+
}
76+
77+
export type TypeOutputReturnType = {
78+
[TypeOutput.Number]: number
79+
[TypeOutput.BN]: BN
80+
[TypeOutput.Buffer]: Buffer
81+
[TypeOutput.PrefixedHexString]: PrefixedHexString
82+
}
83+
84+
/**
85+
* Convert an input to a specified type
86+
* @param input value to convert
87+
* @param outputType type to output
88+
*/
89+
export function toType<T extends TypeOutput>(
90+
input: ToBufferInputTypes,
91+
outputType: T
92+
): TypeOutputReturnType[T] {
93+
if (typeof input === 'string' && !isHexString(input)) {
94+
throw new Error(`A string must be provided with a 0x-prefix, given: ${input}`)
95+
} else if (typeof input === 'number' && !Number.isSafeInteger(input)) {
96+
throw new Error(
97+
'The provided number is greater than MAX_SAFE_INTEGER (please use an alternative input type)'
98+
)
99+
}
100+
101+
input = toBuffer(input)
102+
103+
if (outputType === TypeOutput.Buffer) {
104+
return input as any
105+
} else if (outputType === TypeOutput.BN) {
106+
return new BN(input) as any
107+
} else if (outputType === TypeOutput.Number) {
108+
const bn = new BN(input)
109+
const max = new BN(Number.MAX_SAFE_INTEGER.toString())
110+
if (bn.gt(max)) {
111+
throw new Error(
112+
'The provided number is greater than MAX_SAFE_INTEGER (please use an alternative output type)'
113+
)
114+
}
115+
return bn.toNumber() as any
116+
} else {
117+
// outputType === TypeOutput.PrefixedHexString
118+
return `0x${input.toString('hex')}` as any
119+
}
120+
}

test/account.spec.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -602,9 +602,35 @@ describe('.toChecksumAddress()', function() {
602602
for (const [chainId, addresses] of Object.entries(eip1191ChecksummAddresses)) {
603603
for (const addr of addresses) {
604604
assert.equal(toChecksumAddress(addr.toLowerCase(), Number(chainId)), addr)
605+
assert.equal(toChecksumAddress(addr.toLowerCase(), Buffer.from([chainId])), addr)
606+
assert.equal(toChecksumAddress(addr.toLowerCase(), new BN(chainId)), addr)
607+
assert.equal(
608+
toChecksumAddress(addr.toLowerCase(), '0x' + Buffer.from([chainId]).toString('hex')),
609+
addr
610+
)
605611
}
606612
}
607613
})
614+
it('Should encode large chain ids greater than MAX_INTEGER correctly', function() {
615+
const addr = '0x88021160C5C792225E4E5452585947470010289D'
616+
const chainIDBuffer = Buffer.from('796f6c6f763378', 'hex')
617+
assert.equal(toChecksumAddress(addr.toLowerCase(), chainIDBuffer), addr)
618+
assert.equal(toChecksumAddress(addr.toLowerCase(), new BN(chainIDBuffer)), addr)
619+
assert.equal(
620+
toChecksumAddress(addr.toLowerCase(), '0x' + chainIDBuffer.toString('hex')),
621+
addr
622+
)
623+
const chainIDNumber = parseInt(chainIDBuffer.toString('hex'), 16)
624+
assert.throws(
625+
() => {
626+
toChecksumAddress(addr.toLowerCase(), chainIDNumber)
627+
},
628+
{
629+
message:
630+
'The provided number is greater than MAX_SAFE_INTEGER (please use an alternative input type)'
631+
}
632+
)
633+
})
608634
})
609635

610636
describe('input format', function() {
@@ -613,6 +639,11 @@ describe('.toChecksumAddress()', function() {
613639
toChecksumAddress('52908400098527886E0F7030069857D2E4169EE7'.toLowerCase())
614640
})
615641
})
642+
it('Should throw when the chainId is not hex-prefixed', function() {
643+
assert.throws(function() {
644+
toChecksumAddress('0xde709f2102306220921060314715629080e2fb77', '1234')
645+
})
646+
})
616647
})
617648
})
618649

@@ -633,6 +664,12 @@ describe('.isValidChecksumAddress()', function() {
633664
for (const [chainId, addresses] of Object.entries(eip1191ChecksummAddresses)) {
634665
for (const addr of addresses) {
635666
assert.equal(isValidChecksumAddress(addr, Number(chainId)), true)
667+
assert.equal(isValidChecksumAddress(addr, Buffer.from([chainId])), true)
668+
assert.equal(isValidChecksumAddress(addr, new BN(chainId)), true)
669+
assert.equal(
670+
isValidChecksumAddress(addr, '0x' + Buffer.from([chainId]).toString('hex')),
671+
true
672+
)
636673
}
637674
}
638675
})

0 commit comments

Comments
 (0)