Skip to content

bug: get_public_key() has no retry unlike sign_message() — transient failure at startup hangs validator waiting for manual intervention #268

Description

@pplmaverick

Environment

  • arc-node version: v0.7.3 (90d71dc, HEAD of main as of 2026-08-16)
  • Affected crate: arc-remote-signer (crates/remote-signer)
  • Signing config: SigningConfig::Remote (consensus node configured to use an external gRPC signing service — the HSM/KMS-backed signing path)
  • Component: crates/malachite-app (Node::consensus_identity())

Description

RemoteSignerClient exposes two RPCs to the remote signing service, but only one is retried.

  • sign_message()sign_message_with_retry() (crates/remote-signer/src/client.rs:172-215) wraps the gRPC call in backon's RetryableWithContext, using the configured RetryConfig (default: 3 retries, exponential backoff 100ms → 5s).
  • get_public_key() (client.rs:127-130) calls get_public_key_internal() (client.rs:133-170) exactly once. Any transient failure returns Err(RemoteSigningError::Status(...)) immediately, with zero retry.

get_public_key() is called exactly once, on the consensus startup path, from Node::consensus_identity() when SigningConfig::Remote is configured:

// crates/malachite-app/src/node.rs:373-376
let public_key = remote_provider
    .public_key()
    .await
    .wrap_err("Failed to get public key from remote signer")?;

RemoteSigningProvider::public_key() (provider.rs:73-108) is a cache-then-fetch wrapper around client.get_public_key() — it does not add its own retry either.

If this single call fails, the error propagates via ? through setup_node_identity()start()run():

// crates/malachite-app/src/node.rs:769-781
Err(e) => {
    let startup_error = e.wrap_err("Node failed to start");
    error!("{startup_error:?}");
    error!("Manual intervention required! Waiting for termination signal (SIGTERM)...");
    wait_for_termination().await;
    return Err(startup_error);
}

The process does not exit and does not retry — it blocks indefinitely in wait_for_termination(). Because the process stays alive, a supervisor watching for exit codes (systemd Restart=on-failure, k8s liveness via process exit) will not automatically restart it.

This creates an asymmetric reliability profile: consensus signing during steady-state is resilient to transient connectivity blips (3 retries with backoff). Node startup is not.

Steps to Reproduce

  1. Configure a consensus node with SigningConfig::Remote pointing at a working remote-signer gRPC endpoint.
  2. Arrange for the first public_key() gRPC call in consensus_identity() to fail — e.g.:
    • Start the node before the remote-signer service has finished initializing, or
    • Briefly block/reset the connection (e.g. iptables -j REJECT on the signer port for ~1 request), then let it become reachable again moments later.
  3. Observe the node logs.

Expected Behavior

Given sign_message()'s existing retry precedent, get_public_key() should retry the same way — a transient failure that resolves within the retry window should not prevent node startup.

Actual Behavior

error: Failed to get public key from remote signer: ...
error: Node failed to start
error: Manual intervention required! Waiting for termination signal (SIGTERM)...

The node hangs indefinitely, even though the remote signer becomes reachable moments later.

Impact

For a validator running with a remote signer (the intended production HSM/KMS-backed setup), any transient hiccup between the validator and the remote signer at the exact moment of (re)startup — both restarted together during a deploy, remote signer still initializing, a brief network partition, a load-balancer health-check gap — causes the validator to fail to (re)join consensus and sit idle until an operator manually restarts it.

This is a liveness/availability gap, not a fund-loss or consensus-safety issue. It was evaluated as not meeting the bar for a private HackerOne report and is filed as a public reliability bug per SECURITY.md.

Proposed Fix

Extract a generic fetch_with_retry() helper and have both get_public_key() and sign_message() call it, so get_public_key() gets the same RetryConfig-driven exponential backoff. A fix is included in the accompanying PR.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions