Skip to main content
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):
1

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))?;
2

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.
3

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:
  1. Keychain: Open Keychain Access app, search for com.metalayer.zipdrop, and delete the entry
  2. 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

DataStorage LocationSecurity Level
Access Key IDmacOS KeychainEncrypted by OS
Secret Access KeymacOS KeychainEncrypted by OS
Bucket NameConfig filePublic (non-sensitive)
Account IDConfig filePublic (non-sensitive)
Public URL BaseConfig filePublic (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

Build docs developers (and LLMs) love