Files
oai-swift/oAI/Services/EncryptionService.swift
T
rune 76b58e6fdc Rename app from oAI to Confab
"oAI" reads as easily confused with OpenAI, both visually and in
casual conversation. Renamed to "Confab" throughout: Xcode
target/scheme/bundle ID (com.oai.Confab), Info.plist and Help Book
identity, all user-facing UI text, internal Log subsystem and color
identifiers, localization catalogs (6 languages, including a proper
reworded/retranslated Intel-deprecation notice), Help Book HTML
content, and docs (README/DEVELOPMENT/PRIVACY/SECURITY).

Deliberately cosmetic-only: the on-disk data folder
(~/Library/Application Support/oAI/), database/backup filenames,
Keychain service identifiers, and EncryptionService's key-derivation
inputs are all left untouched so existing conversations, settings,
and stored API keys survive the update with zero migration and no
re-entering credentials. Verified live: a real signed build
successfully decrypted a stored API key and loaded an existing
conversation database after the bundle ID change.

Also includes a small already-completed, previously uncommitted
model-release-date feature (ModelInfo/OpenRouterModels/
OpenRouterProvider/ModelInfoView) that happened to share several
files with this rename.

Gitignored on this branch and updated on disk but not part of this
commit: CLAUDE.md, RELEASE_NOTES.md, and the build*.sh scripts.
2026-08-02 14:58:14 +02:00

131 lines
4.1 KiB
Swift

//
// EncryptionService.swift
// Confab
//
// Secure encryption for sensitive data (API keys)
// Uses CryptoKit with machine-specific key derivation
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
//
// This file is part of Confab.
//
// Confab is licensed under the PolyForm Noncommercial License 1.0.0.
// You may use, study, modify, and share it for any noncommercial
// purpose. Commercial use — including selling Confab or any part of
// it, standalone or bundled into another product or service —
// requires a separate commercial license from the copyright holder.
//
// See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>.
import Foundation
import CryptoKit
import IOKit
class EncryptionService {
nonisolated static let shared = EncryptionService()
private let encryptionKey: SymmetricKey
private init() {
self.encryptionKey = Self.deriveEncryptionKey()
}
// MARK: - Public Interface
/// Encrypt a string value
nonisolated func encrypt(_ value: String) throws -> String {
guard let data = value.data(using: .utf8) else {
throw EncryptionError.invalidInput
}
let sealedBox = try AES.GCM.seal(data, using: encryptionKey)
guard let combined = sealedBox.combined else {
throw EncryptionError.encryptionFailed
}
return combined.base64EncodedString()
}
/// Decrypt a string value
nonisolated func decrypt(_ encryptedValue: String) throws -> String {
guard let data = Data(base64Encoded: encryptedValue) else {
throw EncryptionError.invalidInput
}
let sealedBox = try AES.GCM.SealedBox(combined: data)
let decryptedData = try AES.GCM.open(sealedBox, using: encryptionKey)
guard let decryptedString = String(data: decryptedData, encoding: .utf8) else {
throw EncryptionError.decryptionFailed
}
return decryptedString
}
// MARK: - Key Derivation
/// Derive encryption key from machine-specific data
private static func deriveEncryptionKey() -> SymmetricKey {
let machineUUID = getMachineUUID()
// Pinned, not read from Bundle.main.bundleIdentifier: the app's bundle ID changed
// (oAI -> Confab rename) but this key material must not, or every already-encrypted
// setting (provider API keys, sync/email credentials) becomes undecryptable.
let bundleID = "com.oai.oAI"
let salt = "oAI-secure-storage-v1"
let keyMaterial = "\(machineUUID)-\(bundleID)-\(salt)"
let hash = SHA256.hash(data: Data(keyMaterial.utf8))
return SymmetricKey(data: hash)
}
/// Get machine-specific UUID (IOPlatformUUID)
private static func getMachineUUID() -> String {
// Get IOPlatformUUID from IOKit
let platformExpert = IOServiceGetMatchingService(
kIOMainPortDefault,
IOServiceMatching("IOPlatformExpertDevice")
)
guard platformExpert != 0 else {
// Fallback to a stable identifier if IOKit unavailable
return "oai-fallback-uuid"
}
defer { IOObjectRelease(platformExpert) }
guard let uuidData = IORegistryEntryCreateCFProperty(
platformExpert,
"IOPlatformUUID" as CFString,
kCFAllocatorDefault,
0
) else {
return "oai-fallback-uuid"
}
return (uuidData.takeRetainedValue() as? String) ?? "oai-fallback-uuid"
}
// MARK: - Errors
enum EncryptionError: LocalizedError {
case invalidInput
case encryptionFailed
case decryptionFailed
var errorDescription: String? {
switch self {
case .invalidInput:
return "Invalid input data for encryption/decryption"
case .encryptionFailed:
return "Failed to encrypt data"
case .decryptionFailed:
return "Failed to decrypt data"
}
}
}
}