1
0
Fork 0
mirror of https://codeberg.org/tyy/aspm synced 2024-12-23 04:49:29 -07:00

Add conversion from JWT to PKCS#8 (for asp.keyoxide.org), and add support for uploading profiles to an ASPE server

This commit is contained in:
TymanWasTaken 2023-06-26 18:05:34 -04:00
parent b2766622c8
commit 463d4eb459
Signed by: Ty
GPG key ID: 2813440C772555A4
5 changed files with 1032 additions and 36 deletions

7
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,7 @@
{
"cSpell.words": [
"josekit",
"PKCS",
"Pkey"
]
}

800
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -12,6 +12,9 @@ clap = { version = "4.3.8", features = ["derive"] }
data-encoding = "2.4.0" data-encoding = "2.4.0"
indoc = "2.0.1" indoc = "2.0.1"
josekit = "0.8.3" josekit = "0.8.3"
openssl = "0.10.55"
reqwest = "0.11.18"
serde = { version = "1.0.164", features = ["derive"] } serde = { version = "1.0.164", features = ["derive"] }
serde_json = "1.0.99" serde_json = "1.0.99"
sha2 = "0.10.7" sha2 = "0.10.7"
tokio = { version = "1.28.2", features = ["macros", "rt-multi-thread"] }

View file

@ -3,18 +3,24 @@ mod structs;
use std::{ use std::{
fs::File, fs::File,
io::{Read, Write}, io::{Read, Write},
time::SystemTime,
}; };
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use data_encoding::{BASE32_NOPAD, BASE64URL_NOPAD, BASE64_NOPAD}; use data_encoding::{BASE32_NOPAD, BASE64URL_NOPAD, BASE64_NOPAD};
use indoc::printdoc; use indoc::printdoc;
use josekit::{ use josekit::{
jwk::{alg::ec::EcCurve, Jwk}, jwk::{
alg::ec::{EcCurve, EcKeyPair},
Jwk,
},
jws::{JwsHeader, ES256}, jws::{JwsHeader, ES256},
jwt::{self, JwtPayload}, jwt::{self, JwtPayload},
}; };
use openssl::pkey::PKey;
use reqwest::{header, redirect, StatusCode};
use sha2::{Digest, Sha512}; use sha2::{Digest, Sha512};
use structs::JwkExt; use structs::{AspRequestAction, AspRequestClaims, JwkExt};
use crate::structs::{AspClaims, AspType}; use crate::structs::{AspClaims, AspType};
@ -33,15 +39,21 @@ enum Commands {
CreateKey { CreateKey {
/// The file to output to (or - for stdout) /// The file to output to (or - for stdout)
#[arg(short, long)] #[arg(short, long)]
file: String file: String,
}, },
/// Converts a pkcs8 private key to a JWK (Ec, p-256) /// Converts a pkcs8 private key to a JWK (Ec, p-256)
ConvertPkcs8 { ConvertFromPkcs8 {
/// The pkcs8-encoded private key /// The pkcs8-encoded private key
data: String, data: String,
/// The file to output to (or - for stdout) /// The file to output to (or - for stdout)
#[arg(short, long)] #[arg(short, long)]
file: String file: String,
},
/// Converts a JWT (Ec, p-256) to a pkcs8 private key
ConvertToPkcs8 {
/// The file to input the JWT from (or - for stdin)
#[arg(short, long)]
key: String,
}, },
/// Creates a profile with the specified claims /// Creates a profile with the specified claims
CreateProfile { CreateProfile {
@ -72,6 +84,16 @@ enum Commands {
/// The file (or "-") that contains the JWT /// The file (or "-") that contains the JWT
profile: String, 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
#[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 { impl JwkExt for Jwk {
@ -93,7 +115,17 @@ impl JwkExt for Jwk {
} }
} }
fn main() { #[tokio::main]
async fn main() {
let http = reqwest::Client::builder()
.redirect(redirect::Policy::none())
.user_agent(format!(
"{}/{}",
env!("CARGO_PKG_NAME"),
env!("CARGO_PKG_VERSION")
))
.build()
.expect("unable to create http client");
let args = Cli::parse(); let args = Cli::parse();
match &args.command { match &args.command {
@ -101,24 +133,62 @@ fn main() {
let key = Jwk::generate_ec_key(EcCurve::P256).expect("Unable to generate EC256 JWK"); let key = Jwk::generate_ec_key(EcCurve::P256).expect("Unable to generate EC256 JWK");
if file != "-" { if file != "-" {
let mut file = File::create("key.jwk").unwrap(); let mut file = File::create("key.jwk").unwrap();
file.write_all(key.to_string().as_bytes()).expect("unable to write key to file"); file.write_all(key.to_string().as_bytes())
.expect("unable to write key to file");
} else { } else {
std::io::stdout().write_all(key.to_string().as_bytes()).expect("unable to write key to stdout"); std::io::stdout()
.write_all(key.to_string().as_bytes())
.expect("unable to write key to stdout");
}; };
}, }
Commands::ConvertPkcs8 { data, file } => { Commands::ConvertFromPkcs8 { data, file } => {
let keypair = ES256.key_pair_from_der( let keypair = ES256
BASE64_NOPAD.decode(data.as_bytes()).expect("unable to base64 decode input private key") .key_pair_from_der(
).expect("unable to parse private key into an ES256 key pair"); BASE64_NOPAD
.decode(data.as_bytes())
.expect("unable to base64 decode input private key"),
)
.expect("unable to parse private key into an ES256 key pair");
let jwk = keypair.to_jwk_key_pair(); let jwk = keypair.to_jwk_key_pair();
if file != "-" { if file != "-" {
let mut file = File::create("key.jwk").unwrap(); let mut file = File::create("key.jwk").unwrap();
file.write_all(jwk.to_string().as_bytes()).expect("unable to write key to file"); file.write_all(jwk.to_string().as_bytes())
.expect("unable to write key to file");
} else { } else {
std::io::stdout().write_all(jwk.to_string().as_bytes()).expect("unable to write key to stdout"); std::io::stdout()
.write_all(jwk.to_string().as_bytes())
.expect("unable to write key to stdout");
}; };
} }
Commands::ConvertToPkcs8 { 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");
// 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(&jwk).expect("unable to create EcKeyPair from jwk");
let pem_private = key_pair.to_pem_private_key();
let pkey = PKey::private_key_from_pem(&pem_private).unwrap();
println!(
"{}",
BASE64_NOPAD.encode(&pkey.as_ref().private_key_to_pkcs8().unwrap())
);
}
Commands::CreateProfile { Commands::CreateProfile {
key, key,
claim, claim,
@ -245,5 +315,114 @@ fn main() {
claims_formatted = claims.claims.iter().map(|claim| format!("- {claim}")).collect::<Vec<String>>().join("\n") claims_formatted = claims.claims.iter().map(|claim| format!("- {claim}")).collect::<Vec<String>>().join("\n")
} }
} }
Commands::UploadProfile {
key,
server,
profile,
} => {
if key == "-" && profile == "-" {
panic!("Only one of `--key <key>` and `<profile>` 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");
};
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[..]);
// 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 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 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");
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")
}
}
}
} }
} }

View file

@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize};
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub enum AspType { pub enum AspType {
Profile, Profile,
Request,
} }
#[derive(Serialize, Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug)]
@ -16,16 +17,56 @@ pub struct AspClaims {
pub name: String, pub name: String,
#[serde(rename = "http://ariadne.id/claims")] #[serde(rename = "http://ariadne.id/claims")]
pub claims: Vec<String>, pub claims: Vec<String>,
#[serde(rename = "http://ariadne.id/description")] #[serde(
rename = "http://ariadne.id/description",
skip_serializing_if = "Option::is_none"
)]
pub description: Option<String>, pub description: Option<String>,
#[serde(rename = "http://ariadne.id/avatar_url")] #[serde(
rename = "http://ariadne.id/avatar_url",
skip_serializing_if = "Option::is_none"
)]
pub avatar_url: Option<String>, pub avatar_url: Option<String>,
#[serde(rename = "http://ariadne.id/email")] #[serde(
rename = "http://ariadne.id/email",
skip_serializing_if = "Option::is_none"
)]
pub email: Option<String>, pub email: Option<String>,
#[serde(rename = "http://ariadne.id/color")] #[serde(
rename = "http://ariadne.id/color",
skip_serializing_if = "Option::is_none"
)]
pub color: Option<String>, pub color: Option<String>,
} }
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub enum AspRequestAction {
Create,
Update,
Delete,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct AspRequestClaims {
#[serde(rename = "http://ariadne.id/version")]
pub version: u8,
#[serde(rename = "http://ariadne.id/type")]
pub r#type: AspType,
#[serde(rename = "http://ariadne.id/action")]
pub action: AspRequestAction,
#[serde(
rename = "http://ariadne.id/profile_jws",
skip_serializing_if = "Option::is_none"
)]
pub profile_jws: Option<String>,
#[serde(
rename = "http://ariadne.id/aspe_uri",
skip_serializing_if = "Option::is_none"
)]
pub aspe_uri: Option<String>,
}
pub trait JwkExt { pub trait JwkExt {
fn fingerprint(&self) -> String; fn fingerprint(&self) -> String;
} }