mod httpsig;
mod key;
use std::path::{Path, PathBuf};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use clap::{Parser, Subcommand};
use ed25519_dalek::SigningKey;
use serde_json::json;
use wimsey_identifier::WorkloadIdentifier;
use wimsey_wit::{Confirmation, Jwk, WitClaims};
use wimsey_wpt::WptClaims;
use crate::key::JwkKey;
pub type Result<T> = std::result::Result<T, Box<dyn std::error::Error + Send + Sync>>;
#[derive(Parser)]
#[command(
name = "wimsey",
version,
about = "A vendor-neutral WIMSE reference implementation"
)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Key {
#[command(subcommand)]
cmd: KeyCmd,
},
Wit {
#[command(subcommand)]
cmd: WitCmd,
},
Wpt {
#[command(subcommand)]
cmd: WptCmd,
},
Httpsig {
#[command(subcommand)]
cmd: Box<httpsig::HttpsigCmd>,
},
}
#[derive(Subcommand)]
enum KeyCmd {
Generate {
#[arg(long)]
seed: Option<String>,
#[arg(long)]
out: Option<PathBuf>,
},
Public {
#[arg(long, value_name = "FILE")]
r#in: PathBuf,
#[arg(long)]
out: Option<PathBuf>,
},
}
#[derive(Subcommand)]
enum WitCmd {
Issue {
#[arg(long, value_name = "FILE")]
issuer_key: PathBuf,
#[arg(long)]
sub: String,
#[arg(long)]
iss: Option<String>,
#[arg(long, value_name = "FILE")]
cnf_key: PathBuf,
#[arg(long, default_value_t = 3600)]
ttl: u64,
#[arg(long)]
kid: Option<String>,
#[arg(long)]
jti: Option<String>,
#[arg(long)]
now: Option<u64>,
},
Verify {
#[arg(long, value_name = "FILE")]
issuer_jwk: PathBuf,
#[arg(long, conflicts_with = "token_file")]
token: Option<String>,
#[arg(long)]
token_file: Option<PathBuf>,
#[arg(long)]
expected_iss: Option<String>,
#[arg(long)]
now: Option<u64>,
},
Inspect {
#[arg(long, conflicts_with = "token_file")]
token: Option<String>,
#[arg(long)]
token_file: Option<PathBuf>,
},
}
#[derive(Subcommand)]
enum WptCmd {
New {
#[arg(long, value_name = "FILE")]
pop_key: PathBuf,
#[arg(long)]
wit: String,
#[arg(long)]
aud: String,
#[arg(long, default_value_t = 120)]
ttl: u64,
#[arg(long)]
jti: Option<String>,
#[arg(long)]
now: Option<u64>,
},
Verify {
#[arg(long, value_name = "FILE")]
issuer_jwk: PathBuf,
#[arg(long)]
wit: String,
#[arg(long)]
aud: String,
#[arg(long)]
proof: String,
#[arg(long)]
expected_iss: Option<String>,
#[arg(long)]
now: Option<u64>,
},
}
fn main() {
if let Err(err) = run(Cli::parse()) {
eprintln!("error: {err}");
std::process::exit(1);
}
}
fn run(cli: Cli) -> Result<()> {
match cli.command {
Command::Key { cmd } => run_key(cmd),
Command::Wit { cmd } => run_wit(cmd),
Command::Wpt { cmd } => run_wpt(cmd),
Command::Httpsig { cmd } => httpsig::run(*cmd),
}
}
fn run_key(cmd: KeyCmd) -> Result<()> {
match cmd {
KeyCmd::Generate { seed, out } => {
let signing_key = if let Some(seed) = seed {
let bytes = URL_SAFE_NO_PAD.decode(seed.trim())?;
let seed: [u8; 32] = bytes.try_into().map_err(|_| "seed is not 32 bytes")?;
SigningKey::from_bytes(&seed)
} else {
let mut seed = [0u8; 32];
getrandom::fill(&mut seed).map_err(|e| format!("getrandom: {e}"))?;
SigningKey::from_bytes(&seed)
};
emit(
&key::to_json(&JwkKey::from_signing_key(&signing_key))?,
out.as_deref(),
)
}
KeyCmd::Public { r#in, out } => {
let jwk = key::load(&r#in)?;
if jwk.d.is_some() {
jwk.signing_key()?;
} else {
jwk.verifying_key()?;
}
emit(&key::to_json(&jwk.to_public())?, out.as_deref())
}
}
}
fn run_wit(cmd: WitCmd) -> Result<()> {
match cmd {
WitCmd::Issue {
issuer_key,
sub,
iss,
cnf_key,
ttl,
kid,
jti,
now,
} => {
let issuer = key::load(&issuer_key)?.signing_key()?;
let cnf = key::load(&cnf_key)?.verifying_key()?;
let iat = now.unwrap_or_else(wimsey_wit::now_unix);
let exp = iat
.checked_add(ttl)
.ok_or("ttl overflows the expiry time")?;
let claims = WitClaims {
iss: iss.map(|s| s.trim().to_owned()),
sub: WorkloadIdentifier::parse(sub.trim())?,
iat: Some(iat),
exp,
jti: Some(jti.map_or_else(random_id, Ok)?),
cnf: Confirmation {
jwk: Jwk::from_ed25519(&cnf),
},
};
let token = wimsey_wit::issue(&claims, kid.as_deref(), &issuer)?;
println!("{token}");
Ok(())
}
WitCmd::Verify {
issuer_jwk,
token,
token_file,
expected_iss,
now,
} => {
let key = key::load(&issuer_jwk)?.verifying_key()?;
let token = read_token(token, token_file)?;
let mut validation =
wimsey_wit::Validation::at(now.unwrap_or_else(wimsey_wit::now_unix));
if let Some(iss) = expected_iss {
validation = validation.expect_issuer(iss.trim().to_owned());
}
let verified = wimsey_wit::verify(&token, &key, &validation)?;
let out = json!({ "kid": verified.kid, "claims": verified.claims });
println!("{}", serde_json::to_string_pretty(&out)?);
Ok(())
}
WitCmd::Inspect { token, token_file } => {
let token = read_token(token, token_file)?;
let header: serde_json::Value = serde_json::from_slice(&decode_part(&token, 0)?)?;
let claims: serde_json::Value = serde_json::from_slice(&decode_part(&token, 1)?)?;
let out = json!({ "header": header, "claims": claims });
println!("{}", serde_json::to_string_pretty(&out)?);
Ok(())
}
}
}
fn run_wpt(cmd: WptCmd) -> Result<()> {
match cmd {
WptCmd::New {
pop_key,
wit,
aud,
ttl,
jti,
now,
} => {
let pop = key::load(&pop_key)?.signing_key()?;
let iat = now.unwrap_or_else(wimsey_wit::now_unix);
let exp = iat
.checked_add(ttl)
.ok_or("ttl overflows the expiry time")?;
let claims = WptClaims {
aud: aud.trim().to_owned(),
exp,
jti: jti.map_or_else(random_id, Ok)?,
wth: wimsey_wpt::wit_thumbprint(wit.trim()),
ath: None,
};
let proof = wimsey_wpt::issue(&claims, &pop)?;
println!("{proof}");
Ok(())
}
WptCmd::Verify {
issuer_jwk,
wit,
aud,
proof,
expected_iss,
now,
} => {
let wit = wit.trim();
let now = now.unwrap_or_else(wimsey_wit::now_unix);
let issuer = key::load(&issuer_jwk)?.verifying_key()?;
let mut wit_validation = wimsey_wit::Validation::at(now);
if let Some(iss) = expected_iss {
wit_validation = wit_validation.expect_issuer(iss.trim().to_owned());
}
let verified_wit = wimsey_wit::verify(wit, &issuer, &wit_validation)?;
let validation = wimsey_wpt::Validation::new(now, aud.trim(), wit);
let verified = wimsey_wpt::verify(proof.trim(), &verified_wit.pop_key, &validation)?;
let out = json!({ "sub": verified_wit.claims.sub, "wpt": verified.claims });
println!("{}", serde_json::to_string_pretty(&out)?);
Ok(())
}
}
}
fn decode_part(token: &str, index: usize) -> Result<Vec<u8>> {
let part = token
.split('.')
.nth(index)
.ok_or("token does not have the expected number of parts")?;
Ok(URL_SAFE_NO_PAD.decode(part)?)
}
fn read_token(token: Option<String>, token_file: Option<PathBuf>) -> Result<String> {
match (token, token_file) {
(Some(token), _) => Ok(token.trim().to_owned()),
(None, Some(path)) => Ok(std::fs::read_to_string(path)?.trim().to_owned()),
(None, None) => Err("provide --token or --token-file".into()),
}
}
fn random_id() -> Result<String> {
use std::fmt::Write as _;
let mut bytes = [0u8; 16];
getrandom::fill(&mut bytes).map_err(|e| format!("getrandom: {e}"))?;
let mut id = String::with_capacity(bytes.len() * 2);
for b in bytes {
let _ = write!(id, "{b:02x}");
}
Ok(id)
}
fn emit(content: &str, out: Option<&Path>) -> Result<()> {
match out {
Some(path) => write_owner_only(path, content)?,
None => println!("{content}"),
}
Ok(())
}
fn write_owner_only(path: &Path, content: &str) -> Result<()> {
use std::io::Write as _;
let mut temp_name = path
.file_name()
.ok_or("output path has no file name")?
.to_os_string();
temp_name.push(format!(".tmp-{}", random_id()?));
let temp_path = match path.parent().filter(|p| !p.as_os_str().is_empty()) {
Some(dir) => dir.join(&temp_name),
None => PathBuf::from(&temp_name),
};
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt as _;
options.mode(0o600);
}
let mut file = options
.open(&temp_path)
.map_err(|e| format!("creating temporary file: {e}"))?;
let write_result = file
.write_all(content.as_bytes())
.and_then(|()| file.sync_all());
drop(file);
if let Err(e) = write_result {
let _ = std::fs::remove_file(&temp_path);
return Err(e.into());
}
if let Err(e) = std::fs::rename(&temp_path, path) {
let _ = std::fs::remove_file(&temp_path);
return Err(format!("renaming temporary file: {e}").into());
}
Ok(())
}