mirror of
https://codeberg.org/tyy/aspm
synced 2024-12-23 01:19:28 -07:00
Add feature-gated import-gpg subcommand to cli
This commit is contained in:
parent
765b20a90e
commit
5d982897a2
7 changed files with 1263 additions and 42 deletions
4
.vscode/settings.json
vendored
4
.vscode/settings.json
vendored
|
@ -3,14 +3,18 @@
|
||||||
"anstyle",
|
"anstyle",
|
||||||
"Aspe",
|
"Aspe",
|
||||||
"Aspm",
|
"Aspm",
|
||||||
|
"gpgme",
|
||||||
"josekit",
|
"josekit",
|
||||||
"keygrip",
|
"keygrip",
|
||||||
"keywrap",
|
"keywrap",
|
||||||
|
"NOPAD",
|
||||||
|
"Pinentry",
|
||||||
"PKCS",
|
"PKCS",
|
||||||
"Pkey",
|
"Pkey",
|
||||||
"printdoc",
|
"printdoc",
|
||||||
"subkey",
|
"subkey",
|
||||||
"subkeys",
|
"subkeys",
|
||||||
|
"userid",
|
||||||
"writedoc"
|
"writedoc"
|
||||||
]
|
]
|
||||||
}
|
}
|
907
Cargo.lock
generated
907
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
@ -27,6 +27,14 @@ sea-orm-migration = "0.11.3"
|
||||||
async-trait = "0.1.68"
|
async-trait = "0.1.68"
|
||||||
tokio = "1.29.1"
|
tokio = "1.29.1"
|
||||||
clap-stdin = "0.2.0"
|
clap-stdin = "0.2.0"
|
||||||
|
gpgme = { version = "0.11.0", optional = true }
|
||||||
|
pgp = { version = "0.10.2", optional = true }
|
||||||
|
josekit = { version = "0.8.3", optional = true }
|
||||||
|
elliptic-curve = { version = "0.13.5", optional = true }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
gpg-compat = ["dep:gpgme", "dep:pgp", "dep:josekit", "dep:elliptic-curve"]
|
||||||
|
default = ["gpg-compat"]
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
strip = true
|
strip = true
|
||||||
|
|
272
src/commands/keys/import_gpg.rs
Normal file
272
src/commands/keys/import_gpg.rs
Normal file
|
@ -0,0 +1,272 @@
|
||||||
|
#![cfg(feature = "gpg-compat")]
|
||||||
|
|
||||||
|
use std::{io::Write, sync::Arc};
|
||||||
|
|
||||||
|
use anyhow::{anyhow, bail, Context};
|
||||||
|
use argon2::{password_hash::SaltString, Argon2, PasswordHasher};
|
||||||
|
use clap::Parser;
|
||||||
|
use data_encoding::{BASE64URL_NOPAD, BASE64_NOPAD};
|
||||||
|
use dialoguer::{theme::ColorfulTheme, Password};
|
||||||
|
use elliptic_curve::sec1::{Coordinates, ToEncodedPoint};
|
||||||
|
use gpgme::{Context as GpgContext, PassphraseRequest};
|
||||||
|
use pgp::{
|
||||||
|
crypto::ecc_curve::ECCCurve,
|
||||||
|
types::{EcdsaPublicParams, KeyTrait, PlainSecretParams, PublicParams, SecretParams},
|
||||||
|
};
|
||||||
|
use sea_orm::{ActiveValue, EntityTrait};
|
||||||
|
use tokio::runtime::Runtime;
|
||||||
|
|
||||||
|
use crate::{commands::AspmSubcommand, entities::keys};
|
||||||
|
|
||||||
|
/// A command to import a key from GPG, by fingerprint. The password used to store the key will be the exact same as the password used to decrypt the key from GPG, and the key alias will be the primary UID (or only UID) from the GPG key.
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
pub struct KeysImportGpgCommand {
|
||||||
|
/// The full fingerprint of the GPG key to import. In order to import a subkey, the subkey's fingerprint much be provided, rather than the primary key's.
|
||||||
|
fingerprint: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl AspmSubcommand for KeysImportGpgCommand {
|
||||||
|
fn execute_sync(&self, state: crate::AspmState, runtime: Runtime) -> Result<(), anyhow::Error> {
|
||||||
|
let mut ctx = GpgContext::from_protocol(gpgme::Protocol::OpenPgp)
|
||||||
|
.context("Unable to create GPG context")?;
|
||||||
|
ctx.set_pinentry_mode(gpgme::PinentryMode::Loopback)
|
||||||
|
.context("Unable to set GPG pinentry mode")?;
|
||||||
|
|
||||||
|
let mut found_key = None::<gpgme::Key>;
|
||||||
|
for key in ctx
|
||||||
|
.secret_keys()
|
||||||
|
.context("Unable to fetch GPG secret keys")?
|
||||||
|
{
|
||||||
|
let Ok(key) = key else {
|
||||||
|
continue
|
||||||
|
};
|
||||||
|
|
||||||
|
if key.fingerprint().unwrap_or("") != self.fingerprint
|
||||||
|
&& key
|
||||||
|
.subkeys()
|
||||||
|
.all(|subkey| subkey.fingerprint().unwrap_or("") != self.fingerprint)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
found_key = Some(key);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let Some(found_key) = found_key else {
|
||||||
|
eprintln!("No key was found matching the provided fingerprint");
|
||||||
|
std::process::exit(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
// For some reason, when exporting secret keys, GPG will prompt for a password but then still
|
||||||
|
// export the key in an encrypted form. In order to prevent a password needing to be entered
|
||||||
|
// twice (one for GPG and one to decrypt the data), the password is prompted once here and stored.
|
||||||
|
let password = Arc::new(
|
||||||
|
Password::with_theme(&ColorfulTheme::default())
|
||||||
|
.with_prompt("Please enter the password for this key. This is both used to decrypt the OpenPGP key, and then to re-encrypt it in the aspm format")
|
||||||
|
.interact()
|
||||||
|
.context("Unable to prompt on stderr")?,
|
||||||
|
);
|
||||||
|
let password_ref = password.clone();
|
||||||
|
|
||||||
|
let data = ctx.with_passphrase_provider(
|
||||||
|
move |_: PassphraseRequest, out: &mut dyn Write| {
|
||||||
|
out.write_all(password_ref.as_bytes())?;
|
||||||
|
Ok(())
|
||||||
|
},
|
||||||
|
|ctx| {
|
||||||
|
let mut data: gpgme::Data<'_> =
|
||||||
|
gpgme::Data::new().context("Unable to create GPG data object")?;
|
||||||
|
let export_result = ctx.export(
|
||||||
|
[found_key.fingerprint().unwrap()],
|
||||||
|
gpgme::ExportMode::SECRET,
|
||||||
|
&mut data,
|
||||||
|
);
|
||||||
|
match export_result {
|
||||||
|
Ok(data) => data,
|
||||||
|
Err(e) => match e.code() {
|
||||||
|
11 => {
|
||||||
|
eprintln!("Wrong password");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
return Err::<Vec<u8>, anyhow::Error>(e.into())
|
||||||
|
.context("Unable to export secret key from GPG")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
data.try_into_bytes()
|
||||||
|
.ok_or(anyhow!("Unable to retrieve byte data from gpg data object"))
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let mut parsed = pgp::from_bytes_many(data.as_slice());
|
||||||
|
let parsed = parsed.nth(0).context("Invalid GPG data")?;
|
||||||
|
|
||||||
|
let Ok(key) = parsed else {
|
||||||
|
bail!("GPG data was unable to be parsed");
|
||||||
|
};
|
||||||
|
|
||||||
|
let key = key.into_secret();
|
||||||
|
let Some(uid) = key
|
||||||
|
.details
|
||||||
|
.users
|
||||||
|
.iter()
|
||||||
|
.find(|uid| uid.is_primary())
|
||||||
|
.or_else(|| {
|
||||||
|
if key.details.users.len() == 1 {
|
||||||
|
Some(&key.details.users[0])
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}) else {
|
||||||
|
eprintln!("Key being imported has no primary uid. This must be set, as it is used for the key alias.");
|
||||||
|
std::process::exit(1);
|
||||||
|
};
|
||||||
|
let (algorithm, public_params, secret_params) = {
|
||||||
|
if key
|
||||||
|
.primary_key
|
||||||
|
.fingerprint()
|
||||||
|
.iter()
|
||||||
|
.map(|byte| format!("{:02X}", byte))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("") == self.fingerprint
|
||||||
|
{
|
||||||
|
(
|
||||||
|
key.primary_key.algorithm(),
|
||||||
|
key.primary_key.public_params(),
|
||||||
|
key.primary_key.secret_params(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
let subkey = &key
|
||||||
|
.secret_subkeys
|
||||||
|
.iter()
|
||||||
|
.find(|subkey| {
|
||||||
|
subkey
|
||||||
|
.fingerprint()
|
||||||
|
.iter()
|
||||||
|
.map(|byte| format!("{:02X}", byte))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("") == self.fingerprint
|
||||||
|
})
|
||||||
|
.context("Unable to find subkey after parsing")?
|
||||||
|
.key;
|
||||||
|
(
|
||||||
|
subkey.algorithm(),
|
||||||
|
subkey.public_params(),
|
||||||
|
subkey.secret_params(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let unlocked = match secret_params {
|
||||||
|
SecretParams::Plain(params) => params.clone(),
|
||||||
|
SecretParams::Encrypted(params) => {
|
||||||
|
let password_ref = password.clone();
|
||||||
|
params
|
||||||
|
.unlock(move || password_ref.to_string(), algorithm, public_params)
|
||||||
|
.context("Unable to parse key secret params")?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let asp_key = match &unlocked {
|
||||||
|
PlainSecretParams::ECDSA(priv_mpi) => {
|
||||||
|
let public_key = match public_params {
|
||||||
|
PublicParams::ECDSA(public_params) => {
|
||||||
|
if let EcdsaPublicParams::P256 { key, p: _ } = public_params {
|
||||||
|
key
|
||||||
|
} else {
|
||||||
|
eprintln!("GPG key uses an unsupported elliptic curve type. Only Ed25519 and P-256 curves are supported.");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => bail!("Key had EdDSA secret params, but not EdDSA public params"),
|
||||||
|
};
|
||||||
|
asp::keys::AspKey::from_jwk(
|
||||||
|
josekit::jwk::Jwk::from_map({
|
||||||
|
let mut map = josekit::Map::new();
|
||||||
|
map.insert("kty".to_string(), "EC".into());
|
||||||
|
map.insert("crv".to_string(), "P-256".into());
|
||||||
|
map.insert(
|
||||||
|
"d".to_string(),
|
||||||
|
BASE64URL_NOPAD.encode(priv_mpi.as_bytes()).into(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let encoded = public_key.to_encoded_point(false);
|
||||||
|
let Coordinates::Uncompressed { x, y } = encoded.coordinates() else {
|
||||||
|
bail!("EC key coordinates were not uncompressed")
|
||||||
|
};
|
||||||
|
map.insert("x".to_string(), BASE64URL_NOPAD.encode(x.as_slice()).into());
|
||||||
|
map.insert("y".to_string(), BASE64URL_NOPAD.encode(y.as_slice()).into());
|
||||||
|
map
|
||||||
|
})
|
||||||
|
.context("Unable to construct JWK map")?,
|
||||||
|
)?
|
||||||
|
}
|
||||||
|
PlainSecretParams::EdDSA(priv_mpi) => {
|
||||||
|
let pub_mpi = match public_params {
|
||||||
|
PublicParams::EdDSA { curve, q: pub_mpi } => {
|
||||||
|
if let ECCCurve::Ed25519 = curve {
|
||||||
|
pub_mpi
|
||||||
|
} else {
|
||||||
|
eprintln!("GPG key uses an unsupported elliptic curve type. Only Ed25519 and P-256 curves are supported.");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => bail!("Key had EdDSA secret params, but not EdDSA public params"),
|
||||||
|
};
|
||||||
|
asp::keys::AspKey::from_jwk(
|
||||||
|
josekit::jwk::Jwk::from_map({
|
||||||
|
let mut map = josekit::Map::new();
|
||||||
|
map.insert("kty".to_string(), "OKP".into());
|
||||||
|
map.insert("crv".to_string(), "Ed25519".into());
|
||||||
|
map.insert(
|
||||||
|
"d".to_string(),
|
||||||
|
BASE64URL_NOPAD.encode(priv_mpi.as_bytes()).into(),
|
||||||
|
);
|
||||||
|
|
||||||
|
map.insert(
|
||||||
|
"x".to_string(),
|
||||||
|
BASE64URL_NOPAD.encode(pub_mpi.as_bytes()).into(),
|
||||||
|
);
|
||||||
|
|
||||||
|
map
|
||||||
|
})
|
||||||
|
.context("Unable to construct JWK map")?,
|
||||||
|
)?
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
eprintln!("GPG key is of unsupported type. Only Ed25519 and EC p-256 keys can be imported");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let argon_salt = SaltString::from_b64(
|
||||||
|
&BASE64_NOPAD.encode(asp_key.fingerprint.to_uppercase().as_bytes()),
|
||||||
|
)
|
||||||
|
.context("Unable to derive argon2 salt")?;
|
||||||
|
let argon2 = Argon2::default();
|
||||||
|
let hash = argon2
|
||||||
|
.hash_password(password.as_bytes(), &argon_salt)
|
||||||
|
.or(Err(anyhow!("Unable to derive encryption key")))?;
|
||||||
|
let aes_key = hash.hash.context("Unable to derive encryption key")?;
|
||||||
|
let aes_key = &aes_key.as_bytes()[0..32];
|
||||||
|
|
||||||
|
let encrypted = asp_key.export_encrypted(aes_key)?;
|
||||||
|
|
||||||
|
let entry = keys::ActiveModel {
|
||||||
|
fingerprint: ActiveValue::Set(asp_key.fingerprint.clone()),
|
||||||
|
key_type: ActiveValue::Set(asp_key.key_type.into()),
|
||||||
|
alias: ActiveValue::Set(format!("{uid}", uid = uid.id.id())),
|
||||||
|
encrypted: ActiveValue::Set(encrypted),
|
||||||
|
};
|
||||||
|
// Because GPGME's context object is not 'Send', normal .await can't be used here, so this just blocks as a workaround
|
||||||
|
let res = runtime
|
||||||
|
.block_on(async { keys::Entity::insert(entry).exec(&state.db).await })
|
||||||
|
.context("Unable to add key to database")?;
|
||||||
|
if res.last_insert_id != asp_key.fingerprint {
|
||||||
|
bail!("The key was unable to be saved to the database")
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,6 +2,8 @@ use clap::{Parser, Subcommand};
|
||||||
|
|
||||||
pub mod export;
|
pub mod export;
|
||||||
pub mod generate;
|
pub mod generate;
|
||||||
|
#[cfg(feature = "gpg-compat")]
|
||||||
|
pub mod import_gpg;
|
||||||
pub mod list;
|
pub mod list;
|
||||||
|
|
||||||
/// A subcommand to allow the management of keys, which can then be used to create, modify, or delete profiles.
|
/// A subcommand to allow the management of keys, which can then be used to create, modify, or delete profiles.
|
||||||
|
@ -16,4 +18,6 @@ pub enum KeysSubcommands {
|
||||||
Generate(generate::KeysGenerateCommand),
|
Generate(generate::KeysGenerateCommand),
|
||||||
List(list::KeysListCommand),
|
List(list::KeysListCommand),
|
||||||
Export(export::KeysExportCommand),
|
Export(export::KeysExportCommand),
|
||||||
|
#[cfg(feature = "gpg-compat")]
|
||||||
|
ImportGpg(import_gpg::KeysImportGpgCommand),
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,13 +2,19 @@ pub mod keys;
|
||||||
|
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use sea_orm::{ColumnTrait, Condition, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder};
|
use sea_orm::{ColumnTrait, Condition, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder};
|
||||||
|
use tokio::runtime::Runtime;
|
||||||
|
|
||||||
use crate::entities::keys::{Column as KeysColumn, Entity as KeysEntity, Model as KeysModel};
|
use crate::entities::keys::{Column as KeysColumn, Entity as KeysEntity, Model as KeysModel};
|
||||||
use crate::AspmState;
|
use crate::AspmState;
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
pub trait AspmSubcommand: Parser {
|
pub trait AspmSubcommand: Parser {
|
||||||
async fn execute(&self, state: AspmState) -> Result<(), anyhow::Error>;
|
async fn execute(&self, _state: AspmState) -> Result<(), anyhow::Error> {
|
||||||
|
panic!("Not implemented")
|
||||||
|
}
|
||||||
|
fn execute_sync(&self, _state: AspmState, _runtime: Runtime) -> Result<(), anyhow::Error> {
|
||||||
|
panic!("Not implemented")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
|
|
50
src/main.rs
50
src/main.rs
|
@ -43,6 +43,9 @@ struct AspmCommand {
|
||||||
/// 3. OS default
|
/// 3. OS default
|
||||||
#[arg(short, long, value_name = "FILE")]
|
#[arg(short, long, value_name = "FILE")]
|
||||||
data_dir: Option<PathBuf>,
|
data_dir: Option<PathBuf>,
|
||||||
|
/// If this flag is provided, errors will be returned extra verbose, for debugging purposes
|
||||||
|
#[arg(short, long)]
|
||||||
|
verbose: bool,
|
||||||
/// The subcommand to use
|
/// The subcommand to use
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
subcommand: AspmSubcommands,
|
subcommand: AspmSubcommands,
|
||||||
|
@ -77,16 +80,28 @@ pub enum AspmSubcommands {
|
||||||
Keys(commands::keys::KeysSubcommand),
|
Keys(commands::keys::KeysSubcommand),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
fn main() {
|
||||||
async fn main() {
|
|
||||||
if let Err(e) = cli().await {
|
|
||||||
eprintln!("An error occurred while running that command:\n{e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn cli() -> Result<(), anyhow::Error> {
|
|
||||||
let parsed = AspmCommand::parse();
|
let parsed = AspmCommand::parse();
|
||||||
|
let verbose = parsed.verbose;
|
||||||
|
|
||||||
|
if let Err(e) = cli(parsed) {
|
||||||
|
match verbose {
|
||||||
|
true => {
|
||||||
|
eprintln!("An error occurred while running that command:\n{e:?}");
|
||||||
|
}
|
||||||
|
false => eprintln!("An error occurred while running that command:\n{e}"),
|
||||||
|
}
|
||||||
|
std::process::exit(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cli(parsed: AspmCommand) -> Result<(), anyhow::Error> {
|
||||||
|
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let state = runtime.block_on(async {
|
||||||
// Check the data dir (read and write)
|
// Check the data dir (read and write)
|
||||||
let data_dir = parsed.data_dir.unwrap_or(
|
let data_dir = parsed.data_dir.unwrap_or(
|
||||||
std::env::var("ASPM_DATA_DIR").map(|s| s.into()).unwrap_or(
|
std::env::var("ASPM_DATA_DIR").map(|s| s.into()).unwrap_or(
|
||||||
|
@ -96,7 +111,6 @@ async fn cli() -> Result<(), anyhow::Error> {
|
||||||
);
|
);
|
||||||
std::fs::create_dir_all(&data_dir).or(Err(AspmError::DataDirCreate))?;
|
std::fs::create_dir_all(&data_dir).or(Err(AspmError::DataDirCreate))?;
|
||||||
std::fs::read_dir(&data_dir).or(Err(AspmError::DataDirRead))?;
|
std::fs::read_dir(&data_dir).or(Err(AspmError::DataDirRead))?;
|
||||||
|
|
||||||
// Construct the database in the dir
|
// Construct the database in the dir
|
||||||
let db = Database::connect(DATABASE_URL.replace("DB_PATH", &{
|
let db = Database::connect(DATABASE_URL.replace("DB_PATH", &{
|
||||||
let mut new = data_dir.clone();
|
let mut new = data_dir.clone();
|
||||||
|
@ -107,7 +121,6 @@ async fn cli() -> Result<(), anyhow::Error> {
|
||||||
}?))
|
}?))
|
||||||
.await
|
.await
|
||||||
.context("Unable to open database")?;
|
.context("Unable to open database")?;
|
||||||
|
|
||||||
let schema_manager = SchemaManager::new(&db);
|
let schema_manager = SchemaManager::new(&db);
|
||||||
Migrator::up(&db, None)
|
Migrator::up(&db, None)
|
||||||
.await
|
.await
|
||||||
|
@ -120,12 +133,23 @@ async fn cli() -> Result<(), anyhow::Error> {
|
||||||
// Make the state
|
// Make the state
|
||||||
let state = AspmState { data_dir, db };
|
let state = AspmState { data_dir, db };
|
||||||
|
|
||||||
|
Ok::<AspmState, anyhow::Error>(state)
|
||||||
|
})?;
|
||||||
|
|
||||||
// Call the subcommand
|
// Call the subcommand
|
||||||
match &parsed.subcommand {
|
match &parsed.subcommand {
|
||||||
AspmSubcommands::Keys(subcommand) => match &subcommand.subcommand {
|
AspmSubcommands::Keys(subcommand) => match &subcommand.subcommand {
|
||||||
KeysSubcommands::Generate(subcommand) => subcommand.execute(state).await,
|
KeysSubcommands::Generate(subcommand) => {
|
||||||
KeysSubcommands::List(subcommand) => subcommand.execute(state).await,
|
runtime.block_on(async { subcommand.execute(state).await })
|
||||||
KeysSubcommands::Export(subcommand) => subcommand.execute(state).await,
|
}
|
||||||
|
KeysSubcommands::List(subcommand) => {
|
||||||
|
runtime.block_on(async { subcommand.execute(state).await })
|
||||||
|
}
|
||||||
|
KeysSubcommands::Export(subcommand) => {
|
||||||
|
runtime.block_on(async { subcommand.execute(state).await })
|
||||||
|
}
|
||||||
|
#[cfg(feature = "gpg-compat")]
|
||||||
|
KeysSubcommands::ImportGpg(subcommand) => subcommand.execute_sync(state, runtime),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue