diff --git a/Cargo.toml b/Cargo.toml index ba9e30c..1fac4da 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,7 +46,7 @@ required-features = ["agent"] env_logger = "0.11.0" rand = "0.8.5" rsa = { version = "0.9.6", features = ["sha2", "sha1"] } -tokio = { version = "1", features = ["macros", "rt-multi-thread"] } +tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync"] } sha1 = { version = "0.10.5", default-features = false, features = ["oid"] } testresult = "0.4.0" hex-literal = "0.4.1" diff --git a/examples/extensions.rs b/examples/extensions.rs new file mode 100644 index 0000000..89965ac --- /dev/null +++ b/examples/extensions.rs @@ -0,0 +1,161 @@ +use ssh_agent_lib::proto::{extension::MessageExtension, Identity, ProtoError}; +use ssh_encoding::{CheckedSum, Decode, Encode, Reader, Writer}; +use ssh_key::public::KeyData; + +pub struct RequestDecryptIdentities; + +const DECRYPT_DERIVE_IDS: &str = "decrypt-derive-ids@metacode.biz"; + +impl MessageExtension for RequestDecryptIdentities { + const NAME: &'static str = DECRYPT_DERIVE_IDS; +} + +impl Encode for RequestDecryptIdentities { + fn encoded_len(&self) -> Result { + Ok(0) + } + + fn encode(&self, _writer: &mut impl Writer) -> Result<(), ssh_encoding::Error> { + Ok(()) + } +} + +impl Decode for RequestDecryptIdentities { + type Error = ProtoError; + + fn decode(_reader: &mut impl Reader) -> core::result::Result { + Ok(Self) + } +} + +#[derive(Debug)] +pub struct DecryptIdentities { + pub identities: Vec, +} + +impl MessageExtension for DecryptIdentities { + const NAME: &'static str = DECRYPT_DERIVE_IDS; +} + +impl Decode for DecryptIdentities { + type Error = ProtoError; + + fn decode(reader: &mut impl Reader) -> Result { + let len = u32::decode(reader)?; + let mut identities = vec![]; + + for _ in 0..len { + identities.push(Identity::decode(reader)?); + } + + Ok(Self { identities }) + } +} + +impl Encode for DecryptIdentities { + fn encoded_len(&self) -> ssh_encoding::Result { + let ids = &self.identities; + let mut lengths = Vec::with_capacity(1 + ids.len()); + // Prefixed length + lengths.push(4); + + for id in ids { + lengths.push(id.encoded_len()?); + } + + lengths.checked_sum() + } + + fn encode(&self, writer: &mut impl Writer) -> ssh_encoding::Result<()> { + let ids = &self.identities; + (ids.len() as u32).encode(writer)?; + for id in ids { + id.encode(writer)?; + } + Ok(()) + } +} + +const DECRYPT_DERIVE: &str = "decrypt-derive@metacode.biz"; + +#[derive(Clone, PartialEq, Debug)] +pub struct DecryptDeriveRequest { + pub pubkey: KeyData, + + pub data: Vec, + + pub flags: u32, +} + +impl MessageExtension for DecryptDeriveRequest { + const NAME: &'static str = DECRYPT_DERIVE; +} + +impl Decode for DecryptDeriveRequest { + type Error = ProtoError; + + fn decode(reader: &mut impl Reader) -> Result { + let pubkey = reader.read_prefixed(KeyData::decode)?; + let data = Vec::decode(reader)?; + let flags = u32::decode(reader)?; + + Ok(Self { + pubkey, + data, + flags, + }) + } +} + +impl Encode for DecryptDeriveRequest { + fn encoded_len(&self) -> ssh_encoding::Result { + [ + self.pubkey.encoded_len_prefixed()?, + self.data.encoded_len()?, + self.flags.encoded_len()?, + ] + .checked_sum() + } + + fn encode(&self, writer: &mut impl Writer) -> ssh_encoding::Result<()> { + self.pubkey.encode_prefixed(writer)?; + self.data.encode(writer)?; + self.flags.encode(writer)?; + + Ok(()) + } +} + +#[derive(Debug)] +pub struct DecryptDeriveResponse { + pub data: Vec, +} + +impl MessageExtension for DecryptDeriveResponse { + const NAME: &'static str = DECRYPT_DERIVE; +} + +impl Encode for DecryptDeriveResponse { + fn encoded_len(&self) -> Result { + self.data.encoded_len() + } + + fn encode(&self, writer: &mut impl Writer) -> Result<(), ssh_encoding::Error> { + self.data.encode(writer) + } +} + +impl Decode for DecryptDeriveResponse { + type Error = ProtoError; + + fn decode(reader: &mut impl Reader) -> core::result::Result { + Ok(Self { + data: Vec::decode(reader)?, + }) + } +} + +#[allow(dead_code)] // rust will complain if main is missing in example crate +fn main() { + panic!("This is just a helper lib crate for extensions"); +} diff --git a/examples/openpgp-card-agent.rs b/examples/openpgp-card-agent.rs index f84d305..dc40fab 100644 --- a/examples/openpgp-card-agent.rs +++ b/examples/openpgp-card-agent.rs @@ -20,7 +20,7 @@ use card_backend_pcsc::PcscBackend; use clap::Parser; use openpgp_card::{ algorithm::AlgorithmAttributes, - crypto_data::{EccType, PublicKeyMaterial}, + crypto_data::{Cryptogram, EccType, PublicKeyMaterial}, Card, KeyType, }; use retainer::{Cache, CacheExpiration}; @@ -29,13 +29,20 @@ use service_binding::Binding; use ssh_agent_lib::{ agent::{bind, Session}, error::AgentError, - proto::{AddSmartcardKeyConstrained, Identity, KeyConstraint, SignRequest, SmartcardKey}, + proto::{ + extension::MessageExtension, AddSmartcardKeyConstrained, Extension, Identity, + KeyConstraint, ProtoError, SignRequest, SmartcardKey, + }, }; use ssh_key::{ public::{Ed25519PublicKey, KeyData}, Algorithm, Signature, }; use testresult::TestResult; +mod extensions; +use extensions::{ + DecryptDeriveRequest, DecryptDeriveResponse, DecryptIdentities, RequestDecryptIdentities, +}; #[derive(Clone)] struct CardSession { @@ -114,6 +121,36 @@ impl CardSession { Err(error) => Err(AgentError::other(error)), } } + + async fn decrypt_derive( + &mut self, + req: DecryptDeriveRequest, + ) -> Result>, Box> { + if let Ok(cards) = PcscBackend::cards(None) { + for card in cards { + let mut card = Card::new(card?)?; + let mut tx = card.transaction()?; + if let PublicKeyMaterial::E(e) = tx.public_key(KeyType::Decryption)? { + if let AlgorithmAttributes::Ecc(ecc) = e.algo() { + if ecc.ecc_type() == EccType::ECDH { + let pubkey = KeyData::Ed25519(Ed25519PublicKey(e.data().try_into()?)); + if pubkey == req.pubkey { + let ident = tx.application_identifier()?.ident(); + let pin = self.pwds.get(&ident).await; + if let Some(pin) = pin { + tx.verify_pw1_user(pin.expose_secret().as_bytes())?; + + let data = tx.decipher(Cryptogram::ECDH(&req.data))?; + return Ok(Some(data)); + } + } + } + } + } + } + } + Ok(None) + } } #[ssh_agent_lib::async_trait] @@ -174,6 +211,62 @@ impl Session for CardSession { async fn sign(&mut self, request: SignRequest) -> Result { self.handle_sign(request).await.map_err(AgentError::Other) } + + async fn extension(&mut self, extension: Extension) -> Result, AgentError> { + if extension.name == RequestDecryptIdentities::NAME { + let identities = if let Ok(cards) = PcscBackend::cards(None) { + cards + .flat_map(|card| { + let mut card = Card::new(card?)?; + let mut tx = card.transaction()?; + let ident = tx.application_identifier()?.ident(); + if let PublicKeyMaterial::E(e) = tx.public_key(KeyType::Decryption)? { + if let AlgorithmAttributes::Ecc(ecc) = e.algo() { + if ecc.ecc_type() == EccType::ECDH { + return Ok::<_, Box>(Some(Identity { + pubkey: KeyData::Ed25519(Ed25519PublicKey( + e.data().try_into()?, + )), + comment: ident, + })); + } + } + } + Ok(None) + }) + .flatten() + .collect::>() + } else { + vec![] + }; + + Ok(Some( + Extension::new_message(DecryptIdentities { identities }) + .map_err(AgentError::other)?, + )) + } else if extension.name == DecryptDeriveRequest::NAME { + let req = extension + .parse_message::()? + .expect("message to be there"); + + let decrypted = self.decrypt_derive(req).await.map_err(AgentError::Other)?; + + if let Some(decrypted) = decrypted { + Ok(Some( + Extension::new_message(DecryptDeriveResponse { data: decrypted }) + .map_err(AgentError::other)?, + )) + } else { + Err(AgentError::from(ProtoError::UnsupportedCommand { + command: 27, + })) + } + } else { + Err(AgentError::from(ProtoError::UnsupportedCommand { + command: 27, + })) + } + } } #[derive(Debug, Parser)] diff --git a/examples/pgp-wrapper.rs b/examples/pgp-wrapper.rs index f7bfbaf..22c364f 100644 --- a/examples/pgp-wrapper.rs +++ b/examples/pgp-wrapper.rs @@ -32,65 +32,196 @@ //! ``` //! //! Works perfectly in conjunction with `openpgp-card-agent.rs`! +//! +//! If the SSH agent implements `decrypt derive` extension this agent additionally +//! creates encryption capable subkey and supports the `decrypt` subcommand: +//! +//! ```sh +//! echo I like strawberries | gpg -er 4EB27E153DDC454364B36B59A142E92C91BE3AD5 > /tmp/encrypted.pgp +//! SSH_AUTH_SOCK=/tmp/ext-agent.sock cargo run --example pgp-wrapper -- decrypt < /tmp/encrypted.pgp +//! ... +//! I like strawberries +//! ``` -use std::cell::RefCell; +use std::io::Write as _; use chrono::DateTime; use clap::Parser; use pgp::{ crypto::{ecc_curve::ECCCurve, hash::HashAlgorithm, public_key::PublicKeyAlgorithm}, packet::{ - KeyFlags, PublicKey, SignatureConfig, SignatureType, SignatureVersion, Subpacket, - SubpacketData, UserId, + KeyFlags, PacketTrait, PublicKey, SignatureConfig, SignatureType, SignatureVersion, + Subpacket, SubpacketData, UserId, }, ser::Serialize, - types::{KeyTrait, KeyVersion, Mpi, PublicKeyTrait, PublicParams, SecretKeyTrait, Version}, - KeyDetails, Signature, + types::{ + CompressionAlgorithm, KeyTrait, KeyVersion, Mpi, PublicKeyTrait, PublicParams, + SecretKeyTrait, Version, + }, + Deserializable as _, Esk, KeyDetails, Message, PlainSessionKey, Signature, }; use service_binding::Binding; -use ssh_agent_lib::{agent::Session, client::connect, proto::SignRequest}; +use ssh_agent_lib::{ + agent::Session, + client::connect, + proto::{Extension, SignRequest}, +}; use ssh_key::public::KeyData; use tokio::runtime::Runtime; +use tokio::sync::Mutex; +mod extensions; +use extensions::{ + DecryptDeriveRequest, DecryptDeriveResponse, DecryptIdentities, RequestDecryptIdentities, +}; struct WrappedKey { public_key: PublicKey, pubkey: KeyData, - client: RefCell>, + client: Mutex>, } -impl WrappedKey { - fn new(pubkey: KeyData, client: Box) -> Self { - let KeyData::Ed25519(key) = pubkey.clone() else { - panic!("The first key was not ed25519!"); - }; +#[derive(Clone, Copy, Debug)] +enum KeyRole { + Signing, + Decryption, +} + +impl From for PublicKeyAlgorithm { + fn from(value: KeyRole) -> Self { + match value { + KeyRole::Signing => PublicKeyAlgorithm::EdDSA, + KeyRole::Decryption => PublicKeyAlgorithm::ECDH, + } + } +} - let mut key_bytes = key.0.to_vec(); - // Add prefix to mark that this MPI uses EdDSA point representation. - // See https://datatracker.ietf.org/doc/draft-koch-eddsa-for-openpgp/ - key_bytes.insert(0, 0x40); - - let public_key = PublicKey::new( - Version::New, - KeyVersion::V4, - PublicKeyAlgorithm::EdDSA, - // use fixed date so that the fingerprint generation is deterministic - DateTime::parse_from_rfc3339("2016-09-06T17:00:00+02:00") - .expect("date to be valid") - .into(), - None, - PublicParams::EdDSA { - curve: ECCCurve::Ed25519, - q: key_bytes.into(), - }, - ) - .expect("key to be valid"); +fn ssh_to_pgp(pubkey: KeyData, key_role: KeyRole) -> PublicKey { + let KeyData::Ed25519(key) = pubkey.clone() else { + panic!("The first key was not ed25519!"); + }; + + let mut key_bytes = key.0.to_vec(); + // Add prefix to mark that this MPI uses EdDSA point representation. + // See https://datatracker.ietf.org/doc/draft-koch-eddsa-for-openpgp/ + key_bytes.insert(0, 0x40); + + let public_params = match key_role { + KeyRole::Signing => PublicParams::EdDSA { + curve: ECCCurve::Ed25519, + q: key_bytes.into(), + }, + // most common values taken from + // https://gitlab.com/sequoia-pgp/sequoia/-/issues/838#note_909813463 + KeyRole::Decryption => PublicParams::ECDH { + curve: ECCCurve::Curve25519, + p: key_bytes.into(), + hash: HashAlgorithm::SHA2_256, + alg_sym: pgp::crypto::sym::SymmetricKeyAlgorithm::AES128, + }, + }; + + PublicKey::new( + Version::New, + KeyVersion::V4, + key_role.into(), + // use fixed date so that the fingerprint generation is deterministic + DateTime::parse_from_rfc3339("2016-09-06T17:00:00+02:00") + .expect("date to be valid") + .into(), + None, + public_params, + ) + .expect("key to be valid") +} +impl WrappedKey { + fn new(pubkey: KeyData, client: Box, key_role: KeyRole) -> Self { + let public_key = ssh_to_pgp(pubkey.clone(), key_role); Self { pubkey, - client: RefCell::new(client), + client: Mutex::new(client), public_key, } } + + fn decrypt( + &self, + mpis: &[Mpi], + ) -> Result<(Vec, pgp::crypto::sym::SymmetricKeyAlgorithm), pgp::errors::Error> { + if let PublicParams::ECDH { + curve, + alg_sym, + hash, + .. + } = self.public_key().public_params() + { + let ciphertext = mpis[0].as_bytes(); + + // encrypted and wrapped value derived from the session key + let encrypted_session_key = mpis[2].as_bytes(); + + let ciphertext = if *curve == ECCCurve::Curve25519 { + assert_eq!( + ciphertext[0], 0x40, + "Unexpected shape of Cv25519 encrypted data" + ); + + // Strip trailing 0x40 + &ciphertext[1..] + } else { + unimplemented!(); + }; + + let plaintext = Runtime::new() + .expect("creating runtime to succeed") + .handle() + .block_on(async { + let mut client = self.client.lock().await; + let result = client.extension( + Extension::new_message(DecryptDeriveRequest { + pubkey: self.pubkey.clone(), + data: ciphertext.to_vec(), + flags: 0, + }) + .expect("encoding to work"), + ); + result.await + }) + .expect("decryption to succeed") + .expect("result not to be empty"); + + let shared_secret = &plaintext + .parse_message::() + .expect("decoding to succeed") + .expect("not to be empty") + .data[..]; + + let encrypted_key_len: usize = mpis[1].first().copied().map(Into::into).unwrap_or(0); + + let decrypted_key: Vec = pgp::crypto::ecdh::derive_session_key( + shared_secret.try_into().expect("shape to be good"), + encrypted_session_key, + encrypted_key_len, + &(curve.oid(), *alg_sym, *hash), + &self.public_key.fingerprint(), + )?; + + // strip off the leading session key algorithm octet, and the two trailing checksum octets + let dec_len = decrypted_key.len(); + let (sessionkey, checksum) = ( + &decrypted_key[1..dec_len - 2], + &decrypted_key[dec_len - 2..], + ); + + // ... check the checksum, while we have it at hand + pgp::crypto::checksum::simple(checksum, sessionkey)?; + + let session_key_algorithm = decrypted_key[0].into(); + Ok((sessionkey.to_vec(), session_key_algorithm)) + } else { + unimplemented!(); + } + } } impl std::fmt::Debug for WrappedKey { @@ -141,15 +272,14 @@ impl SecretKeyTrait for WrappedKey { type Unlocked = Self; - fn unlock(&self, _pw: F, _work: G) -> pgp::errors::Result + fn unlock(&self, _pw: F, work: G) -> pgp::errors::Result where F: FnOnce() -> String, G: FnOnce(&Self::Unlocked) -> pgp::errors::Result, { - unimplemented!("key unlock is implemented in the ssh agent") + work(self) } - #[allow(clippy::await_holding_refcell_ref)] fn create_signature( &self, _key_pw: F, @@ -163,7 +293,7 @@ impl SecretKeyTrait for WrappedKey { .expect("creating runtime to succeed") .handle() .block_on(async { - let mut client = self.client.try_borrow_mut().expect("not to be shared"); + let mut client = self.client.lock().await; let result = client.sign(SignRequest { pubkey: self.pubkey.clone(), data: data.to_vec(), @@ -196,6 +326,7 @@ impl SecretKeyTrait for WrappedKey { enum Args { Generate { userid: String }, Sign, + Decrypt, } fn main() -> testresult::TestResult { @@ -203,7 +334,7 @@ fn main() -> testresult::TestResult { let rt = Runtime::new()?; - let (client, identities) = rt.block_on(async move { + let (client, identities, decrypt_ids) = rt.block_on(async move { #[cfg(unix)] let mut client = connect(Binding::FilePath(std::env::var("SSH_AUTH_SOCK")?.into()).try_into()?)?; @@ -218,15 +349,46 @@ fn main() -> testresult::TestResult { panic!("We need at least one ed25519 identity!"); } - Ok::<_, testresult::TestError>((client, identities)) + let decrypt_ids = if let Ok(Some(identities)) = client + .extension(Extension::new_message(RequestDecryptIdentities)?) + .await + { + identities + .parse_message::()? + .map(|d| d.identities) + .unwrap_or_default() + } else { + vec![] + }; + + Ok::<_, testresult::TestError>((client, identities, decrypt_ids)) })?; let pubkey = &identities[0].pubkey; - let signer = WrappedKey::new(pubkey.clone(), client); - match args { Args::Generate { userid } => { + let subkeys = if let Some(decryption_id) = decrypt_ids.first() { + let mut keyflags = KeyFlags::default(); + keyflags.set_encrypt_comms(true); + keyflags.set_encrypt_storage(true); + let pk = ssh_to_pgp(decryption_id.pubkey.clone(), KeyRole::Decryption); + vec![pgp::PublicSubkey::new( + pgp::packet::PublicSubkey::new( + pk.packet_version(), + pk.version(), + pk.algorithm(), + *pk.created_at(), + pk.expiration(), + pk.public_params().clone(), + )?, + keyflags, + )] + } else { + vec![] + }; + + let signer = WrappedKey::new(pubkey.clone(), client, KeyRole::Signing); let mut keyflags = KeyFlags::default(); keyflags.set_sign(true); keyflags.set_certify(true); @@ -240,15 +402,16 @@ fn main() -> testresult::TestResult { keyflags, Default::default(), Default::default(), - Default::default(), + vec![CompressionAlgorithm::Uncompressed].into(), None, ), - vec![], + subkeys, ); let signed_pk = composed_pk.sign(&signer, String::new)?; signed_pk.to_writer(&mut std::io::stdout())?; } Args::Sign => { + let signer = WrappedKey::new(pubkey.clone(), client, KeyRole::Signing); let signature = SignatureConfig::new_v4( SignatureVersion::V4, SignatureType::Binary, @@ -281,6 +444,37 @@ fn main() -> testresult::TestResult { let signature = Signature::from_config(signature, signed_hash_value, raw_sig); pgp::packet::write_packet(&mut std::io::stdout(), &signature)?; } + Args::Decrypt => { + let decryptor = + WrappedKey::new(decrypt_ids[0].pubkey.clone(), client, KeyRole::Decryption); + let message = Message::from_bytes(std::io::stdin())?; + + let Message::Encrypted { esk, edata } = message else { + panic!("not encrypted"); + }; + + let mpis = if let Esk::PublicKeyEncryptedSessionKey(ref k) = esk[0] { + k.mpis() + } else { + panic!("whoops") + }; + + let (session_key, session_key_algorithm) = + decryptor.unlock(String::new, |priv_key| priv_key.decrypt(mpis))?; + + let plain_session_key = PlainSessionKey::V4 { + key: session_key, + sym_alg: session_key_algorithm, + }; + + let decrypted = edata.decrypt(plain_session_key)?; + + if let Message::Literal(data) = decrypted { + std::io::stdout().write_all(data.data())?; + } else { + eprintln!("decrypted: {:?}", &decrypted); + } + } } Ok(()) diff --git a/examples/ssh-agent-client.rs b/examples/ssh-agent-client.rs index 41b41c1..410d621 100644 --- a/examples/ssh-agent-client.rs +++ b/examples/ssh-agent-client.rs @@ -1,6 +1,7 @@ use service_binding::Binding; -use ssh_agent_lib::client::connect; - +use ssh_agent_lib::{client::connect, proto::Extension}; +mod extensions; +use extensions::{DecryptIdentities, RequestDecryptIdentities}; #[tokio::main] async fn main() -> Result<(), Box> { #[cfg(unix)] @@ -16,5 +17,15 @@ async fn main() -> Result<(), Box> { client.request_identities().await? ); + if let Ok(Some(identities)) = client + .extension(Extension::new_message(RequestDecryptIdentities)?) + .await + { + let identities = identities.parse_message::()?; + eprintln!("Decrypt identities that this agent knows of: {identities:#?}",); + } else { + eprintln!("No decryption identities found."); + } + Ok(()) }