mirror of
https://codeberg.org/tyy/aspm
synced 2024-12-23 05:59:28 -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:
parent
b2766622c8
commit
463d4eb459
5 changed files with 1032 additions and 36 deletions
7
.vscode/settings.json
vendored
Normal file
7
.vscode/settings.json
vendored
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"cSpell.words": [
|
||||||
|
"josekit",
|
||||||
|
"PKCS",
|
||||||
|
"Pkey"
|
||||||
|
]
|
||||||
|
}
|
800
Cargo.lock
generated
800
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
@ -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"] }
|
||||||
|
|
209
src/main.rs
209
src/main.rs
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue