Skip to content

Commit b8fe1ea

Browse files
committed
fix(pubsub): verify message from matches signing key and prevent seen-cache poisoning
Closes a strict-sign origin spoofing vulnerability in the pubsub stack. A peer with any valid signing key could publish a signed message whose `from` named a different peer; receivers attributed the message to the impersonated peer and the forged `from || seqno` poisoned the seen-cache, suppressing the legitimate follow-up message from the real author. Three changes: * PubsubCrypto.pubsubValidate now rejects messages missing/empty `from`, `key` or `signature`, returns false (never throws) on un-parseable key/from, and requires `PeerId.fromPubKey(msg.key) == PeerId(msg.from)` in addition to the existing signature check. * PubsubApiImpl.PublisherImpl.publishExt rejects any caller-supplied `from` that does not match the publisher's signing key, throwing IllegalArgumentException. Null `from` continues to default to the signing key's PeerId as before; unsigned publishers (no privKey) are unchanged. * AbstractRouter no longer leaves a seenMessages entry behind for a message that fails validation. On both sync and async validation failure the entry inserted speculatively at the start of `onInbound` is evicted, so a later legitimate message with the same id is still processed and delivered. Adds PubsubStrictSignSpoofTest covering forged-from rejection, missing-field rejection, publishExt mismatch rejection, the matching-from happy path, and an end-to-end regression test that the seen-cache is no longer poisoned by a rejected forgery.
1 parent 18b0d95 commit b8fe1ea

4 files changed

Lines changed: 359 additions & 4 deletions

File tree

libp2p/src/main/kotlin/io/libp2p/pubsub/AbstractRouter.kt

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,11 @@ abstract class AbstractRouter(
226226
true
227227
} catch (e: Exception) {
228228
logger.debug("Invalid pubsub message from peer {}: {}", peer, it, e)
229-
seenMessages[it] = Optional.of(ValidationResult.Invalid)
229+
// Evict the speculatively-inserted seen-cache entry so that a later
230+
// legitimate message with the same id (e.g. same from||seqno) is not
231+
// suppressed by a forged predecessor that failed validation.
232+
// See security issue 01 (pubsub strict-sign spoof / seen-cache poisoning).
233+
seenMessages -= it.messageId
230234
notifyUnseenInvalidMessage(peer, it)
231235
false
232236
}
@@ -240,8 +244,14 @@ abstract class AbstractRouter(
240244
validFuts.forEach { (msg, validationFut) ->
241245
validationFut.thenAcceptAsync(
242246
{ res ->
243-
seenMessages[msg] = Optional.of(res)
244-
if (res == ValidationResult.Invalid) notifyUnseenInvalidMessage(peer, msg)
247+
if (res == ValidationResult.Invalid) {
248+
// Evict so a later legitimate message with the same id is not
249+
// suppressed by this rejected one.
250+
seenMessages -= msg.messageId
251+
notifyUnseenInvalidMessage(peer, msg)
252+
} else {
253+
seenMessages[msg] = Optional.of(res)
254+
}
245255
},
246256
executor
247257
)

libp2p/src/main/kotlin/io/libp2p/pubsub/PubsubApiImpl.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,16 @@ open class PubsubApiImpl(val router: PubsubRouter) : PubsubApi {
4242
seqId: Long?,
4343
vararg topics: Topic
4444
): CompletableFuture<Unit> {
45+
// If the caller supplies a `from` AND we have a signing key, the `from` MUST
46+
// resolve to the same PeerId as the signing key. Otherwise we'd be signing a
47+
// message that claims a different origin — see security issue 01
48+
// (pubsub strict-sign spoof).
49+
if (from != null && privKey != null) {
50+
val derived = PeerId.fromPubKey(privKey.publicKey()).bytes
51+
require(from.contentEquals(derived)) {
52+
"publishExt 'from' (${PeerId(from)}) does not match the signing key's PeerId (${PeerId(derived)})"
53+
}
54+
}
4555
val mFrom = from?.toProtobuf() ?: this.from
4656
val mSeqId = seqId ?: seqIdGenerator()
4757

libp2p/src/main/kotlin/io/libp2p/pubsub/PubsubCrypto.kt

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
package io.libp2p.pubsub
22

3+
import io.libp2p.core.PeerId
34
import io.libp2p.core.crypto.PrivKey
45
import io.libp2p.core.crypto.marshalPublicKey
56
import io.libp2p.core.crypto.unmarshalPublicKey
67
import io.libp2p.etc.types.toProtobuf
8+
import org.slf4j.LoggerFactory
79
import pubsub.pb.Rpc
810

911
val SignPrefix = "libp2p-pubsub:".toByteArray()
1012

13+
private val logger = LoggerFactory.getLogger("io.libp2p.pubsub.PubsubCrypto")
14+
1115
fun pubsubSign(msg: Rpc.Message, key: PrivKey): Rpc.Message {
1216
if (msg.hasKey() || msg.hasSignature()) throw IllegalArgumentException("Message to sign should not contain 'key' or 'signature' fields")
1317
val signature = key.sign(SignPrefix + msg.toByteArray())
@@ -17,12 +21,59 @@ fun pubsubSign(msg: Rpc.Message, key: PrivKey): Rpc.Message {
1721
.build()
1822
}
1923

24+
/**
25+
* Strict-sign validation for a pubsub [Rpc.Message]:
26+
*
27+
* - `from`, `key` and `signature` MUST all be present and non-empty.
28+
* - `signature` MUST verify under `key` over the SSZ-style serialised message with
29+
* `signature` and `key` cleared.
30+
* - `PeerId.fromPubKey(key)` MUST equal `PeerId(from)`, otherwise an attacker holding
31+
* any valid private key could publish messages whose `from` claims a different peer.
32+
*
33+
* Returns `false` on any validation failure. Never throws.
34+
*/
2035
fun pubsubValidate(msg: Rpc.Message): Boolean {
36+
if (!msg.hasFrom() || msg.from.isEmpty) {
37+
logger.debug("Rejecting pubsub message with missing/empty 'from'")
38+
return false
39+
}
40+
if (!msg.hasKey() || msg.key.isEmpty) {
41+
logger.debug("Rejecting pubsub message with missing/empty 'key'")
42+
return false
43+
}
44+
if (!msg.hasSignature() || msg.signature.isEmpty) {
45+
logger.debug("Rejecting pubsub message with missing/empty 'signature'")
46+
return false
47+
}
48+
49+
val publicKey = try {
50+
unmarshalPublicKey(msg.key.toByteArray())
51+
} catch (e: Exception) {
52+
logger.debug("Rejecting pubsub message with un-parseable 'key': {}", e.toString())
53+
return false
54+
}
55+
56+
val keyDerivedPeerId = PeerId.fromPubKey(publicKey)
57+
val claimedFrom = try {
58+
PeerId(msg.from.toByteArray())
59+
} catch (e: Exception) {
60+
logger.debug("Rejecting pubsub message with un-parseable 'from': {}", e.toString())
61+
return false
62+
}
63+
if (keyDerivedPeerId != claimedFrom) {
64+
logger.debug(
65+
"Rejecting pubsub message: from={} does not match PeerId(key)={}",
66+
claimedFrom,
67+
keyDerivedPeerId
68+
)
69+
return false
70+
}
71+
2172
val msgToSign = Rpc.Message.newBuilder(msg)
2273
.clearSignature()
2374
.clearKey()
2475
.build()
25-
return unmarshalPublicKey(msg.key.toByteArray()).verify(
76+
return publicKey.verify(
2677
SignPrefix + msgToSign.toByteArray(),
2778
msg.signature.toByteArray()
2879
)

0 commit comments

Comments
 (0)