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

  1. Give every consensus node a keyPair via generateNodeKeyPair() so all outbound messages are signed
  2. Build a shared nodeId -> publicKeyPem map and pass the same resolvePeerPublicKey closure to every transport
  3. Verify the sender actually signs: msg.signature must be present (unsigned -> fail-closed false)
  4. After any key rotation, redistribute public keys to all peers before resuming traffic
  5. 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

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


AI-assisted analysis of ruvnet/ruflo@fa13ee4ad6 (2026-08-18). Data as JSON: /api/errors/0dca505cdcdec02b. Report an issue: GitHub.