150 lines
6.5 KiB
Rust
150 lines
6.5 KiB
Rust
//! Store accounts. The secret part (Play service account JSON, App Store Connect .p8 key with its key id and issuer)
|
|
//! lives in the machine's credential profiles, shared with the CLI (profiles.rs: ~/.storebound/credentials, files
|
|
//! 0600); the app data folder keeps the label, the kind, the studio and the profile name. Versions before 0.8 kept
|
|
//! the secret in the OS keychain (service `dev.storebound.desktop`): `Keychain` is still here to move those entries
|
|
//! into profiles once (profiles::sync). Secrets are never sent to the webview: they are read right before a sidecar
|
|
//! call and handed to it in that call's params (memory only), which passes them to the 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<String>,
|
|
/// The credential profile that holds the key (~/.storebound/credentials/<profile>.json, shared with the CLI).
|
|
/// None only for an account of an earlier version whose keychain entry has not moved yet.
|
|
#[serde(default)]
|
|
pub profile: Option<String>,
|
|
/// Play only: the developer account id (PLAY_DEVELOPER_ID in its credential profile, shared with the CLI), for
|
|
/// Play Console deep links. Read from the profile on every list; never a secret.
|
|
#[serde(default, rename = "developerId", skip_serializing_if = "Option::is_none")]
|
|
pub developer_id: Option<String>,
|
|
}
|
|
|
|
/// 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<String, Value>) {
|
|
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()));
|
|
// a profile without ASC_P8: the CLI finds ~/.appstoreconnect/private_keys/AuthKey_<id>.p8 itself
|
|
if !p8.is_empty() {
|
|
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<String, String> {
|
|
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<String, String> {
|
|
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<String, String> {
|
|
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<String, String> {
|
|
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<String, String> {
|
|
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())
|
|
}
|
|
|
|
/// The OS keychain entries of earlier versions (read once to move them into profiles, then deleted); a map in tests.
|
|
pub trait SecretStore: Send + Sync {
|
|
fn set(&self, id: &str, secret: &Secret) -> Result<(), String>;
|
|
fn get(&self, id: &str) -> Result<Secret, String>;
|
|
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<Secret, String> {
|
|
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}")),
|
|
}
|
|
}
|
|
}
|