use {
crate::AppleCodesignError,
jsonwebtoken::{Algorithm, EncodingKey, Header},
log::{debug, error},
reqwest::blocking::Client,
serde::{de::DeserializeOwned, Deserialize, Serialize},
serde_json::Value,
std::{fs::Permissions, io::Write, path::Path, sync::Mutex, time::SystemTime},
};
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
#[cfg(unix)]
fn set_permissions_private(p: &mut Permissions) {
p.set_mode(0o600);
}
#[cfg(windows)]
fn set_permissions_private(_: &mut Permissions) {}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct UnifiedApiKey {
issuer_id: String,
key_id: String,
private_key: String,
}
impl UnifiedApiKey {
pub fn from_ecdsa_pem_path(
issuer_id: impl ToString,
key_id: impl ToString,
path: impl AsRef<Path>,
) -> Result<Self, AppleCodesignError> {
let pem_data = std::fs::read(path.as_ref())?;
let parsed = pem::parse(pem_data).map_err(|e| {
AppleCodesignError::AppStoreConnectApiKey(format!("error parsing PEM: {}", e))
})?;
if parsed.tag != "PRIVATE KEY" {
return Err(AppleCodesignError::AppStoreConnectApiKey(
"does not look like a PRIVATE KEY".to_string(),
));
}
let private_key = base64::encode(parsed.contents);
Ok(Self {
issuer_id: issuer_id.to_string(),
key_id: key_id.to_string(),
private_key,
})
}
pub fn from_json(data: impl AsRef<[u8]>) -> Result<Self, AppleCodesignError> {
Ok(serde_json::from_slice(data.as_ref())?)
}
pub fn from_json_path(path: impl AsRef<Path>) -> Result<Self, AppleCodesignError> {
let data = std::fs::read(path.as_ref())?;
Self::from_json(data)
}
pub fn to_json_string(&self) -> Result<String, AppleCodesignError> {
Ok(serde_json::to_string_pretty(&self)?)
}
pub fn write_json_file(&self, path: impl AsRef<Path>) -> Result<(), AppleCodesignError> {
let path = path.as_ref();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let data = self.to_json_string()?;
let mut fh = std::fs::File::create(path)?;
let mut permissions = fh.metadata()?.permissions();
set_permissions_private(&mut permissions);
fh.set_permissions(permissions)?;
fh.write_all(data.as_bytes())?;
Ok(())
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
struct ConnectTokenRequest {
iss: String,
iat: u64,
exp: u64,
aud: String,
}
pub type AppStoreConnectToken = String;
#[derive(Clone)]
pub struct ConnectTokenEncoder {
key_id: String,
issuer_id: String,
encoding_key: EncodingKey,
}
impl TryFrom<UnifiedApiKey> for ConnectTokenEncoder {
type Error = AppleCodesignError;
fn try_from(value: UnifiedApiKey) -> Result<Self, Self::Error> {
let der = base64::decode(value.private_key).map_err(|e| {
AppleCodesignError::AppStoreConnectApiKey(format!(
"failed to base64 decode private key: {}",
e
))
})?;
Self::from_ecdsa_der(value.key_id, value.issuer_id, &der)
}
}
impl ConnectTokenEncoder {
pub fn from_jwt_encoding_key(
key_id: String,
issuer_id: String,
encoding_key: EncodingKey,
) -> Self {
Self {
key_id,
issuer_id,
encoding_key,
}
}
pub fn from_ecdsa_der(
key_id: String,
issuer_id: String,
der_data: &[u8],
) -> Result<Self, AppleCodesignError> {
let encoding_key = EncodingKey::from_ec_der(der_data);
Ok(Self::from_jwt_encoding_key(key_id, issuer_id, encoding_key))
}
pub fn from_ecdsa_pem(
key_id: String,
issuer_id: String,
pem_data: &[u8],
) -> Result<Self, AppleCodesignError> {
let encoding_key = EncodingKey::from_ec_pem(pem_data)?;
Ok(Self::from_jwt_encoding_key(key_id, issuer_id, encoding_key))
}
pub fn from_ecdsa_pem_path(
key_id: String,
issuer_id: String,
path: impl AsRef<Path>,
) -> Result<Self, AppleCodesignError> {
let data = std::fs::read(path.as_ref())?;
Self::from_ecdsa_pem(key_id, issuer_id, &data)
}
pub fn from_api_key_id(key_id: String, issuer_id: String) -> Result<Self, AppleCodesignError> {
let mut search_paths = vec![std::env::current_dir()?.join("private_keys")];
if let Some(home) = dirs::home_dir() {
search_paths.extend([
home.join("private_keys"),
home.join(".private_keys"),
home.join(".appstoreconnect").join("private_keys"),
]);
}
let filename = format!("AuthKey_{}.p8", key_id);
for path in search_paths {
let candidate = path.join(&filename);
if candidate.exists() {
return Self::from_ecdsa_pem_path(key_id, issuer_id, candidate);
}
}
Err(AppleCodesignError::AppStoreConnectApiKeyNotFound)
}
pub fn new_token(&self, duration: u64) -> Result<AppStoreConnectToken, AppleCodesignError> {
let header = Header {
kid: Some(self.key_id.clone()),
alg: Algorithm::ES256,
..Default::default()
};
let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.expect("calculating UNIX time should never fail")
.as_secs();
let claims = ConnectTokenRequest {
iss: self.issuer_id.clone(),
iat: now,
exp: now + duration,
aud: "appstoreconnect-v1".to_string(),
};
let token = jsonwebtoken::encode(&header, &claims, &self.encoding_key)?;
Ok(token)
}
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct NewSubmissionRequestNotification {
pub channel: String,
pub target: String,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct NewSubmissionRequest {
pub notifications: Vec<NewSubmissionRequestNotification>,
pub sha256: String,
pub submission_name: String,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NewSubmissionResponseDataAttributes {
pub aws_access_key_id: String,
pub aws_secret_access_key: String,
pub aws_session_token: String,
pub bucket: String,
pub object: String,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NewSubmissionResponseData {
pub attributes: NewSubmissionResponseDataAttributes,
pub id: String,
pub r#type: String,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NewSubmissionResponse {
pub data: NewSubmissionResponseData,
pub meta: Value,
}
const APPLE_NOTARY_SUBMIT_SOFTWARE_URL: &str =
"https://appstoreconnect.apple.com/notary/v2/submissions";
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "PascalCase")]
pub enum SubmissionResponseStatus {
Accepted,
#[serde(rename = "In Progress")]
InProgress,
Invalid,
Rejected,
#[serde(other)]
Unknown,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SubmissionResponseDataAttributes {
pub created_date: String,
pub name: String,
pub status: SubmissionResponseStatus,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SubmissionResponseData {
pub attributes: SubmissionResponseDataAttributes,
pub id: String,
pub r#type: String,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SubmissionResponse {
pub data: SubmissionResponseData,
pub meta: Value,
}
impl SubmissionResponse {
pub fn into_result(self) -> Result<Self, AppleCodesignError> {
match self.data.attributes.status {
SubmissionResponseStatus::Accepted => Ok(self),
SubmissionResponseStatus::InProgress => Err(AppleCodesignError::NotarizeIncomplete),
SubmissionResponseStatus::Invalid => Err(AppleCodesignError::NotarizeInvalid),
SubmissionResponseStatus::Rejected => Err(AppleCodesignError::NotarizeRejected(
0,
"Notarization error".into(),
)),
SubmissionResponseStatus::Unknown => Err(AppleCodesignError::NotarizeInvalid),
}
}
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SubmissionLogResponseDataAttributes {
developer_log_url: String,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SubmissionLogResponseData {
pub attributes: SubmissionLogResponseDataAttributes,
pub id: String,
pub r#type: String,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SubmissionLogResponse {
pub data: SubmissionLogResponseData,
pub meta: Value,
}
pub struct AppStoreConnectClient {
client: Client,
connect_token: ConnectTokenEncoder,
token: Mutex<Option<AppStoreConnectToken>>,
}
impl AppStoreConnectClient {
pub fn new(connect_token: ConnectTokenEncoder) -> Result<Self, AppleCodesignError> {
Ok(Self {
client: crate::ticket_lookup::default_client()?,
connect_token,
token: Mutex::new(None),
})
}
fn get_token(&self) -> Result<String, AppleCodesignError> {
let mut token = self.token.lock().unwrap();
if token.is_none() {
token.replace(self.connect_token.new_token(300)?);
}
Ok(token.as_ref().unwrap().clone())
}
fn send_request<T: DeserializeOwned>(
&self,
request: reqwest::blocking::RequestBuilder,
) -> Result<T, AppleCodesignError> {
let request = request.build()?;
let url = request.url().to_string();
debug!("{} {}", request.method(), url);
let response = self.client.execute(request)?;
if response.status().is_success() {
Ok(response.json::<T>()?)
} else {
error!("HTTP error from {}", url);
let body = response.bytes()?;
if let Ok(value) = serde_json::from_slice::<Value>(body.as_ref()) {
for line in serde_json::to_string_pretty(&value)?.lines() {
error!("{}", line);
}
} else {
error!("{}", String::from_utf8_lossy(body.as_ref()));
}
Err(AppleCodesignError::NotarizeServerError)
}
}
pub fn create_submission(
&self,
sha256: &str,
submission_name: &str,
) -> Result<NewSubmissionResponse, AppleCodesignError> {
let token = self.get_token()?;
let body = NewSubmissionRequest {
notifications: Vec::new(),
sha256: sha256.to_string(),
submission_name: submission_name.to_string(),
};
let req = self
.client
.post(APPLE_NOTARY_SUBMIT_SOFTWARE_URL)
.bearer_auth(token)
.header("Accept", "application/json")
.header("Content-Type", "application/json")
.json(&body);
self.send_request(req)
}
pub fn get_submission(
&self,
submission_id: &str,
) -> Result<SubmissionResponse, AppleCodesignError> {
let token = self.get_token()?;
let req = self
.client
.get(format!(
"{}/{}",
APPLE_NOTARY_SUBMIT_SOFTWARE_URL, submission_id
))
.bearer_auth(token)
.header("Accept", "application/json");
self.send_request(req)
}
pub fn get_submission_log(&self, submission_id: &str) -> Result<Value, AppleCodesignError> {
let token = self.get_token()?;
let req = self
.client
.get(format!(
"{}/{}/logs",
APPLE_NOTARY_SUBMIT_SOFTWARE_URL, submission_id
))
.bearer_auth(token)
.header("Accept", "application/json");
let res = self.send_request::<SubmissionLogResponse>(req)?;
let url = res.data.attributes.developer_log_url;
let logs = self.client.get(url).send()?.json::<Value>()?;
Ok(logs)
}
}