mirror of
https://codeberg.org/tyy/aspm
synced 2024-12-23 01:19:28 -07:00
Improve import command
This commit is contained in:
parent
756b97da8a
commit
a04e048f7c
10 changed files with 739 additions and 994 deletions
847
Cargo.lock
generated
847
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
@ -27,14 +27,13 @@ async-trait = "0.1.77"
|
||||||
tokio = "1.35.1"
|
tokio = "1.35.1"
|
||||||
clap-stdin = "0.4.0"
|
clap-stdin = "0.4.0"
|
||||||
gpgme = { version = "0.11.0", optional = true }
|
gpgme = { version = "0.11.0", optional = true }
|
||||||
pgp = { version = "0.10.2", optional = true }
|
sequoia-openpgp = { version = "1.18.0", optional = true }
|
||||||
josekit = { version = "0.8.5", optional = true }
|
josekit = { version = "0.8.5" }
|
||||||
elliptic-curve = { version = "0.13.8", optional = true }
|
|
||||||
aes-gcm = "0.10.3"
|
aes-gcm = "0.10.3"
|
||||||
migrations = { path = "crates/migrations" }
|
migrations = { path = "crates/migrations" }
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
gpg-compat = ["dep:gpgme", "dep:pgp", "dep:josekit", "dep:elliptic-curve"]
|
gpg-compat = ["dep:gpgme", "dep:sequoia-openpgp"]
|
||||||
default = ["gpg-compat"]
|
default = ["gpg-compat"]
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
|
|
|
@ -3,7 +3,7 @@ mod m_20242801_000002_create_profiles_table;
|
||||||
|
|
||||||
use sea_orm_migration::prelude::*;
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
pub use sea_orm_migration::{SchemaManager, MigratorTrait};
|
pub use sea_orm_migration::{MigratorTrait, SchemaManager};
|
||||||
|
|
||||||
pub struct Migrator;
|
pub struct Migrator;
|
||||||
|
|
||||||
|
@ -11,7 +11,7 @@ impl MigratorTrait for Migrator {
|
||||||
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
|
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
|
||||||
vec![
|
vec![
|
||||||
Box::new(m_20230701_000001_create_keys_table::Migration),
|
Box::new(m_20230701_000001_create_keys_table::Migration),
|
||||||
Box::new(m_20242801_000002_create_profiles_table::Migration)
|
Box::new(m_20242801_000002_create_profiles_table::Migration),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,7 +12,8 @@ impl MigrationName for Migration {
|
||||||
impl MigrationTrait for Migration {
|
impl MigrationTrait for Migration {
|
||||||
// Define how to apply this migration: Create the Keys table.
|
// Define how to apply this migration: Create the Keys table.
|
||||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
manager.create_table(
|
manager
|
||||||
|
.create_table(
|
||||||
Table::create()
|
Table::create()
|
||||||
.table(Claims::Table)
|
.table(Claims::Table)
|
||||||
.col(
|
.col(
|
||||||
|
@ -20,30 +21,24 @@ impl MigrationTrait for Migration {
|
||||||
.unsigned()
|
.unsigned()
|
||||||
.not_null()
|
.not_null()
|
||||||
.primary_key()
|
.primary_key()
|
||||||
.auto_increment()
|
.auto_increment(),
|
||||||
)
|
|
||||||
.col(
|
|
||||||
ColumnDef::new(Claims::Uri)
|
|
||||||
.string()
|
|
||||||
.not_null()
|
|
||||||
)
|
|
||||||
.col(
|
|
||||||
ColumnDef::new(Claims::Profile)
|
|
||||||
.string_len(26)
|
|
||||||
.not_null()
|
|
||||||
)
|
)
|
||||||
|
.col(ColumnDef::new(Claims::Uri).string().not_null())
|
||||||
|
.col(ColumnDef::new(Claims::Profile).string_len(26).not_null())
|
||||||
.foreign_key(
|
.foreign_key(
|
||||||
ForeignKey::create()
|
ForeignKey::create()
|
||||||
.name("fk-claims_profile-profiles_key")
|
.name("fk-claims_profile-profiles_key")
|
||||||
.from(Claims::Table, Claims::Profile)
|
.from(Claims::Table, Claims::Profile)
|
||||||
.to(Profiles::Table, Profiles::Key)
|
.to(Profiles::Table, Profiles::Key)
|
||||||
.on_update(ForeignKeyAction::Cascade)
|
.on_update(ForeignKeyAction::Cascade)
|
||||||
.on_delete(ForeignKeyAction::Cascade)
|
.on_delete(ForeignKeyAction::Cascade),
|
||||||
)
|
)
|
||||||
.to_owned()
|
.to_owned(),
|
||||||
).await?;
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
manager.create_table(
|
manager
|
||||||
|
.create_table(
|
||||||
Table::create()
|
Table::create()
|
||||||
.table(Profiles::Table)
|
.table(Profiles::Table)
|
||||||
.col(
|
.col(
|
||||||
|
@ -58,38 +53,19 @@ impl MigrationTrait for Migration {
|
||||||
.from(Profiles::Table, Profiles::Key)
|
.from(Profiles::Table, Profiles::Key)
|
||||||
.to(
|
.to(
|
||||||
super::m_20230701_000001_create_keys_table::Keys::Table,
|
super::m_20230701_000001_create_keys_table::Keys::Table,
|
||||||
super::m_20230701_000001_create_keys_table::Keys::Fingerprint
|
super::m_20230701_000001_create_keys_table::Keys::Fingerprint,
|
||||||
)
|
)
|
||||||
.on_update(ForeignKeyAction::Cascade)
|
.on_update(ForeignKeyAction::Cascade)
|
||||||
.on_delete(ForeignKeyAction::Cascade)
|
.on_delete(ForeignKeyAction::Cascade),
|
||||||
)
|
)
|
||||||
.col(
|
.col(ColumnDef::new(Profiles::Name).string().not_null())
|
||||||
ColumnDef::new(Profiles::Name)
|
.col(ColumnDef::new(Profiles::Description).string().null())
|
||||||
.string()
|
.col(ColumnDef::new(Profiles::AvatarUrl).string().null())
|
||||||
.not_null()
|
.col(ColumnDef::new(Profiles::Email).string().null())
|
||||||
|
.col(ColumnDef::new(Profiles::Color).string_len(7).null())
|
||||||
|
.to_owned(),
|
||||||
)
|
)
|
||||||
.col(
|
.await?;
|
||||||
ColumnDef::new(Profiles::Description)
|
|
||||||
.string()
|
|
||||||
.null()
|
|
||||||
)
|
|
||||||
.col(
|
|
||||||
ColumnDef::new(Profiles::AvatarUrl)
|
|
||||||
.string()
|
|
||||||
.null()
|
|
||||||
)
|
|
||||||
.col(
|
|
||||||
ColumnDef::new(Profiles::Email)
|
|
||||||
.string()
|
|
||||||
.null()
|
|
||||||
)
|
|
||||||
.col(
|
|
||||||
ColumnDef::new(Profiles::Color)
|
|
||||||
.string_len(7)
|
|
||||||
.null()
|
|
||||||
)
|
|
||||||
.to_owned()
|
|
||||||
).await?;
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
290
src/commands/keys/import.rs
Normal file
290
src/commands/keys/import.rs
Normal file
|
@ -0,0 +1,290 @@
|
||||||
|
use anyhow::{anyhow, bail, Context};
|
||||||
|
use argon2::{password_hash::SaltString, Argon2, PasswordHasher};
|
||||||
|
use asp::keys::AspKey;
|
||||||
|
use clap::{Parser, ValueEnum};
|
||||||
|
use data_encoding::{BASE64URL_NOPAD, BASE64_NOPAD};
|
||||||
|
use dialoguer::{theme::ColorfulTheme, Input, Password};
|
||||||
|
|
||||||
|
use aes_gcm::{
|
||||||
|
aead::{Aead, KeyInit},
|
||||||
|
Aes256Gcm, Key,
|
||||||
|
};
|
||||||
|
use indoc::printdoc;
|
||||||
|
use josekit::jwk::Jwk;
|
||||||
|
use sea_orm::{ActiveValue, EntityTrait};
|
||||||
|
|
||||||
|
use crate::{commands::AspmSubcommand, entities::keys};
|
||||||
|
|
||||||
|
#[derive(ValueEnum, Debug, Clone)]
|
||||||
|
pub enum KeyImportFormat {
|
||||||
|
// An unencrypted raw JSON Web Key
|
||||||
|
Jwk,
|
||||||
|
// Imports a PGP key from a local GPG store
|
||||||
|
#[cfg(feature = "gpg-compat")]
|
||||||
|
GPG,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Imports an ASP from raw JWK format. This only will import JWKs that have supported curves.
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
pub struct KeysImportCommand {
|
||||||
|
/// The format of key to import
|
||||||
|
format: KeyImportFormat,
|
||||||
|
/// The key to import, as a file or "-" for stdin. This must be a valid JWK
|
||||||
|
key: String,
|
||||||
|
/// The alias of the key to import. This can be anything, and it can also be omitted to prompt interactively. This has no purpose other than providing a way to nicely name keys, rather than having to remember a fingerprint.
|
||||||
|
#[arg(short = 'n', long)]
|
||||||
|
key_alias: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl AspmSubcommand for KeysImportCommand {
|
||||||
|
async fn execute(&self, state: crate::AspmState) -> Result<(), anyhow::Error> {
|
||||||
|
let alias = if let Some(alias) = &self.key_alias {
|
||||||
|
alias.clone()
|
||||||
|
} else {
|
||||||
|
Input::with_theme(&ColorfulTheme::default())
|
||||||
|
.with_prompt("Please enter an alias to give to this key")
|
||||||
|
.allow_empty(false)
|
||||||
|
.interact()
|
||||||
|
.context("Unable to prompt on stderr")?
|
||||||
|
};
|
||||||
|
|
||||||
|
let key_password = std::env::var("KEY_PASSWORD").or_else(|_| {
|
||||||
|
Password::with_theme(&ColorfulTheme::default())
|
||||||
|
.with_prompt("Please enter a password to store and encrypt the key with")
|
||||||
|
.with_confirmation(
|
||||||
|
"Please confirm the password",
|
||||||
|
"The two inputs did not match!",
|
||||||
|
)
|
||||||
|
.interact()
|
||||||
|
.context("Unable to prompt on stderr")
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let asp_key = match self.format {
|
||||||
|
KeyImportFormat::Jwk => AspKey::from_jwk(
|
||||||
|
Jwk::from_bytes(self.key.as_bytes()).context("Unable to parse provided JWK")?,
|
||||||
|
)
|
||||||
|
.context("Unable to convert parsed JWK to an AspKey")?,
|
||||||
|
#[cfg(feature = "gpg-compat")]
|
||||||
|
KeyImportFormat::GPG => {
|
||||||
|
use gpgme::{Context as GpgContext, PassphraseRequest};
|
||||||
|
use sequoia_openpgp::{crypto::mpi, packet::key, parse::Parse, types::Curve};
|
||||||
|
use std::{io::Write, sync::Arc};
|
||||||
|
|
||||||
|
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.key
|
||||||
|
&& key
|
||||||
|
.subkeys()
|
||||||
|
.all(|subkey| subkey.fingerprint().unwrap_or("") != self.key)
|
||||||
|
{
|
||||||
|
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 used to decrypt the OpenPGP key retrieved from GnuPG")
|
||||||
|
.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 parsed =
|
||||||
|
sequoia_openpgp::Cert::from_bytes(&data).context("Unable to parse PGP key")?;
|
||||||
|
|
||||||
|
let key = parsed
|
||||||
|
.keys()
|
||||||
|
.find(|k| k.fingerprint().to_hex() == self.key)
|
||||||
|
.context("Unable to find key in certificate")?
|
||||||
|
.parts_into_secret()
|
||||||
|
.context("Key did not have secret data")?
|
||||||
|
.key()
|
||||||
|
.clone()
|
||||||
|
.decrypt_secret(&password.clone().as_bytes().into())
|
||||||
|
.context("Unable to decrypt PGP key")?;
|
||||||
|
|
||||||
|
let public_part = key.mpis();
|
||||||
|
|
||||||
|
let (curve, public, secret) = match key.secret() {
|
||||||
|
key::SecretKeyMaterial::Unencrypted(data) => {
|
||||||
|
data.map(|secret_material| {
|
||||||
|
let (curve, public, private) = match secret_material {
|
||||||
|
mpi::SecretKeyMaterial::ECDSA { scalar } => {
|
||||||
|
let mpi::PublicKey::ECDSA {
|
||||||
|
curve,
|
||||||
|
q: public_part,
|
||||||
|
} = public_part
|
||||||
|
else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
|
||||||
|
(curve, public_part, scalar)
|
||||||
|
},
|
||||||
|
mpi::SecretKeyMaterial::EdDSA { scalar } => {
|
||||||
|
let mpi::PublicKey::EdDSA {
|
||||||
|
curve,
|
||||||
|
q: public_part,
|
||||||
|
} = public_part
|
||||||
|
else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
|
||||||
|
(curve, public_part, scalar)
|
||||||
|
},
|
||||||
|
_ => bail!("Invalid PGP key type, must be either P-256 or Ed25519")
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
curve.clone(),
|
||||||
|
public.clone(),
|
||||||
|
private.clone()
|
||||||
|
))
|
||||||
|
})?
|
||||||
|
}
|
||||||
|
_ => unreachable!(),
|
||||||
|
};
|
||||||
|
|
||||||
|
AspKey::from_jwk(
|
||||||
|
Jwk::from_map({
|
||||||
|
let mut map = josekit::Map::new();
|
||||||
|
|
||||||
|
match curve {
|
||||||
|
Curve::Ed25519 => {
|
||||||
|
map.insert("kty".to_string(), "OKP".into());
|
||||||
|
map.insert("crv".to_string(), "Ed25519".into());
|
||||||
|
map.insert(
|
||||||
|
"d".to_string(),
|
||||||
|
BASE64URL_NOPAD.encode(secret.value()).into(),
|
||||||
|
);
|
||||||
|
|
||||||
|
map.insert(
|
||||||
|
"x".to_string(),
|
||||||
|
BASE64URL_NOPAD.encode(public.value()).into(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
Curve::NistP256 => {
|
||||||
|
map.insert("kty".to_string(), "EC".into());
|
||||||
|
map.insert("crv".to_string(), "P-256".into());
|
||||||
|
map.insert(
|
||||||
|
"d".to_string(),
|
||||||
|
BASE64URL_NOPAD.encode(secret.value()).into(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let (x, y) = public.decode_point(&curve).context("Unable to decode p256 public point")?;
|
||||||
|
|
||||||
|
map.insert(
|
||||||
|
"x".to_string(),
|
||||||
|
BASE64URL_NOPAD.encode(x).into(),
|
||||||
|
);
|
||||||
|
map.insert(
|
||||||
|
"y".to_string(),
|
||||||
|
BASE64URL_NOPAD.encode(y).into(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
_ => bail!("Invalid PGP key type, only Ed25519 and NIST P256 are supported by ASPs.")
|
||||||
|
}
|
||||||
|
|
||||||
|
map
|
||||||
|
}).context("Unable to construct Jwk representation of PGP key")?
|
||||||
|
)?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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(key_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 key = Key::<Aes256Gcm>::from_slice(aes_key);
|
||||||
|
let Ok(cipher_text) = Aes256Gcm::new(&key)
|
||||||
|
.encrypt((&aes_key[0..12]).into(), asp_key.jwk.to_string().as_bytes())
|
||||||
|
else {
|
||||||
|
bail!("Failure encrypting key")
|
||||||
|
};
|
||||||
|
|
||||||
|
// Write to db
|
||||||
|
let entry = keys::ActiveModel {
|
||||||
|
fingerprint: ActiveValue::Set(asp_key.fingerprint.clone()),
|
||||||
|
key_type: ActiveValue::Set(asp_key.key_type.clone().into()),
|
||||||
|
alias: ActiveValue::Set(alias),
|
||||||
|
cipher_text: ActiveValue::Set(cipher_text),
|
||||||
|
};
|
||||||
|
let res = 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")
|
||||||
|
}
|
||||||
|
|
||||||
|
printdoc! {
|
||||||
|
"
|
||||||
|
Successfully imported key!
|
||||||
|
Fingerprint: {fpr}
|
||||||
|
Type: {type:?}
|
||||||
|
",
|
||||||
|
fpr = asp_key.fingerprint,
|
||||||
|
r#type = asp_key.key_type
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,291 +0,0 @@
|
||||||
#![cfg(feature = "gpg-compat")]
|
|
||||||
|
|
||||||
use std::{io::Write, sync::Arc};
|
|
||||||
|
|
||||||
use aes_gcm::{
|
|
||||||
aead::{Aead, KeyInit},
|
|
||||||
Aes256Gcm, Key,
|
|
||||||
};
|
|
||||||
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 indoc::printdoc;
|
|
||||||
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.next().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 key = Key::<Aes256Gcm>::from_slice(aes_key);
|
|
||||||
let Ok(cipher_text) = Aes256Gcm::new(&key)
|
|
||||||
.encrypt((&aes_key[0..12]).into(), asp_key.jwk.to_string().as_bytes())
|
|
||||||
else {
|
|
||||||
bail!("Failure encrypting key")
|
|
||||||
};
|
|
||||||
|
|
||||||
let entry = keys::ActiveModel {
|
|
||||||
fingerprint: ActiveValue::Set(asp_key.fingerprint.clone()),
|
|
||||||
key_type: ActiveValue::Set(asp_key.key_type.clone().into()),
|
|
||||||
alias: ActiveValue::Set(format!("{uid}", uid = uid.id.id())),
|
|
||||||
cipher_text: ActiveValue::Set(cipher_text),
|
|
||||||
};
|
|
||||||
// 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(keys::Entity::insert(entry).exec(&state.db))
|
|
||||||
.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")
|
|
||||||
}
|
|
||||||
|
|
||||||
printdoc! {
|
|
||||||
"
|
|
||||||
Successfully imported key!
|
|
||||||
ASP Fingerprint: {fpr}
|
|
||||||
Type: {type:?}
|
|
||||||
",
|
|
||||||
fpr = asp_key.fingerprint,
|
|
||||||
r#type = asp_key.key_type
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,116 +0,0 @@
|
||||||
use std::io::Read;
|
|
||||||
|
|
||||||
use anyhow::{anyhow, bail, Context};
|
|
||||||
use argon2::{password_hash::SaltString, Argon2, PasswordHasher};
|
|
||||||
use asp::keys::AspKey;
|
|
||||||
use clap::{Parser, ValueEnum};
|
|
||||||
use clap_stdin::FileOrStdin;
|
|
||||||
use data_encoding::BASE64_NOPAD;
|
|
||||||
use dialoguer::{theme::ColorfulTheme, Input, Password};
|
|
||||||
|
|
||||||
use aes_gcm::{
|
|
||||||
aead::{Aead, KeyInit},
|
|
||||||
Aes256Gcm, Key,
|
|
||||||
};
|
|
||||||
use indoc::printdoc;
|
|
||||||
use josekit::jwk::Jwk;
|
|
||||||
use sea_orm::{ActiveValue, EntityTrait};
|
|
||||||
|
|
||||||
use crate::{commands::AspmSubcommand, entities::keys};
|
|
||||||
|
|
||||||
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Debug)]
|
|
||||||
pub enum KeyGenerationType {
|
|
||||||
Ed25519,
|
|
||||||
ES256,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Imports an ASP from raw JWK format. This only will import JWKs that have supported curves.
|
|
||||||
#[derive(Parser, Debug)]
|
|
||||||
pub struct KeysImportJwkCommand {
|
|
||||||
/// The key to import, as a file or "-" for stdin. This must be a valid JWK
|
|
||||||
key: FileOrStdin,
|
|
||||||
/// The alias of the key to import. This can be anything, and it can also be omitted to prompt interactively. This has no purpose other than providing a way to nicely name keys, rather than having to remember a fingerprint.
|
|
||||||
#[arg(short = 'n', long)]
|
|
||||||
key_alias: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
|
||||||
impl AspmSubcommand for KeysImportJwkCommand {
|
|
||||||
async fn execute(&self, state: crate::AspmState) -> Result<(), anyhow::Error> {
|
|
||||||
let alias = if let Some(alias) = &self.key_alias {
|
|
||||||
alias.clone()
|
|
||||||
} else {
|
|
||||||
Input::with_theme(&ColorfulTheme::default())
|
|
||||||
.with_prompt("Please enter an alias to give to this key")
|
|
||||||
.allow_empty(false)
|
|
||||||
.interact()
|
|
||||||
.context("Unable to prompt on stderr")?
|
|
||||||
};
|
|
||||||
|
|
||||||
let key_password = std::env::var("KEY_PASSWORD").or_else(|_| {
|
|
||||||
Password::with_theme(&ColorfulTheme::default())
|
|
||||||
.with_prompt("Please enter a password to store and encrypt the key with")
|
|
||||||
.with_confirmation(
|
|
||||||
"Please confirm the password",
|
|
||||||
"The two inputs did not match!",
|
|
||||||
)
|
|
||||||
.interact()
|
|
||||||
.context("Unable to prompt on stderr")
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let asp_key = AspKey::from_jwk(
|
|
||||||
Jwk::from_bytes({
|
|
||||||
let mut buf = Vec::new();
|
|
||||||
self.key.into_reader()?.read_to_end(&mut buf)?;
|
|
||||||
buf
|
|
||||||
})
|
|
||||||
.context("Unable to parse provided JWK")?,
|
|
||||||
)
|
|
||||||
.context("Unable to convert parsed JWK to an AspKey")?;
|
|
||||||
|
|
||||||
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(key_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 key = Key::<Aes256Gcm>::from_slice(aes_key);
|
|
||||||
let Ok(cipher_text) = Aes256Gcm::new(&key)
|
|
||||||
.encrypt((&aes_key[0..12]).into(), asp_key.jwk.to_string().as_bytes())
|
|
||||||
else {
|
|
||||||
bail!("Failure encrypting key")
|
|
||||||
};
|
|
||||||
|
|
||||||
// Write to db
|
|
||||||
let entry = keys::ActiveModel {
|
|
||||||
fingerprint: ActiveValue::Set(asp_key.fingerprint.clone()),
|
|
||||||
key_type: ActiveValue::Set(asp_key.key_type.clone().into()),
|
|
||||||
alias: ActiveValue::Set(alias),
|
|
||||||
cipher_text: ActiveValue::Set(cipher_text),
|
|
||||||
};
|
|
||||||
let res = 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")
|
|
||||||
}
|
|
||||||
|
|
||||||
printdoc! {
|
|
||||||
"
|
|
||||||
Successfully imported key!
|
|
||||||
Fingerprint: {fpr}
|
|
||||||
Type: {type:?}
|
|
||||||
",
|
|
||||||
fpr = asp_key.fingerprint,
|
|
||||||
r#type = asp_key.key_type
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,18 +0,0 @@
|
||||||
use clap::{Parser, Subcommand};
|
|
||||||
|
|
||||||
#[cfg(feature = "gpg-compat")]
|
|
||||||
mod gpg;
|
|
||||||
mod jwk;
|
|
||||||
|
|
||||||
/// A subcommand to allow the management of keys, which can then be used to create, modify, or delete profiles.
|
|
||||||
#[derive(Parser)]
|
|
||||||
pub struct KeysImportCommand {
|
|
||||||
#[command(subcommand)]
|
|
||||||
pub subcommand: KeysImportSubcommands,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
|
||||||
pub enum KeysImportSubcommands {
|
|
||||||
Gpg(gpg::KeysImportGpgCommand),
|
|
||||||
Jwk(jwk::KeysImportJwkCommand),
|
|
||||||
}
|
|
12
src/main.rs
12
src/main.rs
|
@ -5,13 +5,10 @@ use anstyle::{AnsiColor, Color as AnstyleColor, Style as Anstyle};
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use app_dirs2::{AppDataType, AppInfo};
|
use app_dirs2::{AppDataType, AppInfo};
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use commands::{
|
use commands::{keys::KeysSubcommands, AspmSubcommand};
|
||||||
keys::{import::KeysImportSubcommands, KeysSubcommands},
|
use migrations::{Migrator, MigratorTrait, SchemaManager};
|
||||||
AspmSubcommand,
|
|
||||||
};
|
|
||||||
use sea_orm::{Database, DatabaseConnection};
|
use sea_orm::{Database, DatabaseConnection};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use migrations::{Migrator, SchemaManager, MigratorTrait};
|
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
@ -151,10 +148,7 @@ fn cli(parsed: AspmCommand) -> Result<(), anyhow::Error> {
|
||||||
KeysSubcommands::List(subcommand) => subcommand.execute_sync(state, runtime),
|
KeysSubcommands::List(subcommand) => subcommand.execute_sync(state, runtime),
|
||||||
KeysSubcommands::Export(subcommand) => subcommand.execute_sync(state, runtime),
|
KeysSubcommands::Export(subcommand) => subcommand.execute_sync(state, runtime),
|
||||||
KeysSubcommands::Delete(subcommand) => subcommand.execute_sync(state, runtime),
|
KeysSubcommands::Delete(subcommand) => subcommand.execute_sync(state, runtime),
|
||||||
KeysSubcommands::Import(subcommand) => match &subcommand.subcommand {
|
KeysSubcommands::Import(subcommand) => subcommand.execute_sync(state, runtime),
|
||||||
KeysImportSubcommands::Gpg(subcommand) => subcommand.execute_sync(state, runtime),
|
|
||||||
KeysImportSubcommands::Jwk(subcommand) => subcommand.execute_sync(state, runtime),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue