//! The store accounts of this machine, shared with the storebound CLI: credential profiles in //! `~/.storebound/credentials` (STOREBOUND_CREDENTIALS_DIR), the same files `storebound accounts` lists and every //! terminal command reads (cli/lib/credentials.ts). A profile `.json` holds one App Store Connect team key and / or //! one Play service account; its key files live next to it in `/` (mode 0600, folders 0700) and the profile names //! them by relative path: //! { "label": "Acme", "ASC_KEY_ID": "...", "ASC_ISSUER_ID": "...", "ASC_P8": "acme/app-store-connect.p8", //! "PLAY_SERVICE_ACCOUNT": "acme/play-service-account.json" } //! One desktop account = one store of one profile (`Account::profile`). Accounts the CLI added show up in the app //! (`sync`), and keys the app adds are there for the CLI. Desktop accounts of earlier versions kept their keys in the //! OS keychain; `sync` moves each one into a profile once (the same key as an existing profile = that profile) and //! then deletes the keychain entry. Secrets still never reach the webview: they are read here right before a sidecar //! call. A removed account's key files are moved to `.removed/` (never deleted outright). use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; use serde_json::{Map, Value}; use sha2::{Digest, Sha256}; use crate::accounts::{Account, AccountKind, Secret, SecretStore}; pub const P8_FILE: &str = "app-store-connect.p8"; pub const PLAY_FILE: &str = "play-service-account.json"; /// The folder: STOREBOUND_CREDENTIALS_DIR, else ~/.storebound/credentials (the CLI's profileDir()). pub fn default_dir(home: Option<&Path>) -> PathBuf { if let Some(d) = std::env::var_os("STOREBOUND_CREDENTIALS_DIR").filter(|d| !d.is_empty()) { return PathBuf::from(d); } home.map(Path::to_path_buf).unwrap_or_default().join(".storebound").join("credentials") } /// A profile name: the CLI's PROFILE_RE (letters, digits, . _ -, starting with a letter or digit, max 64). pub fn valid_name(n: &str) -> bool { let mut c = n.chars(); matches!(c.next(), Some(f) if f.is_ascii_alphanumeric()) && n.len() <= 64 && n.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')) } /// A profile name from a label: lower case, other characters as `-` (the CLI's slug). pub fn slug(label: &str) -> String { let mut out = String::new(); for c in label.to_lowercase().chars() { if c.is_ascii_alphanumeric() || c == '.' || c == '_' { out.push(c); } else if !out.ends_with('-') { out.push('-'); } } let s: String = out.trim_matches(|c| c == '-' || c == '.').chars().take(56).collect(); if s.is_empty() || !valid_name(&s) { "account".into() } else { s } } /// The CLI workspace's account id for a profile's store: sha256(":"), 16 hex (cli/lib/workspace.ts /// accountId), so the same account has the same id in `storebound.workspace.json` and in the app. pub fn account_id(kind: AccountKind, profile: &str) -> String { let store = match kind { AccountKind::Apple => "apple", AccountKind::Play => "play", }; let d = Sha256::digest(format!("{store}:{profile}").as_bytes()); d.iter().take(8).map(|b| format!("{b:02x}")).collect() } fn str_of<'a>(m: &'a Map, k: &str) -> Option<&'a str> { m.get(k).and_then(Value::as_str).map(str::trim).filter(|s| !s.is_empty()) } pub struct ProfileStore { pub dir: PathBuf, home: Option, } /// What a profile reaches, for matching (never shown). #[derive(Debug, Default, PartialEq, Eq)] pub struct Identity { pub apple_key_id: Option, pub play_email: Option, } impl ProfileStore { pub fn new(dir: PathBuf, home: Option) -> ProfileStore { ProfileStore { dir, home } } /// The profiles (`*.json` with a valid name), sorted. pub fn names(&self) -> Vec { let mut out: Vec = fs::read_dir(&self.dir) .map(|rd| rd.filter_map(Result::ok).filter_map(|e| e.file_name().to_str().and_then(|n| n.strip_suffix(".json")).map(str::to_string)).filter(|n| valid_name(n)).collect()) .unwrap_or_default(); out.sort(); out } pub fn read(&self, name: &str) -> Result, String> { if !valid_name(name) { return Err(format!("profile {name}: not a profile name")); } let text = fs::read_to_string(self.dir.join(format!("{name}.json"))).map_err(|e| format!("profile {name}: {e}"))?; match serde_json::from_str::(&text) { Ok(Value::Object(m)) => Ok(m), _ => Err(format!("profile {name}: not a JSON object")), } } /// A value that is a key file path: relative to the credentials folder, `~/` from home, or absolute. fn path_of(&self, v: &str) -> PathBuf { if let (Some(rest), Some(h)) = (v.strip_prefix("~/"), self.home.as_ref()) { return h.join(rest); } let p = Path::new(v); if p.is_absolute() { p.to_path_buf() } else { self.dir.join(p) } } /// The content of a value that is inline (PEM / JSON) or a file. fn content(&self, v: &str) -> Result { if v.contains("BEGIN PRIVATE KEY") || v.trim_start().starts_with('{') { return Ok(v.to_string()); } fs::read_to_string(self.path_of(v)).map_err(|e| format!("key file: {e}")) } pub fn kinds(&self, name: &str) -> Vec { let Ok(m) = self.read(name) else { return vec![] }; let mut out = vec![]; if str_of(&m, "ASC_KEY_ID").is_some() && str_of(&m, "ASC_ISSUER_ID").is_some() { out.push(AccountKind::Apple); } if str_of(&m, "PLAY_SERVICE_ACCOUNT").is_some() { out.push(AccountKind::Play); } out } /// The label the profile carries, if any (written by the app; the CLI ignores it). pub fn label(&self, name: &str) -> Option { self.read(name).ok().and_then(|m| str_of(&m, "label").map(str::to_string)) } /// One store's keys of a profile, read from its files right now. pub fn secret(&self, name: &str, kind: AccountKind) -> Result { let m = self.read(name)?; match kind { AccountKind::Apple => { let key_id = str_of(&m, "ASC_KEY_ID").ok_or_else(|| format!("profile {name}: no ASC_KEY_ID"))?.to_string(); let issuer_id = str_of(&m, "ASC_ISSUER_ID").ok_or_else(|| format!("profile {name}: no ASC_ISSUER_ID"))?.to_string(); // no ASC_P8: the CLI looks in ~/.appstoreconnect/private_keys itself let p8 = match str_of(&m, "ASC_P8") { Some(v) => self.content(v)?, None => String::new(), }; Ok(Secret::Apple { key_id, issuer_id, p8 }) } AccountKind::Play => { let v = str_of(&m, "PLAY_SERVICE_ACCOUNT").ok_or_else(|| format!("profile {name}: no PLAY_SERVICE_ACCOUNT"))?; Ok(Secret::Play { service_account: self.content(v)? }) } } } pub fn identity(&self, name: &str) -> Identity { let apple_key_id = self.read(name).ok().and_then(|m| str_of(&m, "ASC_KEY_ID").map(str::to_string)); let play_email = match self.secret(name, AccountKind::Play) { Ok(Secret::Play { service_account }) => email_of(&service_account), _ => None, }; Identity { apple_key_id, play_email } } /// Where a new key goes: the profile that already has this key (same key id / service account e-mail), else the /// profile named after the label when it has no key of this store yet, else a free name. pub fn place(&self, label: &str, secret: &Secret) -> String { let names = self.names(); for n in &names { let id = self.identity(n); let same = match secret { Secret::Apple { key_id, .. } => id.apple_key_id.as_deref() == Some(key_id.as_str()), Secret::Play { service_account } => id.play_email.is_some() && id.play_email == email_of(service_account), }; if same { return n.clone(); } } let base = slug(label); if !names.contains(&base) || !self.kinds(&base).contains(&secret.kind()) { return base; } let kind = match secret.kind() { AccountKind::Apple => "apple", AccountKind::Play => "play", }; let mut i = 1; loop { let n = if i == 1 { format!("{base}-{kind}") } else { format!("{base}-{kind}-{i}") }; if !names.contains(&n) || !self.kinds(&n).contains(&secret.kind()) { return n; } i += 1; } } fn ensure_dir(&self, d: &Path) -> Result<(), String> { fs::create_dir_all(d).map_err(|e| format!("{}: {e}", d.display()))?; private_dir(d); Ok(()) } /// Writes one store's keys into profile `name` (created when missing): the key file in `/` (0600), then the /// profile JSON (0600, atomic), keeping the profile's other store and fields. The written profile is read back. pub fn save(&self, name: &str, label: &str, secret: &Secret) -> Result<(), String> { if !valid_name(name) { return Err(format!("profile {name}: not a profile name")); } self.ensure_dir(&self.dir)?; let mut m = if self.dir.join(format!("{name}.json")).exists() { self.read(name)? } else { Map::new() }; let keys = self.dir.join(name); self.ensure_dir(&keys)?; match secret { Secret::Apple { key_id, issuer_id, p8 } => { if !p8.is_empty() { write_private(&keys.join(P8_FILE), p8.as_bytes())?; m.insert("ASC_P8".into(), Value::String(format!("{name}/{P8_FILE}"))); } m.insert("ASC_KEY_ID".into(), Value::String(key_id.clone())); m.insert("ASC_ISSUER_ID".into(), Value::String(issuer_id.clone())); } Secret::Play { service_account } => { write_private(&keys.join(PLAY_FILE), service_account.as_bytes())?; m.insert("PLAY_SERVICE_ACCOUNT".into(), Value::String(format!("{name}/{PLAY_FILE}"))); } } if !m.contains_key("label") && !label.trim().is_empty() { m.insert("label".into(), Value::String(label.trim().to_string())); } let text = serde_json::to_string_pretty(&Value::Object(m)).map_err(|e| e.to_string())? + "\n"; write_private(&self.dir.join(format!("{name}.json")), text.as_bytes())?; match self.secret(name, secret.kind()) { Ok(back) if back == *secret || matches!((&back, secret), (Secret::Apple { key_id: a, .. }, Secret::Apple { key_id: b, p8, .. }) if a == b && p8.is_empty()) => Ok(()), Ok(_) => Err(format!("profile {name}: the key did not read back the same")), Err(e) => Err(e), } } /// Removes one store from a profile: its key file goes to `.removed/-