diff --git a/crates/asp/src/keys/mod.rs b/crates/asp/src/keys/mod.rs index c79199c..7e2d796 100644 --- a/crates/asp/src/keys/mod.rs +++ b/crates/asp/src/keys/mod.rs @@ -24,6 +24,15 @@ pub enum AspKeyType { ES256, } +impl std::fmt::Display for AspKeyType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::ES256 => "P-256", + Self::Ed25519 => "Ed25519", + }) + } +} + impl From for i32 { fn from(value: AspKeyType) -> Self { match value { diff --git a/crates/migrations/src/m_20242801_000002_create_profiles_table.rs b/crates/migrations/src/m_20242801_000002_create_profiles_table.rs index 61e0a87..1b3feaa 100644 --- a/crates/migrations/src/m_20242801_000002_create_profiles_table.rs +++ b/crates/migrations/src/m_20242801_000002_create_profiles_table.rs @@ -4,7 +4,7 @@ pub struct Migration; impl MigrationName for Migration { fn name(&self) -> &str { - "m_20242801_000002_create_profiles_table.rs" + "m_20242801_000002_create_profiles_table" } } @@ -58,6 +58,7 @@ impl MigrationTrait for Migration { .on_update(ForeignKeyAction::Cascade) .on_delete(ForeignKeyAction::Cascade), ) + .col(ColumnDef::new(Profiles::Alias).string().not_null()) .col(ColumnDef::new(Profiles::Name).string().not_null()) .col(ColumnDef::new(Profiles::Description).string().null()) .col(ColumnDef::new(Profiles::AvatarUrl).string().null()) @@ -88,6 +89,7 @@ impl MigrationTrait for Migration { pub enum Profiles { Table, Key, + Alias, Name, Description, AvatarUrl, diff --git a/src/commands/profiles/create.rs b/src/commands/profiles/create.rs index 9635229..37ce8b1 100644 --- a/src/commands/profiles/create.rs +++ b/src/commands/profiles/create.rs @@ -1,11 +1,19 @@ use std::str::FromStr; use anyhow::Context; -use asp::{profiles::*, utils::jwt::AspJwsType}; +use asp::{keys::AspKeyType, profiles::*, utils::jwt::AspJwsType}; use clap::Parser; -use dialoguer::{theme::ColorfulTheme, Input}; +use dialoguer::{theme::ColorfulTheme, Input, Select}; +use sea_orm::{ActiveValue, EntityTrait}; -use crate::commands::AspmSubcommand; +use crate::{ + commands::AspmSubcommand, + entities::{ + claims::{self, Entity as Claims}, + keys::Entity as Keys, + profiles::{self, Entity as Profiles}, + }, +}; /// Creates a new profile containing claims and other metadata. /// A key is needed to attach this profile to, but multiple profiles can be created for the same key. @@ -32,6 +40,31 @@ impl AspmSubcommand for ProfilesCreateCommand { .context("Unable to prompt on stderr")? }; + let keys = Keys::find() + .all(&state.db) + .await + .context("Unable to query keys from database")?; + + let key_index = Select::with_theme(&theme) + .with_prompt("Please choose the key to attach this profile to") + .items( + keys.iter() + .map(|entry| { + Ok(format!( + "{alias} - {fingerprint} ({key_type})", + alias = entry.alias, + fingerprint = entry.fingerprint, + key_type = AspKeyType::try_from(entry.key_type) + .context("Unable to parse key type from database")? + )) + }) + .collect::, anyhow::Error>>()? + .as_slice(), + ) + .default(0) + .interact() + .context("Unable to prompt for key on stderr")?; + let profile = AriadneSignatureProfile { version: 0, r#type: AspJwsType::Profile, @@ -85,7 +118,30 @@ impl AspmSubcommand for ProfilesCreateCommand { .map_or(Ok(None), |hex: String| HexColor::from_str(&hex).context("Unable to parse color code").map(Some))?, }; - dbg!(profile); + // Save the profile information to the database + Profiles::insert(profiles::ActiveModel { + alias: ActiveValue::Set(alias), + name: ActiveValue::Set(profile.name), + description: ActiveValue::Set(profile.description), + email: ActiveValue::Set(profile.email.map(|email| email.to_string())), + avatar_url: ActiveValue::Set( + profile.avatar_url.map(|avatar_url| avatar_url.to_string()), + ), + color: ActiveValue::Set(profile.color.map(|color| color.to_string())), + key: ActiveValue::Set(keys[key_index].fingerprint.clone()), + }) + .exec(&state.db) + .await + .context("Unable to insert profile into database")?; + // Add all of the claims and link them to the profile that was just created + Claims::insert_many(profile.claims.into_iter().map(|claim| claims::ActiveModel { + profile: ActiveValue::Set(keys[key_index].fingerprint.clone()), + uri: ActiveValue::Set(claim), + ..Default::default() + })) + .exec(&state.db) + .await + .context("Unable to insert claims into database")?; Ok(()) } diff --git a/src/commands/profiles/export.rs b/src/commands/profiles/export.rs new file mode 100644 index 0000000..3440a08 --- /dev/null +++ b/src/commands/profiles/export.rs @@ -0,0 +1,101 @@ +use aes_gcm::{aead::Aead, Aes256Gcm, Key, KeyInit as _}; +use anyhow::{anyhow, bail, Context}; +use argon2::{password_hash::SaltString, Argon2, PasswordHasher as _}; +use asp::{ + keys::AspKey, + profiles::{AriadneSignatureProfile, Email, HexColor, Url}, + utils::jwt::{AspJwsType, JwtSerialize}, +}; +use clap::Parser; +use data_encoding::BASE64_NOPAD; +use dialoguer::{theme::ColorfulTheme, Password}; +use josekit::jwk::Jwk; +use sea_orm::{EntityTrait, ModelTrait}; + +use crate::{commands::AspmSubcommand, entities::prelude::*}; + +/// Exports a profile in JWT format +#[derive(Parser, Debug)] +pub struct ProfilesExportCommand { + /// The fingerprint of the profile to export + fingerprint: String, +} + +#[async_trait::async_trait] +impl AspmSubcommand for ProfilesExportCommand { + async fn execute(&self, state: crate::AspmState) -> Result<(), anyhow::Error> { + let Some(profile) = Profiles::find_by_id(&self.fingerprint) + .one(&state.db) + .await + .context("Unable to query for profiles with fingerprint")? + else { + bail!("No profile found with that fingerprint"); + }; + + let claims = profile + .find_related(Claims) + .all(&state.db) + .await + .context("Unable to query for related claims")?; + + let Some(key) = profile + .find_related(Keys) + .one(&state.db) + .await + .context("Unable to query database for key")? + else { + bail!("The key associated with the queried profile could not be found") + }; + + let asp = AriadneSignatureProfile { + version: 0, + r#type: AspJwsType::Profile, + name: profile.name, + description: profile.description, + avatar_url: profile + .avatar_url + .map_or(Ok(None), |string| Url::parse(&string).map(Some)) + .context("Unable to parse avatar URL from DB")?, + color: profile + .color + .map_or(Ok(None), |string| HexColor::parse_rgb(&string).map(Some)) + .context("Unable to parse color from DB")?, + email: profile + .email + .map_or(Ok(None), |string| Email::from_string(string).map(Some)) + .context("Unable to parse email from DB")?, + claims: claims.into_iter().map(|claim| claim.uri).collect(), + }; + + let key_password = std::env::var("KEY_PASSWORD").or_else(|_| { + Password::with_theme(&ColorfulTheme::default()) + .with_prompt("Please enter a password to decrypt the key with") + .interact() + .context("Unable to prompt on stderr") + })?; + + let argon_salt = + SaltString::from_b64(&BASE64_NOPAD.encode(key.fingerprint.to_uppercase().as_bytes())) + .context("Unable to decode 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 Ok(decrypted) = Aes256Gcm::new(Key::::from_slice(aes_key)) + .decrypt((&aes_key[0..12]).into(), key.cipher_text.as_slice()) + else { + bail!("Unable to decrypt key from database (wrong password?)") + }; + + let encoded = asp + .encode_and_sign(&AspKey::from_jwk(Jwk::from_bytes(decrypted)?)?) + .context("Unable to encode the profile as a JWT and sign it")?; + + println!("{encoded}"); + + Ok(()) + } +} diff --git a/src/commands/profiles/mod.rs b/src/commands/profiles/mod.rs index 7594c5e..bfcbf7a 100644 --- a/src/commands/profiles/mod.rs +++ b/src/commands/profiles/mod.rs @@ -1,6 +1,7 @@ use clap::{Parser, Subcommand}; pub mod create; +pub mod export; /// A subcommand to allow the management of keys, which can then be used to create, modify, or delete profiles. #[derive(Parser)] @@ -12,4 +13,5 @@ pub struct ProfilesSubcommand { #[derive(Subcommand)] pub enum ProfilesSubcommands { Create(create::ProfilesCreateCommand), + Export(export::ProfilesExportCommand), } diff --git a/src/entities/profiles.rs b/src/entities/profiles.rs index 882ec6b..dfd4b54 100644 --- a/src/entities/profiles.rs +++ b/src/entities/profiles.rs @@ -7,6 +7,7 @@ use sea_orm::entity::prelude::*; pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub key: String, + pub alias: String, pub name: String, pub description: Option, pub avatar_url: Option, @@ -23,7 +24,7 @@ pub enum Relation { from = "Column::Key", to = "super::keys::Column::Fingerprint", on_update = "Cascade", - on_delete = "Restrict" + on_delete = "Cascade" )] Keys, } diff --git a/src/main.rs b/src/main.rs index 8d3f839..1d4b3fa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -84,6 +84,10 @@ fn main() { let parsed = AspmCommand::parse(); let verbose = parsed.verbose; + if verbose { + std::env::set_var("RUST_BACKTRACE", "1") + } + if let Err(e) = cli(parsed) { match verbose { true => { @@ -154,6 +158,7 @@ fn cli(parsed: AspmCommand) -> Result<(), anyhow::Error> { }, AspmSubcommands::Profiles(subcommand) => match &subcommand.subcommand { ProfilesSubcommands::Create(subcommand) => subcommand.execute_sync(state, runtime), + ProfilesSubcommands::Export(subcommand) => subcommand.execute_sync(state, runtime), }, } }