ruvnet/ruflo · critical · Error
LocalTransport: signature verification failed for message fr
Error message
LocalTransport: signature verification failed for message from ${msg.from} What it means
LocalTransport verifies inbound messages with Ed25519 when the receiving node was constructed with both keyPair and resolvePeerPublicKey. This error means verification failed: either no public key is known for msg.from, the signature is missing (verifyMessage fails closed on unsigned messages), or the signature does not match the canonicalized message content. It is the transport's fail-closed security gate against forged or tampered consensus messages.
Source
Thrown at v3/@claude-flow/swarm/src/consensus/transport.ts:242
const base: Omit<ConsensusMessage, 'signature'> = {
...msg,
from: this.nodeId,
seq: this.keyPair ? ++this.seqCounter : msg.seq,
};
if (this.keyPair) {
return { ...base, signature: signMessage(base, this.keyPair.privateKeyPem) };
}
return base;
}
/** Deliver an inbound message to a target's handler, with optional sig + replay checks. */
private async deliver(target: LocalTransport, msg: ConsensusMessage): Promise<ConsensusReply> {
if (target.closed) throw new Error(`LocalTransport: peer ${target.nodeId} is closed`);
// Verification path — only when the *target* expects signed messages.
if (target.keyPair && target.resolvePeerPublicKey) {
const pub = target.resolvePeerPublicKey(msg.from);
if (!pub || !verifyMessage(msg, pub)) {
throw new Error(`LocalTransport: signature verification failed for message from ${msg.from}`);
}
// Replay defense: seq must be strictly increasing per sender.
if (typeof msg.seq === 'number') {
const last = target.lastSeenSeq.get(msg.from) ?? 0;
if (msg.seq <= last) throw new Error(`LocalTransport: replayed/out-of-order seq from ${msg.from} (${msg.seq} <= ${last})`);
target.lastSeenSeq.set(msg.from, msg.seq);
}
}
if (!target.handler) return null;
const reply = await target.handler(msg);
return (reply ?? null) as ConsensusReply;
}
async send(to: string, msg: Omit<ConsensusMessage, 'from'>, timeoutMs?: number): Promise<ConsensusReply> {
if (this.closed) throw new Error('LocalTransport: closed');
const target = this.registry.get(to);
if (!target) throw new Error(`LocalTransport: unreachable peer ${to}`);
const stamped = this.stamp(msg);View on GitHub (pinned to fa13ee4ad6)
Solutions
- Give every consensus node a keyPair via generateNodeKeyPair() so all outbound messages are signed
- Build a shared nodeId -> publicKeyPem map and pass the same resolvePeerPublicKey closure to every transport
- Verify the sender actually signs: msg.signature must be present (unsigned -> fail-closed false)
- After any key rotation, redistribute public keys to all peers before resuming traffic
- Never mutate a ConsensusMessage after it is stamped — the signature covers all content fields
Example fix
// before (only the target verifies; sender unsigned -> always fails)
const a = new LocalTransport('a');
const b = new LocalTransport('b', { keyPair: kb, resolvePeerPublicKey: () => undefined });
await a.send('b', { type: 'request-vote', payload: {} }); // throws
// after (both sides sign; shared pubkey map)
const keys = new Map([['a', ka.publicKeyPem], ['b', kb.publicKeyPem]]);
const a = new LocalTransport('a', { keyPair: ka, resolvePeerPublicKey: id => keys.get(id) });
const b = new LocalTransport('b', { keyPair: kb, resolvePeerPublicKey: id => keys.get(id) });
await a.send('b', { type: 'request-vote', payload: {} }); // verifies Defensive patterns
Strategy: validation
Validate before calling
// Before first send, assert the key topology is complete on every verifying node:
import { verifyMessage } from './transport.js';
function assertKeyWiring(nodes, resolvePeerPublicKey) {
for (const n of nodes) {
if (!n.hasKeyPair) continue; // verifier-only check
for (const peer of nodes) {
const pub = resolvePeerPublicKey(peer.nodeId);
if (!pub) throw new Error(`missing pubkey for ${peer.nodeId}`);
}
}
} Try / catch
try { await transport.send(to, msg); }
catch (e) {
if (e instanceof Error && e.message.includes('signature verification failed')) {
// security failure: quarantine the sender/peer key map, alert; do NOT retry blindly
await securityAudit.logBadSignature(to); throw e;
}
throw e;
} Prevention
- Provision keypairs for ALL nodes at construction time (generateNodeKeyPair), not just some
- Distribute one shared nodeId->publicKeyPem map to every transport's resolvePeerPublicKey
- Never mutate a stamped ConsensusMessage; rebuild it and re-sign
- On key rotation, update every peer's key map before resuming traffic
When it happens
Trigger: (1) Sender transport was created without a keyPair, so stamp() emitted an unsigned message to a verifying target. (2) target.resolvePeerPublicKey(msg.from) returns undefined because the sender's public key was never registered in the peer key map. (3) Sender signs with a different key than the one registered for its nodeId. (4) Message content was mutated between stamping and delivery (signature covers deep-sorted-key JSON of all fields except signature).
Common situations: Enabling signing on only some nodes of a swarm ('partial key material'); rotating node keys without updating every peer's resolvePeerPublicKey map; mixing transports built with generateNodeKeyPair() at different times so each node knows only its own key; tests copying messages and tweaking payload fields before redelivery.
Related errors
- LocalTransport: replayed/out-of-order seq from ${msg.from} (
- LocalTransport: peer ${target.nodeId} is closed
- LocalTransport: closed
- LocalTransport: unreachable peer ${to}
- Validation failed: ${result.error}
AI-assisted analysis of ruvnet/ruflo@fa13ee4ad6 (2026-08-18).
Data as JSON: /api/errors/0dca505cdcdec02b.
Report an issue: GitHub.