arti 2.2.0

A rust implementation of the Tor privacy tools.
Documentation
//! Integration test suite for Arti hss.
//!
//! Testing certain `hss` subcommands involves deleting and creating files, which requires the use
//! of a temporary directory. Due to this and other considerations, the `assert_cmd` crate is used to
//! test these subcommands instead of the preferred `trycmd` crate (see [README](../README.md)).
//!
//! ## Note on the test data
//!
//! Test data for this suite is stored in the `hss-extra/hss.in/local` directory. The structure is as follows:
//!
//! ```
//! local
//! ├── ctor-keystore
//! │   ├── hostname
//! │   ├── hs_ed25519_public_key
//! │   └── hs_ed25519_secret_key
//! └── state-dir
//!     └── keystore
//!         ├── hss
//!         │   └── allium-cepa
//!         │       ├── ipts
//!         │       │   ├── k_hss_ntor+2a6054c3432b880b76cf379f66daf1a34c88693efed5e85bd90507a1fea231d7.x25519_private
//!         │       │   ├── k_hss_ntor+84a3a863484ff521081ee8e6e48a6129d0c83bef89fe294a5dda6f782b43dec8.x25519_private
//!         │       │   ├── k_hss_ntor+ce8514e2fe016e4705b064f2226a7628f4226e9a15d28607112e4eac3b3a012f.x25519_private
//!         │       │   ├── k_sid+2a6054c3432b880b76cf379f66daf1a34c88693efed5e85bd90507a1fea231d7.ed25519_private
//!         │       │   ├── k_sid+84a3a863484ff521081ee8e6e48a6129d0c83bef89fe294a5dda6f782b43dec8.ed25519_private
//!         │       │   └── k_sid+ce8514e2fe016e4705b064f2226a7628f4226e9a15d28607112e4eac3b3a012f.ed25519_private
//!         │       ├── ks_hs_blind_id+20326_1440_43200.ed25519_expanded_private
//!         │       ├── ks_hs_blind_id+20327_1440_43200.ed25519_expanded_private
//!         │       ├── ks_hs_id.ed25519_expanded_private
//!         │       └── unrecognized-entry
//!         ├── unrecognized-path
//!         └── unrecognized-path-dir
//!             └── unrecognized-path
//! ```
//!
//! Where:
//!
//! - `local/ctor-keystore` is a fully populated CTor keystore.
//! - `local/state-dir` is an example Arti state directory, partially populated.
//!   This directory typically corresponds to `~/.local/share/arti`. For the
//!   purposes of testing, it contains only `local/state-dir/keystore`.
//! - `local/state-dir/keystore/hss/allium-cepa` is a partially populated keystore for the hidden
//!   service `allium-cepa`. It includes a long-term identity key and several derived keys.
//!   For testing purposes, all derived keys may be empty files. Their presence ensures
//!   they are correctly removed during migration.
//! - `unrecognized-path` and `unrecognized-entry` are included to verify that
//!   the migration process does not remove them. Form more information about unrecognized entries
//!   and paths see [keys list documentation](../../../../doc/keys.md).

use crate::hss::util::{
    ARTI_KEYSTORE_POPULATION, CFG_CTOR_PATH, CFG_PATH, CTorMigrateCmd, EXPECTED_ID_KEY_PATH,
    EXPECTED_UNRECOGNIZED_KEYSTORE_ENTRY, HSS_DIR_PATH, IPTS_DIR_PATH, KEYSTORE_DIR_PATH,
    OnionAddressCmdBuilder, SERVICE_DIR_PATH, UNRECOGNIZED_PATH_1, UNRECOGNIZED_PATH_2,
    UNRECOGNIZED_PATH_2_DIR,
};

mod util;

#[test]
fn migration_succeeds_with_empty_arti_keystore() {
    let cmd = CTorMigrateCmd::new();
    assert!(cmd.is_state_dir_empty());
    assert!(cmd.output().unwrap().status.success());
    assert!(cmd.state_dir_contains_only(&[
        EXPECTED_ID_KEY_PATH,
        KEYSTORE_DIR_PATH,
        HSS_DIR_PATH,
        SERVICE_DIR_PATH
    ]));
}

#[test]
fn migration_succeeds_with_full_arti_keystore_and_batch_enabled() {
    let migrate_cmd = CTorMigrateCmd::new();
    assert!(migrate_cmd.is_state_dir_empty());

    // Since the state directory is currently empty and the configuration provides
    // a functional CTor keystore, `ctor_keystore_onion_address` holds the onion
    // address associated with the CTor keystore's identity key.
    let onion_address_cmd = OnionAddressCmdBuilder::default()
        .config_path(CFG_CTOR_PATH.to_string())
        .state_directory(Some(
            migrate_cmd.state_dir_path().to_string_lossy().to_string(),
        ))
        .build()
        .unwrap();
    let ctor_keystore_onion_address =
        String::from_utf8(onion_address_cmd.output().unwrap().stdout).unwrap();

    migrate_cmd.populate_state_dir();

    // With the state directory populated and the configuration (lacking a CTor keystore) overridden by a
    // command-line flag that provides a functional, fully populated Arti native keystore, the resulting
    // onion address is derived from the Arti keystore's identity key.
    let onion_address_cmd = OnionAddressCmdBuilder::default()
        .config_path(CFG_PATH.to_string())
        .state_directory(Some(
            migrate_cmd.state_dir_path().to_string_lossy().to_string(),
        ))
        .build()
        .unwrap();
    let arti_keystore_onion_address =
        String::from_utf8(onion_address_cmd.output().unwrap().stdout).unwrap();

    // The two onion-addresses are different.
    assert_ne!(ctor_keystore_onion_address, arti_keystore_onion_address);

    assert!(migrate_cmd.state_dir_contains_only(ARTI_KEYSTORE_POPULATION));
    assert!(migrate_cmd.output().unwrap().status.success());
    // `ctor-migrate` substitutes the long-term ID key, removes all other recognized entries,
    // and leaves unrecognized entries and paths unchanged.
    assert!(migrate_cmd.state_dir_contains_only(&[
        EXPECTED_ID_KEY_PATH,
        KEYSTORE_DIR_PATH,
        HSS_DIR_PATH,
        SERVICE_DIR_PATH,
        EXPECTED_UNRECOGNIZED_KEYSTORE_ENTRY,
        IPTS_DIR_PATH,
        UNRECOGNIZED_PATH_1,
        UNRECOGNIZED_PATH_2_DIR,
        UNRECOGNIZED_PATH_2
    ]));

    // The migration has completed: the CTor identity key has been converted into an
    // Arti native identity key. Since no CTor keystore is provided in the config at
    // `CFG_PATH`, the resulting onion address is obtained from the Arti native
    // keystore, which now holds the same identity key as the original CTor keystore.
    let onion_address_cmd = OnionAddressCmdBuilder::default()
        .config_path(CFG_PATH.to_string())
        .state_directory(Some(
            migrate_cmd.state_dir_path().to_string_lossy().to_string(),
        ))
        .build()
        .unwrap();
    let arti_keystore_onion_address =
        String::from_utf8(onion_address_cmd.output().unwrap().stdout).unwrap();

    // The onion addresses are now the same because they are derived from the same
    // identity key, which exists in different formats across the two keystores.
    assert_eq!(ctor_keystore_onion_address, arti_keystore_onion_address)
}

#[test]
fn ctor_migrate_is_idempotent() {
    let cmd = CTorMigrateCmd::new();
    assert!(cmd.is_state_dir_empty());
    assert!(cmd.output().unwrap().status.success());
    assert!(cmd.state_dir_contains_only(&[
        EXPECTED_ID_KEY_PATH,
        KEYSTORE_DIR_PATH,
        HSS_DIR_PATH,
        SERVICE_DIR_PATH
    ]));
    let output = cmd.output().unwrap();
    assert!(!output.status.success());
    let error = String::from_utf8(cmd.output().unwrap().stderr).unwrap();
    assert!(error.contains("error: Service allium-cepa was already migrated."))
}

#[test]
fn ctor_migrate_fails_when_applied_to_unregistered_service() {
    let mut cmd = CTorMigrateCmd::new();
    assert!(cmd.is_state_dir_empty());
    cmd.set_nickname("unregistered".to_string());
    let output = cmd.output().unwrap();
    assert!(!output.status.success());
    let error = String::from_utf8(cmd.output().unwrap().stderr).unwrap();
    assert!(error.contains("error: The service identified using `--nickname unregistered` is not configured with any recognized CTor keystore."))
}

#[test]
fn ctor_migrate_fails_without_registered_ctor_keystore() {
    let mut cmd = CTorMigrateCmd::new();
    assert!(cmd.is_state_dir_empty());
    cmd.set_config(CFG_PATH.to_string());
    let output = cmd.output().unwrap();
    assert!(!output.status.success());
    let error = String::from_utf8(cmd.output().unwrap().stderr).unwrap();
    assert!(error.contains("error: No CTor keystore are configured."))
}

#[test]
fn ctor_migrate_aborts_correctly_without_batch_flag() {
    let mut migrate_cmd = CTorMigrateCmd::new();
    assert!(migrate_cmd.is_state_dir_empty());

    let onion_address_cmd = OnionAddressCmdBuilder::default()
        .config_path(CFG_CTOR_PATH.to_string())
        .state_directory(Some(
            migrate_cmd.state_dir_path().to_string_lossy().to_string(),
        ))
        .build()
        .unwrap();
    let ctor_keystore_onion_address =
        String::from_utf8(onion_address_cmd.output().unwrap().stdout).unwrap();

    migrate_cmd.populate_state_dir();

    let onion_address_cmd = OnionAddressCmdBuilder::default()
        .config_path(CFG_PATH.to_string())
        .state_directory(Some(
            migrate_cmd.state_dir_path().to_string_lossy().to_string(),
        ))
        .build()
        .unwrap();
    let arti_keystore_onion_address =
        String::from_utf8(onion_address_cmd.output().unwrap().stdout).unwrap();

    assert_ne!(ctor_keystore_onion_address, arti_keystore_onion_address);

    migrate_cmd.set_stdin("no".to_string());

    assert!(migrate_cmd.state_dir_contains_only(ARTI_KEYSTORE_POPULATION));
    let output = migrate_cmd.output().unwrap();
    assert!(output.status.success());

    // The migration didn't happen, old files haven't been removed.
    assert!(migrate_cmd.state_dir_contains_only(ARTI_KEYSTORE_POPULATION));

    let onion_address_cmd = OnionAddressCmdBuilder::default()
        .config_path(CFG_PATH.to_string())
        .state_directory(Some(
            migrate_cmd.state_dir_path().to_string_lossy().to_string(),
        ))
        .build()
        .unwrap();
    let arti_keystore_onion_address =
        String::from_utf8(onion_address_cmd.output().unwrap().stdout).unwrap();

    // The identity keys in the Arti and CTor keystores differ, resulting in
    // different onion addresses.
    assert_ne!(ctor_keystore_onion_address, arti_keystore_onion_address);

    assert!(
        String::from_utf8(output.stdout)
            .unwrap()
            .contains("Aborted.")
    )
}