Skip to main content

tempo_consensus/
args.rs

1//! Command line arguments for configuring the consensus layer of a tempo node.
2use std::{
3    net::SocketAddr,
4    num::NonZeroU32,
5    path::{Path, PathBuf},
6    str::FromStr,
7    sync::Arc,
8    time::Duration,
9};
10
11use crate::network_identity::NetworkIdentity;
12use commonware_cryptography::ed25519::PublicKey;
13use eyre::Context;
14use tempo_consensus_config::{SigningKey, SigningKeyPassphrase};
15
16const DEFAULT_MAX_MESSAGE_SIZE_BYTES: u32 =
17    reth_consensus_common::validation::MAX_RLP_BLOCK_SIZE as u32;
18const PASSPHRASE_SECRET_WAIT_WARNING_INTERVAL: Duration = Duration::from_secs(5);
19
20/// Command line arguments for configuring the consensus layer of a tempo node.
21#[derive(Debug, Clone, clap::Args)]
22pub struct Args {
23    /// The file containing the ed25519 signing key for p2p communication.
24    ///
25    /// By default the file is expected to contain a hex-encoded ed25519
26    /// private key in plaintext (unencrypted at rest). When
27    /// `--consensus.secret` is also provided, the file is instead
28    /// treated as a passphrase-encrypted `age` payload whose plaintext is
29    /// the raw encoded ed25519 private key bytes; it is decrypted at
30    /// startup using the passphrase read from the secret path.
31    #[arg(
32        long = "consensus.signing-key",
33        required_unless_present_any = ["follow", "dev"],
34    )]
35    signing_key: Option<PathBuf>,
36
37    /// Path from which the passphrase used to decrypt
38    /// `--consensus.signing-key` is read.
39    ///
40    /// A FIFO created by `mkfifo`, or shell process substitution like `<(...)`,
41    /// is preferred to avoid leaving the passphrase on disk or in process state
42    /// (like via an env var). Regular files are accepted with a warning.
43    #[arg(
44        long = "consensus.secret",
45        value_name = "PATH",
46        requires = "signing_key"
47    )]
48    secret: Option<PathBuf>,
49
50    /// The file containing a share of the bls12-381 threshold signing key.
51    #[arg(long = "consensus.signing-share")]
52    pub signing_share: Option<PathBuf>,
53
54    /// Consensus network bls network identity key. Otherwise derived from genesis
55    #[arg(
56        long = "consensus.network-identity",
57        requires = "network_identity_from_epoch"
58    )]
59    pub(crate) network_identity: Option<NetworkIdentity>,
60
61    /// First epoch where --consensus.network-identity is set after rotation
62    #[arg(
63        long = "consensus.network-identity-from-epoch",
64        requires = "network_identity"
65    )]
66    pub(crate) network_identity_from_epoch: Option<u64>,
67
68    /// The socket address that will be bound to listen for consensus communication from
69    /// other nodes.
70    #[arg(long = "consensus.listen-address", default_value = "127.0.0.1:8000")]
71    pub listen_address: SocketAddr,
72
73    /// The socket address that will be bound to export consensus specific
74    /// metrics.
75    #[arg(long = "consensus.metrics-address", default_value = "127.0.0.1:8001")]
76    pub metrics_address: SocketAddr,
77
78    #[arg(long = "consensus.max-message-size-bytes", default_value_t = DEFAULT_MAX_MESSAGE_SIZE_BYTES)]
79    pub max_message_size_bytes: u32,
80
81    /// The number of worker threads assigned to consensus.
82    #[arg(long = "consensus.worker-threads", default_value_t = 3)]
83    pub worker_threads: usize,
84
85    /// The maximum number of messages that can be queued on the various consensus
86    /// channels before blocking.
87    #[arg(long = "consensus.message-backlog", default_value_t = 16_384)]
88    pub message_backlog: usize,
89
90    /// The overall number of items that can be received on the various consensus
91    /// channels before blocking.
92    #[arg(long = "consensus.mailbox-size", default_value_t = 16_384)]
93    pub mailbox_size: usize,
94
95    /// The maximum number of blocks that will be buffered per peer. Used to
96    /// send and receive blocks over the network of the consensus layer.
97    #[arg(long = "consensus.deque-size", default_value_t = 10)]
98    pub deque_size: usize,
99
100    /// The amount of time to wait for a peer to respond to a consensus request.
101    #[arg(long = "consensus.wait-for-peer-response", default_value = "2s")]
102    pub wait_for_peer_response: PositiveDuration,
103
104    /// The amount of time to wait for a quorum of notarizations in a view
105    /// before attempting to skip the view.
106    #[arg(long = "consensus.wait-for-notarizations", default_value = "2s")]
107    pub wait_for_notarizations: PositiveDuration,
108
109    /// Target wall-clock time between blocks in healthy network conditions.
110    ///
111    /// Local proposal work is paced against this value minus
112    /// `--consensus.network-budget`.
113    #[arg(long = "consensus.target-block-time", default_value = "550ms")]
114    pub target_block_time: PositiveDuration,
115
116    /// Maximum amount of time to wait for the leader's proposal before timing
117    /// out the current view.
118    #[arg(long = "consensus.wait-for-proposal", default_value = "1200ms")]
119    pub wait_for_proposal: PositiveDuration,
120
121    /// The amount of time to wait before retrying a nullify broadcast if stuck
122    /// in a view.
123    #[arg(long = "consensus.wait-to-rebroadcast-nullify", default_value = "10s")]
124    pub wait_to_rebroadcast_nullify: PositiveDuration,
125
126    /// The number of views (like voting rounds) to track. Also called an
127    /// activity timeout.
128    #[arg(long = "consensus.views-to-track", default_value_t = 256)]
129    pub views_to_track: u64,
130
131    /// The number of views (voting rounds) a validator is allowed to be
132    /// inactive until it is immediately skipped should leader selection pick it
133    /// as a proposer. Also called a skip timeout.
134    #[arg(
135        long = "consensus.inactive-views-until-leader-skip",
136        default_value_t = 32
137    )]
138    pub inactive_views_until_leader_skip: u64,
139
140    /// Time reserved for proposal propagation before the target block boundary.
141    ///
142    /// The remaining `target-block-time - network-budget` is the local proposal
143    /// return budget used by consensus and the payload builder.
144    #[arg(long = "consensus.network-budget", default_value = "50ms")]
145    pub network_budget: PositiveDuration,
146
147    /// Deprecated compatibility flag. Ignored by the elastic proposal budget.
148    #[arg(
149        long = "consensus.time-to-prepare-proposal-transactions",
150        value_name = "DURATION",
151        help = "Deprecated: no longer has any effect and will be removed in the next release."
152    )]
153    pub time_to_prepare_proposal_transactions: Option<PositiveDuration>,
154
155    /// Deprecated compatibility flag. Ignored by the elastic proposal budget.
156    #[arg(
157        long = "consensus.minimum-time-before-propose",
158        visible_alias = "consensus.time-to-build-proposal",
159        value_name = "DURATION",
160        help = "Deprecated: no longer has any effect and will be removed in the next release."
161    )]
162    pub minimum_time_before_propose: Option<PositiveDuration>,
163
164    /// The amount of time this node will use to construct a subblock before
165    /// sending it to the next proposer.
166    #[arg(long = "consensus.time-to-build-subblock", default_value = "100ms")]
167    pub time_to_build_subblock: PositiveDuration,
168
169    /// Use defaults optimized for local network environments.
170    /// Only enable in non-production network nodes.
171    #[arg(long = "consensus.use-local-defaults", default_value_t = false)]
172    pub use_local_defaults: bool,
173
174    /// Reduces security by disabling IP-based connection filtering.
175    /// Connections are still authenticated via public key cryptography, but
176    /// anyone can attempt handshakes, increasing exposure to DoS attacks.
177    /// Only enable in trusted network environments.
178    #[arg(long = "consensus.bypass-ip-check", default_value_t = false)]
179    pub bypass_ip_check: bool,
180
181    /// Whether to allow connections with private IP addresses.
182    #[arg(
183        long = "consensus.allow-private-ips",
184        default_value_t = false,
185        default_value_if("use_local_defaults", "true", "true")
186    )]
187    pub allow_private_ips: bool,
188
189    /// Whether to allow DNS-based ingress addresses.
190    #[arg(long = "consensus.allow-dns", default_value_t = true)]
191    pub allow_dns: bool,
192
193    /// Time into the future that a timestamp can be and still be considered valid.
194    #[arg(long = "consensus.synchrony-bound", default_value = "5s")]
195    pub synchrony_bound: PositiveDuration,
196
197    /// How long to wait before attempting to dial peers. Run across all peers
198    /// including the newly discovered ones.
199    #[arg(
200        long = "consensus.wait-before-peers-redial",
201        default_value = "1s",
202        default_value_if("use_local_defaults", "true", "500ms")
203    )]
204    pub wait_before_peers_redial: PositiveDuration,
205
206    /// How long to wait before sending a ping message to peers for liveness detection.
207    #[arg(
208        long = "consensus.wait-before-peers-reping",
209        default_value = "50s",
210        default_value_if("use_local_defaults", "true", "5s")
211    )]
212    pub wait_before_peers_reping: PositiveDuration,
213
214    /// How often to query for new dialable peers.
215    #[arg(
216        long = "consensus.wait-before-peers-discovery",
217        default_value = "60s",
218        default_value_if("use_local_defaults", "true", "30s")
219    )]
220    pub wait_before_peers_discovery: PositiveDuration,
221
222    /// Minimum time between connection attempts to the same peer. A rate-limit
223    /// on connection attempts.
224    #[arg(
225        long = "consensus.connection-per-peer-min-period",
226        default_value = "60s",
227        default_value_if("use_local_defaults", "true", "1s")
228    )]
229    pub connection_per_peer_min_period: PositiveDuration,
230
231    /// Minimum time between handshake attempts from a single IP address. A rate-limit
232    /// on attempts.
233    #[arg(
234        long = "consensus.handshake-per-ip-min-period",
235        default_value = "5s",
236        default_value_if("use_local_defaults", "true", "62ms")
237    )]
238    pub handshake_per_ip_min_period: PositiveDuration,
239
240    /// Minimum time between handshake attempts from a single subnet. A rate-limit
241    /// on attempts.
242    #[arg(
243        long = "consensus.handshake-per-subnet-min-period",
244        default_value = "15ms",
245        default_value_if("use_local_defaults", "true", "7ms")
246    )]
247    pub handshake_per_subnet_min_period: PositiveDuration,
248
249    /// Duration after which a handshake message is considered stale.
250    #[arg(long = "consensus.handshake-stale-after", default_value = "10s")]
251    pub handshake_stale_after: PositiveDuration,
252
253    /// Timeout for the handshake process.
254    #[arg(long = "consensus.handshake-timeout", default_value = "5s")]
255    pub handshake_timeout: PositiveDuration,
256
257    /// Maximum number of concurrent handshake attempts allowed.
258    #[arg(
259        long = "consensus.max-concurrent-handshakes",
260        default_value = "512",
261        default_value_if("use_local_defaults", "true", "1024")
262    )]
263    pub max_concurrent_handshakes: NonZeroU32,
264
265    /// Duration after which a blocked peer is allowed to reconnect.
266    #[arg(
267        long = "consensus.time-to-unblock-byzantine-peer",
268        default_value = "4h",
269        default_value_if("use_local_defaults", "true", "1h")
270    )]
271    pub time_to_unblock_byzantine_peer: PositiveDuration,
272
273    /// Rate limit when backfilling blocks (requests per second).
274    #[arg(long = "consensus.backfill-frequency", default_value = "8")]
275    pub backfill_frequency: std::num::NonZeroU32,
276
277    /// The interval at which to broadcast subblocks to the next proposer.
278    /// Each built subblock is immediately broadcasted to the next proposer (if it's known).
279    /// We broadcast subblock every `subblock-broadcast-interval` to ensure the next
280    /// proposer is aware of the subblock even if they were slightly behind the chain
281    /// once we sent it in the first time.
282    #[arg(long = "consensus.subblock-broadcast-interval", default_value = "50ms")]
283    pub subblock_broadcast_interval: PositiveDuration,
284
285    /// The interval at which to send a forkchoice update heartbeat to the
286    /// execution layer. This is sent periodically even when there are no new
287    /// blocks to ensure the execution layer stays in sync with the consensus
288    /// layer's view of the chain head.
289    #[arg(long = "consensus.fcu-heartbeat-interval", default_value = "5m")]
290    pub fcu_heartbeat_interval: PositiveDuration,
291
292    /// Cache for the signing key loaded from CLI-provided file.
293    #[clap(skip)]
294    loaded_signing_key: Arc<tokio::sync::OnceCell<Option<SigningKey>>>,
295
296    /// Where to store consensus data. If not set, this will be derived from
297    /// `--datadir`.
298    #[arg(long = "consensus.datadir", value_name = "PATH")]
299    pub storage_dir: Option<PathBuf>,
300
301    /// Number of recently finalized blocks the marshal actor keeps in its
302    /// prunable archive. Anything older is served from reth's database
303    /// through the hybrid finalized blocks store.
304    #[arg(
305        long = "consensus.finalized-blocks-retention",
306        default_value_t = crate::storage::DEFAULT_FINALIZED_BLOCKS_RETENTION,
307    )]
308    pub finalized_blocks_retention: u64,
309
310    /// Deprecated compatibility flag. Ignored because the legacy immutable
311    /// finalized blocks archive has been removed.
312    #[arg(
313        long = "consensus.no-legacy-archive",
314        help = "Deprecated: the legacy finalized blocks archive has been removed, so this flag no longer has any effect."
315    )]
316    pub no_legacy_archive: bool,
317}
318
319/// A jiff::SignedDuration that checks that the duration is positive and not zero.
320#[derive(Debug, Clone, Copy)]
321pub struct PositiveDuration(jiff::SignedDuration);
322impl PositiveDuration {
323    pub fn into_duration(self) -> Duration {
324        self.0
325            .try_into()
326            .expect("must be positive. enforced when cli parsing.")
327    }
328}
329
330impl FromStr for PositiveDuration {
331    type Err = Box<dyn std::error::Error + Send + Sync + 'static>;
332
333    fn from_str(s: &str) -> Result<Self, Self::Err> {
334        let duration = s.parse::<jiff::SignedDuration>()?;
335        let _: Duration = duration.try_into().wrap_err("duration must be positive")?;
336
337        Ok(Self(duration))
338    }
339}
340
341impl Args {
342    /// Returns the signing key loaded from the configured file.
343    ///
344    /// When `--consensus.secret` is set, tries to decrypt the signing key.
345    /// If not, treats the file contents as plaintext hex.
346    pub(crate) async fn signing_key(&self) -> eyre::Result<Option<SigningKey>> {
347        Ok(self
348            .loaded_signing_key
349            .get_or_try_init(|| async { self.load_signing_key().await })
350            .await
351            .wrap_err("failed reading signing key")?
352            .clone())
353    }
354
355    async fn load_signing_key(&self) -> eyre::Result<Option<SigningKey>> {
356        Ok(match (self.signing_key.as_ref(), self.secret.as_ref()) {
357            (Some(path), Some(secret)) => {
358                let passphrase = read_secret(secret).await.wrap_err_with(|| {
359                    format!("failed reading secret from `{}`", secret.display())
360                })?;
361                Some(
362                    SigningKey::read_from_file_encrypted(path, passphrase).wrap_err_with(|| {
363                        format!(
364                            "failed reading ed25519 signing key from file `{}`",
365                            path.display()
366                        )
367                    })?,
368                )
369            }
370            (Some(path), None) => Some(
371                SigningKey::read_from_file_unencrypted(path).wrap_err_with(|| {
372                    format!(
373                        "failed reading private ed25519 signing key from file `{}`",
374                        path.display()
375                    )
376                })?,
377            ),
378            (None, Some(_secret)) => {
379                unreachable!(
380                    "clap enforces that `--consensus.secret` requires `--consensus.signing-key` to point at the encrypted key file"
381                );
382            }
383            (None, None) => None,
384        })
385    }
386
387    /// Returns the public key derived from the configured signing key, if any.
388    pub async fn public_key(&self) -> eyre::Result<Option<PublicKey>> {
389        Ok(self
390            .signing_key()
391            .await?
392            .map(|signing_key| signing_key.public_key()))
393    }
394
395    pub fn network_identity(&self) -> Option<tempo_chainspec::NetworkIdentity> {
396        let identity = self.network_identity?;
397        let from_epoch = self
398            .network_identity_from_epoch
399            .expect("network identity from epoch required");
400
401        Some(tempo_chainspec::NetworkIdentity {
402            from_epoch,
403            identity: identity.0,
404        })
405    }
406}
407
408/// Read a single passphrase from `path` via blocking std I/O.
409async fn read_secret<P: AsRef<Path>>(path: P) -> eyre::Result<SigningKeyPassphrase> {
410    let path = path.as_ref().to_path_buf();
411    let task_path = path.clone();
412    let mut read =
413        tokio::task::spawn_blocking(move || tempo_consensus_config::read_secret(&task_path));
414    let mut warning_interval = tokio::time::interval_at(
415        tokio::time::Instant::now() + PASSPHRASE_SECRET_WAIT_WARNING_INTERVAL,
416        PASSPHRASE_SECRET_WAIT_WARNING_INTERVAL,
417    );
418
419    loop {
420        tokio::select! {
421            result = &mut read => {
422                let (passphrase, is_fifo) =
423                    result
424                        .map_err(eyre::Report::new)
425                        .and_then(|res| res.map_err(eyre::Report::new))
426                        .wrap_err("failed reading secret")?;
427                    warn_if_not_fifo(is_fifo, &path);
428                return Ok(passphrase);
429            }
430            _ = warning_interval.tick() => {
431                tracing::warn_span!(
432                    "signing_key_passphrase_secret",
433                    path = %path.display(),
434                )
435                .in_scope(|| {
436                    tracing::warn!(
437                        "still waiting for signing-key passphrase from secret path; if this is a FIFO, write the passphrase and close the writer"
438                    );
439                });
440            }
441        }
442    }
443}
444
445fn warn_if_not_fifo(is_fifo: bool, path: &Path) {
446    if !is_fifo {
447        tracing::warn_span!(
448        "signing_key_passphrase_secret",
449        path = %path.display(),
450    )
451    .in_scope(|| {
452        tracing::warn!(
453            "signing-key passphrase was read from a non-FIFO path; prefer a FIFO to avoid persisting the passphrase on disk"
454        );
455    });
456    }
457}
458
459#[cfg(test)]
460mod tests {
461    use std::{io::Write as _, process::Command, thread, time::Duration};
462
463    use clap::Parser as _;
464    use commonware_codec::Encode as _;
465
466    use super::Args;
467
468    const SIGNING_KEY_HEX: &str =
469        "0x7848b5d711bc9883996317a3f9c90269d56771005d540a19184939c9e8d0db2a";
470    const PASSPHRASE: &str = "correct horse battery staple";
471
472    fn raw_private_key_bytes() -> Vec<u8> {
473        tempo_consensus_config::SigningKey::try_from_hex(SIGNING_KEY_HEX)
474            .unwrap()
475            .into_inner()
476            .encode()
477            .to_vec()
478    }
479
480    #[derive(Debug, clap::Parser)]
481    struct TestCli {
482        // Stubs for the `required_unless_present_any = ["follow", "dev"]`
483        // gate on `--consensus.signing-key`. These args live in the outer
484        // binary's CLI struct; we re-declare them here just so clap can
485        // resolve the references during parse-time validation.
486        #[arg(long = "follow")]
487        #[allow(dead_code)]
488        follow: Option<String>,
489        #[arg(long = "dev")]
490        #[allow(dead_code)]
491        dev: bool,
492
493        #[command(flatten)]
494        consensus: Args,
495    }
496
497    fn parse(args: &[&str]) -> TestCli {
498        TestCli::try_parse_from(std::iter::once("test").chain(args.iter().copied())).unwrap()
499    }
500
501    #[test]
502    fn deprecated_proposal_timing_flags_parse() {
503        for flag in [
504            "--consensus.time-to-prepare-proposal-transactions",
505            "--consensus.minimum-time-before-propose",
506            "--consensus.time-to-build-proposal",
507        ] {
508            parse(&["--dev", flag, "1ms"]);
509        }
510    }
511
512    fn encrypt(plaintext: &[u8], passphrase: &str) -> Vec<u8> {
513        let mut ct = Vec::new();
514        let mut w = age::Encryptor::with_user_passphrase(
515            tempo_consensus_config::SigningKeyPassphrase::from(passphrase),
516        )
517        .wrap_output(&mut ct)
518        .unwrap();
519        w.write_all(plaintext).unwrap();
520        w.finish().unwrap();
521        ct
522    }
523
524    fn mkfifo(path: &std::path::Path) {
525        let status = Command::new("mkfifo")
526            .arg("-m")
527            .arg("600")
528            .arg(path)
529            .status()
530            .expect("mkfifo must be available");
531        assert!(status.success(), "mkfifo failed: {status}");
532    }
533
534    #[tokio::test(flavor = "current_thread")]
535    async fn encrypted_signing_key_via_fifo_roundtrip() {
536        let dir = tempfile::tempdir().unwrap();
537        let key_file = dir.path().join("signing-key.age");
538        std::fs::write(&key_file, encrypt(&raw_private_key_bytes(), PASSPHRASE)).unwrap();
539
540        let fifo = dir.path().join("passphrase.fifo");
541        mkfifo(&fifo);
542
543        let fifo_writer = fifo.clone();
544        let writer = thread::spawn(move || {
545            thread::sleep(Duration::from_millis(50));
546            let mut f = std::fs::OpenOptions::new()
547                .write(true)
548                .open(&fifo_writer)
549                .unwrap();
550            writeln!(f, "{PASSPHRASE}").unwrap();
551        });
552
553        let cli = parse(&[
554            "--consensus.signing-key",
555            key_file.to_str().unwrap(),
556            "--consensus.secret",
557            fifo.to_str().unwrap(),
558        ]);
559
560        let key = cli
561            .consensus
562            .signing_key()
563            .await
564            .expect("signing key must load")
565            .expect("signing key must be Some when --consensus.signing-key is set");
566        writer.join().unwrap();
567
568        let expected = tempo_consensus_config::SigningKey::try_from_hex(SIGNING_KEY_HEX).unwrap();
569        assert_eq!(key.public_key(), expected.public_key());
570    }
571
572    #[tokio::test(flavor = "current_thread")]
573    async fn encrypted_signing_key_via_regular_secret_file_roundtrip() {
574        let dir = tempfile::tempdir().unwrap();
575        let key_file = dir.path().join("signing-key.age");
576        let secret_file = dir.path().join("passphrase.txt");
577        std::fs::write(&key_file, encrypt(&raw_private_key_bytes(), PASSPHRASE)).unwrap();
578        std::fs::write(&secret_file, format!("{PASSPHRASE}\n")).unwrap();
579
580        let cli = parse(&[
581            "--consensus.signing-key",
582            key_file.to_str().unwrap(),
583            "--consensus.secret",
584            secret_file.to_str().unwrap(),
585        ]);
586
587        let key = cli
588            .consensus
589            .signing_key()
590            .await
591            .expect("signing key must load")
592            .expect("signing key must be Some when --consensus.signing-key is set");
593
594        let expected = tempo_consensus_config::SigningKey::try_from_hex(SIGNING_KEY_HEX).unwrap();
595        assert_eq!(key.public_key(), expected.public_key());
596    }
597
598    #[tokio::test(flavor = "current_thread")]
599    async fn encrypted_signing_key_concurrent_calls_share_fifo_read() {
600        let dir = tempfile::tempdir().unwrap();
601        let key_file = dir.path().join("signing-key.age");
602        std::fs::write(&key_file, encrypt(&raw_private_key_bytes(), PASSPHRASE)).unwrap();
603
604        let fifo = dir.path().join("passphrase.fifo");
605        mkfifo(&fifo);
606
607        let fifo_writer = fifo.clone();
608        let writer = thread::spawn(move || {
609            thread::sleep(Duration::from_millis(50));
610            let mut f = std::fs::OpenOptions::new()
611                .write(true)
612                .open(&fifo_writer)
613                .unwrap();
614            writeln!(f, "{PASSPHRASE}").unwrap();
615        });
616
617        let cli = parse(&[
618            "--consensus.signing-key",
619            key_file.to_str().unwrap(),
620            "--consensus.secret",
621            fifo.to_str().unwrap(),
622        ]);
623
624        let consensus_a = cli.consensus.clone();
625        let consensus_b = cli.consensus.clone();
626        let (key_a, key_b) = tokio::time::timeout(Duration::from_secs(2), async {
627            tokio::join!(consensus_a.signing_key(), consensus_b.signing_key())
628        })
629        .await
630        .expect("concurrent signing-key loads must not wait for a second pipe write");
631        writer.join().unwrap();
632
633        let key_a = key_a
634            .expect("first signing key must load")
635            .expect("first signing key must be Some");
636        let key_b = key_b
637            .expect("second signing key must load")
638            .expect("second signing key must be Some");
639
640        let expected = tempo_consensus_config::SigningKey::try_from_hex(SIGNING_KEY_HEX).unwrap();
641        assert_eq!(key_a.public_key(), expected.public_key());
642        assert_eq!(key_b.public_key(), expected.public_key());
643    }
644
645    #[tokio::test(flavor = "current_thread")]
646    async fn encrypted_signing_key_wrong_passphrase_fails() {
647        let dir = tempfile::tempdir().unwrap();
648        let key_file = dir.path().join("signing-key.age");
649        std::fs::write(&key_file, encrypt(&raw_private_key_bytes(), PASSPHRASE)).unwrap();
650
651        let fifo = dir.path().join("passphrase.fifo");
652        mkfifo(&fifo);
653
654        let fifo_writer = fifo.clone();
655        let writer = thread::spawn(move || {
656            thread::sleep(Duration::from_millis(50));
657            let mut f = std::fs::OpenOptions::new()
658                .write(true)
659                .open(&fifo_writer)
660                .unwrap();
661            writeln!(f, "wrong-passphrase").unwrap();
662        });
663
664        let cli = parse(&[
665            "--consensus.signing-key",
666            key_file.to_str().unwrap(),
667            "--consensus.secret",
668            fifo.to_str().unwrap(),
669        ]);
670
671        let _ = cli
672            .consensus
673            .signing_key()
674            .await
675            .expect_err("loading with a wrong passphrase must fail");
676        writer.join().unwrap();
677    }
678}