Skip to content

Commit fc51221

Browse files
authored
feat: support ECDSA private keys (#3059)
Adds support for creating, using and storing ECDSA private keys. This is a prerequisite for storing x509 certs and associated keys in the keychain. Keys are serialized to DER PKI messages (see https://datatracker.ietf.org/doc/html/rfc4210)
1 parent 0712672 commit fc51221

File tree

20 files changed

+950
-53
lines changed

20 files changed

+950
-53
lines changed

.github/dictionary.txt

+3
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,6 @@ buildx
1111
blpop
1212
rpush
1313
additionals
14+
SECG
15+
Certicom
16+
RSAES
+70
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { base58btc } from 'multiformats/bases/base58'
2+
import { CID } from 'multiformats/cid'
3+
import { identity } from 'multiformats/hashes/identity'
4+
import { equals as uint8ArrayEquals } from 'uint8arrays/equals'
5+
import { publicKeyToProtobuf } from '../index.js'
6+
import { privateKeyToPKIMessage, publicKeyToPKIMessage } from './utils.js'
7+
import { hashAndVerify, hashAndSign } from './index.js'
8+
import type { ECDSAPublicKey as ECDSAPublicKeyInterface, ECDSAPrivateKey as ECDSAPrivateKeyInterface } from '@libp2p/interface'
9+
import type { Digest } from 'multiformats/hashes/digest'
10+
import type { Uint8ArrayList } from 'uint8arraylist'
11+
12+
export class ECDSAPublicKey implements ECDSAPublicKeyInterface {
13+
public readonly type = 'ECDSA'
14+
public readonly raw: Uint8Array
15+
private readonly _key: JsonWebKey
16+
17+
constructor (publicKey: JsonWebKey) {
18+
this._key = publicKey
19+
this.raw = publicKeyToPKIMessage(publicKey)
20+
}
21+
22+
toMultihash (): Digest<0x0, number> {
23+
return identity.digest(publicKeyToProtobuf(this))
24+
}
25+
26+
toCID (): CID<unknown, 114, 0x0, 1> {
27+
return CID.createV1(114, this.toMultihash())
28+
}
29+
30+
toString (): string {
31+
return base58btc.encode(this.toMultihash().bytes).substring(1)
32+
}
33+
34+
equals (key?: any): boolean {
35+
if (key == null || !(key.raw instanceof Uint8Array)) {
36+
return false
37+
}
38+
39+
return uint8ArrayEquals(this.raw, key.raw)
40+
}
41+
42+
async verify (data: Uint8Array | Uint8ArrayList, sig: Uint8Array): Promise<boolean> {
43+
return hashAndVerify(this._key, sig, data)
44+
}
45+
}
46+
47+
export class ECDSAPrivateKey implements ECDSAPrivateKeyInterface {
48+
public readonly type = 'ECDSA'
49+
public readonly raw: Uint8Array
50+
private readonly _key: JsonWebKey
51+
public readonly publicKey: ECDSAPublicKey
52+
53+
constructor (privateKey: JsonWebKey, publicKey: JsonWebKey) {
54+
this._key = privateKey
55+
this.raw = privateKeyToPKIMessage(privateKey)
56+
this.publicKey = new ECDSAPublicKey(publicKey)
57+
}
58+
59+
equals (key?: any): boolean {
60+
if (key == null || !(key.raw instanceof Uint8Array)) {
61+
return false
62+
}
63+
64+
return uint8ArrayEquals(this.raw, key.raw)
65+
}
66+
67+
async sign (message: Uint8Array | Uint8ArrayList): Promise<Uint8Array> {
68+
return hashAndSign(this._key, message)
69+
}
70+
}
+50
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import type { JWKKeyPair } from '../interface.js'
2+
import type { Uint8ArrayList } from 'uint8arraylist'
3+
4+
export type Curve = 'P-256' | 'P-384' | 'P-521'
5+
6+
export const ECDSA_P_256_OID = '1.2.840.10045.3.1.7'
7+
export const ECDSA_P_384_OID = '1.3.132.0.34'
8+
export const ECDSA_P_521_OID = '1.3.132.0.35'
9+
10+
export async function generateECDSAKey (curve: Curve = 'P-256'): Promise<JWKKeyPair> {
11+
const keyPair = await crypto.subtle.generateKey({
12+
name: 'ECDSA',
13+
namedCurve: curve
14+
}, true, ['sign', 'verify'])
15+
16+
return {
17+
publicKey: await crypto.subtle.exportKey('jwk', keyPair.publicKey),
18+
privateKey: await crypto.subtle.exportKey('jwk', keyPair.privateKey)
19+
}
20+
}
21+
22+
export async function hashAndSign (key: JsonWebKey, msg: Uint8Array | Uint8ArrayList): Promise<Uint8Array> {
23+
const privateKey = await crypto.subtle.importKey('jwk', key, {
24+
name: 'ECDSA',
25+
namedCurve: key.crv ?? 'P-256'
26+
}, false, ['sign'])
27+
28+
const signature = await crypto.subtle.sign({
29+
name: 'ECDSA',
30+
hash: {
31+
name: 'SHA-256'
32+
}
33+
}, privateKey, msg.subarray())
34+
35+
return new Uint8Array(signature, 0, signature.byteLength)
36+
}
37+
38+
export async function hashAndVerify (key: JsonWebKey, sig: Uint8Array, msg: Uint8Array | Uint8ArrayList): Promise<boolean> {
39+
const publicKey = await crypto.subtle.importKey('jwk', key, {
40+
name: 'ECDSA',
41+
namedCurve: key.crv ?? 'P-256'
42+
}, false, ['verify'])
43+
44+
return crypto.subtle.verify({
45+
name: 'ECDSA',
46+
hash: {
47+
name: 'SHA-256'
48+
}
49+
}, publicKey, sig, msg.subarray())
50+
}
+227
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
import { InvalidParametersError } from '@libp2p/interface'
2+
import { Uint8ArrayList } from 'uint8arraylist'
3+
import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string'
4+
import { toString as uint8ArrayToString } from 'uint8arrays/to-string'
5+
import { decodeDer, encodeBitString, encodeInteger, encodeOctetString, encodeSequence } from '../rsa/der.js'
6+
import { ECDSAPrivateKey as ECDSAPrivateKeyClass, ECDSAPublicKey as ECDSAPublicKeyClass } from './ecdsa.js'
7+
import { generateECDSAKey } from './index.js'
8+
import type { Curve } from '../ecdh/index.js'
9+
import type { ECDSAPublicKey, ECDSAPrivateKey } from '@libp2p/interface'
10+
11+
// 1.2.840.10045.3.1.7 prime256v1 (ANSI X9.62 named elliptic curve)
12+
const OID_256 = Uint8Array.from([0x06, 0x08, 0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x03, 0x01, 0x07])
13+
// 1.3.132.0.34 secp384r1 (SECG (Certicom) named elliptic curve)
14+
const OID_384 = Uint8Array.from([0x06, 0x05, 0x2B, 0x81, 0x04, 0x00, 0x22])
15+
// 1.3.132.0.35 secp521r1 (SECG (Certicom) named elliptic curve)
16+
const OID_521 = Uint8Array.from([0x06, 0x05, 0x2B, 0x81, 0x04, 0x00, 0x23])
17+
18+
const P_256_KEY_JWK = {
19+
ext: true,
20+
kty: 'EC',
21+
crv: 'P-256'
22+
}
23+
24+
const P_384_KEY_JWK = {
25+
ext: true,
26+
kty: 'EC',
27+
crv: 'P-384'
28+
}
29+
30+
const P_521_KEY_JWK = {
31+
ext: true,
32+
kty: 'EC',
33+
crv: 'P-521'
34+
}
35+
36+
const P_256_KEY_LENGTH = 32
37+
const P_384_KEY_LENGTH = 48
38+
const P_521_KEY_LENGTH = 66
39+
40+
export function unmarshalECDSAPrivateKey (bytes: Uint8Array): ECDSAPrivateKey {
41+
const message = decodeDer(bytes)
42+
43+
return pkiMessageToECDSAPrivateKey(message)
44+
}
45+
46+
export function pkiMessageToECDSAPrivateKey (message: any): ECDSAPrivateKey {
47+
const privateKey = message[1]
48+
const d = uint8ArrayToString(privateKey, 'base64url')
49+
const coordinates: Uint8Array = message[2][1][0]
50+
const offset = 1
51+
let x: string
52+
let y: string
53+
54+
if (privateKey.byteLength === P_256_KEY_LENGTH) {
55+
x = uint8ArrayToString(coordinates.subarray(offset, offset + P_256_KEY_LENGTH), 'base64url')
56+
y = uint8ArrayToString(coordinates.subarray(offset + P_256_KEY_LENGTH), 'base64url')
57+
58+
return new ECDSAPrivateKeyClass({
59+
...P_256_KEY_JWK,
60+
key_ops: ['sign'],
61+
d,
62+
x,
63+
y
64+
}, {
65+
...P_256_KEY_JWK,
66+
key_ops: ['verify'],
67+
x,
68+
y
69+
})
70+
}
71+
72+
if (privateKey.byteLength === P_384_KEY_LENGTH) {
73+
x = uint8ArrayToString(coordinates.subarray(offset, offset + P_384_KEY_LENGTH), 'base64url')
74+
y = uint8ArrayToString(coordinates.subarray(offset + P_384_KEY_LENGTH), 'base64url')
75+
76+
return new ECDSAPrivateKeyClass({
77+
...P_384_KEY_JWK,
78+
key_ops: ['sign'],
79+
d,
80+
x,
81+
y
82+
}, {
83+
...P_384_KEY_JWK,
84+
key_ops: ['verify'],
85+
x,
86+
y
87+
})
88+
}
89+
90+
if (privateKey.byteLength === P_521_KEY_LENGTH) {
91+
x = uint8ArrayToString(coordinates.subarray(offset, offset + P_521_KEY_LENGTH), 'base64url')
92+
y = uint8ArrayToString(coordinates.subarray(offset + P_521_KEY_LENGTH), 'base64url')
93+
94+
return new ECDSAPrivateKeyClass({
95+
...P_521_KEY_JWK,
96+
key_ops: ['sign'],
97+
d,
98+
x,
99+
y
100+
}, {
101+
...P_521_KEY_JWK,
102+
key_ops: ['verify'],
103+
x,
104+
y
105+
})
106+
}
107+
108+
throw new InvalidParametersError(`Private key length was wrong length, got ${privateKey.byteLength}, expected 32, 48 or 66`)
109+
}
110+
111+
export function unmarshalECDSAPublicKey (bytes: Uint8Array): ECDSAPublicKey {
112+
const message = decodeDer(bytes)
113+
114+
return pkiMessageToECDSAPublicKey(message)
115+
}
116+
117+
export function pkiMessageToECDSAPublicKey (message: any): ECDSAPublicKey {
118+
const coordinates = message[1][1][0]
119+
const offset = 1
120+
let x: string
121+
let y: string
122+
123+
if (coordinates.byteLength === ((P_256_KEY_LENGTH * 2) + 1)) {
124+
x = uint8ArrayToString(coordinates.subarray(offset, offset + P_256_KEY_LENGTH), 'base64url')
125+
y = uint8ArrayToString(coordinates.subarray(offset + P_256_KEY_LENGTH), 'base64url')
126+
127+
return new ECDSAPublicKeyClass({
128+
...P_256_KEY_JWK,
129+
key_ops: ['verify'],
130+
x,
131+
y
132+
})
133+
}
134+
135+
if (coordinates.byteLength === ((P_384_KEY_LENGTH * 2) + 1)) {
136+
x = uint8ArrayToString(coordinates.subarray(offset, offset + P_384_KEY_LENGTH), 'base64url')
137+
y = uint8ArrayToString(coordinates.subarray(offset + P_384_KEY_LENGTH), 'base64url')
138+
139+
return new ECDSAPublicKeyClass({
140+
...P_384_KEY_JWK,
141+
key_ops: ['verify'],
142+
x,
143+
y
144+
})
145+
}
146+
147+
if (coordinates.byteLength === ((P_521_KEY_LENGTH * 2) + 1)) {
148+
x = uint8ArrayToString(coordinates.subarray(offset, offset + P_521_KEY_LENGTH), 'base64url')
149+
y = uint8ArrayToString(coordinates.subarray(offset + P_521_KEY_LENGTH), 'base64url')
150+
151+
return new ECDSAPublicKeyClass({
152+
...P_521_KEY_JWK,
153+
key_ops: ['verify'],
154+
x,
155+
y
156+
})
157+
}
158+
159+
throw new InvalidParametersError(`coordinates were wrong length, got ${coordinates.byteLength}, expected 65, 97 or 133`)
160+
}
161+
162+
export function privateKeyToPKIMessage (privateKey: JsonWebKey): Uint8Array {
163+
return encodeSequence([
164+
encodeInteger(Uint8Array.from([1])), // header
165+
encodeOctetString(uint8ArrayFromString(privateKey.d ?? '', 'base64url')), // body
166+
encodeSequence([ // PKIProtection
167+
getOID(privateKey.crv)
168+
], 0xA0),
169+
encodeSequence([ // extraCerts
170+
encodeBitString(
171+
new Uint8ArrayList(
172+
Uint8Array.from([0x04]),
173+
uint8ArrayFromString(privateKey.x ?? '', 'base64url'),
174+
uint8ArrayFromString(privateKey.y ?? '', 'base64url')
175+
)
176+
)
177+
], 0xA1)
178+
]).subarray()
179+
}
180+
181+
export function publicKeyToPKIMessage (publicKey: JsonWebKey): Uint8Array {
182+
return encodeSequence([
183+
encodeInteger(Uint8Array.from([1])), // header
184+
encodeSequence([ // PKIProtection
185+
getOID(publicKey.crv)
186+
], 0xA0),
187+
encodeSequence([ // extraCerts
188+
encodeBitString(
189+
new Uint8ArrayList(
190+
Uint8Array.from([0x04]),
191+
uint8ArrayFromString(publicKey.x ?? '', 'base64url'),
192+
uint8ArrayFromString(publicKey.y ?? '', 'base64url')
193+
)
194+
)
195+
], 0xA1)
196+
]).subarray()
197+
}
198+
199+
function getOID (curve?: string): Uint8Array {
200+
if (curve === 'P-256') {
201+
return OID_256
202+
}
203+
204+
if (curve === 'P-384') {
205+
return OID_384
206+
}
207+
208+
if (curve === 'P-521') {
209+
return OID_521
210+
}
211+
212+
throw new InvalidParametersError(`Invalid curve ${curve}`)
213+
}
214+
215+
export async function generateECDSAKeyPair (curve: Curve = 'P-256'): Promise<ECDSAPrivateKey> {
216+
const key = await generateECDSAKey(curve)
217+
218+
return new ECDSAPrivateKeyClass(key.privateKey, key.publicKey)
219+
}
220+
221+
export function ensureECDSAKey (key: Uint8Array, length: number): Uint8Array {
222+
key = Uint8Array.from(key ?? [])
223+
if (key.length !== length) {
224+
throw new InvalidParametersError(`Key must be a Uint8Array of length ${length}, got ${key.length}`)
225+
}
226+
return key
227+
}

0 commit comments

Comments
 (0)