ZipDrop stores your Cloudflare R2 credentials securely using macOS Keychain, ensuring your sensitive API keys are never stored in plain text.
Security Model
ZipDrop follows the principle of separation of secrets and configuration:
- Secrets (Access Key ID, Secret Access Key) → Stored in macOS Keychain
- Non-secrets (bucket name, account ID, public URL) → Stored in config file
This approach ensures that even if your configuration file is compromised, your API credentials remain secure.
The R2Config Structure
ZipDrop uses the following configuration structure (defined in config.rs:10-16):
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct R2Config {
pub access_key: String, // Stored in Keychain
pub secret_key: String, // Stored in Keychain
pub bucket_name: String, // Stored in file
pub account_id: String, // Stored in file
pub public_url_base: String, // Stored in file
}
How Credentials Are Stored
macOS Keychain Storage
When you save your R2 configuration, ZipDrop stores secrets in Keychain as a single JSON blob:
// From config.rs:74-90
pub fn save_r2_config(config: &R2Config) -> Result<(), String> {
// Store both secrets as single JSON blob in Keychain
let secrets = KeychainSecrets {
access_key: config.access_key.clone(),
secret_key: config.secret_key.clone(),
};
let secrets_json = serde_json::to_string(&secrets)
.map_err(|e| format!("Failed to serialize secrets: {}", e))?;
let secrets_entry = Entry::new(SERVICE_NAME, "r2_credentials")
.map_err(|e| format!("Keychain error: {}", e))?;
secrets_entry
.set_password(&secrets_json)
.map_err(|e| format!("Failed to store credentials: {}", e))?;
// ...
}
Service Name: com.metalayer.zipdrop (defined in config.rs:6)
Entry Name: r2_credentials
Both secrets are stored as a single keychain entry to minimize macOS keychain prompts. This means you only need to grant permission once instead of twice.
File-Based Storage
Non-sensitive configuration is stored in a JSON file:
Location: ~/Library/Application Support/zipdrop/config.json
Structure:
{
"bucket_name": "zipdrop-uploads",
"account_id": "abc123...",
"public_url_base": "https://files.yourdomain.com"
}
From config.rs:92-106:
// Store non-secrets in file
let stored = StoredConfig {
bucket_name: config.bucket_name.clone(),
account_id: config.account_id.clone(),
public_url_base: config.public_url_base.clone(),
};
let config_path = get_config_path()?;
let json = serde_json::to_string_pretty(&stored)
.map_err(|e| format!("Failed to serialize config: {}", e))?;
fs::write(&config_path, json)
.map_err(|e| format!("Failed to write config file: {}", e))?;
How Credentials Are Loaded
When ZipDrop starts, it loads credentials by combining both sources (config.rs:145-193):
Load non-secrets from file
let json = fs::read_to_string(&config_path)
.map_err(|e| format!("Failed to read config file: {}", e))?;
let stored: StoredConfig = serde_json::from_str(&json)
.map_err(|e| format!("Failed to parse config: {}", e))?;
Load secrets from Keychain
let secrets_entry = Entry::new(SERVICE_NAME, "r2_credentials")
.map_err(|e| format!("Keychain error: {}", e))?;
let secrets_result = secrets_entry.get_password();
macOS may prompt you to grant ZipDrop access to the keychain entry. This is a one-time permission that persists across app restarts.
Combine into R2Config
Ok(Some(R2Config {
access_key: secrets.access_key,
secret_key: secrets.secret_key,
bucket_name: stored.bucket_name,
account_id: stored.account_id,
public_url_base: stored.public_url_base,
}))
Credential Validation
Before saving credentials, ZipDrop validates them by making a test upload to R2. See the Cloudflare R2 Setup guide for details.
Validation function signature (from uploader.rs:55):
pub async fn validate_r2_credentials(config: &R2Config) -> Result<(), String>
This function is called from the main process (main.rs:243-246):
#[tauri::command]
async fn validate_r2_config(config: R2Config) -> Result<(), String> {
uploader::validate_r2_credentials(&config).await
}
Keychain Migration
Earlier versions of ZipDrop stored credentials in two separate keychain entries. The app now automatically migrates to the single-entry format:
// From config.rs:111-142
pub fn migrate_keychain_entries() {
// Check if migration already completed
let marker_path = get_config_dir().ok().map(|d| d.join(".migrated_v1"));
if let Some(ref path) = marker_path {
if path.exists() {
return; // Already migrated
}
}
// Delete old separate entries
if let Ok(entry) = Entry::new(SERVICE_NAME, "r2_access_key") {
match entry.delete_credential() {
Ok(_) => println!("[zipdrop] Deleted old r2_access_key entry"),
Err(e) => println!("[zipdrop] r2_access_key: {}", e),
}
}
if let Ok(entry) = Entry::new(SERVICE_NAME, "r2_secret_key") {
match entry.delete_credential() {
Ok(_) => println!("[zipdrop] Deleted old r2_secret_key entry"),
Err(e) => println!("[zipdrop] r2_secret_key: {}", e),
}
}
// Mark migration as complete
if let Some(path) = marker_path {
let _ = fs::write(&path, "1");
}
}
Migration runs once on first launch after updating. A marker file (.migrated_v1) prevents repeated cleanup.
Deleting Credentials
To remove all stored credentials, use the Clear Configuration button in ZipDrop’s settings, or manually delete them:
// From config.rs:196-217
pub fn delete_r2_config() -> Result<(), String> {
// Remove from Keychain (new single entry)
if let Ok(entry) = Entry::new(SERVICE_NAME, "r2_credentials") {
let _ = entry.delete_credential();
}
// Also clean up old separate entries if they exist
if let Ok(entry) = Entry::new(SERVICE_NAME, "r2_access_key") {
let _ = entry.delete_credential();
}
if let Ok(entry) = Entry::new(SERVICE_NAME, "r2_secret_key") {
let _ = entry.delete_credential();
}
// Remove config file
let config_path = get_config_path()?;
if config_path.exists() {
fs::remove_file(&config_path)
.map_err(|e| format!("Failed to delete config: {}", e))?;
}
Ok(())
}
Manual Deletion
If needed, you can manually remove credentials:
- Keychain: Open Keychain Access app, search for
com.metalayer.zipdrop, and delete the entry
- Config file: Delete
~/Library/Application Support/zipdrop/config.json
Security Best Practices
Never share your config.json file or keychain entries. While the config file doesn’t contain secrets, it reveals your Account ID and bucket name.
- Rotate API tokens regularly in the Cloudflare dashboard
- Use bucket-scoped tokens instead of account-wide tokens
- Grant only Read & Write permissions (not Admin)
- Delete unused tokens from the Cloudflare dashboard
What Gets Stored Where
| Data | Storage Location | Security Level |
|---|
| Access Key ID | macOS Keychain | Encrypted by OS |
| Secret Access Key | macOS Keychain | Encrypted by OS |
| Bucket Name | Config file | Public (non-sensitive) |
| Account ID | Config file | Public (non-sensitive) |
| Public URL Base | Config file | Public (non-sensitive) |
App State Management
ZipDrop keeps credentials in memory during runtime for performance:
// From main.rs:23-26
pub struct AppState {
pub r2_config: Mutex<Option<R2Config>>,
pub settings: Mutex<AppSettings>,
}
Credentials are loaded once at startup (main.rs:253) and cached in AppState. All upload operations use the cached configuration.
Next Steps