1
0
Fork 0
mirror of https://codeberg.org/tyy/aspm synced 2024-12-22 21:49:28 -07:00

Add feature-gated import-gpg subcommand to cli

This commit is contained in:
Tyler Beckman 2023-07-31 01:32:34 -06:00
parent 765b20a90e
commit 5d982897a2
Signed by: Ty
GPG key ID: 2813440C772555A4
7 changed files with 1263 additions and 42 deletions

View file

@ -3,14 +3,18 @@
"anstyle",
"Aspe",
"Aspm",
"gpgme",
"josekit",
"keygrip",
"keywrap",
"NOPAD",
"Pinentry",
"PKCS",
"Pkey",
"printdoc",
"subkey",
"subkeys",
"userid",
"writedoc"
]
}

907
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -27,6 +27,14 @@ sea-orm-migration = "0.11.3"
async-trait = "0.1.68"
tokio = "1.29.1"
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]
strip = true

View 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(())
}
}

View file

@ -2,6 +2,8 @@ use clap::{Parser, Subcommand};
pub mod export;
pub mod generate;
#[cfg(feature = "gpg-compat")]
pub mod import_gpg;
pub mod list;
/// 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),
List(list::KeysListCommand),
Export(export::KeysExportCommand),
#[cfg(feature = "gpg-compat")]
ImportGpg(import_gpg::KeysImportGpgCommand),
}

View file

@ -2,13 +2,19 @@ pub mod keys;
use clap::Parser;
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::AspmState;
#[async_trait::async_trait]
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]

View file

@ -43,6 +43,9 @@ struct AspmCommand {
/// 3. OS default
#[arg(short, long, value_name = "FILE")]
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
#[command(subcommand)]
subcommand: AspmSubcommands,
@ -77,55 +80,76 @@ pub enum AspmSubcommands {
Keys(commands::keys::KeysSubcommand),
}
#[tokio::main]
async fn main() {
if let Err(e) = cli().await {
eprintln!("An error occurred while running that command:\n{e}");
fn main() {
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);
}
}
async fn cli() -> Result<(), anyhow::Error> {
let parsed = AspmCommand::parse();
fn cli(parsed: AspmCommand) -> Result<(), anyhow::Error> {
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.unwrap();
// Check the data dir (read and write)
let data_dir = parsed.data_dir.unwrap_or(
std::env::var("ASPM_DATA_DIR").map(|s| s.into()).unwrap_or(
app_dirs2::get_app_root(AppDataType::UserData, &APP_INFO)
.map_err(|e| AspmError::Unknown(e.into()))?,
),
);
std::fs::create_dir_all(&data_dir).or(Err(AspmError::DataDirCreate))?;
std::fs::read_dir(&data_dir).or(Err(AspmError::DataDirRead))?;
// Construct the database in the dir
let db = Database::connect(DATABASE_URL.replace("DB_PATH", &{
let mut new = data_dir.clone();
new.push("db.sqlite");
new.to_str()
.context("Unable to parse database path into string")
.map(|s| s.to_string())
}?))
.await
.context("Unable to open database")?;
let schema_manager = SchemaManager::new(&db);
Migrator::up(&db, None)
let state = runtime.block_on(async {
// Check the data dir (read and write)
let data_dir = parsed.data_dir.unwrap_or(
std::env::var("ASPM_DATA_DIR").map(|s| s.into()).unwrap_or(
app_dirs2::get_app_root(AppDataType::UserData, &APP_INFO)
.map_err(|e| AspmError::Unknown(e.into()))?,
),
);
std::fs::create_dir_all(&data_dir).or(Err(AspmError::DataDirCreate))?;
std::fs::read_dir(&data_dir).or(Err(AspmError::DataDirRead))?;
// Construct the database in the dir
let db = Database::connect(DATABASE_URL.replace("DB_PATH", &{
let mut new = data_dir.clone();
new.push("db.sqlite");
new.to_str()
.context("Unable to parse database path into string")
.map(|s| s.to_string())
}?))
.await
.context("Unable to migrate database")?;
assert!(schema_manager
.has_table("keys")
.await
.context("Unable to check database for keys table")?);
.context("Unable to open database")?;
let schema_manager = SchemaManager::new(&db);
Migrator::up(&db, None)
.await
.context("Unable to migrate database")?;
assert!(schema_manager
.has_table("keys")
.await
.context("Unable to check database for keys table")?);
// Make the state
let state = AspmState { data_dir, db };
// Make the state
let state = AspmState { data_dir, db };
Ok::<AspmState, anyhow::Error>(state)
})?;
// Call the subcommand
match &parsed.subcommand {
AspmSubcommands::Keys(subcommand) => match &subcommand.subcommand {
KeysSubcommands::Generate(subcommand) => subcommand.execute(state).await,
KeysSubcommands::List(subcommand) => subcommand.execute(state).await,
KeysSubcommands::Export(subcommand) => subcommand.execute(state).await,
KeysSubcommands::Generate(subcommand) => {
runtime.block_on(async { 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),
},
}
}