137 lines
5.5 KiB
Rust
137 lines
5.5 KiB
Rust
//! 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<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()));
|
|
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())
|
|
}
|
|
|
|
/// 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<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}")),
|
|
}
|
|
}
|
|
}
|