From c167f01de0c8baca70010de8dfc9bb3cea093513 Mon Sep 17 00:00:00 2001 From: Ryan Butler Date: Mon, 18 Nov 2024 17:40:16 -0500 Subject: [PATCH] feat(jwk-util): Initial commit --- Cargo.lock | 26 +++++++ Cargo.toml | 5 ++ jwk-util/Cargo.toml | 28 +++++++ jwk-util/build.rs | 3 + jwk-util/src/conversions.rs | 139 +++++++++++++++++++++++++++++++++++ jwk-util/src/main.rs | 95 ++++++++++++++++++++++++ security-utils/Cargo.toml | 2 +- update-agent/core/Cargo.toml | 8 +- 8 files changed, 301 insertions(+), 5 deletions(-) create mode 100644 jwk-util/Cargo.toml create mode 100644 jwk-util/build.rs create mode 100644 jwk-util/src/conversions.rs create mode 100644 jwk-util/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index cbe23701..dd0ed9b5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1884,6 +1884,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f55bf8e7b65898637379c1b74eb1551107c8294ed26d855ceb9fd1a09cfc9bc0" dependencies = [ "const-oid", + "pem-rfc7468", "zeroize", ] @@ -4262,6 +4263,22 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "orb-jwk-util" +version = "0.0.0" +dependencies = [ + "base64 0.22.1", + "clap", + "color-eyre", + "ed25519-dalek", + "hex-literal", + "jose-jwk", + "orb-build-info 0.0.0", + "pkcs8 0.10.2", + "serde", + "serde_json", +] + [[package]] name = "orb-mcu-interface" version = "0.0.0" @@ -4716,6 +4733,15 @@ dependencies = [ "serde", ] +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.1" diff --git a/Cargo.toml b/Cargo.toml index 2129a9be..6aec995a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ members = [ "endpoints", "header-parsing", "hil", + "jwk-util", "mcu-interface", "mcu-util", "qr-link", @@ -47,15 +48,19 @@ rust-version = "1.81.0" # See rust-toolchain.toml # prevent edge cases where CI doesn't catch build errors due to more features # being present in a --all vs -p build. [workspace.dependencies] +base64 = "0.22.1" bytes = "1.7.1" clap = { version = "4.5", features = ["derive"] } color-eyre = "0.6.2" console-subscriber = "0.4" data-encoding = "2.3" derive_more = { version = "0.99", default-features = false, features = ["display", "from"] } +ed25519-dalek = { version = "2.1.1", default-features = false } eyre = "0.6.12" ftdi-embedded-hal = { version = "0.22.0", features = ["libftd2xx", "libftd2xx-static"] } futures = "0.3.30" +hex-literal = "0.4.1" +jose-jwk = { version = "0.1.2", default-features = false } libc = "0.2.153" nix = { version = "0.28", default-features = false, features = [] } reqwest = { version = "0.11", default-features = false, features = ["rustls-tls", "stream"] } diff --git a/jwk-util/Cargo.toml b/jwk-util/Cargo.toml new file mode 100644 index 00000000..da74036a --- /dev/null +++ b/jwk-util/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "orb-jwk-util" +version = "0.0.0" +authors = ["Ryan Butler "] +description = "CLI utility for manipulating Json Web Keys" +publish = false + +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true + +[dependencies] +clap = { workspace = true, features = ["derive"] } +color-eyre.workspace = true +ed25519-dalek = { workspace = true, features = ["pkcs8", "pem", "std"] } +jose-jwk.workspace = true +orb-build-info.workspace = true +serde = { workspace = true, features = ["derive"] } +serde_json.workspace = true + +[build-dependencies] +orb-build-info = { workspace = true, features = ["build-script"] } + +[dev-dependencies] +base64.workspace = true +hex-literal.workspace = true +pkcs8 = "0.10.2" diff --git a/jwk-util/build.rs b/jwk-util/build.rs new file mode 100644 index 00000000..4996302c --- /dev/null +++ b/jwk-util/build.rs @@ -0,0 +1,3 @@ +fn main() { + orb_build_info::initialize().expect("failed to initialize"); +} diff --git a/jwk-util/src/conversions.rs b/jwk-util/src/conversions.rs new file mode 100644 index 00000000..cc8a205b --- /dev/null +++ b/jwk-util/src/conversions.rs @@ -0,0 +1,139 @@ +#[derive(Debug, Eq, PartialEq, Clone, Copy)] +pub enum ExportPrivKeys { + True, + False, +} + +pub fn dalek_signing_key_to_jwk( + signing_key: &ed25519_dalek::SigningKey, + export_priv_keys: ExportPrivKeys, +) -> jose_jwk::Jwk { + let signing_key_bytes = signing_key.as_bytes().to_vec().into_boxed_slice(); + assert_eq!(signing_key_bytes.len(), ed25519_dalek::SECRET_KEY_LENGTH); + let pub_key_bytes = signing_key + .verifying_key() + .as_bytes() + .to_vec() + .into_boxed_slice(); + assert_eq!(pub_key_bytes.len(), ed25519_dalek::PUBLIC_KEY_LENGTH); + + let okp = jose_jwk::Key::Okp(jose_jwk::Okp { + crv: jose_jwk::OkpCurves::Ed25519, + x: pub_key_bytes.into(), + d: (export_priv_keys == ExportPrivKeys::True) + .then_some(signing_key_bytes.into()), + }); + jose_jwk::Jwk { + key: okp, + prm: jose_jwk::Parameters::default(), + } +} + +#[cfg(test)] +mod test { + use super::*; + use base64::Engine as _; + use jose_jwk::Jwk; + + use ed25519_dalek::pkcs8::DecodePublicKey as _; + use pkcs8::DecodePrivateKey as _; + + #[test] + fn test_signing_key_to_jwk_with_rfc_test_vector() { + // arrange + // See https://datatracker.ietf.org/doc/html/rfc8037#appendix-A.2 + let mut rfc_example = serde_json::json! ({ + "kty": "OKP", + "crv": "Ed25519", + "x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo", // Public part + "d":"nWGxne_9WmC6hEr0kuwsxERJxWl7MmkZcDusAxyuf2A", // Private part + }); + + // Sanity checks + let pubkey_bytes = hex_literal::hex!( + "d7 5a 98 01 82 b1 0a b7 d5 4b fe d3 c9 64 07 3a + 0e e1 72 f3 da a6 23 25 af 02 1a 68 f7 07 51 1a" + ); + let privkey_bytes = hex_literal::hex!( + "9d 61 b1 9d ef fd 5a 60 ba 84 4a f4 92 ec 2c c4 + 44 49 c5 69 7b 32 69 19 70 3b ac 03 1c ae 7f 60" + ); + assert_eq!( + base64::prelude::BASE64_URL_SAFE_NO_PAD + .decode(rfc_example["x"].as_str().unwrap()) + .unwrap(), + pubkey_bytes, + "sanity check: example pubkey bytes should match, they come from the RFC itself" + ); + assert_eq!( + base64::prelude::BASE64_URL_SAFE_NO_PAD + .decode(rfc_example["d"].as_str().unwrap()) + .unwrap(), + privkey_bytes, + "sanity check: example privkey bytes should match, they come from the RFC itself" + ); + let expected_privkey: Jwk = + serde_json::from_value(rfc_example.clone()).unwrap(); + rfc_example["d"].take(); // delete priv key + let expected_pubkey: Jwk = serde_json::from_value(rfc_example).unwrap(); + + // act + assert + let signing_key = ed25519_dalek::SigningKey::from_bytes(&privkey_bytes); + assert_eq!( + dalek_signing_key_to_jwk(&signing_key, ExportPrivKeys::True), + expected_privkey + ); + assert_eq!( + dalek_signing_key_to_jwk(&signing_key, ExportPrivKeys::False), + expected_pubkey + ); + } + + // These keys were randomly generated by https://jwkset.com/generate + #[test] + fn test_known_pem_matches_jwk() { + let mut expected_jwk = serde_json::json! ({ + "kty": "OKP", + "crv": "Ed25519", + "x": "qhVpW12CnO55bQ2625kaWNCz9Uh5SNk7bctS9ieVgL0", + "d": "hVtClEJp0nLXm-ToFB6WLUe0Pnj9A_lrhAky1lVXQ_k" + }); + let expected_privkey: Jwk = + serde_json::from_value(expected_jwk.clone()).unwrap(); + expected_jwk["d"].take(); // delete priv key + let expected_pubkey: Jwk = serde_json::from_value(expected_jwk).unwrap(); + + let expected_pem_pub = r#" +-----BEGIN PUBLIC KEY----- +MCowBQYDK2VwAyEAqhVpW12CnO55bQ2625kaWNCz9Uh5SNk7bctS9ieVgL0= +-----END PUBLIC KEY-----"#; + + let expected_pem_priv = r#" +-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VwBCIEIIVbQpRCadJy15vk6BQeli1HtD54/QP5a4QJMtZVV0P5 +-----END PRIVATE KEY-----"#; + + // PEM -> Dalek + let signing_key = + ed25519_dalek::SigningKey::from_pkcs8_pem(expected_pem_priv).unwrap(); + let verifying_key = + ed25519_dalek::VerifyingKey::from_public_key_pem(expected_pem_pub).unwrap(); + assert_eq!( + signing_key.verifying_key(), + verifying_key, + "sanity check: the pub and priv PEM should match" + ); + + // Dalek -> JWK + assert_eq!( + dalek_signing_key_to_jwk(&signing_key, ExportPrivKeys::True), + expected_privkey, + "converting dalek to jwk should match the expected result" + ); + assert_eq!( + dalek_signing_key_to_jwk(&signing_key, ExportPrivKeys::False), + expected_pubkey, + "converting dalek to jwk should match the expected result" + ); + } +} diff --git a/jwk-util/src/main.rs b/jwk-util/src/main.rs new file mode 100644 index 00000000..0744e077 --- /dev/null +++ b/jwk-util/src/main.rs @@ -0,0 +1,95 @@ +mod conversions; + +use std::io::{Read as _, Write as _}; + +use clap::{ + builder::{styling::AnsiColor, Styles}, + command, Parser, +}; +use color_eyre::eyre::{ensure, WrapErr as _}; +use ed25519_dalek::pkcs8::DecodePrivateKey as _; + +use crate::conversions::{dalek_signing_key_to_jwk, ExportPrivKeys}; + +const BUILD_INFO: orb_build_info::BuildInfo = orb_build_info::make_build_info!(); + +fn clap_v3_styles() -> Styles { + Styles::styled() + .header(AnsiColor::Yellow.on_default()) + .usage(AnsiColor::Green.on_default()) + .literal(AnsiColor::Green.on_default()) + .placeholder(AnsiColor::Green.on_default()) +} + +#[derive(Debug, Parser)] +#[command( + author, + version = BUILD_INFO.version, + styles = clap_v3_styles(), +)] +struct Args { + /// The input format + #[clap(long)] + in_fmt: Format, + /// The output format + #[clap(long)] + out_fmt: Format, + /// The file to read as input. + #[clap(long)] + in_file: std::path::PathBuf, + /// The file to write as output. + #[clap(long)] + out_file: Option, + /// If provided, the output format will include private keys. Will error if the + /// input format doesn't have any private keys + #[clap(long)] + export_priv_keys: bool, +} + +#[derive(Debug, Eq, PartialEq, clap::ValueEnum, Clone, Copy)] +enum Format { + Pkcs8, + Jwk, +} + +fn main() -> color_eyre::Result<()> { + let args = Args::parse(); + + ensure!( + args.in_fmt == Format::Pkcs8, + "todo: input formats other than PKCS8 are not yet supported" + ); + + let mut pem_contents = String::new(); + std::fs::File::open(args.in_file) + .wrap_err("failed to open in_file")? + .read_to_string(&mut pem_contents) + .wrap_err("error while reading file")?; + + // TODO: We should also support pem public keys + let signing_key = ed25519_dalek::SigningKey::from_pkcs8_pem(&pem_contents) + .wrap_err("failed to parse PEM contents into ed25519 signing key")?; + + let jwk = dalek_signing_key_to_jwk( + &signing_key, + if args.export_priv_keys { + ExportPrivKeys::True + } else { + ExportPrivKeys::False + }, + ); + let jwk_string = + serde_json::to_string(&jwk).wrap_err("failed to serialize jwk to string")?; + + if let Some(out_path) = args.out_file { + let mut out_file = + std::fs::File::create(out_path).wrap_err("failed to create output file")?; + out_file + .write_all(jwk_string.as_bytes()) + .wrap_err("failed to write to file")?; + } else { + println!("{jwk_string}"); + } + + Ok(()) +} diff --git a/security-utils/Cargo.toml b/security-utils/Cargo.toml index 17eff4af..029fd64f 100644 --- a/security-utils/Cargo.toml +++ b/security-utils/Cargo.toml @@ -16,6 +16,6 @@ reqwest = ["dep:reqwest"] [dependencies] eyre = "0.6" -hex-literal = "0.4.1" +hex-literal.workspace = true reqwest = { version = "0.11", default-features = false, features = ["rustls-tls-manual-roots"], optional = true } ring = "0.17.0" diff --git a/update-agent/core/Cargo.toml b/update-agent/core/Cargo.toml index 70814fb2..43b097ae 100644 --- a/update-agent/core/Cargo.toml +++ b/update-agent/core/Cargo.toml @@ -18,12 +18,12 @@ rust-version.workspace = true skip-manifest-signature-verification = [] [dependencies] -base64 = "0.22.1" +base64.workspace = true dogstatsd = "0.11.1" -ed25519-dalek = "2.1.1" +ed25519-dalek.workspace = true gpt.workspace = true -hex-literal = "0.4.1" -jose-jwk = { version = "0.1.2", default-features = false } +hex-literal.workspace = true +jose-jwk = { workspace = true, default-features = false } once_cell = "1.18.0" serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true, features = [ "raw_value" ] }