diff --git a/.vscode/settings.json b/.vscode/settings.json index 3e62203..728bf24 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,6 @@ { "cSpell.words": [ + "Aspe", "josekit", "PKCS", "Pkey" diff --git a/Cargo.lock b/Cargo.lock index ecbfe73..bc8f695 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -76,6 +76,7 @@ checksum = "9c7d0618f0e0b7e8ff11427422b64564d5fb0be1940354bfe2e0529b18a9d9b8" name = "ariadne-signature-profile-proto" version = "0.1.0" dependencies = [ + "asp", "clap", "data-encoding", "indoc", @@ -88,6 +89,22 @@ dependencies = [ "tokio", ] +[[package]] +name = "asp" +version = "0.0.0" +dependencies = [ + "anyhow", + "data-encoding", + "josekit", + "openssl", + "reqwest", + "serde", + "serde_json", + "sha2", + "thiserror", + "tokio", +] + [[package]] name = "autocfg" version = "1.1.0" diff --git a/Cargo.toml b/Cargo.toml index 3a90668..d30659c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,3 +18,8 @@ serde = { version = "1.0.164", features = ["derive"] } serde_json = "1.0.99" sha2 = "0.10.7" tokio = { version = "1.28.2", features = ["macros", "rt-multi-thread"] } + +asp = { path = "crates/asp" } + +[workspace] +members = ["crates/*"] diff --git a/crates/asp/Cargo.toml b/crates/asp/Cargo.toml new file mode 100644 index 0000000..5dc8180 --- /dev/null +++ b/crates/asp/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "asp" +version = "0.0.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +anyhow = "1.0.71" +data-encoding = "2.4.0" +josekit = "0.8.3" +openssl = "0.10.55" +reqwest = "0.11.18" +serde = { version = "1.0.164", features = ["derive"] } +serde_json = "1.0.99" +sha2 = "0.10.7" +thiserror = "1.0.40" +tokio = { version = "1.28.2", features = ["macros", "rt-multi-thread"] } diff --git a/crates/asp/src/keys.rs b/crates/asp/src/keys.rs new file mode 100644 index 0000000..afe0b94 --- /dev/null +++ b/crates/asp/src/keys.rs @@ -0,0 +1,61 @@ +use data_encoding::{BASE32_NOPAD, BASE64URL_NOPAD, BASE64_NOPAD}; +use josekit::{ + jwk::{alg::ec::EcKeyPair, Jwk}, + jws::ES256, +}; +use openssl::pkey::PKey; +use sha2::{Digest, Sha512}; +use anyhow::Context; + +pub trait JwtExt { + fn get_fingerprint(&self) -> anyhow::Result; + fn to_pkcs8(&self) -> anyhow::Result; + fn from_pkcs8(pkcs8: &[u8]) -> anyhow::Result; +} + +impl JwtExt for Jwk { + fn get_fingerprint(&self) -> anyhow::Result { + // Get the "x" value of the JWK + let fingerprint = self.parameter("x").context(r#"Jwk "x" parameter was not present"#)?; + // Base64url decode the "x" value and use that as the public key value + let fingerprint = BASE64URL_NOPAD + .decode( + // The as_str() can be unwrapped safely because it is impossible to create a Jwk struct where the "x" value is not a string + fingerprint.as_str().unwrap().as_bytes() + ) + .unwrap(); // The decode() can be unwrapped safely because it is impossible to create a Jwk struct where the "x" value is not base64url decodable + // Sha512 hash the public key + let fingerprint: Vec = { + let mut hash = Sha512::new(); + hash.update(fingerprint); + hash.finalize().to_vec() + }; + // Get the first 16 bytes of the hash + let fingerprint = &fingerprint[0..16]; + // Base32 encode the first 16 bytes, and that is the fingerprint + let fingerprint = BASE32_NOPAD.encode(fingerprint); + Ok(fingerprint) + } + + fn to_pkcs8(&self) -> anyhow::Result { + // This was done because I couldn't find an easy way to get a PKCS#8 encoded private key from the josekit Jwk struct, so I did the following as a workaround, directly using the openssl library: + // 1. Get the josekit EcKeyPair from the Jwk + // 2. Convert that to a PEM private key + // 3. Get the openssl Pkey struct by loading the PRM private key + // 4. Convert that Pkey into the PKCS#8 encoded private key (and then base64 encode it) + let key_pair = EcKeyPair::from_jwk(self)?; + let pem_private = key_pair.to_pem_private_key(); + let pkey = PKey::private_key_from_pem(&pem_private)?; + let pkcs8 = pkey + .as_ref() + .private_key_to_pkcs8()?; + let encoded = BASE64_NOPAD.encode(&pkcs8); + Ok(encoded) + } + + fn from_pkcs8(pkcs8: &[u8]) -> anyhow::Result { + let decoded_pkcs8 = BASE64_NOPAD.decode(pkcs8)?; + let key_pair = ES256.key_pair_from_der(decoded_pkcs8)?; + Ok(key_pair.to_jwk_key_pair()) + } +} \ No newline at end of file diff --git a/crates/asp/src/lib.rs b/crates/asp/src/lib.rs new file mode 100644 index 0000000..703bc08 --- /dev/null +++ b/crates/asp/src/lib.rs @@ -0,0 +1 @@ +pub mod keys; diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..5d56faf --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +channel = "nightly" diff --git a/src/main.rs b/src/main.rs index 4a31ef9..f52d1b1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,7 +7,7 @@ use std::{ }; use clap::{Parser, Subcommand}; -use data_encoding::{BASE32_NOPAD, BASE64URL_NOPAD, BASE64_NOPAD}; +use data_encoding::{BASE64_NOPAD}; use indoc::printdoc; use josekit::{ jwk::{ @@ -19,10 +19,9 @@ use josekit::{ }; use openssl::pkey::PKey; use reqwest::{header, redirect, StatusCode}; -use sha2::{Digest, Sha512}; -use structs::{AspRequestAction, AspRequestClaims, JwkExt}; +use asp::keys::JwtExt; -use crate::structs::{AspClaims, AspType}; +use crate::structs::{AspClaims, AspType, AspRequestAction, AspRequestClaims}; // Prototype implementation of ASPs #[derive(Parser)] @@ -33,6 +32,24 @@ struct Cli { command: Commands, } +#[derive(Subcommand)] +enum AspeCommand { + /// Uploads a profile to an aspe server + Upload { + /// The path of the key file (stored in JSON JWK format) to be used when signing the profile, or - to parse a JWK from stdin (but only this or the profile can be taken from stdin at once). This can be generated with the `create-key` subcommand. + #[arg(short, long)] + key: String, + /// The JWT profile to upload to the server (or - for stdin, but only this or the key can be taken from stdin at once) + profile: String, + }, + /// Deletes a profile on an aspe server + Delete { + /// The path of the key file (stored in JSON JWK format) to be used when signing the profile, or - to parse a JWK from stdin (but only this or the profile can be taken from stdin at once). This can be generated with the `create-key` subcommand. + #[arg(short, long)] + key: String, + }, +} + #[derive(Subcommand)] enum Commands { /// Creates a new JWK (EC, p-256) @@ -84,37 +101,15 @@ enum Commands { /// The file (or "-") that contains the JWT profile: String, }, - UploadProfile { - /// The path of the key file (stored in JSON JWK format) to be used when signing the profile, or - to parse a JWK from stdin (but only this or the profile can be taken from stdin at once). This can be generated with the `create-key` subcommand. - #[arg(short, long)] - key: String, - /// The domain of the server to upload the profile to + Aspe { + #[command(subcommand)] + command: AspeCommand, + /// The domain of the server to use for aspe operations #[arg(short, long)] server: String, - /// The JWT profile to upload to the server (or - for stdin, but only this or the key can be taken from stdin at once) - profile: String, }, } -impl JwkExt for Jwk { - fn fingerprint(&self) -> String { - let fingerprint = self - .parameter("x") - .expect("jwk did not contain an \"x\" value"); - let fingerprint = BASE64URL_NOPAD - .decode(fingerprint.as_str().unwrap().as_bytes()) - .expect("unable to base64 decode JWK \"x\" value"); - let fingerprint: Vec = { - let mut hash = Sha512::new(); - hash.update(fingerprint); - hash.finalize().to_vec() - }; - let fingerprint = &fingerprint[0..16]; - let fingerprint = BASE32_NOPAD.encode(fingerprint); - fingerprint - } -} - #[tokio::main] async fn main() { let http = reqwest::Client::builder() @@ -213,7 +208,7 @@ async fn main() { let jwk = Jwk::from_bytes(jwk_bytes).expect("unable to parse jwk"); // Derive the key fingerprint from the JWT - let fingerprint = jwk.fingerprint(); + let fingerprint = jwk.get_fingerprint().expect("unable to calculate jwk fingerprint"); // Construct the JWT header let mut header = JwsHeader::new(); @@ -305,7 +300,7 @@ async fn main() { Claims: {claims_formatted} ", - fingerprint = jwk.fingerprint(), + fingerprint = jwk.get_fingerprint().expect("unable to calculate jwt fingerprint"), version = claims.version, name = claims.name, description = claims.description.unwrap_or("N/A".to_string()), @@ -315,112 +310,204 @@ async fn main() { claims_formatted = claims.claims.iter().map(|claim| format!("- {claim}")).collect::>().join("\n") } } - Commands::UploadProfile { - key, - server, - profile, - } => { - if key == "-" && profile == "-" { - panic!("Only one of `--key ` and `` can be `-` at a time") - } + Commands::Aspe { command, server } => { + match command { + AspeCommand::Upload { + key, + profile, + } => { + if key == "-" && profile == "-" { + panic!("Only one of `--key ` and `` can be `-` at a time") + } - // Obtain the JWK w/ private key - let mut jwk_bytes = Vec::new(); - if key == "-" { - std::io::stdin() - .read_to_end(&mut jwk_bytes) - .expect("unable to read from stdin"); - } else { - File::open(key) - .expect("unable to open keyfile") - .read_to_end(&mut jwk_bytes) - .expect("unable to read keyfile"); - }; + // Obtain the JWK w/ private key + let mut jwk_bytes = Vec::new(); + if key == "-" { + std::io::stdin() + .read_to_end(&mut jwk_bytes) + .expect("unable to read from stdin"); + } else { + File::open(key) + .expect("unable to open keyfile") + .read_to_end(&mut jwk_bytes) + .expect("unable to read keyfile"); + }; - let jwk = Jwk::from_bytes(jwk_bytes).expect("unable to parse jwk"); + let jwk = Jwk::from_bytes(jwk_bytes).expect("unable to parse jwk"); - // Obtain the JWT profile - let mut jwt_bytes = Vec::new(); - if profile == "-" { - std::io::stdin() - .read_to_end(&mut jwt_bytes) - .expect("unable to read from stdin"); - } else { - File::open(profile) - .expect("unable to open jwt file") - .read_to_end(&mut jwt_bytes) - .expect("unable to read jwt file"); - }; - // Strip newlines - let jwt_bytes = jwt_bytes.strip_suffix(&[10]).unwrap_or(&jwt_bytes[..]); + // Obtain the JWT profile + let mut jwt_bytes = Vec::new(); + if profile == "-" { + std::io::stdin() + .read_to_end(&mut jwt_bytes) + .expect("unable to read from stdin"); + } else { + File::open(profile) + .expect("unable to open jwt file") + .read_to_end(&mut jwt_bytes) + .expect("unable to read jwt file"); + }; + // Strip newlines + let jwt_bytes = jwt_bytes.strip_suffix(&[10]).unwrap_or(&jwt_bytes[..]); - // Construct a request JWT + // Construct a request JWT - // Construct the JWT header - let mut header = JwsHeader::new(); - header.set_token_type("JWT"); - header.set_algorithm("ES256"); - header.set_jwk( - jwk.to_public_key() - .expect("unable to convert jwk to public key"), - ); - header.set_key_id(jwk.fingerprint()); + // Construct the JWT header + let mut header = JwsHeader::new(); + header.set_token_type("JWT"); + header.set_algorithm("ES256"); + header.set_jwk( + jwk.to_public_key() + .expect("unable to convert jwk to public key"), + ); + header.set_key_id(jwk.get_fingerprint().expect("unable to calculate jwk fingerprint")); - // Construct the payload - let mut payload = JwtPayload::from_map( - serde_json::to_value(AspRequestClaims { - version: 0, - r#type: AspType::Request, - action: AspRequestAction::Create, - aspe_uri: None, - profile_jws: Some( - String::from_utf8(jwt_bytes.to_vec()) - .expect("unable to parse jwt bytes into String"), - ), - }) - .unwrap() - .as_object() - .unwrap() - .clone(), - ) - .expect("unable to create payload from map of claims"); - payload.set_issued_at(&SystemTime::now()); + // Construct the payload + let mut payload = JwtPayload::from_map( + serde_json::to_value(AspRequestClaims { + version: 0, + r#type: AspType::Request, + action: AspRequestAction::Create, + aspe_uri: None, + profile_jws: Some( + String::from_utf8(jwt_bytes.to_vec()) + .expect("unable to parse jwt bytes into String"), + ), + }) + .unwrap() + .as_object() + .unwrap() + .clone(), + ) + .expect("unable to create payload from map of claims"); + payload.set_issued_at(&SystemTime::now()); - // Construct the signer - let signer = ES256 - .signer_from_jwk(&jwk) - .expect("unable to construct signer from jwk"); + // Construct the signer + let signer = ES256 + .signer_from_jwk(&jwk) + .expect("unable to construct signer from jwk"); - // Construct the actual jwt - let jwt = jwt::encode_with_signer(&payload, &header.into(), &signer) - .expect("unable to create and sign jwt"); + // Construct the actual jwt + let jwt = jwt::encode_with_signer(&payload, &header.into(), &signer) + .expect("unable to create and sign jwt"); - // Send the request - let response = http - .post(format!("https://{server}/.well-known/aspe/post")) - .header(header::CONTENT_TYPE, "application/jose; charset=UTF-8") - .body(jwt) - .send() - .await - .expect("unable to send http request to server"); + // Send the request + let response = http + .post(format!("https://{server}/.well-known/aspe/post")) + .header(header::CONTENT_TYPE, "application/jose; charset=UTF-8") + .body(jwt) + .send() + .await + .expect("unable to send http request to server"); - match response.status() { - StatusCode::CREATED => { - dbg!(response.text().await.unwrap()); - } - StatusCode::BAD_REQUEST => { - dbg!(response.text().await.unwrap()); - panic!("Request returned 400, did the request take over 60 seconds to send?") - } - StatusCode::TOO_MANY_REQUESTS => { - dbg!(response.text().await.unwrap()); - panic!("Ratelimited"); - } - _ => { - dbg!(response.status()); - dbg!(response.text().await.unwrap()); + match response.status() { + StatusCode::CREATED => { + dbg!(response.text().await.unwrap()); + } + StatusCode::BAD_REQUEST => { + dbg!(response.text().await.unwrap()); + panic!("Request returned 400, did the request take over 60 seconds to send?") + } + StatusCode::TOO_MANY_REQUESTS => { + dbg!(response.text().await.unwrap()); + panic!("Ratelimited"); + } + _ => { + dbg!(response.status()); + dbg!(response.text().await.unwrap()); - panic!("wtf") + panic!("wtf") + } + } + }, + AspeCommand::Delete { + key, + } => { + // Obtain the JWK w/ private key + let mut jwk_bytes = Vec::new(); + if key == "-" { + std::io::stdin() + .read_to_end(&mut jwk_bytes) + .expect("unable to read from stdin"); + } else { + File::open(key) + .expect("unable to open keyfile") + .read_to_end(&mut jwk_bytes) + .expect("unable to read keyfile"); + }; + + let jwk = Jwk::from_bytes(jwk_bytes).expect("unable to parse jwk"); + + // Construct a request JWT + let fingerprint = jwk.get_fingerprint().expect("unable to calculate jwt fingerprint"); + + // Construct the JWT header + let mut header = JwsHeader::new(); + header.set_token_type("JWT"); + header.set_algorithm("ES256"); + header.set_jwk( + jwk.to_public_key() + .expect("unable to convert jwk to public key"), + ); + header.set_key_id(&fingerprint); + + // Construct the payload + let mut payload = JwtPayload::from_map( + serde_json::to_value(AspRequestClaims { + version: 0, + r#type: AspType::Request, + action: AspRequestAction::Delete, + aspe_uri: Some(format!("aspe:{server}:{fingerprint}")), + profile_jws: None, + }) + .unwrap() + .as_object() + .unwrap() + .clone(), + ) + .expect("unable to create payload from map of claims"); + payload.set_issued_at(&SystemTime::now()); + + // Construct the signer + let signer = ES256 + .signer_from_jwk(&jwk) + .expect("unable to construct signer from jwk"); + + // Construct the actual jwt + let jwt = jwt::encode_with_signer(&payload, &header.into(), &signer) + .expect("unable to create and sign jwt"); + dbg!(&jwt); + // Send the request + let response = http + .post(format!("https://{server}/.well-known/aspe/post")) + .header(header::CONTENT_TYPE, "application/jose; charset=UTF-8") + .body(jwt) + .send() + .await + .expect("unable to send http request to server"); + + match response.status() { + StatusCode::OK => { + dbg!(response.status()); + dbg!(response.text().await.unwrap()); + } + StatusCode::BAD_REQUEST => { + dbg!(response.status()); + dbg!(response.text().await.unwrap()); + panic!("Request returned 400, did the request take over 60 seconds to send?") + } + StatusCode::TOO_MANY_REQUESTS => { + dbg!(response.status()); + dbg!(response.text().await.unwrap()); + panic!("Ratelimited"); + } + _ => { + dbg!(response.status()); + dbg!(response.text().await.unwrap()); + + panic!("wtf") + } + } } } } diff --git a/src/structs.rs b/src/structs.rs index 005fd3b..7e6800e 100644 --- a/src/structs.rs +++ b/src/structs.rs @@ -66,7 +66,3 @@ pub struct AspRequestClaims { )] pub aspe_uri: Option, } - -pub trait JwkExt { - fn fingerprint(&self) -> String; -}