diff --git a/Cargo.lock b/Cargo.lock index 2bf1a10..1a907ab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -243,8 +243,11 @@ dependencies = [ "indoc", "josekit", "migrations", + "scrypt", "sea-orm", "sequoia-openpgp", + "serde", + "serde_json", "thiserror", "tokio", ] @@ -1446,9 +1449,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.2.0" +version = "2.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf2a4f498956c7723dc280afc6a37d0dec50b39a29e232c6187ce4503703e8c2" +checksum = "233cf39063f058ea2caae4091bf4a3ef70a653afbc026f5c4a4135d114e3c177" dependencies = [ "equivalent", "hashbrown 0.14.3", @@ -2055,6 +2058,16 @@ version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "de3145af08024dea9fa9914f381a17b8fc6034dfb00f3a84013f7ff43f29ed4c" +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + [[package]] name = "peeking_take_while" version = "0.1.2" @@ -2534,6 +2547,15 @@ version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f98d2aa92eebf49b69786be48e4477826b256916e84a57ff2a4f21923b48eb4c" +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher", +] + [[package]] name = "same-file" version = "1.0.6" @@ -2558,6 +2580,18 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scrypt" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" +dependencies = [ + "password-hash", + "pbkdf2", + "salsa20", + "sha2", +] + [[package]] name = "sct" version = "0.7.1" @@ -2787,9 +2821,9 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.196" +version = "1.0.197" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "870026e60fa08c69f064aa766c10f10b1d62db9ccd4d0abb206472bee0ce3b32" +checksum = "3fb1c873e1b9b056a4dc4c0c198b24c3ffa059243875552b2bd0933b1aee4ce2" dependencies = [ "serde_derive", ] @@ -2806,9 +2840,9 @@ dependencies = [ [[package]] name = "serde_derive" -version = "1.0.196" +version = "1.0.197" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33c85360c95e7d137454dc81d9a4ed2b8efd8fbe19cee57357b32b9771fccb67" +checksum = "7eb0b34b42edc17f6b7cac84a52a1c5f0e1bb2227e997ca9011ea3dd34e8610b" dependencies = [ "proc-macro2", "quote", @@ -2817,9 +2851,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.112" +version = "1.0.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d1bd37ce2324cf3bf85e5a25f96eb4baf0d5aa6eba43e7ae8958870c4ec48ed" +checksum = "c5f09b1bd632ef549eaa9f60a1f8de742bdbc698e6cee2095fc84dde5f549ae0" dependencies = [ "indexmap", "itoa", diff --git a/Cargo.toml b/Cargo.toml index 82b0be5..471e888 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,9 @@ sequoia-openpgp = { version = "1.18.0", optional = true } josekit = { version = "0.8.5" } aes-gcm = "0.10.3" migrations = { path = "crates/migrations" } +scrypt = "0.11.0" +serde = { version = "1.0.197", features = ["derive"] } +serde_json = "1.0.114" [features] gpg-compat = ["dep:gpgme", "dep:sequoia-openpgp"] diff --git a/crates/asp/src/keys/mod.rs b/crates/asp/src/keys/mod.rs index 19e5209..c79199c 100644 --- a/crates/asp/src/keys/mod.rs +++ b/crates/asp/src/keys/mod.rs @@ -122,7 +122,7 @@ impl AspKey { Self::from_jwk(key_pair.to_jwk_key_pair()) } - pub fn into_pkcs8(&self) -> Result { + pub fn into_pkcs8(&self) -> Result, anyhow::Error> { // 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 @@ -142,9 +142,8 @@ impl AspKey { 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) + Ok(pkcs8) } pub fn generate(key_type: AspKeyType) -> Result { diff --git a/src/commands/keys/export.rs b/src/commands/keys/export.rs index f10683b..e6c55ae 100644 --- a/src/commands/keys/export.rs +++ b/src/commands/keys/export.rs @@ -1,5 +1,5 @@ use aes_gcm::{ - aead::{Aead, KeyInit}, + aead::{rand_core::RngCore, Aead, KeyInit, OsRng}, Aes256Gcm, Key, }; use anstyle::{AnsiColor, Color as AnstyleColor, Reset, Style as Anstyle}; @@ -7,10 +7,11 @@ use anyhow::{anyhow, Context}; use argon2::{password_hash::SaltString, Argon2, PasswordHasher}; use asp::keys::AspKey; use clap::{Parser, ValueEnum}; -use data_encoding::BASE64_NOPAD; +use data_encoding::{BASE64, BASE64_NOPAD}; use dialoguer::{theme::ColorfulTheme, Password}; use indoc::writedoc; use josekit::jwk::Jwk; +use serde::Serialize; use std::io::Write; @@ -19,12 +20,35 @@ use crate::{ entities::prelude::*, }; +#[derive(Serialize)] +#[serde(tag = "alg", rename = "scrypt")] +pub struct ASPToolScryptExport { + #[serde(rename = "prm")] + parameters: ASPToolScryptExportParam, + #[serde(rename = "slt")] + salt: String, + #[serde(rename = "key")] + encrypted_key: String, +} + +#[derive(Serialize)] +pub struct ASPToolScryptExportParam { + #[serde(rename = "N")] + cost: u64, + #[serde(rename = "r")] + block_size: u32, + #[serde(rename = "p")] + parallelism: u32, +} + #[derive(ValueEnum, Debug, Clone)] pub enum KeyExportFormat { - /// An unencrypted PKCS#8 format. This is the format used by the asp.keyoxide.org web tool. + /// An unencrypted PKCS#8 format. #[clap(alias = "PKCS#8")] PKCS8, - /// An unencrypted raw JSON Web Key format. This is likely not the best output to use unless you know what you are doing. + /// An scrypt-encrypted PKCS#8 key, formatted for the asp.keyoxide.org web tool. The password on the exported key is the same as the one used for ASPM. + ASPTool, + /// An unencrypted raw JSON Web Key (JWK) format. Jwk, } @@ -92,10 +116,52 @@ impl AspmSubcommand for KeysExportCommand { let decrypted = AspKey::from_jwk(Jwk::from_bytes(decrypted)?)?; let export = match self.format { - KeyExportFormat::PKCS8 => decrypted - .into_pkcs8() - .context("Unable to convert key into PKCS#8 format")?, KeyExportFormat::Jwk => decrypted.jwk.to_string(), + KeyExportFormat::PKCS8 => BASE64_NOPAD.encode( + decrypted + .into_pkcs8() + .context("Unable to convert key into PKCS#8 format")? + .as_slice(), + ), + KeyExportFormat::ASPTool => { + let pkcs8 = decrypted + .into_pkcs8() + .context("Unable to convert key into PKCS#8 format")?; + let mut salt = [0u8; 16]; + OsRng.fill_bytes(&mut salt); + + let mut derived_key = vec![0u8; pkcs8.len()]; + scrypt::scrypt( + key_password.as_bytes(), + &salt, + &scrypt::Params::recommended(), + &mut derived_key, + ) + .context("Unable to derive PKCS#8 encryption key with scrypt")?; + + let encrypted_key = BASE64.encode( + pkcs8 + .into_iter() + .enumerate() + .map(|(i, byte)| byte ^ derived_key[i]) + .collect::>() + .as_slice(), + ); + + BASE64.encode( + serde_json::to_string(&ASPToolScryptExport { + parameters: ASPToolScryptExportParam { + cost: 1 << scrypt::Params::RECOMMENDED_LOG_N, + block_size: scrypt::Params::RECOMMENDED_R, + parallelism: scrypt::Params::RECOMMENDED_P, + }, + salt: BASE64.encode(&salt), + encrypted_key, + }) + .context("Unable to construct JSON form of ASPTool export")? + .as_bytes(), + ) + } }; eprintln!( diff --git a/src/commands/keys/import.rs b/src/commands/keys/import.rs index 2e44e78..8826dd7 100644 --- a/src/commands/keys/import.rs +++ b/src/commands/keys/import.rs @@ -158,48 +158,42 @@ impl AspmSubcommand for KeysImportCommand { let public_part = key.mpis(); let (curve, public, secret) = match key.secret() { - key::SecretKeyMaterial::Unencrypted(data) => { - data.map(|secret_material| { - let (curve, public, private) = match secret_material { - mpi::SecretKeyMaterial::ECDSA { scalar } => { - let mpi::PublicKey::ECDSA { - curve, - q: public_part, - } = public_part - else { - unreachable!() - }; - - (curve, public_part, scalar) - }, - mpi::SecretKeyMaterial::EdDSA { scalar } => { - let mpi::PublicKey::EdDSA { - curve, - q: public_part, - } = public_part - else { - unreachable!() - }; - - (curve, public_part, scalar) - }, - _ => bail!("Invalid PGP key type, must be either P-256 or Ed25519") - }; + key::SecretKeyMaterial::Unencrypted(data) => data.map(|secret_material| { + let (curve, public, private) = match secret_material { + mpi::SecretKeyMaterial::ECDSA { scalar } => { + let mpi::PublicKey::ECDSA { + curve, + q: public_part, + } = public_part + else { + unreachable!() + }; - Ok(( - curve.clone(), - public.clone(), - private.clone() - )) - })? - } + (curve, public_part, scalar) + } + mpi::SecretKeyMaterial::EdDSA { scalar } => { + let mpi::PublicKey::EdDSA { + curve, + q: public_part, + } = public_part + else { + unreachable!() + }; + + (curve, public_part, scalar) + } + _ => bail!("Invalid PGP key type, must be either P-256 or Ed25519"), + }; + + Ok((curve.clone(), public.clone(), private.clone())) + })?, _ => unreachable!(), }; AspKey::from_jwk( Jwk::from_map({ let mut map = josekit::Map::new(); - + match curve { Curve::Ed25519 => { map.insert("kty".to_string(), "OKP".into()); @@ -237,7 +231,8 @@ impl AspmSubcommand for KeysImportCommand { } map - }).context("Unable to construct Jwk representation of PGP key")? + }) + .context("Unable to construct Jwk representation of PGP key")?, )? } };