//! Store accounts. The secret part (Play service account JSON, App Store Connect .p8 key with its key id and issuer) //! goes into the OS credential store under the service `dev.storebound.desktop`; only a label, the kind and the //! creation time are kept in the app data folder. Secrets are never sent to the webview: they are read from the //! keychain right before a sidecar call and handed to it in that call's params (memory only), which passes them to //! the storebound command as environment variables. use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; pub const KEYRING_SERVICE: &str = "dev.storebound.desktop"; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum AccountKind { Play, Apple, } /// What the app keeps on disk and shows: no secret, no key id, no email. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Account { pub id: String, pub label: String, pub kind: AccountKind, pub created: String, /// The studio this account belongs to (workspace::Studio id), if any. #[serde(default)] pub studio: Option, } /// The keychain entry of one account (JSON). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "lowercase")] pub enum Secret { Play { service_account: String }, Apple { key_id: String, issuer_id: String, p8: String }, } impl Secret { pub fn kind(&self) -> AccountKind { match self { Secret::Play { .. } => AccountKind::Play, Secret::Apple { .. } => AccountKind::Apple, } } /// The env names storebound reads (cli/lib/credentials.ts), as a sidecar `credentials` object. pub fn to_credentials(&self, into: &mut Map) { match self { Secret::Play { service_account } => { into.insert("PLAY_SERVICE_ACCOUNT".into(), Value::String(service_account.clone())); } Secret::Apple { key_id, issuer_id, p8 } => { into.insert("ASC_KEY_ID".into(), Value::String(key_id.clone())); into.insert("ASC_ISSUER_ID".into(), Value::String(issuer_id.clone())); into.insert("ASC_P8".into(), Value::String(p8.clone())); } } } } /// A Google service account JSON key: client_email and a PEM private key. pub fn check_service_account(text: &str) -> Result { let v: Value = serde_json::from_str(text).map_err(|_| "not a JSON file".to_string())?; let email = v.get("client_email").and_then(Value::as_str).unwrap_or_default(); let key = v.get("private_key").and_then(Value::as_str).unwrap_or_default(); if email.is_empty() || !key.contains("PRIVATE KEY") { return Err("not a Google service account key (client_email / private_key missing)".into()); } // Store the compact form: no file path, nothing else from the disk. serde_json::to_string(&v).map_err(|e| e.to_string()) } /// App Store Connect key id: 10 upper-case letters or digits. pub fn check_key_id(s: &str) -> Result { let s = s.trim(); if s.len() == 10 && s.chars().all(|c| c.is_ascii_uppercase() || c.is_ascii_digit()) { Ok(s.to_string()) } else { Err("key id: 10 letters or digits, as shown in App Store Connect > Users and Access > Integrations".into()) } } /// App Store Connect issuer id: a UUID. pub fn check_issuer_id(s: &str) -> Result { let s = s.trim().to_ascii_lowercase(); let parts: Vec<&str> = s.split('-').collect(); let lens = [8, 4, 4, 4, 12]; let ok = parts.len() == 5 && parts.iter().zip(lens).all(|(p, n)| p.len() == n && p.chars().all(|c| c.is_ascii_hexdigit())); if ok { Ok(s) } else { Err("issuer id: the UUID shown above the keys in App Store Connect".into()) } } pub fn check_p8(text: &str) -> Result { if text.contains("-----BEGIN PRIVATE KEY-----") && text.contains("-----END PRIVATE KEY-----") { Ok(text.trim().to_string() + "\n") } else { Err("not an App Store Connect .p8 key (BEGIN PRIVATE KEY missing)".into()) } } pub fn check_label(s: &str) -> Result { let s = s.trim(); if s.is_empty() || s.chars().count() > 60 || s.chars().any(char::is_control) { return Err("name: 1 to 60 characters".into()); } Ok(s.to_string()) } /// Where secrets live. The OS keychain in the app; a map in tests. pub trait SecretStore: Send + Sync { fn set(&self, id: &str, secret: &Secret) -> Result<(), String>; fn get(&self, id: &str) -> Result; fn delete(&self, id: &str) -> Result<(), String>; } pub struct Keychain; impl SecretStore for Keychain { fn set(&self, id: &str, secret: &Secret) -> Result<(), String> { let json = serde_json::to_string(secret).map_err(|e| e.to_string())?; keyring::Entry::new(KEYRING_SERVICE, id).and_then(|e| e.set_password(&json)).map_err(|e| format!("keychain: {e}")) } fn get(&self, id: &str) -> Result { let json = keyring::Entry::new(KEYRING_SERVICE, id).and_then(|e| e.get_password()).map_err(|e| format!("keychain: {e}"))?; serde_json::from_str(&json).map_err(|_| "keychain: unreadable entry".to_string()) } fn delete(&self, id: &str) -> Result<(), String> { match keyring::Entry::new(KEYRING_SERVICE, id).and_then(|e| e.delete_credential()) { Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), Err(e) => Err(format!("keychain: {e}")), } } }