diff --git a/.vscode/aspm.code-snippets b/.vscode/aspm.code-snippets index 2540686..f0c7bf0 100644 --- a/.vscode/aspm.code-snippets +++ b/.vscode/aspm.code-snippets @@ -29,7 +29,7 @@ "", "#[async_trait::async_trait]", "impl AspmSubcommand for $1Command {", - "\tasync fn execute(&self, state: crate::AspmState) -> Result<(), anyhow::Error> {", + "\tasync fn execute(self, state: crate::AspmState) -> Result<(), anyhow::Error> {", "\t\t", "\t\t", "\t\tOk(())", diff --git a/crates/asp/src/aspe/mod.rs b/crates/asp/src/aspe/mod.rs index 350f77f..c7e44f5 100644 --- a/crates/asp/src/aspe/mod.rs +++ b/crates/asp/src/aspe/mod.rs @@ -4,8 +4,18 @@ use reqwest::{ header::{self, HeaderValue}, StatusCode, }; +use serde::Deserialize; use url::Host; +/// The version response for an ASPE-compatible server +#[derive(Deserialize)] +pub struct AspeVersion { + pub name: String, + pub version: String, + pub repository: Option, + pub homepage: Option, +} + /// An ASPE-compatible server pub struct AspeServer { host: Host, @@ -66,8 +76,8 @@ impl From for AspeFetchFailure { impl AspeServer { /// Creates a new [AspeServer] instance given the specified domain. A domain and ONLY a domain should be provided (no scheme, no path, etc). - pub fn new(host: Host) -> anyhow::Result { - Ok(Self { + pub async fn new(host: Host) -> anyhow::Result { + let server = Self { host, client: reqwest::Client::builder() .user_agent(format!( @@ -77,7 +87,30 @@ impl AspeServer { )) .https_only(true) .build()?, - }) + }; + + // Test if this server is valid via a version check + server.version().await?; + + Ok(server) + } + + /// Returns the version information of the aspe server + pub async fn version(&self) -> Result<(), reqwest::Error> { + self.client + .get(format!( + "https://{host}/.well-known/aspe/version", + host = self.host + )) + .header( + header::CONTENT_TYPE, + HeaderValue::from_static("application/json"), + ) + .send() + .await? + .error_for_status()?; + + Ok(()) } /// POSTs a request JWS to the aspe server. This will return a result with the [Err] variant containing an enum detailing specifically why the request failed. @@ -128,9 +161,9 @@ mod tests { use super::{AspeServer, Host}; - #[test] - fn building_aspe_server_succeeds() { - let result = AspeServer::new(Host::Domain(String::from("example.com"))); + #[tokio::test] + async fn building_aspe_server_succeeds() { + let result = AspeServer::new(Host::Domain(String::from("keyoxide.org"))).await; assert!(result.is_ok(), "Constructing an AspeServer should succeed") } } diff --git a/src/commands/aspe/create.rs b/src/commands/aspe/create.rs new file mode 100644 index 0000000..6a46ff3 --- /dev/null +++ b/src/commands/aspe/create.rs @@ -0,0 +1,140 @@ +use aes_gcm::{aead::Aead, Aes256Gcm, Key, KeyInit as _}; +use anyhow::{anyhow, bail, Context}; +use argon2::{password_hash::SaltString, Argon2, PasswordHasher as _}; +use asp::{ + aspe::{ + requests::{AspeRequest, AspeRequestType, AspeRequestVariant}, AspeRequestFailure, AspeServer + }, + hex_color::HexColor, + keys::AspKey, + profiles::AriadneSignatureProfile, + serde_email::Email, + url::{Host, 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::{ColumnTrait, Condition, EntityTrait, ModelTrait, QueryFilter}; + +use crate::{ + commands::AspmSubcommand, + entities::{prelude::*, profiles}, +}; + +/// +#[derive(Parser, Debug)] +pub struct AspeCreateCommand { + /// The fingerprint or alias of the profile to upload + profile: String, + /// The ASPE server to upload this profile to + #[clap(value_parser = Host::parse)] + server: Host, + /// The key to sign this profile with + #[clap(short, long)] + key: Option, +} + +#[async_trait::async_trait] +impl AspmSubcommand for AspeCreateCommand { + async fn execute(self, state: crate::AspmState) -> Result<(), anyhow::Error> { + let server = AspeServer::new(self.server) + .await + .context("Unable to parse provided server into ASPE server, please verify that it is a valid ASPE server")?; + + let Some(profile) = Profiles::find() + .filter( + Condition::any() + .add(profiles::Column::Key.eq(&self.profile)) + .add(profiles::Column::Alias.eq(&self.profile)), + ) + .one(&state.db) + .await + .context("Unable to query for profiles with fingerprint or alias")? + else { + bail!("No profile found with that fingerprint or alias"); + }; + + let claims = profile + .find_related(Claims) + .all(&state.db) + .await + .context("Unable to query for related claims")?; + + let Some(key) = Keys::find_by_id(self.key.unwrap_or(profile.key)) + .one(&state.db) + .await + .context("Unable to query database for key")? + else { + bail!("The key to sign the profile with 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_profile = asp + .encode_and_sign(&AspKey::from_jwk(Jwk::from_bytes(&decrypted)?)?) + .context("Unable to encode the profile as a JWT and sign it")?; + + let create_request = AspeRequest { + version: 0, + r#type: AspeRequestType::Request, + request: AspeRequestVariant::Create { + profile_jws: encoded_profile, + }, + }; + + let encoded_request = create_request + .encode_and_sign(&AspKey::from_jwk(Jwk::from_bytes(&decrypted)?)?) + .context("Unable to encode the profile as a JWT and sign it")?; + + match server.post_request(encoded_request).await { + Ok(_) => Ok(()), + Err(AspeRequestFailure::BadRequest) => bail!("The ASPE server rejected the request due to invalid data"), + Err(AspeRequestFailure::TooLarge) => bail!("The ASPE server rejected the request as being too large"), + Err(AspeRequestFailure::RateLimited) => bail!("The ASPE server rejected the request due to a ratelimit"), + Err(AspeRequestFailure::Unknown(e)) => Err(e.into()) + } + } +} diff --git a/src/commands/aspe/mod.rs b/src/commands/aspe/mod.rs new file mode 100644 index 0000000..d06d606 --- /dev/null +++ b/src/commands/aspe/mod.rs @@ -0,0 +1,15 @@ +use clap::{Parser, Subcommand}; + +mod create; + +/// A subcommand to allow the management of keys, which can then be used to create, modify, or delete profiles. +#[derive(Parser)] +pub struct AspeSubcommand { + #[command(subcommand)] + pub subcommand: AspeSubcommands, +} + +#[derive(Subcommand)] +pub enum AspeSubcommands { + Create(create::AspeCreateCommand), +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 2990362..cfb67ee 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,5 +1,6 @@ pub mod keys; pub mod profiles; +pub mod aspe; use clap::Parser; use sea_orm::{ColumnTrait, Condition, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder}; diff --git a/src/commands/profiles/import.rs b/src/commands/profiles/import.rs index c7be84f..275f7ec 100644 --- a/src/commands/profiles/import.rs +++ b/src/commands/profiles/import.rs @@ -32,7 +32,7 @@ impl AspmSubcommand for ProfilesImportCommand { (host.to_string(), fingerprint.to_string()) }) }) { - Some((host, fingerprint)) => match aspe::AspeServer::new(Host::parse(&host).unwrap())? + Some((host, fingerprint)) => match aspe::AspeServer::new(Host::parse(&host).unwrap()).await? .fetch_profile(&fingerprint) .await { diff --git a/src/main.rs b/src/main.rs index 3867df5..7c3e945 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,7 +6,7 @@ use anstyle::{AnsiColor, Color as AnstyleColor, Style as Anstyle}; use anyhow::Context; use app_dirs2::{AppDataType, AppInfo}; use clap::{Parser, Subcommand}; -use commands::{keys::KeysSubcommands, profiles::ProfilesSubcommands, AspmSubcommand}; +use commands::{aspe::AspeSubcommands, keys::KeysSubcommands, profiles::ProfilesSubcommands, AspmSubcommand}; use migrations::{Migrator, MigratorTrait, SchemaManager}; use sea_orm::{Database, DatabaseConnection}; use thiserror::Error; @@ -79,6 +79,7 @@ impl AspmCommand { pub enum AspmSubcommands { Keys(commands::keys::KeysSubcommand), Profiles(commands::profiles::ProfilesSubcommand), + Aspe(commands::aspe::AspeSubcommand), } fn main() { @@ -169,6 +170,9 @@ fn cli(parsed: AspmCommand) -> Result<(), anyhow::Error> { ProfilesSubcommands::Delete(subcommand) => subcommand.execute_sync(state, runtime), ProfilesSubcommands::Import(subcommand) => subcommand.execute_sync(state, runtime), }, + AspmSubcommands::Aspe(subcommand) => match subcommand.subcommand { + AspeSubcommands::Create(subcommand) => subcommand.execute_sync(state, runtime), + } } }