Skip to content

Commit f0e64b9

Browse files
authored
feat(crypto): add publicKeyForPeer to bind keys to peer ids (#3667)
* feat(crypto): add publicKeyForPeer Returns a peer's public key, accepting a supplied key only if it derives to the peer id. * fix(floodsub): reject a message key that does not derive to the author Verify signatures with publicKeyForPeer and add tests that forge messages through the RPC path. * refactor(libp2p): bind content-routed keys with publicKeyForPeer * refactor(gossipsub): bind the message key with publicKeyForPeer * fix(crypto): throw InvalidPublicKeyError for malformed keys in publicKeyForPeer Also correct the doc comment, which implied a mismatched key falls back to the inlined one. * test(floodsub): add wire delivery controls and clarify forgery comments * test(gossipsub): reject an RSA author without a key * docs(crypto): fix spelling in publicKeyForPeer doc * test(floodsub): mark private queue access in forgery tests
1 parent 57c4b44 commit f0e64b9

9 files changed

Lines changed: 310 additions & 73 deletions

File tree

‎packages/crypto/src/keys/index.ts‎

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
* For encryption / decryption support, RSA keys should be used.
99
*/
1010

11-
import { InvalidParametersError, UnsupportedKeyTypeError } from '@libp2p/interface'
11+
import { InvalidParametersError, InvalidPublicKeyError, UnsupportedKeyTypeError } from '@libp2p/interface'
12+
import { equals as uint8ArrayEquals } from 'uint8arrays/equals'
1213
import { ECDSAPrivateKey as ECDSAPrivateKeyClass } from './ecdsa/ecdsa.ts'
1314
import { ECDSA_P_256_OID, ECDSA_P_384_OID, ECDSA_P_521_OID } from './ecdsa/index.ts'
1415
import { generateECDSAKeyPair, pkiMessageToECDSAPrivateKey, pkiMessageToECDSAPublicKey, unmarshalECDSAPrivateKey, unmarshalECDSAPublicKey } from './ecdsa/utils.ts'
@@ -21,7 +22,7 @@ import { pkcs1ToRSAPrivateKey, pkixToRSAPublicKey, generateRSAKeyPair, pkcs1Mess
2122
import { privateKeyLength as secp256k1PrivateKeyLength, publicKeyLength as secp256k1PublicKeyLength } from './secp256k1/index.ts'
2223
import { generateSecp256k1KeyPair, unmarshalSecp256k1PrivateKey, unmarshalSecp256k1PublicKey } from './secp256k1/utils.ts'
2324
import type { Curve } from './ecdsa/index.ts'
24-
import type { PrivateKey, PublicKey, KeyType, RSAPrivateKey, Secp256k1PrivateKey, Ed25519PrivateKey, Secp256k1PublicKey, Ed25519PublicKey, ECDSAPrivateKey, ECDSAPublicKey } from '@libp2p/interface'
25+
import type { AbortOptions, PeerId, PrivateKey, PublicKey, KeyType, RSAPrivateKey, Secp256k1PrivateKey, Ed25519PrivateKey, Secp256k1PublicKey, Ed25519PublicKey, ECDSAPrivateKey, ECDSAPublicKey } from '@libp2p/interface'
2526
import type { MultihashDigest } from 'multiformats'
2627
import type { Digest } from 'multiformats/hashes/digest'
2728

@@ -158,6 +159,40 @@ export function publicKeyToProtobuf (key: PublicKey): Uint8Array {
158159
})
159160
}
160161

162+
/**
163+
* Returns a public key for the peer that is safe for verifying its signatures.
164+
*
165+
* A supplied key must derive to the peer id, otherwise `InvalidPublicKeyError`
166+
* is thrown, even if the peer id embeds a key. With no supplied key the key
167+
* inlined in the peer id is returned, or `InvalidPublicKeyError` is thrown if
168+
* there is none. Malformed key bytes also throw `InvalidPublicKeyError`.
169+
*/
170+
export async function publicKeyForPeer (peer: Pick<PeerId, 'publicKey' | 'toMultihash'>, key?: Uint8Array | PublicKey, options?: AbortOptions): Promise<PublicKey> {
171+
options?.signal?.throwIfAborted()
172+
173+
if (key != null) {
174+
let publicKey: PublicKey
175+
176+
try {
177+
publicKey = key instanceof Uint8Array ? publicKeyFromProtobuf(key) : key
178+
} catch (err: any) {
179+
throw new InvalidPublicKeyError(`Could not decode public key - ${err.message}`)
180+
}
181+
182+
if (!uint8ArrayEquals(publicKey.toMultihash().bytes, peer.toMultihash().bytes)) {
183+
throw new InvalidPublicKeyError('Public key does not match peer id')
184+
}
185+
186+
return publicKey
187+
}
188+
189+
if (peer.publicKey == null) {
190+
throw new InvalidPublicKeyError('No public key available for peer id')
191+
}
192+
193+
return peer.publicKey
194+
}
195+
161196
/**
162197
* Converts a protobuf serialized private key into its representative object
163198
*/
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
/* eslint-env mocha */
2+
import { expect } from 'aegir/chai'
3+
import { identity } from 'multiformats/hashes/identity'
4+
import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string'
5+
import { generateKeyPair, publicKeyForPeer, publicKeyToProtobuf } from '../../src/keys/index.ts'
6+
import type { PeerId, PublicKey } from '@libp2p/interface'
7+
8+
type Peer = Pick<PeerId, 'publicKey' | 'toMultihash'>
9+
10+
// a peer id as parsed from the wire: an inlined key for Ed25519/secp256k1, no
11+
// key for RSA
12+
function peerFor (key: PublicKey): Peer {
13+
return {
14+
publicKey: key.type === 'RSA' ? undefined : key,
15+
toMultihash: () => key.toMultihash()
16+
} as any
17+
}
18+
19+
describe('publicKeyForPeer', function () {
20+
this.timeout(20 * 1000)
21+
22+
it('returns the key inlined in an Ed25519 peer id', async () => {
23+
const key = (await generateKeyPair('Ed25519')).publicKey
24+
25+
await expect(publicKeyForPeer(peerFor(key))).to.eventually.equal(key)
26+
})
27+
28+
it('accepts a supplied key that derives to an Ed25519 peer id', async () => {
29+
const key = (await generateKeyPair('Ed25519')).publicKey
30+
31+
const result = await publicKeyForPeer(peerFor(key), publicKeyToProtobuf(key))
32+
33+
expect(result.toMultihash().bytes).to.equalBytes(key.toMultihash().bytes)
34+
})
35+
36+
it('rejects a supplied key that does not derive to an Ed25519 peer id', async () => {
37+
const victim = (await generateKeyPair('Ed25519')).publicKey
38+
const attacker = (await generateKeyPair('Ed25519')).publicKey
39+
40+
await expect(publicKeyForPeer(peerFor(victim), attacker)).to.eventually.be.rejected
41+
.with.property('name', 'InvalidPublicKeyError')
42+
})
43+
44+
it('rejects a supplied key that does not derive to a secp256k1 peer id', async () => {
45+
const victim = (await generateKeyPair('secp256k1')).publicKey
46+
const attacker = (await generateKeyPair('Ed25519')).publicKey
47+
48+
await expect(publicKeyForPeer(peerFor(victim), attacker)).to.eventually.be.rejected
49+
.with.property('name', 'InvalidPublicKeyError')
50+
})
51+
52+
it('rejects malformed key bytes', async () => {
53+
const key = (await generateKeyPair('Ed25519')).publicKey
54+
55+
await expect(publicKeyForPeer(peerFor(key), uint8ArrayFromString('not a key'))).to.eventually.be.rejected
56+
.with.property('name', 'InvalidPublicKeyError')
57+
})
58+
59+
describe('RSA peer id without a key', () => {
60+
let key: PublicKey
61+
62+
before(async () => {
63+
key = (await generateKeyPair('RSA', 2048)).publicKey
64+
})
65+
66+
it('accepts a supplied key that derives to the peer id', async () => {
67+
const result = await publicKeyForPeer(peerFor(key), publicKeyToProtobuf(key))
68+
69+
expect(result.toMultihash().bytes).to.equalBytes(key.toMultihash().bytes)
70+
})
71+
72+
it('rejects a supplied key that does not derive to the peer id', async () => {
73+
const attacker = (await generateKeyPair('Ed25519')).publicKey
74+
75+
await expect(publicKeyForPeer(peerFor(key), attacker)).to.eventually.be.rejected
76+
.with.property('name', 'InvalidPublicKeyError')
77+
})
78+
79+
it('rejects when no key is supplied', async () => {
80+
await expect(publicKeyForPeer(peerFor(key))).to.eventually.be.rejected
81+
.with.property('name', 'InvalidPublicKeyError')
82+
})
83+
})
84+
85+
it('rejects a URL peer id', async () => {
86+
const url: Peer = {
87+
publicKey: undefined,
88+
toMultihash: () => identity.digest(uint8ArrayFromString('https://example.com'))
89+
} as any
90+
91+
await expect(publicKeyForPeer(url)).to.eventually.be.rejected
92+
.with.property('name', 'InvalidPublicKeyError')
93+
})
94+
95+
it('rejects when aborted', async () => {
96+
const key = (await generateKeyPair('Ed25519')).publicKey
97+
98+
await expect(publicKeyForPeer(peerFor(key), undefined, { signal: AbortSignal.abort() })).to.eventually.be.rejected
99+
.with.property('name', 'AbortError')
100+
})
101+
})

‎packages/floodsub/src/sign.ts‎

Lines changed: 15 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { publicKeyForPeer } from '@libp2p/crypto/keys'
12
import { peerIdFromPrivateKey } from '@libp2p/peer-id'
23
import { concat as uint8ArrayConcat } from 'uint8arrays/concat'
34
import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string'
@@ -9,7 +10,7 @@ import type { PeerId, PrivateKey, PublicKey } from '@libp2p/interface'
910
export const SignPrefix = uint8ArrayFromString('libp2p-pubsub:')
1011

1112
/**
12-
* Signs the provided message with the given `peerId`
13+
* Signs the provided message with the given private key
1314
*/
1415
export async function signMessage (privateKey: PrivateKey, message: { from: PeerId, topic: string, data: Uint8Array, sequenceNumber: bigint }, encode: (rpc: PubSubRPCMessage) => Uint8Array): Promise<SignedMessage> {
1516
// @ts-expect-error signature field is missing, added below
@@ -34,7 +35,8 @@ export async function signMessage (privateKey: PrivateKey, message: { from: Peer
3435
}
3536

3637
/**
37-
* Verifies the signature of the given message
38+
* Verifies the signature of the given message, returning false if its key does
39+
* not belong to the author
3840
*/
3941
export async function verifySignature (message: SignedMessage, encode: (rpc: PubSubRPCMessage) => Uint8Array): Promise<boolean> {
4042
if (message.type !== 'signed') {
@@ -59,39 +61,19 @@ export async function verifySignature (message: SignedMessage, encode: (rpc: Pub
5961
}).subarray()
6062
])
6163

62-
// Get the public key
63-
const pubKey = messagePublicKey(message)
64+
let pubKey: PublicKey
6465

65-
// verify the base message
66-
return pubKey.verify(bytes, message.signature)
67-
}
66+
try {
67+
// only trust a supplied key if it derives to the author
68+
pubKey = await publicKeyForPeer(message.from, message.key)
69+
} catch (err: any) {
70+
if (err.name === 'InvalidPublicKeyError') {
71+
return false
72+
}
6873

69-
/**
70-
* Returns the PublicKey associated with the given message.
71-
* If no valid PublicKey can be retrieved an error will be returned.
72-
*/
73-
export function messagePublicKey (message: SignedMessage): PublicKey {
74-
if (message.type !== 'signed') {
75-
throw new Error('Message type must be "signed" to have a public key')
74+
throw err
7675
}
7776

78-
// should be available in the from property of the message (peer id)
79-
if (message.from == null) {
80-
throw new Error('Could not get the public key from the originator id')
81-
}
82-
83-
// an Ed25519/secp256k1 peer id embeds its own public key, so a key recovered
84-
// from `from` is provably the author's; trust it over the supplied message.key
85-
if (message.from.publicKey != null) {
86-
return message.from.publicKey
87-
}
88-
89-
// otherwise the supplied key must derive to the from peer id, else a message
90-
// could be signed by one key while claiming a different author
91-
if (message.key != null && message.from.equals(message.key.toMultihash().bytes)) {
92-
return message.key
93-
}
94-
95-
// We couldn't validate pubkey is from the originator, error
96-
throw new Error('Could not get the public key from the originator id')
77+
// verify the base message
78+
return pubKey.verify(bytes, message.signature)
9779
}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
/* eslint-env mocha */
2+
import { generateKeyPair, publicKeyToProtobuf } from '@libp2p/crypto/keys'
3+
import { start, stop } from '@libp2p/interface'
4+
import { defaultLogger } from '@libp2p/logger'
5+
import { peerIdFromPrivateKey } from '@libp2p/peer-id'
6+
import { expect } from 'aegir/chai'
7+
import { stubInterface } from 'sinon-ts'
8+
import { concat as uint8ArrayConcat } from 'uint8arrays/concat'
9+
import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string'
10+
import { floodsub } from '../src/index.ts'
11+
import { RPC } from '../src/message/rpc.ts'
12+
import { PeerStreams } from '../src/peer-streams.ts'
13+
import { SignPrefix } from '../src/sign.ts'
14+
import { randomSeqno, toRpcMessage } from '../src/utils.ts'
15+
import type { PubSubRPCMessage } from '../src/floodsub.ts'
16+
import type { FloodSub } from '../src/index.ts'
17+
import type { PeerId, PrivateKey, PublicKey } from '@libp2p/interface'
18+
import type { Registrar } from '@libp2p/interface-internal'
19+
20+
const topic = 'test-topic'
21+
const data = uint8ArrayFromString('hello')
22+
23+
// a wire message claiming to be from `from`, signed by `signer` and carrying
24+
// `key` in its key field
25+
async function wireMessage (from: PeerId, signer: PrivateKey, key: PublicKey): Promise<PubSubRPCMessage> {
26+
const unsigned = toRpcMessage({
27+
type: 'signed',
28+
from,
29+
data,
30+
sequenceNumber: randomSeqno(),
31+
topic,
32+
signature: new Uint8Array(0),
33+
key
34+
})
35+
delete unsigned.signature
36+
delete unsigned.key
37+
38+
const signature = await signer.sign(uint8ArrayConcat([SignPrefix, RPC.Message.encode(unsigned)]))
39+
40+
return { ...unsigned, signature, key: publicKeyToProtobuf(key) }
41+
}
42+
43+
describe('forged messages over the wire', () => {
44+
let pubsub: FloodSub
45+
let sender: PeerStreams
46+
let attackerKey: PrivateKey
47+
let delivered: number
48+
49+
beforeEach(async () => {
50+
const privateKey = await generateKeyPair('Ed25519')
51+
52+
pubsub = floodsub()({
53+
peerId: peerIdFromPrivateKey(privateKey),
54+
privateKey,
55+
registrar: stubInterface<Registrar>(),
56+
logger: defaultLogger()
57+
})
58+
await start(pubsub)
59+
pubsub.subscribe(topic)
60+
61+
attackerKey = await generateKeyPair('Ed25519')
62+
sender = new PeerStreams(peerIdFromPrivateKey(attackerKey))
63+
delivered = 0
64+
pubsub.addEventListener('message', () => { delivered++ })
65+
})
66+
67+
afterEach(async () => {
68+
await stop(pubsub)
69+
})
70+
71+
async function receive (message: PubSubRPCMessage): Promise<void> {
72+
// @ts-expect-error private method
73+
await pubsub['processRpc'](sender, { subscriptions: [], messages: [message] })
74+
// @ts-expect-error private field
75+
await pubsub.queue.onIdle()
76+
}
77+
78+
it('delivers a message signed by its author', async () => {
79+
await receive(await wireMessage(sender.peerId, attackerKey, attackerKey.publicKey))
80+
81+
expect(delivered).to.equal(1)
82+
})
83+
84+
it('delivers a message relayed from a different Ed25519 author', async () => {
85+
const authorKey = await generateKeyPair('Ed25519')
86+
87+
await receive(await wireMessage(peerIdFromPrivateKey(authorKey), authorKey, authorKey.publicKey))
88+
89+
expect(delivered).to.equal(1)
90+
})
91+
92+
it('delivers a message from an RSA author carrying its key', async () => {
93+
const authorKey = await generateKeyPair('RSA', 2048)
94+
95+
await receive(await wireMessage(peerIdFromPrivateKey(authorKey), authorKey, authorKey.publicKey))
96+
97+
expect(delivered).to.equal(1)
98+
})
99+
100+
it('drops a message signed by the attacker claiming an Ed25519 author', async () => {
101+
const victim = peerIdFromPrivateKey(await generateKeyPair('Ed25519'))
102+
103+
await receive(await wireMessage(victim, attackerKey, attackerKey.publicKey))
104+
105+
expect(delivered).to.equal(0)
106+
})
107+
108+
// dropped as unsigned by `isSigned` before signature verification, since the
109+
// key does not derive to the author
110+
it('drops a message signed by the attacker claiming an RSA author', async () => {
111+
const victim = peerIdFromPrivateKey(await generateKeyPair('RSA', 2048))
112+
113+
await receive(await wireMessage(victim, attackerKey, attackerKey.publicKey))
114+
115+
expect(delivered).to.equal(0)
116+
})
117+
118+
it('drops a message signed by its Ed25519 author that carries a different key', async () => {
119+
const authorKey = await generateKeyPair('Ed25519')
120+
121+
await receive(await wireMessage(peerIdFromPrivateKey(authorKey), authorKey, attackerKey.publicKey))
122+
123+
expect(delivered).to.equal(0)
124+
})
125+
})

‎packages/floodsub/test/sign.spec.ts‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -150,9 +150,8 @@ describe('author key binding', () => {
150150
await expect(verifySignature(forged, encodeMessage)).to.eventually.equal(false)
151151
})
152152

153-
// an RSA peer id does not carry its public key, so `from.publicKey` is
154-
// undefined and verification goes through the derive-to-author check rather
155-
// than the authoritative-key branch that the inlined types above exercise
153+
// an RSA peer id does not carry its public key, so the message key is the only
154+
// source and must derive to the author
156155
describe('peer id without an inline public key (RSA)', () => {
157156
let rsaKey: PrivateKey
158157
let author: PeerId
@@ -175,7 +174,7 @@ describe('author key binding', () => {
175174
it('rejects a message whose key does not derive to the author', async () => {
176175
const forged = await forge(author, await generateKeyPair('Ed25519'))
177176

178-
await expect(verifySignature(forged, encodeMessage)).to.be.rejectedWith('Could not get the public key from the originator id')
177+
await expect(verifySignature(forged, encodeMessage)).to.eventually.equal(false)
179178
})
180179
})
181180
})

0 commit comments

Comments
 (0)