Skip to content

Commit 9cd2a81

Browse files
authored
Signed keys and wallet signature (#159)
fix: signed keys & wallet signature
1 parent 0ff9a04 commit 9cd2a81

13 files changed

+528
-161
lines changed

package-lock.json

+7-7
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@
6161
"dependencies": {
6262
"@noble/secp256k1": "^1.5.2",
6363
"@stardazed/streams-polyfill": "^2.4.0",
64-
"@xmtp/proto": "~3.0.0",
64+
"@xmtp/proto": "^3.1.0",
6565
"ethers": "^5.5.3",
6666
"long": "^5.2.0",
6767
"node-localstorage": "^2.2.1"

src/ContactBundle.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { contact, publicKey } from '@xmtp/proto'
22
import { PublicKeyBundle } from './crypto'
3-
import PublicKey from './crypto/PublicKey'
3+
import { PublicKey } from './crypto/PublicKey'
44

55
// ContactBundle packages all the infromation which a client uses to advertise on the network.
66
export default class ContactBundle implements contact.ContactBundleV1 {

src/crypto/PrivateKey.ts

+164-26
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,177 @@
11
import { privateKey } from '@xmtp/proto'
22
import * as secp from '@noble/secp256k1'
33
import Long from 'long'
4-
import Signature from './Signature'
5-
import PublicKey from './PublicKey'
4+
import Signature, {
5+
ECDSACompactWithRecovery,
6+
ecdsaSignerKey,
7+
KeySigner,
8+
} from './Signature'
9+
import { PublicKey, SignedPublicKey, UnsignedPublicKey } from './PublicKey'
610
import Ciphertext from './Ciphertext'
711
import { decrypt, encrypt, sha256 } from './encryption'
12+
import { equalBytes } from './utils'
813

9-
// PrivateKey represents a secp256k1 private key.
10-
export default class PrivateKey implements privateKey.PrivateKey {
14+
// SECP256k1 private key
15+
type secp256k1 = {
16+
bytes: Uint8Array // D big-endian, 32 bytes
17+
}
18+
19+
// Validate SECP256k1 private key
20+
function secp256k1Check(key: secp256k1): void {
21+
if (key.bytes.length !== 32) {
22+
throw new Error(`invalid private key length: ${key.bytes.length}`)
23+
}
24+
}
25+
26+
// A private key signed with another key pair or a wallet.
27+
export class SignedPrivateKey
28+
implements privateKey.SignedPrivateKey, KeySigner
29+
{
30+
createdNs: Long // time the key was generated, ns since epoch
31+
secp256k1: secp256k1 // eslint-disable-line camelcase
32+
publicKey: SignedPublicKey // caches corresponding PublicKey
33+
34+
constructor(obj: privateKey.SignedPrivateKey) {
35+
if (!obj.secp256k1) {
36+
throw new Error('invalid private key')
37+
}
38+
secp256k1Check(obj.secp256k1)
39+
this.secp256k1 = obj.secp256k1
40+
this.createdNs = obj.createdNs
41+
if (!obj.publicKey) {
42+
throw new Error('missing public key')
43+
}
44+
this.publicKey = new SignedPublicKey(obj.publicKey)
45+
}
46+
47+
// Create a random key pair signed by the signer.
48+
static async generate(signer: KeySigner): Promise<SignedPrivateKey> {
49+
const secp256k1 = {
50+
bytes: secp.utils.randomPrivateKey(),
51+
}
52+
const createdNs = Long.fromNumber(new Date().getTime()).mul(1000000)
53+
const unsigned = new UnsignedPublicKey({
54+
secp256k1Uncompressed: {
55+
bytes: secp.getPublicKey(secp256k1.bytes),
56+
},
57+
createdNs: createdNs,
58+
})
59+
const signed = await signer.signKey(unsigned)
60+
return new SignedPrivateKey({
61+
secp256k1,
62+
createdNs,
63+
publicKey: signed,
64+
})
65+
}
66+
67+
// Time the key was generated.
68+
generated(): Date | undefined {
69+
return new Date(this.createdNs.div(1000000).toNumber())
70+
}
71+
72+
// Sign provided digest.
73+
async sign(digest: Uint8Array): Promise<Signature> {
74+
const [signature, recovery] = await secp.sign(
75+
digest,
76+
this.secp256k1.bytes,
77+
{
78+
recovered: true,
79+
der: false,
80+
}
81+
)
82+
return new Signature({
83+
ecdsaCompact: { bytes: signature, recovery },
84+
})
85+
}
86+
87+
// Sign provided public key.
88+
async signKey(pub: UnsignedPublicKey): Promise<SignedPublicKey> {
89+
const keyBytes = pub.toBytes()
90+
const digest = await sha256(keyBytes)
91+
const signature = await this.sign(digest)
92+
return new SignedPublicKey({
93+
keyBytes,
94+
signature,
95+
})
96+
}
97+
98+
// Return public key of the signer of the provided signed key.
99+
static async signerKey(
100+
key: SignedPublicKey,
101+
signature: ECDSACompactWithRecovery
102+
): Promise<UnsignedPublicKey | undefined> {
103+
const digest = await sha256(key.bytesToSign())
104+
return ecdsaSignerKey(digest, signature)
105+
}
106+
107+
// Derive shared secret from peer's PublicKey;
108+
// the peer can derive the same secret using their private key and our public key.
109+
sharedSecret(peer: SignedPublicKey | UnsignedPublicKey): Uint8Array {
110+
return secp.getSharedSecret(
111+
this.secp256k1.bytes,
112+
peer.secp256k1Uncompressed.bytes,
113+
false
114+
)
115+
}
116+
117+
// encrypt plain bytes using a shared secret derived from peer's PublicKey;
118+
// additionalData allows including unencrypted parts of a Message in the authentication
119+
// protection provided by the encrypted part (to make the whole Message tamper evident)
120+
encrypt(
121+
plain: Uint8Array,
122+
peer: UnsignedPublicKey,
123+
additionalData?: Uint8Array
124+
): Promise<Ciphertext> {
125+
const secret = this.sharedSecret(peer)
126+
return encrypt(plain, secret, additionalData)
127+
}
128+
129+
// decrypt Ciphertext using a shared secret derived from peer's PublicKey;
130+
// throws if any part of Ciphertext or additionalData was tampered with
131+
decrypt(
132+
encrypted: Ciphertext,
133+
peer: UnsignedPublicKey,
134+
additionalData?: Uint8Array
135+
): Promise<Uint8Array> {
136+
const secret = this.sharedSecret(peer)
137+
return decrypt(encrypted, secret, additionalData)
138+
}
139+
140+
// Does the provided PublicKey correspond to this PrivateKey?
141+
matches(key: SignedPublicKey): boolean {
142+
return this.publicKey.equals(key)
143+
}
144+
145+
// Is other the same/equivalent key?
146+
equals(other: this): boolean {
147+
return (
148+
equalBytes(this.secp256k1.bytes, other.secp256k1.bytes) &&
149+
this.publicKey.equals(other.publicKey)
150+
)
151+
}
152+
153+
// Encode this key into bytes.
154+
toBytes(): Uint8Array {
155+
return privateKey.SignedPrivateKey.encode(this).finish()
156+
}
157+
158+
// Decode key from bytes.
159+
static fromBytes(bytes: Uint8Array): SignedPrivateKey {
160+
return new SignedPrivateKey(privateKey.SignedPrivateKey.decode(bytes))
161+
}
162+
}
163+
164+
// LEGACY: PrivateKey represents a secp256k1 private key.
165+
export class PrivateKey implements privateKey.PrivateKey {
11166
timestamp: Long
12-
secp256k1: privateKey.PrivateKey_Secp256k1 | undefined // eslint-disable-line camelcase
167+
secp256k1: secp256k1 // eslint-disable-line camelcase
13168
publicKey: PublicKey // caches corresponding PublicKey
14169

15170
constructor(obj: privateKey.PrivateKey) {
16171
if (!obj.secp256k1) {
17172
throw new Error('invalid private key')
18173
}
19-
if (obj.secp256k1.bytes.length !== 32) {
20-
throw new Error(
21-
`invalid private key length: ${obj.secp256k1.bytes.length}`
22-
)
23-
}
174+
secp256k1Check(obj.secp256k1)
24175
this.timestamp = obj.timestamp
25176
this.secp256k1 = obj.secp256k1
26177
if (!obj.publicKey) {
@@ -48,17 +199,11 @@ export default class PrivateKey implements privateKey.PrivateKey {
48199
}
49200

50201
generated(): Date | undefined {
51-
if (!this.timestamp) {
52-
return undefined
53-
}
54202
return new Date(this.timestamp.toNumber())
55203
}
56204

57205
// sign provided digest
58206
async sign(digest: Uint8Array): Promise<Signature> {
59-
if (!this.secp256k1) {
60-
throw new Error('invalid private key')
61-
}
62207
const [signature, recovery] = await secp.sign(
63208
digest,
64209
this.secp256k1.bytes,
@@ -74,9 +219,6 @@ export default class PrivateKey implements privateKey.PrivateKey {
74219

75220
// sign provided public key
76221
async signKey(pub: PublicKey): Promise<PublicKey> {
77-
if (!pub.secp256k1Uncompressed) {
78-
throw new Error('invalid public key')
79-
}
80222
const digest = await sha256(pub.bytesToSign())
81223
pub.signature = await this.sign(digest)
82224
return pub
@@ -85,12 +227,6 @@ export default class PrivateKey implements privateKey.PrivateKey {
85227
// derive shared secret from peer's PublicKey;
86228
// the peer can derive the same secret using their PrivateKey and our PublicKey
87229
sharedSecret(peer: PublicKey): Uint8Array {
88-
if (!peer.secp256k1Uncompressed) {
89-
throw new Error('invalid public key')
90-
}
91-
if (!this.secp256k1) {
92-
throw new Error('invalid private key')
93-
}
94230
return secp.getSharedSecret(
95231
this.secp256k1.bytes,
96232
peer.secp256k1Uncompressed.bytes,
@@ -121,15 +257,17 @@ export default class PrivateKey implements privateKey.PrivateKey {
121257
return decrypt(encrypted, secret, additionalData)
122258
}
123259

124-
// Does the provided PublicKey correspnd to this PrivateKey?
260+
// Does the provided PublicKey correspond to this PrivateKey?
125261
matches(key: PublicKey): boolean {
126262
return this.publicKey.equals(key)
127263
}
128264

265+
// Encode this key into bytes.
129266
toBytes(): Uint8Array {
130267
return privateKey.PrivateKey.encode(this).finish()
131268
}
132269

270+
// Decode key from bytes.
133271
static fromBytes(bytes: Uint8Array): PrivateKey {
134272
return new PrivateKey(privateKey.PrivateKey.decode(bytes))
135273
}

src/crypto/PrivateKeyBundle.ts

+9-8
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { privateKey as proto } from '@xmtp/proto'
2-
import PrivateKey from './PrivateKey'
3-
import PublicKey from './PublicKey'
2+
import { PrivateKey } from './PrivateKey'
3+
import { PublicKey } from './PublicKey'
44
import PublicKeyBundle from './PublicKeyBundle'
55
import Ciphertext from './Ciphertext'
6-
import * as ethers from 'ethers'
6+
import { Signer } from 'ethers'
77
import { bytesToHex, getRandomValues, hexToBytes } from './utils'
88
import { decrypt, encrypt } from './encryption'
99
import { NoMatchingPreKeyError } from './errors'
@@ -22,7 +22,7 @@ export default class PrivateKeyBundle implements proto.PrivateKeyBundleV1 {
2222

2323
// Generate a new key bundle with the preKey signed byt the identityKey.
2424
// Optionally sign the identityKey with the provided wallet as well.
25-
static async generate(wallet?: ethers.Signer): Promise<PrivateKeyBundle> {
25+
static async generate(wallet?: Signer): Promise<PrivateKeyBundle> {
2626
const identityKey = PrivateKey.generate()
2727
if (wallet) {
2828
await identityKey.publicKey.signWithWallet(wallet)
@@ -100,7 +100,7 @@ export default class PrivateKeyBundle implements proto.PrivateKeyBundleV1 {
100100

101101
static storageSigRequestText(preKey: Uint8Array): string {
102102
// Note that an update to this signature request text will require
103-
// addition of backward compatability for existing encrypted bundles
103+
// addition of backward compatibility for existing encrypted bundles
104104
// and/or a migration; otherwise clients will no longer be able to
105105
// decrypt those bundles.
106106
return (
@@ -112,7 +112,7 @@ export default class PrivateKeyBundle implements proto.PrivateKeyBundleV1 {
112112
}
113113

114114
// encrypts/serializes the bundle for storage
115-
async toEncryptedBytes(wallet: ethers.Signer): Promise<Uint8Array> {
115+
async toEncryptedBytes(wallet: Signer): Promise<Uint8Array> {
116116
// serialize the contents
117117
if (this.preKeys.length === 0) {
118118
throw new Error('missing pre-keys')
@@ -125,6 +125,7 @@ export default class PrivateKeyBundle implements proto.PrivateKeyBundleV1 {
125125
identityKey: this.identityKey,
126126
preKeys: this.preKeys,
127127
},
128+
v2: undefined,
128129
}).finish()
129130
const wPreKey = getRandomValues(new Uint8Array(32))
130131
const secret = hexToBytes(
@@ -140,7 +141,7 @@ export default class PrivateKeyBundle implements proto.PrivateKeyBundleV1 {
140141
}
141142

142143
encode(): Uint8Array {
143-
return proto.PrivateKeyBundle.encode({ v1: this }).finish()
144+
return proto.PrivateKeyBundle.encode({ v1: this, v2: undefined }).finish()
144145
}
145146

146147
static decode(bytes: Uint8Array): PrivateKeyBundle {
@@ -156,7 +157,7 @@ export default class PrivateKeyBundle implements proto.PrivateKeyBundleV1 {
156157

157158
// decrypts/deserializes the bundle from storage bytes
158159
static async fromEncryptedBytes(
159-
wallet: ethers.Signer,
160+
wallet: Signer,
160161
bytes: Uint8Array
161162
): Promise<PrivateKeyBundle> {
162163
const [eBundle, needsUpdateA] = getEncryptedV1Bundle(bytes)

0 commit comments

Comments
 (0)