Skip to content

Commit 9acccaa

Browse files
authored
fix: add DER decoder and allow passing protobuf digest (#3013)
Adds enough of a DER decoder/encoder to parse/store public keys from PKIX bytes and private keys in PKCS#1 bytes. Also allows passing the digest of a public key in to `publicKeyFromProtobuf` so we can avoid having to recalculate it. This becomes a big bottleneck when loading a routing table's worth of peers from the peer store where many of them are RSA peers.
1 parent 64052ee commit 9acccaa

File tree

10 files changed

+791
-126
lines changed

10 files changed

+791
-126
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/* eslint-disable no-console */
2+
import * as asn1js from 'asn1js'
3+
import Benchmark from 'benchmark'
4+
import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string'
5+
import { toString as uint8ArrayToString } from 'uint8arrays/to-string'
6+
import { decodeDer } from '../../dist/src/keys/rsa/der.js'
7+
8+
// results
9+
// % node ./benchmark/der/decode-pkcs1-to-jwk.js
10+
// asn1js x 74,179 ops/sec ±0.57% (94 runs sampled)
11+
// decodeDer x 272,341 ops/sec ±0.67% (96 runs sampled)
12+
13+
const suite = new Benchmark.Suite('decode PKCS#1 to JWK public key')
14+
15+
const encoded = 'MIIBOgIBAAJBAL2ujQhqyQTxxktr+/9R4gs0SeXc3jldrKYBIUfEjo6L+q5isoFkzUV5Tc8eV19wHSN8shr8QJ67MMzMG6lYIYUCAwEAAQJAWlDTfE+EOaN5XI4lJfPyIo2aJiXddgkhXMWq+AYiLsKqquDCMPExj4S+k6tjdwzFVvEKoaSXTgTAqgPyyd1aAQIhAPeUGH5izGi3BDyXao+fONvHCQHtyxtUKllFoT8bJVsFAiEAxCJLFLFy1v5rodCKIvlBSQ8FbJqz7mk04Tf2eT3bdIECIFxDNWmMGg7//TUzXEPPm1nT75hnbKRvliSUnUWuMRqdAiBP5MhAvafR/AFMAO7EIFR/tia3fq0cyK5Jr8ouyQvEAQIhAJyqAHjILO1S+hqtsZidzIxi9qlIc2cEqOlzLH9RlPxy'
16+
const der = uint8ArrayFromString(encoded, 'base64')
17+
18+
suite.add('asn1js', async () => {
19+
const { result } = asn1js.fromBER(der)
20+
const values = result.valueBlock.value
21+
22+
return {
23+
n: asn1jsIntegerToBase64(values[1]),
24+
e: asn1jsIntegerToBase64(values[2]),
25+
d: asn1jsIntegerToBase64(values[3]),
26+
p: asn1jsIntegerToBase64(values[4]),
27+
q: asn1jsIntegerToBase64(values[5]),
28+
dp: asn1jsIntegerToBase64(values[6]),
29+
dq: asn1jsIntegerToBase64(values[7]),
30+
qi: asn1jsIntegerToBase64(values[8]),
31+
kty: 'RSA',
32+
alg: 'RS256'
33+
}
34+
})
35+
36+
suite.add('decodeDer', async () => {
37+
const values = decodeDer(der)
38+
39+
return {
40+
n: uint8ArrayToString(values[1], 'base64url'),
41+
e: uint8ArrayToString(values[2], 'base64url'),
42+
d: uint8ArrayToString(values[3], 'base64url'),
43+
p: uint8ArrayToString(values[4], 'base64url'),
44+
q: uint8ArrayToString(values[5], 'base64url'),
45+
dp: uint8ArrayToString(values[6], 'base64url'),
46+
dq: uint8ArrayToString(values[7], 'base64url'),
47+
qi: uint8ArrayToString(values[8], 'base64url'),
48+
kty: 'RSA',
49+
alg: 'RS256'
50+
}
51+
})
52+
53+
suite
54+
.on('cycle', (event) => console.log(String(event.target)))
55+
.run({ async: true })
56+
57+
function asn1jsIntegerToBase64 (int) {
58+
let buf = int.valueBlock.valueHexView
59+
60+
// chrome rejects values with leading 0s
61+
while (buf[0] === 0) {
62+
buf = buf.subarray(1)
63+
}
64+
65+
return uint8ArrayToString(buf, 'base64url')
66+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/* eslint-disable no-console */
2+
import * as asn1js from 'asn1js'
3+
import Benchmark from 'benchmark'
4+
import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string'
5+
import { toString as uint8ArrayToString } from 'uint8arrays/to-string'
6+
import { decodeDer } from '../../dist/src/keys/rsa/der.js'
7+
8+
// results
9+
// % % node ./benchmark/der/decode-pkix-to-jwk.js
10+
// asn1js x 99,871 ops/sec ±0.47% (95 runs sampled)
11+
// decodeDer x 1,052,352 ops/sec ±0.33% (98 runs sampled)
12+
13+
const suite = new Benchmark.Suite('decode PKIX to JWK public key')
14+
15+
const encoded = 'MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAM8jzWa2jaL3NENRi8tI2P5jjbF1biAz4004xVLD9pG/G+HERbN9v7fvXwsB0kCu8VSfAD2JVS62C5oQ1mDQFEMCAwEAAQ'
16+
const der = uint8ArrayFromString(encoded, 'base64')
17+
18+
suite.add('asn1js', async () => {
19+
const { result } = asn1js.fromBER(der)
20+
21+
// @ts-expect-error this looks fragile but DER is a canonical format so we are
22+
// safe to have deeply property chains like this
23+
const values = result.valueBlock.value[1].valueBlock.value[0].valueBlock.value
24+
25+
return {
26+
kty: 'RSA',
27+
n: asn1jsIntegerToBase64(values[0]),
28+
e: asn1jsIntegerToBase64(values[1])
29+
}
30+
})
31+
32+
suite.add('decodeDer', async () => {
33+
const decoded = decodeDer(der, {
34+
offset: 0
35+
})
36+
37+
return {
38+
kty: 'RSA',
39+
n: uint8ArrayToString(
40+
decoded[1][0],
41+
'base64url'
42+
),
43+
e: uint8ArrayToString(
44+
decoded[1][1],
45+
'base64url'
46+
)
47+
}
48+
})
49+
50+
suite
51+
.on('cycle', (event) => console.log(String(event.target)))
52+
.run({ async: true })
53+
54+
function asn1jsIntegerToBase64 (int) {
55+
let buf = int.valueBlock.valueHexView
56+
57+
// chrome rejects values with leading 0s
58+
while (buf[0] === 0) {
59+
buf = buf.subarray(1)
60+
}
61+
62+
return uint8ArrayToString(buf, 'base64url')
63+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
/* eslint-disable no-console */
2+
import * as asn1js from 'asn1js'
3+
import Benchmark from 'benchmark'
4+
import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string'
5+
import { encodeSequence, encodeInteger } from '../../dist/src/keys/rsa/der.js'
6+
import { RSA_KEY_512_BITS } from '../../dist/test/fixtures/rsa.js'
7+
8+
// results
9+
// % node ./benchmark/der/encode-jwk-to-pkcs1.js
10+
// asn1js x 21,453 ops/sec ±0.54% (92 runs sampled)
11+
// encodeSequence x 65,991 ops/sec ±0.36% (98 runs sampled)
12+
13+
const jwk = RSA_KEY_512_BITS.privateKey
14+
15+
const suite = new Benchmark.Suite('encode JWK private key to PKCS#1')
16+
17+
suite.add('asn1js', async () => {
18+
const root = new asn1js.Sequence({
19+
value: [
20+
new asn1js.Integer({ value: 0 }),
21+
asn1js.Integer.fromBigInt(bufToBn(uint8ArrayFromString(jwk.n, 'base64url'))),
22+
asn1js.Integer.fromBigInt(bufToBn(uint8ArrayFromString(jwk.e, 'base64url'))),
23+
asn1js.Integer.fromBigInt(bufToBn(uint8ArrayFromString(jwk.d, 'base64url'))),
24+
asn1js.Integer.fromBigInt(bufToBn(uint8ArrayFromString(jwk.p, 'base64url'))),
25+
asn1js.Integer.fromBigInt(bufToBn(uint8ArrayFromString(jwk.q, 'base64url'))),
26+
asn1js.Integer.fromBigInt(bufToBn(uint8ArrayFromString(jwk.dp, 'base64url'))),
27+
asn1js.Integer.fromBigInt(bufToBn(uint8ArrayFromString(jwk.dq, 'base64url'))),
28+
asn1js.Integer.fromBigInt(bufToBn(uint8ArrayFromString(jwk.qi, 'base64url')))
29+
]
30+
})
31+
32+
const der = root.toBER()
33+
34+
return new Uint8Array(der, 0, der.byteLength)
35+
})
36+
37+
suite.add('encodeSequence', async () => {
38+
return encodeSequence([
39+
encodeInteger(Uint8Array.from([0])),
40+
encodeInteger(uint8ArrayFromString(jwk.n, 'base64url')),
41+
encodeInteger(uint8ArrayFromString(jwk.e, 'base64url')),
42+
encodeInteger(uint8ArrayFromString(jwk.d, 'base64url')),
43+
encodeInteger(uint8ArrayFromString(jwk.p, 'base64url')),
44+
encodeInteger(uint8ArrayFromString(jwk.q, 'base64url')),
45+
encodeInteger(uint8ArrayFromString(jwk.dp, 'base64url')),
46+
encodeInteger(uint8ArrayFromString(jwk.dq, 'base64url')),
47+
encodeInteger(uint8ArrayFromString(jwk.qi, 'base64url'))
48+
]).subarray()
49+
})
50+
51+
suite
52+
.on('cycle', (event) => console.log(String(event.target)))
53+
.run({ async: true })
54+
55+
function bufToBn (u8) {
56+
const hex = []
57+
58+
u8.forEach(function (i) {
59+
let h = i.toString(16)
60+
61+
if (h.length % 2 > 0) {
62+
h = `0${h}`
63+
}
64+
65+
hex.push(h)
66+
})
67+
68+
return BigInt('0x' + hex.join(''))
69+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/* eslint-disable no-console */
2+
import * as asn1js from 'asn1js'
3+
import Benchmark from 'benchmark'
4+
import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string'
5+
import { encodeBitString, encodeSequence, encodeInteger } from '../../dist/src/keys/rsa/der.js'
6+
import { RSA_KEY_512_BITS } from '../../dist/test/fixtures/rsa.js'
7+
8+
// results
9+
// % node ./benchmark/der/encode-jwk-to-pkix.js
10+
// asn1js x 41,774 ops/sec ±0.67% (91 runs sampled)
11+
// encodeDer x 244,387 ops/sec ±0.36% (95 runs sampled)
12+
13+
const jwk = RSA_KEY_512_BITS.publicKey
14+
15+
const suite = new Benchmark.Suite('encode JWK public key to PKIX')
16+
17+
const algorithmIdentifierSequence = Uint8Array.from([
18+
0x30, 0x0D, 0x06, 0x09, 0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x01, 0x05, 0x00
19+
])
20+
21+
suite.add('asn1js', async () => {
22+
const root = new asn1js.Sequence({
23+
value: [
24+
new asn1js.Sequence({
25+
value: [
26+
// rsaEncryption
27+
new asn1js.ObjectIdentifier({
28+
value: '1.2.840.113549.1.1.1'
29+
}),
30+
new asn1js.Null()
31+
]
32+
}),
33+
new asn1js.BitString({
34+
valueHex: new asn1js.Sequence({
35+
value: [
36+
asn1js.Integer.fromBigInt(bufToBn(uint8ArrayFromString(jwk.n, 'base64url'))),
37+
asn1js.Integer.fromBigInt(bufToBn(uint8ArrayFromString(jwk.e, 'base64url')))
38+
]
39+
}).toBER()
40+
})
41+
]
42+
})
43+
44+
return root.toBER()
45+
})
46+
47+
suite.add('encodeDer', async () => {
48+
return encodeSequence([
49+
algorithmIdentifierSequence,
50+
encodeBitString(
51+
encodeSequence([
52+
encodeInteger(uint8ArrayFromString(jwk.n, 'base64url')),
53+
encodeInteger(uint8ArrayFromString(jwk.e, 'base64url'))
54+
])
55+
)
56+
]).subarray()
57+
})
58+
59+
suite
60+
.on('cycle', (event) => console.log(String(event.target)))
61+
.run({ async: true })
62+
63+
function bufToBn (u8) {
64+
const hex = []
65+
66+
u8.forEach(function (i) {
67+
let h = i.toString(16)
68+
69+
if (h.length % 2 > 0) {
70+
h = `0${h}`
71+
}
72+
73+
hex.push(h)
74+
})
75+
76+
return BigInt('0x' + hex.join(''))
77+
}

packages/crypto/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,6 @@
9595
"@libp2p/interface": "^2.6.1",
9696
"@noble/curves": "^1.7.0",
9797
"@noble/hashes": "^1.6.1",
98-
"asn1js": "^3.0.5",
9998
"multiformats": "^13.3.1",
10099
"protons-runtime": "^5.5.0",
101100
"uint8arraylist": "^2.4.8",
@@ -104,6 +103,7 @@
104103
"devDependencies": {
105104
"@types/mocha": "^10.0.10",
106105
"aegir": "^45.1.1",
106+
"asn1js": "^3.0.5",
107107
"benchmark": "^2.1.4",
108108
"protons": "^7.6.0"
109109
},

packages/crypto/src/keys/index.ts

+10-3
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { pkcs1ToRSAPrivateKey, pkixToRSAPublicKey, generateRSAKeyPair } from './
1515
import { generateSecp256k1KeyPair, unmarshalSecp256k1PrivateKey, unmarshalSecp256k1PublicKey } from './secp256k1/utils.js'
1616
import type { PrivateKey, PublicKey, KeyType, RSAPrivateKey, Secp256k1PrivateKey, Ed25519PrivateKey, Secp256k1PublicKey, Ed25519PublicKey } from '@libp2p/interface'
1717
import type { MultihashDigest } from 'multiformats'
18+
import type { Digest } from 'multiformats/hashes/digest'
1819

1920
export { generateEphemeralKeyPair } from './ecdh/index.js'
2021
export type { Curve } from './ecdh/index.js'
@@ -61,15 +62,21 @@ export async function generateKeyPairFromSeed (type: string, seed: Uint8Array):
6162
}
6263

6364
/**
64-
* Converts a protobuf serialized public key into its representative object
65+
* Converts a protobuf serialized public key into its representative object.
66+
*
67+
* For RSA public keys optionally pass the multihash digest of the public key if
68+
* it is known. If the digest is omitted it will be calculated which can be
69+
* expensive.
70+
*
71+
* For other key types the digest option is ignored.
6572
*/
66-
export function publicKeyFromProtobuf (buf: Uint8Array): PublicKey {
73+
export function publicKeyFromProtobuf (buf: Uint8Array, digest?: Digest<18, number>): PublicKey {
6774
const { Type, Data } = pb.PublicKey.decode(buf)
6875
const data = Data ?? new Uint8Array()
6976

7077
switch (Type) {
7178
case pb.KeyType.RSA:
72-
return pkixToRSAPublicKey(data)
79+
return pkixToRSAPublicKey(data, digest)
7380
case pb.KeyType.Ed25519:
7481
return unmarshalEd25519PublicKey(data)
7582
case pb.KeyType.secp256k1:

0 commit comments

Comments
 (0)