mirror of
https://codeberg.org/tyy/aspm
synced 2024-12-23 01:19:28 -07:00
Add aspe create command, untested
This commit is contained in:
parent
bb43c833fc
commit
ecbb551e34
7 changed files with 202 additions and 9 deletions
2
.vscode/aspm.code-snippets
vendored
2
.vscode/aspm.code-snippets
vendored
|
@ -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(())",
|
||||||
|
|
|
@ -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
140
src/commands/aspe/create.rs
Normal 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
15
src/commands/aspe/mod.rs
Normal 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),
|
||||||
|
}
|
|
@ -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};
|
||||||
|
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
@ -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),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue