Skip to main content
NOT Using macOS KeychainDespite its name, this service does not use the macOS Keychain. Credentials are stored in plain text in UserDefaults without encryption. This is a deliberate design choice for a personal single-user tool, prioritizing simplicity over maximum security.Security implications:
  • Credentials are stored unencrypted
  • Accessible to other processes with UserDefaults access
  • Included in Time Machine backups
  • Visible in UserDefaults plist files
For production applications handling sensitive credentials, consider using the system Keychain (Security framework) instead.

Overview

KeychainService provides persistent storage for R2 credentials. Despite its name, this implementation uses UserDefaults rather than the system Keychain, which is appropriate for a personal single-user tool where credentials are stored on the user’s own machine.
Located at: Fiaxe/Services/KeychainService.swift:6

Type Definition

enum KeychainService
Implemented as an enum with static methods (no instances).

Methods

saveAll()

Saves an array of R2 credentials to persistent storage.
static func saveAll(_ credentials: [R2Credentials]) throws
credentials
[R2Credentials]
required
Array of R2 credentials to save. Replaces any previously saved credentials.
Throws: Encoding errors if the credentials cannot be serialized to JSON.

Implementation Details

// Example from KeychainService.swift:9-12
static func saveAll(_ credentials: [R2Credentials]) throws {
    let data = try JSONEncoder().encode(credentials)
    UserDefaults.standard.set(data, forKey: storageKey)
}
The method:
  1. Encodes the credentials array to JSON using JSONEncoder
  2. Stores the JSON data in UserDefaults under the key "fiaxe.r2credentials"
  3. Automatically synchronizes to disk via UserDefaults

loadAll()

Retrieves all stored R2 credentials from persistent storage.
static func loadAll() throws -> [R2Credentials]
Returns: Array of stored R2 credentials. Returns an empty array if no credentials are stored. Throws: Decoding errors if the stored data cannot be deserialized.

Implementation Details

// Example from KeychainService.swift:14-23
static func loadAll() throws -> [R2Credentials] {
    guard let data = UserDefaults.standard.data(forKey: storageKey) else { return [] }
    if let decoded = try? JSONDecoder().decode([R2Credentials].self, from: data) {
        return decoded
    }
    if let single = try? JSONDecoder().decode(R2Credentials.self, from: data) {
        return [single]
    }
    return []
}
The method handles migration from older versions:
  1. Attempts to load data from UserDefaults
  2. If no data exists, returns an empty array
  3. First tries to decode as an array of credentials (current format)
  4. Falls back to decoding a single credential object (legacy format)
  5. Automatically wraps legacy single credential in an array
  6. Returns empty array if decoding fails

deleteAll()

Removes all stored credentials from persistent storage.
static func deleteAll() throws
Throws: No errors are thrown in the current implementation.

Implementation Details

// Example from KeychainService.swift:25-27
static func deleteAll() throws {
    UserDefaults.standard.removeObject(forKey: storageKey)
}
Simply removes the stored data from UserDefaults.

Storage Implementation

The service uses a simple key-value approach:
private static let storageKey = "fiaxe.r2credentials"
All credentials are stored as JSON data under this single key in UserDefaults.

Data Format

Credentials are stored as JSON-encoded R2Credentials objects:
struct R2Credentials: Codable, Sendable {
    let accountId: String
    let accessKeyId: String
    let secretAccessKey: String
    let bucketName: String
    // ... other fields
}
Stored JSON format:
[
  {
    "accountId": "your-account-id",
    "accessKeyId": "your-access-key-id",
    "secretAccessKey": "your-secret-access-key",
    "bucketName": "my-bucket"
  },
  // ... more credentials
]

Usage Examples

// Save credentials
let credentials = [
    R2Credentials(
        accountId: "account-1",
        accessKeyId: "key-1",
        secretAccessKey: "secret-1",
        bucketName: "bucket-1"
    ),
    R2Credentials(
        accountId: "account-2",
        accessKeyId: "key-2",
        secretAccessKey: "secret-2",
        bucketName: "bucket-2"
    )
]

try KeychainService.saveAll(credentials)

// Load credentials
let storedCredentials = try KeychainService.loadAll()
print("Loaded \(storedCredentials.count) credential(s)")

for cred in storedCredentials {
    print("Bucket: \(cred.bucketName)")
}

// Delete all credentials
try KeychainService.deleteAll()

Migration Support

The loadAll() method includes backward compatibility:
// Old format (single credential)
{
  "accountId": "...",
  "accessKeyId": "...",
  // ...
}

// New format (array of credentials)
[
  { "accountId": "...", ... },
  { "accountId": "...", ... }
]
Both formats are automatically handled, with single credentials wrapped in an array.

Security Considerations

Security Trade-offsThis implementation prioritizes simplicity for a personal tool:
  • Pro: Simple implementation, no Keychain complexity
  • Pro: Appropriate for single-user, local-only tools
  • Con: Credentials stored in plain text
  • Con: Accessible to other processes with UserDefaults access
  • Con: Included in Time Machine backups
For production multi-user apps, use the system Keychain:
import Security

// Store in Keychain
let query: [String: Any] = [
    kSecClass as String: kSecClassGenericPassword,
    kSecAttrAccount as String: "r2credentials",
    kSecValueData as String: data
]
SecItemAdd(query as CFDictionary, nil)

When to Use UserDefaults vs Keychain

Use UserDefaults (current approach) when:
  • Building a personal single-user tool
  • Credentials are for the user’s own accounts
  • Simplicity is more important than maximum security
  • Running on the user’s own trusted machine
Use Keychain when:
  • Building a production app for distribution
  • Handling credentials for multiple users
  • Need encrypted storage
  • Require secure credential synchronization
  • Need to prevent unauthorized access

Persistence Guarantees

UserDefaults automatically persists changes to disk, but synchronization is asynchronous. For critical operations, you can force synchronization:
UserDefaults.standard.set(data, forKey: key)
UserDefaults.standard.synchronize()  // Force write to disk
However, this is rarely necessary as the system handles synchronization reliably.

Testing

For testing, you can use a separate UserDefaults suite:
// In your test code
let testDefaults = UserDefaults(suiteName: "com.example.tests")!

// Modify KeychainService to accept a UserDefaults instance
enum KeychainService {
    static var defaults: UserDefaults = .standard
    
    static func saveAll(_ credentials: [R2Credentials]) throws {
        let data = try JSONEncoder().encode(credentials)
        defaults.set(data, forKey: storageKey)
    }
    // ...
}

// In tests
KeychainService.defaults = testDefaults