1
0
Fork 0
mirror of https://codeberg.org/tyy/aspm synced 2024-12-22 20:39:29 -07:00

Add aspe create command, untested

This commit is contained in:
Tyler Beckman 2024-03-20 22:53:13 -06:00
parent bb43c833fc
commit ecbb551e34
Signed by: Ty
GPG key ID: 2813440C772555A4
7 changed files with 202 additions and 9 deletions

View file

@ -29,7 +29,7 @@
"", "",
"#[async_trait::async_trait]", "#[async_trait::async_trait]",
"impl AspmSubcommand for $1Command {", "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\t", "\t\t",
"\t\tOk(())", "\t\tOk(())",

View file

@ -4,8 +4,18 @@ use reqwest::{
header::{self, HeaderValue}, header::{self, HeaderValue},
StatusCode, StatusCode,
}; };
use serde::Deserialize;
use url::Host; 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<String>,
pub homepage: Option<String>,
}
/// An ASPE-compatible server /// An ASPE-compatible server
pub struct AspeServer { pub struct AspeServer {
host: Host, host: Host,
@ -66,8 +76,8 @@ impl From<reqwest::Error> for AspeFetchFailure {
impl AspeServer { 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). /// 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<Self> { pub async fn new(host: Host) -> anyhow::Result<Self> {
Ok(Self { let server = Self {
host, host,
client: reqwest::Client::builder() client: reqwest::Client::builder()
.user_agent(format!( .user_agent(format!(
@ -77,7 +87,30 @@ impl AspeServer {
)) ))
.https_only(true) .https_only(true)
.build()?, .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. /// 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}; use super::{AspeServer, Host};
#[test] #[tokio::test]
fn building_aspe_server_succeeds() { async fn building_aspe_server_succeeds() {
let result = AspeServer::new(Host::Domain(String::from("example.com"))); let result = AspeServer::new(Host::Domain(String::from("keyoxide.org"))).await;
assert!(result.is_ok(), "Constructing an AspeServer should succeed") assert!(result.is_ok(), "Constructing an AspeServer should succeed")
} }
} }

140
src/commands/aspe/create.rs Normal file
View file

@ -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<String>,
}
#[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::<Aes256Gcm>::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())
}
}
}

15
src/commands/aspe/mod.rs Normal file
View file

@ -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),
}

View file

@ -1,5 +1,6 @@
pub mod keys; pub mod keys;
pub mod profiles; pub mod profiles;
pub mod aspe;
use clap::Parser; use clap::Parser;
use sea_orm::{ColumnTrait, Condition, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder}; use sea_orm::{ColumnTrait, Condition, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder};

View file

@ -32,7 +32,7 @@ impl AspmSubcommand for ProfilesImportCommand {
(host.to_string(), fingerprint.to_string()) (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) .fetch_profile(&fingerprint)
.await .await
{ {

View file

@ -6,7 +6,7 @@ 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::{keys::KeysSubcommands, profiles::ProfilesSubcommands, AspmSubcommand}; use commands::{aspe::AspeSubcommands, keys::KeysSubcommands, profiles::ProfilesSubcommands, AspmSubcommand};
use migrations::{Migrator, MigratorTrait, SchemaManager}; use migrations::{Migrator, MigratorTrait, SchemaManager};
use sea_orm::{Database, DatabaseConnection}; use sea_orm::{Database, DatabaseConnection};
use thiserror::Error; use thiserror::Error;
@ -79,6 +79,7 @@ impl AspmCommand {
pub enum AspmSubcommands { pub enum AspmSubcommands {
Keys(commands::keys::KeysSubcommand), Keys(commands::keys::KeysSubcommand),
Profiles(commands::profiles::ProfilesSubcommand), Profiles(commands::profiles::ProfilesSubcommand),
Aspe(commands::aspe::AspeSubcommand),
} }
fn main() { fn main() {
@ -169,6 +170,9 @@ fn cli(parsed: AspmCommand) -> Result<(), anyhow::Error> {
ProfilesSubcommands::Delete(subcommand) => subcommand.execute_sync(state, runtime), ProfilesSubcommands::Delete(subcommand) => subcommand.execute_sync(state, runtime),
ProfilesSubcommands::Import(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),
}
} }
} }