Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions Wevo/Extension/IdentityExportCrypto.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
//
// IdentityExportCrypto.swift
// Wevo
//

import Foundation
import CryptoKit
import CommonCrypto

/// Passphrase-based encryption for identity exports: PBKDF2-HMAC-SHA256 key derivation + AES-GCM.
enum IdentityExportCrypto {
/// PBKDF2 iteration count (OWASP-recommended floor for PBKDF2-HMAC-SHA256).
static let iterations = 210_000
static let saltLength = 16
static let minPassphraseLength = 8
/// Accepted iteration range on import. Bounds untrusted envelope values so they can never
/// overflow the UInt32 conversion (crash) or make PBKDF2 run for an abusive amount of time.
static let minIterations = 100_000
static let maxIterations = 2_000_000

enum CryptoError: Error, LocalizedError {
case emptyPassphrase
case passphraseTooShort
case keyDerivationFailed
case sealFailed

var errorDescription: String? {
switch self {
case .emptyPassphrase: return "A passphrase is required."
case .passphraseTooShort: return "Passphrase must be at least \(IdentityExportCrypto.minPassphraseLength) characters."
case .keyDerivationFailed: return "Failed to derive the encryption key."
case .sealFailed: return "Failed to encrypt the identity."
}
}
}

/// 16 cryptographically random bytes (from CryptoKit's CSPRNG).
static func randomSalt() -> Data {
SymmetricKey(size: .bits128).withUnsafeBytes { Data($0) }
}

/// Derives a 256-bit AES key from `passphrase` using PBKDF2-HMAC-SHA256.
static func deriveKey(passphrase: String, salt: Data, iterations: Int) throws -> SymmetricKey {
guard !passphrase.isEmpty else { throw CryptoError.emptyPassphrase }
// Non-trapping conversion: a negative or out-of-UInt32-range value (from an untrusted
// envelope) surfaces as a catchable error instead of a fatal runtime trap.
guard let rounds = UInt32(exactly: iterations), rounds >= 1 else {
throw CryptoError.keyDerivationFailed
}
let passData = Data(passphrase.utf8)
var derived = [UInt8](repeating: 0, count: 32)
let status: Int32 = passData.withUnsafeBytes { rawPass in
salt.withUnsafeBytes { rawSalt in
CCKeyDerivationPBKDF(
CCPBKDFAlgorithm(kCCPBKDF2),
rawPass.bindMemory(to: CChar.self).baseAddress, passData.count,
rawSalt.bindMemory(to: UInt8.self).baseAddress, salt.count,
CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256),
rounds,
&derived, derived.count
)
}
}
guard status == kCCSuccess else { throw CryptoError.keyDerivationFailed }
return SymmetricKey(data: Data(derived))
}

/// Encrypts `plaintext` with a passphrase. Returns (salt, sealedCombined) for the envelope.
static func encrypt(_ plaintext: Data, passphrase: String) throws -> (salt: Data, sealed: Data) {
guard passphrase.count >= minPassphraseLength else { throw CryptoError.passphraseTooShort }
let salt = randomSalt()
let key = try deriveKey(passphrase: passphrase, salt: salt, iterations: iterations)
let box = try AES.GCM.seal(plaintext, using: key)
guard let combined = box.combined else { throw CryptoError.sealFailed }
return (salt, combined)
}

/// Decrypts a sealed box produced by `encrypt`. Throws on a wrong passphrase or tampering
/// (AES-GCM authentication failure).
static func decrypt(sealed: Data, salt: Data, iterations: Int, passphrase: String) throws -> Data {
let key = try deriveKey(passphrase: passphrase, salt: salt, iterations: iterations)
let box = try AES.GCM.SealedBox(combined: sealed)
return try AES.GCM.open(box, using: key)
}
}
28 changes: 28 additions & 0 deletions Wevo/Models/Transfer/IdentityEncryptedExport.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
//
// IdentityEncryptedExport.swift
// Wevo
//

import Foundation

/// Passphrase-encrypted `.wevo-identity` export envelope (format version 1).
///
/// Metadata (`id`, `nickname`, `publicKey`) is stored in cleartext — none of it is secret, and it
/// lets the import screen show a preview before the passphrase is entered. Only the P-256 private
/// key is encrypted: it is sealed with AES-GCM under a key derived from the user's passphrase via
/// PBKDF2-HMAC-SHA256 (see `IdentityExportCrypto`). This replaces the previous plaintext export,
/// which wrote the raw private key to disk with no protection.
struct IdentityEncryptedExport: Codable {
let version: Int
let id: UUID
let nickname: String
let publicKey: String
let exportedAt: Date
let kdf: String
let iterations: Int
let salt: String // base64
let sealed: String // base64 of AES-GCM combined box (nonce + ciphertext + tag)

static let currentVersion = 1
static let kdfName = "PBKDF2-SHA256"
}
46 changes: 44 additions & 2 deletions Wevo/UI/Identity/IdentityDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,15 @@ struct IdentityDetailView: View {
private struct IdentityDetailContent: View {
@State var viewModel: IdentityDetailViewModel

@State private var showingPassphrasePrompt = false
@State private var passphrase = ""
@State private var confirmPassphrase = ""

private static let minPassphraseLength = 8
private var passphraseValid: Bool {
passphrase.count >= Self.minPassphraseLength && passphrase == confirmPassphrase
}

var body: some View {
List {
Section("Information") {
Expand Down Expand Up @@ -62,9 +71,11 @@ private struct IdentityDetailContent: View {

Section("Share") {
Button {
Task { await viewModel.authenticateAndExport() }
passphrase = ""
confirmPassphrase = ""
showingPassphrasePrompt = true
} label: {
Label("Share Identity (Plain)", systemImage: "square.and.arrow.up")
Label("Export Identity (Encrypted)", systemImage: "square.and.arrow.up")
}
.disabled(viewModel.isAuthenticating)
.alert("Export Error", isPresented: .constant(viewModel.exportError != nil)) {
Expand Down Expand Up @@ -102,6 +113,37 @@ private struct IdentityDetailContent: View {
.sheet(isPresented: $viewModel.showingEditSheet) {
EditIdentityView(identity: viewModel.identity)
}
.sheet(isPresented: $showingPassphrasePrompt) {
NavigationStack {
Form {
Section {
SecureField("Passphrase", text: $passphrase)
SecureField("Confirm passphrase", text: $confirmPassphrase)
} footer: {
Text("You'll need this passphrase to import the identity elsewhere. At least \(Self.minPassphraseLength) characters. It cannot be recovered if lost.")
}
}
.navigationTitle("Set Export Passphrase")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { showingPassphrasePrompt = false }
}
ToolbarItem(placement: .confirmationAction) {
Button("Export") {
showingPassphrasePrompt = false
Task { await viewModel.authenticateAndExport(passphrase: passphrase) }
}
.disabled(!passphraseValid)
}
}
}
#if os(macOS)
.frame(minWidth: 400, minHeight: 240)
#endif
}
.onDisappear {
viewModel.cleanupExportFile()
}
Expand Down
4 changes: 2 additions & 2 deletions Wevo/UI/Identity/IdentityDetailViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,11 @@ final class IdentityDetailViewModel {
self.deps = deps
}

func authenticateAndExport() async {
func authenticateAndExport(passphrase: String) async {
isAuthenticating = true
defer { isAuthenticating = false }
do {
shareURL = try await deps.authenticateAndExportIdentityUseCase.execute(identity: identity)
shareURL = try await deps.authenticateAndExportIdentityUseCase.execute(identity: identity, passphrase: passphrase)
exportError = nil
} catch {
exportError = "Failed to export identity: \(error.localizedDescription)"
Expand Down
25 changes: 19 additions & 6 deletions Wevo/UI/Identity/IdentityImportView.swift
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
import SwiftUI

struct IdentityImportView: View {
let exportData: IdentityPlainExport
let exportData: IdentityEncryptedExport
let onComplete: () -> Void
let onCancel: () -> Void

@State private var passphrase = ""
@State private var loadError: String?
@State private var isImporting = false
@Environment(\.dismiss) private var dismiss
@Environment(\.dependencies) private var deps

init(exportData: IdentityPlainExport, onComplete: @escaping () -> Void, onCancel: @escaping () -> Void) {
init(exportData: IdentityEncryptedExport, onComplete: @escaping () -> Void, onCancel: @escaping () -> Void) {
self.exportData = exportData
self.onComplete = onComplete
self.onCancel = onCancel
Expand All @@ -35,6 +36,13 @@ struct IdentityImportView: View {
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
}
Section {
SecureField("Passphrase", text: $passphrase)
} header: {
Text("Passphrase")
} footer: {
Text("Enter the passphrase this identity was exported with.")
}
if let loadError = loadError {
Section {
Text(loadError)
Expand All @@ -50,6 +58,7 @@ struct IdentityImportView: View {
}
ToolbarItem(placement: .confirmationAction) {
Button("Import") { Task { await importNow() } }
.disabled(passphrase.isEmpty || isImporting)
}
}
}
Expand All @@ -62,7 +71,7 @@ struct IdentityImportView: View {
isImporting = true
let useCase = ImportIdentityFromExportUseCaseImpl(keychainRepository: deps.keychainRepository)
do {
try useCase.execute(exportData: exportData)
try useCase.execute(exportData: exportData, passphrase: passphrase)
isImporting = false
onComplete()
dismiss()
Expand All @@ -74,12 +83,16 @@ struct IdentityImportView: View {
}

#Preview("Identity Import") {
let exportData = IdentityPlainExport(
let exportData = IdentityEncryptedExport(
version: IdentityEncryptedExport.currentVersion,
id: UUID(),
nickname: "Preview Key",
publicKey: "PreviewPublicKey",
privateKey: "PreviewPrivateKeyBase64",
exportedAt: .now
exportedAt: .now,
kdf: IdentityEncryptedExport.kdfName,
iterations: IdentityExportCrypto.iterations,
salt: "cHJldmlld3NhbHQ=",
sealed: "cHJldmlld3NlYWxlZA=="
)

IdentityImportView(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,9 @@ enum AuthenticateAndExportIdentityUseCaseError: Error, LocalizedError {
}

protocol AuthenticateAndExportIdentityUseCase {
/// Performs biometric authentication and, on success, exports the identity as a file URL
func execute(identity: Identity) async throws -> URL
/// Performs biometric authentication and, on success, exports the identity as a
/// passphrase-encrypted file URL.
func execute(identity: Identity, passphrase: String) async throws -> URL
}

struct AuthenticateAndExportIdentityUseCaseImpl {
Expand All @@ -36,7 +37,7 @@ struct AuthenticateAndExportIdentityUseCaseImpl {
}

extension AuthenticateAndExportIdentityUseCaseImpl: AuthenticateAndExportIdentityUseCase {
func execute(identity: Identity) async throws -> URL {
func execute(identity: Identity, passphrase: String) async throws -> URL {
let context = LAContext()
var error: NSError?

Expand All @@ -55,6 +56,6 @@ extension AuthenticateAndExportIdentityUseCaseImpl: AuthenticateAndExportIdentit
}

let exportUseCase = ExportIdentityUseCaseImpl(keychainRepository: keychainRepository)
return try exportUseCase.execute(identity: identity)
return try exportUseCase.execute(identity: identity, passphrase: passphrase)
}
}
21 changes: 15 additions & 6 deletions Wevo/UseCase/Identity/ExportIdentityUseCase.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,28 +8,37 @@
import Foundation

protocol ExportIdentityUseCase {
func execute(identity: Identity) throws -> URL
func execute(identity: Identity, passphrase: String) throws -> URL
}

struct ExportIdentityUseCaseImpl: ExportIdentityUseCase {
let keychainRepository: KeychainRepository

func execute(identity: Identity) throws -> URL {
/// Exports the identity as a passphrase-encrypted `.wevo-identity` envelope. The private key
/// is AES-GCM sealed under a PBKDF2 key derived from `passphrase`; metadata stays cleartext.
func execute(identity: Identity, passphrase: String) throws -> URL {
let privateKeyData = try keychainRepository.getPrivateKey(id: identity.id)
let export = IdentityPlainExport(
let (salt, sealed) = try IdentityExportCrypto.encrypt(privateKeyData, passphrase: passphrase)

let export = IdentityEncryptedExport(
version: IdentityEncryptedExport.currentVersion,
id: identity.id,
nickname: identity.nickname,
publicKey: identity.publicKey,
privateKey: privateKeyData.base64EncodedString(),
exportedAt: Date()
exportedAt: Date(),
kdf: IdentityEncryptedExport.kdfName,
iterations: IdentityExportCrypto.iterations,
salt: salt.base64EncodedString(),
sealed: sealed.base64EncodedString()
)
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let data = try encoder.encode(export)
let fileName = "identity-\(identity.id.uuidString).wevo-identity"
let url = FileManager.default.temporaryDirectory.appendingPathComponent(fileName)
try data.write(to: url)
// Unreadable while the device is locked; the plaintext key never touches disk.
try data.write(to: url, options: [.completeFileProtection, .atomic])
return url
}
}
Loading