use super::google::{base64url, decode_base64, pem_to_der};
use std::time::{SystemTime, UNIX_EPOCH};
const LIFETIME_SECONDS: u64 = 15 * 60;
const AUDIENCE: &str = "appstoreconnect-v1";
pub struct ApiKey {
pub key_id: String,
pub issuer_id: String,
pub private_key: String,
}
impl ApiKey {
pub fn load(
key_id: &str,
issuer_id: &str,
key: &str,
root: &std::path::Path,
) -> Result<Self, String> {
let private_key = if key.contains("BEGIN") {
key.to_string()
} else {
let path = root.join(key.trim());
if path.is_file() {
std::fs::read_to_string(&path)
.map_err(|err| format!("cannot read {}: {err}", path.display()))?
} else {
let bytes = decode_base64(key).map_err(|message| {
format!("the key is neither PEM, a file, nor base64: {message}")
})?;
String::from_utf8(bytes).map_err(|_| "the decoded key is not text".to_string())?
}
};
if !private_key.contains("BEGIN") {
return Err("the key does not look like a .p8 file".to_string());
}
Ok(Self {
key_id: key_id.to_string(),
issuer_id: issuer_id.to_string(),
private_key,
})
}
}
pub fn token_claims(key: &ApiKey, now: u64) -> String {
let header = format!(r#"{{"alg":"ES256","kid":"{}","typ":"JWT"}}"#, key.key_id);
let claims = format!(
r#"{{"iss":"{}","iat":{now},"exp":{},"aud":"{AUDIENCE}"}}"#,
key.issuer_id,
now + LIFETIME_SECONDS
);
format!(
"{}.{}",
base64url(header.as_bytes()),
base64url(claims.as_bytes())
)
}
pub fn token(key: &ApiKey) -> Result<String, String> {
let der = pem_to_der(&key.private_key)?;
let rng = ring::rand::SystemRandom::new();
let pair = ring::signature::EcdsaKeyPair::from_pkcs8(
&ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING,
&der,
&rng,
)
.map_err(|err| format!("the App Store Connect key could not be read: {err}"))?;
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|since| since.as_secs())
.unwrap_or_default();
let signed = token_claims(key, now);
let signature = pair
.sign(&rng, signed.as_bytes())
.map_err(|err| format!("could not sign the token: {err}"))?;
Ok(format!("{signed}.{}", base64url(signature.as_ref())))
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
fn key() -> ApiKey {
ApiKey {
key_id: "ABC123".to_string(),
issuer_id: "69a6de7e-0000-0000-0000-000000000000".to_string(),
private_key: String::new(),
}
}
fn decode(part: &str) -> String {
let standard = part.replace('-', "+").replace('_', "/");
String::from_utf8(decode_base64(&standard).expect("valid base64")).expect("utf8")
}
#[test]
fn the_header_names_the_key() {
let claims = token_claims(&key(), 1_000);
let header = decode(claims.split('.').next().expect("a header"));
assert!(header.contains(r#""alg":"ES256""#), "{header}");
assert!(header.contains(r#""kid":"ABC123""#), "{header}");
}
#[test]
fn the_payload_expires_within_apples_limit() {
let claims = token_claims(&key(), 1_000);
let payload = decode(claims.split('.').nth(1).expect("a payload"));
assert!(
payload.contains(r#""iss":"69a6de7e-0000-0000-0000-000000000000""#),
"{payload}"
);
assert!(
payload.contains(r#""aud":"appstoreconnect-v1""#),
"{payload}"
);
assert!(payload.contains(r#""iat":1000"#), "{payload}");
assert!(payload.contains(r#""exp":1900"#), "{payload}");
}
#[test]
fn reads_a_key_given_as_pem() {
let pem = "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----";
let key = ApiKey::load("K", "I", pem, Path::new(".")).expect("valid");
assert!(key.private_key.contains("BEGIN"));
}
#[test]
fn reads_a_key_given_as_base64() {
let pem = "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----";
let encoded = {
const ALPHABET: &[u8] =
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let bytes = pem.as_bytes();
let mut out = String::new();
for chunk in bytes.chunks(3) {
let b = [
chunk[0],
*chunk.get(1).unwrap_or(&0),
*chunk.get(2).unwrap_or(&0),
];
let triple = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | b[2] as u32;
for position in 0..4 {
if position <= chunk.len() {
out.push(
ALPHABET[((triple >> (18 - position * 6)) & 0x3F) as usize] as char,
);
} else {
out.push('=');
}
}
}
out
};
let key = ApiKey::load("K", "I", &encoded, Path::new(".")).expect("valid");
assert!(key.private_key.contains("BEGIN"), "{}", key.private_key);
}
#[test]
fn rejects_something_that_is_not_a_key() {
assert!(ApiKey::load("K", "I", "not a key at all", Path::new(".")).is_err());
}
}