diff --git a/Cargo.lock b/Cargo.lock index f8b0d2e48..ecc58f375 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -177,6 +177,7 @@ dependencies = [ "apple-flat-package", "apple-xar", "aws-config", + "aws-sdk-kms", "aws-sdk-s3", "aws-smithy-http", "aws-smithy-types", @@ -476,6 +477,29 @@ dependencies = [ "uuid", ] +[[package]] +name = "aws-sdk-kms" +version = "1.98.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50c74fef3d08159467cad98300f33a2e3bd1a985d527ad66ab0ea83c95e3a615" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "regex-lite", + "tracing", +] + [[package]] name = "aws-sdk-s3" version = "1.120.0" diff --git a/apple-codesign/Cargo.toml b/apple-codesign/Cargo.toml index 51538b906..4dcb02997 100644 --- a/apple-codesign/Cargo.toml +++ b/apple-codesign/Cargo.toml @@ -19,6 +19,7 @@ path = "src/main.rs" anyhow = "1.0.100" aws-config = { version = "1.8.12", optional = true } aws-sdk-s3 = { version = "1.120.0", optional = true } +aws-sdk-kms = { version = "1.98.0", optional = true } aws-smithy-http = { version = "0.62.6", optional = true } aws-smithy-types = { version = "1.3.6", optional = true } base64 = "0.22.1" @@ -121,7 +122,7 @@ trycmd-indygreg-fork = "0.14.20" zip = { version = "7.1.0", default-features = false } [features] -default = ["notarize"] +default = ["notarize", "aws-kms"] notarize = [ "app-store-connect", "aws-config", @@ -131,3 +132,4 @@ notarize = [ ] smartcard = ["yubikey"] pkcs11 = ["cryptoki"] +aws-kms = ["aws-sdk-kms"] diff --git a/apple-codesign/src/aws_kms.rs b/apple-codesign/src/aws_kms.rs new file mode 100644 index 000000000..e7baa4d34 --- /dev/null +++ b/apple-codesign/src/aws_kms.rs @@ -0,0 +1,378 @@ +//! Using the AWS Key Management Service to sign binaries. +//! This uses the AWS SDK, which accepts authentication in similar ways to the +//! CLI: +//! +//! Example: +//! ```text +//! rcodesign sign --aws-kms-key-id 'arn:aws:kms:us-east-1:123456781234:key/12345678-1234-1234-1234-123456789123' --aws-kms-certificate-file ./cert.pem.crt ./rcodesign +//! ``` +//! +//! For testing, you can use a rather annoying key wrapping procedure to +//! import the keys from `rcodesign generate-self-signed-certificate` to KMS: +//! +//! +//! Alternatively, you can create a CSR from the KMS public key and then self +//! sign it. + +use std::io::BufRead; +use std::io::BufReader; +use std::io::Read; +use std::process::Child; +use std::process::Command; +use std::process::Stdio; +use std::sync::Arc; + +use aws_config::BehaviorVersion; +use bcder::{decode::Constructed, Mode}; +use rand::Rng; +use signature::Signer; +use tempfile::TempDir; +use x509_certificate::{ + rfc5280::SubjectPublicKeyInfo, EcdsaCurve, KeyAlgorithm, KeyInfoSigner, Sign, Signature, + SignatureAlgorithm, X509CertificateError, +}; + +use aws_sdk_kms::types::KeySpec as AWSKeySpec; +use aws_sdk_kms::types::SigningAlgorithmSpec as AWSSigningAlgorithm; + +use crate::{cryptography::PrivateKey, AppleCodesignError}; + +fn key_algorithm_to_aws(key_alg: KeyAlgorithm) -> AWSKeySpec { + match key_alg { + KeyAlgorithm::Rsa => AWSKeySpec::Rsa2048, + KeyAlgorithm::Ecdsa(EcdsaCurve::Secp256r1) => AWSKeySpec::EccNistP256, + KeyAlgorithm::Ecdsa(EcdsaCurve::Secp384r1) => AWSKeySpec::EccNistP384, + KeyAlgorithm::Ed25519 => AWSKeySpec::EccNistEdwards25519, + } +} + +pub struct KMSSigner { + pub runtime: tokio::runtime::Runtime, + client: aws_sdk_kms::Client, +} + +pub struct TestKMSSigner { + process: Child, + _stderr_logger: std::thread::JoinHandle<()>, + #[allow(dead_code)] + tmpdir: TempDir, + pub signer: Arc, +} + +impl TestKMSSigner { + /// Creates a test signing process for KMS + pub fn new(local_kms_exe: &str) -> Result { + let tmpdir = TempDir::new()?; + let mut parts = None; + 'trying: for attempt in 1..=5 { + if attempt == 5 { + return Err(AppleCodesignError::AWSKMSError( + "Failed to start up local-kms".into(), + )); + } + let try_port = rand::thread_rng().gen_range(2000u16..65535); + + let mut proc = Command::new(local_kms_exe) + .env("KMS_DATA_PATH", tmpdir.path()) + .env("KMS_SEED_PATH", "/dev/null") + // TODO: this needs to be set somehow safely, I guess we can read + // the stderr and watch for the log line for starting + // successfully?? + .env("PORT", try_port.to_string()) + .stderr(Stdio::piped()) + .spawn()?; + let pipe = proc.stderr.take().unwrap(); + let mut buf_reader = BufReader::new(pipe); + let mut buf = String::new(); + + while let Ok(read) = buf_reader.read_line(&mut buf) { + println!("{}", buf); + if read == 0 { + // EOF before we got a startup message, that seems bad + continue 'trying; + } + if buf.contains("started on") { + // we got one! we unfortunately can't drop the stderr for + // the daemon as it will cause it to terminate, so we have + // to have a thread reading it and throwing it away. + let logger_thread = std::thread::spawn(move || { + let mut buf = vec![0; 100]; + while let Ok(_) = buf_reader.read(&mut buf) {} + }); + parts = Some((proc, try_port, logger_thread)); + break 'trying; + } + buf.clear(); + } + } + let (process, port, logger_thread) = parts.unwrap(); + + let signer = KMSSigner::new_local(port)?; + + Ok(TestKMSSigner { + _stderr_logger: logger_thread, + tmpdir, + process, + signer, + }) + } +} + +impl Drop for TestKMSSigner { + fn drop(&mut self) { + let _ = self.process.kill(); + } +} + +impl KMSSigner { + /// Creates a new KMSSigner for production use. + pub fn new() -> Result, AppleCodesignError> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + + let config = runtime.block_on(aws_config::load_defaults(BehaviorVersion::v2026_01_12())); + let client = aws_sdk_kms::Client::new(&config); + Ok(Arc::new(KMSSigner { runtime, client })) + } + + pub fn new_local(port: u16) -> Result, AppleCodesignError> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + + let config = runtime.block_on( + aws_config::defaults(BehaviorVersion::v2026_01_12()) + .endpoint_url(format!("http://localhost:{port}")) + .test_credentials() + .region("eu-west-2") + .load(), + ); + let client = aws_sdk_kms::Client::new(&config); + Ok(Arc::new(KMSSigner { runtime, client })) + } +} + +pub struct AWSKMSKey { + signer: Arc, + public_key_info: SubjectPublicKeyInfo, + key_id: String, +} + +impl AWSKMSKey { + pub async fn new( + signer: Arc, + key_id: String, + ) -> Result { + let pubkey_resp = signer + .client + .get_public_key() + .key_id(&key_id) + .send() + .await + .map_err(|e| { + AppleCodesignError::AWSKMSGetPublicKeyError( + aws_smithy_types::error::display::DisplayErrorContext(e).into(), + ) + })?; + + let pubkey = pubkey_resp + .public_key + .ok_or(AppleCodesignError::AWSKMSError( + "missing pubkey in pubkey response".to_owned(), + ))?; + + let actual_pubkey = + Constructed::decode(pubkey.as_ref(), Mode::Der, SubjectPublicKeyInfo::take_from) + .map_err(|e| { + AppleCodesignError::AWSKMSError(format!("Non-decodable DER from AWS?! {}", e)) + })?; + + Ok(AWSKMSKey { + signer, + public_key_info: actual_pubkey, + key_id, + }) + } + + /// Creates a new signing key on KMS. + /// + /// This function is intended only for use in tests. + pub async fn create_key( + signer: Arc, + alg: KeyAlgorithm, + ) -> Result<(String, Self), AppleCodesignError> { + let new_key = signer + .client + .create_key() + .key_spec(key_algorithm_to_aws(alg)) + .key_usage(aws_sdk_kms::types::KeyUsageType::SignVerify) + .send() + .await + .expect("creating kms key, testing only"); + + let key_id = &new_key.key_metadata.expect("key metadata missing").key_id; + Ok((key_id.clone(), Self::new(signer, key_id.clone()).await?)) + } + + pub fn public_key_info(&self) -> &SubjectPublicKeyInfo { + &self.public_key_info + } + + fn choose_signature_algorithm( + &self, + ) -> Result<(SignatureAlgorithm, AWSSigningAlgorithm), String> { + let algorithm = self + .key_algorithm() + .ok_or_else(|| "Cannot determine key algorithm from certificate".to_owned())?; + + // For reference: + Ok(match algorithm { + KeyAlgorithm::Ecdsa(EcdsaCurve::Secp256r1) => ( + SignatureAlgorithm::EcdsaSha256, + AWSSigningAlgorithm::EcdsaSha256, + ), + KeyAlgorithm::Ecdsa(EcdsaCurve::Secp384r1) => ( + SignatureAlgorithm::EcdsaSha384, + AWSSigningAlgorithm::EcdsaSha384, + ), + KeyAlgorithm::Rsa => ( + SignatureAlgorithm::RsaSha256, + AWSSigningAlgorithm::RsassaPkcs1V15Sha256, + ), + _ => { + return Err(format!( + "Unsupported key algorithm for AWS KMS: {:?}", + algorithm + )) + } + }) + } +} + +impl Signer for AWSKMSKey { + fn try_sign(&self, msg: &[u8]) -> Result { + let (_alg, aws_algorithm) = self + .choose_signature_algorithm() + .map_err(signature::Error::from_source)?; + + let sig = self + .signer + .runtime + .block_on( + self.signer + .client + .sign() + .key_id(&self.key_id) + .signing_algorithm(aws_algorithm) + .message(msg.into()) + .send(), + ) + .map_err(|e| { + signature::Error::from_source(AppleCodesignError::AWSKMSSignError( + aws_smithy_types::error::display::DisplayErrorContext(e).into(), + )) + })?; + + Ok(sig + .signature + .ok_or(signature::Error::from_source( + "AWS KMS gave us no signature. This should not occur".to_owned(), + ))? + .into_inner() + .into()) + } +} + +impl Sign for AWSKMSKey { + fn sign( + &self, + message: &[u8], + ) -> Result< + (Vec, x509_certificate::SignatureAlgorithm), + x509_certificate::X509CertificateError, + > { + // NOTE: This function isn't actually used anywhere AFAIK. + let (algorithm, _aws_algorithm) = self + .choose_signature_algorithm() + .map_err(X509CertificateError::Other)?; + Ok((self.try_sign(message)?.into(), algorithm)) + } + + fn key_algorithm(&self) -> Option { + KeyAlgorithm::try_from(&self.public_key_info.algorithm).ok() + } + + fn public_key_data(&self) -> bytes::Bytes { + self.public_key_info.subject_public_key.octet_bytes() + } + + fn signature_algorithm( + &self, + ) -> Result { + Ok(self + .choose_signature_algorithm() + .map_err(X509CertificateError::UnknownSignatureAlgorithm)? + .0) + } + + fn private_key_data(&self) -> Option>> { + // This is simply not obtainable. + None + } + + fn rsa_primes( + &self, + ) -> Result< + Option<(zeroize::Zeroizing>, zeroize::Zeroizing>)>, + x509_certificate::X509CertificateError, + > { + // This is also a private key. + Ok(None) + } +} + +impl KeyInfoSigner for AWSKMSKey {} + +impl PrivateKey for AWSKMSKey { + fn as_key_info_signer(&self) -> &dyn x509_certificate::KeyInfoSigner { + self + } + + fn to_public_key_peer_decrypt( + &self, + ) -> Result< + Box, + AppleCodesignError, + > { + Err(AppleCodesignError::AWSKMSError( + "Remote signing is not supported with KMS yet".to_owned(), + )) + } + + fn finish(&self) -> Result<(), AppleCodesignError> { + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture() -> TestKMSSigner { + TestKMSSigner::new("/Users/jade/go/bin/local-kms").expect("making test kms signer") + } + + #[test] + fn test_sign_foo() { + let test = fixture(); + let key = test + .signer + .runtime + .block_on(AWSKMSKey::create_key( + test.signer.clone(), + KeyAlgorithm::Rsa, + )) + .unwrap(); + } +} diff --git a/apple-codesign/src/cli/certificate_source.rs b/apple-codesign/src/cli/certificate_source.rs index b7d30c00a..a39745357 100644 --- a/apple-codesign/src/cli/certificate_source.rs +++ b/apple-codesign/src/cli/certificate_source.rs @@ -644,6 +644,10 @@ pub struct CertificateSource { #[serde(default, rename = "pkcs11", skip_serializing_if = "Option::is_none")] pub pkcs11_key: Option, + #[command(flatten)] + #[serde(default, rename = "aws_kms", skip_serializing_if = "Option::is_none")] + pub aws_kms_key: Option, + #[command(flatten)] #[serde(default, rename = "remote", skip_serializing_if = "Option::is_none")] pub remote_signing_key: Option, @@ -684,6 +688,10 @@ impl CertificateSource { res.push(key as &dyn KeySource); } + if let Some(key) = &self.aws_kms_key { + res.push(key as &dyn KeySource); + } + if let Some(key) = &self.pkcs11_key { res.push(key as &dyn KeySource); } @@ -719,6 +727,117 @@ impl CertificateSource { } } +fn load_certificate_from_file( + cert_file: &PathBuf, +) -> Result { + let cert_data = std::fs::read(cert_file)?; + + // Try PEM first + if let Ok(pem) = pem::parse(&cert_data) { + if pem.tag() == "CERTIFICATE" { + return CapturedX509Certificate::from_der(pem.contents()).map_err(|e| { + AppleCodesignError::Pkcs11Error(format!( + "failed to parse PEM certificate from file {}: {}", + cert_file.display(), + e + )) + }); + } else { + warn!( + "PEM file {} has tag '{}' but expected 'CERTIFICATE'; attempting DER parsing", + cert_file.display(), + pem.tag() + ); + } + } + + // Try DER + CapturedX509Certificate::from_der(cert_data).map_err(|e| { + AppleCodesignError::Pkcs11Error(format!( + "failed to parse certificate from file {}: {}", + cert_file.display(), + e + )) + }) +} + +#[derive(Args, Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct AWSKMSSigningKey { + /// AWS KMS Key ID to sign with. For example: + /// - UUID: `1234abcd-12ab-34cd-56ef-1234567890ab` + /// - ARN: `arn:aws:kms:us-east-2:111122223333:key/1234abcd-12ab-34cd-56ef-1234567890ab` + /// - Alias name: `alias/ExampleAlias` + /// - Alias ARN: `arn:aws:kms:us-east-2:111122223333:alias/ExampleAlias` + #[arg(long = "aws-kms-key-id")] + pub aws_key_id: Option, + + /// Path to certificate file (PEM/DER) + #[arg(long = "aws-kms-certificate-file", value_name = "PATH")] + pub aws_certificate_file: Option, +} + +impl KeySource for AWSKMSSigningKey { + #[cfg(feature = "aws-kms")] + fn resolve_certificates(&self) -> Result { + // Load certificate from local file + let cert = if let Some(cert_file) = &self.aws_certificate_file { + // Load certificate from local file (similar to osslsigncode) + info!( + "loading certificate from local file: {}", + cert_file.display() + ); + Some(load_certificate_from_file(cert_file)?) + } else { + // It is not an error to not have a certificate (e.g. you might be + // generating a CSR), and if it is required it will be enforced + // later. + None + }; + + let key_id = self + .aws_key_id + .as_ref() + .ok_or(AppleCodesignError::AWSKMSError( + "--aws-kms-key-id is required for signing with AWS KMS".to_owned(), + ))?; + + let aws = crate::aws_kms::KMSSigner::new()?; + + let key = aws.runtime.block_on(crate::aws_kms::AWSKMSKey::new( + aws.clone(), + key_id.to_owned(), + ))?; + + if let Some(ref cert) = cert { + info!( + "loaded certificate: {}", + cert.subject_common_name() + .unwrap_or_else(|| "unknown".into()) + ); + let expected_pubkey = &cert.tbs_certificate().subject_public_key_info; + if expected_pubkey != key.public_key_info() { + return Err(AppleCodesignError::KeyNotUsable( + "Public key of given private key does not match the certificate".to_owned(), + )); + } + } + + Ok(SigningCertificates { + keys: vec![Box::new(key)], + certs: cert.into_iter().collect(), + }) + } + + #[cfg(not(feature = "aws-kms"))] + fn resolve_certificates(&self) -> Result { + if self.key_id.is_some() { + error!("AWS KMS support not available; ignoring --aws-kms-* arguments"); + } + Ok(Default::default()) + } +} + #[derive(Args, Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(deny_unknown_fields)] pub struct Pkcs11SigningKey { @@ -821,7 +940,7 @@ impl KeySource for Pkcs11SigningKey { "loading certificate from local file: {}", cert_file.display() ); - self.load_certificate_from_file(cert_file)? + load_certificate_from_file(cert_file)? } else { return Err(AppleCodesignError::Pkcs11Error( "--pkcs11-certificate-file is required for Apple code signing workflows".into(), @@ -902,39 +1021,4 @@ impl Pkcs11SigningKey { Ok(None) } } - - fn load_certificate_from_file( - &self, - cert_file: &PathBuf, - ) -> Result { - let cert_data = std::fs::read(cert_file)?; - - // Try PEM first - if let Ok(pem) = pem::parse(&cert_data) { - if pem.tag() == "CERTIFICATE" { - return CapturedX509Certificate::from_der(pem.contents()).map_err(|e| { - AppleCodesignError::Pkcs11Error(format!( - "failed to parse PEM certificate from file {}: {}", - cert_file.display(), - e - )) - }); - } else { - warn!( - "PEM file {} has tag '{}' but expected 'CERTIFICATE'; attempting DER parsing", - cert_file.display(), - pem.tag() - ); - } - } - - // Try DER - CapturedX509Certificate::from_der(cert_data).map_err(|e| { - AppleCodesignError::Pkcs11Error(format!( - "failed to parse certificate from file {}: {}", - cert_file.display(), - e - )) - }) - } } diff --git a/apple-codesign/src/cryptography.rs b/apple-codesign/src/cryptography.rs index f6a1dda59..4c3fec437 100644 --- a/apple-codesign/src/cryptography.rs +++ b/apple-codesign/src/cryptography.rs @@ -300,6 +300,7 @@ impl<'a> TryFrom> for InMemoryPrivateKey { let curve_oid = value.algorithm.parameters_oid()?; match curve_oid.as_bytes() { + // FIXME(jade): support more curves x if x == OID_EC_P256.as_bytes() => { let secret_key = ECSecretKey::::try_from(value)?; diff --git a/apple-codesign/src/error.rs b/apple-codesign/src/error.rs index df2290909..1c1673e4d 100644 --- a/apple-codesign/src/error.rs +++ b/apple-codesign/src/error.rs @@ -368,6 +368,34 @@ pub enum AppleCodesignError { #[error("zip structs error: {0}")] ZipStructs(#[from] zip_structs::zip_error::ZipReadError), + #[error("AWS KMS error: {0}")] + AWSKMSError(String), + + #[cfg(feature = "aws-kms")] + #[error("AWS KMS sign error: {0}")] + AWSKMSSignError( + Box< + aws_smithy_types::error::display::DisplayErrorContext< + aws_sdk_kms::error::SdkError, + >, + >, + ), + + #[cfg(feature = "aws-kms")] + #[error("AWS KMS get public key error: {0}")] + AWSKMSGetPublicKeyError( + Box< + aws_smithy_types::error::display::DisplayErrorContext< + aws_sdk_kms::error::SdkError< + aws_sdk_kms::operation::get_public_key::GetPublicKeyError, + >, + >, + >, + ), + + #[error("Selected private key is not usable: {0}")] + KeyNotUsable(String), + #[error("PKCS11 error: {0}")] Pkcs11Error(String), diff --git a/apple-codesign/src/lib.rs b/apple-codesign/src/lib.rs index 80fc59721..34ae27b66 100644 --- a/apple-codesign/src/lib.rs +++ b/apple-codesign/src/lib.rs @@ -168,5 +168,8 @@ pub mod windows; #[cfg(feature = "yubikey")] pub mod yubikey; +#[cfg(feature = "aws-kms")] +pub mod aws_kms; + #[cfg(feature = "pkcs11")] pub mod pkcs11; diff --git a/apple-codesign/src/signing_settings.rs b/apple-codesign/src/signing_settings.rs index d997927c3..cc9dcf920 100644 --- a/apple-codesign/src/signing_settings.rs +++ b/apple-codesign/src/signing_settings.rs @@ -279,7 +279,7 @@ impl ScopedSetting { /// Represents code signing settings. /// /// This type holds settings related to a single logical signing operation. -/// Some settings (such as the signing key-pair are global). Other settings +/// Some settings (such as the signing key-pair) are global. Other settings /// (such as the entitlements or designated requirement) can be applied on a /// more granular, scoped basis. The scoping of these lower-level settings is /// controlled via [SettingsScope]. If a setting is specified with a scope, it diff --git a/apple-codesign/src/windows.rs b/apple-codesign/src/windows.rs index 962ce3ae1..4042ce43b 100644 --- a/apple-codesign/src/windows.rs +++ b/apple-codesign/src/windows.rs @@ -386,6 +386,9 @@ impl Signer for StoreCertificate { impl Sign for StoreCertificate { fn sign(&self, message: &[u8]) -> Result<(Vec, SignatureAlgorithm), X509CertificateError> { + // FIXME(jade): this is likely buggy and wrong: this is the signature + // algorithm of this *certificate*, which is not the algorithm used by + // the signature function. However, this function isn't used. let algorithm = self.signature_algorithm()?; Ok((self.try_sign(message)?.into(), algorithm))