From 285c80f8f8ad2b1a036eb6450ca67404f10704a3 Mon Sep 17 00:00:00 2001 From: OpenCow Date: Tue, 28 Jul 2026 10:41:37 +0200 Subject: [PATCH 1/4] feat: add end-to-end encrypted kDrive vault --- .../EncryptedVaultService.swift | 986 ++++++++++++++++++ PotassiumProviderCore/KDriveObjectStore.swift | 269 +++++ .../ProviderActionRuntime.swift | 44 +- PotassiumProviderCore/ProviderConstants.swift | 2 + .../ProviderDomainConfiguration.swift | 49 + .../VaultContentCipher.swift | 379 +++++++ PotassiumProviderCore/VaultCryptography.swift | 414 ++++++++ PotassiumProviderCore/VaultJournal.swift | 795 ++++++++++++++ PotassiumProviderCore/VaultKeyStore.swift | 183 ++++ PotassiumProviderCore/VaultMaintenance.swift | 196 ++++ PotassiumProviderCore/VaultMigration.swift | 481 +++++++++ PotassiumProviderCore/VaultModels.swift | 428 ++++++++ PotassiumProviderCore/VaultProvisioning.swift | 369 +++++++ PotassiumProviderCore/VaultRecoveryKit.swift | 288 +++++ PotassiumProviderCore/VaultSQLiteStore.swift | 531 ++++++++++ README.md | 8 + doc/APP_AND_DOMAINS.md | 9 + doc/ARCHITECTURE.md | 9 + doc/AUTHENTICATION.md | 5 + doc/CONFLICTS.md | 7 + doc/CONTEXTUAL_ACTIONS.md | 5 + doc/ENCRYPTED_VAULT.md | 262 +++++ doc/ENCRYPTED_VAULT_MIGRATION.md | 90 ++ doc/FILE_PROVIDER_CLEANUP.md | 6 + doc/KDRIVE_API_MAPPING.md | 10 + doc/LISTING_AND_VERSIONING.md | 6 + doc/MUTATIONS.md | 8 + doc/PERSISTENCE.md | 10 + doc/TESTING_AND_DEVELOPMENT.md | 10 + .../FileProviderDomainRegistrar.swift | 42 +- .../PotassiumProviderAppModel.swift | 357 ++++++- potassiumProvider/ProviderSetupView.swift | 310 +++++- .../ProviderActionViewModel.swift | 79 +- .../ProviderActionViews.swift | 85 ++ .../FileProviderEnumerator.swift | 109 ++ .../FileProviderItem.swift | 87 +- .../FileProviderRuntime.swift | 95 +- ...tassiumFileProviderExtension+Actions.swift | 49 + ...umFileProviderExtension+KnownFolders.swift | 58 ++ ...umFileProviderExtension+Thumbnailing.swift | 71 ++ .../PotassiumFileProviderExtension.swift | 271 ++++- .../ProviderEventRecording.swift | 14 +- .../KDriveObjectStoreLeakageTests.swift | 55 + .../VaultCryptographyTests.swift | 408 ++++++++ .../VaultDomainConfigurationTests.swift | 58 ++ .../VaultJournalTests.swift | 262 +++++ .../VaultMigrationTests.swift | 388 +++++++ .../VaultProvisioningTests.swift | 550 ++++++++++ .../VaultSQLiteStoreTests.swift | 61 ++ 49 files changed, 9209 insertions(+), 59 deletions(-) create mode 100644 PotassiumProviderCore/EncryptedVaultService.swift create mode 100644 PotassiumProviderCore/KDriveObjectStore.swift create mode 100644 PotassiumProviderCore/VaultContentCipher.swift create mode 100644 PotassiumProviderCore/VaultCryptography.swift create mode 100644 PotassiumProviderCore/VaultJournal.swift create mode 100644 PotassiumProviderCore/VaultKeyStore.swift create mode 100644 PotassiumProviderCore/VaultMaintenance.swift create mode 100644 PotassiumProviderCore/VaultMigration.swift create mode 100644 PotassiumProviderCore/VaultModels.swift create mode 100644 PotassiumProviderCore/VaultProvisioning.swift create mode 100644 PotassiumProviderCore/VaultRecoveryKit.swift create mode 100644 PotassiumProviderCore/VaultSQLiteStore.swift create mode 100644 doc/ENCRYPTED_VAULT.md create mode 100644 doc/ENCRYPTED_VAULT_MIGRATION.md create mode 100644 potassiumProviderTests/KDriveObjectStoreLeakageTests.swift create mode 100644 potassiumProviderTests/VaultCryptographyTests.swift create mode 100644 potassiumProviderTests/VaultDomainConfigurationTests.swift create mode 100644 potassiumProviderTests/VaultJournalTests.swift create mode 100644 potassiumProviderTests/VaultMigrationTests.swift create mode 100644 potassiumProviderTests/VaultProvisioningTests.swift create mode 100644 potassiumProviderTests/VaultSQLiteStoreTests.swift diff --git a/PotassiumProviderCore/EncryptedVaultService.swift b/PotassiumProviderCore/EncryptedVaultService.swift new file mode 100644 index 0000000..6e9b2b8 --- /dev/null +++ b/PotassiumProviderCore/EncryptedVaultService.swift @@ -0,0 +1,986 @@ +import CryptoKit +import Foundation + +public struct VaultItemPage: Equatable, Sendable { + public let items: [VaultItem] + public let nextCursor: String? + + public init(items: [VaultItem], nextCursor: String?) { + self.items = items + self.nextCursor = nextCursor + } +} + +public enum VaultChangeScope: Equatable, Sendable { + case children(parentID: VaultItemIdentifier?) + case trash + case workingSet +} + +public struct VaultItemChanges: Equatable, Sendable { + public let updated: [VaultItem] + public let deleted: [VaultItemIdentifier] + public let frontier: VaultFrontier + + public init( + updated: [VaultItem], + deleted: [VaultItemIdentifier], + frontier: VaultFrontier + ) { + self.updated = updated + self.deleted = deleted + self.frontier = frontier + } +} + +public struct VaultStagedContent: Codable, Equatable, Sendable { + public let itemID: VaultItemIdentifier + public let contentRevision: VaultRevision + public let objectToken: String + public let ciphertextURL: URL + public let wrappedContentKey: Data + public let noncePrefix: UInt64 + public let plaintextLength: Int64 + public let plaintextDigest: Data + public let frameCount: UInt32 + + public init( + itemID: VaultItemIdentifier, + contentRevision: VaultRevision, + objectToken: String, + ciphertextURL: URL, + wrappedContentKey: Data, + noncePrefix: UInt64, + plaintextLength: Int64, + plaintextDigest: Data, + frameCount: UInt32 + ) { + self.itemID = itemID + self.contentRevision = contentRevision + self.objectToken = objectToken + self.ciphertextURL = ciphertextURL + self.wrappedContentKey = wrappedContentKey + self.noncePrefix = noncePrefix + self.plaintextLength = plaintextLength + self.plaintextDigest = plaintextDigest + self.frameCount = frameCount + } +} + +public struct VaultUploadedContent: Codable, Equatable, Sendable { + public let staged: VaultStagedContent + public let remoteFileID: Int + + public init(staged: VaultStagedContent, remoteFileID: Int) { + self.staged = staged + self.remoteFileID = remoteFileID + } + + public var contentReference: VaultContentReference { + VaultContentReference( + encryptionItemID: staged.itemID, + objectToken: staged.objectToken, + remoteFileID: remoteFileID, + wrappedContentKey: staged.wrappedContentKey, + noncePrefix: staged.noncePrefix, + plaintextLength: staged.plaintextLength, + plaintextDigest: staged.plaintextDigest, + frameCount: staged.frameCount + ) + } +} + +public enum EncryptedVaultError: Error, Equatable, LocalizedError, Sendable { + case missingConfiguration + case missingKey + case missingContent + case itemNotFound + case parentNotFound + case notDirectory + case unsupportedNativeSharing + case staleRevision + case syncAnchorExpired + + public var errorDescription: String? { + switch self { + case .missingConfiguration: + return "The encrypted domain configuration is incomplete." + case .missingKey: + return "The vault key is not available on this device." + case .missingContent: + return "The encrypted item has no content revision." + case .itemNotFound: + return "The encrypted item no longer exists." + case .parentNotFound: + return "The encrypted destination folder no longer exists." + case .notDirectory: + return "The encrypted destination is not a folder." + case .unsupportedNativeSharing: + return "Recipient-key sharing is not supported for encrypted vaults in version 1." + case .staleRevision: + return "The encrypted item changed on another device." + case .syncAnchorExpired: + return "The encrypted sync anchor is no longer retained." + } + } +} + +public protocol EncryptedVaultProviding: Sendable { + func synchronize() async throws -> VaultFrontier + func item(_ identifier: VaultItemIdentifier) async throws -> VaultItem + func children( + of parentID: VaultItemIdentifier?, + trashed: Bool, + cursor: String?, + limit: Int + ) async throws -> VaultItemPage + func workingSet(limit: Int) async throws -> [VaultItem] + func changes( + since anchorString: String, + scope: VaultChangeScope + ) async throws -> VaultItemChanges + func fetchContent( + itemID: VaultItemIdentifier, + expectedRevision: VaultRevision?, + to plaintextURL: URL + ) async throws -> VaultItem + func createDirectory( + parentID: VaultItemIdentifier?, + filename: String, + createdAt: Date + ) async throws -> VaultItem + func createFile( + parentID: VaultItemIdentifier?, + filename: String, + contentTypeIdentifier: String?, + plaintextURL: URL, + modifiedAt: Date + ) async throws -> VaultItem + func modify( + itemID: VaultItemIdentifier, + baseContentRevision: VaultRevision, + baseMetadataRevision: VaultRevision, + parentID: VaultItemIdentifier?, + filename: String, + favorite: Bool, + plaintextURL: URL?, + modifiedAt: Date + ) async throws -> VaultItem + func trash( + itemID: VaultItemIdentifier, + baseContentRevision: VaultRevision, + baseMetadataRevision: VaultRevision + ) async throws + func restore(itemID: VaultItemIdentifier, parentID: VaultItemIdentifier?) async throws -> VaultItem + func purge( + itemID: VaultItemIdentifier, + baseContentRevision: VaultRevision, + baseMetadataRevision: VaultRevision + ) async throws + func duplicate(itemID: VaultItemIdentifier) async throws -> VaultItem + func versions(itemID: VaultItemIdentifier) async throws -> [VaultVersion] + func restoreVersion( + itemID: VaultItemIdentifier, + contentRevision: VaultRevision + ) async throws -> VaultItem +} + +public actor EncryptedVaultService: EncryptedVaultProviding { + private let configuration: ProviderDomainConfiguration + private let vaultConfiguration: ProviderVaultConfiguration + private let layout: VaultBootstrap.RemoteLayout + private let rootKey: VaultKeyMaterial + private let deviceID: UUID + private let objectStore: any KDriveObjectStoreProviding + private let localStore: any VaultLocalStateStoring + private let keyStore: any VaultKeyStoring + private let temporaryDirectoryURL: URL + + public init( + configuration: ProviderDomainConfiguration, + rootKey: VaultKeyMaterial, + deviceID: UUID, + objectStore: any KDriveObjectStoreProviding, + localStore: any VaultLocalStateStoring, + keyStore: any VaultKeyStoring, + temporaryDirectoryURL: URL = FileManager.default.temporaryDirectory + ) throws { + guard configuration.encryptionMode == .opaqueVaultV1, + let vaultConfiguration = configuration.vault, + let layout = vaultConfiguration.remoteLayout else { + throw EncryptedVaultError.missingConfiguration + } + self.configuration = configuration + self.vaultConfiguration = vaultConfiguration + self.layout = layout + self.rootKey = rootKey + self.deviceID = deviceID + self.objectStore = objectStore + self.localStore = localStore + self.keyStore = keyStore + self.temporaryDirectoryURL = temporaryDirectoryURL + } + + public func synchronize() async throws -> VaultFrontier { + let localObjects = try await localStore.journalObjects() + var objectsByID = Dictionary(uniqueKeysWithValues: localObjects.map { + ($0.transactionID, $0) + }) + var knownRemoteIDs = Set(localObjects.compactMap(\.remoteFileID)) + var observedRemoteIDs: Set = [] + var cursor: String? + var downloaded: [VaultStoredJournalObject] = [] + + repeat { + let page = try await objectStore.listObjects( + containerID: layout.journalContainerID, + cursor: cursor + ) + for object in page.objects { + observedRemoteIDs.insert(object.id) + guard knownRemoteIDs.contains(object.id) == false else { + continue + } + let url = temporaryURL(prefix: "journal-download") + defer { try? FileManager.default.removeItem(at: url) } + try await objectStore.downloadObject(fileID: object.id, to: url) + let envelope = try Data(contentsOf: url, options: .mappedIfSafe) + let transaction = try VaultFixedTransactionCodec.open( + envelope, + objectToken: object.token, + rootKey: rootKey, + vaultID: vaultConfiguration.vaultIdentifier, + keyEpoch: vaultConfiguration.keyEpoch + ) + guard objectsByID[transaction.id] == nil else { + throw VaultJournalError.duplicateTransaction(transaction.id) + } + let stored = VaultStoredJournalObject( + transactionID: transaction.id, + objectToken: object.token, + remoteFileID: object.id, + envelope: envelope, + committedAt: object.serverUpdatedAt + ) + objectsByID[transaction.id] = stored + knownRemoteIDs.insert(object.id) + downloaded.append(stored) + } + cursor = page.nextCursor + } while cursor != nil + + // Journal compaction is intentionally disabled in v1. Therefore every + // previously stored remote journal object must remain present in a + // complete listing. Folding cached transactions into a listing that + // omitted them would mask a server rollback. + guard knownRemoteIDs.isSubset(of: observedRemoteIDs) else { + throw VaultJournalError.rollbackDetected + } + + let transactions = try objectsByID.values.map { stored in + try VaultFixedTransactionCodec.open( + stored.envelope, + objectToken: stored.objectToken, + rootKey: rootKey, + vaultID: vaultConfiguration.vaultIdentifier, + keyEpoch: vaultConfiguration.keyEpoch + ) + } + let state = try VaultJournalReducer.reduce(transactions) + let trustedState = try await keyStore.loadTrustedState( + vaultID: vaultConfiguration.vaultIdentifier + ) + try VaultRollbackValidator.validate( + trustedState: trustedState, + currentState: state + ) + + let localState = try await localStore.state() + if downloaded.isEmpty { + if localState != state { + try await localStore.replace(with: state) + } + } else { + try await localStore.save( + state: state, + journalObjects: downloaded + ) + } + try await keyStore.saveTrustedState(VaultTrustedState( + vaultID: vaultConfiguration.vaultIdentifier, + keyEpoch: vaultConfiguration.keyEpoch, + frontier: state.frontier, + checkpointDigest: try VaultMerkleTree.root(for: transactions) + )) + return state.frontier + } + + public func item(_ identifier: VaultItemIdentifier) async throws -> VaultItem { + guard let item = try await localStore.item(identifier) else { + throw EncryptedVaultError.itemNotFound + } + return item + } + + public func children( + of parentID: VaultItemIdentifier?, + trashed: Bool, + cursor: String?, + limit: Int + ) async throws -> VaultItemPage { + let source: [VaultItem] + if trashed, parentID == nil { + source = try await localStore.allItems().filter(\.isTrashed) + } else { + source = try await localStore.children(of: parentID, trashed: trashed) + } + let all = source + .sorted { + let order = $0.filename.localizedStandardCompare($1.filename) + return order == .orderedSame + ? $0.id.rawValue.uuidString < $1.id.rawValue.uuidString + : order == .orderedAscending + } + let offset = cursor.flatMap(Int.init) ?? 0 + guard offset >= 0, offset <= all.count else { + throw VaultCryptoError.invalidLength + } + let pageSize = max(1, limit) + let upper = min(all.count, offset + pageSize) + let items = Array(all[offset.. [VaultItem] { + Array(try await localStore.allItems() + .filter { $0.isTrashed == false } + .sorted { + if $0.modifiedAt != $1.modifiedAt { + return $0.modifiedAt > $1.modifiedAt + } + return $0.id.rawValue.uuidString < $1.id.rawValue.uuidString + } + .prefix(max(1, limit))) + } + + public func changes( + since anchorString: String, + scope: VaultChangeScope + ) async throws -> VaultItemChanges { + let frontier = try await synchronize() + let before: VaultReducedState + if let retained = try await localStore.state(anchorString: anchorString) { + before = retained + } else if anchorString == VaultFrontier().anchorString { + before = VaultReducedState() + } else { + throw EncryptedVaultError.syncAnchorExpired + } + let after = try await localStore.state() + let identifiers = Set(before.items.keys).union(after.items.keys) + var updated: [VaultItem] = [] + var deleted: [VaultItemIdentifier] = [] + for identifier in identifiers.sorted(by: { + $0.rawValue.uuidString < $1.rawValue.uuidString + }) { + let oldItem = before.items[identifier] + let newItem = after.items[identifier] + guard oldItem != newItem else { continue } + let wasInScope = oldItem.map { itemIsInScope($0, scope: scope) } ?? false + let isInScope = newItem.map { itemIsInScope($0, scope: scope) } ?? false + guard wasInScope || isInScope else { continue } + if let newItem { + updated.append(newItem) + } else { + deleted.append(identifier) + } + } + return VaultItemChanges( + updated: updated, + deleted: deleted, + frontier: frontier + ) + } + + public func fetchContent( + itemID: VaultItemIdentifier, + expectedRevision: VaultRevision?, + to plaintextURL: URL + ) async throws -> VaultItem { + _ = try await synchronize() + let item = try await item(itemID) + if let expectedRevision, expectedRevision != item.contentRevision { + throw EncryptedVaultError.staleRevision + } + guard let reference = item.contentReference, + let remoteFileID = reference.remoteFileID else { + throw EncryptedVaultError.missingContent + } + let ciphertextURL = temporaryURL(prefix: "content-download") + defer { try? FileManager.default.removeItem(at: ciphertextURL) } + try await objectStore.downloadObject(fileID: remoteFileID, to: ciphertextURL) + try Task.checkCancellation() + let contentKey = try VaultCryptography.unwrapContentKey( + reference.wrappedContentKey, + objectToken: reference.objectToken, + rootKey: rootKey, + vaultID: vaultConfiguration.vaultIdentifier, + keyEpoch: vaultConfiguration.keyEpoch + ) + try VaultContentCipher.decrypt( + ciphertextURL: ciphertextURL, + plaintextURL: plaintextURL, + context: VaultContentEncryptionContext( + vaultID: vaultConfiguration.vaultIdentifier, + itemID: reference.encryptionItemID, + contentRevision: item.contentRevision, + objectToken: reference.objectToken, + keyEpoch: vaultConfiguration.keyEpoch + ), + contentKey: contentKey, + expectedNoncePrefix: reference.noncePrefix, + expectedPlaintextLength: reference.plaintextLength, + expectedPlaintextDigest: reference.plaintextDigest, + expectedFrameCount: reference.frameCount + ) + try applyPlaintextFileProtection(to: plaintextURL) + return item + } + + public func createDirectory( + parentID: VaultItemIdentifier?, + filename: String, + createdAt: Date + ) async throws -> VaultItem { + _ = try await synchronize() + try await validateParent(parentID) + let identifier = VaultItemIdentifier() + let contentRevision = VaultRevision( + hashing: Data("directory:\(identifier.rawValue.uuidString)".utf8) + ) + var item = VaultItem( + id: identifier, + parentID: parentID, + filename: filename, + isDirectory: true, + createdAt: createdAt, + modifiedAt: createdAt, + contentRevision: contentRevision, + metadataRevision: contentRevision + ) + item.metadataRevision = try metadataRevision(for: item) + return try await commitUpsert(item, base: nil) + } + + public func createFile( + parentID: VaultItemIdentifier?, + filename: String, + contentTypeIdentifier: String?, + plaintextURL: URL, + modifiedAt: Date + ) async throws -> VaultItem { + _ = try await synchronize() + try await validateParent(parentID) + let identifier = VaultItemIdentifier() + let encrypted = try await encryptAndUpload( + plaintextURL: plaintextURL, + itemID: identifier + ) + var item = VaultItem( + id: identifier, + parentID: parentID, + filename: filename, + isDirectory: false, + contentTypeIdentifier: contentTypeIdentifier, + createdAt: modifiedAt, + modifiedAt: modifiedAt, + plaintextSize: encrypted.reference.plaintextLength, + contentRevision: encrypted.revision, + metadataRevision: encrypted.revision, + contentReference: encrypted.reference + ) + item.metadataRevision = try metadataRevision(for: item) + return try await commitUpsert(item, base: nil) + } + + /// Migration-only staging boundary. The returned file contains ciphertext + /// and can be resumed without retaining a plaintext staging file. + public func stageFileImport( + itemID: VaultItemIdentifier = VaultItemIdentifier(), + plaintextURL: URL + ) async throws -> VaultStagedContent { + let token = try VaultCryptography.makeObjectToken( + rootKey: rootKey, + vaultID: vaultConfiguration.vaultIdentifier + ) + let revision = try VaultRevision.random() + let ciphertextURL = temporaryURL(prefix: "migration-content") + let result: VaultContentEncryptionResult + do { + result = try VaultContentCipher.encrypt( + plaintextURL: plaintextURL, + ciphertextURL: ciphertextURL, + context: VaultContentEncryptionContext( + vaultID: vaultConfiguration.vaultIdentifier, + itemID: itemID, + contentRevision: revision, + objectToken: token, + keyEpoch: vaultConfiguration.keyEpoch + ) + ) + } catch { + try? FileManager.default.removeItem(at: ciphertextURL) + throw error + } + let wrappedKey = try VaultCryptography.wrapContentKey( + result.contentKey, + objectToken: token, + rootKey: rootKey, + vaultID: vaultConfiguration.vaultIdentifier, + keyEpoch: vaultConfiguration.keyEpoch + ) + return VaultStagedContent( + itemID: itemID, + contentRevision: revision, + objectToken: token, + ciphertextURL: ciphertextURL, + wrappedContentKey: wrappedKey, + noncePrefix: result.noncePrefix, + plaintextLength: result.plaintextLength, + plaintextDigest: result.plaintextDigest, + frameCount: result.frameCount + ) + } + + public func uploadStagedFileImport( + _ staged: VaultStagedContent + ) async throws -> VaultUploadedContent { + let remote = try await uploadIdempotently( + containerID: layout.contentContainerID, + token: staged.objectToken, + fileURL: staged.ciphertextURL + ) + return VaultUploadedContent(staged: staged, remoteFileID: remote.id) + } + + public func commitUploadedFileImport( + _ uploaded: VaultUploadedContent, + parentID: VaultItemIdentifier?, + filename: String, + contentTypeIdentifier: String?, + createdAt: Date, + modifiedAt: Date + ) async throws -> VaultItem { + _ = try await synchronize() + try await validateParent(parentID) + var item = VaultItem( + id: uploaded.staged.itemID, + parentID: parentID, + filename: filename, + isDirectory: false, + contentTypeIdentifier: contentTypeIdentifier, + createdAt: createdAt, + modifiedAt: modifiedAt, + plaintextSize: uploaded.staged.plaintextLength, + contentRevision: uploaded.staged.contentRevision, + metadataRevision: uploaded.staged.contentRevision, + contentReference: uploaded.contentReference + ) + item.metadataRevision = try metadataRevision(for: item) + let committed = try await commitUpsert(item, base: nil) + try? FileManager.default.removeItem(at: uploaded.staged.ciphertextURL) + return committed + } + + public func discardStagedFileImport(_ staged: VaultStagedContent) { + try? FileManager.default.removeItem(at: staged.ciphertextURL) + } + + public func modify( + itemID: VaultItemIdentifier, + baseContentRevision: VaultRevision, + baseMetadataRevision: VaultRevision, + parentID: VaultItemIdentifier?, + filename: String, + favorite: Bool, + plaintextURL: URL?, + modifiedAt: Date + ) async throws -> VaultItem { + _ = try await synchronize() + try await validateParent(parentID) + let base = try await item(itemID) + guard base.contentRevision == baseContentRevision, + base.metadataRevision == baseMetadataRevision else { + throw EncryptedVaultError.staleRevision + } + var desired = base + desired.parentID = parentID + desired.filename = filename + desired.isFavorite = favorite + + if let plaintextURL { + if let existingReference = base.contentReference { + desired.versions.insert(VaultVersion( + contentRevision: base.contentRevision, + contentReference: existingReference, + plaintextSize: base.plaintextSize, + modifiedAt: base.modifiedAt + ), at: 0) + desired.versions = retainedVersions(desired.versions) + } + let encrypted = try await encryptAndUpload( + plaintextURL: plaintextURL, + itemID: itemID + ) + desired.contentRevision = encrypted.revision + desired.contentReference = encrypted.reference + desired.plaintextSize = encrypted.reference.plaintextLength + desired.modifiedAt = modifiedAt + } + desired.metadataRevision = try metadataRevision(for: desired) + return try await commitUpsert(desired, base: base) + } + + public func trash( + itemID: VaultItemIdentifier, + baseContentRevision: VaultRevision, + baseMetadataRevision: VaultRevision + ) async throws { + _ = try await synchronize() + let current = try await item(itemID) + guard current.contentRevision == baseContentRevision, + current.metadataRevision == baseMetadataRevision else { + throw EncryptedVaultError.staleRevision + } + _ = try await commit( + operation: .trash( + itemID: itemID, + baseContentRevision: baseContentRevision, + baseMetadataRevision: baseMetadataRevision + ), + base: current + ) + } + + public func restore( + itemID: VaultItemIdentifier, + parentID: VaultItemIdentifier? + ) async throws -> VaultItem { + _ = try await synchronize() + try await validateParent(parentID) + let current = try await item(itemID) + let state = try await commit( + operation: .restore(itemID: itemID, parentID: parentID), + base: current + ) + guard let restored = state.items[itemID] else { + throw EncryptedVaultError.itemNotFound + } + return restored + } + + public func purge( + itemID: VaultItemIdentifier, + baseContentRevision: VaultRevision, + baseMetadataRevision: VaultRevision + ) async throws { + _ = try await synchronize() + let current = try await item(itemID) + guard current.contentRevision == baseContentRevision, + current.metadataRevision == baseMetadataRevision else { + throw EncryptedVaultError.staleRevision + } + _ = try await commit( + operation: .purge( + itemID: itemID, + baseContentRevision: baseContentRevision, + baseMetadataRevision: baseMetadataRevision + ), + base: current + ) + } + + public func duplicate(itemID: VaultItemIdentifier) async throws -> VaultItem { + _ = try await synchronize() + let source = try await item(itemID) + let duplicateID = VaultItemIdentifier() + var duplicate = VaultItem( + id: duplicateID, + parentID: source.parentID, + filename: duplicateFilename(source.filename), + isDirectory: source.isDirectory, + contentTypeIdentifier: source.contentTypeIdentifier, + createdAt: Date(), + modifiedAt: source.modifiedAt, + plaintextSize: source.plaintextSize, + isFavorite: false, + isTrashed: false, + contentRevision: source.contentRevision, + metadataRevision: source.metadataRevision, + contentReference: source.contentReference, + versions: source.versions + ) + duplicate.metadataRevision = try metadataRevision(for: duplicate) + return try await commitUpsert(duplicate, base: nil) + } + + public func versions(itemID: VaultItemIdentifier) async throws -> [VaultVersion] { + _ = try await synchronize() + return try await item(itemID).versions + } + + public func restoreVersion( + itemID: VaultItemIdentifier, + contentRevision: VaultRevision + ) async throws -> VaultItem { + _ = try await synchronize() + let base = try await item(itemID) + guard let version = base.versions.first(where: { + $0.contentRevision == contentRevision + }) else { + throw EncryptedVaultError.missingContent + } + var desired = base + if let currentReference = base.contentReference { + desired.versions.insert(VaultVersion( + contentRevision: base.contentRevision, + contentReference: currentReference, + plaintextSize: base.plaintextSize, + modifiedAt: base.modifiedAt + ), at: 0) + } + desired.versions.removeAll { $0.contentRevision == contentRevision } + desired.versions = retainedVersions(desired.versions) + desired.contentRevision = version.contentRevision + desired.contentReference = version.contentReference + desired.plaintextSize = version.plaintextSize + desired.modifiedAt = Date() + desired.metadataRevision = try metadataRevision(for: desired) + return try await commitUpsert(desired, base: base) + } + + private func commitUpsert( + _ item: VaultItem, + base: VaultItem? + ) async throws -> VaultItem { + let state = try await commit(operation: .upsert(item), base: base) + guard let committed = state.items[item.id] else { + throw EncryptedVaultError.itemNotFound + } + return committed + } + + private func commit( + operation: VaultTransaction.Operation, + base: VaultItem? + ) async throws -> VaultReducedState { + let current = try await localStore.state() + let transaction = VaultTransaction( + parents: current.frontier, + deviceID: deviceID, + baseItem: base, + operation: operation + ) + let token = try VaultCryptography.makeObjectToken( + rootKey: rootKey, + vaultID: vaultConfiguration.vaultIdentifier + ) + let envelope = try VaultFixedTransactionCodec.seal( + transaction, + objectToken: token, + rootKey: rootKey, + vaultID: vaultConfiguration.vaultIdentifier, + keyEpoch: vaultConfiguration.keyEpoch + ) + let transactionURL = temporaryURL(prefix: "transaction") + defer { try? FileManager.default.removeItem(at: transactionURL) } + try envelope.write(to: transactionURL, options: [.atomic]) + + // Publishing this immutable object is the sole atomic visibility point. + let remote = try await uploadIdempotently( + containerID: layout.journalContainerID, + token: token, + fileURL: transactionURL + ) + let checkpoint = VaultCheckpoint( + frontier: current.frontier, + items: Array(current.items.values), + transactionMerkleRoot: Data() + ) + let reduced = try VaultJournalReducer.reduce( + [transaction], + checkpoint: checkpoint + ) + let combined = VaultReducedState( + items: reduced.items, + frontier: reduced.frontier, + appliedTransactionIDs: current.appliedTransactionIDs.union([transaction.id]), + conflicts: current.conflicts + reduced.conflicts + ) + try await localStore.save( + state: combined, + journalObjects: [VaultStoredJournalObject( + transactionID: transaction.id, + objectToken: token, + remoteFileID: remote.id, + envelope: envelope, + committedAt: remote.serverUpdatedAt + )] + ) + try await keyStore.saveTrustedState(VaultTrustedState( + vaultID: vaultConfiguration.vaultIdentifier, + keyEpoch: vaultConfiguration.keyEpoch, + frontier: combined.frontier, + checkpointDigest: nil + )) + return combined + } + + private func encryptAndUpload( + plaintextURL: URL, + itemID: VaultItemIdentifier + ) async throws -> (revision: VaultRevision, reference: VaultContentReference) { + let token = try VaultCryptography.makeObjectToken( + rootKey: rootKey, + vaultID: vaultConfiguration.vaultIdentifier + ) + let provisionalRevision = try VaultRevision.random() + let context = VaultContentEncryptionContext( + vaultID: vaultConfiguration.vaultIdentifier, + itemID: itemID, + contentRevision: provisionalRevision, + objectToken: token, + keyEpoch: vaultConfiguration.keyEpoch + ) + let ciphertextURL = temporaryURL(prefix: "content") + defer { try? FileManager.default.removeItem(at: ciphertextURL) } + let result = try VaultContentCipher.encrypt( + plaintextURL: plaintextURL, + ciphertextURL: ciphertextURL, + context: context + ) + let remote = try await uploadIdempotently( + containerID: layout.contentContainerID, + token: token, + fileURL: ciphertextURL + ) + let wrappedKey = try VaultCryptography.wrapContentKey( + result.contentKey, + objectToken: token, + rootKey: rootKey, + vaultID: vaultConfiguration.vaultIdentifier, + keyEpoch: vaultConfiguration.keyEpoch + ) + return ( + provisionalRevision, + VaultContentReference( + encryptionItemID: itemID, + objectToken: token, + remoteFileID: remote.id, + wrappedContentKey: wrappedKey, + noncePrefix: result.noncePrefix, + plaintextLength: result.plaintextLength, + plaintextDigest: result.plaintextDigest, + frameCount: result.frameCount + ) + ) + } + + private func uploadIdempotently( + containerID: Int, + token: String, + fileURL: URL + ) async throws -> KDriveOpaqueObject { + do { + return try await objectStore.uploadObject( + containerID: containerID, + token: token, + fileURL: fileURL + ) + } catch KDriveObjectStoreError.responseRejected(let statusCode, _) + where statusCode == 409 { + var cursor: String? + repeat { + let page = try await objectStore.listObjects( + containerID: containerID, + cursor: cursor + ) + if let existing = page.objects.first(where: { $0.token == token }) { + return existing + } + cursor = page.nextCursor + } while cursor != nil + throw KDriveObjectStoreError.responseRejected( + statusCode: statusCode, + body: "An idempotent upload conflicted without a matching opaque token." + ) + } + } + + private func validateParent(_ parentID: VaultItemIdentifier?) async throws { + guard let parentID else { return } + guard let parent = try await localStore.item(parentID) else { + throw EncryptedVaultError.parentNotFound + } + guard parent.isDirectory, parent.isTrashed == false else { + throw EncryptedVaultError.notDirectory + } + } + + private func metadataRevision(for item: VaultItem) throws -> VaultRevision { + try VaultRevisionDigests.metadata(for: item) + } + + private func retainedVersions(_ versions: [VaultVersion]) -> [VaultVersion] { + let newestFirst = versions.sorted { $0.modifiedAt > $1.modifiedAt } + let cutoff = Date().addingTimeInterval(-30 * 24 * 60 * 60) + var retained = Array(newestFirst.prefix(10)) + let retainedIDs = Set(retained.map(\.contentRevision)) + retained.append(contentsOf: newestFirst.filter { + $0.modifiedAt >= cutoff && retainedIDs.contains($0.contentRevision) == false + }) + return retained + } + + private func duplicateFilename(_ filename: String) -> String { + let pathExtension = (filename as NSString).pathExtension + let base = (filename as NSString).deletingPathExtension + if pathExtension.isEmpty { + return "\(base) copy" + } + return "\(base) copy.\(pathExtension)" + } + + private func temporaryURL(prefix: String) -> URL { + temporaryDirectoryURL + .appendingPathComponent("\(prefix)-\(UUID().uuidString)") + .appendingPathExtension("bin") + } + + private func applyPlaintextFileProtection(to url: URL) throws { + #if canImport(Darwin) + try FileManager.default.setAttributes( + [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], + ofItemAtPath: url.path + ) + #endif + } + + private func itemIsInScope( + _ item: VaultItem, + scope: VaultChangeScope + ) -> Bool { + switch scope { + case .children(let parentID): + return item.parentID == parentID && item.isTrashed == false + case .trash: + return item.isTrashed + case .workingSet: + return item.isTrashed == false + } + } + +} diff --git a/PotassiumProviderCore/KDriveObjectStore.swift b/PotassiumProviderCore/KDriveObjectStore.swift new file mode 100644 index 0000000..f9be88e --- /dev/null +++ b/PotassiumProviderCore/KDriveObjectStore.swift @@ -0,0 +1,269 @@ +import Foundation +import PotassiumChannelCore +import PotassiumKDrive + +public struct KDriveOpaqueObject: Equatable, Identifiable, Sendable { + public let id: Int + public let parentID: Int + public let token: String + public let byteCount: Int64? + public let serverUpdatedAt: Date + public let isContainer: Bool + + public init( + id: Int, + parentID: Int, + token: String, + byteCount: Int64?, + serverUpdatedAt: Date, + isContainer: Bool + ) { + self.id = id + self.parentID = parentID + self.token = token + self.byteCount = byteCount + self.serverUpdatedAt = serverUpdatedAt + self.isContainer = isContainer + } +} + +public struct KDriveOpaqueObjectPage: Equatable, Sendable { + public let objects: [KDriveOpaqueObject] + public let nextCursor: String? + + public init(objects: [KDriveOpaqueObject], nextCursor: String?) { + self.objects = objects + self.nextCursor = nextCursor + } +} + +public protocol KDriveObjectStoreProviding: Sendable { + func createContainer(parentID: Int, token: String) async throws -> KDriveOpaqueObject + func listObjects(containerID: Int, cursor: String?) async throws -> KDriveOpaqueObjectPage + func uploadObject( + containerID: Int, + token: String, + fileURL: URL + ) async throws -> KDriveOpaqueObject + func downloadObject(fileID: Int, to destinationURL: URL) async throws + func deleteObject(fileID: Int) async throws +} + +public enum KDriveObjectStoreError: Error, Equatable, LocalizedError, Sendable { + case invalidObjectToken + case unexpectedPhysicalName(String) + case missingHTTPResponse + case responseRejected(statusCode: Int, body: String) + + public var errorDescription: String? { + switch self { + case .invalidObjectToken: + return "The opaque object token is invalid." + case .unexpectedPhysicalName: + return "kDrive returned an object with a non-vault physical name." + case .missingHTTPResponse: + return "kDrive returned no HTTP response." + case .responseRejected(let statusCode, _): + return "kDrive rejected an opaque object transfer with HTTP \(statusCode)." + } + } +} + +/// The only kDrive adapter used by encrypted domains. Uploads are backed by a +/// file URL and downloads arrive as URLSession temporary files; ciphertext is +/// never assembled in one in-memory `Data` value. +public struct PotassiumKDriveObjectStore: KDriveObjectStoreProviding { + private let driveID: Int + private let bearerToken: String + private let session: URLSession + private let client: InfomaniakAPIClient + private let metadataService: any KDriveFileProviding + + public init( + driveID: Int, + bearerToken: String, + apiBaseURL: URL = ProviderConstants.apiBaseURL, + session: URLSession = .shared + ) { + self.driveID = driveID + self.bearerToken = bearerToken + self.session = session + self.client = InfomaniakAPIClient( + configuration: APIClientConfiguration( + baseURL: apiBaseURL, + bearerToken: bearerToken + ), + session: session + ) + self.metadataService = PotassiumKDriveService( + bearerToken: bearerToken, + apiBaseURL: apiBaseURL, + session: session + ) + } + + public func createContainer( + parentID: Int, + token: String + ) async throws -> KDriveOpaqueObject { + try validateToken(token) + let item = try await metadataService.createDirectory( + driveID: driveID, + parentID: parentID, + name: token + ) + return try opaqueObject(item, expectsContainer: true) + } + + public func listObjects( + containerID: Int, + cursor: String? + ) async throws -> KDriveOpaqueObjectPage { + let page = try await metadataService.listDirectory( + driveID: driveID, + folderID: containerID, + cursor: cursor, + limit: 200 + ) + let nextCursor = try KDriveListingValidator.validatedNextCursor( + currentCursor: cursor, + nextCursor: page.nextCursor, + hasMore: page.hasMore + ) + return KDriveOpaqueObjectPage( + objects: try page.items.map { try opaqueObject($0, expectsContainer: nil) }, + nextCursor: nextCursor + ) + } + + public func uploadObject( + containerID: Int, + token: String, + fileURL: URL + ) async throws -> KDriveOpaqueObject { + try validateToken(token) + let byteCount = try fileURL.resourceValues( + forKeys: [.fileSizeKey] + ).fileSize ?? 0 + let request = try await uploadRequest( + containerID: containerID, + token: token, + byteCount: byteCount + ) + let (responseData, response) = try await session.upload( + for: request, + fromFile: fileURL + ) + try validate(response: response, responseData: responseData) + let decoded = try InfomaniakJSONResponseDecoder().decode( + InfomaniakResponse.self, + from: responseData + ) + return try opaqueObject(decoded.data.remoteItem, expectsContainer: false) + } + + public func downloadObject(fileID: Int, to destinationURL: URL) async throws { + let request = try await client.makeURLRequest( + for: KDriveRequests.downloadFile(driveId: driveID, fileId: fileID) + ) + let (temporaryURL, response) = try await session.download(for: request) + try validate(response: response, responseData: Data()) + try Task.checkCancellation() + try FileManager.default.createDirectory( + at: destinationURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + if FileManager.default.fileExists(atPath: destinationURL.path) { + try FileManager.default.removeItem(at: destinationURL) + } + try FileManager.default.moveItem(at: temporaryURL, to: destinationURL) + try applyCiphertextFileProtection(to: destinationURL) + } + + public func deleteObject(fileID: Int) async throws { + try await metadataService.trashItem(driveID: driveID, fileID: fileID) + try await metadataService.deleteTrashedItem(driveID: driveID, fileID: fileID) + } + + func uploadRequest( + containerID: Int, + token: String, + byteCount: Int + ) async throws -> URLRequest { + let placeholder = APIRequest( + method: .post, + path: "/3/drive/\(driveID)/upload", + queryParameters: [ + QueryParameter(name: "total_size", value: .integer(byteCount)), + QueryParameter(name: "client_token", value: .string(token)), + QueryParameter(name: "conflict", value: .string("error")), + QueryParameter(name: "directory_id", value: .integer(containerID)), + QueryParameter(name: "file_name", value: .string("\(token).bin")), + ], + headers: [ + HTTPHeader(name: "Accept", value: "application/json"), + HTTPHeader(name: "Content-Type", value: "application/octet-stream"), + ] + ) + var request = try await client.makeURLRequest(for: placeholder) + request.httpBody = nil + // Keep the token local to URLSession's credential-free request copy. + request.setValue("Bearer \(bearerToken)", forHTTPHeaderField: "Authorization") + return request + } + + private func opaqueObject( + _ item: KDriveRemoteItem, + expectsContainer: Bool? + ) throws -> KDriveOpaqueObject { + let token: String + if item.isDirectory { + token = item.name + } else { + guard item.name.hasSuffix(".bin") else { + throw KDriveObjectStoreError.unexpectedPhysicalName(item.name) + } + token = String(item.name.dropLast(4)) + } + try validateToken(token) + if let expectsContainer, expectsContainer != item.isDirectory { + throw KDriveObjectStoreError.unexpectedPhysicalName(item.name) + } + return KDriveOpaqueObject( + id: item.id, + parentID: item.parentID, + token: token, + byteCount: item.size.map(Int64.init), + serverUpdatedAt: item.updatedAt, + isContainer: item.isDirectory + ) + } + + private func validateToken(_ token: String) throws { + guard let bytes = Data(base64URLEncoded: token), + bytes.count == 20 else { + throw KDriveObjectStoreError.invalidObjectToken + } + } + + private func validate(response: URLResponse, responseData: Data) throws { + guard let httpResponse = response as? HTTPURLResponse else { + throw KDriveObjectStoreError.missingHTTPResponse + } + guard (200..<300).contains(httpResponse.statusCode) else { + throw KDriveObjectStoreError.responseRejected( + statusCode: httpResponse.statusCode, + body: String(data: responseData, encoding: .utf8) ?? "" + ) + } + } + + private func applyCiphertextFileProtection(to url: URL) throws { + #if canImport(Darwin) + try FileManager.default.setAttributes( + [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], + ofItemAtPath: url.path + ) + #endif + } +} diff --git a/PotassiumProviderCore/ProviderActionRuntime.swift b/PotassiumProviderCore/ProviderActionRuntime.swift index fccf7f5..15ac9cd 100644 --- a/PotassiumProviderCore/ProviderActionRuntime.swift +++ b/PotassiumProviderCore/ProviderActionRuntime.swift @@ -5,17 +5,20 @@ public struct ProviderActionRuntime: Sendable { public let remote: any KDriveFileProviding public let actions: any KDriveContextActionProviding public let eventStore: (any KDriveProviderEventStoring)? + public let encryptedVault: (any EncryptedVaultProviding)? public init( configuration: ProviderDomainConfiguration, remote: any KDriveFileProviding, actions: any KDriveContextActionProviding, - eventStore: (any KDriveProviderEventStoring)? + eventStore: (any KDriveProviderEventStoring)?, + encryptedVault: (any EncryptedVaultProviding)? = nil ) { self.configuration = configuration self.remote = remote self.actions = actions self.eventStore = eventStore + self.encryptedVault = encryptedVault } public static func load(domainIdentifier: String) async throws -> ProviderActionRuntime { @@ -47,11 +50,48 @@ public struct ProviderActionRuntime: Sendable { let eventStore = try? KDriveProviderEventSQLiteStore( appGroupIdentifier: ProviderConstants.appGroupIdentifier ) + let encryptedVault: (any EncryptedVaultProviding)? + if configuration.encryptionMode == .opaqueVaultV1 { + guard let vaultConfiguration = configuration.vault else { + throw ProviderActionRuntimeError.configurationUnavailable + } + let keyStore = KeychainVaultKeyStore( + accessGroup: ProviderConstants.keychainAccessGroup + ) + guard let rootKey = try await keyStore.loadRootKey( + vaultID: vaultConfiguration.vaultIdentifier + ) else { + throw ProviderActionRuntimeError.notAuthenticated + } + let localStore = try VaultSQLiteStore( + appGroupIdentifier: ProviderConstants.appGroupIdentifier, + domainIdentifier: configuration.domainIdentifier, + vaultID: vaultConfiguration.vaultIdentifier, + rootKey: rootKey, + keyEpoch: vaultConfiguration.keyEpoch + ) + encryptedVault = try EncryptedVaultService( + configuration: configuration, + rootKey: rootKey, + deviceID: try await keyStore.loadOrCreateDeviceID( + vaultID: vaultConfiguration.vaultIdentifier + ), + objectStore: PotassiumKDriveObjectStore( + driveID: configuration.driveID, + bearerToken: token.accessToken + ), + localStore: localStore, + keyStore: keyStore + ) + } else { + encryptedVault = nil + } return ProviderActionRuntime( configuration: configuration, remote: service, actions: service, - eventStore: eventStore + eventStore: eventStore, + encryptedVault: encryptedVault ) } } diff --git a/PotassiumProviderCore/ProviderConstants.swift b/PotassiumProviderCore/ProviderConstants.swift index a5eaa25..2243f56 100644 --- a/PotassiumProviderCore/ProviderConstants.swift +++ b/PotassiumProviderCore/ProviderConstants.swift @@ -4,6 +4,7 @@ public enum ProviderConstants { public static let appGroupIdentifier = "group.net.weavee.potassiumProvider" public static let keychainAccessGroup = "2LST6WT4P6.net.weavee.potassiumProvider" public static let keychainService = "net.weavee.potassiumProvider.kDrive" + public static let vaultKeychainService = "net.weavee.potassiumProvider.vault" public static let keychainAccount = "oauthToken" public static let legacyAccountIdentifier = "legacy-account" public static let logSubsystem = "net.weavee.potassiumProvider" @@ -14,4 +15,5 @@ public enum ProviderConstants { public static let defaultRootFileID = 1 public static let apiBaseURL = URL(string: "https://api.infomaniak.com")! public static let driveBaseURL = URL(string: "https://api.kdrive.infomaniak.com")! + public static let encryptedVaultFeatureFlag = "EncryptedVaultsEnabled" } diff --git a/PotassiumProviderCore/ProviderDomainConfiguration.swift b/PotassiumProviderCore/ProviderDomainConfiguration.swift index 2d74144..ddf1925 100644 --- a/PotassiumProviderCore/ProviderDomainConfiguration.swift +++ b/PotassiumProviderCore/ProviderDomainConfiguration.swift @@ -13,6 +13,39 @@ public enum ProviderKnownFolderLayout: String, Codable, Equatable, Sendable { case machineNamespace } +public enum ProviderEncryptionMode: String, Codable, Equatable, Sendable { + /// Compatibility mode for domains created before encrypted vault support. + case legacyPlaintext + + /// Version 1 opaque, client-side encrypted vault. + case opaqueVaultV1 +} + +public struct ProviderVaultConfiguration: Codable, Equatable, Sendable { + public var vaultIdentifier: VaultIdentifier + public var vaultRootFileID: Int + public var vaultHeaderFileID: Int + public var formatVersion: UInt16 + public var keyEpoch: UInt32 + public var remoteLayout: VaultBootstrap.RemoteLayout? + + public init( + vaultIdentifier: VaultIdentifier, + vaultRootFileID: Int, + vaultHeaderFileID: Int, + formatVersion: UInt16 = VaultFormat.currentVersion, + keyEpoch: UInt32 = VaultFormat.currentKeyEpoch, + remoteLayout: VaultBootstrap.RemoteLayout? = nil + ) { + self.vaultIdentifier = vaultIdentifier + self.vaultRootFileID = vaultRootFileID + self.vaultHeaderFileID = vaultHeaderFileID + self.formatVersion = formatVersion + self.keyEpoch = keyEpoch + self.remoteLayout = remoteLayout + } +} + public struct ProviderAccount: Codable, Equatable, Identifiable, Sendable { public var id: String { accountIdentifier } @@ -148,6 +181,8 @@ public struct ProviderDomainConfiguration: Codable, Equatable, Identifiable, Sen public var driveName: String public var rootFileID: Int public var knownFolderLayout: ProviderKnownFolderLayout + public var encryptionMode: ProviderEncryptionMode + public var vault: ProviderVaultConfiguration? public var createdAt: Date public var updatedAt: Date @@ -159,6 +194,8 @@ public struct ProviderDomainConfiguration: Codable, Equatable, Identifiable, Sen driveName: String, rootFileID: Int = ProviderConstants.defaultRootFileID, knownFolderLayout: ProviderKnownFolderLayout = .machineNamespace, + encryptionMode: ProviderEncryptionMode = .legacyPlaintext, + vault: ProviderVaultConfiguration? = nil, createdAt: Date = Date(), updatedAt: Date = Date() ) { @@ -169,6 +206,8 @@ public struct ProviderDomainConfiguration: Codable, Equatable, Identifiable, Sen self.driveName = driveName self.rootFileID = rootFileID self.knownFolderLayout = knownFolderLayout + self.encryptionMode = encryptionMode + self.vault = vault self.createdAt = createdAt self.updatedAt = updatedAt } @@ -198,6 +237,8 @@ public struct ProviderDomainConfiguration: Codable, Equatable, Identifiable, Sen case driveName case rootFileID case knownFolderLayout + case encryptionMode + case vault case createdAt case updatedAt } @@ -216,6 +257,14 @@ public struct ProviderDomainConfiguration: Codable, Equatable, Identifiable, Sen ProviderKnownFolderLayout.self, forKey: .knownFolderLayout ) ?? .legacyPrivate + encryptionMode = try container.decodeIfPresent( + ProviderEncryptionMode.self, + forKey: .encryptionMode + ) ?? .legacyPlaintext + vault = try container.decodeIfPresent( + ProviderVaultConfiguration.self, + forKey: .vault + ) createdAt = try container.decode(Date.self, forKey: .createdAt) updatedAt = try container.decode(Date.self, forKey: .updatedAt) } diff --git a/PotassiumProviderCore/VaultContentCipher.swift b/PotassiumProviderCore/VaultContentCipher.swift new file mode 100644 index 0000000..fda27b6 --- /dev/null +++ b/PotassiumProviderCore/VaultContentCipher.swift @@ -0,0 +1,379 @@ +import CryptoKit +import Foundation + +public struct VaultContentEncryptionContext: Equatable, Sendable { + public let vaultID: VaultIdentifier + public let itemID: VaultItemIdentifier + public let contentRevision: VaultRevision + public let objectToken: String + public let keyEpoch: UInt32 + + public init( + vaultID: VaultIdentifier, + itemID: VaultItemIdentifier, + contentRevision: VaultRevision, + objectToken: String, + keyEpoch: UInt32 = VaultFormat.currentKeyEpoch + ) { + self.vaultID = vaultID + self.itemID = itemID + self.contentRevision = contentRevision + self.objectToken = objectToken + self.keyEpoch = keyEpoch + } +} + +public struct VaultContentEncryptionResult: Equatable, Sendable { + public let contentKey: VaultKeyMaterial + public let noncePrefix: UInt64 + public let plaintextLength: Int64 + public let plaintextDigest: Data + public let frameCount: UInt32 + public let ciphertextLength: Int64 + + public init( + contentKey: VaultKeyMaterial, + noncePrefix: UInt64, + plaintextLength: Int64, + plaintextDigest: Data, + frameCount: UInt32, + ciphertextLength: Int64 + ) { + self.contentKey = contentKey + self.noncePrefix = noncePrefix + self.plaintextLength = plaintextLength + self.plaintextDigest = plaintextDigest + self.frameCount = frameCount + self.ciphertextLength = ciphertextLength + } +} + +public enum VaultContentCipher { + private static let magic = Data("KPC1".utf8) + private static let headerByteCount = 4 + 2 + 4 + 8 + private static let authenticationTagByteCount = 16 + + public static func encrypt( + plaintextURL: URL, + ciphertextURL: URL, + context: VaultContentEncryptionContext, + contentKey suppliedContentKey: VaultKeyMaterial? = nil, + noncePrefix suppliedNoncePrefix: UInt64? = nil + ) throws -> VaultContentEncryptionResult { + let contentKey = try suppliedContentKey ?? VaultKeyMaterial.random() + let noncePrefix = try suppliedNoncePrefix ?? VaultRandom.uint64() + let sourceSize = try plaintextURL.resourceValues( + forKeys: [.fileSizeKey] + ).fileSize.map(Int64.init) ?? 0 + let maximumPlaintextSize = + Int64(UInt32.max) * Int64(VaultFormat.contentFrameSize) + guard sourceSize <= maximumPlaintextSize else { + throw VaultCryptoError.frameLimitExceeded + } + let input = try FileHandle(forReadingFrom: plaintextURL) + try prepareOutputFile(at: ciphertextURL) + let output = try FileHandle(forWritingTo: ciphertextURL) + var completed = false + + defer { + try? input.close() + try? output.close() + if completed == false { + try? FileManager.default.removeItem(at: ciphertextURL) + } + } + + var header = Data() + header.append(magic) + header.appendUInt16(VaultFormat.currentVersion) + header.appendUInt32(UInt32(VaultFormat.contentFrameSize)) + header.appendUInt64(noncePrefix) + try output.write(contentsOf: header) + + var hasher = SHA256() + var plaintextLength: Int64 = 0 + var frameIndex: UInt32 = 0 + var current = try input.read(upToCount: VaultFormat.contentFrameSize) ?? Data() + + while true { + try Task.checkCancellation() + guard frameIndex < UInt32.max else { + throw VaultCryptoError.frameLimitExceeded + } + let next = try input.read(upToCount: VaultFormat.contentFrameSize) ?? Data() + let isFinal = next.isEmpty + let unpaddedCount = current.count + plaintextLength += Int64(unpaddedCount) + hasher.update(data: current) + + let padded: Data + if isFinal { + padded = try paddedFinalFrame(current) + } else { + guard current.count == VaultFormat.contentFrameSize else { + throw VaultCryptoError.invalidFrame + } + padded = current + } + + let nonce = try frameNonce(prefix: noncePrefix, index: frameIndex) + let associatedData = try frameAssociatedData( + context: context, + frameIndex: frameIndex, + paddedLength: padded.count, + isFinal: isFinal + ) + let sealed = try AES.GCM.seal( + padded, + using: contentKey.symmetricKey, + nonce: nonce, + authenticating: associatedData + ) + var frame = Data() + frame.append(sealed.ciphertext) + frame.append(sealed.tag) + guard frame.count <= Int(UInt32.max) else { + throw VaultCryptoError.invalidFrame + } + var frameHeader = Data() + frameHeader.appendUInt32(UInt32(frame.count)) + try output.write(contentsOf: frameHeader) + try output.write(contentsOf: frame) + frameIndex += 1 + + if isFinal { + break + } + current = next + } + + try output.synchronize() + let ciphertextLength = try output.offset() + completed = true + return VaultContentEncryptionResult( + contentKey: contentKey, + noncePrefix: noncePrefix, + plaintextLength: plaintextLength, + plaintextDigest: Data(hasher.finalize()), + frameCount: frameIndex, + ciphertextLength: Int64(ciphertextLength) + ) + } + + public static func decrypt( + ciphertextURL: URL, + plaintextURL: URL, + context: VaultContentEncryptionContext, + contentKey: VaultKeyMaterial, + expectedNoncePrefix: UInt64, + expectedPlaintextLength: Int64, + expectedPlaintextDigest: Data, + expectedFrameCount: UInt32 + ) throws { + guard expectedPlaintextLength >= 0, + expectedPlaintextDigest.count == SHA256.Digest.byteCount else { + throw VaultCryptoError.invalidLength + } + let frameSize = Int64(VaultFormat.contentFrameSize) + let expectedCount = max( + 1, + expectedPlaintextLength / frameSize + + (expectedPlaintextLength.isMultiple(of: frameSize) ? 0 : 1) + ) + guard expectedCount <= Int64(UInt32.max), + expectedFrameCount == UInt32(expectedCount) else { + throw VaultCryptoError.invalidFrame + } + let input = try FileHandle(forReadingFrom: ciphertextURL) + try prepareOutputFile(at: plaintextURL) + let output = try FileHandle(forWritingTo: plaintextURL) + var completed = false + + defer { + try? input.close() + try? output.close() + if completed == false { + try? FileManager.default.removeItem(at: plaintextURL) + } + } + + let header = try readExactly(headerByteCount, from: input) + var cursor = VaultDataCursor(data: header) + guard try cursor.read(count: 4) == magic else { + throw VaultCryptoError.invalidEnvelopeMagic + } + let version = try cursor.readUInt16() + guard version == VaultFormat.currentVersion else { + throw VaultCryptoError.unsupportedFormatVersion(version) + } + guard try cursor.readUInt32() == UInt32(VaultFormat.contentFrameSize) else { + throw VaultCryptoError.invalidFrame + } + let noncePrefix = try cursor.readUInt64() + guard noncePrefix == expectedNoncePrefix else { + throw VaultCryptoError.authenticationFailed + } + + var hasher = SHA256() + var plaintextLength: Int64 = 0 + var frameIndex: UInt32 = 0 + + while frameIndex < expectedFrameCount { + try Task.checkCancellation() + let frameLengthData = try readExactly(4, from: input) + var frameLengthCursor = VaultDataCursor(data: frameLengthData) + let frameLength = Int(try frameLengthCursor.readUInt32()) + guard frameLength >= authenticationTagByteCount, + frameLength <= VaultFormat.contentFrameSize + authenticationTagByteCount else { + throw VaultCryptoError.invalidFrame + } + let frame = try readExactly(frameLength, from: input) + let ciphertext = frame.dropLast(authenticationTagByteCount) + let tag = frame.suffix(authenticationTagByteCount) + let isFinal = frameIndex == expectedFrameCount - 1 + let associatedData = try frameAssociatedData( + context: context, + frameIndex: frameIndex, + paddedLength: ciphertext.count, + isFinal: isFinal + ) + let nonce = try frameNonce(prefix: noncePrefix, index: frameIndex) + let sealed = try AES.GCM.SealedBox(nonce: nonce, ciphertext: ciphertext, tag: tag) + let padded: Data + do { + padded = try AES.GCM.open( + sealed, + using: contentKey.symmetricKey, + authenticating: associatedData + ) + } catch { + throw VaultCryptoError.authenticationFailed + } + + let remaining = expectedPlaintextLength - plaintextLength + guard remaining >= 0 else { + throw VaultCryptoError.contentLengthMismatch + } + let unpaddedLength = isFinal + ? min(Int64(padded.count), remaining) + : Int64(padded.count) + guard isFinal || padded.count == VaultFormat.contentFrameSize, + unpaddedLength >= 0, + unpaddedLength <= Int64(padded.count) else { + throw VaultCryptoError.invalidFrame + } + if isFinal { + let expectedPaddedLength = paddedFinalFrameSize( + for: Int(unpaddedLength) + ) + guard padded.count == expectedPaddedLength else { + throw VaultCryptoError.invalidFrame + } + } + let plaintext = padded.prefix(Int(unpaddedLength)) + hasher.update(data: plaintext) + try output.write(contentsOf: plaintext) + plaintextLength += unpaddedLength + frameIndex += 1 + } + + let trailing = try input.read(upToCount: 1) ?? Data() + guard trailing.isEmpty else { + throw VaultCryptoError.invalidFrame + } + guard plaintextLength == expectedPlaintextLength else { + throw VaultCryptoError.contentLengthMismatch + } + guard Data(hasher.finalize()) == expectedPlaintextDigest else { + throw VaultCryptoError.contentDigestMismatch + } + + try output.synchronize() + completed = true + } + + public static func paddedFinalFrameSize(for plaintextByteCount: Int) -> Int { + let required = max(plaintextByteCount, VaultFormat.minimumFinalFrameSize) + var bucket = VaultFormat.minimumFinalFrameSize + while bucket < required, bucket < VaultFormat.contentFrameSize { + bucket *= 2 + } + return min(bucket, VaultFormat.contentFrameSize) + } + + private static func paddedFinalFrame(_ plaintext: Data) throws -> Data { + guard plaintext.count <= VaultFormat.contentFrameSize else { + throw VaultCryptoError.invalidFrame + } + let targetSize = paddedFinalFrameSize(for: plaintext.count) + var result = plaintext + if targetSize > plaintext.count { + result.append(try VaultRandom.bytes(count: targetSize - plaintext.count)) + } + return result + } + + private static func frameNonce(prefix: UInt64, index: UInt32) throws -> AES.GCM.Nonce { + var data = Data() + data.appendUInt64(prefix) + data.appendUInt32(index) + return try AES.GCM.Nonce(data: data) + } + + private static func frameAssociatedData( + context: VaultContentEncryptionContext, + frameIndex: UInt32, + paddedLength: Int, + isFinal: Bool + ) throws -> Data { + guard let token = Data(base64URLEncoded: context.objectToken), token.count == 20 else { + throw VaultCryptoError.invalidObjectToken + } + var data = magic + data.appendUInt16(VaultFormat.currentVersion) + data.appendUInt32(context.keyEpoch) + data.append(context.vaultID.rawValue.data) + data.append(context.itemID.rawValue.data) + data.append(context.contentRevision.data) + data.append(token) + data.appendUInt32(frameIndex) + data.appendUInt32(UInt32(paddedLength)) + data.append(isFinal ? 1 : 0) + return data + } + + private static func prepareOutputFile(at url: URL) throws { + let manager = FileManager.default + try manager.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + if manager.fileExists(atPath: url.path) { + try manager.removeItem(at: url) + } + guard manager.createFile(atPath: url.path, contents: nil) else { + throw CocoaError(.fileWriteUnknown) + } + #if canImport(Darwin) + try manager.setAttributes( + [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], + ofItemAtPath: url.path + ) + #endif + } + + private static func readExactly(_ count: Int, from handle: FileHandle) throws -> Data { + guard count >= 0 else { + throw VaultCryptoError.invalidLength + } + var result = Data() + while result.count < count { + let chunk = try handle.read(upToCount: count - result.count) ?? Data() + guard chunk.isEmpty == false else { + throw VaultCryptoError.invalidFrame + } + result.append(chunk) + } + return result + } +} diff --git a/PotassiumProviderCore/VaultCryptography.swift b/PotassiumProviderCore/VaultCryptography.swift new file mode 100644 index 0000000..3dfa82f --- /dev/null +++ b/PotassiumProviderCore/VaultCryptography.swift @@ -0,0 +1,414 @@ +import CryptoKit +import Foundation +import Security + +public enum VaultCryptoError: Error, Equatable, LocalizedError, Sendable { + case invalidKeyLength + case invalidLength + case invalidObjectToken + case invalidEnvelope + case invalidEnvelopeMagic + case unsupportedFormatVersion(UInt16) + case unexpectedObjectRole + case unexpectedVault + case authenticationFailed + case randomGenerationFailed(OSStatus) + case frameLimitExceeded + case invalidFrame + case contentLengthMismatch + case contentDigestMismatch + case recoveryKitInvalid + case recoveryKitChecksumMismatch + + public var errorDescription: String? { + switch self { + case .invalidKeyLength: + return "The vault key has an invalid length." + case .invalidLength: + return "The encrypted value has an invalid length." + case .invalidObjectToken: + return "The encrypted object token is invalid." + case .invalidEnvelope: + return "The encrypted vault envelope is malformed." + case .invalidEnvelopeMagic: + return "The encrypted vault envelope has an invalid format marker." + case .unsupportedFormatVersion(let version): + return "Vault format version \(version) is not supported." + case .unexpectedObjectRole: + return "The encrypted object has an unexpected role." + case .unexpectedVault: + return "The encrypted object belongs to a different vault." + case .authenticationFailed: + return "The encrypted object failed authentication." + case .randomGenerationFailed(let status): + return "Secure random generation failed with status \(status)." + case .frameLimitExceeded: + return "The file is too large for the vault content format." + case .invalidFrame: + return "The encrypted content contains an invalid frame." + case .contentLengthMismatch: + return "The decrypted content length does not match its authenticated metadata." + case .contentDigestMismatch: + return "The decrypted content digest does not match its authenticated metadata." + case .recoveryKitInvalid: + return "The recovery kit is malformed." + case .recoveryKitChecksumMismatch: + return "The recovery kit checksum is invalid." + } + } +} + +public enum VaultCryptography { + private static let envelopeMagic = Data("KPE1".utf8) + private static let envelopeHeaderByteCount = 4 + 2 + 1 + 4 + 16 + 20 + + public static func makeRootKey() throws -> VaultKeyMaterial { + try VaultKeyMaterial.random() + } + + public static func makeObjectToken() throws -> String { + try VaultRandom.bytes(count: 20).vaultBase64URLEncodedString() + } + + public static func makeObjectToken( + rootKey: VaultKeyMaterial, + vaultID: VaultIdentifier + ) throws -> String { + let namingKey = deriveKey( + rootKey: rootKey, + vaultID: vaultID, + label: "object-name" + ) + let randomInput = try VaultRandom.bytes(count: 32) + let digest = HMAC.authenticationCode( + for: randomInput, + using: namingKey.symmetricKey + ) + return Data(digest.prefix(20)).vaultBase64URLEncodedString() + } + + public static func deriveKey( + rootKey: VaultKeyMaterial, + vaultID: VaultIdentifier, + label: String, + salt: Data = Data(), + outputByteCount: Int = VaultKeyMaterial.byteCount + ) -> VaultKeyMaterial { + let contextSalt = vaultID.rawValue.data + salt + let key = HKDF.deriveKey( + inputKeyMaterial: rootKey.symmetricKey, + salt: contextSalt, + info: Data("net.weavee.potassiumProvider.vault.\(label)".utf8), + outputByteCount: outputByteCount + ) + let data = key.withUnsafeBytes { Data($0) } + return VaultKeyMaterial(data: data)! + } + + public static func seal( + _ value: Value, + role: VaultObjectRole, + objectToken: String, + rootKey: VaultKeyMaterial, + vaultID: VaultIdentifier, + keyEpoch: UInt32 = VaultFormat.currentKeyEpoch + ) throws -> Data { + let plaintext = try VaultCoding.encoder.encode(value) + return try seal( + plaintext, + role: role, + objectToken: objectToken, + rootKey: rootKey, + vaultID: vaultID, + keyEpoch: keyEpoch + ) + } + + public static func seal( + _ plaintext: Data, + role: VaultObjectRole, + objectToken: String, + rootKey: VaultKeyMaterial, + vaultID: VaultIdentifier, + keyEpoch: UInt32 = VaultFormat.currentKeyEpoch + ) throws -> Data { + let token = try objectTokenData(objectToken) + let header = envelopeHeader( + role: role, + keyEpoch: keyEpoch, + vaultID: vaultID, + objectToken: token + ) + let objectKey = deriveKey( + rootKey: rootKey, + vaultID: vaultID, + label: "object.\(role.rawValue).epoch.\(keyEpoch)", + salt: token + ) + do { + let sealed = try AES.GCM.seal( + plaintext, + using: objectKey.symmetricKey, + authenticating: header + ) + guard let combined = sealed.combined else { + throw VaultCryptoError.invalidEnvelope + } + return header + combined + } catch let error as VaultCryptoError { + throw error + } catch { + throw VaultCryptoError.authenticationFailed + } + } + + public static func open( + _ type: Value.Type, + envelope: Data, + expectedRole: VaultObjectRole, + expectedObjectToken: String, + rootKey: VaultKeyMaterial, + vaultID: VaultIdentifier, + keyEpoch: UInt32 = VaultFormat.currentKeyEpoch + ) throws -> Value { + let plaintext = try open( + envelope, + expectedRole: expectedRole, + expectedObjectToken: expectedObjectToken, + rootKey: rootKey, + vaultID: vaultID, + keyEpoch: keyEpoch + ) + return try VaultCoding.decoder.decode(type, from: plaintext) + } + + public static func open( + _ envelope: Data, + expectedRole: VaultObjectRole, + expectedObjectToken: String, + rootKey: VaultKeyMaterial, + vaultID: VaultIdentifier, + keyEpoch: UInt32 = VaultFormat.currentKeyEpoch + ) throws -> Data { + guard envelope.count > envelopeHeaderByteCount else { + throw VaultCryptoError.invalidEnvelope + } + var cursor = VaultDataCursor(data: envelope) + guard try cursor.read(count: 4) == envelopeMagic else { + throw VaultCryptoError.invalidEnvelopeMagic + } + let version = try cursor.readUInt16() + guard version == VaultFormat.currentVersion else { + throw VaultCryptoError.unsupportedFormatVersion(version) + } + guard let role = VaultObjectRole(rawValue: try cursor.readUInt8()), + role == expectedRole else { + throw VaultCryptoError.unexpectedObjectRole + } + let envelopeEpoch = try cursor.readUInt32() + guard envelopeEpoch == keyEpoch else { + throw VaultCryptoError.authenticationFailed + } + let envelopeVaultID = VaultIdentifier(rawValue: UUID(bytes: try cursor.read(count: 16))) + guard envelopeVaultID == vaultID else { + throw VaultCryptoError.unexpectedVault + } + let token = try cursor.read(count: 20) + guard token == (try objectTokenData(expectedObjectToken)) else { + throw VaultCryptoError.invalidObjectToken + } + let header = envelope.prefix(envelopeHeaderByteCount) + let objectKey = deriveKey( + rootKey: rootKey, + vaultID: vaultID, + label: "object.\(role.rawValue).epoch.\(envelopeEpoch)", + salt: token + ) + + do { + let sealedBox = try AES.GCM.SealedBox(combined: envelope.dropFirst(envelopeHeaderByteCount)) + return try AES.GCM.open( + sealedBox, + using: objectKey.symmetricKey, + authenticating: header + ) + } catch { + throw VaultCryptoError.authenticationFailed + } + } + + public static func wrapContentKey( + _ contentKey: VaultKeyMaterial, + objectToken: String, + rootKey: VaultKeyMaterial, + vaultID: VaultIdentifier, + keyEpoch: UInt32 = VaultFormat.currentKeyEpoch + ) throws -> Data { + let token = try objectTokenData(objectToken) + let wrappingKey = deriveKey( + rootKey: rootKey, + vaultID: vaultID, + label: "content-wrap.epoch.\(keyEpoch)", + salt: token + ) + let associatedData = contentKeyAssociatedData( + vaultID: vaultID, + keyEpoch: keyEpoch, + objectToken: token + ) + let sealed = try AES.GCM.seal( + contentKey.data, + using: wrappingKey.symmetricKey, + authenticating: associatedData + ) + guard let combined = sealed.combined else { + throw VaultCryptoError.invalidEnvelope + } + return combined + } + + public static func unwrapContentKey( + _ wrappedKey: Data, + objectToken: String, + rootKey: VaultKeyMaterial, + vaultID: VaultIdentifier, + keyEpoch: UInt32 = VaultFormat.currentKeyEpoch + ) throws -> VaultKeyMaterial { + let token = try objectTokenData(objectToken) + let wrappingKey = deriveKey( + rootKey: rootKey, + vaultID: vaultID, + label: "content-wrap.epoch.\(keyEpoch)", + salt: token + ) + let associatedData = contentKeyAssociatedData( + vaultID: vaultID, + keyEpoch: keyEpoch, + objectToken: token + ) + do { + let sealed = try AES.GCM.SealedBox(combined: wrappedKey) + let data = try AES.GCM.open( + sealed, + using: wrappingKey.symmetricKey, + authenticating: associatedData + ) + guard let key = VaultKeyMaterial(data: data) else { + throw VaultCryptoError.invalidKeyLength + } + return key + } catch let error as VaultCryptoError { + throw error + } catch { + throw VaultCryptoError.authenticationFailed + } + } + + public static func revision(for value: Value) throws -> VaultRevision { + VaultRevision(hashing: try VaultCoding.encoder.encode(value)) + } + + private static func objectTokenData(_ value: String) throws -> Data { + guard let token = Data(base64URLEncoded: value), token.count == 20 else { + throw VaultCryptoError.invalidObjectToken + } + return token + } + + private static func envelopeHeader( + role: VaultObjectRole, + keyEpoch: UInt32, + vaultID: VaultIdentifier, + objectToken: Data + ) -> Data { + var result = Data() + result.append(envelopeMagic) + result.appendUInt16(VaultFormat.currentVersion) + result.append(role.rawValue) + result.appendUInt32(keyEpoch) + result.append(vaultID.rawValue.data) + result.append(objectToken) + return result + } + + private static func contentKeyAssociatedData( + vaultID: VaultIdentifier, + keyEpoch: UInt32, + objectToken: Data + ) -> Data { + var data = Data("KPW1".utf8) + data.appendUInt16(VaultFormat.currentVersion) + data.appendUInt32(keyEpoch) + data.append(vaultID.rawValue.data) + data.append(objectToken) + return data + } +} + +enum VaultCoding { + static var encoder: JSONEncoder { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + encoder.dateEncodingStrategy = .millisecondsSince1970 + return encoder + } + + static var decoder: JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .millisecondsSince1970 + return decoder + } +} + +struct VaultDataCursor { + let data: Data + private(set) var offset = 0 + + mutating func read(count: Int) throws -> Data { + guard count >= 0, offset <= data.count - count else { + throw VaultCryptoError.invalidLength + } + let result = data.subdata(in: offset..<(offset + count)) + offset += count + return result + } + + mutating func readUInt8() throws -> UInt8 { + try read(count: 1)[0] + } + + mutating func readUInt16() throws -> UInt16 { + try read(count: 2).withUnsafeBytes { + UInt16(bigEndian: $0.loadUnaligned(as: UInt16.self)) + } + } + + mutating func readUInt32() throws -> UInt32 { + try read(count: 4).withUnsafeBytes { + UInt32(bigEndian: $0.loadUnaligned(as: UInt32.self)) + } + } + + mutating func readUInt64() throws -> UInt64 { + try read(count: 8).withUnsafeBytes { + UInt64(bigEndian: $0.loadUnaligned(as: UInt64.self)) + } + } +} + +extension Data { + mutating func appendUInt16(_ value: UInt16) { + var bigEndian = value.bigEndian + Swift.withUnsafeBytes(of: &bigEndian) { append(contentsOf: $0) } + } + + mutating func appendUInt32(_ value: UInt32) { + var bigEndian = value.bigEndian + Swift.withUnsafeBytes(of: &bigEndian) { append(contentsOf: $0) } + } + + mutating func appendUInt64(_ value: UInt64) { + var bigEndian = value.bigEndian + Swift.withUnsafeBytes(of: &bigEndian) { append(contentsOf: $0) } + } +} diff --git a/PotassiumProviderCore/VaultJournal.swift b/PotassiumProviderCore/VaultJournal.swift new file mode 100644 index 0000000..b7b2366 --- /dev/null +++ b/PotassiumProviderCore/VaultJournal.swift @@ -0,0 +1,795 @@ +import CryptoKit +import Foundation + +public enum VaultJournalError: Error, Equatable, LocalizedError, Sendable { + case duplicateTransaction(UUID) + case missingParent(transactionID: UUID, parentID: UUID) + case cyclicGraph + case invalidBaseRevision(VaultItemIdentifier) + case malformedFixedTransaction + case transactionTooLarge(Int) + case rollbackDetected + case invalidMerkleProof + + public var errorDescription: String? { + switch self { + case .duplicateTransaction: + return "The encrypted journal contains a duplicate transaction." + case .missingParent: + return "The encrypted journal is missing causal ancestry." + case .cyclicGraph: + return "The encrypted journal contains a causal cycle." + case .invalidBaseRevision: + return "A mutation does not match its declared base revision." + case .malformedFixedTransaction: + return "The fixed-size transaction object is malformed." + case .transactionTooLarge: + return "The encrypted transaction exceeds the 64 KiB format limit." + case .rollbackDetected: + return "The remote vault state does not include this device's last trusted state." + case .invalidMerkleProof: + return "The checkpoint did not provide a valid transaction inclusion proof." + } + } +} + +public struct VaultConflict: Codable, Equatable, Identifiable, Sendable { + public enum Kind: String, Codable, Sendable { + case content + case metadata + case siblingName + case deletionRejected + case folderDeletionRejected + } + + public let id: UUID + public let kind: Kind + public let itemID: VaultItemIdentifier + public let transactionID: UUID + public let conflictCopyID: VaultItemIdentifier? + + public init( + kind: Kind, + itemID: VaultItemIdentifier, + transactionID: UUID, + conflictCopyID: VaultItemIdentifier? = nil + ) { + self.id = Self.stableUUID( + components: [ + Data(kind.rawValue.utf8), + itemID.rawValue.data, + transactionID.data, + ] + ) + self.kind = kind + self.itemID = itemID + self.transactionID = transactionID + self.conflictCopyID = conflictCopyID + } + + private static func stableUUID(components: [Data]) -> UUID { + var hasher = SHA256() + for component in components { + hasher.update(data: component) + } + var bytes = Array(Data(hasher.finalize()).prefix(16)) + bytes[6] = (bytes[6] & 0x0F) | 0x50 + bytes[8] = (bytes[8] & 0x3F) | 0x80 + return UUID(bytes: Data(bytes)) + } +} + +public struct VaultReducedState: Equatable, Sendable { + public let items: [VaultItemIdentifier: VaultItem] + public let frontier: VaultFrontier + public let appliedTransactionIDs: Set + public let conflicts: [VaultConflict] + + public init( + items: [VaultItemIdentifier: VaultItem] = [:], + frontier: VaultFrontier = VaultFrontier(), + appliedTransactionIDs: Set = [], + conflicts: [VaultConflict] = [] + ) { + self.items = items + self.frontier = frontier + self.appliedTransactionIDs = appliedTransactionIDs + self.conflicts = conflicts + } +} + +/// Canonical transaction replay. Every client first topologically sorts by +/// causal ancestry and then by transaction UUID. No server timestamp is used. +public enum VaultJournalReducer { + public static func reduce( + _ transactions: [VaultTransaction], + checkpoint: VaultCheckpoint? = nil + ) throws -> VaultReducedState { + let ordered = try canonicalOrder( + transactions, + knownAncestorIDs: checkpoint?.frontier.transactionIDs ?? [] + ) + var items = Dictionary(uniqueKeysWithValues: (checkpoint?.items ?? []).map { ($0.id, $0) }) + var frontier = checkpoint?.frontier ?? VaultFrontier() + var applied = Set() + var conflicts: [VaultConflict] = [] + let forcedDirectoryDeletionRejections = directoryDeletionRejections( + in: ordered + ) + + for transaction in ordered { + frontier.replaceParents( + transaction.parents.transactionIDs, + with: transaction.id + ) + applied.insert(transaction.id) + } + for transaction in ordered { + try apply( + transaction, + forceDirectoryDeletionRejection: + forcedDirectoryDeletionRejections.contains(transaction.id), + items: &items, + conflicts: &conflicts + ) + } + + try resolveSiblingNameCollisions(items: &items, conflicts: &conflicts) + return VaultReducedState( + items: items, + frontier: frontier, + appliedTransactionIDs: applied, + conflicts: conflicts.sorted { $0.id.uuidString < $1.id.uuidString } + ) + } + + private static func directoryDeletionRejections( + in transactions: [VaultTransaction] + ) -> Set { + let byID = Dictionary(uniqueKeysWithValues: transactions.map { + ($0.id, $0) + }) + var rejected: Set = [] + for deletion in transactions { + guard let directoryID = directoryDeletionTarget(deletion) else { + continue + } + let hasNewChild = transactions.contains { candidate in + guard candidate.id != deletion.id, + case .upsert(let item) = candidate.operation, + item.parentID == directoryID, + item.isTrashed == false else { + return false + } + // Children already in the deletion's causal history are normal + // subtree members. A concurrent or later child preserves the + // folder and rejects the stale deletion. + return isAncestor(candidate.id, of: deletion.id, byID: byID) == false + } + if hasNewChild { + rejected.insert(deletion.id) + } + } + return rejected + } + + private static func directoryDeletionTarget( + _ transaction: VaultTransaction + ) -> VaultItemIdentifier? { + guard transaction.baseItem?.isDirectory == true else { return nil } + switch transaction.operation { + case .trash(let itemID, _, _), .purge(let itemID, _, _): + return itemID + default: + return nil + } + } + + private static func isAncestor( + _ possibleAncestor: UUID, + of transactionID: UUID, + byID: [UUID: VaultTransaction] + ) -> Bool { + var pending = Array(byID[transactionID]?.parents.transactionIDs ?? []) + var visited: Set = [] + while let candidate = pending.popLast() { + if candidate == possibleAncestor { return true } + guard visited.insert(candidate).inserted, + let transaction = byID[candidate] else { + continue + } + pending.append(contentsOf: transaction.parents.transactionIDs) + } + return false + } + + public static func canonicalOrder( + _ transactions: [VaultTransaction], + knownAncestorIDs: Set = [] + ) throws -> [VaultTransaction] { + var byID: [UUID: VaultTransaction] = [:] + for transaction in transactions { + guard byID.updateValue(transaction, forKey: transaction.id) == nil else { + throw VaultJournalError.duplicateTransaction(transaction.id) + } + } + + let transactionIDs = Set(byID.keys) + for transaction in transactions { + for parentID in transaction.parents.transactionIDs + where transactionIDs.contains(parentID) == false && knownAncestorIDs.contains(parentID) == false { + throw VaultJournalError.missingParent( + transactionID: transaction.id, + parentID: parentID + ) + } + } + + var remainingParents = Dictionary(uniqueKeysWithValues: transactions.map { + ($0.id, $0.parents.transactionIDs.subtracting(knownAncestorIDs)) + }) + var ready = remainingParents + .filter { $0.value.isEmpty } + .map(\.key) + .sorted(by: uuidLessThan) + var ordered: [VaultTransaction] = [] + + while let next = ready.first { + ready.removeFirst() + guard let transaction = byID[next] else { continue } + ordered.append(transaction) + remainingParents.removeValue(forKey: next) + for transactionID in remainingParents.keys.sorted(by: uuidLessThan) { + guard remainingParents[transactionID]?.remove(next) != nil, + remainingParents[transactionID]?.isEmpty == true else { + continue + } + ready.append(transactionID) + ready.sort(by: uuidLessThan) + } + } + + guard ordered.count == transactions.count else { + throw VaultJournalError.cyclicGraph + } + return ordered + } + + private static func apply( + _ transaction: VaultTransaction, + forceDirectoryDeletionRejection: Bool, + items: inout [VaultItemIdentifier: VaultItem], + conflicts: inout [VaultConflict] + ) throws { + if forceDirectoryDeletionRejection, + let itemID = directoryDeletionTarget(transaction) { + conflicts.append(VaultConflict( + kind: .folderDeletionRejected, + itemID: itemID, + transactionID: transaction.id + )) + return + } + switch transaction.operation { + case .upsert(let desired): + try applyUpsert( + desired, + base: transaction.baseItem, + transaction: transaction, + items: &items, + conflicts: &conflicts + ) + case .trash(let itemID, let baseContentRevision, let baseMetadataRevision): + guard var current = items[itemID] else { return } + guard current.contentRevision == baseContentRevision, + current.metadataRevision == baseMetadataRevision else { + conflicts.append(VaultConflict( + kind: current.isDirectory ? .folderDeletionRejected : .deletionRejected, + itemID: itemID, + transactionID: transaction.id + )) + return + } + current.isTrashed = true + current.metadataRevision = try metadataRevision(for: current) + items[itemID] = current + if current.isDirectory { + try setDescendants( + of: itemID, + isTrashed: true, + items: &items + ) + } + case .restore(let itemID, let parentID): + guard var current = items[itemID] else { return } + current.parentID = parentID + current.isTrashed = false + current.metadataRevision = try metadataRevision(for: current) + items[itemID] = current + if current.isDirectory { + try setDescendants( + of: itemID, + isTrashed: false, + items: &items + ) + } + case .purge(let itemID, let baseContentRevision, let baseMetadataRevision): + guard let current = items[itemID] else { return } + guard current.contentRevision == baseContentRevision, + current.metadataRevision == baseMetadataRevision else { + conflicts.append(VaultConflict( + kind: current.isDirectory ? .folderDeletionRejected : .deletionRejected, + itemID: itemID, + transactionID: transaction.id + )) + return + } + if current.isDirectory, + items.values.contains(where: { $0.parentID == itemID && $0.isTrashed == false }) { + conflicts.append(VaultConflict( + kind: .folderDeletionRejected, + itemID: itemID, + transactionID: transaction.id + )) + return + } + if current.isDirectory { + removeDescendants(of: itemID, items: &items) + } + items.removeValue(forKey: itemID) + } + } + + private static func setDescendants( + of parentID: VaultItemIdentifier, + isTrashed: Bool, + items: inout [VaultItemIdentifier: VaultItem] + ) throws { + let childIDs = items.values + .filter { $0.parentID == parentID } + .map(\.id) + for childID in childIDs { + guard var child = items[childID] else { continue } + child.isTrashed = isTrashed + child.metadataRevision = try metadataRevision(for: child) + items[childID] = child + if child.isDirectory { + try setDescendants( + of: childID, + isTrashed: isTrashed, + items: &items + ) + } + } + } + + private static func removeDescendants( + of parentID: VaultItemIdentifier, + items: inout [VaultItemIdentifier: VaultItem] + ) { + let childIDs = items.values + .filter { $0.parentID == parentID } + .map(\.id) + for childID in childIDs { + removeDescendants(of: childID, items: &items) + items.removeValue(forKey: childID) + } + } + + private static func applyUpsert( + _ desired: VaultItem, + base: VaultItem?, + transaction: VaultTransaction, + items: inout [VaultItemIdentifier: VaultItem], + conflicts: inout [VaultConflict] + ) throws { + guard let current = items[desired.id] else { + if base != nil { + // Delete versus edit preserves the edit. + conflicts.append(VaultConflict( + kind: .deletionRejected, + itemID: desired.id, + transactionID: transaction.id + )) + } + items[desired.id] = desired + return + } + + guard let base else { + // A base-less upsert is valid only for an idempotent create. + if current == desired { return } + throw VaultJournalError.invalidBaseRevision(desired.id) + } + + let currentContentChanged = current.contentRevision != base.contentRevision + let desiredContentChanged = desired.contentRevision != base.contentRevision + let currentMetadataChanged = current.metadataRevision != base.metadataRevision + let desiredMetadataChanged = desired.metadataRevision != base.metadataRevision + + if currentContentChanged && desiredContentChanged && + current.contentRevision != desired.contentRevision { + let conflictID = stableConflictCopyID( + originalItemID: desired.id, + transactionID: transaction.id + ) + var conflictCopy = desired + conflictCopy = VaultItem( + id: conflictID, + parentID: desired.parentID, + filename: conflictFilename(desired.filename, transactionID: transaction.id), + isDirectory: desired.isDirectory, + contentTypeIdentifier: desired.contentTypeIdentifier, + createdAt: desired.createdAt, + modifiedAt: desired.modifiedAt, + plaintextSize: desired.plaintextSize, + isFavorite: desired.isFavorite, + isTrashed: desired.isTrashed, + contentRevision: desired.contentRevision, + metadataRevision: desired.metadataRevision, + contentReference: desired.contentReference, + versions: desired.versions + ) + conflictCopy.metadataRevision = try metadataRevision(for: conflictCopy) + items[conflictID] = conflictCopy + conflicts.append(VaultConflict( + kind: .content, + itemID: desired.id, + transactionID: transaction.id, + conflictCopyID: conflictID + )) + return + } + + var merged = current + if desiredContentChanged { + merged.contentRevision = desired.contentRevision + merged.contentReference = desired.contentReference + merged.plaintextSize = desired.plaintextSize + merged.modifiedAt = desired.modifiedAt + merged.versions = desired.versions + } + if desiredMetadataChanged { + if currentMetadataChanged && metadataFields(of: current) != metadataFields(of: desired) { + conflicts.append(VaultConflict( + kind: .metadata, + itemID: desired.id, + transactionID: transaction.id + )) + } + // Canonical replay means the later canonical transaction wins + // conflicting metadata fields while independent content survives. + merged.parentID = desired.parentID + merged.filename = desired.filename + merged.contentTypeIdentifier = desired.contentTypeIdentifier + merged.createdAt = desired.createdAt + merged.isFavorite = desired.isFavorite + merged.isTrashed = desired.isTrashed + merged.metadataRevision = desired.metadataRevision + } + items[desired.id] = merged + } + + private static func resolveSiblingNameCollisions( + items: inout [VaultItemIdentifier: VaultItem], + conflicts: inout [VaultConflict] + ) throws { + let visibleItems = items.values.filter { $0.isTrashed == false } + let groups = Dictionary(grouping: visibleItems) { + SiblingKey(parentID: $0.parentID, normalizedName: $0.filename.precomposedStringWithCanonicalMapping.lowercased()) + } + + for group in groups.values where group.count > 1 { + let ordered = group.sorted { $0.id.rawValue.uuidString < $1.id.rawValue.uuidString } + for item in ordered.dropFirst() { + guard var renamed = items[item.id] else { continue } + renamed.filename = conflictFilename( + renamed.filename, + transactionID: renamed.id.rawValue + ) + renamed.metadataRevision = try metadataRevision(for: renamed) + items[item.id] = renamed + conflicts.append(VaultConflict( + kind: .siblingName, + itemID: item.id, + transactionID: item.id.rawValue + )) + } + } + } + + private static func metadataFields(of item: VaultItem) -> MetadataFields { + MetadataFields( + parentID: item.parentID, + filename: item.filename, + isDirectory: item.isDirectory, + typeIdentifier: item.contentTypeIdentifier, + createdAt: item.createdAt, + favorite: item.isFavorite, + trashed: item.isTrashed + ) + } + + private static func metadataRevision(for item: VaultItem) throws -> VaultRevision { + try VaultRevisionDigests.metadata(for: item) + } + + private static func stableConflictCopyID( + originalItemID: VaultItemIdentifier, + transactionID: UUID + ) -> VaultItemIdentifier { + var hasher = SHA256() + hasher.update(data: Data("vault-conflict-copy".utf8)) + hasher.update(data: originalItemID.rawValue.data) + hasher.update(data: transactionID.data) + var bytes = Array(Data(hasher.finalize()).prefix(16)) + bytes[6] = (bytes[6] & 0x0F) | 0x50 + bytes[8] = (bytes[8] & 0x3F) | 0x80 + return VaultItemIdentifier(rawValue: UUID(bytes: Data(bytes))) + } + + private static func conflictFilename(_ filename: String, transactionID: UUID) -> String { + let suffix = String(transactionID.uuidString.prefix(8)).lowercased() + let pathExtension = (filename as NSString).pathExtension + let base = (filename as NSString).deletingPathExtension + if pathExtension.isEmpty { + return "\(base) (conflict \(suffix))" + } + return "\(base) (conflict \(suffix)).\(pathExtension)" + } + + private static func uuidLessThan(_ lhs: UUID, _ rhs: UUID) -> Bool { + lhs.uuidString < rhs.uuidString + } + + private struct MetadataFields: Codable, Equatable { + let parentID: VaultItemIdentifier? + let filename: String + let isDirectory: Bool + let typeIdentifier: String? + let createdAt: Date + let favorite: Bool + let trashed: Bool + } + + private struct SiblingKey: Hashable { + let parentID: VaultItemIdentifier? + let normalizedName: String + } +} + +public enum VaultFixedTransactionCodec { + private static let lengthByteCount = MemoryLayout.size + private static let envelopeOverhead = 4 + 2 + 1 + 4 + 16 + 20 + 12 + 16 + private static let payloadByteCount = VaultFormat.transactionObjectSize - envelopeOverhead + + public static func seal( + _ transaction: VaultTransaction, + objectToken: String, + rootKey: VaultKeyMaterial, + vaultID: VaultIdentifier, + keyEpoch: UInt32 = VaultFormat.currentKeyEpoch + ) throws -> Data { + let encoded = try VaultCoding.encoder.encode(transaction) + let paddingCount = payloadByteCount - lengthByteCount - encoded.count + guard paddingCount >= 0 else { + throw VaultJournalError.transactionTooLarge(encoded.count) + } + var payload = Data() + payload.appendUInt32(UInt32(encoded.count)) + payload.append(encoded) + payload.append(try VaultRandom.bytes(count: paddingCount)) + let envelope = try VaultCryptography.seal( + payload, + role: .transaction, + objectToken: objectToken, + rootKey: rootKey, + vaultID: vaultID, + keyEpoch: keyEpoch + ) + guard envelope.count == VaultFormat.transactionObjectSize else { + throw VaultJournalError.malformedFixedTransaction + } + return envelope + } + + public static func open( + _ envelope: Data, + objectToken: String, + rootKey: VaultKeyMaterial, + vaultID: VaultIdentifier, + keyEpoch: UInt32 = VaultFormat.currentKeyEpoch + ) throws -> VaultTransaction { + guard envelope.count == VaultFormat.transactionObjectSize else { + throw VaultJournalError.malformedFixedTransaction + } + let payload = try VaultCryptography.open( + envelope, + expectedRole: .transaction, + expectedObjectToken: objectToken, + rootKey: rootKey, + vaultID: vaultID, + keyEpoch: keyEpoch + ) + var cursor = VaultDataCursor(data: payload) + let encodedLength = Int(try cursor.readUInt32()) + guard encodedLength > 0, + encodedLength <= payload.count - lengthByteCount else { + throw VaultJournalError.malformedFixedTransaction + } + let encoded = try cursor.read(count: encodedLength) + do { + return try VaultCoding.decoder.decode(VaultTransaction.self, from: encoded) + } catch { + throw VaultJournalError.malformedFixedTransaction + } + } +} + +public struct VaultMerkleProof: Codable, Equatable, Sendable { + public struct Step: Codable, Equatable, Sendable { + public let siblingDigest: Data + public let siblingIsLeft: Bool + + public init(siblingDigest: Data, siblingIsLeft: Bool) { + self.siblingDigest = siblingDigest + self.siblingIsLeft = siblingIsLeft + } + } + + public let transactionID: UUID + public let leafDigest: Data + public let steps: [Step] + + public init(transactionID: UUID, leafDigest: Data, steps: [Step]) { + self.transactionID = transactionID + self.leafDigest = leafDigest + self.steps = steps + } +} + +public enum VaultMerkleTree { + public static let emptyRoot = Data(SHA256.hash(data: Data("vault-empty-merkle-tree".utf8))) + + public static func root(for transactions: [VaultTransaction]) throws -> Data { + let ordered = try VaultJournalReducer.canonicalOrder(transactions) + return merkleRoot(ordered.map { + merkleLeaf( + transactionID: $0.id, + transactionDigest: transactionDigest($0) + ) + }) + } + + public static func proof( + for transactionID: UUID, + in transactions: [VaultTransaction] + ) throws -> VaultMerkleProof { + let ordered = try VaultJournalReducer.canonicalOrder(transactions) + guard var index = ordered.firstIndex(where: { $0.id == transactionID }) else { + throw VaultJournalError.invalidMerkleProof + } + let transaction = ordered[index] + let selectedTransactionDigest = transactionDigest(transaction) + var level = ordered.map { + merkleLeaf( + transactionID: $0.id, + transactionDigest: transactionDigest($0) + ) + } + var steps: [VaultMerkleProof.Step] = [] + + while level.count > 1 { + if level.count.isMultiple(of: 2) == false { + level.append(level.last!) + } + let siblingIndex = index.isMultiple(of: 2) ? index + 1 : index - 1 + steps.append(VaultMerkleProof.Step( + siblingDigest: level[siblingIndex], + siblingIsLeft: siblingIndex < index + )) + var next: [Data] = [] + for pairStart in stride(from: 0, to: level.count, by: 2) { + next.append(nodeDigest(left: level[pairStart], right: level[pairStart + 1])) + } + index /= 2 + level = next + } + + return VaultMerkleProof( + transactionID: transactionID, + leafDigest: selectedTransactionDigest, + steps: steps + ) + } + + public static func verify(_ proof: VaultMerkleProof, expectedRoot: Data) -> Bool { + guard expectedRoot.count == SHA256.Digest.byteCount, + proof.leafDigest.count == SHA256.Digest.byteCount, + proof.steps.allSatisfy({ + $0.siblingDigest.count == SHA256.Digest.byteCount + }) else { + return false + } + var digest = merkleLeaf( + transactionID: proof.transactionID, + transactionDigest: proof.leafDigest + ) + for step in proof.steps { + digest = step.siblingIsLeft + ? nodeDigest(left: step.siblingDigest, right: digest) + : nodeDigest(left: digest, right: step.siblingDigest) + } + return digest == expectedRoot + } + + private static func transactionDigest(_ transaction: VaultTransaction) -> Data { + Data(SHA256.hash(data: + (try? VaultCoding.encoder.encode(transaction)) ?? Data() + )) + } + + private static func merkleLeaf( + transactionID: UUID, + transactionDigest: Data + ) -> Data { + var data = Data([0]) + data.append(transactionID.data) + data.append(transactionDigest) + return Data(SHA256.hash(data: data)) + } + + private static func merkleRoot(_ leaves: [Data]) -> Data { + guard leaves.isEmpty == false else { return emptyRoot } + var level = leaves + while level.count > 1 { + if level.count.isMultiple(of: 2) == false { + level.append(level.last!) + } + var next: [Data] = [] + for index in stride(from: 0, to: level.count, by: 2) { + next.append(nodeDigest(left: level[index], right: level[index + 1])) + } + level = next + } + return level[0] + } + + private static func nodeDigest(left: Data, right: Data) -> Data { + var data = Data([1]) + data.append(left) + data.append(right) + return Data(SHA256.hash(data: data)) + } +} + +public enum VaultRollbackValidator { + public static func validate( + trustedState: VaultTrustedState?, + currentState: VaultReducedState, + checkpointRoot: Data? = nil, + inclusionProofs: [VaultMerkleProof] = [] + ) throws { + guard let trustedState else { + return + } + + let missing = trustedState.frontier.transactionIDs + .subtracting(currentState.appliedTransactionIDs) + guard missing.isEmpty == false else { + return + } + guard let checkpointRoot else { + throw VaultJournalError.rollbackDetected + } + let proofsByID = Dictionary(uniqueKeysWithValues: inclusionProofs.map { + ($0.transactionID, $0) + }) + for transactionID in missing { + guard let proof = proofsByID[transactionID], + VaultMerkleTree.verify(proof, expectedRoot: checkpointRoot) else { + throw VaultJournalError.rollbackDetected + } + } + } +} diff --git a/PotassiumProviderCore/VaultKeyStore.swift b/PotassiumProviderCore/VaultKeyStore.swift new file mode 100644 index 0000000..31e2b2c --- /dev/null +++ b/PotassiumProviderCore/VaultKeyStore.swift @@ -0,0 +1,183 @@ +import Foundation +import Security + +public protocol VaultKeyStoring: Sendable { + func loadRootKey(vaultID: VaultIdentifier) async throws -> VaultKeyMaterial? + func saveRootKey(_ key: VaultKeyMaterial, vaultID: VaultIdentifier) async throws + func deleteRootKey(vaultID: VaultIdentifier) async throws + func loadTrustedState(vaultID: VaultIdentifier) async throws -> VaultTrustedState? + func saveTrustedState(_ state: VaultTrustedState) async throws + func deleteTrustedState(vaultID: VaultIdentifier) async throws +} + +public protocol VaultDeviceIdentityStoring: Sendable { + func loadOrCreateDeviceID(vaultID: VaultIdentifier) async throws -> UUID +} + +public actor KeychainVaultKeyStore: VaultKeyStoring, VaultDeviceIdentityStoring { + private let service: String + private let accessGroup: String? + + public init( + service: String = ProviderConstants.vaultKeychainService, + accessGroup: String? = nil + ) { + self.service = service + self.accessGroup = accessGroup + } + + public func loadRootKey(vaultID: VaultIdentifier) throws -> VaultKeyMaterial? { + guard let data = try loadData(account: rootKeyAccount(vaultID)) else { return nil } + guard let key = VaultKeyMaterial(data: data) else { + throw VaultCryptoError.invalidKeyLength + } + return key + } + + public func saveRootKey(_ key: VaultKeyMaterial, vaultID: VaultIdentifier) throws { + try saveData(key.data, account: rootKeyAccount(vaultID)) + } + + public func deleteRootKey(vaultID: VaultIdentifier) throws { + try deleteData(account: rootKeyAccount(vaultID)) + } + + public func loadTrustedState(vaultID: VaultIdentifier) throws -> VaultTrustedState? { + guard let data = try loadData(account: trustedStateAccount(vaultID)) else { return nil } + return try VaultCoding.decoder.decode(VaultTrustedState.self, from: data) + } + + public func saveTrustedState(_ state: VaultTrustedState) throws { + try saveData( + try VaultCoding.encoder.encode(state), + account: trustedStateAccount(state.vaultID) + ) + } + + public func deleteTrustedState(vaultID: VaultIdentifier) throws { + try deleteData(account: trustedStateAccount(vaultID)) + } + + public func loadOrCreateDeviceID(vaultID: VaultIdentifier) throws -> UUID { + let account = deviceIDAccount(vaultID) + if let data = try loadData(account: account), + data.count == MemoryLayout.size { + return UUID(bytes: data) + } + let identifier = UUID() + try saveData(identifier.data, account: account) + return identifier + } + + private func loadData(account: String) throws -> Data? { + var query = baseQuery(account: account) + query[kSecMatchLimit as String] = kSecMatchLimitOne + query[kSecReturnData as String] = true + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { return nil } + guard status == errSecSuccess, let data = result as? Data else { + throw VaultKeyStoreError.unhandledStatus(status) + } + return data + } + + private func saveData(_ data: Data, account: String) throws { + let query = baseQuery(account: account) + let attributes: [String: Any] = [ + kSecValueData as String: data, + kSecAttrAccessible as String: + kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + ] + let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + if updateStatus == errSecSuccess { return } + guard updateStatus == errSecItemNotFound else { + throw VaultKeyStoreError.unhandledStatus(updateStatus) + } + + var addQuery = query + addQuery[kSecValueData as String] = data + addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + addQuery[kSecAttrSynchronizable as String] = false + let status = SecItemAdd(addQuery as CFDictionary, nil) + guard status == errSecSuccess else { + throw VaultKeyStoreError.unhandledStatus(status) + } + } + + private func deleteData(account: String) throws { + let status = SecItemDelete(baseQuery(account: account) as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw VaultKeyStoreError.unhandledStatus(status) + } + } + + private func baseQuery(account: String) -> [String: Any] { + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecAttrSynchronizable as String: false, + kSecUseDataProtectionKeychain as String: true, + ] + if let accessGroup { + query[kSecAttrAccessGroup as String] = accessGroup + } + return query + } + + private func rootKeyAccount(_ vaultID: VaultIdentifier) -> String { + "vaultRootKey:\(vaultID.rawValue.uuidString.lowercased())" + } + + private func trustedStateAccount(_ vaultID: VaultIdentifier) -> String { + "vaultTrustedState:\(vaultID.rawValue.uuidString.lowercased())" + } + + private func deviceIDAccount(_ vaultID: VaultIdentifier) -> String { + "vaultDeviceID:\(vaultID.rawValue.uuidString.lowercased())" + } +} + +public actor InMemoryVaultKeyStore: VaultKeyStoring { + private var keys: [VaultIdentifier: VaultKeyMaterial] = [:] + private var trustedStates: [VaultIdentifier: VaultTrustedState] = [:] + + public init() {} + + public func loadRootKey(vaultID: VaultIdentifier) -> VaultKeyMaterial? { + keys[vaultID] + } + + public func saveRootKey(_ key: VaultKeyMaterial, vaultID: VaultIdentifier) { + keys[vaultID] = key + } + + public func deleteRootKey(vaultID: VaultIdentifier) { + keys[vaultID] = nil + } + + public func loadTrustedState(vaultID: VaultIdentifier) -> VaultTrustedState? { + trustedStates[vaultID] + } + + public func saveTrustedState(_ state: VaultTrustedState) { + trustedStates[state.vaultID] = state + } + + public func deleteTrustedState(vaultID: VaultIdentifier) { + trustedStates[vaultID] = nil + } +} + +public enum VaultKeyStoreError: Error, Equatable, LocalizedError, Sendable { + case unhandledStatus(OSStatus) + + public var errorDescription: String? { + switch self { + case .unhandledStatus(let status): + return "Vault keychain operation failed with status \(status)." + } + } +} diff --git a/PotassiumProviderCore/VaultMaintenance.swift b/PotassiumProviderCore/VaultMaintenance.swift new file mode 100644 index 0000000..3b3e518 --- /dev/null +++ b/PotassiumProviderCore/VaultMaintenance.swift @@ -0,0 +1,196 @@ +import Foundation + +public struct VaultGarbageCollectionReport: Equatable, Sendable { + public let checkpointFileID: Int + public let examinedObjectCount: Int + public let deletedObjectCount: Int + + public init( + checkpointFileID: Int, + examinedObjectCount: Int, + deletedObjectCount: Int + ) { + self.checkpointFileID = checkpointFileID + self.examinedObjectCount = examinedObjectCount + self.deletedObjectCount = deletedObjectCount + } +} + +/// Conservative maintenance: an authenticated checkpoint is uploaded and +/// downloaded again before any unreferenced ciphertext can be deleted. +/// Journal objects remain immutable in v1 until Merkle-node proof retrieval has +/// passed independent review; this intentionally prefers quota use over an +/// unsafe compaction. +public actor VaultMaintenanceService { + private let vaultConfiguration: ProviderVaultConfiguration + private let rootKey: VaultKeyMaterial + private let objectStore: any KDriveObjectStoreProviding + private let localStore: any VaultLocalStateStoring + private let vault: any EncryptedVaultProviding + private let temporaryDirectoryURL: URL + + public init( + vaultConfiguration: ProviderVaultConfiguration, + rootKey: VaultKeyMaterial, + objectStore: any KDriveObjectStoreProviding, + localStore: any VaultLocalStateStoring, + vault: any EncryptedVaultProviding, + temporaryDirectoryURL: URL = FileManager.default.temporaryDirectory + ) throws { + guard vaultConfiguration.remoteLayout != nil else { + throw EncryptedVaultError.missingConfiguration + } + self.vaultConfiguration = vaultConfiguration + self.rootKey = rootKey + self.objectStore = objectStore + self.localStore = localStore + self.vault = vault + self.temporaryDirectoryURL = temporaryDirectoryURL + } + + public func checkpointAndCollectUnreferencedContent( + retentionInterval: TimeInterval = 30 * 24 * 60 * 60, + now: Date = Date() + ) async throws -> VaultGarbageCollectionReport { + guard let layout = vaultConfiguration.remoteLayout else { + throw EncryptedVaultError.missingConfiguration + } + let synchronizedFrontier = try await vault.synchronize() + let state = try await localStore.state() + guard state.frontier == synchronizedFrontier else { + throw VaultJournalError.rollbackDetected + } + let storedTransactions = try await localStore.journalObjects() + let transactions = try storedTransactions.map { + try VaultFixedTransactionCodec.open( + $0.envelope, + objectToken: $0.objectToken, + rootKey: rootKey, + vaultID: vaultConfiguration.vaultIdentifier, + keyEpoch: vaultConfiguration.keyEpoch + ) + } + let checkpoint = VaultCheckpoint( + frontier: state.frontier, + items: Array(state.items.values), + transactionMerkleRoot: try VaultMerkleTree.root(for: transactions), + createdAt: now + ) + let checkpointToken = try VaultCryptography.makeObjectToken( + rootKey: rootKey, + vaultID: vaultConfiguration.vaultIdentifier + ) + let envelope = try VaultCryptography.seal( + checkpoint, + role: .checkpoint, + objectToken: checkpointToken, + rootKey: rootKey, + vaultID: vaultConfiguration.vaultIdentifier, + keyEpoch: vaultConfiguration.keyEpoch + ) + let uploadURL = temporaryURL(prefix: "maintenance-checkpoint") + let verificationURL = temporaryURL(prefix: "maintenance-verification") + defer { + try? FileManager.default.removeItem(at: uploadURL) + try? FileManager.default.removeItem(at: verificationURL) + } + try envelope.write(to: uploadURL, options: [.atomic]) + let remoteCheckpoint = try await objectStore.uploadObject( + containerID: layout.checkpointContainerID, + token: checkpointToken, + fileURL: uploadURL + ) + try await objectStore.downloadObject( + fileID: remoteCheckpoint.id, + to: verificationURL + ) + let verified = try VaultCryptography.open( + VaultCheckpoint.self, + envelope: Data(contentsOf: verificationURL, options: .mappedIfSafe), + expectedRole: .checkpoint, + expectedObjectToken: checkpointToken, + rootKey: rootKey, + vaultID: vaultConfiguration.vaultIdentifier, + keyEpoch: vaultConfiguration.keyEpoch + ) + guard verified == checkpoint else { + throw VaultCryptoError.authenticationFailed + } + + let referencedTokens = Self.referencedContentTokens(in: verified.items) + let cutoff = now.addingTimeInterval(-max(0, retentionInterval)) + var candidatesByToken = Dictionary(uniqueKeysWithValues: + try await localStore.garbageCollectionCandidates().map { + ($0.objectToken, $0) + } + ) + var observedTokens: Set = [] + var cursor: String? + var examined = 0 + var deleted = 0 + repeat { + let page = try await objectStore.listObjects( + containerID: layout.contentContainerID, + cursor: cursor + ) + for object in page.objects where object.isContainer == false { + examined += 1 + observedTokens.insert(object.token) + guard referencedTokens.contains(object.token) == false else { + candidatesByToken[object.token] = nil + continue + } + if let candidate = candidatesByToken[object.token], + candidate.remoteFileID == object.id { + if candidate.firstObservedAt <= cutoff, + candidate.observedFrontier.transactionIDs.isSubset( + of: state.appliedTransactionIDs + ) { + try await objectStore.deleteObject(fileID: object.id) + candidatesByToken[object.token] = nil + deleted += 1 + } + } else { + candidatesByToken[object.token] = VaultGarbageCollectionCandidate( + objectToken: object.token, + remoteFileID: object.id, + firstObservedAt: now, + observedFrontier: state.frontier + ) + } + } + cursor = page.nextCursor + } while cursor != nil + candidatesByToken = candidatesByToken.filter { + observedTokens.contains($0.key) && + referencedTokens.contains($0.key) == false + } + try await localStore.replaceGarbageCollectionCandidates( + Array(candidatesByToken.values) + ) + + return VaultGarbageCollectionReport( + checkpointFileID: remoteCheckpoint.id, + examinedObjectCount: examined, + deletedObjectCount: deleted + ) + } + + private static func referencedContentTokens(in items: [VaultItem]) -> Set { + var tokens: Set = [] + for item in items { + if let token = item.contentReference?.objectToken { + tokens.insert(token) + } + tokens.formUnion(item.versions.map(\.contentReference.objectToken)) + } + return tokens + } + + private func temporaryURL(prefix: String) -> URL { + temporaryDirectoryURL.appendingPathComponent( + "\(prefix)-\(UUID().uuidString)", + isDirectory: false + ) + } +} diff --git a/PotassiumProviderCore/VaultMigration.swift b/PotassiumProviderCore/VaultMigration.swift new file mode 100644 index 0000000..de6bef6 --- /dev/null +++ b/PotassiumProviderCore/VaultMigration.swift @@ -0,0 +1,481 @@ +import CryptoKit +import Foundation + +public enum VaultMigrationState: Int, Codable, Comparable, Sendable { + case inventoried + case encrypted + case uploaded + case committed + case verified + case sourcePurged + + public static func < (lhs: VaultMigrationState, rhs: VaultMigrationState) -> Bool { + lhs.rawValue < rhs.rawValue + } +} + +public struct VaultMigrationSourceItem: Codable, Equatable, Sendable { + public let sourceIdentifier: String + public let sourceParentIdentifier: String? + public let sourceRevision: String + public let filename: String + public let isDirectory: Bool + public let contentTypeIdentifier: String? + public let createdAt: Date + public let modifiedAt: Date + public let plaintextSize: Int64 + + public init( + sourceIdentifier: String, + sourceParentIdentifier: String?, + sourceRevision: String, + filename: String, + isDirectory: Bool, + contentTypeIdentifier: String?, + createdAt: Date, + modifiedAt: Date, + plaintextSize: Int64 + ) { + self.sourceIdentifier = sourceIdentifier + self.sourceParentIdentifier = sourceParentIdentifier + self.sourceRevision = sourceRevision + self.filename = filename + self.isDirectory = isDirectory + self.contentTypeIdentifier = contentTypeIdentifier + self.createdAt = createdAt + self.modifiedAt = modifiedAt + self.plaintextSize = plaintextSize + } +} + +public struct VaultMigrationRecord: Codable, Equatable, Identifiable, Sendable { + public var id: String { source.sourceIdentifier } + public var source: VaultMigrationSourceItem + public var destinationItemID: VaultItemIdentifier + public var destinationParentID: VaultItemIdentifier? + public var state: VaultMigrationState + public var stagedContent: VaultStagedContent? + public var uploadedContent: VaultUploadedContent? + public var verifiedDigest: Data? + public var updatedAt: Date + + public init( + source: VaultMigrationSourceItem, + destinationItemID: VaultItemIdentifier = VaultItemIdentifier(), + destinationParentID: VaultItemIdentifier?, + state: VaultMigrationState = .inventoried, + stagedContent: VaultStagedContent? = nil, + uploadedContent: VaultUploadedContent? = nil, + verifiedDigest: Data? = nil, + updatedAt: Date = Date() + ) { + self.source = source + self.destinationItemID = destinationItemID + self.destinationParentID = destinationParentID + self.state = state + self.stagedContent = stagedContent + self.uploadedContent = uploadedContent + self.verifiedDigest = verifiedDigest + self.updatedAt = updatedAt + } +} + +public struct VaultMigrationPreflight: Equatable, Sendable { + public let itemCount: Int + public let plaintextByteCount: Int64 + public let estimatedCiphertextByteCount: Int64 + public let inaccessibleItemCount: Int + public let sharedItemCount: Int + public let versionCount: Int + public let ownsKnownFolders: Bool + + public init( + itemCount: Int, + plaintextByteCount: Int64, + estimatedCiphertextByteCount: Int64, + inaccessibleItemCount: Int, + sharedItemCount: Int, + versionCount: Int, + ownsKnownFolders: Bool + ) { + self.itemCount = itemCount + self.plaintextByteCount = plaintextByteCount + self.estimatedCiphertextByteCount = estimatedCiphertextByteCount + self.inaccessibleItemCount = inaccessibleItemCount + self.sharedItemCount = sharedItemCount + self.versionCount = versionCount + self.ownsKnownFolders = ownsKnownFolders + } +} + +public protocol VaultMigrationJournalStoring: Sendable { + func records() async throws -> [VaultMigrationRecord] + func record(sourceIdentifier: String) async throws -> VaultMigrationRecord? + func save(_ record: VaultMigrationRecord) async throws + func removeAll() async throws +} + +public actor InMemoryVaultMigrationJournal: VaultMigrationJournalStoring { + private var values: [String: VaultMigrationRecord] = [:] + + public init() {} + + public func records() -> [VaultMigrationRecord] { + values.values.sorted { $0.source.sourceIdentifier < $1.source.sourceIdentifier } + } + + public func record(sourceIdentifier: String) -> VaultMigrationRecord? { + values[sourceIdentifier] + } + + public func save(_ record: VaultMigrationRecord) { + values[record.source.sourceIdentifier] = record + } + + public func removeAll() { + values.removeAll() + } +} + +/// The complete migration journal is encrypted with the vault local-state key, +/// so source names and paths never appear in local support databases. +public actor VaultMigrationFileJournal: VaultMigrationJournalStoring { + private let fileURL: URL + private let rootKey: VaultKeyMaterial + private let vaultID: VaultIdentifier + private let keyEpoch: UInt32 + private var cachedRecords: [String: VaultMigrationRecord]? + + public init( + fileURL: URL, + rootKey: VaultKeyMaterial, + vaultID: VaultIdentifier, + keyEpoch: UInt32 = VaultFormat.currentKeyEpoch + ) { + self.fileURL = fileURL + self.rootKey = rootKey + self.vaultID = vaultID + self.keyEpoch = keyEpoch + } + + public func records() throws -> [VaultMigrationRecord] { + try load().values.sorted { $0.source.sourceIdentifier < $1.source.sourceIdentifier } + } + + public func record(sourceIdentifier: String) throws -> VaultMigrationRecord? { + try load()[sourceIdentifier] + } + + public func save(_ record: VaultMigrationRecord) throws { + var values = try load() + values[record.source.sourceIdentifier] = record + try persist(values) + } + + public func removeAll() throws { + cachedRecords = [:] + guard FileManager.default.fileExists(atPath: fileURL.path) else { return } + try FileManager.default.removeItem(at: fileURL) + } + + private func load() throws -> [String: VaultMigrationRecord] { + if let cachedRecords { return cachedRecords } + guard FileManager.default.fileExists(atPath: fileURL.path) else { + cachedRecords = [:] + return [:] + } + let envelope = try Data(contentsOf: fileURL, options: .mappedIfSafe) + let records = try VaultCryptography.open( + [VaultMigrationRecord].self, + envelope: envelope, + expectedRole: .localState, + expectedObjectToken: objectToken, + rootKey: rootKey, + vaultID: vaultID, + keyEpoch: keyEpoch + ) + let values = Dictionary(uniqueKeysWithValues: records.map { + ($0.source.sourceIdentifier, $0) + }) + cachedRecords = values + return values + } + + private func persist(_ records: [String: VaultMigrationRecord]) throws { + let sorted = records.values.sorted { + $0.source.sourceIdentifier < $1.source.sourceIdentifier + } + let envelope = try VaultCryptography.seal( + sorted, + role: .localState, + objectToken: objectToken, + rootKey: rootKey, + vaultID: vaultID, + keyEpoch: keyEpoch + ) + try FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try envelope.write(to: fileURL, options: [.atomic, .completeFileProtection]) + cachedRecords = records + } + + private var objectToken: String { + Data( + SHA256.hash(data: Data("migration-journal:\(vaultID.rawValue.uuidString)".utf8)) + .prefix(20) + ) + .base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +} + +public protocol VaultMigrationSourceProviding: Sendable { + func currentRevision(sourceIdentifier: String) async throws -> String + func download(sourceIdentifier: String, to destinationURL: URL) async throws + func purgePlaintext(sourceIdentifier: String) async throws +} + +public protocol VaultMigrationDestinationProviding: EncryptedVaultProviding { + func stageFileImport( + itemID: VaultItemIdentifier, + plaintextURL: URL + ) async throws -> VaultStagedContent + func uploadStagedFileImport( + _ staged: VaultStagedContent + ) async throws -> VaultUploadedContent + func commitUploadedFileImport( + _ uploaded: VaultUploadedContent, + parentID: VaultItemIdentifier?, + filename: String, + contentTypeIdentifier: String?, + createdAt: Date, + modifiedAt: Date + ) async throws -> VaultItem + func discardStagedFileImport(_ staged: VaultStagedContent) async +} + +extension EncryptedVaultService: VaultMigrationDestinationProviding {} + +public enum VaultMigrationError: Error, Equatable, LocalizedError, Sendable { + case sourceChanged(String) + case verificationFailed(String) + case invalidJournalState(String) + case sourceNotVerified(String) + + public var errorDescription: String? { + switch self { + case .sourceChanged: + return "The source item changed during migration and must be recopied." + case .verificationFailed: + return "The encrypted destination did not verify against the source digest and size." + case .invalidJournalState: + return "The resumable migration journal contains an invalid transition." + case .sourceNotVerified: + return "Plaintext cannot be purged before its encrypted copy is verified." + } + } +} + +/// Resumable single-item state machine. Source purge is deliberately a +/// separate method; no copy operation can delete plaintext. +public actor VaultMigrationCoordinator { + private let source: any VaultMigrationSourceProviding + private let destination: any VaultMigrationDestinationProviding + private let journal: any VaultMigrationJournalStoring + private let temporaryDirectoryURL: URL + + public init( + source: any VaultMigrationSourceProviding, + destination: any VaultMigrationDestinationProviding, + journal: any VaultMigrationJournalStoring, + temporaryDirectoryURL: URL = FileManager.default.temporaryDirectory + ) { + self.source = source + self.destination = destination + self.journal = journal + self.temporaryDirectoryURL = temporaryDirectoryURL + } + + public func inventory( + _ sourceItem: VaultMigrationSourceItem, + destinationParentID: VaultItemIdentifier? + ) async throws -> VaultMigrationRecord { + if let existing = try await journal.record( + sourceIdentifier: sourceItem.sourceIdentifier + ), existing.source.sourceRevision == sourceItem.sourceRevision { + return existing + } + let record = VaultMigrationRecord( + source: sourceItem, + destinationParentID: destinationParentID + ) + try await journal.save(record) + return record + } + + public func resume(sourceIdentifier: String) async throws -> VaultMigrationRecord { + guard var record = try await journal.record(sourceIdentifier: sourceIdentifier) else { + throw VaultMigrationError.invalidJournalState(sourceIdentifier) + } + if record.state == .sourcePurged { return record } + guard try await source.currentRevision(sourceIdentifier: sourceIdentifier) + == record.source.sourceRevision else { + if let staged = record.stagedContent { + await destination.discardStagedFileImport(staged) + } + throw VaultMigrationError.sourceChanged(sourceIdentifier) + } + if record.state == .verified { return record } + + if record.source.isDirectory { + if record.state == .inventoried { + let item = try await destination.createDirectory( + parentID: record.destinationParentID, + filename: record.source.filename, + createdAt: record.source.createdAt + ) + record.destinationItemID = item.id + record.state = .committed + record.updatedAt = Date() + try await journal.save(record) + } + // Observe the immutable transaction back through the complete + // journal listing before treating the folder as remotely durable. + _ = try await destination.synchronize() + record.state = .verified + record.updatedAt = Date() + try await journal.save(record) + return record + } + + if record.state == .inventoried || stagedFileIsMissing(record) { + let plaintextURL = temporaryURL(prefix: "migration-plaintext") + defer { try? FileManager.default.removeItem(at: plaintextURL) } + try await source.download( + sourceIdentifier: sourceIdentifier, + to: plaintextURL + ) + try applyPlaintextProtection(to: plaintextURL) + let staged = try await destination.stageFileImport( + itemID: record.destinationItemID, + plaintextURL: plaintextURL + ) + record.stagedContent = staged + record.uploadedContent = nil + record.state = .encrypted + record.updatedAt = Date() + try await journal.save(record) + } + + if record.state == .encrypted { + guard let staged = record.stagedContent else { + throw VaultMigrationError.invalidJournalState(sourceIdentifier) + } + let uploaded = try await destination.uploadStagedFileImport(staged) + record.uploadedContent = uploaded + record.state = .uploaded + record.updatedAt = Date() + try await journal.save(record) + } + + if record.state == .uploaded { + guard try await source.currentRevision(sourceIdentifier: sourceIdentifier) + == record.source.sourceRevision, + let uploaded = record.uploadedContent else { + throw VaultMigrationError.sourceChanged(sourceIdentifier) + } + _ = try await destination.commitUploadedFileImport( + uploaded, + parentID: record.destinationParentID, + filename: record.source.filename, + contentTypeIdentifier: record.source.contentTypeIdentifier, + createdAt: record.source.createdAt, + modifiedAt: record.source.modifiedAt + ) + record.state = .committed + record.updatedAt = Date() + try await journal.save(record) + } + + if record.state == .committed { + let verificationURL = temporaryURL(prefix: "migration-verification") + defer { try? FileManager.default.removeItem(at: verificationURL) } + _ = try await destination.fetchContent( + itemID: record.destinationItemID, + expectedRevision: record.uploadedContent?.staged.contentRevision, + to: verificationURL + ) + let digest = try Self.digestAndSize(of: verificationURL) + guard digest.size == record.source.plaintextSize, + digest.digest == record.uploadedContent?.staged.plaintextDigest else { + throw VaultMigrationError.verificationFailed(sourceIdentifier) + } + guard try await source.currentRevision( + sourceIdentifier: sourceIdentifier + ) == record.source.sourceRevision else { + throw VaultMigrationError.sourceChanged(sourceIdentifier) + } + record.verifiedDigest = digest.digest + record.state = .verified + record.updatedAt = Date() + try await journal.save(record) + } + return record + } + + public func purgeVerifiedSource(sourceIdentifier: String) async throws { + guard var record = try await journal.record(sourceIdentifier: sourceIdentifier), + record.state == .verified else { + throw VaultMigrationError.sourceNotVerified(sourceIdentifier) + } + guard try await source.currentRevision( + sourceIdentifier: sourceIdentifier + ) == record.source.sourceRevision else { + throw VaultMigrationError.sourceChanged(sourceIdentifier) + } + try await source.purgePlaintext(sourceIdentifier: sourceIdentifier) + record.state = .sourcePurged + record.updatedAt = Date() + try await journal.save(record) + } + + private func stagedFileIsMissing(_ record: VaultMigrationRecord) -> Bool { + guard record.state == .encrypted, let staged = record.stagedContent else { + return false + } + return FileManager.default.fileExists(atPath: staged.ciphertextURL.path) == false + } + + private func temporaryURL(prefix: String) -> URL { + temporaryDirectoryURL.appendingPathComponent( + "\(prefix)-\(UUID().uuidString)", + isDirectory: false + ) + } + + private func applyPlaintextProtection(to url: URL) throws { + #if canImport(Darwin) + try FileManager.default.setAttributes( + [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], + ofItemAtPath: url.path + ) + #endif + } + + private static func digestAndSize(of url: URL) throws -> (digest: Data, size: Int64) { + let handle = try FileHandle(forReadingFrom: url) + defer { try? handle.close() } + var hasher = SHA256() + var size: Int64 = 0 + while let data = try handle.read(upToCount: VaultFormat.contentFrameSize), + data.isEmpty == false { + hasher.update(data: data) + size += Int64(data.count) + } + return (Data(hasher.finalize()), size) + } +} diff --git a/PotassiumProviderCore/VaultModels.swift b/PotassiumProviderCore/VaultModels.swift new file mode 100644 index 0000000..3e36ee9 --- /dev/null +++ b/PotassiumProviderCore/VaultModels.swift @@ -0,0 +1,428 @@ +import CryptoKit +import Foundation +import Security +import UniformTypeIdentifiers + +public enum VaultFormat { + public static let currentVersion: UInt16 = 1 + public static let currentKeyEpoch: UInt32 = 1 + public static let contentFrameSize = 1_048_576 + public static let minimumFinalFrameSize = 4_096 + public static let transactionObjectSize = 65_536 + public static let fileProviderIdentifierPrefix = "ev1:" +} + +public struct VaultIdentifier: RawRepresentable, Codable, Hashable, Sendable { + public let rawValue: UUID + + public init(rawValue: UUID) { + self.rawValue = rawValue + } + + public init() { + self.init(rawValue: UUID()) + } +} + +public struct VaultItemIdentifier: RawRepresentable, Codable, Hashable, Sendable { + public let rawValue: UUID + + public init(rawValue: UUID) { + self.rawValue = rawValue + } + + public init() { + self.init(rawValue: UUID()) + } + + public init?(fileProviderIdentifier: String) { + guard fileProviderIdentifier.hasPrefix(VaultFormat.fileProviderIdentifierPrefix) else { + return nil + } + let encoded = String(fileProviderIdentifier.dropFirst(VaultFormat.fileProviderIdentifierPrefix.count)) + guard let data = Data(base64URLEncoded: encoded), + data.count == MemoryLayout.size else { + return nil + } + self.init(rawValue: UUID(bytes: data)) + } + + public var fileProviderIdentifier: String { + VaultFormat.fileProviderIdentifierPrefix + rawValue.data.vaultBase64URLEncodedString() + } +} + +public struct VaultRevision: Codable, Hashable, Sendable { + public static let byteCount = SHA256.Digest.byteCount + + public let data: Data + + public init?(data: Data) { + guard data.count == Self.byteCount else { return nil } + self.data = data + } + + public init(hashing data: D) { + self.data = Data(SHA256.hash(data: data)) + } + + public static func random() throws -> VaultRevision { + VaultRevision(data: try VaultRandom.bytes(count: byteCount))! + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let value = try container.decode(Data.self) + guard let revision = Self(data: value) else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "A vault revision must contain exactly \(Self.byteCount) bytes." + ) + } + self = revision + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(data) + } +} + +public struct VaultFrontier: Codable, Equatable, Sendable { + public private(set) var transactionIDs: Set + + public init(transactionIDs: Set = []) { + self.transactionIDs = transactionIDs + } + + public mutating func replaceParents(_ parents: Set, with transactionID: UUID) { + transactionIDs.subtract(parents) + transactionIDs.insert(transactionID) + } + + public func sortedTransactionIDs() -> [UUID] { + transactionIDs.sorted { $0.uuidString < $1.uuidString } + } + + public var anchorString: String { + var material = Data("vault-frontier-v1".utf8) + for identifier in sortedTransactionIDs() { + material.append(identifier.data) + } + return Data(SHA256.hash(data: material)).base64EncodedString() + } + + private enum CodingKeys: String, CodingKey { + case transactionIDs + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + transactionIDs = Set(try container.decode([UUID].self, forKey: .transactionIDs)) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(sortedTransactionIDs(), forKey: .transactionIDs) + } +} + +public struct VaultContentReference: Codable, Equatable, Sendable { + /// Logical item UUID used in frame associated data when this immutable + /// revision was first encrypted. Copy-on-write duplicates retain it. + public let encryptionItemID: VaultItemIdentifier + public let objectToken: String + public let remoteFileID: Int? + public let wrappedContentKey: Data + public let noncePrefix: UInt64 + public let plaintextLength: Int64 + public let plaintextDigest: Data + public let frameCount: UInt32 + + public init( + encryptionItemID: VaultItemIdentifier, + objectToken: String, + remoteFileID: Int? = nil, + wrappedContentKey: Data, + noncePrefix: UInt64, + plaintextLength: Int64, + plaintextDigest: Data, + frameCount: UInt32 + ) { + self.encryptionItemID = encryptionItemID + self.objectToken = objectToken + self.remoteFileID = remoteFileID + self.wrappedContentKey = wrappedContentKey + self.noncePrefix = noncePrefix + self.plaintextLength = plaintextLength + self.plaintextDigest = plaintextDigest + self.frameCount = frameCount + } +} + +public struct VaultItem: Codable, Equatable, Identifiable, Sendable { + public let id: VaultItemIdentifier + public var parentID: VaultItemIdentifier? + public var filename: String + public var isDirectory: Bool + public var contentTypeIdentifier: String? + public var createdAt: Date + public var modifiedAt: Date + public var plaintextSize: Int64 + public var isFavorite: Bool + public var isTrashed: Bool + public var contentRevision: VaultRevision + public var metadataRevision: VaultRevision + public var contentReference: VaultContentReference? + public var versions: [VaultVersion] + + public init( + id: VaultItemIdentifier = VaultItemIdentifier(), + parentID: VaultItemIdentifier?, + filename: String, + isDirectory: Bool, + contentTypeIdentifier: String? = nil, + createdAt: Date = Date(), + modifiedAt: Date = Date(), + plaintextSize: Int64 = 0, + isFavorite: Bool = false, + isTrashed: Bool = false, + contentRevision: VaultRevision, + metadataRevision: VaultRevision, + contentReference: VaultContentReference? = nil, + versions: [VaultVersion] = [] + ) { + self.id = id + self.parentID = parentID + self.filename = filename + self.isDirectory = isDirectory + self.contentTypeIdentifier = contentTypeIdentifier + self.createdAt = createdAt + self.modifiedAt = modifiedAt + self.plaintextSize = plaintextSize + self.isFavorite = isFavorite + self.isTrashed = isTrashed + self.contentRevision = contentRevision + self.metadataRevision = metadataRevision + self.contentReference = contentReference + self.versions = versions + } + + public var contentType: UTType { + if isDirectory { return .folder } + if let contentTypeIdentifier, let type = UTType(contentTypeIdentifier) { + return type + } + return UTType(filenameExtension: (filename as NSString).pathExtension) ?? .data + } +} + +public enum VaultRevisionDigests { + public static func metadata(for item: VaultItem) throws -> VaultRevision { + try VaultCryptography.revision(for: MetadataMaterial( + parentID: item.parentID, + filename: item.filename, + isDirectory: item.isDirectory, + contentTypeIdentifier: item.contentTypeIdentifier, + createdAt: item.createdAt, + isFavorite: item.isFavorite, + isTrashed: item.isTrashed + )) + } + + private struct MetadataMaterial: Codable { + let parentID: VaultItemIdentifier? + let filename: String + let isDirectory: Bool + let contentTypeIdentifier: String? + let createdAt: Date + let isFavorite: Bool + let isTrashed: Bool + } +} + +public struct VaultVersion: Codable, Equatable, Identifiable, Sendable { + public var id: VaultRevision { contentRevision } + public let contentRevision: VaultRevision + public let contentReference: VaultContentReference + public let plaintextSize: Int64 + public let modifiedAt: Date + + public init( + contentRevision: VaultRevision, + contentReference: VaultContentReference, + plaintextSize: Int64, + modifiedAt: Date + ) { + self.contentRevision = contentRevision + self.contentReference = contentReference + self.plaintextSize = plaintextSize + self.modifiedAt = modifiedAt + } +} + +public struct VaultTransaction: Codable, Equatable, Identifiable, Sendable { + public enum Operation: Codable, Equatable, Sendable { + case upsert(VaultItem) + case trash( + itemID: VaultItemIdentifier, + baseContentRevision: VaultRevision, + baseMetadataRevision: VaultRevision + ) + case restore(itemID: VaultItemIdentifier, parentID: VaultItemIdentifier?) + case purge( + itemID: VaultItemIdentifier, + baseContentRevision: VaultRevision, + baseMetadataRevision: VaultRevision + ) + } + + public let id: UUID + public let parents: VaultFrontier + public let deviceID: UUID + public let createdAt: Date + /// The item observed by the author before applying `operation`. It provides + /// the common base needed for deterministic three-way conflict resolution. + public let baseItem: VaultItem? + public let operation: Operation + + public init( + id: UUID = UUID(), + parents: VaultFrontier, + deviceID: UUID, + createdAt: Date = Date(), + baseItem: VaultItem? = nil, + operation: Operation + ) { + self.id = id + self.parents = parents + self.deviceID = deviceID + self.createdAt = createdAt + self.baseItem = baseItem + self.operation = operation + } +} + +public struct VaultCheckpoint: Codable, Equatable, Sendable { + public let frontier: VaultFrontier + public let items: [VaultItem] + public let transactionMerkleRoot: Data + public let createdAt: Date + + public init( + frontier: VaultFrontier, + items: [VaultItem], + transactionMerkleRoot: Data, + createdAt: Date = Date() + ) { + self.frontier = frontier + self.items = items.sorted { $0.id.rawValue.uuidString < $1.id.rawValue.uuidString } + self.transactionMerkleRoot = transactionMerkleRoot + self.createdAt = createdAt + } +} + +public struct VaultTrustedState: Codable, Equatable, Sendable { + public let vaultID: VaultIdentifier + public let keyEpoch: UInt32 + public let frontier: VaultFrontier + public let checkpointDigest: Data? + public let observedAt: Date + + public init( + vaultID: VaultIdentifier, + keyEpoch: UInt32, + frontier: VaultFrontier, + checkpointDigest: Data?, + observedAt: Date = Date() + ) { + self.vaultID = vaultID + self.keyEpoch = keyEpoch + self.frontier = frontier + self.checkpointDigest = checkpointDigest + self.observedAt = observedAt + } +} + +public enum VaultObjectRole: UInt8, Codable, Sendable { + case metadata = 1 + case transaction = 2 + case checkpoint = 3 + case merkleNode = 4 + case localState = 5 +} + +public struct VaultKeyMaterial: Equatable, Sendable { + public static let byteCount = 32 + + public let data: Data + + public init?(data: Data) { + guard data.count == Self.byteCount else { return nil } + self.data = data + } + + public static func random() throws -> VaultKeyMaterial { + VaultKeyMaterial(data: try VaultRandom.bytes(count: byteCount))! + } + + var symmetricKey: SymmetricKey { + SymmetricKey(data: data) + } +} + +enum VaultRandom { + static func bytes(count: Int) throws -> Data { + guard count >= 0 else { + throw VaultCryptoError.invalidLength + } + var data = Data(repeating: 0, count: count) + let status = data.withUnsafeMutableBytes { bytes in + SecRandomCopyBytes(kSecRandomDefault, count, bytes.baseAddress!) + } + guard status == errSecSuccess else { + throw VaultCryptoError.randomGenerationFailed(status) + } + return data + } + + static func uint64() throws -> UInt64 { + let data = try bytes(count: MemoryLayout.size) + return data.withUnsafeBytes { $0.loadUnaligned(as: UInt64.self) } + } +} + +extension UUID { + init(bytes data: Data) { + precondition(data.count == MemoryLayout.size) + self = data.withUnsafeBytes { bytes in + let value = bytes.loadUnaligned(as: uuid_t.self) + return UUID(uuid: value) + } + } + + var data: Data { + var value = uuid + return withUnsafeBytes(of: &value) { Data($0) } + } +} + +extension Data { + init?(base64URLEncoded value: String) { + var normalized = value + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + let remainder = normalized.count % 4 + if remainder != 0 { + normalized += String(repeating: "=", count: 4 - remainder) + } + self.init(base64Encoded: normalized) + } + + func vaultBase64URLEncodedString() -> String { + base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +} diff --git a/PotassiumProviderCore/VaultProvisioning.swift b/PotassiumProviderCore/VaultProvisioning.swift new file mode 100644 index 0000000..cd7cece --- /dev/null +++ b/PotassiumProviderCore/VaultProvisioning.swift @@ -0,0 +1,369 @@ +import Foundation + +public struct PendingVaultProvisioning: Sendable { + public let driveID: Int + public let vaultID: VaultIdentifier + public let rootKey: VaultKeyMaterial + public let recoveryKit: VaultRecoveryKit + public let vaultConfiguration: ProviderVaultConfiguration + + public init( + driveID: Int, + vaultID: VaultIdentifier, + rootKey: VaultKeyMaterial, + recoveryKit: VaultRecoveryKit, + vaultConfiguration: ProviderVaultConfiguration + ) { + self.driveID = driveID + self.vaultID = vaultID + self.rootKey = rootKey + self.recoveryKit = recoveryKit + self.vaultConfiguration = vaultConfiguration + } +} + +public struct PendingVaultRecoveryRotation: Sendable { + public let recoveryKit: VaultRecoveryKit + public let vaultConfiguration: ProviderVaultConfiguration + + public init( + recoveryKit: VaultRecoveryKit, + vaultConfiguration: ProviderVaultConfiguration + ) { + self.recoveryKit = recoveryKit + self.vaultConfiguration = vaultConfiguration + } +} + +public enum VaultProvisioningError: Error, Equatable, LocalizedError, Sendable { + case recoveryConfirmationMismatch + case missingRemoteLayout + case checkpointNotFound + case driveMismatch + + public var errorDescription: String? { + switch self { + case .recoveryConfirmationMismatch: + return "The recovery-kit confirmation does not match the generated kit." + case .missingRemoteLayout: + return "The vault bootstrap does not contain a supported remote layout." + case .checkpointNotFound: + return "The authenticated vault checkpoint could not be found." + case .driveMismatch: + return "The recovery kit belongs to a different kDrive." + } + } +} + +public struct VaultProvisioningService: Sendable { + private let objectStore: any KDriveObjectStoreProviding + private let keyStore: any VaultKeyStoring + private let temporaryDirectoryURL: URL + + public init( + objectStore: any KDriveObjectStoreProviding, + keyStore: any VaultKeyStoring, + temporaryDirectoryURL: URL = FileManager.default.temporaryDirectory + ) { + self.objectStore = objectStore + self.keyStore = keyStore + self.temporaryDirectoryURL = temporaryDirectoryURL + } + + /// Creates only randomized physical containers and authenticated encrypted + /// bootstrap/checkpoint objects. The caller must show `recoveryKit` and call + /// `confirm` before saving or registering a File Provider domain. + public func prepareNewVault( + driveID: Int, + remoteParentID: Int = ProviderConstants.defaultRootFileID + ) async throws -> PendingVaultProvisioning { + let vaultID = VaultIdentifier() + let rootKey = try VaultCryptography.makeRootKey() + let recoverySecret = try VaultKeyMaterial.random() + let rootToken = try VaultCryptography.makeObjectToken( + rootKey: rootKey, + vaultID: vaultID + ) + let rootObject = try await objectStore.createContainer( + parentID: remoteParentID, + token: rootToken + ) + + do { + let contentContainer = try await objectStore.createContainer( + parentID: rootObject.id, + token: VaultCryptography.makeObjectToken(rootKey: rootKey, vaultID: vaultID) + ) + let journalContainer = try await objectStore.createContainer( + parentID: rootObject.id, + token: VaultCryptography.makeObjectToken(rootKey: rootKey, vaultID: vaultID) + ) + let checkpointContainer = try await objectStore.createContainer( + parentID: rootObject.id, + token: VaultCryptography.makeObjectToken(rootKey: rootKey, vaultID: vaultID) + ) + let checkpointToken = try VaultCryptography.makeObjectToken( + rootKey: rootKey, + vaultID: vaultID + ) + let layout = VaultBootstrap.RemoteLayout( + contentContainerID: contentContainer.id, + journalContainerID: journalContainer.id, + checkpointContainerID: checkpointContainer.id, + checkpointToken: checkpointToken + ) + + let checkpoint = VaultCheckpoint( + frontier: VaultFrontier(), + items: [], + transactionMerkleRoot: VaultMerkleTree.emptyRoot + ) + let checkpointEnvelope = try VaultCryptography.seal( + checkpoint, + role: .checkpoint, + objectToken: checkpointToken, + rootKey: rootKey, + vaultID: vaultID + ) + let checkpointURL = temporaryURL(prefix: "checkpoint") + defer { try? FileManager.default.removeItem(at: checkpointURL) } + try checkpointEnvelope.write(to: checkpointURL, options: [.atomic]) + _ = try await objectStore.uploadObject( + containerID: checkpointContainer.id, + token: checkpointToken, + fileURL: checkpointURL + ) + + let bootstrapToken = try VaultCryptography.makeObjectToken( + rootKey: rootKey, + vaultID: vaultID + ) + let bootstrap = try VaultBootstrap.create( + vaultID: vaultID, + rootKey: rootKey, + recoverySecret: recoverySecret, + remoteLayout: layout + ) + let bootstrapURL = temporaryURL(prefix: "bootstrap") + defer { try? FileManager.default.removeItem(at: bootstrapURL) } + try bootstrap.write(to: bootstrapURL, options: [.atomic]) + let header = try await objectStore.uploadObject( + containerID: rootObject.id, + token: bootstrapToken, + fileURL: bootstrapURL + ) + + let recoveryKit = VaultRecoveryKit( + vaultID: vaultID, + driveID: driveID, + vaultRootFileID: rootObject.id, + vaultHeaderFileID: header.id, + recoverySecret: recoverySecret + ) + return PendingVaultProvisioning( + driveID: driveID, + vaultID: vaultID, + rootKey: rootKey, + recoveryKit: recoveryKit, + vaultConfiguration: ProviderVaultConfiguration( + vaultIdentifier: vaultID, + vaultRootFileID: rootObject.id, + vaultHeaderFileID: header.id, + remoteLayout: layout + ) + ) + } catch { + try? await objectStore.deleteObject(fileID: rootObject.id) + throw error + } + } + + public func confirm( + _ pending: PendingVaultProvisioning, + recoveryKitConfirmation: String + ) async throws -> ProviderVaultConfiguration { + let confirmedKit: VaultRecoveryKit + do { + confirmedKit = try VaultRecoveryKit(encoded: recoveryKitConfirmation) + } catch { + throw VaultProvisioningError.recoveryConfirmationMismatch + } + guard confirmedKit == pending.recoveryKit else { + throw VaultProvisioningError.recoveryConfirmationMismatch + } + try await keyStore.saveRootKey(pending.rootKey, vaultID: pending.vaultID) + try await keyStore.saveTrustedState(VaultTrustedState( + vaultID: pending.vaultID, + keyEpoch: pending.vaultConfiguration.keyEpoch, + frontier: VaultFrontier(), + checkpointDigest: VaultMerkleTree.emptyRoot + )) + return pending.vaultConfiguration + } + + public func cancel(_ pending: PendingVaultProvisioning) async { + // This root was created by this unfinished provisioning session and has + // never been registered as a domain, so removing it cannot delete user + // plaintext or an established vault. + try? await objectStore.deleteObject( + fileID: pending.vaultConfiguration.vaultRootFileID + ) + } + + public func openExistingVault( + recoveryKitText: String, + expectedDriveID: Int + ) async throws -> ProviderVaultConfiguration { + let kit = try VaultRecoveryKit(encoded: recoveryKitText) + guard kit.driveID == expectedDriveID else { + throw VaultProvisioningError.driveMismatch + } + let bootstrapURL = temporaryURL(prefix: "bootstrap-download") + defer { try? FileManager.default.removeItem(at: bootstrapURL) } + try await objectStore.downloadObject( + fileID: kit.vaultHeaderFileID, + to: bootstrapURL + ) + let unlocked = try VaultBootstrap.unlock( + Data(contentsOf: bootstrapURL, options: .mappedIfSafe), + recoverySecret: kit.recoverySecret, + expectedVaultID: kit.vaultID + ) + guard let layout = unlocked.remoteLayout else { + throw VaultProvisioningError.missingRemoteLayout + } + let checkpointObject = try await findObject( + token: layout.checkpointToken, + containerID: layout.checkpointContainerID + ) + let checkpointURL = temporaryURL(prefix: "checkpoint-download") + defer { try? FileManager.default.removeItem(at: checkpointURL) } + try await objectStore.downloadObject( + fileID: checkpointObject.id, + to: checkpointURL + ) + let checkpoint = try VaultCryptography.open( + VaultCheckpoint.self, + envelope: Data(contentsOf: checkpointURL, options: .mappedIfSafe), + expectedRole: .checkpoint, + expectedObjectToken: layout.checkpointToken, + rootKey: unlocked.rootKey, + vaultID: unlocked.vaultID, + keyEpoch: unlocked.keyEpoch + ) + + try await keyStore.saveRootKey(unlocked.rootKey, vaultID: unlocked.vaultID) + try await keyStore.saveTrustedState(VaultTrustedState( + vaultID: unlocked.vaultID, + keyEpoch: unlocked.keyEpoch, + frontier: checkpoint.frontier, + checkpointDigest: checkpoint.transactionMerkleRoot + )) + return ProviderVaultConfiguration( + vaultIdentifier: unlocked.vaultID, + vaultRootFileID: kit.vaultRootFileID, + vaultHeaderFileID: kit.vaultHeaderFileID, + formatVersion: VaultFormat.currentVersion, + keyEpoch: unlocked.keyEpoch, + remoteLayout: layout + ) + } + + /// Rewraps the existing root key under a fresh recovery secret. This does + /// not revoke an old recovery kit while an older bootstrap object or server + /// backup remains reachable; device revocation requires full rekeying. + public func prepareRecoveryRotation( + configuration: ProviderVaultConfiguration, + driveID: Int, + currentRecoveryKitText: String + ) async throws -> PendingVaultRecoveryRotation { + let currentKit = try VaultRecoveryKit(encoded: currentRecoveryKitText) + guard currentKit.vaultID == configuration.vaultIdentifier, + currentKit.driveID == driveID else { + throw VaultProvisioningError.recoveryConfirmationMismatch + } + let bootstrapURL = temporaryURL(prefix: "rotation-bootstrap-download") + defer { try? FileManager.default.removeItem(at: bootstrapURL) } + try await objectStore.downloadObject( + fileID: currentKit.vaultHeaderFileID, + to: bootstrapURL + ) + let unlocked = try VaultBootstrap.unlock( + Data(contentsOf: bootstrapURL, options: .mappedIfSafe), + recoverySecret: currentKit.recoverySecret, + expectedVaultID: configuration.vaultIdentifier + ) + guard let layout = unlocked.remoteLayout else { + throw VaultProvisioningError.missingRemoteLayout + } + + let newRecoverySecret = try VaultKeyMaterial.random() + let newHeaderToken = try VaultCryptography.makeObjectToken( + rootKey: unlocked.rootKey, + vaultID: configuration.vaultIdentifier + ) + let newBootstrap = try VaultBootstrap.create( + vaultID: configuration.vaultIdentifier, + keyEpoch: configuration.keyEpoch, + rootKey: unlocked.rootKey, + recoverySecret: newRecoverySecret, + remoteLayout: layout + ) + let uploadURL = temporaryURL(prefix: "rotation-bootstrap") + defer { try? FileManager.default.removeItem(at: uploadURL) } + try newBootstrap.write(to: uploadURL, options: [.atomic]) + let header = try await objectStore.uploadObject( + containerID: configuration.vaultRootFileID, + token: newHeaderToken, + fileURL: uploadURL + ) + var rotatedConfiguration = configuration + rotatedConfiguration.vaultHeaderFileID = header.id + return PendingVaultRecoveryRotation( + recoveryKit: VaultRecoveryKit( + vaultID: configuration.vaultIdentifier, + driveID: driveID, + vaultRootFileID: configuration.vaultRootFileID, + vaultHeaderFileID: header.id, + recoverySecret: newRecoverySecret + ), + vaultConfiguration: rotatedConfiguration + ) + } + + public func confirmRecoveryRotation( + _ pending: PendingVaultRecoveryRotation, + recoveryKitConfirmation: String + ) throws -> ProviderVaultConfiguration { + guard let confirmation = try? VaultRecoveryKit( + encoded: recoveryKitConfirmation + ), confirmation == pending.recoveryKit else { + throw VaultProvisioningError.recoveryConfirmationMismatch + } + return pending.vaultConfiguration + } + + private func findObject( + token: String, + containerID: Int + ) async throws -> KDriveOpaqueObject { + var cursor: String? + repeat { + let page = try await objectStore.listObjects( + containerID: containerID, + cursor: cursor + ) + if let object = page.objects.first(where: { $0.token == token }) { + return object + } + cursor = page.nextCursor + } while cursor != nil + throw VaultProvisioningError.checkpointNotFound + } + + private func temporaryURL(prefix: String) -> URL { + temporaryDirectoryURL + .appendingPathComponent("\(prefix)-\(UUID().uuidString)") + .appendingPathExtension("bin") + } +} diff --git a/PotassiumProviderCore/VaultRecoveryKit.swift b/PotassiumProviderCore/VaultRecoveryKit.swift new file mode 100644 index 0000000..37e561a --- /dev/null +++ b/PotassiumProviderCore/VaultRecoveryKit.swift @@ -0,0 +1,288 @@ +import CryptoKit +import Foundation + +public struct VaultRecoveryKit: Equatable, Sendable { + public static let prefix = "KPV1" + private static let magic = Data("KPR1".utf8) + private static let payloadByteCount = 4 + 2 + 16 + 8 + 8 + 8 + VaultKeyMaterial.byteCount + private static let checksumByteCount = 5 + + public let vaultID: VaultIdentifier + public let driveID: Int + public let vaultRootFileID: Int + public let vaultHeaderFileID: Int + public let recoverySecret: VaultKeyMaterial + + public init( + vaultID: VaultIdentifier, + driveID: Int, + vaultRootFileID: Int, + vaultHeaderFileID: Int, + recoverySecret: VaultKeyMaterial + ) { + self.vaultID = vaultID + self.driveID = driveID + self.vaultRootFileID = vaultRootFileID + self.vaultHeaderFileID = vaultHeaderFileID + self.recoverySecret = recoverySecret + } + + public static func create( + vaultID: VaultIdentifier, + driveID: Int, + vaultRootFileID: Int, + vaultHeaderFileID: Int + ) throws -> VaultRecoveryKit { + VaultRecoveryKit( + vaultID: vaultID, + driveID: driveID, + vaultRootFileID: vaultRootFileID, + vaultHeaderFileID: vaultHeaderFileID, + recoverySecret: try VaultKeyMaterial.random() + ) + } + + public var encoded: String { + var payload = Data() + payload.append(Self.magic) + payload.appendUInt16(VaultFormat.currentVersion) + payload.append(vaultID.rawValue.data) + payload.appendUInt64(UInt64(bitPattern: Int64(driveID))) + payload.appendUInt64(UInt64(bitPattern: Int64(vaultRootFileID))) + payload.appendUInt64(UInt64(bitPattern: Int64(vaultHeaderFileID))) + payload.append(recoverySecret.data) + payload.append(Data(SHA256.hash(data: payload)).prefix(Self.checksumByteCount)) + + let base32 = VaultBase32.encode(payload) + let groups = stride(from: 0, to: base32.count, by: 5).map { start -> String in + let lower = base32.index(base32.startIndex, offsetBy: start) + let upper = base32.index( + lower, + offsetBy: min(5, base32.count - start), + limitedBy: base32.endIndex + )! + return String(base32[lower.. 1, + let decoded = VaultBase32.decode(normalized.dropFirst().joined()) else { + throw VaultCryptoError.recoveryKitInvalid + } + guard decoded.count == Self.payloadByteCount + Self.checksumByteCount else { + throw VaultCryptoError.recoveryKitInvalid + } + let payload = decoded.prefix(Self.payloadByteCount) + let checksum = decoded.suffix(Self.checksumByteCount) + guard Data(SHA256.hash(data: payload)).prefix(Self.checksumByteCount) == checksum else { + throw VaultCryptoError.recoveryKitChecksumMismatch + } + + var cursor = VaultDataCursor(data: Data(payload)) + guard try cursor.read(count: 4) == Self.magic else { + throw VaultCryptoError.recoveryKitInvalid + } + let version = try cursor.readUInt16() + guard version == VaultFormat.currentVersion else { + throw VaultCryptoError.unsupportedFormatVersion(version) + } + vaultID = VaultIdentifier(rawValue: UUID(bytes: try cursor.read(count: 16))) + driveID = Int(Int64(bitPattern: try cursor.readUInt64())) + vaultRootFileID = Int(Int64(bitPattern: try cursor.readUInt64())) + vaultHeaderFileID = Int(Int64(bitPattern: try cursor.readUInt64())) + guard let secret = VaultKeyMaterial(data: try cursor.read(count: VaultKeyMaterial.byteCount)) else { + throw VaultCryptoError.invalidKeyLength + } + recoverySecret = secret + } +} + +public enum VaultBootstrap { + private static let magic = Data("KPB1".utf8) + private static let headerByteCount = 4 + 2 + 4 + 16 + + public struct RemoteLayout: Codable, Equatable, Sendable { + public let contentContainerID: Int + public let journalContainerID: Int + public let checkpointContainerID: Int + public let checkpointToken: String + + public init( + contentContainerID: Int, + journalContainerID: Int, + checkpointContainerID: Int, + checkpointToken: String + ) { + self.contentContainerID = contentContainerID + self.journalContainerID = journalContainerID + self.checkpointContainerID = checkpointContainerID + self.checkpointToken = checkpointToken + } + } + + public struct Unlocked: Equatable, Sendable { + public let vaultID: VaultIdentifier + public let keyEpoch: UInt32 + public let rootKey: VaultKeyMaterial + public let remoteLayout: RemoteLayout? + } + + public static func create( + vaultID: VaultIdentifier, + keyEpoch: UInt32 = VaultFormat.currentKeyEpoch, + rootKey: VaultKeyMaterial, + recoverySecret: VaultKeyMaterial, + remoteLayout: RemoteLayout? = nil + ) throws -> Data { + let header = makeHeader(vaultID: vaultID, keyEpoch: keyEpoch) + let wrappingKey = recoveryWrappingKey( + recoverySecret: recoverySecret, + vaultID: vaultID, + keyEpoch: keyEpoch + ) + let payload = try VaultCoding.encoder.encode(BootstrapPayload( + rootKey: rootKey.data, + remoteLayout: remoteLayout + )) + let sealed = try AES.GCM.seal( + payload, + using: wrappingKey.symmetricKey, + authenticating: header + ) + guard let combined = sealed.combined else { + throw VaultCryptoError.invalidEnvelope + } + return header + combined + } + + public static func unlock( + _ bootstrap: Data, + recoverySecret: VaultKeyMaterial, + expectedVaultID: VaultIdentifier? = nil + ) throws -> Unlocked { + guard bootstrap.count > headerByteCount else { + throw VaultCryptoError.invalidEnvelope + } + var cursor = VaultDataCursor(data: bootstrap) + guard try cursor.read(count: 4) == magic else { + throw VaultCryptoError.invalidEnvelopeMagic + } + let version = try cursor.readUInt16() + guard version == VaultFormat.currentVersion else { + throw VaultCryptoError.unsupportedFormatVersion(version) + } + let keyEpoch = try cursor.readUInt32() + let vaultID = VaultIdentifier(rawValue: UUID(bytes: try cursor.read(count: 16))) + if let expectedVaultID, expectedVaultID != vaultID { + throw VaultCryptoError.unexpectedVault + } + let wrappingKey = recoveryWrappingKey( + recoverySecret: recoverySecret, + vaultID: vaultID, + keyEpoch: keyEpoch + ) + do { + let box = try AES.GCM.SealedBox(combined: bootstrap.dropFirst(headerByteCount)) + let payloadData = try AES.GCM.open( + box, + using: wrappingKey.symmetricKey, + authenticating: bootstrap.prefix(headerByteCount) + ) + let payload = try VaultCoding.decoder.decode( + BootstrapPayload.self, + from: payloadData + ) + guard let rootKey = VaultKeyMaterial(data: payload.rootKey) else { + throw VaultCryptoError.invalidKeyLength + } + return Unlocked( + vaultID: vaultID, + keyEpoch: keyEpoch, + rootKey: rootKey, + remoteLayout: payload.remoteLayout + ) + } catch let error as VaultCryptoError { + throw error + } catch { + throw VaultCryptoError.authenticationFailed + } + } + + private static func makeHeader(vaultID: VaultIdentifier, keyEpoch: UInt32) -> Data { + var header = Data() + header.append(magic) + header.appendUInt16(VaultFormat.currentVersion) + header.appendUInt32(keyEpoch) + header.append(vaultID.rawValue.data) + return header + } + + private static func recoveryWrappingKey( + recoverySecret: VaultKeyMaterial, + vaultID: VaultIdentifier, + keyEpoch: UInt32 + ) -> VaultKeyMaterial { + VaultCryptography.deriveKey( + rootKey: recoverySecret, + vaultID: vaultID, + label: "recovery-wrap.epoch.\(keyEpoch)" + ) + } + + private struct BootstrapPayload: Codable { + let rootKey: Data + let remoteLayout: RemoteLayout? + } +} + +enum VaultBase32 { + private static let alphabet = Array("ABCDEFGHIJKLMNOPQRSTUVWXYZ234567") + private static let decodeTable = Dictionary( + uniqueKeysWithValues: alphabet.enumerated().map { ($1, UInt8($0)) } + ) + + static func encode(_ data: Data) -> String { + var accumulator: UInt32 = 0 + var bits = 0 + var result = "" + + for byte in data { + accumulator = (accumulator << 8) | UInt32(byte) + bits += 8 + while bits >= 5 { + bits -= 5 + result.append(alphabet[Int((accumulator >> UInt32(bits)) & 0x1F)]) + } + } + if bits > 0 { + result.append(alphabet[Int((accumulator << UInt32(5 - bits)) & 0x1F)]) + } + return result + } + + static func decode(_ value: String) -> Data? { + var accumulator: UInt32 = 0 + var bits = 0 + var result = Data() + + for character in value { + guard let decoded = decodeTable[character] else { return nil } + accumulator = (accumulator << 5) | UInt32(decoded) + bits += 5 + if bits >= 8 { + bits -= 8 + result.append(UInt8((accumulator >> UInt32(bits)) & 0xFF)) + } + } + if bits > 0, accumulator & ((1 << UInt32(bits)) - 1) != 0 { + return nil + } + return result + } +} diff --git a/PotassiumProviderCore/VaultSQLiteStore.swift b/PotassiumProviderCore/VaultSQLiteStore.swift new file mode 100644 index 0000000..a315f97 --- /dev/null +++ b/PotassiumProviderCore/VaultSQLiteStore.swift @@ -0,0 +1,531 @@ +import CryptoKit +import Foundation +@preconcurrency import SQLite + +public struct VaultStoredJournalObject: Equatable, Sendable { + public let transactionID: UUID + public let objectToken: String + public let remoteFileID: Int? + public let envelope: Data + public let committedAt: Date + + public init( + transactionID: UUID, + objectToken: String, + remoteFileID: Int?, + envelope: Data, + committedAt: Date + ) { + self.transactionID = transactionID + self.objectToken = objectToken + self.remoteFileID = remoteFileID + self.envelope = envelope + self.committedAt = committedAt + } +} + +public struct VaultGarbageCollectionCandidate: Codable, Equatable, Sendable { + public let objectToken: String + public let remoteFileID: Int + public let firstObservedAt: Date + public let observedFrontier: VaultFrontier + + public init( + objectToken: String, + remoteFileID: Int, + firstObservedAt: Date, + observedFrontier: VaultFrontier + ) { + self.objectToken = objectToken + self.remoteFileID = remoteFileID + self.firstObservedAt = firstObservedAt + self.observedFrontier = observedFrontier + } +} + +public protocol VaultLocalStateStoring: Sendable { + func item(_ identifier: VaultItemIdentifier) async throws -> VaultItem? + func children(of parentID: VaultItemIdentifier?, trashed: Bool) async throws -> [VaultItem] + func allItems() async throws -> [VaultItem] + func state() async throws -> VaultReducedState + func state(anchorString: String) async throws -> VaultReducedState? + func replace(with state: VaultReducedState) async throws + func save( + state: VaultReducedState, + journalObjects: [VaultStoredJournalObject] + ) async throws + func journalObjects() async throws -> [VaultStoredJournalObject] + func garbageCollectionCandidates() async throws -> [VaultGarbageCollectionCandidate] + func replaceGarbageCollectionCandidates( + _ candidates: [VaultGarbageCollectionCandidate] + ) async throws + func removeAll() async throws +} + +public enum VaultLocalStoreError: Error, Equatable, LocalizedError, Sendable { + case invalidStoredIdentifier(String) + case missingState + + public var errorDescription: String? { + switch self { + case .invalidStoredIdentifier: + return "The local encrypted vault index contains an invalid item identifier." + case .missingState: + return "The local encrypted vault index is missing its state record." + } + } +} + +/// A generation-based logical index. Item payloads and the frontier are AEAD +/// encrypted even though this database lives only on the trusted endpoint. +public actor VaultSQLiteStore: VaultLocalStateStoring { + private let database: Connection + private let domainIdentifier: String + private let vaultID: VaultIdentifier + private let rootKey: VaultKeyMaterial + private let keyEpoch: UInt32 + + public init( + databaseURL: URL, + domainIdentifier: String, + vaultID: VaultIdentifier, + rootKey: VaultKeyMaterial, + keyEpoch: UInt32 = VaultFormat.currentKeyEpoch + ) throws { + try FileManager.default.createDirectory( + at: databaseURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let database = try Connection(databaseURL.path) + try Self.configure(database) + try Self.createTables(database) + self.database = database + self.domainIdentifier = domainIdentifier + self.vaultID = vaultID + self.rootKey = rootKey + self.keyEpoch = keyEpoch + } + + public init( + appGroupIdentifier: String = ProviderConstants.appGroupIdentifier, + domainIdentifier: String, + vaultID: VaultIdentifier, + rootKey: VaultKeyMaterial, + keyEpoch: UInt32 = VaultFormat.currentKeyEpoch + ) throws { + guard let containerURL = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: appGroupIdentifier + ) else { + throw DomainConfigurationStoreError.missingAppGroupContainer(appGroupIdentifier) + } + try self.init( + databaseURL: containerURL.appendingPathComponent("EncryptedVaults.sqlite3"), + domainIdentifier: domainIdentifier, + vaultID: vaultID, + rootKey: rootKey, + keyEpoch: keyEpoch + ) + } + + public func item(_ identifier: VaultItemIdentifier) throws -> VaultItem? { + guard let generation = try activeGeneration() else { return nil } + let query = Schema.items.filter( + Schema.domain == domainIdentifier && + Schema.generation == generation && + Schema.itemID == identifier.rawValue.uuidString + ).limit(1) + guard let row = try database.pluck(query) else { return nil } + return try decodeItem(row[Schema.envelope], identifier: identifier) + } + + public func children( + of parentID: VaultItemIdentifier?, + trashed: Bool + ) throws -> [VaultItem] { + guard let generation = try activeGeneration() else { return [] } + let parent = parentID?.rawValue.uuidString ?? "" + let query = Schema.items.filter( + Schema.domain == domainIdentifier && + Schema.generation == generation && + Schema.parentID == parent && + Schema.isTrashed == trashed + ).order(Schema.itemID.asc) + return try database.prepare(query).map { row in + let identifier = try itemIdentifier(row[Schema.itemID]) + return try decodeItem(row[Schema.envelope], identifier: identifier) + } + } + + public func allItems() throws -> [VaultItem] { + guard let generation = try activeGeneration() else { return [] } + let query = Schema.items.filter( + Schema.domain == domainIdentifier && + Schema.generation == generation + ).order(Schema.itemID.asc) + return try database.prepare(query).map { row in + let identifier = try itemIdentifier(row[Schema.itemID]) + return try decodeItem(row[Schema.envelope], identifier: identifier) + } + } + + public func state() throws -> VaultReducedState { + guard let generation = try activeGeneration(), + let stateRow = try database.pluck(Schema.generations.filter( + Schema.domain == domainIdentifier && + Schema.generation == generation + )) else { + return VaultReducedState() + } + return try state(generation: generation, stateRow: stateRow) + } + + public func state(anchorString: String) throws -> VaultReducedState? { + let query = Schema.generations.filter( + Schema.domain == domainIdentifier + ).order(Schema.generation.desc) + for row in try database.prepare(query) { + let stored = try decodeGenerationState(row[Schema.stateEnvelope]) + guard stored.frontier.anchorString == anchorString else { + continue + } + return try state( + generation: row[Schema.generation], + stateRow: row + ) + } + return nil + } + + private func state( + generation: Int64, + stateRow: Row + ) throws -> VaultReducedState { + let items = try items(generation: generation) + let stored = try decodeGenerationState(stateRow[Schema.stateEnvelope]) + return VaultReducedState( + items: Dictionary(uniqueKeysWithValues: items.map { ($0.id, $0) }), + frontier: stored.frontier, + appliedTransactionIDs: Set(stored.appliedTransactionIDs), + conflicts: stored.conflicts + ) + } + + public func replace(with state: VaultReducedState) throws { + try database.transaction { + try writeGeneration(state) + } + } + + public func save( + state: VaultReducedState, + journalObjects: [VaultStoredJournalObject] + ) throws { + try database.transaction { + for journalObject in journalObjects { + try database.run( + Schema.journal.insert(or: .ignore, + Schema.domain <- domainIdentifier, + Schema.transactionID <- journalObject.transactionID.uuidString, + Schema.objectToken <- journalObject.objectToken, + Schema.remoteFileID <- journalObject.remoteFileID.map(Int64.init), + Schema.envelope <- Blob(bytes: [UInt8](journalObject.envelope)), + Schema.committedAt <- journalObject.committedAt.timeIntervalSince1970 + ) + ) + } + try writeGeneration(state) + } + } + + public func journalObjects() throws -> [VaultStoredJournalObject] { + let query = Schema.journal.filter( + Schema.domain == domainIdentifier + ).order(Schema.transactionID.asc) + return try database.prepare(query).map { row in + guard let transactionID = UUID(uuidString: row[Schema.transactionID]) else { + throw VaultLocalStoreError.invalidStoredIdentifier(row[Schema.transactionID]) + } + return VaultStoredJournalObject( + transactionID: transactionID, + objectToken: row[Schema.objectToken], + remoteFileID: row[Schema.remoteFileID].map(Int.init), + envelope: Data(row[Schema.envelope].bytes), + committedAt: Date(timeIntervalSince1970: row[Schema.committedAt]) + ) + } + } + + public func garbageCollectionCandidates() throws -> [VaultGarbageCollectionCandidate] { + let query = Schema.garbageCollection.filter( + Schema.domain == domainIdentifier + ).order(Schema.objectToken.asc) + return try database.prepare(query).map { row in + let token = row[Schema.objectToken] + return try VaultCryptography.open( + VaultGarbageCollectionCandidate.self, + envelope: Data(row[Schema.envelope].bytes), + expectedRole: .localState, + expectedObjectToken: localToken("garbage-collection:\(token)"), + rootKey: rootKey, + vaultID: vaultID, + keyEpoch: keyEpoch + ) + } + } + + public func replaceGarbageCollectionCandidates( + _ candidates: [VaultGarbageCollectionCandidate] + ) throws { + try database.transaction { + try database.run(Schema.garbageCollection.filter( + Schema.domain == domainIdentifier + ).delete()) + for candidate in candidates.sorted(by: { + $0.objectToken < $1.objectToken + }) { + let envelope = try VaultCryptography.seal( + candidate, + role: .localState, + objectToken: localToken( + "garbage-collection:\(candidate.objectToken)" + ), + rootKey: rootKey, + vaultID: vaultID, + keyEpoch: keyEpoch + ) + try database.run( + Schema.garbageCollection.insert( + Schema.domain <- domainIdentifier, + Schema.objectToken <- candidate.objectToken, + Schema.envelope <- Blob(bytes: [UInt8](envelope)) + ) + ) + } + } + } + + public func removeAll() throws { + try database.transaction { + try database.run(Schema.items.filter(Schema.domain == domainIdentifier).delete()) + try database.run(Schema.generations.filter(Schema.domain == domainIdentifier).delete()) + try database.run(Schema.heads.filter(Schema.domain == domainIdentifier).delete()) + try database.run(Schema.journal.filter(Schema.domain == domainIdentifier).delete()) + try database.run( + Schema.garbageCollection.filter( + Schema.domain == domainIdentifier + ).delete() + ) + } + } + + private func writeGeneration(_ state: VaultReducedState) throws { + let nextGeneration = (try activeGeneration() ?? 0) + 1 + let generationState = StoredGenerationState( + frontier: state.frontier, + appliedTransactionIDs: state.appliedTransactionIDs.sorted { + $0.uuidString < $1.uuidString + }, + conflicts: state.conflicts + ) + let stateEnvelope = try encodeGenerationState(generationState) + try database.run( + Schema.generations.insert( + Schema.domain <- domainIdentifier, + Schema.generation <- nextGeneration, + Schema.stateEnvelope <- Blob(bytes: [UInt8](stateEnvelope)), + Schema.createdAt <- Date().timeIntervalSince1970 + ) + ) + + for item in state.items.values.sorted(by: { + $0.id.rawValue.uuidString < $1.id.rawValue.uuidString + }) { + let envelope = try encodeItem(item) + try database.run( + Schema.items.insert( + Schema.domain <- domainIdentifier, + Schema.generation <- nextGeneration, + Schema.itemID <- item.id.rawValue.uuidString, + Schema.parentID <- item.parentID?.rawValue.uuidString ?? "", + Schema.isTrashed <- item.isTrashed, + Schema.envelope <- Blob(bytes: [UInt8](envelope)) + ) + ) + } + + try database.run( + Schema.heads.insert(or: .replace, + Schema.domain <- domainIdentifier, + Schema.generation <- nextGeneration + ) + ) + try database.run(Schema.items.filter( + Schema.domain == domainIdentifier && + Schema.generation <= nextGeneration - Self.retainedGenerationCount + ).delete()) + try database.run(Schema.generations.filter( + Schema.domain == domainIdentifier && + Schema.generation <= nextGeneration - Self.retainedGenerationCount + ).delete()) + } + + private func activeGeneration() throws -> Int64? { + try database.pluck(Schema.heads.filter( + Schema.domain == domainIdentifier + ))?[Schema.generation] + } + + private func items(generation: Int64) throws -> [VaultItem] { + let query = Schema.items.filter( + Schema.domain == domainIdentifier && + Schema.generation == generation + ).order(Schema.itemID.asc) + return try database.prepare(query).map { row in + let identifier = try itemIdentifier(row[Schema.itemID]) + return try decodeItem(row[Schema.envelope], identifier: identifier) + } + } + + private func encodeItem(_ item: VaultItem) throws -> Data { + try VaultCryptography.seal( + item, + role: .localState, + objectToken: localToken("item:\(item.id.rawValue.uuidString)"), + rootKey: rootKey, + vaultID: vaultID, + keyEpoch: keyEpoch + ) + } + + private func decodeItem( + _ blob: Blob, + identifier: VaultItemIdentifier + ) throws -> VaultItem { + try VaultCryptography.open( + VaultItem.self, + envelope: Data(blob.bytes), + expectedRole: .localState, + expectedObjectToken: localToken("item:\(identifier.rawValue.uuidString)"), + rootKey: rootKey, + vaultID: vaultID, + keyEpoch: keyEpoch + ) + } + + private func encodeGenerationState(_ state: StoredGenerationState) throws -> Data { + try VaultCryptography.seal( + state, + role: .localState, + objectToken: localToken("generation-state"), + rootKey: rootKey, + vaultID: vaultID, + keyEpoch: keyEpoch + ) + } + + private func decodeGenerationState(_ blob: Blob) throws -> StoredGenerationState { + try VaultCryptography.open( + StoredGenerationState.self, + envelope: Data(blob.bytes), + expectedRole: .localState, + expectedObjectToken: localToken("generation-state"), + rootKey: rootKey, + vaultID: vaultID, + keyEpoch: keyEpoch + ) + } + + private func localToken(_ purpose: String) -> String { + let digest = Data(SHA256.hash(data: Data( + "local:\(domainIdentifier):\(purpose)".utf8 + ))) + return Data(digest.prefix(20)).vaultBase64URLEncodedString() + } + + private func itemIdentifier(_ value: String) throws -> VaultItemIdentifier { + guard let uuid = UUID(uuidString: value) else { + throw VaultLocalStoreError.invalidStoredIdentifier(value) + } + return VaultItemIdentifier(rawValue: uuid) + } + + private static func configure(_ database: Connection) throws { + try database.execute("PRAGMA journal_mode = WAL") + try database.execute("PRAGMA foreign_keys = ON") + try database.execute("PRAGMA busy_timeout = 5000") + } + + private static func createTables(_ database: Connection) throws { + try database.run(Schema.heads.create(ifNotExists: true) { table in + table.column(Schema.domain, primaryKey: true) + table.column(Schema.generation) + }) + try database.run(Schema.generations.create(ifNotExists: true) { table in + table.column(Schema.domain) + table.column(Schema.generation) + table.column(Schema.stateEnvelope) + table.column(Schema.createdAt) + table.primaryKey(Schema.domain, Schema.generation) + }) + try database.run(Schema.items.create(ifNotExists: true) { table in + table.column(Schema.domain) + table.column(Schema.generation) + table.column(Schema.itemID) + table.column(Schema.parentID) + table.column(Schema.isTrashed) + table.column(Schema.envelope) + table.primaryKey(Schema.domain, Schema.generation, Schema.itemID) + }) + try database.run(Schema.items.createIndex( + Schema.domain, + Schema.generation, + Schema.parentID, + Schema.isTrashed, + ifNotExists: true + )) + try database.run(Schema.journal.create(ifNotExists: true) { table in + table.column(Schema.domain) + table.column(Schema.transactionID) + table.column(Schema.objectToken) + table.column(Schema.remoteFileID) + table.column(Schema.envelope) + table.column(Schema.committedAt) + table.primaryKey(Schema.domain, Schema.transactionID) + }) + try database.run(Schema.garbageCollection.create(ifNotExists: true) { table in + table.column(Schema.domain) + table.column(Schema.objectToken) + table.column(Schema.envelope) + table.primaryKey(Schema.domain, Schema.objectToken) + }) + } + + private struct StoredGenerationState: Codable { + let frontier: VaultFrontier + let appliedTransactionIDs: [UUID] + let conflicts: [VaultConflict] + } + + private enum Schema { + static let heads = Table("vault_heads_v1") + static let generations = Table("vault_generations_v1") + static let items = Table("vault_items_v1") + static let journal = Table("vault_journal_v1") + static let garbageCollection = Table("vault_gc_candidates_v1") + + static let domain = Expression("domain_identifier") + static let generation = Expression("generation") + static let itemID = Expression("item_identifier") + static let parentID = Expression("parent_identifier") + static let isTrashed = Expression("is_trashed") + static let envelope = Expression("envelope") + static let stateEnvelope = Expression("state_envelope") + static let createdAt = Expression("created_at") + static let transactionID = Expression("transaction_identifier") + static let objectToken = Expression("object_token") + static let remoteFileID = Expression("remote_file_identifier") + static let committedAt = Expression("committed_at") + } + + private static let retainedGenerationCount: Int64 = 4 +} diff --git a/README.md b/README.md index 6edca12..63e187d 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,12 @@ data. state, validation, and remaining manual release gates. - [Architecture](doc/ARCHITECTURE.md): targets, modules, persistence, runtime boundaries, and high-level data flow. +- [Encrypted Vault Format v1](doc/ENCRYPTED_VAULT.md): threat model, leakage, + key custody, binary formats, opaque synchronization, rollback behavior, and + the security-review feature gate. +- [Encrypted Vault Migration](doc/ENCRYPTED_VAULT_MIGRATION.md): resumable + encrypted migration journal, verification-before-purge invariant, and + Desktop/Documents cutover. - [App And Domains](doc/APP_AND_DOMAINS.md): SwiftUI setup app, kDrive loading, File Provider domain registration, and macOS Desktop & Documents controls. - [Authentication](doc/AUTHENTICATION.md): OAuth PKCE, manual token entry, @@ -139,6 +145,8 @@ local Xcode requires a more specific variant. - Do not commit bearer tokens, refresh tokens, account identifiers, private links, or user data. +- Encrypted vaults are experimental and disabled by default pending independent + cryptographic review. The feature flag is not a production-readiness claim. - The current conflict handling delegates many decisions to kDrive. Read [Conflicts](doc/CONFLICTS.md) before relying on it for important files. - SQLite snapshots cache metadata only. File contents and thumbnails are not diff --git a/doc/APP_AND_DOMAINS.md b/doc/APP_AND_DOMAINS.md index 04631d2..a3eee33 100644 --- a/doc/APP_AND_DOMAINS.md +++ b/doc/APP_AND_DOMAINS.md @@ -237,3 +237,12 @@ Manual access tokens are accepted for development and testing. Each manual token creates an independent local account and is saved in the same account-scoped token store as OAuth tokens. A manually entered token may not have a refresh token or expiration, so reconnecting may be required when it stops working. + +## Encrypted vault domains + +When the security-review feature flag is enabled, drive management offers +Create Encrypted Vault and Open Existing Vault. Creation shows a one-time text +and QR recovery kit and requires exact confirmation before saving the device +key or registering the domain. Existing plaintext domains remain separately +registered migration sources. Normal removal/logout retains vault keys; the +separate Forget Key workflow requires the matching recovery kit. diff --git a/doc/ARCHITECTURE.md b/doc/ARCHITECTURE.md index 7681155..a5cd0e7 100644 --- a/doc/ARCHITECTURE.md +++ b/doc/ARCHITECTURE.md @@ -80,3 +80,12 @@ through Apple's extension context. It is useful for comparing concepts such as enumeration, domain state, and conflict handling, but it is not integrated into `potassiumProvider.xcodeproj` and should not be treated as part of this product's build graph. + +## Encrypted vault boundary + +For `opaqueVaultV1`, runtime loading adds the root key and trusted frontier from +Keychain, an encrypted UUID-keyed SQLite generation, and +`KDriveObjectStoreProviding` plus `EncryptedVaultProviding`. Provider-facing +code receives only `VaultItem`; `KDriveRemoteItem` remains physical-object +metadata and cannot construct Finder metadata. See +[Encrypted Vault Format v1](ENCRYPTED_VAULT.md). diff --git a/doc/AUTHENTICATION.md b/doc/AUTHENTICATION.md index 355631e..4096d5c 100644 --- a/doc/AUTHENTICATION.md +++ b/doc/AUTHENTICATION.md @@ -56,6 +56,11 @@ fixed local legacy account, copies the old token into `oauthToken:legacy-account deletes the old single-token key, and rewrites legacy domain configuration JSON with the account identifier. +Encrypted vault root keys use a separate Keychain service and accounts keyed by +vault UUID. They use the shared access group, Data Protection Keychain, +`AfterFirstUnlockThisDeviceOnly`, and no synchronization. The recovery secret is +not stored. See [Encrypted Vault Format v1](ENCRYPTED_VAULT.md). + ## Manual Access Token Path The app also supports a manual access token. This creates a token value with: diff --git a/doc/CONFLICTS.md b/doc/CONFLICTS.md index 272e565..a0c4cd8 100644 --- a/doc/CONFLICTS.md +++ b/doc/CONFLICTS.md @@ -351,3 +351,10 @@ listing state, and stale existing-item mutations. The provider is still not a full conflict-safe sync engine until pending operations, broader staged uploads, explicit create collision handling, stronger server version tokens, and local name-collision policy are in place. + +Those limitations describe legacy plaintext domains. Encrypted domains replay +an authenticated transaction DAG in canonical order, preserve concurrent +content edits as deterministic conflict copies, merge independent +content/metadata changes, reject stale deletion, protect concurrent children, +and retain sibling collisions with deterministic suffixes. Encrypted conflict +rows are opaque. See [Encrypted Vault Format v1](ENCRYPTED_VAULT.md). diff --git a/doc/CONTEXTUAL_ACTIONS.md b/doc/CONTEXTUAL_ACTIONS.md index 6e59b1d..68f7229 100644 --- a/doc/CONTEXTUAL_ACTIONS.md +++ b/doc/CONTEXTUAL_ACTIONS.md @@ -90,3 +90,8 @@ favorite, duplicate, trash restore, share-link CRUD, version pagination, and version restore. `PotassiumKDriveService` implements it exclusively with typed PotassiumKDrive 0.2.0 service calls. Existing `KDriveFileProviding` mutation semantics remain unchanged. + +For encrypted items, favorite, duplicate, trash restore, and logical version +restore call `EncryptedVaultProviding`. Thumbnails and versions are local +authenticated vault operations. Share-link panels stop before any kDrive +sharing call and explain that recipient-key sharing is not supported in v1. diff --git a/doc/ENCRYPTED_VAULT.md b/doc/ENCRYPTED_VAULT.md new file mode 100644 index 0000000..e9dd5ec --- /dev/null +++ b/doc/ENCRYPTED_VAULT.md @@ -0,0 +1,262 @@ +# Encrypted Vault Format v1 + +Status: implemented behind `EncryptedVaultsEnabled`; **not approved for +production use**. Independent cryptographic review and resolution of all +high-severity findings are release gates. This document is the versioned format +specification for format version 1. + +## Security boundary + +An encrypted domain is an opaque client-side vault. Finder and Files receive +decrypted logical items from the trusted Apple endpoint. kDrive receives only +random physical container names, random object tokens, `.bin` ciphertext +objects, `application/octet-stream`, server-required account/drive identifiers, +and transfer protocol fields. + +The following logical data is encrypted: content, names, paths, extensions, +MIME/UTI data, parent relationships, dates, exact sizes, favorites, logical +trash, logical versions, thumbnails, device identifiers, conflict information, +and Desktop/Documents namespace names. + +The server can still observe: + +- the account and drive used by OAuth/API traffic; +- vault existence, random physical containers, object counts, padded + ciphertext sizes, server timestamps, quota use, and deletion; +- request timing, IP/network metadata, access patterns, and which opaque object + is fetched; +- denial of service, omission, and rollback attempts. + +AES-GCM authenticates corruption, modification, substitution, object-role +swaps, vault swaps, and epoch swaps. A returning device stores its last trusted +frontier in the Keychain and rejects a state that omits that frontier. A new +device cannot detect history hidden before its first trusted checkpoint without +an independent external witness. + +Because journal compaction is disabled in v1, a returning device also requires +every remote journal object in its local trusted cache to remain present in a +complete server listing. It never fills an omitted server listing from cache +and silently calls the result current. + +Plaintext necessarily exists on an unlocked trusted endpoint. File Provider may +materialize content and metadata locally, and Spotlight may index the visible +working set. That is required for transparent Finder/Files behavior and is +outside the server-side threat model. + +## Key hierarchy and custody + +Each vault has an independent random 256-bit vault root key. HKDF-SHA-256 uses: + +```text +salt = vault UUID bytes || object-specific salt +info = UTF-8("net.weavee.potassiumProvider.vault." || label) +``` + +Labels are domain separated by object role and key epoch. Content-key wrapping +uses `content-wrap.epoch.`. Encrypted object envelopes use +`object..epoch.`. Local SQLite and migration records use the +local-state role and are independently authenticated. + +Every content revision has a fresh random 256-bit data-encryption key, random +object token, random content revision, and random 64-bit frame nonce prefix. +Plaintext hashes are never used for naming or deduplication. + +The unwrapped root key is stored per device as a generic-password item with: + +- the shared application access group; +- `kSecUseDataProtectionKeychain = true`; +- synchronization disabled; +- `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`. + +This permits background File Provider work after the device's first unlock and +prevents key migration through backups. A locked-before-first-unlock or missing +key maps to `NSFileProviderError.notAuthenticated`. Invalid authentication +tags, malformed formats, and rollback map to `.cannotSynchronize`. An unlock +failure never initializes, overwrites, or deletes remote state. + +Normal domain removal, logout, uninstall, full logout, and hard purge retain +vault keys and trusted frontiers. “Forget Key on This Device” is separate, +requires the matching recovery kit, deletes only the root key, and retains the +trusted rollback frontier. + +## Recovery kit and bootstrap + +A separate random 256-bit recovery secret derives the bootstrap wrapping key. +Only an AES-256-GCM-wrapped root key and encrypted remote layout are uploaded. +The recovery secret never leaves the setup/recovery UI. + +The recovery kit is grouped, checksummed Base32 beginning with `KPV1`. Its +payload contains: + +```text +magic "KPR1" +format version (u16, big endian) +vault UUID (16 bytes) +drive ID (signed value encoded in u64, big endian) +physical vault-root file ID (u64) +bootstrap file ID (u64) +recovery secret (32 bytes) +SHA-256 checksum prefix (5 bytes) +``` + +The app displays text and a locally generated QR code once. The user must paste +the complete kit back before the root key is committed and the File Provider +domain is registered. Opening an existing vault downloads and authenticates the +bootstrap and initial checkpoint before saving the key. + +Recovery rotation uploads a new bootstrap wrapped by a new recovery secret and +requires confirmation of the new kit. It does **not** revoke old server +versions, backups, or an old bootstrap that still wraps the same root key. +Revoking a lost device requires a fresh root-key epoch and re-encryption of all +reachable content. The supported safe rekey model is a replacement vault plus +the verified migration state machine; destruction of the old vault is a +separate purge decision. + +Loss of every device key and the recovery kit is intentionally unrecoverable. + +## Physical layout + +A vault root is a random 20-byte Base64URL token. Beneath it are random content, +journal, and checkpoint containers. Files are named +`.bin` and transferred as +`application/octet-stream`. Directory names are also random tokens. + +The object-store adapter accepts no logical name, path, type, date, hash, or +device name. Uploads use URLSession file-backed upload tasks and downloads use +file tasks. An opaque token is also the kDrive client token and uploads request +conflict-as-error behavior, making retry lookup idempotent. + +## General authenticated envelope + +All integers are big endian: + +```text +"KPE1" (4) +format version u16 +object role u8 +key epoch u32 +vault UUID (16) +object token (20) +AES-GCM combined value: + random nonce (12) + ciphertext (variable) + authentication tag (16) +``` + +The entire 47-byte header is associated data. The key is derived for the role, +epoch, vault, and object token. Metadata payloads use sorted-key JSON with +milliseconds-since-1970 dates. Decoders reject unsupported versions, incorrect +roles, vaults, tokens, epochs, lengths, and malformed decrypted values. + +## Content format + +Content is independently authenticated in 1 MiB frames: + +```text +"KPC1" (4) +format version u16 +frame size u32 (= 1,048,576) +random nonce prefix u64 +repeat: + ciphertext-plus-tag length u32 + ciphertext + AES-GCM tag (16) +``` + +The 96-bit frame nonce is `prefix-u64 || frame-index-u32`. Associated data binds +the magic/version, epoch, vault UUID, logical item UUID, random content revision, +object token, frame index, padded length, and final-frame flag. Files exceeding +the 32-bit frame-index space are rejected before transfer. + +The final frame is padded with random bytes to the smallest power-of-two bucket +from 4 KiB through 1 MiB. Empty files have one 4 KiB encrypted frame. The exact +plaintext length and SHA-256 digest exist only inside authenticated encrypted +item metadata. Decryption writes only to a protected temporary file, verifies +every tag, total length, frame count, padding bucket, absence of trailing data, +and final digest, and removes partial plaintext on any failure or cancellation. + +## Logical model and synchronization + +Logical identifiers are random UUIDs unrelated to kDrive IDs. File Provider +identifiers are `ev1:`. A `VaultItem` contains encrypted +parent UUID, filename, type, dates, exact size, favorite/trash flags, content +and metadata revision digests, wrapped content key, opaque blob reference, and +logical versions. + +Each mutation is an immutable 64 KiB encrypted transaction. It contains a +random transaction UUID, causal parent frontier, random per-vault device UUID, +base item/revisions, and exactly one upsert/trash/restore/purge operation. +Content ciphertext uploads first; publishing the transaction is the visibility +point. Uncommitted ciphertext is never enumerated. + +Clients topologically sort the DAG and use transaction UUID ordering for a +deterministic replay: + +- concurrent content edits keep the deterministic winner at the original UUID + and synthesize a stable conflict-copy UUID/name for each loser; +- independent content and metadata changes merge; +- concurrent metadata conflicts use canonical transaction order and emit an + opaque conflict record; +- stale delete loses to edit; +- folder deletion loses to a concurrent visible child; +- sibling name collisions keep both using deterministic conflict suffixes. + +The local generation-based SQLite index stores UUID keys and frontiers. Item +records and generation state are encrypted even on the trusted endpoint. Four +complete encrypted generations are retained so File Provider change +enumeration is computed from the caller's requested frontier, not from whatever +snapshot another enumerator most recently loaded. An older unknown frontier +returns `syncAnchorExpired`; moves and trash transitions are emitted as item +updates, while only logical purge is emitted as deletion. +Provider activity/conflict rows for encrypted domains contain only opaque item +identifiers and fixed summaries; display names are resolved from the unlocked +vault. + +Thumbnails are generated locally only after authenticated decryption. Encrypted +domains never call kDrive thumbnail, preview, latest, favorites, shared, +activity, or native-version endpoints. Favorite, duplicate (copy-on-write), +trash, restore, purge, and logical version restore are vault transactions. +Native kDrive share links are disabled with an explicit recipient-key-sharing +message. + +## Checkpoints, rollback, and collection + +Encrypted checkpoints contain the reconstructed logical index, causal frontier, +and a Merkle root over compacted transactions. The current implementation +retains immutable transaction objects and therefore always validates returning +frontiers directly. Merkle proof construction/verification is implemented and +tested; each leaf binds both the authenticated transaction digest and its UUID +so an inclusion path cannot be relabeled for another frontier entry. Remote +journal deletion remains deliberately disabled until immutable Merkle-node +retrieval and independent review are complete. + +Maintenance first synchronizes the complete journal, uploads a new random +immutable checkpoint, and downloads and authenticates it. An unreferenced +content object then becomes an encrypted local garbage-collection candidate; +the server timestamp is not trusted as its age. Deletion is possible only after +the candidate remains unreferenced through a later synchronized, authenticated +checkpoint beyond the configured retention window. Candidate receipts are +encrypted in the local vault SQLite store. Defaults retain at least 10 versions +and 30 days. + +This conservative boundary means the first reviewed release may use more quota, +but cannot destroy journal ancestry merely because a server cursor or timestamp +is misleading. + +## Product limitations + +kDrive web preview, server search, server malware inspection, native sharing, +and kDrive logical version history cannot operate on ciphertext. Encrypted +Desktop and Documents are logical folders; their plaintext names and Mac +namespace occur only in encrypted transactions. + +The feature flag defaults off: + +```sh +defaults write net.weavee.potassiumProvider EncryptedVaultsEnabled -bool YES +``` + +Enabling it is for development and security review, not a confidentiality +claim. The rollout gate is: format/crypto tests, read-only prototype, +single-device mutation testing, multi-device conflict testing, migration pilot, +independent security audit, then explicit default enablement. diff --git a/doc/ENCRYPTED_VAULT_MIGRATION.md b/doc/ENCRYPTED_VAULT_MIGRATION.md new file mode 100644 index 0000000..e7b14ea --- /dev/null +++ b/doc/ENCRYPTED_VAULT_MIGRATION.md @@ -0,0 +1,90 @@ +# Encrypted Vault Migration + +Migration never converts a plaintext File Provider domain in place. The source +remains a separately registered legacy domain while a new encrypted domain gets +new logical UUIDs and random physical kDrive objects. + +## Preflight + +Before copying, inventory item and version counts, plaintext bytes, estimated +padding overhead, available quota, inaccessible/shared items, and active +Desktop/Documents ownership. Shared items and historical versions may require +separate access and purge decisions. Do not start a known-folder cutover until +both source and destination can be reconciled. + +The resumable migration journal is itself encrypted with the destination +vault's local-state key. Each source item moves monotonically through: + +```text +inventoried → encrypted → uploaded → committed → verified → source-purged +``` + +The record uses an opaque source identifier and stable destination UUID. +Ciphertext staging details, logical names, revisions, and digests are inside the +encrypted journal. A missing local ciphertext stage causes safe re-download and +re-encryption. Opaque upload tokens make retries idempotent. A transaction +commit retried after an interrupted local journal update is an idempotent +base-less upsert of the same logical item. + +## Copy and verification + +For a file: + +1. Download source plaintext to a File Provider/protected temporary file. +2. Stream-encrypt it into authenticated frames and immediately remove plaintext. +3. Upload randomized ciphertext. +4. Publish its encrypted logical transaction. +5. Download/decrypt the committed object to a separate protected temporary + file. +6. Compare exact size and SHA-256 against the authenticated staged result. +7. Mark the record verified. + +The coordinator checks the source revision before staging, before commit, after +the authenticated destination round-trip, and immediately before a separately +confirmed source purge. A changed source raises `sourceChanged`; the caller +re-inventories and recopies from the new base. Directories are committed before +their children and are marked verified only after their immutable transaction +is observed back through a complete journal synchronization. + +The copy/resume API has no source-deletion path. `purgeVerifiedSource` is a +separate explicit operation, refuses every state except `verified`, and +revalidates the exact source revision. Therefore source deletion cannot precede +an authenticated round-trip or erase an edit made after verification. + +## Desktop and Documents + +Known-folder migration must: + +1. pause/coordinate source ownership; +2. copy the initial tree; +3. reconcile a final source delta; +4. claim the encrypted logical `Private//Desktop` and `Documents`; +5. verify local availability through File Provider; +6. only then offer source purge. + +The encrypted-domain known-folder resolver creates these names as logical vault +transactions. No physical `Private/` path is sent to kDrive. + +## Plaintext purge + +Purge is separately confirmed and best effort. It should disable reachable +share links, remove accessible versions, trash and permanently delete live +items, and then re-inventory to report anything still reachable. kDrive APIs may +not expose server backups or every historical copy. + +Migration cannot retroactively hide prior server observations, backups, deleted +versions, external shares, downloaded copies, or recipient copies. Recovery +rotation does not revoke old root-key wrappers. Lost-device revocation requires +a replacement root-key epoch, complete verified re-encryption, and explicit +purge of the old vault where possible. + +## Failure policy + +- Quota exhaustion pauses without changing or deleting the source. +- Verification failure keeps both source and ciphertext for diagnosis/retry. +- Cancellation removes partial plaintext and leaves the last durable journal + state. +- Uncommitted ciphertext remains invisible and becomes eligible for + checkpoint-covered garbage collection after retention. +- Source purge failures remain visible as `verified`, never as + `source-purged`. diff --git a/doc/FILE_PROVIDER_CLEANUP.md b/doc/FILE_PROVIDER_CLEANUP.md index 62b563f..7414255 100644 --- a/doc/FILE_PROVIDER_CLEANUP.md +++ b/doc/FILE_PROVIDER_CLEANUP.md @@ -98,6 +98,12 @@ key if it still exists. - may use the File Provider remove-all fallback if domain listing failed and targeted removal by saved configuration also fails. +All reset modes deliberately retain encrypted-vault root keys, device +identities, and trusted rollback frontiers in the Data Protection Keychain. +Deleting account/OAuth credentials is not authorization to make an encrypted +vault unrecoverable. Use the app's separate “Forget Key on This Device” action, +which requires the matching recovery kit. + ## Safety Boundary The cleanup script does not delete remote kDrive files. diff --git a/doc/KDRIVE_API_MAPPING.md b/doc/KDRIVE_API_MAPPING.md index 3e5c467..7de5330 100644 --- a/doc/KDRIVE_API_MAPPING.md +++ b/doc/KDRIVE_API_MAPPING.md @@ -114,3 +114,13 @@ both "invalid" and "cursor". The partial-activity request is batched at 200 identifiers and uses the last durable successful-poll watermark. It includes create, delete, trash, restore, update, rename, move, favorite, and share actions relevant to working-set state. + +## Opaque vault mapping + +Encrypted domains use only random-container create/list, file-backed ciphertext +upload/download, and physical ciphertext deletion. Upload fields contain a +random `.bin` name, random client token, conflict-as-error, numeric container +ID, byte count, and `application/octet-stream`. Logical names, paths, MIME +types, dates, hashes, device names, favorites, shares, and versions are never +sent through this boundary. Latest/favorite/shared/activity/preview/thumbnail +endpoints are not called for encrypted items. diff --git a/doc/LISTING_AND_VERSIONING.md b/doc/LISTING_AND_VERSIONING.md index 0238596..86bf8b1 100644 --- a/doc/LISTING_AND_VERSIONING.md +++ b/doc/LISTING_AND_VERSIONING.md @@ -226,3 +226,9 @@ Stale content replacement preserves both by uploading the local bytes as a renamed conflict copy. Stale rename, move, trash, and permanent delete requests are blocked before the server is mutated. See [Conflicts](CONFLICTS.md) for the remaining limitations. + +Encrypted domains rebuild enumeration and working-set deltas from decrypted +local vault generations and poll only opaque journal containers. Their +`NSFileProviderItemVersion` values are 32-byte logical content and metadata +revision digests. Logical version history is retained inside encrypted item +records and does not use kDrive version endpoints. diff --git a/doc/MUTATIONS.md b/doc/MUTATIONS.md index 23871b6..7d12aa6 100644 --- a/doc/MUTATIONS.md +++ b/doc/MUTATIONS.md @@ -145,3 +145,11 @@ Normal folder metadata eventually reconciles through advanced listing: Root, working set, and trash reconcile through full legacy listing plus local diff. + +## Encrypted-domain mutations + +Encrypted domains stream content into a new random-key ciphertext object first; +a fixed-size authenticated transaction is published last as the visibility +point. Create, modify, move, rename, favorite, duplicate, trash, restore, purge, +and version restore operate on logical UUID items. Retry uses the opaque object +token for idempotency. See [Encrypted Vault Format v1](ENCRYPTED_VAULT.md). diff --git a/doc/PERSISTENCE.md b/doc/PERSISTENCE.md index c3ff0da..a5924d9 100644 --- a/doc/PERSISTENCE.md +++ b/doc/PERSISTENCE.md @@ -338,3 +338,13 @@ legacy/test-friendly implementation of `KDriveSnapshotStoring`. It is no longer the default store for the app or extension. No migration from old JSON snapshots is currently performed. The file store also honors conditional saves, which keeps tests and fallback callers aligned with SQLite behavior. + +## Encrypted vault state + +Encrypted domains use `EncryptedVaults.sqlite3` generations keyed by logical +UUID strings and authenticated journal frontiers. Item and generation payloads +are AEAD-encrypted with the vault local-state key. The resumable migration +journal is an authenticated encrypted file. Activity and conflict rows retain +only opaque `ev1:` identifiers and fixed summaries. Domain JSON stores +non-secret vault locators, format version, and key epoch, never root or recovery +keys. diff --git a/doc/TESTING_AND_DEVELOPMENT.md b/doc/TESTING_AND_DEVELOPMENT.md index 76bddfa..f60a69e 100644 --- a/doc/TESTING_AND_DEVELOPMENT.md +++ b/doc/TESTING_AND_DEVELOPMENT.md @@ -211,3 +211,13 @@ kDrive account. Do not use customer data. Automated `AsyncOperationLimiter` tests cover the concurrency cap, cancellation while waiting, and permit release after errors. These manual checks cover the Finder presentation and process RSS behavior that unit tests cannot establish. + +## Encrypted vault gates + +Run cryptographic known-answer, envelope/frame tamper, fixed transaction, +randomized DAG replay, Merkle/rollback, streaming cancellation, migration +interruption, recovery, and request-leakage tests before enabling the +development flag. Capture all mocked requests and reject known logical names, +paths, types, dates, hashes, device names, or plaintext bytes. Benchmark 100,000 +items, 10,000 siblings, and multi-gigabyte files. Independent review with all +high-severity findings resolved is required before default enablement. diff --git a/potassiumProvider/FileProviderDomainRegistrar.swift b/potassiumProvider/FileProviderDomainRegistrar.swift index bbc3d90..0f62d85 100644 --- a/potassiumProvider/FileProviderDomainRegistrar.swift +++ b/potassiumProvider/FileProviderDomainRegistrar.swift @@ -9,6 +9,10 @@ protocol ProviderDomainRegistering { func removeDomain(for configuration: ProviderDomainConfiguration) async throws func knownFolderSyncStates() async throws -> [String: ProviderKnownFolderSyncState] func claimKnownFolders(for configuration: ProviderDomainConfiguration, parentFileID: Int) async throws + func claimKnownFolders( + for configuration: ProviderDomainConfiguration, + parentItemIdentifier: String + ) async throws func releaseKnownFolders(for configuration: ProviderDomainConfiguration) async throws func userVisibleRootURL(for configuration: ProviderDomainConfiguration) async throws -> URL func signalWorkingSet(for configuration: ProviderDomainConfiguration) async throws @@ -30,6 +34,13 @@ extension ProviderDomainRegistering { throw ProviderKnownFolderRegistrationError.unsupportedPlatform } + func claimKnownFolders( + for configuration: ProviderDomainConfiguration, + parentItemIdentifier: String + ) async throws { + throw ProviderKnownFolderRegistrationError.unsupportedPlatform + } + func releaseKnownFolders(for configuration: ProviderDomainConfiguration) async throws { throw ProviderKnownFolderRegistrationError.unsupportedPlatform } @@ -102,12 +113,28 @@ struct FileProviderDomainRegistrar: ProviderDomainRegistering { } func claimKnownFolders(for configuration: ProviderDomainConfiguration, parentFileID: Int) async throws { + #if os(macOS) + try await claimKnownFolders( + for: configuration, + parentItemIdentifier: KDriveItemIdentifier.item(parentFileID).rawValue + ) + #else + throw ProviderKnownFolderRegistrationError.unsupportedPlatform + #endif + } + + func claimKnownFolders( + for configuration: ProviderDomainConfiguration, + parentItemIdentifier: String + ) async throws { #if os(macOS) let manager = try await manager(for: configuration) - let locations = Self.makeKnownFolderLocations(parentFileID: parentFileID) - let reason = "Keep your Desktop & Documents in sync with \(configuration.displayName) in kDrive." + let locations = Self.makeKnownFolderLocations( + parentItemIdentifier: NSFileProviderItemIdentifier(parentItemIdentifier) + ) + let reason = "Keep your Desktop & Documents in sync with \(configuration.displayName)." - Self.logger.info("claim known folders for domain(\(configuration.domainIdentifier, privacy: .public)) parentFileID(\(parentFileID, privacy: .public))") + Self.logger.info("claim known folders for domain(\(configuration.domainIdentifier, privacy: .public))") try await manager.claimKnownFolders(locations, localizedReason: reason) Self.logger.info("claimed known folders for domain(\(configuration.domainIdentifier, privacy: .public))") #else @@ -158,15 +185,16 @@ struct FileProviderDomainRegistrar: ProviderDomainRegistering { } #if os(macOS) - static func makeKnownFolderLocations(parentFileID: Int) -> NSFileProviderKnownFolderLocations { - let parentIdentifier = NSFileProviderItemIdentifier(KDriveItemIdentifier.item(parentFileID).rawValue) + static func makeKnownFolderLocations( + parentItemIdentifier: NSFileProviderItemIdentifier + ) -> NSFileProviderKnownFolderLocations { let locations = NSFileProviderKnownFolderLocations() locations.desktopLocation = NSFileProviderKnownFolderLocations.Location( - parentItemIdentifier: parentIdentifier, + parentItemIdentifier: parentItemIdentifier, filename: "Desktop" ) locations.documentsLocation = NSFileProviderKnownFolderLocations.Location( - parentItemIdentifier: parentIdentifier, + parentItemIdentifier: parentItemIdentifier, filename: "Documents" ) return locations diff --git a/potassiumProvider/PotassiumProviderAppModel.swift b/potassiumProvider/PotassiumProviderAppModel.swift index 130373b..290fd7c 100644 --- a/potassiumProvider/PotassiumProviderAppModel.swift +++ b/potassiumProvider/PotassiumProviderAppModel.swift @@ -33,6 +33,8 @@ final class PotassiumProviderAppModel: ObservableObject { @Published private(set) var knownFolderTransitionDomainIdentifiers: Set = [] @Published private(set) var activeDriveActions: [ProviderDriveKey: ProviderDriveAction] = [:] @Published private(set) var isReloadingStoredState = false + @Published private(set) var pendingVaultProvisioning: PendingVaultProvisioning? + @Published private(set) var encryptedVaultsEnabled: Bool @Published private(set) var statusMessage: String? @Published var errorMessage: String? @Published var manualAccessToken = "" @@ -48,7 +50,12 @@ final class PotassiumProviderAppModel: ObservableObject { private let snapshotStore: (any KDriveSnapshotStoring)? private let eventStore: (any KDriveProviderEventStoring)? private let fileProviderFactory: (String) -> any KDriveFileProviding + private let objectStoreFactory: (Int, String) -> any KDriveObjectStoreProviding + private let vaultKeyStore: any VaultKeyStoring + private let vaultDeviceIdentityStore: any VaultDeviceIdentityStoring private let computerNameProvider: @Sendable () throws -> String + private var pendingVaultAccountIdentifier: String? + private var pendingVaultDriveName: String? private var automaticallyLoadedDriveAccountIdentifiers: Set = [] private var fileProviderDomainChangeCancellable: AnyCancellable? @@ -65,6 +72,14 @@ final class PotassiumProviderAppModel: ObservableObject { initialDrivesByAccountIdentifier: [String: [KDriveDriveSummary]] = [:], initialDomains: [ProviderDomainConfiguration] = [], fileProviderFactory: @escaping (String) -> any KDriveFileProviding = { PotassiumKDriveService(bearerToken: $0) }, + objectStoreFactory: @escaping (Int, String) -> any KDriveObjectStoreProviding = { + PotassiumKDriveObjectStore(driveID: $0, bearerToken: $1) + }, + vaultKeyStore: (any VaultKeyStoring)? = nil, + vaultDeviceIdentityStore: (any VaultDeviceIdentityStoring)? = nil, + encryptedVaultsEnabled: Bool = UserDefaults.standard.bool( + forKey: ProviderConstants.encryptedVaultFeatureFlag + ), computerNameProvider: @escaping @Sendable () throws -> String = { try KDriveMachineNamespaceName.current() } ) { self.accountStore = accountStore ?? Self.makeDefaultAccountStore() @@ -75,6 +90,15 @@ final class PotassiumProviderAppModel: ObservableObject { self.snapshotStore = snapshotStore ?? Self.makeDefaultSnapshotStore() self.eventStore = eventStore ?? Self.makeDefaultEventStore() self.fileProviderFactory = fileProviderFactory + self.objectStoreFactory = objectStoreFactory + let defaultVaultKeyStore = KeychainVaultKeyStore( + accessGroup: ProviderConstants.keychainAccessGroup + ) + self.vaultKeyStore = vaultKeyStore ?? defaultVaultKeyStore + self.vaultDeviceIdentityStore = vaultDeviceIdentityStore + ?? (vaultKeyStore as? any VaultDeviceIdentityStoring) + ?? defaultVaultKeyStore + self.encryptedVaultsEnabled = encryptedVaultsEnabled self.computerNameProvider = computerNameProvider accounts = initialAccounts drivesByAccountIdentifier = initialDrivesByAccountIdentifier @@ -393,6 +417,160 @@ final class PotassiumProviderAppModel: ObservableObject { await addDomain(accountIdentifier: accountIdentifier) } + /// Creates the randomized remote vault and exposes its one-time recovery + /// kit in memory. No domain is registered and no key is committed to the + /// Keychain until `confirmEncryptedVault` succeeds. + func prepareEncryptedVault( + accountIdentifier: String, + drive: KDriveDriveSummary + ) async { + guard encryptedVaultsEnabled else { + errorMessage = "Encrypted vaults are disabled until the format passes the configured security-review gate." + statusMessage = nil + return + } + guard pendingVaultProvisioning == nil else { + errorMessage = "Finish or cancel the current vault setup first." + return + } + let key = ProviderDriveKey(accountIdentifier: accountIdentifier, driveID: drive.id) + guard beginDriveAction(.addingToFiles, for: key) else { return } + defer { endDriveAction(for: key) } + + do { + let token = try await usableToken(accountIdentifier: accountIdentifier) + let service = VaultProvisioningService( + objectStore: objectStoreFactory(drive.id, token.accessToken), + keyStore: vaultKeyStore + ) + let pending = try await service.prepareNewVault(driveID: drive.id) + pendingVaultAccountIdentifier = accountIdentifier + pendingVaultDriveName = drive.name + pendingVaultProvisioning = pending + errorMessage = nil + statusMessage = "Save the recovery kit and confirm it before the encrypted domain is registered." + } catch { + errorMessage = "Could not prepare the encrypted vault: \(error.localizedDescription)" + statusMessage = nil + } + } + + func confirmEncryptedVault(recoveryKitConfirmation: String) async { + guard let pending = pendingVaultProvisioning, + let accountIdentifier = pendingVaultAccountIdentifier, + let driveName = pendingVaultDriveName else { + errorMessage = "There is no pending encrypted vault to confirm." + return + } + let key = ProviderDriveKey(accountIdentifier: accountIdentifier, driveID: pending.driveID) + guard beginDriveAction(.addingToFiles, for: key) else { return } + defer { endDriveAction(for: key) } + + do { + let token = try await usableToken(accountIdentifier: accountIdentifier) + let service = VaultProvisioningService( + objectStore: objectStoreFactory(pending.driveID, token.accessToken), + keyStore: vaultKeyStore + ) + let vaultConfiguration = try await service.confirm( + pending, + recoveryKitConfirmation: recoveryKitConfirmation + ) + try await registerEncryptedDomain( + accountIdentifier: accountIdentifier, + driveID: pending.driveID, + driveName: driveName, + vaultConfiguration: vaultConfiguration + ) + clearPendingVault() + statusMessage = "Created the encrypted vault and added it to Files." + errorMessage = nil + } catch { + errorMessage = "Could not confirm the encrypted vault: \(error.localizedDescription)" + statusMessage = nil + } + } + + func cancelEncryptedVaultProvisioning() async { + guard let pending = pendingVaultProvisioning, + let accountIdentifier = pendingVaultAccountIdentifier else { + clearPendingVault() + return + } + if let token = try? await usableToken(accountIdentifier: accountIdentifier) { + let service = VaultProvisioningService( + objectStore: objectStoreFactory(pending.driveID, token.accessToken), + keyStore: vaultKeyStore + ) + await service.cancel(pending) + } + clearPendingVault() + statusMessage = "Cancelled encrypted-vault setup." + } + + func openEncryptedVault( + accountIdentifier: String, + drive: KDriveDriveSummary, + recoveryKitText: String + ) async { + guard encryptedVaultsEnabled else { + errorMessage = "Encrypted vaults are disabled until the format passes the configured security-review gate." + return + } + let key = ProviderDriveKey(accountIdentifier: accountIdentifier, driveID: drive.id) + guard beginDriveAction(.addingToFiles, for: key) else { return } + defer { endDriveAction(for: key) } + + do { + let token = try await usableToken(accountIdentifier: accountIdentifier) + let service = VaultProvisioningService( + objectStore: objectStoreFactory(drive.id, token.accessToken), + keyStore: vaultKeyStore + ) + let vaultConfiguration = try await service.openExistingVault( + recoveryKitText: recoveryKitText, + expectedDriveID: drive.id + ) + try await registerEncryptedDomain( + accountIdentifier: accountIdentifier, + driveID: drive.id, + driveName: drive.name, + vaultConfiguration: vaultConfiguration + ) + statusMessage = "Opened the encrypted vault on this device." + errorMessage = nil + } catch { + errorMessage = "Could not open the encrypted vault: \(error.localizedDescription)" + statusMessage = nil + } + } + + /// Normal logout and domain removal deliberately retain the device key. + /// This separate destructive action requires the matching recovery kit and + /// preserves the rollback checkpoint so a later import can still alarm. + func forgetVaultKey( + for configuration: ProviderDomainConfiguration, + recoveryKitConfirmation: String + ) async { + guard let vault = configuration.vault else { + errorMessage = "The selected domain is not an encrypted vault." + return + } + do { + let kit = try VaultRecoveryKit(encoded: recoveryKitConfirmation) + guard kit.vaultID == vault.vaultIdentifier, + kit.driveID == configuration.driveID else { + throw VaultProvisioningError.recoveryConfirmationMismatch + } + try await vaultKeyStore.deleteRootKey(vaultID: vault.vaultIdentifier) + statusMessage = "Forgot this vault key on this device. The recovery kit is required to unlock it again." + errorMessage = nil + } catch { + errorMessage = "Could not forget the vault key: \(error.localizedDescription)" + statusMessage = nil + } + } + func removeDomain(_ configuration: ProviderDomainConfiguration) async { let key = driveKey(for: configuration) guard beginDriveAction(.removingFromFiles, for: key) else { return } @@ -424,33 +602,60 @@ final class PotassiumProviderAppModel: ObservableObject { var namespacedConfiguration = configuration var didClaimKnownFolders = false + var plaintextNamespaceName: String? do { let token = try await usableToken(accountIdentifier: configuration.accountIdentifier) - let remote = fileProviderFactory(token.accessToken) - let privateFileID = try await KDrivePrivateDirectoryResolver.resolveFileID( - driveID: configuration.driveID, - rootFileID: configuration.rootFileID, - remote: remote - ) - let namespace = try await KDriveMachineNamespaceResolver.resolveOrCreate( - driveID: configuration.driveID, - privateDirectoryFileID: privateFileID, - computerName: try computerNameProvider(), - remote: remote - ) - namespacedConfiguration.knownFolderLayout = .machineNamespace namespacedConfiguration.updatedAt = Date() try await domainStore.save(namespacedConfiguration) replaceDomainConfiguration(namespacedConfiguration) - try await domainRegistrar.claimKnownFolders( - for: namespacedConfiguration, - parentFileID: namespace.fileID - ) + if configuration.encryptionMode == .opaqueVaultV1 { + let vault = try await makeEncryptedVaultService( + configuration: namespacedConfiguration, + accessToken: token.accessToken + ) + _ = try await vault.synchronize() + let privateFolder = try await resolveOrCreateVaultFolder( + named: "Private", + parentID: nil, + vault: vault + ) + let namespace = try await resolveOrCreateVaultFolder( + named: try computerNameProvider(), + parentID: privateFolder.id, + vault: vault + ) + try await domainRegistrar.claimKnownFolders( + for: namespacedConfiguration, + parentItemIdentifier: namespace.id.fileProviderIdentifier + ) + } else { + let remote = fileProviderFactory(token.accessToken) + let privateFileID = try await KDrivePrivateDirectoryResolver.resolveFileID( + driveID: configuration.driveID, + rootFileID: configuration.rootFileID, + remote: remote + ) + let namespace = try await KDriveMachineNamespaceResolver.resolveOrCreate( + driveID: configuration.driveID, + privateDirectoryFileID: privateFileID, + computerName: try computerNameProvider(), + remote: remote + ) + plaintextNamespaceName = namespace.name + try await domainRegistrar.claimKnownFolders( + for: namespacedConfiguration, + parentFileID: namespace.fileID + ) + } didClaimKnownFolders = true try await refreshKnownFolderSyncStates() - statusMessage = "Desktop and Documents now sync with \(configuration.displayName) in kDrive /Private/\(namespace.name)." + if configuration.encryptionMode == .opaqueVaultV1 { + statusMessage = "Desktop and Documents now sync with \(configuration.displayName)." + } else { + statusMessage = "Desktop and Documents now sync with \(configuration.displayName) in kDrive /Private/\(plaintextNamespaceName ?? "")." + } errorMessage = nil } catch { if didClaimKnownFolders == false, namespacedConfiguration != configuration { @@ -719,6 +924,108 @@ final class PotassiumProviderAppModel: ObservableObject { return Data(base64Encoded: payload) } + private func registerEncryptedDomain( + accountIdentifier: String, + driveID: Int, + driveName: String, + vaultConfiguration: ProviderVaultConfiguration + ) async throws { + guard domains.contains(where: { + $0.vault?.vaultIdentifier == vaultConfiguration.vaultIdentifier + }) == false else { + throw VaultDomainRegistrationError.vaultAlreadyRegistered + } + + let now = Date() + let configuration = ProviderDomainConfiguration( + accountIdentifier: accountIdentifier, + displayName: "\(ProviderDomainConfiguration.finderDisplayName(forDriveName: driveName)) — Encrypted", + driveID: driveID, + driveName: driveName, + knownFolderLayout: .machineNamespace, + encryptionMode: .opaqueVaultV1, + vault: vaultConfiguration, + createdAt: now, + updatedAt: now + ) + + try await domainStore.save(configuration) + do { + let synchronizedState = try await synchronizedDomainConfigurations() + domains = synchronizedState.configurations + if let registrationError = synchronizedState.registrationError { + throw registrationError + } + try await refreshKnownFolderSyncStates() + } catch { + await rollbackFailedDomainAddition(configuration) + throw error + } + } + + private func makeEncryptedVaultService( + configuration: ProviderDomainConfiguration, + accessToken: String + ) async throws -> any EncryptedVaultProviding { + guard let vaultConfiguration = configuration.vault, + let rootKey = try await vaultKeyStore.loadRootKey( + vaultID: vaultConfiguration.vaultIdentifier + ) else { + throw EncryptedVaultError.missingKey + } + let deviceID = try await vaultDeviceIdentityStore.loadOrCreateDeviceID( + vaultID: vaultConfiguration.vaultIdentifier + ) + let localStore = try VaultSQLiteStore( + appGroupIdentifier: ProviderConstants.appGroupIdentifier, + domainIdentifier: configuration.domainIdentifier, + vaultID: vaultConfiguration.vaultIdentifier, + rootKey: rootKey, + keyEpoch: vaultConfiguration.keyEpoch + ) + return try EncryptedVaultService( + configuration: configuration, + rootKey: rootKey, + deviceID: deviceID, + objectStore: objectStoreFactory(configuration.driveID, accessToken), + localStore: localStore, + keyStore: vaultKeyStore + ) + } + + private func resolveOrCreateVaultFolder( + named filename: String, + parentID: VaultItemIdentifier?, + vault: any EncryptedVaultProviding + ) async throws -> VaultItem { + var cursor: String? + repeat { + let page = try await vault.children( + of: parentID, + trashed: false, + cursor: cursor, + limit: 200 + ) + if let item = page.items.first(where: { + $0.isDirectory && $0.filename == filename + }) { + return item + } + cursor = page.nextCursor + } while cursor != nil + return try await vault.createDirectory( + parentID: parentID, + filename: filename, + createdAt: Date() + ) + } + + private func clearPendingVault() { + pendingVaultProvisioning = nil + pendingVaultAccountIdentifier = nil + pendingVaultDriveName = nil + } + private func removeDomainAndLocalState(_ configuration: ProviderDomainConfiguration) async throws { try await releaseKnownFoldersBeforeRemovingDomain(configuration) try await domainRegistrar.removeDomain(for: configuration) @@ -983,7 +1290,11 @@ final class PotassiumProviderAppModel: ObservableObject { accounts: [String: ProviderAccount] ) -> [String: String] { let baseNames = Dictionary(uniqueKeysWithValues: configurations.map { - ($0.domainIdentifier, ProviderDomainConfiguration.finderDisplayName(forDriveName: $0.driveName)) + let driveName = ProviderDomainConfiguration.finderDisplayName(forDriveName: $0.driveName) + let displayName = $0.encryptionMode == .opaqueVaultV1 + ? "\(driveName) — Encrypted" + : driveName + return ($0.domainIdentifier, displayName) }) let groupedByBaseName = Dictionary(grouping: configurations) { configuration in baseNames[configuration.domainIdentifier]?.localizedLowercase ?? configuration.driveName.localizedLowercase @@ -1137,6 +1448,14 @@ enum PotassiumProviderAppModelError: Error, Equatable, LocalizedError { } } +private enum VaultDomainRegistrationError: Error, LocalizedError { + case vaultAlreadyRegistered + + var errorDescription: String? { + "This encrypted vault is already registered on this device." + } +} + private extension String { var nilIfEmpty: String? { isEmpty ? nil : self diff --git a/potassiumProvider/ProviderSetupView.swift b/potassiumProvider/ProviderSetupView.swift index 6abe331..407d816 100644 --- a/potassiumProvider/ProviderSetupView.swift +++ b/potassiumProvider/ProviderSetupView.swift @@ -1,5 +1,7 @@ import PotassiumProviderCore import SwiftUI +import CoreImage +import CoreImage.CIFilterBuiltins enum ProviderSetupRoute: Hashable { case addAccount @@ -15,10 +17,23 @@ struct ProviderDriveDescriptor: Identifiable, Equatable { let accountIdentifier: String let driveID: Int let remote: KDriveDriveSummary? - let configuration: ProviderDomainConfiguration? + let configurations: [ProviderDomainConfiguration] + + var configuration: ProviderDomainConfiguration? { + configurations.first(where: { $0.encryptionMode == .opaqueVaultV1 }) + ?? configurations.first + } + + var encryptedConfiguration: ProviderDomainConfiguration? { + configurations.first { $0.encryptionMode == .opaqueVaultV1 } + } + + var legacyConfigurations: [ProviderDomainConfiguration] { + configurations.filter { $0.encryptionMode == .legacyPlaintext } + } var name: String { - remote?.name ?? configuration?.driveName ?? "kDrive \(driveID)" + remote?.name ?? configurations.first?.driveName ?? "kDrive \(driveID)" } var role: String? { @@ -36,7 +51,7 @@ struct ProviderDriveDescriptor: Identifiable, Equatable { } var isConfigured: Bool { - configuration != nil + configurations.isEmpty == false } var remoteDetailsAreAvailable: Bool { @@ -48,10 +63,7 @@ struct ProviderDriveDescriptor: Identifiable, Equatable { drives: [KDriveDriveSummary], configurations: [ProviderDomainConfiguration] ) -> [ProviderDriveDescriptor] { - var configurationsByDriveID: [Int: ProviderDomainConfiguration] = [:] - for configuration in configurations where configurationsByDriveID[configuration.driveID] == nil { - configurationsByDriveID[configuration.driveID] = configuration - } + let configurationsByDriveID = Dictionary(grouping: configurations, by: \.driveID) var seenDriveIDs: Set = [] var descriptors = drives.compactMap { drive -> ProviderDriveDescriptor? in @@ -60,18 +72,18 @@ struct ProviderDriveDescriptor: Identifiable, Equatable { accountIdentifier: accountIdentifier, driveID: drive.id, remote: drive, - configuration: configurationsByDriveID[drive.id] + configurations: configurationsByDriveID[drive.id] ?? [] ) } - descriptors.append(contentsOf: configurations - .filter { seenDriveIDs.insert($0.driveID).inserted } - .map { + descriptors.append(contentsOf: configurationsByDriveID + .filter { seenDriveIDs.insert($0.key).inserted } + .map { driveID, configurations in ProviderDriveDescriptor( accountIdentifier: accountIdentifier, - driveID: $0.driveID, + driveID: driveID, remote: nil, - configuration: $0 + configurations: configurations ) }) return descriptors @@ -520,6 +532,8 @@ private struct ProviderDriveManagementView: View { @State private var isRemovalConfirmationPresented = false @State private var isStopSyncConfirmationPresented = false + @State private var isOpenVaultPresented = false + @State private var isForgetKeyPresented = false var body: some View { Group { @@ -564,6 +578,23 @@ private struct ProviderDriveManagementView: View { } message: { Text("This removes the File Provider domain, cached snapshots, activities, conflicts, and other provider-local state. Remote kDrive files are not deleted.") } + .sheet(isPresented: pendingVaultBinding) { + VaultRecoveryConfirmationView(model: model) + } + .sheet(isPresented: $isOpenVaultPresented) { + if let drive = descriptor?.remote { + VaultOpenView( + model: model, + accountIdentifier: key.accountIdentifier, + drive: drive + ) + } + } + .sheet(isPresented: $isForgetKeyPresented) { + if let configuration = descriptor?.encryptedConfiguration { + VaultForgetKeyView(model: model, configuration: configuration) + } + } #if os(macOS) .confirmationDialog( "Stop syncing Desktop and Documents?", @@ -597,6 +628,17 @@ private struct ProviderDriveManagementView: View { activeAction != nil || model.isLoadingDrives(for: key.accountIdentifier) } + private var pendingVaultBinding: Binding { + Binding( + get: { model.pendingVaultProvisioning != nil }, + set: { isPresented in + if isPresented == false, model.pendingVaultProvisioning != nil { + Task { await model.cancelEncryptedVaultProvisioning() } + } + } + ) + } + private var knownFolderRemotePath: String { guard let configuration = descriptor?.configuration else { return "/Private/" @@ -637,27 +679,48 @@ private struct ProviderDriveManagementView: View { .foregroundStyle(descriptor.isConfigured ? .green : .secondary) } - if descriptor.configuration == nil, let remote = descriptor.remote { + if descriptor.encryptedConfiguration == nil, let remote = descriptor.remote { Button { Task { - await model.addDomain( + await model.prepareEncryptedVault( accountIdentifier: key.accountIdentifier, drive: remote ) } } label: { actionLabel( - title: "Add to Files", - systemImage: "folder.badge.plus", + title: "Create Encrypted Vault", + systemImage: "lock.square.stack", action: .addingToFiles ) } .buttonStyle(.borderedProminent) - .disabled(isBusy) - .accessibilityIdentifier("drive.addToFiles") + .disabled(isBusy || model.encryptedVaultsEnabled == false) + .accessibilityIdentifier("drive.createEncryptedVault") + + Button("Open Existing Vault", systemImage: "key.viewfinder") { + isOpenVaultPresented = true + } + .disabled(isBusy || model.encryptedVaultsEnabled == false) + .accessibilityIdentifier("drive.openEncryptedVault") + + if model.encryptedVaultsEnabled == false { + Label( + "Encrypted vault creation is behind the security-review feature gate.", + systemImage: "checkmark.shield" + ) + .font(.footnote) + .foregroundStyle(.secondary) + } } if let configuration = descriptor.configuration { + LabeledContent( + "Storage", + value: configuration.encryptionMode == .opaqueVaultV1 + ? "End-to-end encrypted vault" + : "Legacy plaintext migration source" + ) Button { Task { if let url = await model.userVisibleRootURL(for: configuration) { @@ -693,6 +756,29 @@ private struct ProviderDriveManagementView: View { } .disabled(isBusy) .accessibilityIdentifier("drive.syncNow") + + if configuration.encryptionMode == .opaqueVaultV1 { + Button("Forget Key on This Device", systemImage: "key.slash") { + isForgetKeyPresented = true + } + .disabled(isBusy) + } + } + } + + if descriptor.legacyConfigurations.isEmpty == false { + Section { + ForEach(descriptor.legacyConfigurations) { configuration in + Label( + "\(configuration.displayName) stores readable names, metadata, and contents on kDrive.", + systemImage: "exclamationmark.triangle" + ) + .foregroundStyle(.orange) + } + } header: { + Text("Migration Sources") + } footer: { + Text("Legacy domains remain available while encrypted migration is verified. Source purge is a separate destructive workflow.") } } @@ -871,6 +957,192 @@ private struct ProviderSetupErrorBanner: View { } } +private struct VaultRecoveryConfirmationView: View { + @ObservedObject var model: PotassiumProviderAppModel + @Environment(\.dismiss) private var dismiss + @State private var confirmation = "" + + var body: some View { + NavigationStack { + Form { + Section { + if let kit = model.pendingVaultProvisioning?.recoveryKit.encoded { + VaultRecoveryQRCode(value: kit) + .frame(maxWidth: .infinity) + Text(kit) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + .accessibilityIdentifier("vault.recoveryKit") + } + } header: { + Text("One-time Recovery Kit") + } footer: { + Text("Save this offline. It is never uploaded, logged, or automatically exported. Losing every device key and this kit makes the vault unrecoverable.") + } + + Section("Confirm") { + TextEditor(text: $confirmation) + .font(.system(.caption, design: .monospaced)) + .frame(minHeight: 110) + .accessibilityIdentifier("vault.recoveryConfirmation") + Text("Paste the complete recovery kit to prove you saved it.") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + .navigationTitle("Save Recovery Kit") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + Task { + await model.cancelEncryptedVaultProvisioning() + dismiss() + } + } + } + ToolbarItem(placement: .confirmationAction) { + Button("Create Vault") { + Task { + await model.confirmEncryptedVault( + recoveryKitConfirmation: confirmation + ) + if model.pendingVaultProvisioning == nil { + dismiss() + } + } + } + .disabled(confirmation.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + } + .interactiveDismissDisabled() + .frame(minWidth: 520, minHeight: 620) + } +} + +private struct VaultOpenView: View { + @ObservedObject var model: PotassiumProviderAppModel + let accountIdentifier: String + let drive: KDriveDriveSummary + + @Environment(\.dismiss) private var dismiss + @State private var recoveryKit = "" + + var body: some View { + NavigationStack { + Form { + Section { + TextEditor(text: $recoveryKit) + .font(.system(.caption, design: .monospaced)) + .frame(minHeight: 150) + .accessibilityIdentifier("vault.openRecoveryKit") + } header: { + Text("Recovery Kit") + } footer: { + Text("The kit is used locally to authenticate the bootstrap and checkpoint. It is not uploaded or saved.") + } + } + .navigationTitle("Open Encrypted Vault") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Open") { + Task { + await model.openEncryptedVault( + accountIdentifier: accountIdentifier, + drive: drive, + recoveryKitText: recoveryKit + ) + if model.errorMessage == nil { + dismiss() + } + } + } + .disabled(recoveryKit.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + } + .frame(minWidth: 500, minHeight: 360) + } +} + +private struct VaultForgetKeyView: View { + @ObservedObject var model: PotassiumProviderAppModel + let configuration: ProviderDomainConfiguration + + @Environment(\.dismiss) private var dismiss + @State private var recoveryKit = "" + + var body: some View { + NavigationStack { + Form { + Section { + TextEditor(text: $recoveryKit) + .font(.system(.caption, design: .monospaced)) + .frame(minHeight: 150) + } header: { + Text("Recovery Confirmation") + } footer: { + Text("This deletes only the unwrapped vault key on this device. Domain removal, logout, and uninstall do not delete it.") + } + } + .navigationTitle("Forget Vault Key") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Forget Key", role: .destructive) { + Task { + await model.forgetVaultKey( + for: configuration, + recoveryKitConfirmation: recoveryKit + ) + if model.errorMessage == nil { + dismiss() + } + } + } + .disabled(recoveryKit.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + } + .frame(minWidth: 500, minHeight: 360) + } +} + +private struct VaultRecoveryQRCode: View { + let value: String + + var body: some View { + if let image = Self.image(value) { + Image(decorative: image, scale: 1) + .interpolation(.none) + .resizable() + .scaledToFit() + .frame(width: 220, height: 220) + .accessibilityLabel("Recovery kit QR code") + } + } + + private static func image(_ value: String) -> CGImage? { + let filter = CIFilter.qrCodeGenerator() + filter.message = Data(value.utf8) + filter.correctionLevel = "M" + guard let output = filter.outputImage?.transformed( + by: CGAffineTransform(scaleX: 8, y: 8) + ) else { + return nil + } + return CIContext(options: [.useSoftwareRenderer: false]).createCGImage( + output, + from: output.extent + ) + } +} + private extension ProviderAccountAuthenticationKind { var title: String { switch self { diff --git a/potassiumProviderActions/ProviderActionViewModel.swift b/potassiumProviderActions/ProviderActionViewModel.swift index 6065515..2479a8a 100644 --- a/potassiumProviderActions/ProviderActionViewModel.swift +++ b/potassiumProviderActions/ProviderActionViewModel.swift @@ -32,8 +32,10 @@ final class ProviderActionViewModel: ObservableObject { let itemIdentifier: NSFileProviderItemIdentifier @Published private(set) var item: KDriveRemoteItem? + @Published private(set) var vaultItem: VaultItem? @Published private(set) var shareLink: KDriveShareLinkSummary? @Published private(set) var versions: [KDriveFileVersionSummary] = [] + @Published private(set) var vaultVersions: [VaultVersion] = [] @Published private(set) var hasMoreVersions = false @Published private(set) var isLoading = true @Published private(set) var isWorking = false @@ -64,6 +66,26 @@ final class ProviderActionViewModel: ObservableObject { defer { isLoading = false } do { let runtime = try await ProviderActionRuntime.load(domainIdentifier: domainIdentifier) + if let vault = runtime.encryptedVault { + guard let identifier = VaultItemIdentifier( + fileProviderIdentifier: itemIdentifier.rawValue + ) else { + throw ProviderActionRuntimeError.configurationUnavailable + } + _ = try await vault.synchronize() + let item = try await vault.item(identifier) + self.runtime = runtime + self.vaultItem = item + switch mode { + case .shareLink: + throw EncryptedVaultError.unsupportedNativeSharing + case .versionHistory: + vaultVersions = try await vault.versions(itemID: identifier) + .sorted { $0.modifiedAt > $1.modifiedAt } + hasMoreVersions = false + } + return + } let parsedIdentifier = try KDriveItemIdentifier(rawValue: itemIdentifier.rawValue) guard let fileID = parsedIdentifier.fileID(rootFileID: runtime.configuration.rootFileID) else { throw ProviderActionRuntimeError.configurationUnavailable @@ -196,6 +218,37 @@ final class ProviderActionViewModel: ObservableObject { } } + func restore(_ version: VaultVersion) async { + guard let runtime, + let vault = runtime.encryptedVault, + let currentItem = vaultItem else { + return + } + await performWork { + let restored = try await vault.restoreVersion( + itemID: currentItem.id, + contentRevision: version.contentRevision + ) + self.vaultItem = restored + self.vaultVersions = try await vault.versions(itemID: restored.id) + .sorted { $0.modifiedAt > $1.modifiedAt } + self.message = "Restored the authenticated encrypted version." + try? await runtime.eventStore?.recordActivity(KDriveProviderActivityEvent( + domainIdentifier: self.domainIdentifier, + driveID: runtime.configuration.driveID, + kind: .versionRestore, + itemIdentifier: restored.id.fileProviderIdentifier, + itemName: nil, + itemPath: nil, + summary: "Restored an encrypted logical version." + )) + await self.signalEncryptedParentAndWorkingSet( + runtime: runtime, + parentID: restored.parentID + ) + } + } + func copyShareLink() { guard let url = shareLink?.url else { return } #if os(macOS) @@ -266,6 +319,25 @@ final class ProviderActionViewModel: ObservableObject { } } + private func signalEncryptedParentAndWorkingSet( + runtime: ProviderActionRuntime, + parentID: VaultItemIdentifier? + ) async { + let domain = NSFileProviderDomain( + identifier: NSFileProviderDomainIdentifier(rawValue: runtime.configuration.domainIdentifier), + displayName: runtime.configuration.displayName + ) + guard let manager = NSFileProviderManager(for: domain) else { return } + let parentIdentifier = parentID.map { + NSFileProviderItemIdentifier($0.fileProviderIdentifier) + } ?? .rootContainer + for identifier in [parentIdentifier, .workingSet] { + await withCheckedContinuation { continuation in + manager.signalEnumerator(for: identifier) { _ in continuation.resume() } + } + } + } + private func record( kind: KDriveProviderActivityKind, summary: String, @@ -306,9 +378,10 @@ final class ProviderActionViewModel: ObservableObject { kind: kind, outcome: .failure, severity: .error, - itemIdentifier: item.map { KDriveItemIdentifier.item($0.id).rawValue }, - itemName: item?.name, - itemPath: item?.path, + itemIdentifier: vaultItem?.id.fileProviderIdentifier + ?? item.map { KDriveItemIdentifier.item($0.id).rawValue }, + itemName: vaultItem == nil ? item?.name : nil, + itemPath: vaultItem == nil ? item?.path : nil, summary: summary, diagnostic: KDriveProviderActivityErrorDiagnostic( errorCategory: category, diff --git a/potassiumProviderActions/ProviderActionViews.swift b/potassiumProviderActions/ProviderActionViews.swift index 76843d2..f2a3a0f 100644 --- a/potassiumProviderActions/ProviderActionViews.swift +++ b/potassiumProviderActions/ProviderActionViews.swift @@ -17,6 +17,17 @@ struct ProviderActionRootView: View { systemImage: "exclamationmark.triangle", description: Text(error) ) + } else if let item = model.vaultItem { + switch model.mode { + case .shareLink: + ContentUnavailableView( + "Encrypted Sharing Unavailable", + systemImage: "person.crop.circle.badge.xmark", + description: Text("Recipient-key sharing is not supported for encrypted vaults in version 1.") + ) + case .versionHistory: + VaultVersionHistoryActionView(model: model, item: item) + } } else if let item = model.item { switch model.mode { case .shareLink: @@ -53,6 +64,80 @@ struct ProviderActionRootView: View { } } +private struct VaultVersionHistoryActionView: View { + @ObservedObject var model: ProviderActionViewModel + let item: VaultItem + @State private var pendingRestore: VaultVersion? + + var body: some View { + List { + Section { + Label(item.filename, systemImage: item.isDirectory ? "folder" : "doc") + .lineLimit(2) + } + + if model.vaultVersions.isEmpty { + ContentUnavailableView( + "No Previous Versions", + systemImage: "clock", + description: Text("The encrypted logical history has no prior revisions.") + ) + } else { + Section("Encrypted Versions") { + ForEach(model.vaultVersions) { version in + HStack { + VStack(alignment: .leading, spacing: 4) { + Text( + version.modifiedAt, + format: .dateTime.year().month().day().hour().minute() + ) + .font(.headline) + Text(ByteCountFormatter.string( + fromByteCount: version.plaintextSize, + countStyle: .file + )) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Button("Restore") { pendingRestore = version } + .buttonStyle(.borderless) + } + } + } + } + + if model.isWorking { + ProgressView() + } else if let error = model.errorMessage { + Label(error, systemImage: "exclamationmark.triangle") + .foregroundStyle(.red) + } else if let message = model.message { + Label(message, systemImage: "checkmark.circle") + .foregroundStyle(.secondary) + } + } + .confirmationDialog( + "Restore this encrypted version?", + isPresented: Binding( + get: { pendingRestore != nil }, + set: { if $0 == false { pendingRestore = nil } } + ), + titleVisibility: .visible + ) { + if let version = pendingRestore { + Button("Restore Version") { + pendingRestore = nil + Task { await model.restore(version) } + } + } + Button("Cancel", role: .cancel) { pendingRestore = nil } + } message: { + Text("The current revision remains in encrypted logical version history.") + } + } +} + private struct ShareLinkActionView: View { @ObservedObject var model: ProviderActionViewModel let item: KDriveRemoteItem diff --git a/potassiumProviderFileProvider/FileProviderEnumerator.swift b/potassiumProviderFileProvider/FileProviderEnumerator.swift index 28012b7..a8a8e90 100644 --- a/potassiumProviderFileProvider/FileProviderEnumerator.swift +++ b/potassiumProviderFileProvider/FileProviderEnumerator.swift @@ -26,6 +26,18 @@ final class FileProviderEnumerator: NSObject, NSFileProviderEnumerator { do { let loadedRuntime = try await FileProviderRuntime.load(domain: self.domain) runtime = loadedRuntime + if let vault = loadedRuntime.encryptedVault { + _ = try await vault.synchronize() + let vaultPage = try await self.listVaultItems( + vault: vault, + startingAt: page + ) + observer.didEnumerate(vaultPage.items.map(FileProviderItem.init(vaultItem:))) + observer.finishEnumerating( + upTo: FileProviderPageCodec.page(from: vaultPage.nextCursor) + ) + return + } let itemPage = try await self.listItems(runtime: loadedRuntime, startingAt: page) let enumeratesTrash = self.containerItemIdentifier == .trashContainer observer.didEnumerate(itemPage.items.map { @@ -66,6 +78,17 @@ final class FileProviderEnumerator: NSObject, NSFileProviderEnumerator { let configuration = try await FileProviderRuntime.loadConfiguration(domain: domain) domainIdentifier = configuration.domainIdentifier driveID = configuration.driveID + if configuration.encryptionMode == .opaqueVaultV1 { + let runtime = try await FileProviderRuntime.load(domain: domain) + guard let vault = runtime.encryptedVault else { + throw NSFileProviderError(.notAuthenticated) + } + let frontier = try await vault.synchronize() + completionHandler(FileProviderPageCodec.anchor( + from: frontier.anchorString + )) + return + } let snapshotStore = try FileProviderRuntime.makeSnapshotStore() if self.containerItemIdentifier == .workingSet { let stateStore = try FileProviderRuntime.makeWorkingSetStateStore() @@ -122,6 +145,32 @@ final class FileProviderEnumerator: NSObject, NSFileProviderEnumerator { do { let loadedRuntime = try await FileProviderRuntime.load(domain: self.domain) runtime = loadedRuntime + if let vault = loadedRuntime.encryptedVault { + guard let requestedAnchor else { + throw NSFileProviderError(.syncAnchorExpired) + } + let changes = try await vault.changes( + since: requestedAnchor, + scope: try self.vaultChangeScope() + ) + if changes.updated.isEmpty == false { + observer.didUpdate( + changes.updated.map(FileProviderItem.init(vaultItem:)) + ) + } + if changes.deleted.isEmpty == false { + observer.didDeleteItems(withIdentifiers: changes.deleted.map { + NSFileProviderItemIdentifier($0.fileProviderIdentifier) + }) + } + observer.finishEnumeratingChanges( + upTo: FileProviderPageCodec.anchor( + from: changes.frontier.anchorString + ), + moreComing: false + ) + return + } try await self.validateContainer(runtime: loadedRuntime) if self.containerItemIdentifier == .workingSet { try await self.enumerateWorkingSetChanges( @@ -212,6 +261,66 @@ final class FileProviderEnumerator: NSObject, NSFileProviderEnumerator { } } + private func listVaultItems( + vault: any EncryptedVaultProviding, + startingAt page: NSFileProviderPage + ) async throws -> VaultItemPage { + let cursor = FileProviderPageCodec.cursor(from: page) + if containerItemIdentifier == .workingSet { + let items = try await vault.workingSet(limit: 1_000) + return VaultItemPage(items: items, nextCursor: nil) + } + if containerItemIdentifier == .rootContainer { + return try await vault.children( + of: nil, + trashed: false, + cursor: cursor, + limit: 200 + ) + } + if containerItemIdentifier == .trashContainer { + return try await vault.children( + of: nil, + trashed: true, + cursor: cursor, + limit: 200 + ) + } + guard let parentID = VaultItemIdentifier( + fileProviderIdentifier: containerItemIdentifier.rawValue + ) else { + throw NSFileProviderError(.noSuchItem) + } + let parent = try await vault.item(parentID) + guard parent.isDirectory else { + throw NSFileProviderError(.noSuchItem) + } + return try await vault.children( + of: parentID, + trashed: false, + cursor: cursor, + limit: 200 + ) + } + + private func vaultChangeScope() throws -> VaultChangeScope { + if containerItemIdentifier == .workingSet { + return .workingSet + } + if containerItemIdentifier == .rootContainer { + return .children(parentID: nil) + } + if containerItemIdentifier == .trashContainer { + return .trash + } + guard let parentID = VaultItemIdentifier( + fileProviderIdentifier: containerItemIdentifier.rawValue + ) else { + throw NSFileProviderError(.noSuchItem) + } + return .children(parentID: parentID) + } + private func recordFailure( _ error: Error, runtime: FileProviderRuntime?, diff --git a/potassiumProviderFileProvider/FileProviderItem.swift b/potassiumProviderFileProvider/FileProviderItem.swift index 9313d8d..0acdbcd 100644 --- a/potassiumProviderFileProvider/FileProviderItem.swift +++ b/potassiumProviderFileProvider/FileProviderItem.swift @@ -34,10 +34,22 @@ final class FileProviderItem: NSObject, NSFileProviderItemProtocol { self.parentItemIdentifier = .rootContainer self.filename = configuration.displayName self.contentType = .folder - self.itemVersion = NSFileProviderItemVersion( - contentVersion: Data("root-\(configuration.rootFileID)".utf8), - metadataVersion: Data(configuration.updatedAt.timeIntervalSince1970.description.utf8) - ) + if let vault = configuration.vault, + configuration.encryptionMode == .opaqueVaultV1 { + self.itemVersion = NSFileProviderItemVersion( + contentVersion: VaultRevision( + hashing: Data("root-content:\(vault.vaultIdentifier.rawValue.uuidString)".utf8) + ).data, + metadataVersion: VaultRevision( + hashing: Data("root-metadata:\(configuration.displayName):\(vault.keyEpoch)".utf8) + ).data + ) + } else { + self.itemVersion = NSFileProviderItemVersion( + contentVersion: Data("root-\(configuration.rootFileID)".utf8), + metadataVersion: Data(configuration.updatedAt.timeIntervalSince1970.description.utf8) + ) + } self.documentSize = nil self.creationDate = configuration.createdAt self.contentModificationDate = configuration.updatedAt @@ -58,6 +70,73 @@ final class FileProviderItem: NSObject, NSFileProviderItemProtocol { super.init() } + init(vaultItem: VaultItem) { + self.itemIdentifier = NSFileProviderItemIdentifier( + vaultItem.id.fileProviderIdentifier + ) + self.parentItemIdentifier = vaultItem.isTrashed + ? .trashContainer + : vaultItem.parentID.map { + NSFileProviderItemIdentifier($0.fileProviderIdentifier) + } ?? .rootContainer + self.filename = vaultItem.filename + self.contentType = vaultItem.contentType + self.itemVersion = NSFileProviderItemVersion( + contentVersion: vaultItem.contentRevision.data, + metadataVersion: vaultItem.metadataRevision.data + ) + self.documentSize = vaultItem.isDirectory + ? nil + : NSNumber(value: vaultItem.plaintextSize) + self.creationDate = vaultItem.createdAt + self.contentModificationDate = vaultItem.modifiedAt + #if !os(macOS) + self.isTrashed = vaultItem.isTrashed + #endif + self.isUploaded = true + #if os(macOS) + self.contentPolicy = .downloadLazily + #endif + self.userInfo = [ + FileProviderItemUserInfoKey.isDirectory: vaultItem.isDirectory, + FileProviderItemUserInfoKey.isFavorite: vaultItem.isFavorite, + FileProviderItemUserInfoKey.isTrashed: vaultItem.isTrashed, + FileProviderItemUserInfoKey.isRoot: false, + ] + + if vaultItem.isTrashed { + self.capabilities = [.allowsReading, .allowsDeleting] + } else if vaultItem.isDirectory { + var capabilities: NSFileProviderItemCapabilities = [ + .allowsContentEnumerating, + .allowsAddingSubItems, + .allowsReading, + .allowsRenaming, + .allowsReparenting, + .allowsTrashing, + .allowsDeleting, + ] + #if !os(macOS) + capabilities.insert(.allowsEvicting) + #endif + self.capabilities = capabilities + } else { + var capabilities: NSFileProviderItemCapabilities = [ + .allowsReading, + .allowsWriting, + .allowsRenaming, + .allowsReparenting, + .allowsTrashing, + .allowsDeleting, + ] + #if !os(macOS) + capabilities.insert(.allowsEvicting) + #endif + self.capabilities = capabilities + } + super.init() + } + init( remoteItem: KDriveRemoteItem, rootFileID: Int = ProviderConstants.defaultRootFileID, diff --git a/potassiumProviderFileProvider/FileProviderRuntime.swift b/potassiumProviderFileProvider/FileProviderRuntime.swift index 064f29a..068e391 100644 --- a/potassiumProviderFileProvider/FileProviderRuntime.swift +++ b/potassiumProviderFileProvider/FileProviderRuntime.swift @@ -18,6 +18,7 @@ struct FileProviderRuntime: Sendable { let snapshotStore: any KDriveSnapshotStoring let workingSetStateStore: any KDriveWorkingSetStateStoring let eventStore: (any KDriveProviderEventStoring)? + let encryptedVault: (any EncryptedVaultProviding)? private init( configuration: ProviderDomainConfiguration, @@ -27,7 +28,8 @@ struct FileProviderRuntime: Sendable { workingSetRemote: any KDriveWorkingSetRemoteProviding, snapshotStore: any KDriveSnapshotStoring, workingSetStateStore: any KDriveWorkingSetStateStoring, - eventStore: (any KDriveProviderEventStoring)? + eventStore: (any KDriveProviderEventStoring)?, + encryptedVault: (any EncryptedVaultProviding)? ) { self.configuration = configuration self.token = token @@ -37,6 +39,7 @@ struct FileProviderRuntime: Sendable { self.snapshotStore = snapshotStore self.workingSetStateStore = workingSetStateStore self.eventStore = eventStore + self.encryptedVault = encryptedVault } static func load(domain: NSFileProviderDomain) async throws -> FileProviderRuntime { @@ -61,6 +64,56 @@ struct FileProviderRuntime: Sendable { let sqliteStore = try makeSQLiteStore() let remote = PotassiumKDriveService(bearerToken: token.accessToken) + let encryptedVault: (any EncryptedVaultProviding)? + if configuration.encryptionMode == .opaqueVaultV1 { + guard let vaultConfiguration = configuration.vault, + vaultConfiguration.formatVersion == VaultFormat.currentVersion, + vaultConfiguration.remoteLayout != nil else { + throw NSFileProviderError(.cannotSynchronize) + } + let keyStore = KeychainVaultKeyStore( + accessGroup: ProviderConstants.keychainAccessGroup + ) + let rootKey: VaultKeyMaterial + do { + guard let loadedKey = try await keyStore.loadRootKey( + vaultID: vaultConfiguration.vaultIdentifier + ) else { + throw NSFileProviderError(.notAuthenticated) + } + rootKey = loadedKey + } catch is NSFileProviderError { + throw NSFileProviderError(.notAuthenticated) + } catch VaultKeyStoreError.unhandledStatus { + throw NSFileProviderError(.notAuthenticated) + } catch { + throw NSFileProviderError(.cannotSynchronize) + } + let deviceID = try await keyStore.loadOrCreateDeviceID( + vaultID: vaultConfiguration.vaultIdentifier + ) + let localStore = try VaultSQLiteStore( + appGroupIdentifier: ProviderConstants.appGroupIdentifier, + domainIdentifier: configuration.domainIdentifier, + vaultID: vaultConfiguration.vaultIdentifier, + rootKey: rootKey, + keyEpoch: vaultConfiguration.keyEpoch + ) + let objectStore = PotassiumKDriveObjectStore( + driveID: configuration.driveID, + bearerToken: token.accessToken + ) + encryptedVault = try EncryptedVaultService( + configuration: configuration, + rootKey: rootKey, + deviceID: deviceID, + objectStore: objectStore, + localStore: localStore, + keyStore: keyStore + ) + } else { + encryptedVault = nil + } FileProviderLog.runtime.debug("loaded runtime for domain(\(domain.identifier.rawValue, privacy: .public)) driveID(\(configuration.driveID, privacy: .public)) rootFileID(\(configuration.rootFileID, privacy: .public))") return FileProviderRuntime( configuration: configuration, @@ -70,7 +123,8 @@ struct FileProviderRuntime: Sendable { workingSetRemote: remote, snapshotStore: sqliteStore, workingSetStateStore: sqliteStore, - eventStore: makeEventStore() + eventStore: makeEventStore(), + encryptedVault: encryptedVault ) } @@ -248,6 +302,43 @@ func providerErrorMapping(_ error: Error) -> ProviderErrorMapping { ) } + if error is VaultCryptoError || + error is VaultJournalError || + error is VaultLocalStoreError || + error is VaultProvisioningError { + let mappedError = NSFileProviderError(.cannotSynchronize) + return ProviderErrorMapping( + mappedError: mappedError, + diagnostic: providerDiagnostic( + category: .storage, + originalError: error, + mappedError: mappedError + ) + ) + } + + if let vaultError = error as? EncryptedVaultError { + let mappedError: NSFileProviderError + switch vaultError { + case .missingKey: + mappedError = NSFileProviderError(.notAuthenticated) + case .itemNotFound: + mappedError = NSFileProviderError(.noSuchItem) + case .syncAnchorExpired: + mappedError = NSFileProviderError(.syncAnchorExpired) + default: + mappedError = NSFileProviderError(.cannotSynchronize) + } + return ProviderErrorMapping( + mappedError: mappedError, + diagnostic: providerDiagnostic( + category: .storage, + originalError: error, + mappedError: mappedError + ) + ) + } + let nsError = error as NSError if nsError.domain == NSURLErrorDomain { FileProviderLog.runtime.error("map URL error \(nsError.code, privacy: .public) to serverUnreachable: \(nsError.localizedDescription, privacy: .public)") diff --git a/potassiumProviderFileProvider/PotassiumFileProviderExtension+Actions.swift b/potassiumProviderFileProvider/PotassiumFileProviderExtension+Actions.swift index 3d90317..bae3bda 100644 --- a/potassiumProviderFileProvider/PotassiumFileProviderExtension+Actions.swift +++ b/potassiumProviderFileProvider/PotassiumFileProviderExtension+Actions.swift @@ -24,6 +24,55 @@ extension PotassiumFileProviderExtension: NSFileProviderCustomAction { } let loadedRuntime = try await FileProviderRuntime.load(domain: self.domain) runtime = loadedRuntime + if let vault = loadedRuntime.encryptedVault { + guard let vaultItemID = VaultItemIdentifier( + fileProviderIdentifier: selectedIdentifier.rawValue + ), + let action = ProviderDirectContextAction( + rawValue: actionIdentifier.rawValue + ) else { + throw NSFileProviderError(.noSuchItem) + } + let current = try await vault.item(vaultItemID) + let updated: VaultItem + switch action { + case .addFavorite, .removeFavorite: + updated = try await vault.modify( + itemID: vaultItemID, + baseContentRevision: current.contentRevision, + baseMetadataRevision: current.metadataRevision, + parentID: current.parentID, + filename: current.filename, + favorite: action == .addFavorite, + plaintextURL: nil, + modifiedAt: current.modifiedAt + ) + case .duplicate: + updated = try await vault.duplicate(itemID: vaultItemID) + case .restoreFromTrash: + updated = try await vault.restore( + itemID: vaultItemID, + parentID: current.parentID + ) + } + await self.signalEncryptedMutation( + runtime: loadedRuntime, + parentIDs: [current.parentID, updated.parentID], + includesTrash: action == .restoreFromTrash + ) + await ProviderEventRecorder.recordActivity( + kind: Self.activityKind(for: action), + runtime: loadedRuntime, + itemIdentifier: updated.id.fileProviderIdentifier, + itemName: nil, + itemPath: nil, + summary: "Performed an encrypted vault action." + ) + await lifecycle.finish(markProgressComplete: true) { + completionHandler(nil) + } + return + } let parsedIdentifier = try KDriveItemIdentifier(rawValue: selectedIdentifier.rawValue) guard let fileID = parsedIdentifier.fileID( rootFileID: loadedRuntime.configuration.rootFileID diff --git a/potassiumProviderFileProvider/PotassiumFileProviderExtension+KnownFolders.swift b/potassiumProviderFileProvider/PotassiumFileProviderExtension+KnownFolders.swift index 5510507..ce572dc 100644 --- a/potassiumProviderFileProvider/PotassiumFileProviderExtension+KnownFolders.swift +++ b/potassiumProviderFileProvider/PotassiumFileProviderExtension+KnownFolders.swift @@ -19,6 +19,37 @@ extension PotassiumFileProviderExtension: NSFileProviderKnownFolderSupporting { Task { do { let runtime = try await FileProviderRuntime.load(domain: self.fileProviderDomain) + if let vault = runtime.encryptedVault { + _ = try await vault.synchronize() + let privateFolder = try await self.resolveVaultFolder( + named: "Private", + parentID: nil, + vault: vault + ) + let namespaceFolder = try await self.resolveVaultFolder( + named: try KDriveMachineNamespaceName.current(), + parentID: privateFolder.id, + vault: vault + ) + let parentIdentifier = NSFileProviderItemIdentifier( + namespaceFolder.id.fileProviderIdentifier + ) + let locations = NSFileProviderKnownFolderLocations() + if requestsDesktop { + locations.desktopLocation = NSFileProviderKnownFolderLocations.Location( + parentItemIdentifier: parentIdentifier, + filename: "Desktop" + ) + } + if requestsDocuments { + locations.documentsLocation = NSFileProviderKnownFolderLocations.Location( + parentItemIdentifier: parentIdentifier, + filename: "Documents" + ) + } + completionHandler(locations, nil) + return + } let privateFileID = try await KDrivePrivateDirectoryResolver.resolveFileID( driveID: runtime.configuration.driveID, rootFileID: runtime.configuration.rootFileID, @@ -77,5 +108,32 @@ extension PotassiumFileProviderExtension: NSFileProviderKnownFolderSupporting { } } } + + private func resolveVaultFolder( + named filename: String, + parentID: VaultItemIdentifier?, + vault: any EncryptedVaultProviding + ) async throws -> VaultItem { + var cursor: String? + repeat { + let page = try await vault.children( + of: parentID, + trashed: false, + cursor: cursor, + limit: 200 + ) + if let folder = page.items.first(where: { + $0.isDirectory && $0.filename == filename + }) { + return folder + } + cursor = page.nextCursor + } while cursor != nil + return try await vault.createDirectory( + parentID: parentID, + filename: filename, + createdAt: Date() + ) + } } #endif diff --git a/potassiumProviderFileProvider/PotassiumFileProviderExtension+Thumbnailing.swift b/potassiumProviderFileProvider/PotassiumFileProviderExtension+Thumbnailing.swift index f4235d6..b50030d 100644 --- a/potassiumProviderFileProvider/PotassiumFileProviderExtension+Thumbnailing.swift +++ b/potassiumProviderFileProvider/PotassiumFileProviderExtension+Thumbnailing.swift @@ -1,8 +1,11 @@ import FileProvider import Foundation +import ImageIO import InfomaniakConcurrency import OSLog import PotassiumProviderCore +import QuickLookThumbnailing +import UniformTypeIdentifiers extension PotassiumFileProviderExtension: NSFileProviderThumbnailing { public func fetchThumbnails( @@ -69,6 +72,35 @@ extension PotassiumFileProviderExtension: NSFileProviderThumbnailing { ) async throws { try Task.checkCancellation() do { + if let vault = runtime.encryptedVault { + guard let identifier = VaultItemIdentifier( + fileProviderIdentifier: itemIdentifier.rawValue + ) else { + perThumbnailCompletionHandler(itemIdentifier, nil, nil) + return + } + let item = try await vault.item(identifier) + guard item.isDirectory == false else { + perThumbnailCompletionHandler(itemIdentifier, nil, nil) + return + } + let plaintextURL = temporaryDirectoryURL + .appendingPathComponent("thumbnail-\(UUID().uuidString)") + .appendingPathExtension((item.filename as NSString).pathExtension) + defer { try? FileManager.default.removeItem(at: plaintextURL) } + _ = try await vault.fetchContent( + itemID: identifier, + expectedRevision: item.contentRevision, + to: plaintextURL + ) + let data = try await localThumbnailData( + fileURL: plaintextURL, + dimensions: dimensions + ) + try Task.checkCancellation() + perThumbnailCompletionHandler(itemIdentifier, data, nil) + return + } let identifier = try KDriveItemIdentifier(rawValue: itemIdentifier.rawValue) guard case let .item(fileID) = identifier else { perThumbnailCompletionHandler(itemIdentifier, nil, nil) @@ -107,6 +139,45 @@ extension PotassiumFileProviderExtension: NSFileProviderThumbnailing { perThumbnailCompletionHandler(itemIdentifier, nil, mappedError) } } + + private func localThumbnailData( + fileURL: URL, + dimensions: KDriveThumbnailDimensions + ) async throws -> Data? { + let request = QLThumbnailGenerator.Request( + fileAt: fileURL, + size: CGSize(width: dimensions.width, height: dimensions.height), + scale: 1, + representationTypes: .thumbnail + ) + let representation = try await withCheckedThrowingContinuation { + (continuation: CheckedContinuation) in + QLThumbnailGenerator.shared.generateBestRepresentation( + for: request + ) { representation, error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume(returning: representation) + } + } + } + guard let image = representation?.cgImage else { return nil } + let data = NSMutableData() + guard let destination = CGImageDestinationCreateWithData( + data, + UTType.png.identifier as CFString, + 1, + nil + ) else { + return nil + } + CGImageDestinationAddImage(destination, image, nil) + guard CGImageDestinationFinalize(destination) else { + return nil + } + return data as Data + } } private struct KDriveThumbnailDimensions { diff --git a/potassiumProviderFileProvider/PotassiumFileProviderExtension.swift b/potassiumProviderFileProvider/PotassiumFileProviderExtension.swift index 45f7278..39f6479 100644 --- a/potassiumProviderFileProvider/PotassiumFileProviderExtension.swift +++ b/potassiumProviderFileProvider/PotassiumFileProviderExtension.swift @@ -14,7 +14,7 @@ public final class PotassiumFileProviderExtension: NSObject, NSFileProviderRepli let domain: NSFileProviderDomain let manager: NSFileProviderManager - private let temporaryDirectoryURL: URL + let temporaryDirectoryURL: URL private var remotePollingTask: Task? var fileProviderDomain: NSFileProviderDomain { @@ -43,6 +43,11 @@ public final class PotassiumFileProviderExtension: NSObject, NSFileProviderRepli Task { do { let runtime = try await FileProviderRuntime.load(domain: domain) + if let vault = runtime.encryptedVault { + _ = try await vault.synchronize() + await signalWorkingSet(runtime: runtime) + return + } let systemItems = try await MaterializedSetReader.read(using: manager) let materializedItems = systemItems.compactMap { item -> KDriveMaterializedItem? in let fileID: Int @@ -102,6 +107,19 @@ public final class PotassiumFileProviderExtension: NSObject, NSFileProviderRepli } return } + if let vault = loadedRuntime.encryptedVault { + _ = try await vault.synchronize() + guard let vaultIdentifier = VaultItemIdentifier( + fileProviderIdentifier: identifier.rawValue + ) else { + throw NSFileProviderError(.noSuchItem) + } + let vaultItem = try await vault.item(vaultIdentifier) + await lifecycle.finish(markProgressComplete: true) { + completionHandler(FileProviderItem(vaultItem: vaultItem), nil) + } + return + } let itemIdentifier = try KDriveItemIdentifier(rawValue: identifier.rawValue) guard let fileID = itemIdentifier.fileID(rootFileID: loadedRuntime.configuration.rootFileID) else { @@ -154,6 +172,47 @@ public final class PotassiumFileProviderExtension: NSObject, NSFileProviderRepli do { let loadedRuntime = try await FileProviderRuntime.load(domain: domain) runtime = loadedRuntime + if let vault = loadedRuntime.encryptedVault { + guard let identifier = VaultItemIdentifier( + fileProviderIdentifier: itemIdentifier.rawValue + ) else { + throw NSFileProviderError(.noSuchItem) + } + let requestedRevision: VaultRevision? + if let requestedVersion { + guard let revision = VaultRevision( + data: requestedVersion.contentVersion + ) else { + throw contentVersionUnavailableError() + } + requestedRevision = revision + } else { + requestedRevision = nil + } + let current = try await vault.item(identifier) + progress.prepareForByteCount(Int(current.plaintextSize)) + let temporaryURL = temporaryDirectoryURL + .appendingPathComponent("download-\(UUID().uuidString)") + .appendingPathExtension((current.filename as NSString).pathExtension) + let fetched = try await Self.contentTransferLimiter.withPermit { + try await vault.fetchContent( + itemID: identifier, + expectedRevision: requestedRevision, + to: temporaryURL + ) + } + let delivered = await lifecycle.finish(markProgressComplete: true) { + completionHandler( + temporaryURL, + FileProviderItem(vaultItem: fetched), + nil + ) + } + if delivered == false { + try? FileManager.default.removeItem(at: temporaryURL) + } + return + } let identifier = try KDriveItemIdentifier(rawValue: itemIdentifier.rawValue) guard let fileID = identifier.fileID(rootFileID: loadedRuntime.configuration.rootFileID) else { throw NSFileProviderError(.noSuchItem) @@ -265,6 +324,73 @@ public final class PotassiumFileProviderExtension: NSObject, NSFileProviderRepli do { let loadedRuntime = try await FileProviderRuntime.load(domain: self.domain) runtime = loadedRuntime + if let vault = loadedRuntime.encryptedVault { + let parentID = try self.vaultParentIdentifier( + itemTemplate.parentItemIdentifier + ) + let created: VaultItem + if isDirectory { + created = try await vault.createDirectory( + parentID: parentID, + filename: itemTemplate.filename, + createdAt: itemTemplate.creationDate.flatMap { $0 } ?? Date() + ) + } else { + let generatedEmptyURL: URL? + let plaintextURL: URL + if let url { + plaintextURL = url + generatedEmptyURL = nil + } else { + let emptyURL = self.temporaryDirectoryURL + .appendingPathComponent("empty-\(UUID().uuidString)") + guard FileManager.default.createFile( + atPath: emptyURL.path, + contents: Data() + ) else { + throw CocoaError(.fileWriteUnknown) + } + plaintextURL = emptyURL + generatedEmptyURL = emptyURL + } + defer { + if let generatedEmptyURL { + try? FileManager.default.removeItem(at: generatedEmptyURL) + } + } + created = try await Self.contentTransferLimiter.withPermit { + try await vault.createFile( + parentID: parentID, + filename: itemTemplate.filename, + contentTypeIdentifier: itemTemplate.contentType?.identifier, + plaintextURL: plaintextURL, + modifiedAt: itemTemplate.contentModificationDate.flatMap { $0 } ?? Date() + ) + } + } + await self.signalEncryptedMutation( + runtime: loadedRuntime, + parentIDs: [parentID], + includesTrash: false + ) + await ProviderEventRecorder.recordActivity( + kind: .create, + runtime: loadedRuntime, + itemIdentifier: created.id.fileProviderIdentifier, + itemName: nil, + itemPath: nil, + summary: "Created an encrypted \(kind)." + ) + await lifecycle.finish(markProgressComplete: true) { + completionHandler( + FileProviderItem(vaultItem: created), + [], + false, + nil + ) + } + return + } let coordinator = self.makeMutationCoordinator(runtime: loadedRuntime) let parentID = try self.fileID(forParentIdentifier: itemTemplate.parentItemIdentifier, runtime: loadedRuntime) let createdItem: KDriveRemoteItem @@ -358,6 +484,73 @@ public final class PotassiumFileProviderExtension: NSObject, NSFileProviderRepli do { let loadedRuntime = try await FileProviderRuntime.load(domain: self.domain) runtime = loadedRuntime + if let vault = loadedRuntime.encryptedVault { + guard let vaultItemID = VaultItemIdentifier( + fileProviderIdentifier: item.itemIdentifier.rawValue + ), + let baseContentRevision = VaultRevision(data: version.contentVersion), + let baseMetadataRevision = VaultRevision(data: version.metadataVersion) else { + throw NSFileProviderError(.cannotSynchronize) + } + let current = try await vault.item(vaultItemID) + if changedFields.contains(.parentItemIdentifier), + item.parentItemIdentifier == .trashContainer { + try await vault.trash( + itemID: vaultItemID, + baseContentRevision: baseContentRevision, + baseMetadataRevision: baseMetadataRevision + ) + await self.signalEncryptedMutation( + runtime: loadedRuntime, + parentIDs: [current.parentID], + includesTrash: true + ) + await lifecycle.finish(markProgressComplete: true) { + completionHandler(nil, [], false, nil) + } + return + } + + let parentID = changedFields.contains(.parentItemIdentifier) + ? try self.vaultParentIdentifier(item.parentItemIdentifier) + : current.parentID + let updated = try await Self.contentTransferLimiter.withPermit { + try await vault.modify( + itemID: vaultItemID, + baseContentRevision: baseContentRevision, + baseMetadataRevision: baseMetadataRevision, + parentID: parentID, + filename: changedFields.contains(.filename) + ? item.filename + : current.filename, + favorite: current.isFavorite, + plaintextURL: changesContents ? newContents : nil, + modifiedAt: item.contentModificationDate.flatMap { $0 } ?? Date() + ) + } + await self.signalEncryptedMutation( + runtime: loadedRuntime, + parentIDs: [current.parentID, updated.parentID], + includesTrash: false + ) + await ProviderEventRecorder.recordActivity( + kind: .modify, + runtime: loadedRuntime, + itemIdentifier: updated.id.fileProviderIdentifier, + itemName: nil, + itemPath: nil, + summary: "Modified an encrypted item." + ) + await lifecycle.finish(markProgressComplete: true) { + completionHandler( + FileProviderItem(vaultItem: updated), + [], + false, + nil + ) + } + return + } let identifier = try KDriveItemIdentifier(rawValue: item.itemIdentifier.rawValue) guard let fileID = identifier.fileID(rootFileID: loadedRuntime.configuration.rootFileID) else { throw NSFileProviderError(.noSuchItem) @@ -572,6 +765,37 @@ public final class PotassiumFileProviderExtension: NSObject, NSFileProviderRepli do { let loadedRuntime = try await FileProviderRuntime.load(domain: self.domain) runtime = loadedRuntime + if let vault = loadedRuntime.encryptedVault { + guard let vaultItemID = VaultItemIdentifier( + fileProviderIdentifier: itemIdentifier.rawValue + ), + let baseContentRevision = VaultRevision(data: version.contentVersion), + let baseMetadataRevision = VaultRevision(data: version.metadataVersion) else { + throw NSFileProviderError(.cannotSynchronize) + } + try await vault.purge( + itemID: vaultItemID, + baseContentRevision: baseContentRevision, + baseMetadataRevision: baseMetadataRevision + ) + await self.signalEncryptedMutation( + runtime: loadedRuntime, + parentIDs: [], + includesTrash: true + ) + await ProviderEventRecorder.recordActivity( + kind: .delete, + runtime: loadedRuntime, + itemIdentifier: vaultItemID.fileProviderIdentifier, + itemName: nil, + itemPath: nil, + summary: "Purged an encrypted item." + ) + await lifecycle.finish(markProgressComplete: true) { + completionHandler(nil) + } + return + } let identifier = try KDriveItemIdentifier(rawValue: itemIdentifier.rawValue) guard let fileID = identifier.fileID(rootFileID: loadedRuntime.configuration.rootFileID) else { throw NSFileProviderError(.noSuchItem) @@ -656,6 +880,42 @@ public final class PotassiumFileProviderExtension: NSObject, NSFileProviderRepli ?? runtime.configuration.rootFileID } + func vaultParentIdentifier( + _ parentIdentifier: NSFileProviderItemIdentifier + ) throws -> VaultItemIdentifier? { + if parentIdentifier == .rootContainer { + return nil + } + guard parentIdentifier != .trashContainer, + parentIdentifier != .workingSet, + let identifier = VaultItemIdentifier( + fileProviderIdentifier: parentIdentifier.rawValue + ) else { + throw NSFileProviderError(.cannotSynchronize) + } + return identifier + } + + func signalEncryptedMutation( + runtime: FileProviderRuntime, + parentIDs: [VaultItemIdentifier?], + includesTrash: Bool + ) async { + var containers = parentIDs.map { parentID in + parentID.map { + NSFileProviderItemIdentifier($0.fileProviderIdentifier) + } ?? .rootContainer + } + if includesTrash { + containers.append(.trashContainer) + } + for container in uniqueContainerIdentifiers(containers) + where container != .workingSet { + await signalEnumerator(for: container, runtime: runtime) + } + await signalWorkingSet(runtime: runtime) + } + func recordProviderFailure( _ error: Error, runtime: FileProviderRuntime?, @@ -769,6 +1029,15 @@ public final class PotassiumFileProviderExtension: NSObject, NSFileProviderRepli try await Task.sleep(for: .seconds(KDriveWorkingSetPollCoordinator.pollingInterval)) guard let self, Task.isCancelled == false else { return } let runtime = try await FileProviderRuntime.load(domain: self.domain) + if let vault = runtime.encryptedVault { + let before = try await vault.workingSet(limit: 1_000) + _ = try await vault.synchronize() + let after = try await vault.workingSet(limit: 1_000) + if before != after { + await self.signalWorkingSet(runtime: runtime) + } + continue + } let outcome = try await self.pollWorkingSet(runtime: runtime) if outcome.didPoll, outcome.changes.isEmpty == false { await self.signalWorkingSet(runtime: runtime) diff --git a/potassiumProviderFileProvider/ProviderEventRecording.swift b/potassiumProviderFileProvider/ProviderEventRecording.swift index 281733d..604504f 100644 --- a/potassiumProviderFileProvider/ProviderEventRecording.swift +++ b/potassiumProviderFileProvider/ProviderEventRecording.swift @@ -6,6 +6,14 @@ import PotassiumProviderCore enum ProviderEventRecorder { static func saveConflict(_ event: KDriveConflictEvent, runtime: FileProviderRuntime) async { guard let eventStore = runtime.eventStore else { return } + var event = event + if runtime.configuration.encryptionMode == .opaqueVaultV1 { + event.originalItemName = nil + event.originalItemPath = nil + event.conflictItemName = nil + event.conflictItemPath = nil + event.stagedUploadRelativePath = nil + } do { try await eventStore.saveConflict(event) try await eventStore.recordActivity(KDriveProviderActivityEvent( @@ -41,6 +49,8 @@ enum ProviderEventRecorder { httpStatusCode: Int? = nil, remoteRequestID: String? = nil ) async { + let storesOpaqueSummaryOnly = + runtime.configuration.encryptionMode == .opaqueVaultV1 await recordActivity( kind: kind, eventStore: runtime.eventStore, @@ -48,8 +58,8 @@ enum ProviderEventRecorder { driveID: runtime.configuration.driveID, scope: .domain, itemIdentifier: itemIdentifier, - itemName: itemName, - itemPath: itemPath, + itemName: storesOpaqueSummaryOnly ? nil : itemName, + itemPath: storesOpaqueSummaryOnly ? nil : itemPath, summary: summary, relatedConflictID: relatedConflictID, outcome: outcome, diff --git a/potassiumProviderTests/KDriveObjectStoreLeakageTests.swift b/potassiumProviderTests/KDriveObjectStoreLeakageTests.swift new file mode 100644 index 0000000..ae9cdb4 --- /dev/null +++ b/potassiumProviderTests/KDriveObjectStoreLeakageTests.swift @@ -0,0 +1,55 @@ +import Foundation +@testable import PotassiumProviderCore +import Testing + +struct KDriveObjectStoreLeakageTests { + @Test func opaqueUploadRequestContainsNoLogicalMetadata() async throws { + let token = Data(0..<20).opaqueTokenForTest + let store = PotassiumKDriveObjectStore( + driveID: 42, + bearerToken: "redacted-bearer-token", + apiBaseURL: URL(string: "https://api.example.test")! + ) + let request = try await store.uploadRequest( + containerID: 73, + token: token, + byteCount: 65_536 + ) + let observableRequest = [ + request.url?.absoluteString ?? "", + request.allHTTPHeaderFields? + .sorted { $0.key < $1.key } + .map { "\($0.key):\($0.value)" } + .joined(separator: "\n") ?? "", + request.httpBody.map { String(decoding: $0, as: UTF8.self) } ?? "", + ].joined(separator: "\n") + + for forbidden in [ + "Quarterly Plan.pdf", + "/Private/Alice Mac/Desktop", + ".pdf", + "application/pdf", + "2026-07-28", + "plaintext-sha256", + "Alice Mac", + "highly private body bytes", + ] { + #expect(observableRequest.contains(forbidden) == false) + } + #expect(request.url?.query?.contains("file_name=\(token).bin") == true) + #expect( + request.value(forHTTPHeaderField: "Content-Type") + == "application/octet-stream" + ) + #expect(request.httpBody == nil) + } +} + +private extension Data { + var opaqueTokenForTest: String { + base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +} diff --git a/potassiumProviderTests/VaultCryptographyTests.swift b/potassiumProviderTests/VaultCryptographyTests.swift new file mode 100644 index 0000000..93f0ed9 --- /dev/null +++ b/potassiumProviderTests/VaultCryptographyTests.swift @@ -0,0 +1,408 @@ +import CryptoKit +import Foundation +@testable import PotassiumProviderCore +import Testing + +struct VaultCryptographyTests { + @Test func hkdfDerivationMatchesFrozenVector() { + let key = VaultCryptography.deriveKey( + rootKey: VaultKeyMaterial(data: Data(repeating: 0x0B, count: 32))!, + vaultID: VaultIdentifier( + rawValue: UUID(uuidString: "00112233-4455-6677-8899-AABBCCDDEEFF")! + ), + label: "metadata", + salt: Data((0...12).map(UInt8.init)) + ) + #expect( + key.data.hex + == "a8558f162164c1b7179ca27f2de10d10bf6a920c4c42b4570967ef9c143aa006" + ) + } + + @Test func aesGCMMatchesNISTKnownAnswerVector() throws { + let key = SymmetricKey(data: Data(repeating: 0, count: 32)) + let nonce = try AES.GCM.Nonce(data: Data(repeating: 0, count: 12)) + let sealed = try AES.GCM.seal( + Data(repeating: 0, count: 16), + using: key, + nonce: nonce + ) + #expect(sealed.ciphertext.hex == "cea7403d4d606b6e074ec5d3baf39d18") + #expect(sealed.tag.hex == "d0d1c8a799996bf0265b98b5d48ab919") + } + + @Test func fileProviderItemIdentifierRoundTrips() { + let identifier = VaultItemIdentifier( + rawValue: UUID(uuidString: "00112233-4455-6677-8899-AABBCCDDEEFF")! + ) + + #expect( + VaultItemIdentifier(fileProviderIdentifier: identifier.fileProviderIdentifier) + == identifier + ) + #expect(VaultItemIdentifier(fileProviderIdentifier: "42") == nil) + #expect(VaultItemIdentifier(fileProviderIdentifier: "ev1:not-base64") == nil) + } + + @Test func encryptedEnvelopeRoundTripsAndBindsContext() throws { + let vaultID = VaultIdentifier( + rawValue: UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE")! + ) + let otherVaultID = VaultIdentifier( + rawValue: UUID(uuidString: "11111111-2222-3333-4444-555555555555")! + ) + let rootKey = VaultKeyMaterial(data: Data(repeating: 0x11, count: 32))! + let tokenData = Data(0..<20) + let token = tokenData.vaultBase64URLEncodedString() + let value = Fixture(message: "private-name.txt", count: 42) + + let encrypted = try VaultCryptography.seal( + value, + role: .metadata, + objectToken: token, + rootKey: rootKey, + vaultID: vaultID + ) + let randomized = try VaultCryptography.seal( + value, + role: .metadata, + objectToken: token, + rootKey: rootKey, + vaultID: vaultID + ) + #expect(encrypted != randomized) + #expect(encrypted.range(of: Data(value.message.utf8)) == nil) + + let opened = try VaultCryptography.open( + Fixture.self, + envelope: encrypted, + expectedRole: .metadata, + expectedObjectToken: token, + rootKey: rootKey, + vaultID: vaultID + ) + #expect(opened == value) + + #expect(throws: VaultCryptoError.unexpectedObjectRole) { + try VaultCryptography.open( + Fixture.self, + envelope: encrypted, + expectedRole: .transaction, + expectedObjectToken: token, + rootKey: rootKey, + vaultID: vaultID + ) + } + #expect(throws: VaultCryptoError.unexpectedVault) { + try VaultCryptography.open( + Fixture.self, + envelope: encrypted, + expectedRole: .metadata, + expectedObjectToken: token, + rootKey: rootKey, + vaultID: otherVaultID + ) + } + + var tampered = encrypted + tampered[tampered.index(before: tampered.endIndex)] ^= 0x01 + #expect(throws: VaultCryptoError.authenticationFailed) { + try VaultCryptography.open( + Fixture.self, + envelope: tampered, + expectedRole: .metadata, + expectedObjectToken: token, + rootKey: rootKey, + vaultID: vaultID + ) + } + } + + @Test func contentKeyWrapRoundTripsAndRejectsObjectSwap() throws { + let vaultID = VaultIdentifier() + let rootKey = try VaultKeyMaterial.random() + let contentKey = try VaultKeyMaterial.random() + let objectToken = try VaultCryptography.makeObjectToken() + let otherToken = try VaultCryptography.makeObjectToken() + + let wrapped = try VaultCryptography.wrapContentKey( + contentKey, + objectToken: objectToken, + rootKey: rootKey, + vaultID: vaultID + ) + let unwrapped = try VaultCryptography.unwrapContentKey( + wrapped, + objectToken: objectToken, + rootKey: rootKey, + vaultID: vaultID + ) + #expect(unwrapped == contentKey) + + #expect(throws: VaultCryptoError.authenticationFailed) { + try VaultCryptography.unwrapContentKey( + wrapped, + objectToken: otherToken, + rootKey: rootKey, + vaultID: vaultID + ) + } + } + + @Test func bootstrapUnlockRequiresRecoverySecretAndVaultIdentity() throws { + let vaultID = VaultIdentifier() + let rootKey = try VaultKeyMaterial.random() + let recoverySecret = try VaultKeyMaterial.random() + let bootstrap = try VaultBootstrap.create( + vaultID: vaultID, + rootKey: rootKey, + recoverySecret: recoverySecret + ) + + let unlocked = try VaultBootstrap.unlock( + bootstrap, + recoverySecret: recoverySecret, + expectedVaultID: vaultID + ) + #expect(unlocked.vaultID == vaultID) + #expect(unlocked.rootKey == rootKey) + #expect(unlocked.keyEpoch == VaultFormat.currentKeyEpoch) + + #expect(throws: VaultCryptoError.authenticationFailed) { + try VaultBootstrap.unlock( + bootstrap, + recoverySecret: VaultKeyMaterial(data: Data(repeating: 0xAA, count: 32))!, + expectedVaultID: vaultID + ) + } + #expect(throws: VaultCryptoError.unexpectedVault) { + try VaultBootstrap.unlock( + bootstrap, + recoverySecret: recoverySecret, + expectedVaultID: VaultIdentifier() + ) + } + } + + @Test func recoveryKitRoundTripsAndDetectsTypingError() throws { + let kit = VaultRecoveryKit( + vaultID: VaultIdentifier( + rawValue: UUID(uuidString: "01234567-89AB-CDEF-0123-456789ABCDEF")! + ), + driveID: 123, + vaultRootFileID: 456, + vaultHeaderFileID: 789, + recoverySecret: VaultKeyMaterial(data: Data(0..<32))! + ) + + let decoded = try VaultRecoveryKit(encoded: kit.encoded.lowercased()) + #expect(decoded == kit) + #expect(kit.encoded.hasPrefix("KPV1-")) + + var mistyped = kit.encoded + let index = mistyped.index(before: mistyped.endIndex) + mistyped.replaceSubrange(index...index, with: mistyped[index] == "A" ? "B" : "A") + let detectsTypingError: Bool = { + do { + _ = try VaultRecoveryKit(encoded: mistyped) + return false + } catch VaultCryptoError.recoveryKitChecksumMismatch { + return true + } catch VaultCryptoError.recoveryKitInvalid { + return true + } catch { + return false + } + }() + #expect(detectsTypingError) + } + + @Test( + arguments: [ + Data(), + Data("hello".utf8), + Data(repeating: 0x5A, count: 5_000), + Data(repeating: 0xC3, count: VaultFormat.contentFrameSize + 123), + ] + ) + func contentCipherRoundTripsAcrossPaddingAndFrameBoundaries(plaintext: Data) throws { + try withTemporaryDirectory { directory in + let plaintextURL = directory.appendingPathComponent("plain") + let encryptedURL = directory.appendingPathComponent("cipher") + let decryptedURL = directory.appendingPathComponent("opened") + try plaintext.write(to: plaintextURL) + let context = try contentContext() + + let result = try VaultContentCipher.encrypt( + plaintextURL: plaintextURL, + ciphertextURL: encryptedURL, + context: context + ) + #expect(result.plaintextLength == plaintext.count) + #expect(result.plaintextDigest == Data(SHA256.hash(data: plaintext))) + #expect((try Data(contentsOf: encryptedURL)).range(of: plaintext) == nil) + + try VaultContentCipher.decrypt( + ciphertextURL: encryptedURL, + plaintextURL: decryptedURL, + context: context, + contentKey: result.contentKey, + expectedNoncePrefix: result.noncePrefix, + expectedPlaintextLength: result.plaintextLength, + expectedPlaintextDigest: result.plaintextDigest, + expectedFrameCount: result.frameCount + ) + #expect(try Data(contentsOf: decryptedURL) == plaintext) + } + } + + @Test func contentCipherRandomizesIdenticalPlaintextAndRejectsTampering() throws { + try withTemporaryDirectory { directory in + let plaintext = Data(repeating: 0x7E, count: 12_345) + let plaintextURL = directory.appendingPathComponent("plain") + let firstURL = directory.appendingPathComponent("first") + let secondURL = directory.appendingPathComponent("second") + let openedURL = directory.appendingPathComponent("opened") + try plaintext.write(to: plaintextURL) + let firstContext = try contentContext() + let secondContext = try contentContext() + + let first = try VaultContentCipher.encrypt( + plaintextURL: plaintextURL, + ciphertextURL: firstURL, + context: firstContext + ) + _ = try VaultContentCipher.encrypt( + plaintextURL: plaintextURL, + ciphertextURL: secondURL, + context: secondContext + ) + #expect(try Data(contentsOf: firstURL) != Data(contentsOf: secondURL)) + + var tampered = try Data(contentsOf: firstURL) + tampered[tampered.index(before: tampered.endIndex)] ^= 0x80 + try tampered.write(to: firstURL) + let rejectedTamperingWithoutPartialPlaintext: Bool = { + do { + try VaultContentCipher.decrypt( + ciphertextURL: firstURL, + plaintextURL: openedURL, + context: firstContext, + contentKey: first.contentKey, + expectedNoncePrefix: first.noncePrefix, + expectedPlaintextLength: first.plaintextLength, + expectedPlaintextDigest: first.plaintextDigest, + expectedFrameCount: first.frameCount + ) + return false + } catch VaultCryptoError.authenticationFailed { + return FileManager.default.fileExists(atPath: openedURL.path) == false + } catch { + return false + } + }() + #expect(rejectedTamperingWithoutPartialPlaintext) + } + } + + @Test func cancelledContentDecryptionRemovesPartialPlaintext() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("VaultCryptographyTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let plaintextURL = directory.appendingPathComponent("plain") + let encryptedURL = directory.appendingPathComponent("cipher") + let openedURL = directory.appendingPathComponent("opened") + try Data(repeating: 0x4D, count: VaultFormat.contentFrameSize * 2 + 1) + .write(to: plaintextURL) + let context = try contentContext() + let result = try VaultContentCipher.encrypt( + plaintextURL: plaintextURL, + ciphertextURL: encryptedURL, + context: context + ) + + let task = Task { + await Task.yield() + try VaultContentCipher.decrypt( + ciphertextURL: encryptedURL, + plaintextURL: openedURL, + context: context, + contentKey: result.contentKey, + expectedNoncePrefix: result.noncePrefix, + expectedPlaintextLength: result.plaintextLength, + expectedPlaintextDigest: result.plaintextDigest, + expectedFrameCount: result.frameCount + ) + } + task.cancel() + + await #expect(throws: CancellationError.self) { + try await task.value + } + #expect(FileManager.default.fileExists(atPath: openedURL.path) == false) + } + + @Test func finalFramePaddingUsesDocumentedBuckets() { + #expect(VaultContentCipher.paddedFinalFrameSize(for: 0) == 4_096) + #expect(VaultContentCipher.paddedFinalFrameSize(for: 4_096) == 4_096) + #expect(VaultContentCipher.paddedFinalFrameSize(for: 4_097) == 8_192) + #expect(VaultContentCipher.paddedFinalFrameSize(for: 600_000) == 1_048_576) + #expect( + VaultContentCipher.paddedFinalFrameSize(for: VaultFormat.contentFrameSize) + == VaultFormat.contentFrameSize + ) + } + + @Test func inMemoryKeyStoreKeepsRootAndTrustedFrontierSeparate() async throws { + let store = InMemoryVaultKeyStore() + let vaultID = VaultIdentifier() + let key = try VaultKeyMaterial.random() + let state = VaultTrustedState( + vaultID: vaultID, + keyEpoch: 2, + frontier: VaultFrontier(transactionIDs: [UUID()]), + checkpointDigest: Data(repeating: 0x20, count: 32) + ) + + try await store.saveRootKey(key, vaultID: vaultID) + try await store.saveTrustedState(state) + #expect(try await store.loadRootKey(vaultID: vaultID) == key) + #expect(try await store.loadTrustedState(vaultID: vaultID) == state) + + try await store.deleteRootKey(vaultID: vaultID) + #expect(try await store.loadRootKey(vaultID: vaultID) == nil) + #expect(try await store.loadTrustedState(vaultID: vaultID) == state) + } + + private func contentContext() throws -> VaultContentEncryptionContext { + VaultContentEncryptionContext( + vaultID: VaultIdentifier(), + itemID: VaultItemIdentifier(), + contentRevision: try VaultRevision.random(), + objectToken: try VaultCryptography.makeObjectToken() + ) + } + + private func withTemporaryDirectory( + _ operation: (URL) throws -> Void + ) throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("VaultCryptographyTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + try operation(directory) + } +} + +private extension Data { + var hex: String { + map { String(format: "%02x", $0) }.joined() + } +} + +private struct Fixture: Codable, Equatable { + let message: String + let count: Int +} diff --git a/potassiumProviderTests/VaultDomainConfigurationTests.swift b/potassiumProviderTests/VaultDomainConfigurationTests.swift new file mode 100644 index 0000000..25a0b1b --- /dev/null +++ b/potassiumProviderTests/VaultDomainConfigurationTests.swift @@ -0,0 +1,58 @@ +import Foundation +@testable import PotassiumProviderCore +import Testing + +struct VaultDomainConfigurationTests { + @Test func legacyConfigurationDefaultsToPlaintextMode() throws { + let json = """ + { + "domainIdentifier": "legacy-domain", + "accountIdentifier": "legacy-account", + "displayName": "Legacy", + "driveID": 12, + "driveName": "Legacy", + "rootFileID": 1, + "knownFolderLayout": "machineNamespace", + "createdAt": "2026-07-28T00:00:00Z", + "updatedAt": "2026-07-28T00:00:00Z" + } + """ + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + + let configuration = try decoder.decode( + ProviderDomainConfiguration.self, + from: Data(json.utf8) + ) + #expect(configuration.encryptionMode == .legacyPlaintext) + #expect(configuration.vault == nil) + } + + @Test func encryptedConfigurationRoundTripsThroughFileStore() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("VaultDomainConfigurationTests-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: directory) } + let store = DomainConfigurationFileStore(directoryURL: directory) + let vault = ProviderVaultConfiguration( + vaultIdentifier: VaultIdentifier(), + vaultRootFileID: 101, + vaultHeaderFileID: 102, + keyEpoch: 3 + ) + let configuration = ProviderDomainConfiguration( + domainIdentifier: "encrypted-domain", + displayName: "Private", + driveID: 9, + driveName: "Drive", + encryptionMode: .opaqueVaultV1, + vault: vault, + createdAt: Date(timeIntervalSince1970: 100), + updatedAt: Date(timeIntervalSince1970: 200) + ) + + try await store.save(configuration) + let loaded = try await store.configuration(domainIdentifier: configuration.domainIdentifier) + #expect(loaded == configuration) + #expect(loaded?.vault == vault) + } +} diff --git a/potassiumProviderTests/VaultJournalTests.swift b/potassiumProviderTests/VaultJournalTests.swift new file mode 100644 index 0000000..37d900e --- /dev/null +++ b/potassiumProviderTests/VaultJournalTests.swift @@ -0,0 +1,262 @@ +import Foundation +@testable import PotassiumProviderCore +import Testing + +struct VaultJournalTests { + @Test func fixedTransactionIs64KiBAuthenticatedAndRandomized() throws { + let rootKey = VaultKeyMaterial(data: Data(repeating: 0x11, count: 32))! + let vaultID = VaultIdentifier( + rawValue: UUID(uuidString: "00112233-4455-6677-8899-AABBCCDDEEFF")! + ) + let transaction = VaultTransaction( + id: UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE")!, + parents: VaultFrontier(), + deviceID: UUID(uuidString: "11111111-2222-3333-4444-555555555555")!, + createdAt: Date(timeIntervalSince1970: 1_700_000_000), + operation: .upsert(Self.item(named: "private-name.txt")) + ) + let token = Data(0..<20).vaultBase64URLForTest() + + let first = try VaultFixedTransactionCodec.seal( + transaction, + objectToken: token, + rootKey: rootKey, + vaultID: vaultID + ) + let second = try VaultFixedTransactionCodec.seal( + transaction, + objectToken: token, + rootKey: rootKey, + vaultID: vaultID + ) + #expect(first.count == VaultFormat.transactionObjectSize) + #expect(first != second) + #expect(first.range(of: Data("private-name.txt".utf8)) == nil) + #expect(try VaultFixedTransactionCodec.open( + first, + objectToken: token, + rootKey: rootKey, + vaultID: vaultID + ) == transaction) + + var tampered = first + tampered[tampered.index(before: tampered.endIndex)] ^= 1 + #expect(throws: VaultCryptoError.authenticationFailed) { + try VaultFixedTransactionCodec.open( + tampered, + objectToken: token, + rootKey: rootKey, + vaultID: vaultID + ) + } + } + + @Test func concurrentContentEditsConvergeForEveryReplayOrder() throws { + let itemID = VaultItemIdentifier( + rawValue: UUID(uuidString: "AAAAAAAA-0000-0000-0000-000000000001")! + ) + let base = Self.item(named: "report.txt", id: itemID, contentByte: 1) + let createID = UUID(uuidString: "10000000-0000-0000-0000-000000000000")! + let firstID = UUID(uuidString: "20000000-0000-0000-0000-000000000000")! + let secondID = UUID(uuidString: "30000000-0000-0000-0000-000000000000")! + let create = VaultTransaction( + id: createID, + parents: VaultFrontier(), + deviceID: UUID(), + operation: .upsert(base) + ) + let first = VaultTransaction( + id: firstID, + parents: VaultFrontier(transactionIDs: [createID]), + deviceID: UUID(), + baseItem: base, + operation: .upsert(Self.item(named: "report.txt", id: itemID, contentByte: 2)) + ) + let second = VaultTransaction( + id: secondID, + parents: VaultFrontier(transactionIDs: [createID]), + deviceID: UUID(), + baseItem: base, + operation: .upsert(Self.item(named: "report.txt", id: itemID, contentByte: 3)) + ) + + let expected = try VaultJournalReducer.reduce([create, first, second]) + for order in [ + [second, create, first], + [first, second, create], + [create, second, first], + ] { + #expect(try VaultJournalReducer.reduce(order) == expected) + } + #expect(expected.items.count == 2) + #expect(expected.items[itemID]?.contentRevision == Self.revision(byte: 2)) + #expect(expected.conflicts.contains { $0.kind == VaultConflict.Kind.content }) + let conflictCopyID = try #require( + expected.conflicts.first { $0.kind == .content }?.conflictCopyID + ) + let conflictCopy = try #require(expected.items[conflictCopyID]) + #expect( + conflictCopy.metadataRevision + == (try VaultRevisionDigests.metadata(for: conflictCopy)) + ) + } + + @Test func independentConcurrentContentAndMetadataEditsMerge() throws { + let itemID = VaultItemIdentifier( + rawValue: UUID(uuidString: "BBBBBBBB-0000-0000-0000-000000000001")! + ) + var base = Self.item(named: "before.txt", id: itemID, contentByte: 1) + base.metadataRevision = try VaultRevisionDigests.metadata(for: base) + let createID = UUID(uuidString: "10000000-0000-0000-0000-000000000001")! + let create = VaultTransaction( + id: createID, + parents: VaultFrontier(), + deviceID: UUID(), + operation: .upsert(base) + ) + + var renamed = base + renamed.filename = "after.txt" + renamed.metadataRevision = try VaultRevisionDigests.metadata(for: renamed) + let rename = VaultTransaction( + id: UUID(uuidString: "20000000-0000-0000-0000-000000000001")!, + parents: VaultFrontier(transactionIDs: [createID]), + deviceID: UUID(), + baseItem: base, + operation: .upsert(renamed) + ) + + var edited = base + edited.contentRevision = Self.revision(byte: 2) + edited.modifiedAt = Date(timeIntervalSince1970: 2) + edited.plaintextSize = 2 + let contentEdit = VaultTransaction( + id: UUID(uuidString: "30000000-0000-0000-0000-000000000001")!, + parents: VaultFrontier(transactionIDs: [createID]), + deviceID: UUID(), + baseItem: base, + operation: .upsert(edited) + ) + + let expected = try VaultJournalReducer.reduce([create, rename, contentEdit]) + for order in [ + [contentEdit, create, rename], + [rename, contentEdit, create], + ] { + #expect(try VaultJournalReducer.reduce(order) == expected) + } + #expect(expected.items[itemID]?.filename == "after.txt") + #expect(expected.items[itemID]?.contentRevision == Self.revision(byte: 2)) + #expect(expected.items[itemID]?.plaintextSize == 2) + #expect(expected.conflicts.isEmpty) + } + + @Test func staleDeleteLosesToEditAndFolderDeleteLosesToChild() throws { + let folder = Self.item(named: "Folder", isDirectory: true, contentByte: 1) + let createFolder = VaultTransaction( + id: UUID(uuidString: "10000000-0000-0000-0000-000000000000")!, + parents: VaultFrontier(), + deviceID: UUID(), + operation: .upsert(folder) + ) + let child = Self.item( + named: "child.txt", + parentID: folder.id, + contentByte: 2 + ) + let createChild = VaultTransaction( + id: UUID(uuidString: "30000000-0000-0000-0000-000000000000")!, + parents: VaultFrontier(transactionIDs: [createFolder.id]), + deviceID: UUID(), + operation: .upsert(child) + ) + let purge = VaultTransaction( + id: UUID(uuidString: "20000000-0000-0000-0000-000000000000")!, + parents: VaultFrontier(transactionIDs: [createFolder.id]), + deviceID: UUID(), + baseItem: folder, + operation: .purge( + itemID: folder.id, + baseContentRevision: folder.contentRevision, + baseMetadataRevision: folder.metadataRevision + ) + ) + let state = try VaultJournalReducer.reduce([createFolder, createChild, purge]) + #expect(state.items[folder.id] != nil) + #expect(state.items[child.id] != nil) + #expect(state.conflicts.contains { + $0.kind == VaultConflict.Kind.folderDeletionRejected + }) + } + + @Test func merkleProofAllowsTrustedFrontierCompactionButRejectsWrongRoot() throws { + let transaction = VaultTransaction( + parents: VaultFrontier(), + deviceID: UUID(), + operation: .upsert(Self.item(named: "file")) + ) + let root = try VaultMerkleTree.root(for: [transaction]) + let proof = try VaultMerkleTree.proof(for: transaction.id, in: [transaction]) + let relabeledProof = VaultMerkleProof( + transactionID: UUID(), + leafDigest: proof.leafDigest, + steps: proof.steps + ) + #expect(VaultMerkleTree.verify(relabeledProof, expectedRoot: root) == false) + let trusted = VaultTrustedState( + vaultID: VaultIdentifier(), + keyEpoch: 1, + frontier: VaultFrontier(transactionIDs: [transaction.id]), + checkpointDigest: root + ) + let compactedState = VaultReducedState() + + try VaultRollbackValidator.validate( + trustedState: trusted, + currentState: compactedState, + checkpointRoot: root, + inclusionProofs: [proof] + ) + #expect(throws: VaultJournalError.rollbackDetected) { + try VaultRollbackValidator.validate( + trustedState: trusted, + currentState: compactedState, + checkpointRoot: Data(repeating: 0xCC, count: 32), + inclusionProofs: [proof] + ) + } + } + + private static func item( + named filename: String, + id: VaultItemIdentifier = VaultItemIdentifier(), + parentID: VaultItemIdentifier? = nil, + isDirectory: Bool = false, + contentByte: UInt8 = 1 + ) -> VaultItem { + VaultItem( + id: id, + parentID: parentID, + filename: filename, + isDirectory: isDirectory, + createdAt: Date(timeIntervalSince1970: 1), + modifiedAt: Date(timeIntervalSince1970: 1), + plaintextSize: isDirectory ? 0 : 1, + contentRevision: Self.revision(byte: contentByte), + metadataRevision: Self.revision(byte: contentByte &+ 100) + ) + } + + private static func revision(byte: UInt8) -> VaultRevision { + VaultRevision(data: Data(repeating: byte, count: VaultRevision.byteCount))! + } +} + +private extension Data { + func vaultBase64URLForTest() -> String { + base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +} diff --git a/potassiumProviderTests/VaultMigrationTests.swift b/potassiumProviderTests/VaultMigrationTests.swift new file mode 100644 index 0000000..fe80490 --- /dev/null +++ b/potassiumProviderTests/VaultMigrationTests.swift @@ -0,0 +1,388 @@ +import CryptoKit +import Foundation +@testable import PotassiumProviderCore +import Testing + +struct VaultMigrationTests { + @Test func encryptedJournalDoesNotExposeSourceMetadata() async throws { + let directory = Self.temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let fileURL = directory.appendingPathComponent("migration.bin") + let key = try VaultKeyMaterial.random() + let vaultID = VaultIdentifier() + let journal = VaultMigrationFileJournal( + fileURL: fileURL, + rootKey: key, + vaultID: vaultID + ) + let record = VaultMigrationRecord( + source: Self.sourceItem(filename: "secret-name.txt"), + destinationParentID: nil, + updatedAt: Date(timeIntervalSince1970: 100) + ) + try await journal.save(record) + + let stored = try Data(contentsOf: fileURL) + #expect(stored.range(of: Data("secret-name.txt".utf8)) == nil) + let reopened = VaultMigrationFileJournal( + fileURL: fileURL, + rootKey: key, + vaultID: vaultID + ) + #expect(try await reopened.record(sourceIdentifier: "source-1") == record) + + let wrongKey = VaultMigrationFileJournal( + fileURL: fileURL, + rootKey: try VaultKeyMaterial.random(), + vaultID: vaultID + ) + await #expect(throws: VaultCryptoError.authenticationFailed) { + try await wrongKey.records() + } + } + + @Test func sourcePurgeCannotPrecedeAuthenticatedVerification() async throws { + let directory = Self.temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let plaintext = Data("highly private migration bytes".utf8) + let source = FakeMigrationSource(data: plaintext) + let destination = FakeMigrationDestination(directory: directory) + let journal = InMemoryVaultMigrationJournal() + let coordinator = VaultMigrationCoordinator( + source: source, + destination: destination, + journal: journal, + temporaryDirectoryURL: directory + ) + _ = try await coordinator.inventory( + Self.sourceItem(filename: "private.txt", size: Int64(plaintext.count)), + destinationParentID: Optional.none + ) + + await #expect(throws: VaultMigrationError.sourceNotVerified("source-1")) { + try await coordinator.purgeVerifiedSource(sourceIdentifier: "source-1") + } + #expect(await source.purgeCount() == 0) + + let verified = try await coordinator.resume(sourceIdentifier: "source-1") + #expect(verified.state == VaultMigrationState.verified) + #expect(verified.verifiedDigest == Data(SHA256.hash(data: plaintext))) + #expect(await source.purgeCount() == 0) + + try await coordinator.purgeVerifiedSource(sourceIdentifier: "source-1") + #expect(await source.purgeCount() == 1) + #expect( + try await journal.record(sourceIdentifier: "source-1")?.state + == VaultMigrationState.sourcePurged + ) + } + + @Test func changedSourceFailsClosedBeforeCommit() async throws { + let directory = Self.temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let source = FakeMigrationSource(data: Data("first".utf8)) + let coordinator = VaultMigrationCoordinator( + source: source, + destination: FakeMigrationDestination(directory: directory), + journal: InMemoryVaultMigrationJournal(), + temporaryDirectoryURL: directory + ) + _ = try await coordinator.inventory( + Self.sourceItem(filename: "changing.txt", size: 5), + destinationParentID: Optional.none + ) + await source.setRevision("revision-2") + + await #expect(throws: VaultMigrationError.sourceChanged("source-1")) { + try await coordinator.resume(sourceIdentifier: "source-1") + } + #expect(await source.purgeCount() == 0) + } + + @Test func sourceChangeAfterVerificationStillBlocksPlaintextPurge() async throws { + let directory = Self.temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + let plaintext = Data("verified, then changed".utf8) + let source = FakeMigrationSource(data: plaintext) + let coordinator = VaultMigrationCoordinator( + source: source, + destination: FakeMigrationDestination(directory: directory), + journal: InMemoryVaultMigrationJournal(), + temporaryDirectoryURL: directory + ) + _ = try await coordinator.inventory( + Self.sourceItem( + filename: "changed-after-verification.txt", + size: Int64(plaintext.count) + ), + destinationParentID: Optional.none + ) + let verified = try await coordinator.resume(sourceIdentifier: "source-1") + #expect(verified.state == .verified) + + await source.setRevision("revision-2") + await #expect(throws: VaultMigrationError.sourceChanged("source-1")) { + try await coordinator.purgeVerifiedSource(sourceIdentifier: "source-1") + } + #expect(await source.purgeCount() == 0) + } + + private static func sourceItem( + filename: String, + size: Int64 = 12 + ) -> VaultMigrationSourceItem { + VaultMigrationSourceItem( + sourceIdentifier: "source-1", + sourceParentIdentifier: nil, + sourceRevision: "revision-1", + filename: filename, + isDirectory: false, + contentTypeIdentifier: "public.data", + createdAt: Date(timeIntervalSince1970: 10), + modifiedAt: Date(timeIntervalSince1970: 20), + plaintextSize: size + ) + } + + private static func temporaryDirectory() -> URL { + FileManager.default.temporaryDirectory.appendingPathComponent( + "VaultMigrationTests-\(UUID().uuidString)", + isDirectory: true + ) + } +} + +private actor FakeMigrationSource: VaultMigrationSourceProviding { + private let data: Data + private var revision = "revision-1" + private var purges = 0 + + init(data: Data) { + self.data = data + } + + func currentRevision(sourceIdentifier: String) -> String { + revision + } + + func download(sourceIdentifier: String, to destinationURL: URL) throws { + try data.write(to: destinationURL) + } + + func purgePlaintext(sourceIdentifier: String) { + purges += 1 + } + + func setRevision(_ value: String) { + revision = value + } + + func purgeCount() -> Int { + purges + } +} + +private actor FakeMigrationDestination: VaultMigrationDestinationProviding { + private let directory: URL + private var contentsByItemID: [VaultItemIdentifier: Data] = [:] + private var items: [VaultItemIdentifier: VaultItem] = [:] + + init(directory: URL) { + self.directory = directory + } + + func synchronize() -> VaultFrontier { VaultFrontier() } + + func item(_ identifier: VaultItemIdentifier) throws -> VaultItem { + guard let item = items[identifier] else { throw EncryptedVaultError.itemNotFound } + return item + } + + func children( + of parentID: VaultItemIdentifier?, + trashed: Bool, + cursor: String?, + limit: Int + ) -> VaultItemPage { + VaultItemPage(items: [], nextCursor: nil) + } + + func workingSet(limit: Int) -> [VaultItem] { Array(items.values) } + + func changes( + since anchorString: String, + scope: VaultChangeScope + ) -> VaultItemChanges { + VaultItemChanges( + updated: [], + deleted: [], + frontier: VaultFrontier() + ) + } + + func fetchContent( + itemID: VaultItemIdentifier, + expectedRevision: VaultRevision?, + to plaintextURL: URL + ) throws -> VaultItem { + guard let item = items[itemID], let data = contentsByItemID[itemID] else { + throw EncryptedVaultError.itemNotFound + } + try data.write(to: plaintextURL) + return item + } + + func createDirectory( + parentID: VaultItemIdentifier?, + filename: String, + createdAt: Date + ) -> VaultItem { + let revision = VaultRevision(hashing: Data(filename.utf8)) + let item = VaultItem( + parentID: parentID, + filename: filename, + isDirectory: true, + createdAt: createdAt, + modifiedAt: createdAt, + contentRevision: revision, + metadataRevision: revision + ) + items[item.id] = item + return item + } + + func stageFileImport( + itemID: VaultItemIdentifier, + plaintextURL: URL + ) throws -> VaultStagedContent { + let data = try Data(contentsOf: plaintextURL) + let ciphertextURL = directory.appendingPathComponent("stage-\(itemID.rawValue)") + try data.write(to: ciphertextURL) + contentsByItemID[itemID] = data + return VaultStagedContent( + itemID: itemID, + contentRevision: VaultRevision(hashing: data), + objectToken: Data(repeating: 7, count: 20).base64URL, + ciphertextURL: ciphertextURL, + wrappedContentKey: Data(repeating: 8, count: 60), + noncePrefix: 9, + plaintextLength: Int64(data.count), + plaintextDigest: Data(SHA256.hash(data: data)), + frameCount: 1 + ) + } + + func uploadStagedFileImport( + _ staged: VaultStagedContent + ) -> VaultUploadedContent { + VaultUploadedContent(staged: staged, remoteFileID: 99) + } + + func commitUploadedFileImport( + _ uploaded: VaultUploadedContent, + parentID: VaultItemIdentifier?, + filename: String, + contentTypeIdentifier: String?, + createdAt: Date, + modifiedAt: Date + ) -> VaultItem { + let item = VaultItem( + id: uploaded.staged.itemID, + parentID: parentID, + filename: filename, + isDirectory: false, + contentTypeIdentifier: contentTypeIdentifier, + createdAt: createdAt, + modifiedAt: modifiedAt, + plaintextSize: uploaded.staged.plaintextLength, + contentRevision: uploaded.staged.contentRevision, + metadataRevision: uploaded.staged.contentRevision, + contentReference: uploaded.contentReference + ) + items[item.id] = item + return item + } + + func discardStagedFileImport(_ staged: VaultStagedContent) { + try? FileManager.default.removeItem(at: staged.ciphertextURL) + } + + func createFile( + parentID: VaultItemIdentifier?, + filename: String, + contentTypeIdentifier: String?, + plaintextURL: URL, + modifiedAt: Date + ) throws -> VaultItem { + throw FakeMigrationError.unsupported + } + + func modify( + itemID: VaultItemIdentifier, + baseContentRevision: VaultRevision, + baseMetadataRevision: VaultRevision, + parentID: VaultItemIdentifier?, + filename: String, + favorite: Bool, + plaintextURL: URL?, + modifiedAt: Date + ) throws -> VaultItem { + throw FakeMigrationError.unsupported + } + + func trash( + itemID: VaultItemIdentifier, + baseContentRevision: VaultRevision, + baseMetadataRevision: VaultRevision + ) throws { + throw FakeMigrationError.unsupported + } + + func restore( + itemID: VaultItemIdentifier, + parentID: VaultItemIdentifier? + ) throws -> VaultItem { + throw FakeMigrationError.unsupported + } + + func purge( + itemID: VaultItemIdentifier, + baseContentRevision: VaultRevision, + baseMetadataRevision: VaultRevision + ) throws { + throw FakeMigrationError.unsupported + } + + func duplicate(itemID: VaultItemIdentifier) throws -> VaultItem { + throw FakeMigrationError.unsupported + } + + func versions(itemID: VaultItemIdentifier) -> [VaultVersion] { [] } + + func restoreVersion( + itemID: VaultItemIdentifier, + contentRevision: VaultRevision + ) throws -> VaultItem { + throw FakeMigrationError.unsupported + } +} + +private enum FakeMigrationError: Error { + case unsupported +} + +private extension Data { + var base64URL: String { + base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +} diff --git a/potassiumProviderTests/VaultProvisioningTests.swift b/potassiumProviderTests/VaultProvisioningTests.swift new file mode 100644 index 0000000..f169a6e --- /dev/null +++ b/potassiumProviderTests/VaultProvisioningTests.swift @@ -0,0 +1,550 @@ +import Foundation +@testable import PotassiumProviderCore +import Testing + +struct VaultProvisioningTests { + @Test func domainIsNotUnlockedUntilRecoveryKitConfirmation() async throws { + let directory = Self.temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let objectStore = InMemoryOpaqueObjectStore() + let keyStore = InMemoryVaultKeyStore() + let service = VaultProvisioningService( + objectStore: objectStore, + keyStore: keyStore, + temporaryDirectoryURL: directory + ) + + let pending = try await service.prepareNewVault(driveID: 42) + #expect(try await keyStore.loadRootKey(vaultID: pending.vaultID) == nil) + await #expect(throws: VaultProvisioningError.recoveryConfirmationMismatch) { + try await service.confirm( + pending, + recoveryKitConfirmation: "KPV1-NOT-A-RECOVERY-KIT" + ) + } + #expect(try await keyStore.loadRootKey(vaultID: pending.vaultID) == nil) + + let configuration = try await service.confirm( + pending, + recoveryKitConfirmation: pending.recoveryKit.encoded + ) + #expect(configuration == pending.vaultConfiguration) + #expect(try await keyStore.loadRootKey(vaultID: pending.vaultID) == pending.rootKey) + #expect( + try await keyStore.loadTrustedState(vaultID: pending.vaultID)?.frontier + == VaultFrontier() + ) + + let stored = await objectStore.filePayloads() + #expect(stored.allSatisfy { + $0.range(of: pending.rootKey.data) == nil + && $0.range(of: pending.recoveryKit.recoverySecret.data) == nil + }) + #expect(await objectStore.allTokens().allSatisfy(Self.isOpaqueToken)) + } + + @Test func recoveryImportAuthenticatesBootstrapAndCheckpoint() async throws { + let directory = Self.temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let objectStore = InMemoryOpaqueObjectStore() + let firstKeyStore = InMemoryVaultKeyStore() + let firstService = VaultProvisioningService( + objectStore: objectStore, + keyStore: firstKeyStore, + temporaryDirectoryURL: directory + ) + let pending = try await firstService.prepareNewVault(driveID: 9) + _ = try await firstService.confirm( + pending, + recoveryKitConfirmation: pending.recoveryKit.encoded + ) + + let returningKeyStore = InMemoryVaultKeyStore() + let returningService = VaultProvisioningService( + objectStore: objectStore, + keyStore: returningKeyStore, + temporaryDirectoryURL: directory + ) + let opened = try await returningService.openExistingVault( + recoveryKitText: pending.recoveryKit.encoded, + expectedDriveID: 9 + ) + #expect(opened == pending.vaultConfiguration) + #expect( + try await returningKeyStore.loadRootKey(vaultID: pending.vaultID) + == pending.rootKey + ) + } + + @Test func cancellingUnregisteredProvisioningDeletesOnlyItsNewRoot() async throws { + let directory = Self.temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let objectStore = InMemoryOpaqueObjectStore() + let service = VaultProvisioningService( + objectStore: objectStore, + keyStore: InMemoryVaultKeyStore(), + temporaryDirectoryURL: directory + ) + let unrelated = try await objectStore.createContainer( + parentID: 1, + token: Data(repeating: 0xAB, count: 20).opaqueToken + ) + let pending = try await service.prepareNewVault(driveID: 2) + + await service.cancel(pending) + #expect(await objectStore.contains(fileID: unrelated.id)) + #expect( + await objectStore.contains( + fileID: pending.vaultConfiguration.vaultRootFileID + ) == false + ) + } + + @Test func copyOnWriteDuplicateDecryptsSharedImmutableRevision() async throws { + let directory = Self.temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let objectStore = InMemoryOpaqueObjectStore() + let content = try await objectStore.createContainer( + parentID: 1, + token: Data(repeating: 1, count: 20).opaqueToken + ) + let journal = try await objectStore.createContainer( + parentID: 1, + token: Data(repeating: 2, count: 20).opaqueToken + ) + let checkpoints = try await objectStore.createContainer( + parentID: 1, + token: Data(repeating: 3, count: 20).opaqueToken + ) + let vaultID = VaultIdentifier() + let rootKey = try VaultKeyMaterial.random() + let configuration = ProviderDomainConfiguration( + domainIdentifier: "duplicate-domain", + displayName: "Encrypted", + driveID: 5, + driveName: "Drive", + encryptionMode: .opaqueVaultV1, + vault: ProviderVaultConfiguration( + vaultIdentifier: vaultID, + vaultRootFileID: 1, + vaultHeaderFileID: 2, + remoteLayout: VaultBootstrap.RemoteLayout( + contentContainerID: content.id, + journalContainerID: journal.id, + checkpointContainerID: checkpoints.id, + checkpointToken: Data(repeating: 4, count: 20).opaqueToken + ) + ) + ) + let localStore = try VaultSQLiteStore( + databaseURL: directory.appendingPathComponent("vault.sqlite3"), + domainIdentifier: configuration.domainIdentifier, + vaultID: vaultID, + rootKey: rootKey + ) + let service = try EncryptedVaultService( + configuration: configuration, + rootKey: rootKey, + deviceID: UUID(), + objectStore: objectStore, + localStore: localStore, + keyStore: InMemoryVaultKeyStore(), + temporaryDirectoryURL: directory + ) + let plaintext = Data("duplicate me without decrypting on the server".utf8) + let sourceURL = directory.appendingPathComponent("source") + try plaintext.write(to: sourceURL) + let original = try await service.createFile( + parentID: nil, + filename: "secret.txt", + contentTypeIdentifier: "public.plain-text", + plaintextURL: sourceURL, + modifiedAt: Date() + ) + let duplicate = try await service.duplicate(itemID: original.id) + #expect(duplicate.id != original.id) + #expect(duplicate.contentReference == original.contentReference) + + let openedURL = directory.appendingPathComponent("opened") + _ = try await service.fetchContent( + itemID: duplicate.id, + expectedRevision: duplicate.contentRevision, + to: openedURL + ) + #expect(try Data(contentsOf: openedURL) == plaintext) + } + + @Test func returningDeviceRejectsOmittedRemoteJournalObject() async throws { + let directory = Self.temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let objectStore = InMemoryOpaqueObjectStore() + let keyStore = InMemoryVaultKeyStore() + let provisioning = VaultProvisioningService( + objectStore: objectStore, + keyStore: keyStore, + temporaryDirectoryURL: directory + ) + let pending = try await provisioning.prepareNewVault(driveID: 8) + _ = try await provisioning.confirm( + pending, + recoveryKitConfirmation: pending.recoveryKit.encoded + ) + let configuration = ProviderDomainConfiguration( + domainIdentifier: "rollback-domain", + displayName: "Encrypted", + driveID: 8, + driveName: "Drive", + encryptionMode: .opaqueVaultV1, + vault: pending.vaultConfiguration + ) + let localStore = try VaultSQLiteStore( + databaseURL: directory.appendingPathComponent("rollback.sqlite3"), + domainIdentifier: configuration.domainIdentifier, + vaultID: pending.vaultID, + rootKey: pending.rootKey + ) + let vault = try EncryptedVaultService( + configuration: configuration, + rootKey: pending.rootKey, + deviceID: UUID(), + objectStore: objectStore, + localStore: localStore, + keyStore: keyStore, + temporaryDirectoryURL: directory + ) + _ = try await vault.createDirectory( + parentID: nil, + filename: "private-folder", + createdAt: Date(timeIntervalSince1970: 100) + ) + + let journalContainerID = try #require( + pending.vaultConfiguration.remoteLayout?.journalContainerID + ) + let journalPage = await objectStore.listObjects( + containerID: journalContainerID, + cursor: nil + ) + let journalObject = try #require(journalPage.objects.first) + await objectStore.deleteObject(fileID: journalObject.id) + + await #expect(throws: VaultJournalError.rollbackDetected) { + try await vault.synchronize() + } + } + + @Test func garbageCollectionRequiresTwoVerifiedCheckpointsAndLocalAge() async throws { + let directory = Self.temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let objectStore = InMemoryOpaqueObjectStore() + let keyStore = InMemoryVaultKeyStore() + let provisioning = VaultProvisioningService( + objectStore: objectStore, + keyStore: keyStore, + temporaryDirectoryURL: directory + ) + let pending = try await provisioning.prepareNewVault(driveID: 12) + _ = try await provisioning.confirm( + pending, + recoveryKitConfirmation: pending.recoveryKit.encoded + ) + let configuration = ProviderDomainConfiguration( + domainIdentifier: "maintenance-domain", + displayName: "Encrypted", + driveID: 12, + driveName: "Drive", + encryptionMode: .opaqueVaultV1, + vault: pending.vaultConfiguration + ) + let localStore = try VaultSQLiteStore( + databaseURL: directory.appendingPathComponent("maintenance.sqlite3"), + domainIdentifier: configuration.domainIdentifier, + vaultID: pending.vaultID, + rootKey: pending.rootKey + ) + let vault = try EncryptedVaultService( + configuration: configuration, + rootKey: pending.rootKey, + deviceID: UUID(), + objectStore: objectStore, + localStore: localStore, + keyStore: keyStore, + temporaryDirectoryURL: directory + ) + let plaintextURL = directory.appendingPathComponent("referenced") + try Data("referenced private bytes".utf8).write(to: plaintextURL) + let referenced = try await vault.createFile( + parentID: nil, + filename: "referenced.txt", + contentTypeIdentifier: "public.plain-text", + plaintextURL: plaintextURL, + modifiedAt: Date(timeIntervalSince1970: 100) + ) + let referencedFileID = try #require(referenced.contentReference?.remoteFileID) + + let layout = try #require(pending.vaultConfiguration.remoteLayout) + let orphanURL = directory.appendingPathComponent("orphan") + try Data(repeating: 0xA5, count: 256).write(to: orphanURL) + let orphan = try await objectStore.uploadObject( + containerID: layout.contentContainerID, + token: VaultCryptography.makeObjectToken( + rootKey: pending.rootKey, + vaultID: pending.vaultID + ), + fileURL: orphanURL + ) + let maintenance = try VaultMaintenanceService( + vaultConfiguration: pending.vaultConfiguration, + rootKey: pending.rootKey, + objectStore: objectStore, + localStore: localStore, + vault: vault, + temporaryDirectoryURL: directory + ) + + let first = try await maintenance.checkpointAndCollectUnreferencedContent( + retentionInterval: 100, + now: Date(timeIntervalSince1970: 1_000) + ) + #expect(first.deletedObjectCount == 0) + #expect(await objectStore.contains(fileID: orphan.id)) + + let stillYoung = try await maintenance.checkpointAndCollectUnreferencedContent( + retentionInterval: 100, + now: Date(timeIntervalSince1970: 1_099) + ) + #expect(stillYoung.deletedObjectCount == 0) + #expect(await objectStore.contains(fileID: orphan.id)) + + let collected = try await maintenance.checkpointAndCollectUnreferencedContent( + retentionInterval: 100, + now: Date(timeIntervalSince1970: 1_101) + ) + #expect(collected.deletedObjectCount == 1) + #expect(await objectStore.contains(fileID: orphan.id) == false) + #expect(await objectStore.contains(fileID: referencedFileID)) + } + + @Test func changeHistoryUsesRequestedFrontierAndReportsMovesAsUpdates() async throws { + let directory = Self.temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let objectStore = InMemoryOpaqueObjectStore() + let keyStore = InMemoryVaultKeyStore() + let provisioning = VaultProvisioningService( + objectStore: objectStore, + keyStore: keyStore, + temporaryDirectoryURL: directory + ) + let pending = try await provisioning.prepareNewVault(driveID: 13) + _ = try await provisioning.confirm( + pending, + recoveryKitConfirmation: pending.recoveryKit.encoded + ) + let configuration = ProviderDomainConfiguration( + domainIdentifier: "changes-domain", + displayName: "Encrypted", + driveID: 13, + driveName: "Drive", + encryptionMode: .opaqueVaultV1, + vault: pending.vaultConfiguration + ) + let localStore = try VaultSQLiteStore( + databaseURL: directory.appendingPathComponent("changes.sqlite3"), + domainIdentifier: configuration.domainIdentifier, + vaultID: pending.vaultID, + rootKey: pending.rootKey + ) + let vault = try EncryptedVaultService( + configuration: configuration, + rootKey: pending.rootKey, + deviceID: UUID(), + objectStore: objectStore, + localStore: localStore, + keyStore: keyStore, + temporaryDirectoryURL: directory + ) + + let folder = try await vault.createDirectory( + parentID: nil, + filename: "folder", + createdAt: Date(timeIntervalSince1970: 10) + ) + let folderFrontier = try await vault.synchronize() + let folderAnchor = folderFrontier.anchorString + let moving = try await vault.createDirectory( + parentID: nil, + filename: "moving", + createdAt: Date(timeIntervalSince1970: 20) + ) + let createChanges = try await vault.changes( + since: folderAnchor, + scope: .children(parentID: nil) + ) + #expect(createChanges.updated.map(\.id) == [moving.id]) + #expect(createChanges.deleted.isEmpty) + + let beforeMoveAnchor = createChanges.frontier.anchorString + let moved = try await vault.modify( + itemID: moving.id, + baseContentRevision: moving.contentRevision, + baseMetadataRevision: moving.metadataRevision, + parentID: folder.id, + filename: moving.filename, + favorite: moving.isFavorite, + plaintextURL: nil, + modifiedAt: moving.modifiedAt + ) + let moveChanges = try await vault.changes( + since: beforeMoveAnchor, + scope: .children(parentID: nil) + ) + #expect(moveChanges.updated.map(\.id) == [moving.id]) + #expect(moveChanges.updated.first?.parentID == folder.id) + #expect(moveChanges.deleted.isEmpty) + #expect(moved.parentID == folder.id) + + for index in 0..<4 { + _ = try await vault.createDirectory( + parentID: nil, + filename: "generation-\(index)", + createdAt: Date(timeIntervalSince1970: Double(30 + index)) + ) + } + await #expect(throws: EncryptedVaultError.syncAnchorExpired) { + try await vault.changes( + since: folderAnchor, + scope: .children(parentID: nil) + ) + } + } + + private static func isOpaqueToken(_ value: String) -> Bool { + guard value.count == 27 else { return false } + return value.allSatisfy { + $0.isLetter || $0.isNumber || $0 == "-" || $0 == "_" + } + } + + private static func temporaryDirectory() -> URL { + FileManager.default.temporaryDirectory.appendingPathComponent( + "VaultProvisioningTests-\(UUID().uuidString)", + isDirectory: true + ) + } +} + +private actor InMemoryOpaqueObjectStore: KDriveObjectStoreProviding { + private struct Entry { + var metadata: KDriveOpaqueObject + var payload: Data? + } + + private var nextID = 10 + private var entries: [Int: Entry] = [:] + + func createContainer(parentID: Int, token: String) -> KDriveOpaqueObject { + insert(parentID: parentID, token: token, payload: nil, isContainer: true) + } + + func listObjects( + containerID: Int, + cursor: String? + ) -> KDriveOpaqueObjectPage { + KDriveOpaqueObjectPage( + objects: entries.values + .map(\.metadata) + .filter { $0.parentID == containerID } + .sorted { $0.id < $1.id }, + nextCursor: nil + ) + } + + func uploadObject( + containerID: Int, + token: String, + fileURL: URL + ) throws -> KDriveOpaqueObject { + insert( + parentID: containerID, + token: token, + payload: try Data(contentsOf: fileURL), + isContainer: false + ) + } + + func downloadObject(fileID: Int, to destinationURL: URL) throws { + guard let payload = entries[fileID]?.payload else { + throw TestObjectStoreError.missing + } + try payload.write(to: destinationURL) + } + + func deleteObject(fileID: Int) { + let descendants = recursiveDescendants(of: fileID) + for identifier in descendants.union([fileID]) { + entries[identifier] = nil + } + } + + func filePayloads() -> [Data] { + entries.values.compactMap(\.payload) + } + + func allTokens() -> [String] { + entries.values.map(\.metadata.token) + } + + func contains(fileID: Int) -> Bool { + entries[fileID] != nil + } + + private func insert( + parentID: Int, + token: String, + payload: Data?, + isContainer: Bool + ) -> KDriveOpaqueObject { + let identifier = nextID + nextID += 1 + let value = KDriveOpaqueObject( + id: identifier, + parentID: parentID, + token: token, + byteCount: payload.map { Int64($0.count) }, + serverUpdatedAt: Date(timeIntervalSince1970: Double(identifier)), + isContainer: isContainer + ) + entries[identifier] = Entry(metadata: value, payload: payload) + return value + } + + private func recursiveDescendants(of fileID: Int) -> Set { + let children = entries.values + .map(\.metadata) + .filter { $0.parentID == fileID } + .map(\.id) + return children.reduce(into: Set(children)) { result, child in + result.formUnion(recursiveDescendants(of: child)) + } + } +} + +private enum TestObjectStoreError: Error { + case missing +} + +private extension Data { + var opaqueToken: String { + base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +} diff --git a/potassiumProviderTests/VaultSQLiteStoreTests.swift b/potassiumProviderTests/VaultSQLiteStoreTests.swift new file mode 100644 index 0000000..7018aa8 --- /dev/null +++ b/potassiumProviderTests/VaultSQLiteStoreTests.swift @@ -0,0 +1,61 @@ +import Foundation +@testable import PotassiumProviderCore +import Testing + +struct VaultSQLiteStoreTests { + @Test func generationsRoundTripWithoutPersistingDecryptedNames() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + "VaultSQLiteStoreTests-\(UUID().uuidString)", + isDirectory: true + ) + defer { try? FileManager.default.removeItem(at: directory) } + let databaseURL = directory.appendingPathComponent("vault.sqlite3") + let key = try VaultKeyMaterial.random() + let vaultID = VaultIdentifier() + let store = try VaultSQLiteStore( + databaseURL: databaseURL, + domainIdentifier: "encrypted-domain", + vaultID: vaultID, + rootKey: key + ) + let revision = VaultRevision(hashing: Data("revision".utf8)) + let item = VaultItem( + parentID: nil, + filename: "extremely-secret-name.pdf", + isDirectory: false, + contentTypeIdentifier: "com.adobe.pdf", + createdAt: Date(timeIntervalSince1970: 10), + modifiedAt: Date(timeIntervalSince1970: 20), + plaintextSize: 123, + contentRevision: revision, + metadataRevision: revision + ) + let frontier = VaultFrontier(transactionIDs: [UUID()]) + try await store.replace(with: VaultReducedState( + items: [item.id: item], + frontier: frontier + )) + + #expect(try await store.item(item.id) == item) + #expect(try await store.state().frontier == frontier) + for url in [ + databaseURL, + URL(fileURLWithPath: databaseURL.path + "-wal"), + URL(fileURLWithPath: databaseURL.path + "-shm"), + ] where FileManager.default.fileExists(atPath: url.path) { + let databaseBytes = try Data(contentsOf: url) + #expect(databaseBytes.range(of: Data(item.filename.utf8)) == nil) + #expect(databaseBytes.range(of: Data("com.adobe.pdf".utf8)) == nil) + } + + let wrongKeyStore = try VaultSQLiteStore( + databaseURL: databaseURL, + domainIdentifier: "encrypted-domain", + vaultID: vaultID, + rootKey: try VaultKeyMaterial.random() + ) + await #expect(throws: VaultCryptoError.authenticationFailed) { + try await wrongKeyStore.item(item.id) + } + } +} From 53e2a0124ab470fe82da05213ccc096b75fb3de0 Mon Sep 17 00:00:00 2001 From: OpenCow Date: Sun, 2 Aug 2026 10:04:47 +0200 Subject: [PATCH 2/4] feat: add guided encrypted vault onboarding --- PotassiumProviderCore/ProviderConstants.swift | 4 + .../VaultCloudAccessStore.swift | 489 ++++++++++++ PotassiumProviderCore/VaultKeyStore.swift | 16 +- PotassiumProviderCore/VaultProvisioning.swift | 183 +++++ PotassiumProviderCore/VaultRecoveryKit.swift | 56 +- README.md | 19 +- doc/APP_AND_DOMAINS.md | 29 + doc/AUTHENTICATION.md | 21 + doc/ENCRYPTED_VAULT.md | 41 + doc/FILE_PROVIDER_LIFECYCLE.md | 7 + doc/PERSISTENCE.md | 11 + .../FileProviderDomainRegistrar.swift | 73 ++ .../PotassiumProviderAppModel.swift | 737 +++++++++++++++++- potassiumProvider/ProviderSetupView.swift | 735 +++++++++++++++-- .../VaultUserPresenceAuthorizer.swift | 35 + .../VaultCloudAccessTests.swift | 401 ++++++++++ .../VaultProvisioningTests.swift | 4 +- .../VaultUXAppModelTests.swift | 444 +++++++++++ .../potassiumProviderUITests.swift | 4 +- 19 files changed, 3215 insertions(+), 94 deletions(-) create mode 100644 PotassiumProviderCore/VaultCloudAccessStore.swift create mode 100644 potassiumProvider/VaultUserPresenceAuthorizer.swift create mode 100644 potassiumProviderTests/VaultCloudAccessTests.swift create mode 100644 potassiumProviderTests/VaultUXAppModelTests.swift diff --git a/PotassiumProviderCore/ProviderConstants.swift b/PotassiumProviderCore/ProviderConstants.swift index 2243f56..650567c 100644 --- a/PotassiumProviderCore/ProviderConstants.swift +++ b/PotassiumProviderCore/ProviderConstants.swift @@ -5,6 +5,8 @@ public enum ProviderConstants { public static let keychainAccessGroup = "2LST6WT4P6.net.weavee.potassiumProvider" public static let keychainService = "net.weavee.potassiumProvider.kDrive" public static let vaultKeychainService = "net.weavee.potassiumProvider.vault" + public static let vaultCloudAccessKeychainService = + "net.weavee.potassiumProvider.vault.cloud-access" public static let keychainAccount = "oauthToken" public static let legacyAccountIdentifier = "legacy-account" public static let logSubsystem = "net.weavee.potassiumProvider" @@ -16,4 +18,6 @@ public enum ProviderConstants { public static let apiBaseURL = URL(string: "https://api.infomaniak.com")! public static let driveBaseURL = URL(string: "https://api.kdrive.infomaniak.com")! public static let encryptedVaultFeatureFlag = "EncryptedVaultsEnabled" + public static let encryptedVaultICloudKeychainFeatureFlag = + "EncryptedVaultICloudKeychainEnabled" } diff --git a/PotassiumProviderCore/VaultCloudAccessStore.swift b/PotassiumProviderCore/VaultCloudAccessStore.swift new file mode 100644 index 0000000..65b509f --- /dev/null +++ b/PotassiumProviderCore/VaultCloudAccessStore.swift @@ -0,0 +1,489 @@ +import Foundation +import Security + +private struct VaultCloudAccessCodingKey: CodingKey { + let stringValue: String + let intValue: Int? = nil + + init?(stringValue: String) { + self.stringValue = stringValue + } + + init?(intValue: Int) { + return nil + } +} + +private struct StrictVaultRemoteLayout: Decodable { + let value: VaultBootstrap.RemoteLayout + + private enum CodingKeys: String, CodingKey { + case contentContainerID + case journalContainerID + case checkpointContainerID + case checkpointToken + } + + init(from decoder: Decoder) throws { + let rawContainer = try decoder.container( + keyedBy: VaultCloudAccessCodingKey.self + ) + let expectedKeys = Set([ + "contentContainerID", + "journalContainerID", + "checkpointContainerID", + "checkpointToken", + ]) + guard Set(rawContainer.allKeys.map(\.stringValue)) == expectedKeys else { + throw VaultCloudAccessStoreError.malformedRecord + } + + let container = try decoder.container(keyedBy: CodingKeys.self) + let contentContainerID = try container.decode( + Int.self, + forKey: .contentContainerID + ) + let journalContainerID = try container.decode( + Int.self, + forKey: .journalContainerID + ) + let checkpointContainerID = try container.decode( + Int.self, + forKey: .checkpointContainerID + ) + let checkpointToken = try container.decode( + String.self, + forKey: .checkpointToken + ) + guard contentContainerID > 0, + journalContainerID > 0, + checkpointContainerID > 0, + let token = Data(base64URLEncoded: checkpointToken), + token.count == 20 else { + throw VaultCloudAccessStoreError.malformedRecord + } + value = VaultBootstrap.RemoteLayout( + contentContainerID: contentContainerID, + journalContainerID: journalContainerID, + checkpointContainerID: checkpointContainerID, + checkpointToken: checkpointToken + ) + } +} + +/// A convenience copy of the information required to open a vault on another +/// trusted Apple device. This record never contains the recovery secret, +/// rollback frontier, or device identity. +public struct VaultCloudAccessRecord: Codable, Equatable, Sendable { + public static let currentRecordVersion: UInt16 = 1 + + public let recordVersion: UInt16 + public let vaultID: VaultIdentifier + public let driveID: Int + public let vaultRootFileID: Int + public let vaultHeaderFileID: Int + public let formatVersion: UInt16 + public let keyEpoch: UInt32 + public let remoteLayout: VaultBootstrap.RemoteLayout + public let rootKey: VaultKeyMaterial + public let createdAt: Date + + public init( + vaultID: VaultIdentifier, + driveID: Int, + vaultRootFileID: Int, + vaultHeaderFileID: Int, + formatVersion: UInt16, + keyEpoch: UInt32, + remoteLayout: VaultBootstrap.RemoteLayout, + rootKey: VaultKeyMaterial, + createdAt: Date = Date() + ) { + recordVersion = Self.currentRecordVersion + self.vaultID = vaultID + self.driveID = driveID + self.vaultRootFileID = vaultRootFileID + self.vaultHeaderFileID = vaultHeaderFileID + self.formatVersion = formatVersion + self.keyEpoch = keyEpoch + self.remoteLayout = remoteLayout + self.rootKey = rootKey + self.createdAt = createdAt + } + + public init( + configuration: ProviderVaultConfiguration, + driveID: Int, + rootKey: VaultKeyMaterial, + createdAt: Date = Date() + ) throws { + guard let remoteLayout = configuration.remoteLayout else { + throw VaultCloudAccessStoreError.malformedRecord + } + self.init( + vaultID: configuration.vaultIdentifier, + driveID: driveID, + vaultRootFileID: configuration.vaultRootFileID, + vaultHeaderFileID: configuration.vaultHeaderFileID, + formatVersion: configuration.formatVersion, + keyEpoch: configuration.keyEpoch, + remoteLayout: remoteLayout, + rootKey: rootKey, + createdAt: createdAt + ) + } + + public var vaultConfiguration: ProviderVaultConfiguration { + ProviderVaultConfiguration( + vaultIdentifier: vaultID, + vaultRootFileID: vaultRootFileID, + vaultHeaderFileID: vaultHeaderFileID, + formatVersion: formatVersion, + keyEpoch: keyEpoch, + remoteLayout: remoteLayout + ) + } + + private enum CodingKeys: String, CodingKey { + case recordVersion + case vaultID + case driveID + case vaultRootFileID + case vaultHeaderFileID + case formatVersion + case keyEpoch + case remoteLayout + case rootKey + case createdAt + } + + public init(from decoder: Decoder) throws { + let rawContainer = try decoder.container( + keyedBy: VaultCloudAccessCodingKey.self + ) + let expectedKeys = Set([ + "recordVersion", + "vaultID", + "driveID", + "vaultRootFileID", + "vaultHeaderFileID", + "formatVersion", + "keyEpoch", + "remoteLayout", + "rootKey", + "createdAt", + ]) + guard Set(rawContainer.allKeys.map(\.stringValue)) == expectedKeys else { + throw VaultCloudAccessStoreError.malformedRecord + } + + let container = try decoder.container(keyedBy: CodingKeys.self) + recordVersion = try container.decode(UInt16.self, forKey: .recordVersion) + guard recordVersion == Self.currentRecordVersion else { + throw VaultCloudAccessStoreError.unsupportedRecordVersion(recordVersion) + } + vaultID = try container.decode(VaultIdentifier.self, forKey: .vaultID) + driveID = try container.decode(Int.self, forKey: .driveID) + vaultRootFileID = try container.decode(Int.self, forKey: .vaultRootFileID) + vaultHeaderFileID = try container.decode(Int.self, forKey: .vaultHeaderFileID) + formatVersion = try container.decode(UInt16.self, forKey: .formatVersion) + keyEpoch = try container.decode(UInt32.self, forKey: .keyEpoch) + remoteLayout = try container.decode( + StrictVaultRemoteLayout.self, + forKey: .remoteLayout + ).value + let rootKeyData = try container.decode(Data.self, forKey: .rootKey) + guard let rootKey = VaultKeyMaterial(data: rootKeyData) else { + throw VaultCloudAccessStoreError.malformedRecord + } + self.rootKey = rootKey + createdAt = try container.decode(Date.self, forKey: .createdAt) + + guard driveID > 0, + vaultRootFileID > 0, + vaultHeaderFileID > 0, + formatVersion == VaultFormat.currentVersion, + keyEpoch > 0 else { + throw VaultCloudAccessStoreError.malformedRecord + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(recordVersion, forKey: .recordVersion) + try container.encode(vaultID, forKey: .vaultID) + try container.encode(driveID, forKey: .driveID) + try container.encode(vaultRootFileID, forKey: .vaultRootFileID) + try container.encode(vaultHeaderFileID, forKey: .vaultHeaderFileID) + try container.encode(formatVersion, forKey: .formatVersion) + try container.encode(keyEpoch, forKey: .keyEpoch) + try container.encode(remoteLayout, forKey: .remoteLayout) + try container.encode(rootKey.data, forKey: .rootKey) + try container.encode(createdAt, forKey: .createdAt) + } +} + +public struct VaultCloudAccessCandidate: Identifiable, Equatable, Sendable { + public var id: VaultIdentifier { vaultID } + + public let vaultID: VaultIdentifier + public let driveID: Int + public let keyEpoch: UInt32 + public let createdAt: Date + + public init(record: VaultCloudAccessRecord) { + vaultID = record.vaultID + driveID = record.driveID + keyEpoch = record.keyEpoch + createdAt = record.createdAt + } +} + +public enum VaultCloudAccessStatus: Equatable, Sendable { + case disabled + case available + case unavailable + case staleEpoch + case conflict +} + +public enum VaultLocalKeyStatus: Equatable, Sendable { + case available + case locked + case missing + case invalid +} + +public enum VaultSetupStep: String, Codable, Equatable, Sendable { + case overview + case keyAccess + case recoveryKit + case registering + case desktopDocuments + case complete +} + +public struct VaultSetupOutcome: Equatable, Sendable { + public var configuration: ProviderDomainConfiguration? + public var cloudAccessStatus: VaultCloudAccessStatus + public var recoveryKitVerified: Bool + public var desktopDocumentsDeferred: Bool + public var desktopDocumentsEnabled: Bool + + public init( + configuration: ProviderDomainConfiguration? = nil, + cloudAccessStatus: VaultCloudAccessStatus = .disabled, + recoveryKitVerified: Bool = false, + desktopDocumentsDeferred: Bool = false, + desktopDocumentsEnabled: Bool = false + ) { + self.configuration = configuration + self.cloudAccessStatus = cloudAccessStatus + self.recoveryKitVerified = recoveryKitVerified + self.desktopDocumentsDeferred = desktopDocumentsDeferred + self.desktopDocumentsEnabled = desktopDocumentsEnabled + } +} + +public struct VaultUXPreferences: Codable, Equatable, Sendable { + public static let currentOnboardingVersion = 1 + + public var onboardingVersion: Int + public var desktopDocumentsDeferred: Bool + + public init( + onboardingVersion: Int = Self.currentOnboardingVersion, + desktopDocumentsDeferred: Bool = false + ) { + self.onboardingVersion = onboardingVersion + self.desktopDocumentsDeferred = desktopDocumentsDeferred + } +} + +public protocol VaultCloudAccessStoring: Sendable { + func records() async throws -> [VaultCloudAccessRecord] + func record(vaultID: VaultIdentifier) async throws -> VaultCloudAccessRecord? + func save(_ record: VaultCloudAccessRecord) async throws + func delete(vaultID: VaultIdentifier) async throws +} + +public actor KeychainVaultCloudAccessStore: VaultCloudAccessStoring { + public let service: String + public let accessGroup: String? + + public init( + service: String = ProviderConstants.vaultCloudAccessKeychainService, + accessGroup: String? = nil + ) { + self.service = service + self.accessGroup = accessGroup + } + + public func records() throws -> [VaultCloudAccessRecord] { + var query = baseQuery() + query[kSecMatchLimit as String] = kSecMatchLimitAll + query[kSecReturnData as String] = true + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { return [] } + try Self.validate(status) + + let payloads: [Data] + if let values = result as? [Data] { + payloads = values + } else if let value = result as? Data { + payloads = [value] + } else { + throw VaultCloudAccessStoreError.malformedRecord + } + return try payloads.map(Self.decode) + } + + public func record(vaultID: VaultIdentifier) throws -> VaultCloudAccessRecord? { + var query = baseQuery(account: account(vaultID)) + query[kSecMatchLimit as String] = kSecMatchLimitOne + query[kSecReturnData as String] = true + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { return nil } + try Self.validate(status) + guard let data = result as? Data else { + throw VaultCloudAccessStoreError.malformedRecord + } + let record = try Self.decode(data) + guard record.vaultID == vaultID else { + throw VaultCloudAccessStoreError.conflictingRecord + } + return record + } + + public func save(_ record: VaultCloudAccessRecord) throws { + let data = try VaultCoding.encoder.encode(record) + let query = baseQuery(account: account(record.vaultID)) + let attributes = saveAttributes(data: data) + let updateStatus = SecItemUpdate( + query as CFDictionary, + attributes as CFDictionary + ) + if updateStatus == errSecSuccess { return } + if updateStatus != errSecItemNotFound { + try Self.validate(updateStatus) + } + + var addQuery = query + addQuery[kSecValueData as String] = data + addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlocked + let status = SecItemAdd(addQuery as CFDictionary, nil) + try Self.validate(status) + } + + func saveAttributes(data: Data) -> [String: Any] { + [ + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked, + ] + } + + public func delete(vaultID: VaultIdentifier) throws { + let status = SecItemDelete( + baseQuery(account: account(vaultID)) as CFDictionary + ) + guard status != errSecItemNotFound else { return } + try Self.validate(status) + } + + /// Exposed internally so tests can lock the synchronization and data + /// protection attributes without exercising a user's actual iCloud Keychain. + func baseQuery(account: String? = nil) -> [String: Any] { + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrSynchronizable as String: true, + kSecUseDataProtectionKeychain as String: true, + ] + if let account { + query[kSecAttrAccount as String] = account + } + if let accessGroup { + query[kSecAttrAccessGroup as String] = accessGroup + } + return query + } + + private func account(_ vaultID: VaultIdentifier) -> String { + "vaultCloudAccess:\(vaultID.rawValue.uuidString.lowercased())" + } + + private static func decode(_ data: Data) throws -> VaultCloudAccessRecord { + do { + return try VaultCoding.decoder.decode( + VaultCloudAccessRecord.self, + from: data + ) + } catch let error as VaultCloudAccessStoreError { + throw error + } catch { + throw VaultCloudAccessStoreError.malformedRecord + } + } + + private static func validate(_ status: OSStatus) throws { + guard status != errSecInteractionNotAllowed else { + throw VaultCloudAccessStoreError.interactionNotAllowed + } + guard status == errSecSuccess else { + throw VaultCloudAccessStoreError.unhandledStatus(status) + } + } +} + +public actor InMemoryVaultCloudAccessStore: VaultCloudAccessStoring { + private var values: [VaultIdentifier: VaultCloudAccessRecord] = [:] + + public init(records: [VaultCloudAccessRecord] = []) { + values = Dictionary(uniqueKeysWithValues: records.map { ($0.vaultID, $0) }) + } + + public func records() -> [VaultCloudAccessRecord] { + values.values.sorted { + $0.vaultID.rawValue.uuidString < $1.vaultID.rawValue.uuidString + } + } + + public func record(vaultID: VaultIdentifier) -> VaultCloudAccessRecord? { + values[vaultID] + } + + public func save(_ record: VaultCloudAccessRecord) { + values[record.vaultID] = record + } + + public func delete(vaultID: VaultIdentifier) { + values[vaultID] = nil + } +} + +public enum VaultCloudAccessStoreError: Error, Equatable, LocalizedError, Sendable { + case unsupportedRecordVersion(UInt16) + case malformedRecord + case conflictingRecord + case interactionNotAllowed + case unhandledStatus(OSStatus) + + public var errorDescription: String? { + switch self { + case .unsupportedRecordVersion(let version): + return "iCloud Keychain vault record version \(version) is not supported." + case .malformedRecord: + return "The iCloud Keychain vault record is malformed." + case .conflictingRecord: + return "iCloud Keychain contains a conflicting vault record." + case .interactionNotAllowed: + return "Unlock this device before accessing the iCloud Keychain vault record." + case .unhandledStatus(let status): + return "iCloud Keychain access failed with status \(status)." + } + } +} diff --git a/PotassiumProviderCore/VaultKeyStore.swift b/PotassiumProviderCore/VaultKeyStore.swift index 31e2b2c..1f8db90 100644 --- a/PotassiumProviderCore/VaultKeyStore.swift +++ b/PotassiumProviderCore/VaultKeyStore.swift @@ -85,11 +85,7 @@ public actor KeychainVaultKeyStore: VaultKeyStoring, VaultDeviceIdentityStoring private func saveData(_ data: Data, account: String) throws { let query = baseQuery(account: account) - let attributes: [String: Any] = [ - kSecValueData as String: data, - kSecAttrAccessible as String: - kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, - ] + let attributes = saveAttributes(data: data) let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) if updateStatus == errSecSuccess { return } guard updateStatus == errSecItemNotFound else { @@ -106,6 +102,14 @@ public actor KeychainVaultKeyStore: VaultKeyStoring, VaultDeviceIdentityStoring } } + func saveAttributes(data: Data) -> [String: Any] { + [ + kSecValueData as String: data, + kSecAttrAccessible as String: + kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + ] + } + private func deleteData(account: String) throws { let status = SecItemDelete(baseQuery(account: account) as CFDictionary) guard status == errSecSuccess || status == errSecItemNotFound else { @@ -113,7 +117,7 @@ public actor KeychainVaultKeyStore: VaultKeyStoring, VaultDeviceIdentityStoring } } - private func baseQuery(account: String) -> [String: Any] { + func baseQuery(account: String) -> [String: Any] { var query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, diff --git a/PotassiumProviderCore/VaultProvisioning.swift b/PotassiumProviderCore/VaultProvisioning.swift index cd7cece..3379233 100644 --- a/PotassiumProviderCore/VaultProvisioning.swift +++ b/PotassiumProviderCore/VaultProvisioning.swift @@ -40,6 +40,8 @@ public enum VaultProvisioningError: Error, Equatable, LocalizedError, Sendable { case missingRemoteLayout case checkpointNotFound case driveMismatch + case cloudRecordMismatch + case keyEpochMismatch public var errorDescription: String? { switch self { @@ -51,6 +53,10 @@ public enum VaultProvisioningError: Error, Equatable, LocalizedError, Sendable { return "The authenticated vault checkpoint could not be found." case .driveMismatch: return "The recovery kit belongs to a different kDrive." + case .cloudRecordMismatch: + return "The iCloud Keychain record does not match the remote vault." + case .keyEpochMismatch: + return "The iCloud Keychain record belongs to a different vault key epoch." } } } @@ -269,6 +275,150 @@ public struct VaultProvisioningService: Sendable { ) } + /// Proves that a recovery kit opens the selected vault without copying its + /// root key into device custody. Recovery material is used only in memory + /// and is never sent to the object store. + public func verifyRecoveryKit( + _ recoveryKitText: String, + expectedConfiguration: ProviderVaultConfiguration, + expectedDriveID: Int + ) async throws { + let kit = try VaultRecoveryKit(encoded: recoveryKitText) + guard kit.driveID == expectedDriveID else { + throw VaultProvisioningError.driveMismatch + } + guard kit.vaultID == expectedConfiguration.vaultIdentifier, + kit.vaultRootFileID == expectedConfiguration.vaultRootFileID, + kit.vaultHeaderFileID == expectedConfiguration.vaultHeaderFileID + else { + throw VaultProvisioningError.recoveryConfirmationMismatch + } + + let bootstrapURL = temporaryURL(prefix: "recovery-verification") + defer { try? FileManager.default.removeItem(at: bootstrapURL) } + try await objectStore.downloadObject( + fileID: kit.vaultHeaderFileID, + to: bootstrapURL + ) + let unlocked = try VaultBootstrap.unlock( + Data(contentsOf: bootstrapURL, options: .mappedIfSafe), + recoverySecret: kit.recoverySecret, + expectedVaultID: kit.vaultID + ) + guard unlocked.keyEpoch == expectedConfiguration.keyEpoch, + unlocked.remoteLayout == expectedConfiguration.remoteLayout, + let layout = unlocked.remoteLayout else { + throw VaultProvisioningError.cloudRecordMismatch + } + + let checkpointObject = try await findObject( + token: layout.checkpointToken, + containerID: layout.checkpointContainerID + ) + let checkpointURL = temporaryURL(prefix: "recovery-checkpoint") + defer { try? FileManager.default.removeItem(at: checkpointURL) } + try await objectStore.downloadObject( + fileID: checkpointObject.id, + to: checkpointURL + ) + _ = try VaultCryptography.open( + VaultCheckpoint.self, + envelope: Data(contentsOf: checkpointURL, options: .mappedIfSafe), + expectedRole: .checkpoint, + expectedObjectToken: layout.checkpointToken, + rootKey: unlocked.rootKey, + vaultID: unlocked.vaultID, + keyEpoch: unlocked.keyEpoch + ) + + if let deviceKey = try await keyStore.loadRootKey( + vaultID: unlocked.vaultID + ), deviceKey != unlocked.rootKey { + throw VaultCloudAccessStoreError.conflictingRecord + } + } + + /// Authenticates a synchronizable convenience record against the remote + /// bootstrap header, checkpoint, complete journal, and any returning-device + /// trusted frontier before copying its root key into device-local custody. + public func openExistingVault( + cloudAccessRecord record: VaultCloudAccessRecord, + expectedDriveID: Int + ) async throws -> ProviderVaultConfiguration { + guard record.driveID == expectedDriveID else { + throw VaultProvisioningError.driveMismatch + } + guard record.formatVersion == VaultFormat.currentVersion else { + throw VaultProvisioningError.cloudRecordMismatch + } + + let bootstrapURL = temporaryURL(prefix: "cloud-bootstrap-download") + defer { try? FileManager.default.removeItem(at: bootstrapURL) } + try await objectStore.downloadObject( + fileID: record.vaultHeaderFileID, + to: bootstrapURL + ) + let header = try VaultBootstrap.inspectHeader( + Data(contentsOf: bootstrapURL, options: .mappedIfSafe) + ) + guard header.vaultID == record.vaultID, + header.formatVersion == record.formatVersion else { + throw VaultProvisioningError.cloudRecordMismatch + } + guard header.keyEpoch == record.keyEpoch else { + throw VaultProvisioningError.keyEpochMismatch + } + + let checkpointObject = try await findObject( + token: record.remoteLayout.checkpointToken, + containerID: record.remoteLayout.checkpointContainerID + ) + let checkpointURL = temporaryURL(prefix: "cloud-checkpoint-download") + defer { try? FileManager.default.removeItem(at: checkpointURL) } + try await objectStore.downloadObject( + fileID: checkpointObject.id, + to: checkpointURL + ) + let checkpoint = try VaultCryptography.open( + VaultCheckpoint.self, + envelope: Data(contentsOf: checkpointURL, options: .mappedIfSafe), + expectedRole: .checkpoint, + expectedObjectToken: record.remoteLayout.checkpointToken, + rootKey: record.rootKey, + vaultID: record.vaultID, + keyEpoch: record.keyEpoch + ) + let transactions = try await loadJournalTransactions(record: record) + let state = try VaultJournalReducer.reduce( + transactions, + checkpoint: checkpoint + ) + + let trustedState = try await keyStore.loadTrustedState( + vaultID: record.vaultID + ) + if let trustedState, trustedState.keyEpoch != record.keyEpoch { + throw VaultProvisioningError.keyEpochMismatch + } + try VaultRollbackValidator.validate( + trustedState: trustedState, + currentState: state + ) + if let existingKey = try await keyStore.loadRootKey(vaultID: record.vaultID), + existingKey != record.rootKey { + throw VaultCloudAccessStoreError.conflictingRecord + } + + try await keyStore.saveTrustedState(VaultTrustedState( + vaultID: record.vaultID, + keyEpoch: record.keyEpoch, + frontier: state.frontier, + checkpointDigest: try VaultMerkleTree.root(for: transactions) + )) + try await keyStore.saveRootKey(record.rootKey, vaultID: record.vaultID) + return record.vaultConfiguration + } + /// Rewraps the existing root key under a fresh recovery secret. This does /// not revoke an old recovery kit while an older bootstrap object or server /// backup remains reachable; device revocation requires full rekeying. @@ -361,6 +511,39 @@ public struct VaultProvisioningService: Sendable { throw VaultProvisioningError.checkpointNotFound } + private func loadJournalTransactions( + record: VaultCloudAccessRecord + ) async throws -> [VaultTransaction] { + var transactionsByID: [UUID: VaultTransaction] = [:] + var cursor: String? + repeat { + let page = try await objectStore.listObjects( + containerID: record.remoteLayout.journalContainerID, + cursor: cursor + ) + for object in page.objects { + let url = temporaryURL(prefix: "cloud-journal-download") + defer { try? FileManager.default.removeItem(at: url) } + try await objectStore.downloadObject(fileID: object.id, to: url) + let transaction = try VaultFixedTransactionCodec.open( + Data(contentsOf: url, options: .mappedIfSafe), + objectToken: object.token, + rootKey: record.rootKey, + vaultID: record.vaultID, + keyEpoch: record.keyEpoch + ) + guard transactionsByID.updateValue( + transaction, + forKey: transaction.id + ) == nil else { + throw VaultJournalError.duplicateTransaction(transaction.id) + } + } + cursor = page.nextCursor + } while cursor != nil + return Array(transactionsByID.values) + } + private func temporaryURL(prefix: String) -> URL { temporaryDirectoryURL .appendingPathComponent("\(prefix)-\(UUID().uuidString)") diff --git a/PotassiumProviderCore/VaultRecoveryKit.swift b/PotassiumProviderCore/VaultRecoveryKit.swift index 37e561a..6d297d7 100644 --- a/PotassiumProviderCore/VaultRecoveryKit.swift +++ b/PotassiumProviderCore/VaultRecoveryKit.swift @@ -133,6 +133,22 @@ public enum VaultBootstrap { public let remoteLayout: RemoteLayout? } + public struct Header: Equatable, Sendable { + public let formatVersion: UInt16 + public let keyEpoch: UInt32 + public let vaultID: VaultIdentifier + + public init( + formatVersion: UInt16, + keyEpoch: UInt32, + vaultID: VaultIdentifier + ) { + self.formatVersion = formatVersion + self.keyEpoch = keyEpoch + self.vaultID = vaultID + } + } + public static func create( vaultID: VaultIdentifier, keyEpoch: UInt32 = VaultFormat.currentKeyEpoch, @@ -166,19 +182,11 @@ public enum VaultBootstrap { recoverySecret: VaultKeyMaterial, expectedVaultID: VaultIdentifier? = nil ) throws -> Unlocked { - guard bootstrap.count > headerByteCount else { - throw VaultCryptoError.invalidEnvelope - } + let inspectedHeader = try inspectHeader(bootstrap) var cursor = VaultDataCursor(data: bootstrap) - guard try cursor.read(count: 4) == magic else { - throw VaultCryptoError.invalidEnvelopeMagic - } - let version = try cursor.readUInt16() - guard version == VaultFormat.currentVersion else { - throw VaultCryptoError.unsupportedFormatVersion(version) - } - let keyEpoch = try cursor.readUInt32() - let vaultID = VaultIdentifier(rawValue: UUID(bytes: try cursor.read(count: 16))) + _ = try cursor.read(count: headerByteCount) + let keyEpoch = inspectedHeader.keyEpoch + let vaultID = inspectedHeader.vaultID if let expectedVaultID, expectedVaultID != vaultID { throw VaultCryptoError.unexpectedVault } @@ -214,6 +222,30 @@ public enum VaultBootstrap { } } + /// Reads only the fixed public bootstrap header. Callers must authenticate + /// another vault object with the root key before trusting a cloud-access + /// record based on this result. + public static func inspectHeader(_ bootstrap: Data) throws -> Header { + guard bootstrap.count > headerByteCount else { + throw VaultCryptoError.invalidEnvelope + } + var cursor = VaultDataCursor(data: bootstrap) + guard try cursor.read(count: 4) == magic else { + throw VaultCryptoError.invalidEnvelopeMagic + } + let version = try cursor.readUInt16() + guard version == VaultFormat.currentVersion else { + throw VaultCryptoError.unsupportedFormatVersion(version) + } + return Header( + formatVersion: version, + keyEpoch: try cursor.readUInt32(), + vaultID: VaultIdentifier( + rawValue: UUID(bytes: try cursor.read(count: 16)) + ) + ) + } + private static func makeHeader(vaultID: VaultIdentifier, keyEpoch: UInt32) -> Data { var header = Data() header.append(magic) diff --git a/README.md b/README.md index 63e187d..01c209c 100644 --- a/README.md +++ b/README.md @@ -26,8 +26,9 @@ data. - [Architecture](doc/ARCHITECTURE.md): targets, modules, persistence, runtime boundaries, and high-level data flow. - [Encrypted Vault Format v1](doc/ENCRYPTED_VAULT.md): threat model, leakage, - key custody, binary formats, opaque synchronization, rollback behavior, and - the security-review feature gate. + device-local and optional iCloud Keychain custody, guided recovery, binary + formats, opaque synchronization, rollback behavior, and security-review + feature gates. - [Encrypted Vault Migration](doc/ENCRYPTED_VAULT_MIGRATION.md): resumable encrypted migration journal, verification-before-purge invariant, and Desktop/Documents cutover. @@ -147,15 +148,19 @@ local Xcode requires a more specific variant. links, or user data. - Encrypted vaults are experimental and disabled by default pending independent cryptographic review. The feature flag is not a production-readiness claim. +- Encrypted-vault onboarding always requires a verified offline recovery kit. + Optional iCloud Keychain access is a separately gated convenience: it can + open a vault on another trusted Apple device, but it does not replace offline + recovery or revoke keys already imported by another device. - The current conflict handling delegates many decisions to kDrive. Read [Conflicts](doc/CONFLICTS.md) before relying on it for important files. - SQLite snapshots cache metadata only. File contents and thumbnails are not stored there. -- On macOS 15 or later, Desktop & Documents sync is an explicit, experimental - opt-in that always handles both folders together under - `Private/` on the selected kDrive. Existing active domains - created before this layout remain directly under `Private` until sync is - stopped and enabled again. +- On macOS 15 or later, Desktop & Documents protection is an explicit action. + Encrypted domains preflight ownership and local key availability before + presenting Apple's consent UI, then upload only opaque vault ciphertext. + A legacy plaintext Potassium owner must complete verified encrypted + migration before its known-folder claim can move. ## License diff --git a/doc/APP_AND_DOMAINS.md b/doc/APP_AND_DOMAINS.md index a3eee33..b22f1f2 100644 --- a/doc/APP_AND_DOMAINS.md +++ b/doc/APP_AND_DOMAINS.md @@ -177,6 +177,16 @@ legacy claim remains there without moving or deleting remote data. Stopping and re-enabling it, or a new system-initiated claim while inactive, upgrades it to the current machine namespace. +Encrypted onboarding includes an explicit Desktop & Documents step after vault +registration. The same preflight is available later from drive management. It +reports key availability, kDrive reachability, current ownership, and quota +when exposed by the API. The UI distinguishes preparing, awaiting consent, +connected/uploading, up to date, quota blocked, and attention required. + +A legacy plaintext Potassium owner blocks direct claiming until verified +encrypted migration completes. Another provider can be handed off through +macOS consent, with a warning that its previous remote copies are not purged. + ## Removing A Domain Removal is initiated from the drive-management screen and requires explicit @@ -246,3 +256,22 @@ and QR recovery kit and requires exact confirmation before saving the device key or registering the domain. Existing plaintext domains remain separately registered migration sources. Normal removal/logout retains vault keys; the separate Forget Key workflow requires the matching recovery kit. + +Creation is a guided flow: threat-boundary overview, device-only versus optional +iCloud Keychain custody, recovery confirmation, durable registration, +Desktop/Documents consent on macOS, and a final summary. Cloud publication and +known-folder failures happen after the durable boundary and remain retryable +without deleting or unregistering the vault. +If setup closes or the app restarts after registration, Security & Recovery +shows **Finish Vault Setup** until the platform-appropriate final step is +completed. Only the onboarding schema version and Desktop/Documents deferral +choice are persisted; key and known-folder status are re-read live. + +Security & Recovery verifies a pasted kit by using it locally to authenticate +the remote encrypted bootstrap and checkpoint. The recovery material is never +sent to kDrive, saved by the app, or copied into iCloud Keychain. + +After kDrive sign-in, matching synchronizable records appear as encrypted +vaults found in iCloud Keychain. The app authenticates a selected record against +remote ciphertext before registration. Recovery-kit opening and “Check Again” +remain available. diff --git a/doc/AUTHENTICATION.md b/doc/AUTHENTICATION.md index 4096d5c..c7d92f1 100644 --- a/doc/AUTHENTICATION.md +++ b/doc/AUTHENTICATION.md @@ -61,6 +61,27 @@ vault UUID. They use the shared access group, Data Protection Keychain, `AfterFirstUnlockThisDeviceOnly`, and no synchronization. The recovery secret is not stored. See [Encrypted Vault Format v1](ENCRYPTED_VAULT.md). +## Optional iCloud Keychain Vault Access + +The foreground app can optionally create a second, versioned convenience +record. It uses service +`net.weavee.potassiumProvider.vault.cloud-access`, account +`vaultCloudAccess:`, the shared access group, +`kSecAttrSynchronizable = true`, and `kSecAttrAccessibleWhenUnlocked`. + +The record contains the root key and only the opaque locators needed to +authenticate the remote vault. It never contains the recovery secret, trusted +rollback frontier, device ID, logical names, account-local ID, or OAuth data. +The File Provider extension never reads this record. Foreground import first +authenticates the bootstrap identity, checkpoint, journal, and any returning +device frontier, then copies the root into the existing non-synchronizing +`AfterFirstUnlockThisDeviceOnly` item. + +iCloud access is opt-in and independently feature-gated. The offline recovery +kit remains mandatory because synchronization can be delayed or unavailable. +Removing the synchronizable record leaves device-local keys untouched and +cannot revoke a key already imported by a lost device. + ## Manual Access Token Path The app also supports a manual access token. This creates a token value with: diff --git a/doc/ENCRYPTED_VAULT.md b/doc/ENCRYPTED_VAULT.md index e9dd5ec..f4f0a70 100644 --- a/doc/ENCRYPTED_VAULT.md +++ b/doc/ENCRYPTED_VAULT.md @@ -103,6 +103,9 @@ The app displays text and a locally generated QR code once. The user must paste the complete kit back before the root key is committed and the File Provider domain is registered. Opening an existing vault downloads and authenticates the bootstrap and initial checkpoint before saving the key. +The existing-vault verification and Forget Key actions also authenticate the +remote encrypted header and checkpoint; they do not accept locator matching as +proof and never transmit or persist the recovery material. Recovery rotation uploads a new bootstrap wrapped by a new recovery secret and requires confirmation of the new kit. It does **not** revoke old server @@ -114,6 +117,25 @@ separate purge decision. Loss of every device key and the recovery kit is intentionally unrecoverable. +### iCloud Keychain convenience + +Device-only custody remains the default. With the separate iCloud Keychain gate +enabled, setup and Security & Recovery can publish a synchronizable +`VaultCloudAccessRecord`. iCloud Keychain protects that record end to end, but +Apple Account recovery and trusted Apple devices then become part of the +custody boundary. + +The record holds the root key, vault/drive identity, opaque physical locators, +format/epoch, and remote layout. It excludes the recovery secret, trusted +frontier, device identity, and logical metadata. Import is explicit and +foreground-only. It authenticates the bootstrap identity plus the encrypted +checkpoint and complete journal before saving a device-local key. A returning +device's trusted frontier is validated and retained. + +Forgetting a device key never silently imports the cloud record. Removing the +cloud record does not erase keys already imported elsewhere; full rekeying +remains the lost-device revocation mechanism. + ## Physical layout A vault root is a random 20-byte Base64URL token. Beneath it are random content, @@ -250,13 +272,32 @@ and kDrive logical version history cannot operate on ciphertext. Encrypted Desktop and Documents are logical folders; their plaintext names and Mac namespace occur only in encrypted transactions. +Onboarding offers Desktop & Documents as a separate macOS consent step after +durable registration. Preflight checks the local key, remote reachability, and +current owner. A legacy plaintext Potassium owner blocks direct claiming until +verified migration; an external owner triggers a warning that prior remote +copies are outside this vault's purge boundary. Transfer UI uses phases and +Finder per-item progress rather than a fabricated percentage. + The feature flag defaults off: ```sh defaults write net.weavee.potassiumProvider EncryptedVaultsEnabled -bool YES ``` +Optional iCloud Keychain custody has a second independent gate: + +```sh +defaults write net.weavee.potassiumProvider EncryptedVaultICloudKeychainEnabled -bool YES +``` + Enabling it is for development and security review, not a confidentiality claim. The rollout gate is: format/crypto tests, read-only prototype, single-device mutation testing, multi-device conflict testing, migration pilot, independent security audit, then explicit default enablement. + +## Apple platform references + +- [Synchronizable Keychain items](https://developer.apple.com/documentation/security/ksecattrsynchronizable) +- [iCloud Keychain security overview](https://support.apple.com/guide/security/icloud-keychain-security-overview-sec1c89c6f3b/web) +- [File Provider framework updates and known folders](https://developer.apple.com/documentation/updates/fileprovider) diff --git a/doc/FILE_PROVIDER_LIFECYCLE.md b/doc/FILE_PROVIDER_LIFECYCLE.md index 59c1976..0841351 100644 --- a/doc/FILE_PROVIDER_LIFECYCLE.md +++ b/doc/FILE_PROVIDER_LIFECYCLE.md @@ -42,6 +42,13 @@ move or delete remote data. Apple's [`NSFileProviderKnownFolderSupporting`](https://developer.apple.com/documentation/fileprovider/nsfileproviderknownfoldersupporting) documentation is the source of truth for this callback and transition behavior. +For encrypted domains, preflight requires a device root key, authenticated +remote synchronization, and safe known-folder ownership before claiming. A +legacy plaintext Potassium owner requires verified migration instead of direct +claiming. Once macOS consents, ordinary create and modify callbacks encrypt each +revision before its opaque object-store upload. The app reports transfer phases +without treating a successful claim as proof that every initial file uploaded. + File Provider reuses existing directory children or creates them at those locations, keeps its default binary-compatibility symlink behavior, and manages the local known-folder transition. Ordinary enumeration and mutation callbacks diff --git a/doc/PERSISTENCE.md b/doc/PERSISTENCE.md index a5924d9..e200ed6 100644 --- a/doc/PERSISTENCE.md +++ b/doc/PERSISTENCE.md @@ -348,3 +348,14 @@ journal is an authenticated encrypted file. Activity and conflict rows retain only opaque `ev1:` identifiers and fixed summaries. Domain JSON stores non-secret vault locators, format version, and key epoch, never root or recovery keys. + +Optional iCloud Keychain access is a separate synchronizable Keychain item, not +app-group JSON or SQLite. It contains the root key and opaque remote +configuration, but not the recovery secret, trusted rollback frontier, device +ID, or logical metadata. Device-local root, frontier, and identity items remain +non-synchronizing `AfterFirstUnlockThisDeviceOnly` values. + +Non-secret onboarding preferences are stored in app-group defaults under the +random vault UUID: onboarding schema version and whether Desktop & Documents +was deferred. Missing or older state produces a resumable Finish Vault Setup +task. Actual key and known-folder status is queried live. diff --git a/potassiumProvider/FileProviderDomainRegistrar.swift b/potassiumProvider/FileProviderDomainRegistrar.swift index 0f62d85..c6d2ff7 100644 --- a/potassiumProvider/FileProviderDomainRegistrar.swift +++ b/potassiumProvider/FileProviderDomainRegistrar.swift @@ -16,6 +16,7 @@ protocol ProviderDomainRegistering { func releaseKnownFolders(for configuration: ProviderDomainConfiguration) async throws func userVisibleRootURL(for configuration: ProviderDomainConfiguration) async throws -> URL func signalWorkingSet(for configuration: ProviderDomainConfiguration) async throws + func knownFolderOwner() async throws -> ProviderKnownFolderOwner? } enum ProviderKnownFolderSyncState: Equatable, Sendable { @@ -25,6 +26,54 @@ enum ProviderKnownFolderSyncState: Equatable, Sendable { case active } +struct ProviderKnownFolderOwner: Equatable, Sendable { + let domainIdentifier: String + let displayName: String + let includesDesktop: Bool + let includesDocuments: Bool + + var isPartial: Bool { + includesDesktop != includesDocuments + } +} + +enum KnownFolderTransferPhase: Equatable, Sendable { + case idle + case preparing + case awaitingConsent + case connectedUploading + case upToDate + case quotaBlocked + case attentionRequired +} + +struct KnownFolderPreflight: Equatable, Sendable { + enum Ownership: Equatable, Sendable { + case none + case thisVault + case legacyPotassium(domainIdentifier: String) + case externalProvider(displayName: String) + case partial(displayName: String) + } + + let ownership: Ownership + let vaultIsUnlocked: Bool + let remoteIsReachable: Bool + let availableQuotaBytes: Int64? + + var canRequestClaim: Bool { + guard vaultIsUnlocked, remoteIsReachable, ownership != .thisVault else { + return false + } + switch ownership { + case .partial, .legacyPotassium: + return false + case .none, .thisVault, .externalProvider: + return true + } + } +} + extension ProviderDomainRegistering { func knownFolderSyncStates() async throws -> [String: ProviderKnownFolderSyncState] { [:] @@ -52,6 +101,10 @@ extension ProviderDomainRegistering { func signalWorkingSet(for configuration: ProviderDomainConfiguration) async throws { throw ProviderKnownFolderRegistrationError.managerUnavailable(configuration.domainIdentifier) } + + func knownFolderOwner() async throws -> ProviderKnownFolderOwner? { + nil + } } @MainActor @@ -112,6 +165,26 @@ struct FileProviderDomainRegistrar: ProviderDomainRegistering { #endif } + func knownFolderOwner() async throws -> ProviderKnownFolderOwner? { + #if os(macOS) + let domains = try await registeredDomains() + return domains.compactMap { domain -> ProviderKnownFolderOwner? in + let folders = domain.replicatedKnownFolders + guard folders.contains(.desktop) || folders.contains(.documents) else { + return nil + } + return ProviderKnownFolderOwner( + domainIdentifier: domain.identifier.rawValue, + displayName: domain.displayName, + includesDesktop: folders.contains(.desktop), + includesDocuments: folders.contains(.documents) + ) + }.first + #else + return nil + #endif + } + func claimKnownFolders(for configuration: ProviderDomainConfiguration, parentFileID: Int) async throws { #if os(macOS) try await claimKnownFolders( diff --git a/potassiumProvider/PotassiumProviderAppModel.swift b/potassiumProvider/PotassiumProviderAppModel.swift index 290fd7c..ad7277d 100644 --- a/potassiumProvider/PotassiumProviderAppModel.swift +++ b/potassiumProvider/PotassiumProviderAppModel.swift @@ -5,6 +5,7 @@ import FileProvider import Foundation import OSLog import PotassiumProviderCore +import Security struct ProviderDriveKey: Hashable, Sendable { let accountIdentifier: String @@ -34,7 +35,22 @@ final class PotassiumProviderAppModel: ObservableObject { @Published private(set) var activeDriveActions: [ProviderDriveKey: ProviderDriveAction] = [:] @Published private(set) var isReloadingStoredState = false @Published private(set) var pendingVaultProvisioning: PendingVaultProvisioning? + @Published private(set) var vaultSetupStep: VaultSetupStep? + @Published private(set) var vaultSetupOutcome = VaultSetupOutcome() + @Published private(set) var cloudAccessCandidatesByDriveID: + [Int: [VaultCloudAccessCandidate]] = [:] + @Published private(set) var cloudAccessStatusesByVaultID: + [VaultIdentifier: VaultCloudAccessStatus] = [:] + @Published private(set) var localKeyStatusesByVaultID: + [VaultIdentifier: VaultLocalKeyStatus] = [:] + @Published private(set) var knownFolderPreflightsByDomainIdentifier: + [String: KnownFolderPreflight] = [:] + @Published private(set) var knownFolderTransferPhasesByDomainIdentifier: + [String: KnownFolderTransferPhase] = [:] + @Published private(set) var vaultUXPreferencesByVaultID: + [VaultIdentifier: VaultUXPreferences] = [:] @Published private(set) var encryptedVaultsEnabled: Bool + @Published private(set) var encryptedVaultICloudKeychainEnabled: Bool @Published private(set) var statusMessage: String? @Published var errorMessage: String? @Published var manualAccessToken = "" @@ -53,6 +69,9 @@ final class PotassiumProviderAppModel: ObservableObject { private let objectStoreFactory: (Int, String) -> any KDriveObjectStoreProviding private let vaultKeyStore: any VaultKeyStoring private let vaultDeviceIdentityStore: any VaultDeviceIdentityStoring + private let vaultCloudAccessStore: any VaultCloudAccessStoring + private let vaultUserPresenceAuthorizer: any VaultUserPresenceAuthorizing + private let vaultUXDefaults: UserDefaults private let computerNameProvider: @Sendable () throws -> String private var pendingVaultAccountIdentifier: String? private var pendingVaultDriveName: String? @@ -77,9 +96,15 @@ final class PotassiumProviderAppModel: ObservableObject { }, vaultKeyStore: (any VaultKeyStoring)? = nil, vaultDeviceIdentityStore: (any VaultDeviceIdentityStoring)? = nil, + vaultCloudAccessStore: (any VaultCloudAccessStoring)? = nil, + vaultUserPresenceAuthorizer: (any VaultUserPresenceAuthorizing)? = nil, + vaultUXDefaults: UserDefaults? = nil, encryptedVaultsEnabled: Bool = UserDefaults.standard.bool( forKey: ProviderConstants.encryptedVaultFeatureFlag ), + encryptedVaultICloudKeychainEnabled: Bool = UserDefaults.standard.bool( + forKey: ProviderConstants.encryptedVaultICloudKeychainFeatureFlag + ), computerNameProvider: @escaping @Sendable () throws -> String = { try KDriveMachineNamespaceName.current() } ) { self.accountStore = accountStore ?? Self.makeDefaultAccountStore() @@ -98,7 +123,20 @@ final class PotassiumProviderAppModel: ObservableObject { self.vaultDeviceIdentityStore = vaultDeviceIdentityStore ?? (vaultKeyStore as? any VaultDeviceIdentityStoring) ?? defaultVaultKeyStore + self.vaultCloudAccessStore = vaultCloudAccessStore + ?? KeychainVaultCloudAccessStore( + accessGroup: ProviderConstants.keychainAccessGroup + ) + self.vaultUserPresenceAuthorizer = vaultUserPresenceAuthorizer + ?? LocalAuthenticationVaultUserPresenceAuthorizer() + self.vaultUXDefaults = vaultUXDefaults + ?? UserDefaults( + suiteName: ProviderConstants.appGroupIdentifier + ) + ?? .standard self.encryptedVaultsEnabled = encryptedVaultsEnabled + self.encryptedVaultICloudKeychainEnabled = + encryptedVaultICloudKeychainEnabled self.computerNameProvider = computerNameProvider accounts = initialAccounts drivesByAccountIdentifier = initialDrivesByAccountIdentifier @@ -167,6 +205,52 @@ final class PotassiumProviderAppModel: ObservableObject { knownFolderSyncStatesByDomainIdentifier[configuration.domainIdentifier] ?? .unavailable } + func knownFolderPreflight( + for configuration: ProviderDomainConfiguration + ) -> KnownFolderPreflight? { + knownFolderPreflightsByDomainIdentifier[configuration.domainIdentifier] + } + + func knownFolderTransferPhase( + for configuration: ProviderDomainConfiguration + ) -> KnownFolderTransferPhase { + knownFolderTransferPhasesByDomainIdentifier[ + configuration.domainIdentifier + ] ?? .idle + } + + func cloudAccessCandidates(driveID: Int) -> [VaultCloudAccessCandidate] { + cloudAccessCandidatesByDriveID[driveID] ?? [] + } + + func cloudAccessStatus( + for configuration: ProviderDomainConfiguration + ) -> VaultCloudAccessStatus { + guard let vaultID = configuration.vault?.vaultIdentifier else { + return .disabled + } + return cloudAccessStatusesByVaultID[vaultID] ?? .disabled + } + + func vaultSetupNeedsAttention( + for configuration: ProviderDomainConfiguration + ) -> Bool { + guard let vaultID = configuration.vault?.vaultIdentifier else { + return false + } + return vaultUXPreferencesByVaultID[vaultID]?.onboardingVersion + != VaultUXPreferences.currentOnboardingVersion + } + + func localKeyStatus( + for configuration: ProviderDomainConfiguration + ) -> VaultLocalKeyStatus { + guard let vaultID = configuration.vault?.vaultIdentifier else { + return .missing + } + return localKeyStatusesByVaultID[vaultID] ?? .missing + } + func isChangingKnownFolderSync(for configuration: ProviderDomainConfiguration) -> Bool { knownFolderTransitionDomainIdentifiers.contains(configuration.domainIdentifier) } @@ -232,6 +316,7 @@ final class PotassiumProviderAppModel: ObservableObject { let synchronizedState = try await synchronizedDomainConfigurations() domains = synchronizedState.configurations try await refreshKnownFolderSyncStates() + await refreshVaultAccessState() seedDraftState() if let synchronizationError = synchronizedState.registrationError { @@ -335,6 +420,7 @@ final class PotassiumProviderAppModel: ObservableObject { let drives = try await fileProviderFactory(token.accessToken).listDrives() drivesByAccountIdentifier[accountIdentifier] = drives + await refreshVaultAccessState() if selectedDriveIDs[accountIdentifier] == nil || drives.contains(where: { $0.id == selectedDriveIDs[accountIdentifier] }) == false { selectedDriveIDs[accountIdentifier] = drives.first?.id @@ -447,15 +533,20 @@ final class PotassiumProviderAppModel: ObservableObject { pendingVaultAccountIdentifier = accountIdentifier pendingVaultDriveName = drive.name pendingVaultProvisioning = pending + vaultSetupStep = .overview + vaultSetupOutcome = VaultSetupOutcome() errorMessage = nil - statusMessage = "Save the recovery kit and confirm it before the encrypted domain is registered." + statusMessage = "Review encrypted-vault protection before saving the recovery kit." } catch { errorMessage = "Could not prepare the encrypted vault: \(error.localizedDescription)" statusMessage = nil } } - func confirmEncryptedVault(recoveryKitConfirmation: String) async { + func confirmEncryptedVault( + recoveryKitConfirmation: String, + useICloudKeychain: Bool = false + ) async { guard let pending = pendingVaultProvisioning, let accountIdentifier = pendingVaultAccountIdentifier, let driveName = pendingVaultDriveName else { @@ -467,6 +558,7 @@ final class PotassiumProviderAppModel: ObservableObject { defer { endDriveAction(for: key) } do { + vaultSetupStep = .registering let token = try await usableToken(accountIdentifier: accountIdentifier) let service = VaultProvisioningService( objectStore: objectStoreFactory(pending.driveID, token.accessToken), @@ -482,10 +574,57 @@ final class PotassiumProviderAppModel: ObservableObject { driveName: driveName, vaultConfiguration: vaultConfiguration ) + guard let configuration = domains.first(where: { configuration in + configuration.vault?.vaultIdentifier == pending.vaultID + }) else { + throw VaultDomainRegistrationError.vaultAlreadyRegistered + } + var cloudStatus = VaultCloudAccessStatus.disabled + if useICloudKeychain { + guard encryptedVaultICloudKeychainEnabled else { + cloudStatus = .unavailable + vaultSetupOutcome = VaultSetupOutcome( + configuration: configuration, + cloudAccessStatus: cloudStatus, + recoveryKitVerified: true + ) + clearPendingVault() + advanceVaultSetupAfterRegistration( + configuration: configuration + ) + statusMessage = "Created the vault. iCloud Keychain convenience remains behind its security-review gate." + errorMessage = nil + return + } + do { + try await vaultCloudAccessStore.save( + VaultCloudAccessRecord( + configuration: vaultConfiguration, + driveID: pending.driveID, + rootKey: pending.rootKey + ) + ) + cloudStatus = .available + } catch { + cloudStatus = .unavailable + } + } + vaultSetupOutcome = VaultSetupOutcome( + configuration: configuration, + cloudAccessStatus: cloudStatus, + recoveryKitVerified: true + ) clearPendingVault() - statusMessage = "Created the encrypted vault and added it to Files." + await refreshVaultAccessState() + advanceVaultSetupAfterRegistration( + configuration: configuration + ) + statusMessage = cloudStatus == .unavailable + ? "Created the encrypted vault. iCloud Keychain setup needs attention." + : "Created the encrypted vault and added it to Files." errorMessage = nil } catch { + vaultSetupStep = .recoveryKit errorMessage = "Could not confirm the encrypted vault: \(error.localizedDescription)" statusMessage = nil } @@ -494,7 +633,7 @@ final class PotassiumProviderAppModel: ObservableObject { func cancelEncryptedVaultProvisioning() async { guard let pending = pendingVaultProvisioning, let accountIdentifier = pendingVaultAccountIdentifier else { - clearPendingVault() + finishVaultSetup() return } if let token = try? await usableToken(accountIdentifier: accountIdentifier) { @@ -505,9 +644,59 @@ final class PotassiumProviderAppModel: ObservableObject { await service.cancel(pending) } clearPendingVault() + vaultSetupStep = nil + vaultSetupOutcome = VaultSetupOutcome() statusMessage = "Cancelled encrypted-vault setup." } + func setVaultSetupStep(_ step: VaultSetupStep) { + guard vaultSetupStep != nil else { return } + vaultSetupStep = step + } + + func finishVaultSetup() { + if vaultSetupStep == .complete, + let configuration = vaultSetupOutcome.configuration { + saveVaultUXPreferences( + VaultUXPreferences( + desktopDocumentsDeferred: + vaultSetupOutcome.desktopDocumentsDeferred + ), + configuration: configuration + ) + } + clearPendingVault() + vaultSetupStep = nil + vaultSetupOutcome = VaultSetupOutcome() + } + + func resumeVaultSetup(for configuration: ProviderDomainConfiguration) { + guard configuration.encryptionMode == .opaqueVaultV1, + let vault = configuration.vault else { + errorMessage = "The selected domain is not an encrypted vault." + return + } + let preferences = vaultUXPreferencesByVaultID[vault.vaultIdentifier] + let desktopDocumentsEnabled = + knownFolderSyncState(for: configuration) == .active + vaultSetupOutcome = VaultSetupOutcome( + configuration: configuration, + cloudAccessStatus: + cloudAccessStatusesByVaultID[vault.vaultIdentifier] + ?? .disabled, + recoveryKitVerified: true, + desktopDocumentsDeferred: + preferences?.desktopDocumentsDeferred ?? false, + desktopDocumentsEnabled: desktopDocumentsEnabled + ) + #if os(macOS) + vaultSetupStep = desktopDocumentsEnabled ? .complete : .desktopDocuments + #else + vaultSetupStep = .complete + #endif + errorMessage = nil + } + func openEncryptedVault( accountIdentifier: String, drive: KDriveDriveSummary, @@ -537,6 +726,7 @@ final class PotassiumProviderAppModel: ObservableObject { driveName: drive.name, vaultConfiguration: vaultConfiguration ) + await refreshVaultAccessState() statusMessage = "Opened the encrypted vault on this device." errorMessage = nil } catch { @@ -545,6 +735,201 @@ final class PotassiumProviderAppModel: ObservableObject { } } + func openEncryptedVaultFromICloud( + accountIdentifier: String, + drive: KDriveDriveSummary, + vaultID: VaultIdentifier + ) async { + guard encryptedVaultICloudKeychainEnabled else { + errorMessage = "iCloud Keychain vault access is disabled until its security review is complete." + return + } + let key = ProviderDriveKey( + accountIdentifier: accountIdentifier, + driveID: drive.id + ) + guard beginDriveAction(.addingToFiles, for: key) else { return } + defer { endDriveAction(for: key) } + + do { + try await vaultUserPresenceAuthorizer.authorize( + reason: "Open the encrypted kDrive vault on this device." + ) + guard let record = try await vaultCloudAccessStore.record( + vaultID: vaultID + ) else { + throw VaultCloudAccessStoreError.malformedRecord + } + let token = try await usableToken(accountIdentifier: accountIdentifier) + let provisioning = VaultProvisioningService( + objectStore: objectStoreFactory(drive.id, token.accessToken), + keyStore: vaultKeyStore + ) + let vaultConfiguration = try await provisioning.openExistingVault( + cloudAccessRecord: record, + expectedDriveID: drive.id + ) + try await registerEncryptedDomain( + accountIdentifier: accountIdentifier, + driveID: drive.id, + driveName: drive.name, + vaultConfiguration: vaultConfiguration + ) + await refreshVaultAccessState() + statusMessage = "Authenticated the iCloud Keychain record and opened the vault on this device." + errorMessage = nil + } catch { + errorMessage = "Could not open the vault from iCloud Keychain: \(error.localizedDescription)" + statusMessage = nil + } + } + + func enableICloudKeychainAccess( + for configuration: ProviderDomainConfiguration + ) async { + guard encryptedVaultICloudKeychainEnabled else { + errorMessage = "iCloud Keychain vault access is behind a separate security-review feature gate." + return + } + guard let vault = configuration.vault else { + errorMessage = "The selected domain is not an encrypted vault." + return + } + do { + try await vaultUserPresenceAuthorizer.authorize( + reason: "Allow trusted Apple devices to open this encrypted vault." + ) + guard let rootKey = try await vaultKeyStore.loadRootKey( + vaultID: vault.vaultIdentifier + ) else { + throw EncryptedVaultError.missingKey + } + if let existing = try await vaultCloudAccessStore.record( + vaultID: vault.vaultIdentifier + ) { + guard existing.keyEpoch <= vault.keyEpoch else { + throw VaultProvisioningError.keyEpochMismatch + } + if existing.keyEpoch == vault.keyEpoch, + existing.rootKey != rootKey { + throw VaultCloudAccessStoreError.conflictingRecord + } + } + try await vaultCloudAccessStore.save( + VaultCloudAccessRecord( + configuration: vault, + driveID: configuration.driveID, + rootKey: rootKey + ) + ) + await refreshVaultAccessState() + statusMessage = "Saved an end-to-end encrypted vault access record to iCloud Keychain. Other devices may take a moment to see it." + errorMessage = nil + } catch { + await refreshVaultAccessState() + errorMessage = "Could not enable iCloud Keychain access: \(error.localizedDescription)" + statusMessage = nil + } + } + + func removeICloudKeychainAccess( + for configuration: ProviderDomainConfiguration + ) async { + guard let vaultID = configuration.vault?.vaultIdentifier else { + errorMessage = "The selected domain is not an encrypted vault." + return + } + do { + try await vaultUserPresenceAuthorizer.authorize( + reason: "Remove this vault access record from iCloud Keychain." + ) + try await vaultCloudAccessStore.delete(vaultID: vaultID) + await refreshVaultAccessState() + statusMessage = "Removed the synchronized iCloud Keychain record. Device-local keys remain available and a full rekey is still required to revoke a lost device." + errorMessage = nil + } catch { + errorMessage = "Could not remove iCloud Keychain access: \(error.localizedDescription)" + statusMessage = nil + } + } + + func restoreVaultKeyFromICloud( + for configuration: ProviderDomainConfiguration + ) async { + guard let vault = configuration.vault else { + errorMessage = "The selected domain is not an encrypted vault." + return + } + do { + try await vaultUserPresenceAuthorizer.authorize( + reason: "Restore this vault key to the current device." + ) + guard let record = try await vaultCloudAccessStore.record( + vaultID: vault.vaultIdentifier + ) else { + throw VaultCloudAccessStoreError.malformedRecord + } + guard record.vaultConfiguration == vault, + record.driveID == configuration.driveID else { + throw VaultProvisioningError.cloudRecordMismatch + } + let token = try await usableToken( + accountIdentifier: configuration.accountIdentifier + ) + let provisioning = VaultProvisioningService( + objectStore: objectStoreFactory( + configuration.driveID, + token.accessToken + ), + keyStore: vaultKeyStore + ) + _ = try await provisioning.openExistingVault( + cloudAccessRecord: record, + expectedDriveID: configuration.driveID + ) + await refreshVaultAccessState() + try? await domainRegistrar.signalWorkingSet(for: configuration) + statusMessage = "Authenticated iCloud Keychain and restored the vault key to this device." + errorMessage = nil + } catch { + await refreshVaultAccessState() + errorMessage = "Could not restore the vault key from iCloud Keychain: \(error.localizedDescription)" + statusMessage = nil + } + } + + func verifyRecoveryKit( + for configuration: ProviderDomainConfiguration, + recoveryKitText: String + ) async { + guard let vault = configuration.vault else { + errorMessage = "The selected domain is not an encrypted vault." + return + } + do { + let token = try await usableToken( + accountIdentifier: configuration.accountIdentifier + ) + let provisioning = VaultProvisioningService( + objectStore: objectStoreFactory( + configuration.driveID, + token.accessToken + ), + keyStore: vaultKeyStore + ) + try await provisioning.verifyRecoveryKit( + recoveryKitText, + expectedConfiguration: vault, + expectedDriveID: configuration.driveID + ) + statusMessage = "The recovery kit authenticated this vault." + errorMessage = nil + } catch { + errorMessage = "The recovery kit could not be verified: \(error.localizedDescription)" + statusMessage = nil + } + } + /// Normal logout and domain removal deliberately retain the device key. /// This separate destructive action requires the matching recovery kit and /// preserves the rollback checkpoint so a later import can still alarm. @@ -557,13 +942,26 @@ final class PotassiumProviderAppModel: ObservableObject { return } do { - let kit = try VaultRecoveryKit(encoded: recoveryKitConfirmation) - guard kit.vaultID == vault.vaultIdentifier, - kit.driveID == configuration.driveID else { - throw VaultProvisioningError.recoveryConfirmationMismatch - } + let token = try await usableToken( + accountIdentifier: configuration.accountIdentifier + ) + let provisioning = VaultProvisioningService( + objectStore: objectStoreFactory( + configuration.driveID, + token.accessToken + ), + keyStore: vaultKeyStore + ) + try await provisioning.verifyRecoveryKit( + recoveryKitConfirmation, + expectedConfiguration: vault, + expectedDriveID: configuration.driveID + ) try await vaultKeyStore.deleteRootKey(vaultID: vault.vaultIdentifier) - statusMessage = "Forgot this vault key on this device. The recovery kit is required to unlock it again." + await refreshVaultAccessState() + statusMessage = cloudAccessStatusesByVaultID[vault.vaultIdentifier] == .available + ? "Forgot this device-local key. Restore from iCloud Keychain or use the recovery kit to unlock it again." + : "Forgot this vault key on this device. The recovery kit is required to unlock it again." errorMessage = nil } catch { errorMessage = "Could not forget the vault key: \(error.localizedDescription)" @@ -595,6 +993,33 @@ final class PotassiumProviderAppModel: ObservableObject { } } + func prepareKnownFolderSync( + for configuration: ProviderDomainConfiguration + ) async { + #if os(macOS) + knownFolderTransferPhasesByDomainIdentifier[ + configuration.domainIdentifier + ] = .preparing + do { + let preflight = try await evaluateKnownFolderPreflight( + for: configuration + ) + knownFolderPreflightsByDomainIdentifier[ + configuration.domainIdentifier + ] = preflight + knownFolderTransferPhasesByDomainIdentifier[ + configuration.domainIdentifier + ] = preflight.canRequestClaim ? .idle : .attentionRequired + errorMessage = nil + } catch { + knownFolderTransferPhasesByDomainIdentifier[ + configuration.domainIdentifier + ] = .attentionRequired + errorMessage = "Could not prepare Desktop and Documents protection: \(error.localizedDescription)" + } + #endif + } + func enableKnownFolderSync(for configuration: ProviderDomainConfiguration) async { #if os(macOS) guard beginKnownFolderTransition(.enablingKnownFolders, for: configuration) else { return } @@ -604,6 +1029,34 @@ final class PotassiumProviderAppModel: ObservableObject { var didClaimKnownFolders = false var plaintextNamespaceName: String? do { + knownFolderTransferPhasesByDomainIdentifier[ + configuration.domainIdentifier + ] = .preparing + let preflight = try await evaluateKnownFolderPreflight( + for: configuration + ) + knownFolderPreflightsByDomainIdentifier[ + configuration.domainIdentifier + ] = preflight + guard preflight.canRequestClaim else { + switch preflight.ownership { + case .legacyPotassium: + throw KnownFolderSetupError.legacyMigrationRequired + case .partial: + throw KnownFolderSetupError.partialClaimRequiresRepair + case .thisVault: + try await refreshKnownFolderSyncStates() + knownFolderTransferPhasesByDomainIdentifier[ + configuration.domainIdentifier + ] = .connectedUploading + return + case .none, .externalProvider: + throw KnownFolderSetupError.preflightFailed + } + } + knownFolderTransferPhasesByDomainIdentifier[ + configuration.domainIdentifier + ] = .awaitingConsent let token = try await usableToken(accountIdentifier: configuration.accountIdentifier) namespacedConfiguration.knownFolderLayout = .machineNamespace namespacedConfiguration.updatedAt = Date() @@ -651,8 +1104,11 @@ final class PotassiumProviderAppModel: ObservableObject { } didClaimKnownFolders = true try await refreshKnownFolderSyncStates() + knownFolderTransferPhasesByDomainIdentifier[ + configuration.domainIdentifier + ] = .connectedUploading if configuration.encryptionMode == .opaqueVaultV1 { - statusMessage = "Desktop and Documents now sync with \(configuration.displayName)." + statusMessage = "Desktop and Documents are connected to \(configuration.displayName). Finder shows initial encrypted-upload progress." } else { statusMessage = "Desktop and Documents now sync with \(configuration.displayName) in kDrive /Private/\(plaintextNamespaceName ?? "")." } @@ -667,7 +1123,17 @@ final class PotassiumProviderAppModel: ObservableObject { } } try? await refreshKnownFolderSyncStates() - guard isUserCancellation(error) == false else { return } + guard isUserCancellation(error) == false else { + knownFolderTransferPhasesByDomainIdentifier[ + configuration.domainIdentifier + ] = .idle + return + } + knownFolderTransferPhasesByDomainIdentifier[ + configuration.domainIdentifier + ] = error.localizedDescription.localizedCaseInsensitiveContains( + "quota" + ) ? .quotaBlocked : .attentionRequired await recordAppFailure( kind: .domainManagement, summary: "Could not enable Desktop and Documents synchronization.", @@ -688,6 +1154,9 @@ final class PotassiumProviderAppModel: ObservableObject { do { try await domainRegistrar.releaseKnownFolders(for: configuration) try await refreshKnownFolderSyncStates() + knownFolderTransferPhasesByDomainIdentifier[ + configuration.domainIdentifier + ] = .idle statusMessage = "Stopped syncing Desktop and Documents with \(configuration.displayName)." errorMessage = nil } catch { @@ -700,10 +1169,39 @@ final class PotassiumProviderAppModel: ObservableObject { ) errorMessage = "Could not stop syncing Desktop and Documents: \(error.localizedDescription)" statusMessage = nil + knownFolderTransferPhasesByDomainIdentifier[ + configuration.domainIdentifier + ] = .attentionRequired } #endif } + func configureDesktopDocumentsDuringSetup(enable: Bool) async { + guard let configuration = vaultSetupOutcome.configuration else { + errorMessage = "The encrypted vault has not been registered." + return + } + guard enable else { + vaultSetupOutcome.desktopDocumentsDeferred = true + saveVaultUXPreferences( + VaultUXPreferences(desktopDocumentsDeferred: true), + configuration: configuration + ) + vaultSetupStep = .complete + return + } + + await enableKnownFolderSync(for: configuration) + if knownFolderSyncState(for: configuration) == .active { + vaultSetupOutcome.desktopDocumentsEnabled = true + saveVaultUXPreferences( + VaultUXPreferences(desktopDocumentsDeferred: false), + configuration: configuration + ) + vaultSetupStep = .complete + } + } + func userVisibleRootURL(for configuration: ProviderDomainConfiguration) async -> URL? { let key = driveKey(for: configuration) guard beginDriveAction(.showingInFiles, for: key) else { return nil } @@ -737,6 +1235,11 @@ final class PotassiumProviderAppModel: ObservableObject { do { try await domainRegistrar.signalWorkingSet(for: configuration) + if knownFolderSyncState(for: configuration) == .active { + knownFolderTransferPhasesByDomainIdentifier[ + configuration.domainIdentifier + ] = .upToDate + } statusMessage = "Requested a fresh sync for \(configuration.displayName)." errorMessage = nil } catch { @@ -1026,6 +1529,199 @@ final class PotassiumProviderAppModel: ObservableObject { pendingVaultDriveName = nil } + private func advanceVaultSetupAfterRegistration( + configuration: ProviderDomainConfiguration + ) { + #if os(macOS) + vaultSetupStep = .desktopDocuments + #else + saveVaultUXPreferences( + VaultUXPreferences(desktopDocumentsDeferred: false), + configuration: configuration + ) + vaultSetupStep = .complete + #endif + } + + private func saveVaultUXPreferences( + _ preferences: VaultUXPreferences, + configuration: ProviderDomainConfiguration + ) { + guard let vaultID = configuration.vault?.vaultIdentifier, + let data = try? JSONEncoder().encode(preferences) else { + return + } + vaultUXDefaults.set( + data, + forKey: "vaultUX:\(vaultID.rawValue.uuidString.lowercased())" + ) + vaultUXPreferencesByVaultID[vaultID] = preferences + } + + private func evaluateKnownFolderPreflight( + for configuration: ProviderDomainConfiguration + ) async throws -> KnownFolderPreflight { + let owner = try await domainRegistrar.knownFolderOwner() + let ownership: KnownFolderPreflight.Ownership + if let owner { + if owner.isPartial { + ownership = .partial(displayName: owner.displayName) + } else if owner.domainIdentifier == configuration.domainIdentifier { + ownership = .thisVault + } else if let ownerConfiguration = domains.first(where: { + $0.domainIdentifier == owner.domainIdentifier + }), ownerConfiguration.encryptionMode == .legacyPlaintext { + ownership = .legacyPotassium( + domainIdentifier: ownerConfiguration.domainIdentifier + ) + } else { + ownership = .externalProvider(displayName: owner.displayName) + } + } else { + ownership = .none + } + + let vaultIsUnlocked: Bool + let remoteIsReachable: Bool + if configuration.encryptionMode == .opaqueVaultV1, + let vaultID = configuration.vault?.vaultIdentifier { + let rootKey = try await vaultKeyStore.loadRootKey(vaultID: vaultID) + vaultIsUnlocked = rootKey != nil + if rootKey != nil { + do { + let token = try await usableToken( + accountIdentifier: configuration.accountIdentifier + ) + let vault = try await makeEncryptedVaultService( + configuration: configuration, + accessToken: token.accessToken + ) + _ = try await vault.synchronize() + remoteIsReachable = true + } catch { + remoteIsReachable = false + } + } else { + remoteIsReachable = false + } + } else { + vaultIsUnlocked = true + do { + _ = try await usableToken( + accountIdentifier: configuration.accountIdentifier + ) + remoteIsReachable = true + } catch { + remoteIsReachable = false + } + } + + return KnownFolderPreflight( + ownership: ownership, + vaultIsUnlocked: vaultIsUnlocked, + remoteIsReachable: remoteIsReachable, + availableQuotaBytes: nil + ) + } + + func refreshVaultAccessState() async { + vaultUXPreferencesByVaultID = Dictionary( + uniqueKeysWithValues: domains.compactMap { configuration in + guard let vaultID = configuration.vault?.vaultIdentifier, + let data = vaultUXDefaults.data( + forKey: + "vaultUX:\(vaultID.rawValue.uuidString.lowercased())" + ), + let preferences = try? JSONDecoder().decode( + VaultUXPreferences.self, + from: data + ) else { + return nil + } + return (vaultID, preferences) + } + ) + + var localStatuses: [VaultIdentifier: VaultLocalKeyStatus] = [:] + for configuration in domains where configuration.encryptionMode == .opaqueVaultV1 { + guard let vaultID = configuration.vault?.vaultIdentifier else { + continue + } + do { + localStatuses[vaultID] = try await vaultKeyStore.loadRootKey( + vaultID: vaultID + ) == nil ? .missing : .available + } catch VaultKeyStoreError.unhandledStatus(let status) + where status == errSecInteractionNotAllowed { + localStatuses[vaultID] = .locked + } catch VaultCryptoError.invalidKeyLength { + localStatuses[vaultID] = .invalid + } catch { + localStatuses[vaultID] = .invalid + } + } + localKeyStatusesByVaultID = localStatuses + + guard encryptedVaultICloudKeychainEnabled else { + cloudAccessCandidatesByDriveID = [:] + cloudAccessStatusesByVaultID = Dictionary( + uniqueKeysWithValues: localStatuses.keys.map { ($0, .disabled) } + ) + return + } + + do { + let records = try await vaultCloudAccessStore.records() + let groupedByVault = Dictionary(grouping: records, by: \.vaultID) + cloudAccessCandidatesByDriveID = Dictionary( + grouping: records.map(VaultCloudAccessCandidate.init), + by: \.driveID + ).mapValues { + $0.sorted { lhs, rhs in + if lhs.createdAt != rhs.createdAt { + return lhs.createdAt < rhs.createdAt + } + return lhs.vaultID.rawValue.uuidString + < rhs.vaultID.rawValue.uuidString + } + } + + var statuses: [VaultIdentifier: VaultCloudAccessStatus] = [:] + for configuration in domains where configuration.encryptionMode == .opaqueVaultV1 { + guard let vault = configuration.vault else { continue } + let matching = groupedByVault[vault.vaultIdentifier] ?? [] + guard matching.count <= 1 else { + statuses[vault.vaultIdentifier] = .conflict + continue + } + guard let record = matching.first else { + statuses[vault.vaultIdentifier] = .disabled + continue + } + if record.keyEpoch != vault.keyEpoch { + statuses[vault.vaultIdentifier] = .staleEpoch + } else if record.driveID != configuration.driveID + || record.vaultConfiguration != vault { + statuses[vault.vaultIdentifier] = .conflict + } else { + let localKey = try? await vaultKeyStore.loadRootKey( + vaultID: vault.vaultIdentifier + ) + statuses[vault.vaultIdentifier] = + localKey != nil && localKey != record.rootKey + ? .conflict + : .available + } + } + cloudAccessStatusesByVaultID = statuses + } catch { + cloudAccessCandidatesByDriveID = [:] + cloudAccessStatusesByVaultID = Dictionary( + uniqueKeysWithValues: localStatuses.keys.map { ($0, .unavailable) } + ) + } + } + private func removeDomainAndLocalState(_ configuration: ProviderDomainConfiguration) async throws { try await releaseKnownFoldersBeforeRemovingDomain(configuration) try await domainRegistrar.removeDomain(for: configuration) @@ -1456,6 +2152,23 @@ private enum VaultDomainRegistrationError: Error, LocalizedError { } } +private enum KnownFolderSetupError: Error, LocalizedError { + case legacyMigrationRequired + case partialClaimRequiresRepair + case preflightFailed + + var errorDescription: String? { + switch self { + case .legacyMigrationRequired: + return "Desktop and Documents are owned by a legacy plaintext Potassium domain. Complete verified encrypted migration before switching ownership." + case .partialClaimRequiresRepair: + return "Only one known folder is currently claimed. Stop the partial configuration before enabling both folders again." + case .preflightFailed: + return "Desktop and Documents protection did not pass its unlock and reachability checks." + } + } +} + private extension String { var nilIfEmpty: String? { isEmpty ? nil : self diff --git a/potassiumProvider/ProviderSetupView.swift b/potassiumProvider/ProviderSetupView.swift index 407d816..31a2dfb 100644 --- a/potassiumProvider/ProviderSetupView.swift +++ b/potassiumProvider/ProviderSetupView.swift @@ -534,6 +534,9 @@ private struct ProviderDriveManagementView: View { @State private var isStopSyncConfirmationPresented = false @State private var isOpenVaultPresented = false @State private var isForgetKeyPresented = false + @State private var isRecoveryVerificationPresented = false + @State private var isCloudRemovalConfirmationPresented = false + @State private var isKnownFolderPreflightPresented = false var body: some View { Group { @@ -578,8 +581,8 @@ private struct ProviderDriveManagementView: View { } message: { Text("This removes the File Provider domain, cached snapshots, activities, conflicts, and other provider-local state. Remote kDrive files are not deleted.") } - .sheet(isPresented: pendingVaultBinding) { - VaultRecoveryConfirmationView(model: model) + .sheet(isPresented: vaultSetupBinding) { + EncryptedVaultSetupFlow(model: model) } .sheet(isPresented: $isOpenVaultPresented) { if let drive = descriptor?.remote { @@ -595,7 +598,40 @@ private struct ProviderDriveManagementView: View { VaultForgetKeyView(model: model, configuration: configuration) } } + .sheet(isPresented: $isRecoveryVerificationPresented) { + if let configuration = descriptor?.encryptedConfiguration { + VaultRecoveryVerificationView( + model: model, + configuration: configuration + ) + } + } + .confirmationDialog( + "Remove this vault from iCloud Keychain?", + isPresented: $isCloudRemovalConfirmationPresented, + titleVisibility: .visible + ) { + Button("Remove from iCloud Keychain", role: .destructive) { + guard let configuration = descriptor?.encryptedConfiguration else { + return + } + Task { + await model.removeICloudKeychainAccess(for: configuration) + } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("The synchronized record is removed globally. Keys already imported by another device remain usable; revoking a lost device requires a full vault rekey.") + } #if os(macOS) + .sheet(isPresented: $isKnownFolderPreflightPresented) { + if let configuration = descriptor?.configuration { + KnownFolderPreflightView( + model: model, + configuration: configuration + ) + } + } .confirmationDialog( "Stop syncing Desktop and Documents?", isPresented: $isStopSyncConfirmationPresented, @@ -628,12 +664,16 @@ private struct ProviderDriveManagementView: View { activeAction != nil || model.isLoadingDrives(for: key.accountIdentifier) } - private var pendingVaultBinding: Binding { + private var vaultSetupBinding: Binding { Binding( - get: { model.pendingVaultProvisioning != nil }, + get: { model.vaultSetupStep != nil }, set: { isPresented in - if isPresented == false, model.pendingVaultProvisioning != nil { - Task { await model.cancelEncryptedVaultProvisioning() } + if isPresented == false, model.vaultSetupStep != nil { + if model.pendingVaultProvisioning != nil { + Task { await model.cancelEncryptedVaultProvisioning() } + } else { + model.finishVaultSetup() + } } } ) @@ -680,6 +720,41 @@ private struct ProviderDriveManagementView: View { } if descriptor.encryptedConfiguration == nil, let remote = descriptor.remote { + if model.encryptedVaultICloudKeychainEnabled { + ForEach(model.cloudAccessCandidates(driveID: remote.id)) { candidate in + Button { + Task { + await model.openEncryptedVaultFromICloud( + accountIdentifier: key.accountIdentifier, + drive: remote, + vaultID: candidate.vaultID + ) + } + } label: { + Label { + VStack(alignment: .leading, spacing: 2) { + Text("Encrypted Vault Found in iCloud Keychain") + Text( + "\(candidate.vaultID.rawValue.uuidString.prefix(8)) · saved \(candidate.createdAt.formatted(date: .abbreviated, time: .shortened))" + ) + .font(.caption) + .foregroundStyle(.secondary) + } + } icon: { + Image(systemName: "icloud.and.arrow.down") + } + } + .buttonStyle(.borderedProminent) + .disabled(isBusy) + .accessibilityIdentifier("drive.openICloudVault") + } + + Button("Check iCloud Keychain Again", systemImage: "arrow.clockwise.icloud") { + Task { await model.refreshVaultAccessState() } + } + .disabled(isBusy) + } + Button { Task { await model.prepareEncryptedVault( @@ -757,15 +832,13 @@ private struct ProviderDriveManagementView: View { .disabled(isBusy) .accessibilityIdentifier("drive.syncNow") - if configuration.encryptionMode == .opaqueVaultV1 { - Button("Forget Key on This Device", systemImage: "key.slash") { - isForgetKeyPresented = true - } - .disabled(isBusy) - } } } + if let configuration = descriptor.encryptedConfiguration { + securityRecoverySection(configuration) + } + if descriptor.legacyConfigurations.isEmpty == false { Section { ForEach(descriptor.legacyConfigurations) { configuration in @@ -825,12 +898,132 @@ private struct ProviderDriveManagementView: View { } } + private func securityRecoverySection( + _ configuration: ProviderDomainConfiguration + ) -> some View { + let localStatus = model.localKeyStatus(for: configuration) + let cloudStatus = model.cloudAccessStatus(for: configuration) + return Section { + if model.vaultSetupNeedsAttention(for: configuration) { + Button( + "Finish Vault Setup", + systemImage: "checklist" + ) { + model.resumeVaultSetup(for: configuration) + } + .buttonStyle(.borderedProminent) + .accessibilityIdentifier("vault.resumeSetup") + } + + LabeledContent("This Device", value: localKeyStatusTitle(localStatus)) + LabeledContent( + "iCloud Keychain", + value: cloudAccessStatusTitle(cloudStatus) + ) + + if model.encryptedVaultICloudKeychainEnabled { + switch cloudStatus { + case .disabled: + Button("Use iCloud Keychain", systemImage: "icloud") { + Task { + await model.enableICloudKeychainAccess( + for: configuration + ) + } + } + .disabled(isBusy || localStatus != .available) + case .available: + if localStatus != .available { + Button( + "Restore Key to This Device", + systemImage: "icloud.and.arrow.down" + ) { + Task { + await model.restoreVaultKeyFromICloud( + for: configuration + ) + } + } + .buttonStyle(.borderedProminent) + } + Button( + "Remove from iCloud Keychain", + systemImage: "icloud.slash", + role: .destructive + ) { + isCloudRemovalConfirmationPresented = true + } + case .unavailable, .staleEpoch, .conflict: + Button("Check iCloud Keychain Again", systemImage: "arrow.clockwise.icloud") { + Task { await model.refreshVaultAccessState() } + } + } + } else { + Label( + "iCloud Keychain convenience is behind a separate security-review gate.", + systemImage: "checkmark.shield" + ) + .font(.footnote) + .foregroundStyle(.secondary) + } + + Button("Verify Recovery Kit", systemImage: "checkmark.shield") { + isRecoveryVerificationPresented = true + } + Button("Forget Key on This Device", systemImage: "key.slash") { + isForgetKeyPresented = true + } + .disabled(isBusy || localStatus != .available) + } header: { + Text("Security & Recovery") + } footer: { + Text("The recovery kit remains the independent fallback. iCloud Keychain does not revoke keys already imported by other devices.") + } + } + + private func localKeyStatusTitle(_ status: VaultLocalKeyStatus) -> String { + switch status { + case .available: + "Available" + case .locked: + "Unlock Device" + case .missing: + "Missing" + case .invalid: + "Invalid" + } + } + + private func cloudAccessStatusTitle( + _ status: VaultCloudAccessStatus + ) -> String { + switch status { + case .disabled: + "Off" + case .available: + "Available" + case .unavailable: + "Unavailable" + case .staleEpoch: + "Update Required" + case .conflict: + "Conflict" + } + } + #if os(macOS) private func knownFolderSection(_ configuration: ProviderDomainConfiguration) -> some View { let state = model.knownFolderSyncState(for: configuration) let remotePath = model.knownFolderRemotePath(for: configuration) + let transferPhase = model.knownFolderTransferPhase(for: configuration) return Section { LabeledContent("Status", value: knownFolderStatusTitle(state)) + if transferPhase != .idle { + LabeledContent( + "Transfer", + value: knownFolderTransferTitle(transferPhase) + ) + } Text(knownFolderDetail(state, remotePath: remotePath)) .font(.subheadline) .foregroundStyle(.secondary) @@ -844,7 +1037,10 @@ private struct ProviderDriveManagementView: View { .accessibilityIdentifier("drive.stopKnownFolders") case .inactive: Button { - Task { await model.enableKnownFolderSync(for: configuration) } + Task { + await model.prepareKnownFolderSync(for: configuration) + isKnownFolderPreflightPresented = true + } } label: { actionLabel( title: "Sync Desktop & Documents", @@ -862,7 +1058,30 @@ private struct ProviderDriveManagementView: View { } header: { Text("Desktop & Documents") } footer: { - Text("macOS manages Desktop and Documents together under kDrive \(remotePath).") + Text(configuration.encryptionMode == .opaqueVaultV1 + ? "macOS manages both folders together. Their contents are encrypted before kDrive upload; Finder shows per-item transfer progress." + : "macOS manages Desktop and Documents together under kDrive \(remotePath).") + } + } + + private func knownFolderTransferTitle( + _ phase: KnownFolderTransferPhase + ) -> String { + switch phase { + case .idle: + "Not Started" + case .preparing: + "Preparing" + case .awaitingConsent: + "Awaiting macOS Consent" + case .connectedUploading: + "Connected · Uploading" + case .upToDate: + "Up to Date" + case .quotaBlocked: + "Quota Blocked" + case .attentionRequired: + "Attention Required" } } @@ -957,66 +1176,291 @@ private struct ProviderSetupErrorBanner: View { } } -private struct VaultRecoveryConfirmationView: View { +private struct EncryptedVaultSetupFlow: View { @ObservedObject var model: PotassiumProviderAppModel @Environment(\.dismiss) private var dismiss @State private var confirmation = "" + @State private var useICloudKeychain = false + @State private var didRunKnownFolderPreflight = false var body: some View { NavigationStack { Form { - Section { - if let kit = model.pendingVaultProvisioning?.recoveryKit.encoded { - VaultRecoveryQRCode(value: kit) - .frame(maxWidth: .infinity) - Text(kit) - .font(.system(.caption, design: .monospaced)) - .textSelection(.enabled) - .accessibilityIdentifier("vault.recoveryKit") - } - } header: { - Text("One-time Recovery Kit") - } footer: { - Text("Save this offline. It is never uploaded, logged, or automatically exported. Losing every device key and this kit makes the vault unrecoverable.") - } - - Section("Confirm") { - TextEditor(text: $confirmation) - .font(.system(.caption, design: .monospaced)) - .frame(minHeight: 110) - .accessibilityIdentifier("vault.recoveryConfirmation") - Text("Paste the complete recovery kit to prove you saved it.") - .font(.footnote) - .foregroundStyle(.secondary) - } + setupContent } - .navigationTitle("Save Recovery Kit") + .navigationTitle(navigationTitle) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { Task { - await model.cancelEncryptedVaultProvisioning() + if model.pendingVaultProvisioning != nil { + await model.cancelEncryptedVaultProvisioning() + } else { + model.finishVaultSetup() + } dismiss() } } } - ToolbarItem(placement: .confirmationAction) { - Button("Create Vault") { - Task { - await model.confirmEncryptedVault( - recoveryKitConfirmation: confirmation - ) - if model.pendingVaultProvisioning == nil { - dismiss() - } - } + confirmationToolbar + } + } + .interactiveDismissDisabled(model.vaultSetupStep != .complete) + .frame(minWidth: 520, minHeight: 620) + } + + @ViewBuilder + private var setupContent: some View { + switch model.vaultSetupStep { + case .overview: + Section("End-to-End Encryption") { + Label( + "kDrive receives randomized authenticated ciphertext objects.", + systemImage: "lock.shield" + ) + Label( + "Finder and Files show normal names, metadata, thumbnails, and contents after local decryption.", + systemImage: "folder" + ) + Label( + "Plaintext remains on this trusted device and may be indexed by Spotlight.", + systemImage: "desktopcomputer" + ) + } + Section("What kDrive Can Still See") { + Text("Vault presence, padded ciphertext sizes, object counts, server timestamps, request timing, access patterns, quota use, and deletion remain visible.") + .foregroundStyle(.secondary) + } + + case .keyAccess: + Section("Key Access") { + Toggle( + "Also use iCloud Keychain", + isOn: $useICloudKeychain + ) + .disabled(model.encryptedVaultICloudKeychainEnabled == false) + Text(useICloudKeychain + ? "A separate end-to-end encrypted access record lets trusted Apple devices open this vault. Apple Account recovery and trusted-device security become part of the custody boundary." + : "The unwrapped vault key remains only in this device’s non-synchronizing Data Protection Keychain.") + .font(.footnote) + .foregroundStyle(.secondary) + if model.encryptedVaultICloudKeychainEnabled == false { + Label( + "iCloud Keychain convenience is behind a separate security-review gate.", + systemImage: "checkmark.shield" + ) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + Section { + Text("The offline recovery kit is required in both modes and is never placed in iCloud Keychain.") + } + + case .recoveryKit: + Section { + if let kit = model.pendingVaultProvisioning?.recoveryKit.encoded { + VaultRecoveryQRCode(value: kit) + .frame(maxWidth: .infinity) + Text(kit) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + .accessibilityIdentifier("vault.recoveryKit") + } + } header: { + Text("One-time Recovery Kit") + } footer: { + Text("Save this offline. It is never uploaded, logged, or automatically exported. Losing every device key and this kit makes the vault unrecoverable.") + } + Section("Confirm") { + TextEditor(text: $confirmation) + .font(.system(.caption, design: .monospaced)) + .frame(minHeight: 110) + .accessibilityIdentifier("vault.recoveryConfirmation") + Text("Paste or scan the complete recovery kit to prove you saved it.") + .font(.footnote) + .foregroundStyle(.secondary) + } + + case .registering: + Section { + HStack { + ProgressView() + Text("Registering and authenticating the encrypted vault…") + } + } + + case .desktopDocuments: + #if os(macOS) + desktopDocumentsSetupContent + #else + Section { + Text("Desktop and Documents uploaded by a Mac remain available in Files on this device.") + } + #endif + + case .complete: + completionContent + + case nil: + EmptyView() + } + } + + #if os(macOS) + @ViewBuilder + private var desktopDocumentsSetupContent: some View { + Section("Protect Desktop & Documents") { + Label( + "macOS will hand both folders to this File Provider domain.", + systemImage: "desktopcomputer" + ) + Text("Each file is encrypted before kDrive upload. Existing copies held by iCloud Drive or another provider are not removed.") + .font(.footnote) + .foregroundStyle(.secondary) + + if let configuration = model.vaultSetupOutcome.configuration, + let preflight = model.knownFolderPreflight(for: configuration) { + KnownFolderPreflightSummary(preflight: preflight) + } else { + HStack { + ProgressView() + Text("Checking ownership, vault unlock, and reachability…") + } + } + + Button("Protect Desktop & Documents", systemImage: "lock.desktopcomputer") { + Task { + await model.configureDesktopDocumentsDuringSetup(enable: true) + } + } + .buttonStyle(.borderedProminent) + .disabled({ + guard let configuration = model.vaultSetupOutcome.configuration, + let preflight = model.knownFolderPreflight(for: configuration) + else { + return true + } + return preflight.canRequestClaim == false + }()) + + Button("Not Now") { + Task { + await model.configureDesktopDocumentsDuringSetup(enable: false) + } + } + } + .task { + guard didRunKnownFolderPreflight == false, + let configuration = model.vaultSetupOutcome.configuration else { + return + } + didRunKnownFolderPreflight = true + await model.prepareKnownFolderSync(for: configuration) + } + } + #endif + + @ViewBuilder + private var completionContent: some View { + Section("Vault Ready") { + Label("Added to Finder and Files", systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + Label("Recovery kit verified", systemImage: "checkmark.shield") + LabeledContent( + "iCloud Keychain", + value: cloudStatusTitle( + model.vaultSetupOutcome.cloudAccessStatus + ) + ) + #if os(macOS) + LabeledContent( + "Desktop & Documents", + value: model.vaultSetupOutcome.desktopDocumentsEnabled + ? "Connected" + : "Not Now" + ) + #else + Text("Desktop and Documents uploaded by a Mac are browsable in Files.") + .foregroundStyle(.secondary) + #endif + } + if model.vaultSetupOutcome.cloudAccessStatus == .unavailable { + Section { + Label( + "The vault remains valid. Retry iCloud Keychain later from Security & Recovery.", + systemImage: "icloud.slash" + ) + .foregroundStyle(.orange) + } + } + } + + @ToolbarContentBuilder + private var confirmationToolbar: some ToolbarContent { + ToolbarItem(placement: .confirmationAction) { + switch model.vaultSetupStep { + case .overview: + Button("Continue") { model.setVaultSetupStep(.keyAccess) } + case .keyAccess: + Button("Continue") { model.setVaultSetupStep(.recoveryKit) } + case .recoveryKit: + Button("Create Vault") { + Task { + await model.confirmEncryptedVault( + recoveryKitConfirmation: confirmation, + useICloudKeychain: useICloudKeychain + ) } - .disabled(confirmation.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } + .disabled( + confirmation.trimmingCharacters( + in: .whitespacesAndNewlines + ).isEmpty + ) + case .complete: + Button("Done") { + model.finishVaultSetup() + dismiss() + } + case .registering, .desktopDocuments, nil: + EmptyView() } } - .interactiveDismissDisabled() - .frame(minWidth: 520, minHeight: 620) + } + + private var navigationTitle: String { + switch model.vaultSetupStep { + case .overview: + "Encrypted Vault" + case .keyAccess: + "Choose Key Access" + case .recoveryKit: + "Save Recovery Kit" + case .registering: + "Creating Vault" + case .desktopDocuments: + "Desktop & Documents" + case .complete: + "Setup Complete" + case nil: + "Encrypted Vault" + } + } + + private func cloudStatusTitle(_ status: VaultCloudAccessStatus) -> String { + switch status { + case .disabled: + "Device Only" + case .available: + "Enabled" + case .unavailable: + "Needs Retry" + case .staleEpoch: + "Update Required" + case .conflict: + "Conflict" + } } } @@ -1068,6 +1512,189 @@ private struct VaultOpenView: View { } } +private struct VaultRecoveryVerificationView: View { + @ObservedObject var model: PotassiumProviderAppModel + let configuration: ProviderDomainConfiguration + + @Environment(\.dismiss) private var dismiss + @State private var recoveryKit = "" + @State private var isVerifying = false + + var body: some View { + NavigationStack { + Form { + Section { + TextEditor(text: $recoveryKit) + .font(.system(.caption, design: .monospaced)) + .frame(minHeight: 150) + } header: { + Text("Recovery Kit") + } footer: { + Text("The kit authenticates the encrypted vault header locally. Recovery material is not sent, saved, or added to iCloud Keychain.") + } + } + .navigationTitle("Verify Recovery Kit") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Verify") { + isVerifying = true + Task { + await model.verifyRecoveryKit( + for: configuration, + recoveryKitText: recoveryKit + ) + isVerifying = false + if model.errorMessage == nil { + dismiss() + } + } + } + .disabled( + isVerifying || recoveryKit.trimmingCharacters( + in: .whitespacesAndNewlines + ).isEmpty + ) + } + } + } + .frame(minWidth: 500, minHeight: 360) + } +} + +#if os(macOS) +private struct KnownFolderPreflightView: View { + @ObservedObject var model: PotassiumProviderAppModel + let configuration: ProviderDomainConfiguration + + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + Form { + Section("Desktop & Documents Preflight") { + if let preflight = model.knownFolderPreflight( + for: configuration + ) { + KnownFolderPreflightSummary(preflight: preflight) + } else { + HStack { + ProgressView() + Text("Checking ownership, unlock, and reachability…") + } + } + } + + Section { + Text("macOS requests consent before moving both known folders. Potassium encrypts every new file revision before its kDrive upload.") + Text("Existing copies held by iCloud Drive or another provider are not removed.") + } + .font(.footnote) + .foregroundStyle(.secondary) + } + .navigationTitle("Protect Desktop & Documents") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Not Now") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + if let preflight = model.knownFolderPreflight( + for: configuration + ), preflight.canRequestClaim { + Button("Continue") { + Task { + await model.enableKnownFolderSync( + for: configuration + ) + if model.knownFolderSyncState( + for: configuration + ) == .active { + dismiss() + } + } + } + } else { + Button("Check Again") { + Task { + await model.prepareKnownFolderSync( + for: configuration + ) + } + } + } + } + } + } + .frame(minWidth: 520, minHeight: 430) + } +} + +private struct KnownFolderPreflightSummary: View { + let preflight: KnownFolderPreflight + + var body: some View { + LabeledContent( + "Vault Key", + value: preflight.vaultIsUnlocked ? "Available" : "Unlock Required" + ) + LabeledContent( + "kDrive", + value: preflight.remoteIsReachable ? "Reachable" : "Unavailable" + ) + LabeledContent("Available Quota", value: quotaTitle) + LabeledContent("Current Owner", value: ownershipTitle) + + switch preflight.ownership { + case .externalProvider(let displayName): + Label( + "\(displayName) may retain earlier server copies after macOS switches ownership.", + systemImage: "exclamationmark.triangle" + ) + .foregroundStyle(.orange) + case .legacyPotassium: + Label( + "Complete verified encrypted migration before releasing the plaintext Potassium domain.", + systemImage: "lock.trianglebadge.exclamationmark" + ) + .foregroundStyle(.orange) + case .partial: + Label( + "Stop the partial known-folder configuration before enabling both folders again.", + systemImage: "wrench.and.screwdriver" + ) + .foregroundStyle(.orange) + case .none, .thisVault: + EmptyView() + } + } + + private var quotaTitle: String { + if let bytes = preflight.availableQuotaBytes { + return ByteCountFormatter.string( + fromByteCount: bytes, + countStyle: .file + ) + } + return "Checked During Upload" + } + + private var ownershipTitle: String { + switch preflight.ownership { + case .none: + "Not Managed" + case .thisVault: + "This Encrypted Vault" + case .legacyPotassium: + "Legacy Potassium" + case .externalProvider(let displayName), .partial(let displayName): + displayName + } + } +} +#endif + private struct VaultForgetKeyView: View { @ObservedObject var model: PotassiumProviderAppModel let configuration: ProviderDomainConfiguration diff --git a/potassiumProvider/VaultUserPresenceAuthorizer.swift b/potassiumProvider/VaultUserPresenceAuthorizer.swift new file mode 100644 index 0000000..27c047f --- /dev/null +++ b/potassiumProvider/VaultUserPresenceAuthorizer.swift @@ -0,0 +1,35 @@ +import Foundation +import LocalAuthentication + +@MainActor +protocol VaultUserPresenceAuthorizing { + func authorize(reason: String) async throws +} + +@MainActor +struct LocalAuthenticationVaultUserPresenceAuthorizer: + VaultUserPresenceAuthorizing +{ + func authorize(reason: String) async throws { + let context = LAContext() + var error: NSError? + guard context.canEvaluatePolicy( + .deviceOwnerAuthentication, + error: &error + ) else { + throw error ?? VaultUserPresenceAuthorizationError.unavailable + } + try await context.evaluatePolicy( + .deviceOwnerAuthentication, + localizedReason: reason + ) + } +} + +enum VaultUserPresenceAuthorizationError: Error, LocalizedError { + case unavailable + + var errorDescription: String? { + "Device authentication is not available." + } +} diff --git a/potassiumProviderTests/VaultCloudAccessTests.swift b/potassiumProviderTests/VaultCloudAccessTests.swift new file mode 100644 index 0000000..23c5904 --- /dev/null +++ b/potassiumProviderTests/VaultCloudAccessTests.swift @@ -0,0 +1,401 @@ +import Foundation +import Security +@testable import PotassiumProviderCore +import Testing + +struct VaultCloudAccessTests { + @Test func recordRoundTripsWithoutRecoveryOrDeviceState() throws { + let record = try makeRecord() + let data = try VaultCoding.encoder.encode(record) + let decoded = try VaultCoding.decoder.decode( + VaultCloudAccessRecord.self, + from: data + ) + + #expect(decoded == record) + let text = try #require(String(data: data, encoding: .utf8)) + #expect(text.contains("recoverySecret") == false) + #expect(text.contains("trustedState") == false) + #expect(text.contains("deviceID") == false) + } + + @Test func malformedRootKeyAndUnsupportedRecordVersionFailClosed() throws { + let record = try makeRecord() + let encoded = try VaultCoding.encoder.encode(record) + var object = try #require( + try JSONSerialization.jsonObject(with: encoded) as? [String: Any] + ) + + object["recordVersion"] = 2 + let future = try JSONSerialization.data(withJSONObject: object) + #expect(throws: VaultCloudAccessStoreError.self) { + _ = try VaultCoding.decoder.decode( + VaultCloudAccessRecord.self, + from: future + ) + } + + object["recordVersion"] = 1 + var remoteLayout = try #require( + object["remoteLayout"] as? [String: Any] + ) + remoteLayout["unexpectedField"] = true + object["remoteLayout"] = remoteLayout + let unknownNestedField = try JSONSerialization.data( + withJSONObject: object + ) + #expect(throws: VaultCloudAccessStoreError.malformedRecord) { + _ = try VaultCoding.decoder.decode( + VaultCloudAccessRecord.self, + from: unknownNestedField + ) + } + + remoteLayout["unexpectedField"] = nil + object["remoteLayout"] = remoteLayout + object["unexpectedField"] = "must fail closed" + let unknownField = try JSONSerialization.data(withJSONObject: object) + #expect(throws: VaultCloudAccessStoreError.malformedRecord) { + _ = try VaultCoding.decoder.decode( + VaultCloudAccessRecord.self, + from: unknownField + ) + } + + object["unexpectedField"] = nil + object["rootKey"] = Data(repeating: 1, count: 8).base64EncodedString() + let malformed = try JSONSerialization.data(withJSONObject: object) + #expect(throws: VaultCloudAccessStoreError.malformedRecord) { + _ = try VaultCoding.decoder.decode( + VaultCloudAccessRecord.self, + from: malformed + ) + } + } + + @Test func recoveryVerificationAuthenticatesSecretWithoutPersistingIt() async throws { + let directory = temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + let objectStore = InMemoryOpaqueObjectStore() + let keyStore = InMemoryVaultKeyStore() + let provisioning = VaultProvisioningService( + objectStore: objectStore, + keyStore: keyStore, + temporaryDirectoryURL: directory + ) + let pending = try await provisioning.prepareNewVault(driveID: 42) + + try await provisioning.verifyRecoveryKit( + pending.recoveryKit.encoded, + expectedConfiguration: pending.vaultConfiguration, + expectedDriveID: 42 + ) + #expect( + try await keyStore.loadRootKey(vaultID: pending.vaultID) == nil + ) + + let wrongKit = VaultRecoveryKit( + vaultID: pending.vaultID, + driveID: pending.driveID, + vaultRootFileID: pending.vaultConfiguration.vaultRootFileID, + vaultHeaderFileID: pending.vaultConfiguration.vaultHeaderFileID, + recoverySecret: try VaultKeyMaterial.random() + ) + await #expect(throws: VaultCryptoError.authenticationFailed) { + try await provisioning.verifyRecoveryKit( + wrongKit.encoded, + expectedConfiguration: pending.vaultConfiguration, + expectedDriveID: 42 + ) + } + #expect( + try await keyStore.loadRootKey(vaultID: pending.vaultID) == nil + ) + } + + @Test func cloudAndDeviceKeychainAttributesRemainSeparated() async throws { + let cloud = KeychainVaultCloudAccessStore( + service: "test.cloud", + accessGroup: "TEAM.test" + ) + let query = await cloud.baseQuery(account: "vaultCloudAccess:test") + let attributes = await cloud.saveAttributes(data: Data([1])) + + #expect( + query[kSecAttrSynchronizable as String] as? Bool == true + ) + #expect( + query[kSecUseDataProtectionKeychain as String] as? Bool == true + ) + #expect( + attributes[kSecAttrAccessible as String] as? String + == kSecAttrAccessibleWhenUnlocked as String + ) + + let device = KeychainVaultKeyStore( + service: "test.device", + accessGroup: "TEAM.test" + ) + let deviceQuery = await device.baseQuery(account: "vaultRootKey:test") + let deviceAttributes = await device.saveAttributes(data: Data([1])) + #expect( + deviceQuery[kSecAttrSynchronizable as String] as? Bool == false + ) + #expect( + deviceAttributes[kSecAttrAccessible as String] as? String + == kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly as String + ) + } + + @Test func authenticatedCloudImportRestoresRootAndPreservesTrustedFrontier() async throws { + let directory = temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + let objectStore = InMemoryOpaqueObjectStore() + let keyStore = InMemoryVaultKeyStore() + let provisioning = VaultProvisioningService( + objectStore: objectStore, + keyStore: keyStore, + temporaryDirectoryURL: directory + ) + let pending = try await provisioning.prepareNewVault(driveID: 42) + _ = try await provisioning.confirm( + pending, + recoveryKitConfirmation: pending.recoveryKit.encoded + ) + let configuration = ProviderDomainConfiguration( + domainIdentifier: "cloud-import", + displayName: "Encrypted", + driveID: 42, + driveName: "Drive", + encryptionMode: .opaqueVaultV1, + vault: pending.vaultConfiguration + ) + let localStore = try VaultSQLiteStore( + databaseURL: directory.appendingPathComponent("cloud-import.sqlite3"), + domainIdentifier: configuration.domainIdentifier, + vaultID: pending.vaultID, + rootKey: pending.rootKey + ) + let vault = try EncryptedVaultService( + configuration: configuration, + rootKey: pending.rootKey, + deviceID: UUID(), + objectStore: objectStore, + localStore: localStore, + keyStore: keyStore, + temporaryDirectoryURL: directory + ) + _ = try await vault.createDirectory( + parentID: nil, + filename: "encrypted-name", + createdAt: Date(timeIntervalSince1970: 100) + ) + let trustedBefore = try #require( + try await keyStore.loadTrustedState(vaultID: pending.vaultID) + ) + try await keyStore.deleteRootKey(vaultID: pending.vaultID) + let record = try VaultCloudAccessRecord( + configuration: pending.vaultConfiguration, + driveID: 42, + rootKey: pending.rootKey + ) + + let opened = try await provisioning.openExistingVault( + cloudAccessRecord: record, + expectedDriveID: 42 + ) + + #expect(opened == pending.vaultConfiguration) + #expect( + try await keyStore.loadRootKey(vaultID: pending.vaultID) + == pending.rootKey + ) + let trustedAfter = try #require( + try await keyStore.loadTrustedState(vaultID: pending.vaultID) + ) + #expect( + trustedAfter.frontier.transactionIDs + .isSuperset(of: trustedBefore.frontier.transactionIDs) + ) + } + + @Test func wrongCloudRootAndStaleEpochNeverPersistLocally() async throws { + let directory = temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + let objectStore = InMemoryOpaqueObjectStore() + let sourceStore = InMemoryVaultKeyStore() + let source = VaultProvisioningService( + objectStore: objectStore, + keyStore: sourceStore, + temporaryDirectoryURL: directory + ) + let pending = try await source.prepareNewVault(driveID: 7) + let targetStore = InMemoryVaultKeyStore() + let target = VaultProvisioningService( + objectStore: objectStore, + keyStore: targetStore, + temporaryDirectoryURL: directory + ) + let wrongRoot = try VaultKeyMaterial.random() + let wrongRecord = try VaultCloudAccessRecord( + configuration: pending.vaultConfiguration, + driveID: 7, + rootKey: wrongRoot + ) + + await #expect(throws: VaultCryptoError.authenticationFailed) { + try await target.openExistingVault( + cloudAccessRecord: wrongRecord, + expectedDriveID: 7 + ) + } + #expect( + try await targetStore.loadRootKey(vaultID: pending.vaultID) == nil + ) + + let stale = VaultCloudAccessRecord( + vaultID: pending.vaultID, + driveID: 7, + vaultRootFileID: pending.vaultConfiguration.vaultRootFileID, + vaultHeaderFileID: pending.vaultConfiguration.vaultHeaderFileID, + formatVersion: pending.vaultConfiguration.formatVersion, + keyEpoch: pending.vaultConfiguration.keyEpoch + 1, + remoteLayout: try #require( + pending.vaultConfiguration.remoteLayout + ), + rootKey: pending.rootKey + ) + await #expect(throws: VaultProvisioningError.keyEpochMismatch) { + try await target.openExistingVault( + cloudAccessRecord: stale, + expectedDriveID: 7 + ) + } + #expect( + try await targetStore.loadRootKey(vaultID: pending.vaultID) == nil + ) + } + + @Test func cloudRestoreRejectsRollbackBeforePersistingRootKey() async throws { + let directory = temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + let objectStore = InMemoryOpaqueObjectStore() + let keyStore = InMemoryVaultKeyStore() + let provisioning = VaultProvisioningService( + objectStore: objectStore, + keyStore: keyStore, + temporaryDirectoryURL: directory + ) + let pending = try await provisioning.prepareNewVault(driveID: 42) + _ = try await provisioning.confirm( + pending, + recoveryKitConfirmation: pending.recoveryKit.encoded + ) + let configuration = ProviderDomainConfiguration( + domainIdentifier: "cloud-rollback", + displayName: "Encrypted", + driveID: 42, + driveName: "Drive", + encryptionMode: .opaqueVaultV1, + vault: pending.vaultConfiguration + ) + let localStore = try VaultSQLiteStore( + databaseURL: directory.appendingPathComponent("cloud-rollback.sqlite3"), + domainIdentifier: configuration.domainIdentifier, + vaultID: pending.vaultID, + rootKey: pending.rootKey + ) + let vault = try EncryptedVaultService( + configuration: configuration, + rootKey: pending.rootKey, + deviceID: UUID(), + objectStore: objectStore, + localStore: localStore, + keyStore: keyStore, + temporaryDirectoryURL: directory + ) + _ = try await vault.createDirectory( + parentID: nil, + filename: "trusted-folder", + createdAt: Date(timeIntervalSince1970: 100) + ) + let trustedBefore = try #require( + try await keyStore.loadTrustedState(vaultID: pending.vaultID) + ) + let journalContainerID = try #require( + pending.vaultConfiguration.remoteLayout?.journalContainerID + ) + let journalPage = await objectStore.listObjects( + containerID: journalContainerID, + cursor: nil + ) + let journalObject = try #require(journalPage.objects.first) + await objectStore.deleteObject(fileID: journalObject.id) + try await keyStore.deleteRootKey(vaultID: pending.vaultID) + let record = try VaultCloudAccessRecord( + configuration: pending.vaultConfiguration, + driveID: 42, + rootKey: pending.rootKey + ) + + await #expect(throws: VaultJournalError.rollbackDetected) { + try await provisioning.openExistingVault( + cloudAccessRecord: record, + expectedDriveID: 42 + ) + } + #expect( + try await keyStore.loadRootKey(vaultID: pending.vaultID) == nil + ) + #expect( + try await keyStore.loadTrustedState(vaultID: pending.vaultID) + == trustedBefore + ) + } + + private func makeRecord() throws -> VaultCloudAccessRecord { + VaultCloudAccessRecord( + vaultID: VaultIdentifier( + rawValue: UUID(uuidString: "00112233-4455-6677-8899-AABBCCDDEEFF")! + ), + driveID: 42, + vaultRootFileID: 10, + vaultHeaderFileID: 11, + formatVersion: VaultFormat.currentVersion, + keyEpoch: VaultFormat.currentKeyEpoch, + remoteLayout: VaultBootstrap.RemoteLayout( + contentContainerID: 12, + journalContainerID: 13, + checkpointContainerID: 14, + checkpointToken: "AAAAAAAAAAAAAAAAAAAAAAAAAAA" + ), + rootKey: try #require( + VaultKeyMaterial(data: Data(repeating: 0xA5, count: 32)) + ), + createdAt: Date(timeIntervalSince1970: 1_700_000_000) + ) + } + + private func temporaryDirectory() -> URL { + FileManager.default.temporaryDirectory.appendingPathComponent( + "VaultCloudAccessTests-\(UUID().uuidString)", + isDirectory: true + ) + } +} diff --git a/potassiumProviderTests/VaultProvisioningTests.swift b/potassiumProviderTests/VaultProvisioningTests.swift index f169a6e..896ff6b 100644 --- a/potassiumProviderTests/VaultProvisioningTests.swift +++ b/potassiumProviderTests/VaultProvisioningTests.swift @@ -440,7 +440,7 @@ struct VaultProvisioningTests { } } -private actor InMemoryOpaqueObjectStore: KDriveObjectStoreProviding { +actor InMemoryOpaqueObjectStore: KDriveObjectStoreProviding { private struct Entry { var metadata: KDriveOpaqueObject var payload: Data? @@ -536,7 +536,7 @@ private actor InMemoryOpaqueObjectStore: KDriveObjectStoreProviding { } } -private enum TestObjectStoreError: Error { +enum TestObjectStoreError: Error { case missing } diff --git a/potassiumProviderTests/VaultUXAppModelTests.swift b/potassiumProviderTests/VaultUXAppModelTests.swift new file mode 100644 index 0000000..9cec7d1 --- /dev/null +++ b/potassiumProviderTests/VaultUXAppModelTests.swift @@ -0,0 +1,444 @@ +import Foundation +import PotassiumProviderCore +import Testing +@testable import potassiumProvider + +@Suite(.serialized) +@MainActor +struct VaultUXAppModelTests { + @Test func knownFolderPreflightRequiresUnlockAndBlocksUnsafeOwnership() { + let external = KnownFolderPreflight( + ownership: .externalProvider(displayName: "iCloud Drive"), + vaultIsUnlocked: true, + remoteIsReachable: true, + availableQuotaBytes: nil + ) + #expect(external.canRequestClaim) + + let legacy = KnownFolderPreflight( + ownership: .legacyPotassium(domainIdentifier: "plaintext"), + vaultIsUnlocked: true, + remoteIsReachable: true, + availableQuotaBytes: nil + ) + #expect(legacy.canRequestClaim == false) + + let locked = KnownFolderPreflight( + ownership: .none, + vaultIsUnlocked: false, + remoteIsReachable: true, + availableQuotaBytes: nil + ) + #expect(locked.canRequestClaim == false) + } + + @Test func cloudAccessCanBeEnabledAndRemovedWithoutChangingDeviceKey() async throws { + let context = try await makeContext() + defer { try? FileManager.default.removeItem(at: context.directory) } + await context.model.refreshVaultAccessState() + + #expect( + context.model.localKeyStatus(for: context.configuration) + == .available + ) + #expect( + context.model.cloudAccessStatus(for: context.configuration) + == .disabled + ) + + await context.model.enableICloudKeychainAccess( + for: context.configuration + ) + #expect( + try await context.cloudStore.record(vaultID: context.vaultID)? + .rootKey == context.rootKey + ) + #expect( + try await context.keyStore.loadRootKey(vaultID: context.vaultID) + == context.rootKey + ) + + await context.model.removeICloudKeychainAccess( + for: context.configuration + ) + #expect( + try await context.cloudStore.record(vaultID: context.vaultID) + == nil + ) + #expect( + try await context.keyStore.loadRootKey(vaultID: context.vaultID) + == context.rootKey + ) + } + + @Test func forgettingDeviceKeyNeverDeletesOrSilentlyImportsCloudRecord() async throws { + let context = try await makeContext() + defer { try? FileManager.default.removeItem(at: context.directory) } + let cloudRecord = try VaultCloudAccessRecord( + configuration: try #require(context.configuration.vault), + driveID: context.configuration.driveID, + rootKey: context.rootKey + ) + try await context.cloudStore.save(cloudRecord) + + await context.model.forgetVaultKey( + for: context.configuration, + recoveryKitConfirmation: context.recoveryKit.encoded + ) + + #expect( + try await context.keyStore.loadRootKey(vaultID: context.vaultID) + == nil + ) + #expect( + try await context.cloudStore.record(vaultID: context.vaultID) + == cloudRecord + ) + #expect( + context.model.localKeyStatus(for: context.configuration) == .missing + ) + } + + @Test func failedCloudPublicationKeepsRegisteredVaultAndRecoveryBoundary() async throws { + let directory = temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + let account = ProviderAccount( + accountIdentifier: "account", + displayName: "Account", + authenticationKind: .oauth + ) + let drive = KDriveDriveSummary( + id: 42, + name: "Drive", + accountID: 1, + role: "admin", + status: "active", + isInMaintenance: false + ) + let tokenStore = InMemoryOAuthTokenStore() + try await tokenStore.saveToken( + KDriveOAuthToken( + accessToken: "test-token", + tokenType: "Bearer", + refreshToken: nil, + scope: nil, + idToken: nil, + expiresAt: nil + ), + accountIdentifier: account.accountIdentifier + ) + let keyStore = InMemoryVaultKeyStore() + let objectStore = InMemoryOpaqueObjectStore() + let model = PotassiumProviderAppModel( + accountStore: ProviderAccountFileStore( + directoryURL: directory.appendingPathComponent("Accounts") + ), + domainStore: DomainConfigurationFileStore( + directoryURL: directory.appendingPathComponent("Domains") + ), + tokenStore: tokenStore, + oauthAuthenticator: VaultUXOAuthAuthenticator(), + domainRegistrar: VaultUXDomainRegistrar(), + automaticallyReloadStoredState: false, + initialAccounts: [account], + initialDrivesByAccountIdentifier: [ + account.accountIdentifier: [drive], + ], + fileProviderFactory: { _ in VaultUXFileProvider() }, + objectStoreFactory: { _, _ in objectStore }, + vaultKeyStore: keyStore, + vaultDeviceIdentityStore: VaultUXDeviceIdentityStore(), + vaultCloudAccessStore: FailingVaultCloudAccessStore(), + vaultUserPresenceAuthorizer: AllowVaultUserPresenceAuthorizer(), + vaultUXDefaults: UserDefaults( + suiteName: "VaultUXAppModelTests.\(UUID().uuidString)" + ), + encryptedVaultsEnabled: true, + encryptedVaultICloudKeychainEnabled: true + ) + + await model.prepareEncryptedVault( + accountIdentifier: account.accountIdentifier, + drive: drive + ) + #expect(model.vaultSetupStep == .overview) + let pending = try #require(model.pendingVaultProvisioning) + #expect( + try await keyStore.loadRootKey(vaultID: pending.vaultID) == nil + ) + + await model.confirmEncryptedVault( + recoveryKitConfirmation: pending.recoveryKit.encoded, + useICloudKeychain: true + ) + + let registered = try #require(model.domains.first) + #expect(registered.vault?.vaultIdentifier == pending.vaultID) + #expect(model.pendingVaultProvisioning == nil) + #expect(model.vaultSetupOutcome.cloudAccessStatus == .unavailable) + #expect(model.vaultSetupOutcome.recoveryKitVerified) + #if os(macOS) + #expect(model.vaultSetupStep == .desktopDocuments) + #expect(model.vaultSetupNeedsAttention(for: registered)) + model.finishVaultSetup() + model.resumeVaultSetup(for: registered) + #expect(model.vaultSetupStep == .desktopDocuments) + await model.configureDesktopDocumentsDuringSetup(enable: false) + await model.refreshVaultAccessState() + #expect(model.vaultSetupNeedsAttention(for: registered) == false) + #else + #expect(model.vaultSetupStep == .complete) + #expect(model.vaultSetupNeedsAttention(for: registered) == false) + #endif + #expect( + try await keyStore.loadRootKey(vaultID: pending.vaultID) + == pending.rootKey + ) + #expect(model.errorMessage == nil) + } + + private func makeContext() async throws -> VaultUXContext { + let directory = temporaryDirectory() + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + let keyStore = InMemoryVaultKeyStore() + let objectStore = InMemoryOpaqueObjectStore() + let provisioning = VaultProvisioningService( + objectStore: objectStore, + keyStore: keyStore, + temporaryDirectoryURL: directory + ) + let pending = try await provisioning.prepareNewVault(driveID: 42) + let vault = try await provisioning.confirm( + pending, + recoveryKitConfirmation: pending.recoveryKit.encoded + ) + let configuration = ProviderDomainConfiguration( + domainIdentifier: "encrypted-domain", + accountIdentifier: "account", + displayName: "Encrypted", + driveID: 42, + driveName: "Drive", + encryptionMode: .opaqueVaultV1, + vault: vault + ) + let cloudStore = InMemoryVaultCloudAccessStore() + let tokenStore = InMemoryOAuthTokenStore() + try await tokenStore.saveToken( + KDriveOAuthToken( + accessToken: "test-token", + tokenType: "Bearer", + refreshToken: nil, + scope: nil, + idToken: nil, + expiresAt: nil + ), + accountIdentifier: configuration.accountIdentifier + ) + let model = PotassiumProviderAppModel( + accountStore: ProviderAccountFileStore( + directoryURL: directory.appendingPathComponent("Accounts") + ), + domainStore: DomainConfigurationFileStore( + directoryURL: directory.appendingPathComponent("Domains") + ), + tokenStore: tokenStore, + oauthAuthenticator: VaultUXOAuthAuthenticator(), + domainRegistrar: VaultUXDomainRegistrar(), + automaticallyReloadStoredState: false, + initialAccounts: [ + ProviderAccount( + accountIdentifier: configuration.accountIdentifier, + displayName: "Account", + authenticationKind: .oauth + ), + ], + initialDomains: [configuration], + fileProviderFactory: { _ in VaultUXFileProvider() }, + objectStoreFactory: { _, _ in objectStore }, + vaultKeyStore: keyStore, + vaultDeviceIdentityStore: VaultUXDeviceIdentityStore(), + vaultCloudAccessStore: cloudStore, + vaultUserPresenceAuthorizer: AllowVaultUserPresenceAuthorizer(), + vaultUXDefaults: UserDefaults( + suiteName: "VaultUXAppModelTests.\(UUID().uuidString)" + ), + encryptedVaultsEnabled: true, + encryptedVaultICloudKeychainEnabled: true + ) + return VaultUXContext( + directory: directory, + model: model, + configuration: configuration, + vaultID: pending.vaultID, + rootKey: pending.rootKey, + recoveryKit: pending.recoveryKit, + keyStore: keyStore, + cloudStore: cloudStore + ) + } + + private func temporaryDirectory() -> URL { + FileManager.default.temporaryDirectory.appendingPathComponent( + "VaultUXAppModelTests-\(UUID().uuidString)", + isDirectory: true + ) + } +} + +private struct VaultUXContext { + let directory: URL + let model: PotassiumProviderAppModel + let configuration: ProviderDomainConfiguration + let vaultID: VaultIdentifier + let rootKey: VaultKeyMaterial + let recoveryKit: VaultRecoveryKit + let keyStore: InMemoryVaultKeyStore + let cloudStore: InMemoryVaultCloudAccessStore +} + +@MainActor +private struct AllowVaultUserPresenceAuthorizer: + VaultUserPresenceAuthorizing +{ + func authorize(reason: String) async throws {} +} + +private actor VaultUXDeviceIdentityStore: VaultDeviceIdentityStoring { + func loadOrCreateDeviceID(vaultID: VaultIdentifier) -> UUID { + UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE")! + } +} + +private actor FailingVaultCloudAccessStore: VaultCloudAccessStoring { + func records() -> [VaultCloudAccessRecord] { [] } + func record(vaultID: VaultIdentifier) -> VaultCloudAccessRecord? { nil } + func save(_ record: VaultCloudAccessRecord) throws { + throw VaultUXTestError.cloudUnavailable + } + func delete(vaultID: VaultIdentifier) {} +} + +@MainActor +private struct VaultUXDomainRegistrar: ProviderDomainRegistering { + func addDomain(for configuration: ProviderDomainConfiguration) async throws {} + func removeDomain(for configuration: ProviderDomainConfiguration) async throws {} +} + +private final class VaultUXOAuthAuthenticator: KDriveOAuthAuthenticating { + func authenticate() async throws -> KDriveOAuthToken { + throw VaultUXTestError.unsupported + } +} + +private actor VaultUXFileProvider: KDriveFileProviding { + func listDrives() async throws -> [KDriveDriveSummary] { [] } + + func item(driveID: Int, fileID: Int) async throws -> KDriveRemoteItem { + throw VaultUXTestError.unsupported + } + + func listDirectory( + driveID: Int, + folderID: Int, + cursor: String?, + limit: Int + ) async throws -> KDriveItemPage { + throw VaultUXTestError.unsupported + } + + func listAdvancedDirectory( + driveID: Int, + folderID: Int, + cursor: String?, + limit: Int + ) async throws -> KDriveAdvancedItemPage { + throw VaultUXTestError.unsupported + } + + func listTrash( + driveID: Int, + cursor: String?, + limit: Int + ) async throws -> KDriveItemPage { + throw VaultUXTestError.unsupported + } + + func downloadFile(driveID: Int, fileID: Int) async throws -> Data { + throw VaultUXTestError.unsupported + } + + func thumbnail( + driveID: Int, + fileID: Int, + width: Int?, + height: Int? + ) async throws -> Data { + throw VaultUXTestError.unsupported + } + + func uploadFile( + driveID: Int, + parentID: Int, + fileName: String, + contents: Data, + lastModifiedAt: Date?, + conflictStrategy: KDriveUploadConflictStrategy + ) async throws -> KDriveRemoteItem { + throw VaultUXTestError.unsupported + } + + func replaceFile( + driveID: Int, + parentID: Int, + fileName: String, + contents: Data, + lastModifiedAt: Date? + ) async throws -> KDriveRemoteItem { + throw VaultUXTestError.unsupported + } + + func createDirectory( + driveID: Int, + parentID: Int, + name: String + ) async throws -> KDriveRemoteItem { + throw VaultUXTestError.unsupported + } + + func renameItem( + driveID: Int, + fileID: Int, + name: String + ) async throws { + throw VaultUXTestError.unsupported + } + + func moveItem( + driveID: Int, + fileID: Int, + destinationParentID: Int, + name: String? + ) async throws { + throw VaultUXTestError.unsupported + } + + func trashItem(driveID: Int, fileID: Int) async throws { + throw VaultUXTestError.unsupported + } + + func deleteTrashedItem(driveID: Int, fileID: Int) async throws { + throw VaultUXTestError.unsupported + } +} + +private enum VaultUXTestError: Error { + case cloudUnavailable + case unsupported +} diff --git a/potassiumProviderUITests/potassiumProviderUITests.swift b/potassiumProviderUITests/potassiumProviderUITests.swift index ddbf0d4..9527e1a 100644 --- a/potassiumProviderUITests/potassiumProviderUITests.swift +++ b/potassiumProviderUITests/potassiumProviderUITests.swift @@ -46,7 +46,9 @@ final class potassiumProviderUITests: XCTestCase { XCTAssertTrue(availableDrive.waitForExistence(timeout: 5)) availableDrive.tap() - XCTAssertTrue(app.buttons["drive.addToFiles"].waitForExistence(timeout: 5)) + XCTAssertTrue( + app.buttons["drive.createEncryptedVault"].waitForExistence(timeout: 5) + ) XCTAssertTrue(app.staticTexts["This drive is currently in maintenance."].exists) } From bee9f3bd5182eb3279a93257f9d603b9960f3551 Mon Sep 17 00:00:00 2001 From: OpenCow Date: Sun, 2 Aug 2026 11:03:53 +0200 Subject: [PATCH 3/4] feat: gate encrypted vault onboarding with warning --- .../VaultCloudAccessStore.swift | 1 + README.md | 2 + doc/APP_AND_DOMAINS.md | 14 ++-- .../PotassiumProviderAppModel.swift | 55 +++++++++++--- potassiumProvider/ProviderSetupView.swift | 71 +++++++++++++++++++ potassiumProvider/ProviderUITestFixture.swift | 3 +- .../VaultUXAppModelTests.swift | 22 +++++- .../potassiumProviderUITests.swift | 37 ++++++++++ 8 files changed, 188 insertions(+), 17 deletions(-) diff --git a/PotassiumProviderCore/VaultCloudAccessStore.swift b/PotassiumProviderCore/VaultCloudAccessStore.swift index 65b509f..fa8641a 100644 --- a/PotassiumProviderCore/VaultCloudAccessStore.swift +++ b/PotassiumProviderCore/VaultCloudAccessStore.swift @@ -255,6 +255,7 @@ public enum VaultLocalKeyStatus: Equatable, Sendable { } public enum VaultSetupStep: String, Codable, Equatable, Sendable { + case unsupportedRiskWarning case overview case keyAccess case recoveryKit diff --git a/README.md b/README.md index 01c209c..8bcf662 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,8 @@ local Xcode requires a more specific variant. links, or user data. - Encrypted vaults are experimental and disabled by default pending independent cryptographic review. The feature flag is not a production-readiness claim. +- Creation starts with a mandatory unsupported-feature and complete-data-loss + warning whose continuation remains disabled for five seconds. - Encrypted-vault onboarding always requires a verified offline recovery kit. Optional iCloud Keychain access is a separately gated convenience: it can open a vault on another trusted Apple device, but it does not replace offline diff --git a/doc/APP_AND_DOMAINS.md b/doc/APP_AND_DOMAINS.md index b22f1f2..9094e73 100644 --- a/doc/APP_AND_DOMAINS.md +++ b/doc/APP_AND_DOMAINS.md @@ -251,11 +251,15 @@ token or expiration, so reconnecting may be required when it stops working. ## Encrypted vault domains When the security-review feature flag is enabled, drive management offers -Create Encrypted Vault and Open Existing Vault. Creation shows a one-time text -and QR recovery kit and requires exact confirmation before saving the device -key or registering the domain. Existing plaintext domains remain separately -registered migration sources. Normal removal/logout retains vault keys; the -separate Forget Key workflow requires the matching recovery kit. +Create Encrypted Vault and Open Existing Vault. Before creation performs any +remote preparation, it displays a mandatory warning that the unsupported +experimental feature may cause complete, unrecoverable data loss and that the +user proceeds entirely on their own. The acknowledgement button remains +disabled for five seconds. Creation then shows a one-time text and QR recovery +kit and requires exact confirmation before saving the device key or registering +the domain. Existing plaintext domains remain separately registered migration +sources. Normal removal/logout retains vault keys; the separate Forget Key +workflow requires the matching recovery kit. Creation is a guided flow: threat-boundary overview, device-only versus optional iCloud Keychain custody, recovery confirmation, durable registration, diff --git a/potassiumProvider/PotassiumProviderAppModel.swift b/potassiumProvider/PotassiumProviderAppModel.swift index ad7277d..f08b28a 100644 --- a/potassiumProvider/PotassiumProviderAppModel.swift +++ b/potassiumProvider/PotassiumProviderAppModel.swift @@ -24,6 +24,7 @@ enum ProviderDriveAction: Equatable, Sendable { @MainActor final class PotassiumProviderAppModel: ObservableObject { private static let log = ProviderLog.app + static let encryptedVaultRiskWarningDelaySeconds: TimeInterval = 5 @Published private(set) var accounts: [ProviderAccount] = [] @Published private(set) var drivesByAccountIdentifier: [String: [KDriveDriveSummary]] = [:] @@ -73,8 +74,11 @@ final class PotassiumProviderAppModel: ObservableObject { private let vaultUserPresenceAuthorizer: any VaultUserPresenceAuthorizing private let vaultUXDefaults: UserDefaults private let computerNameProvider: @Sendable () throws -> String + private let currentDate: () -> Date private var pendingVaultAccountIdentifier: String? + private var pendingVaultDriveID: Int? private var pendingVaultDriveName: String? + private var vaultRiskWarningStartedAt: Date? private var automaticallyLoadedDriveAccountIdentifiers: Set = [] private var fileProviderDomainChangeCancellable: AnyCancellable? @@ -105,7 +109,8 @@ final class PotassiumProviderAppModel: ObservableObject { encryptedVaultICloudKeychainEnabled: Bool = UserDefaults.standard.bool( forKey: ProviderConstants.encryptedVaultICloudKeychainFeatureFlag ), - computerNameProvider: @escaping @Sendable () throws -> String = { try KDriveMachineNamespaceName.current() } + computerNameProvider: @escaping @Sendable () throws -> String = { try KDriveMachineNamespaceName.current() }, + currentDate: @escaping () -> Date = Date.init ) { self.accountStore = accountStore ?? Self.makeDefaultAccountStore() self.domainStore = domainStore ?? Self.makeDefaultDomainStore() @@ -138,6 +143,7 @@ final class PotassiumProviderAppModel: ObservableObject { self.encryptedVaultICloudKeychainEnabled = encryptedVaultICloudKeychainEnabled self.computerNameProvider = computerNameProvider + self.currentDate = currentDate accounts = initialAccounts drivesByAccountIdentifier = initialDrivesByAccountIdentifier domains = initialDomains @@ -503,9 +509,9 @@ final class PotassiumProviderAppModel: ObservableObject { await addDomain(accountIdentifier: accountIdentifier) } - /// Creates the randomized remote vault and exposes its one-time recovery - /// kit in memory. No domain is registered and no key is committed to the - /// Keychain until `confirmEncryptedVault` succeeds. + /// Begins encrypted-vault onboarding without creating remote objects. The + /// unsupported-feature warning must remain visible for the configured delay + /// before `acceptEncryptedVaultRiskAndPrepare` can prepare the vault. func prepareEncryptedVault( accountIdentifier: String, drive: KDriveDriveSummary @@ -515,29 +521,56 @@ final class PotassiumProviderAppModel: ObservableObject { statusMessage = nil return } - guard pendingVaultProvisioning == nil else { + guard pendingVaultProvisioning == nil, vaultSetupStep == nil else { errorMessage = "Finish or cancel the current vault setup first." return } - let key = ProviderDriveKey(accountIdentifier: accountIdentifier, driveID: drive.id) + pendingVaultAccountIdentifier = accountIdentifier + pendingVaultDriveID = drive.id + pendingVaultDriveName = drive.name + vaultRiskWarningStartedAt = currentDate() + vaultSetupStep = .unsupportedRiskWarning + vaultSetupOutcome = VaultSetupOutcome() + errorMessage = nil + statusMessage = "Read and acknowledge the unsupported encrypted-vault data-loss warning." + } + + /// Creates the randomized remote vault only after the mandatory warning + /// delay. No domain is registered and no key is committed to the Keychain + /// until `confirmEncryptedVault` succeeds. + func acceptEncryptedVaultRiskAndPrepare() async { + guard vaultSetupStep == .unsupportedRiskWarning, + let warningStartedAt = vaultRiskWarningStartedAt, + currentDate().timeIntervalSince(warningStartedAt) + >= Self.encryptedVaultRiskWarningDelaySeconds else { + errorMessage = "Wait five seconds before continuing with this unsupported feature." + return + } + guard let accountIdentifier = pendingVaultAccountIdentifier, + let driveID = pendingVaultDriveID, + pendingVaultDriveName != nil else { + errorMessage = "There is no pending encrypted vault setup." + return + } + let key = ProviderDriveKey(accountIdentifier: accountIdentifier, driveID: driveID) guard beginDriveAction(.addingToFiles, for: key) else { return } defer { endDriveAction(for: key) } do { let token = try await usableToken(accountIdentifier: accountIdentifier) let service = VaultProvisioningService( - objectStore: objectStoreFactory(drive.id, token.accessToken), + objectStore: objectStoreFactory(driveID, token.accessToken), keyStore: vaultKeyStore ) - let pending = try await service.prepareNewVault(driveID: drive.id) - pendingVaultAccountIdentifier = accountIdentifier - pendingVaultDriveName = drive.name + let pending = try await service.prepareNewVault(driveID: driveID) pendingVaultProvisioning = pending + vaultRiskWarningStartedAt = nil vaultSetupStep = .overview vaultSetupOutcome = VaultSetupOutcome() errorMessage = nil statusMessage = "Review encrypted-vault protection before saving the recovery kit." } catch { + vaultSetupStep = .unsupportedRiskWarning errorMessage = "Could not prepare the encrypted vault: \(error.localizedDescription)" statusMessage = nil } @@ -1526,7 +1559,9 @@ final class PotassiumProviderAppModel: ObservableObject { private func clearPendingVault() { pendingVaultProvisioning = nil pendingVaultAccountIdentifier = nil + pendingVaultDriveID = nil pendingVaultDriveName = nil + vaultRiskWarningStartedAt = nil } private func advanceVaultSetupAfterRegistration( diff --git a/potassiumProvider/ProviderSetupView.swift b/potassiumProvider/ProviderSetupView.swift index 31a2dfb..a88ed02 100644 --- a/potassiumProvider/ProviderSetupView.swift +++ b/potassiumProvider/ProviderSetupView.swift @@ -1182,6 +1182,10 @@ private struct EncryptedVaultSetupFlow: View { @State private var confirmation = "" @State private var useICloudKeychain = false @State private var didRunKnownFolderPreflight = false + @State private var riskWarningSecondsRemaining = Int( + PotassiumProviderAppModel.encryptedVaultRiskWarningDelaySeconds + ) + @State private var isPreparingVault = false var body: some View { NavigationStack { @@ -1201,17 +1205,72 @@ private struct EncryptedVaultSetupFlow: View { dismiss() } } + .disabled(isPreparingVault) } confirmationToolbar } } .interactiveDismissDisabled(model.vaultSetupStep != .complete) .frame(minWidth: 520, minHeight: 620) + .task(id: model.vaultSetupStep) { + guard model.vaultSetupStep == .unsupportedRiskWarning else { + return + } + riskWarningSecondsRemaining = Int( + PotassiumProviderAppModel.encryptedVaultRiskWarningDelaySeconds + ) + while riskWarningSecondsRemaining > 0 { + do { + try await Task.sleep(for: .seconds(1)) + } catch { + return + } + riskWarningSecondsRemaining -= 1 + } + } } @ViewBuilder private var setupContent: some View { switch model.vaultSetupStep { + case .unsupportedRiskWarning: + Section("Unsupported Experimental Feature") { + Label( + "Complete Data Loss Is Possible", + systemImage: "exclamationmark.triangle.fill" + ) + .font(.headline) + .foregroundStyle(.red) + + Text( + "Using this experimental encrypted-vault feature may result in complete and unrecoverable data loss." + ) + Text( + "This feature is not supported by OpenCow, Infomaniak, Apple, OpenAI, or anyone else. No person or organization can promise recovery or provide support if it fails." + ) + Text("If you decide to continue, you are entirely on your own.") + .fontWeight(.semibold) + } + .accessibilityIdentifier("vault.unsupportedRiskWarning") + + Section { + if isPreparingVault { + HStack { + ProgressView() + Text("Preparing the encrypted vault…") + } + } else if riskWarningSecondsRemaining > 0 { + Text( + "Continue is available in \(riskWarningSecondsRemaining) second\(riskWarningSecondsRemaining == 1 ? "" : "s")." + ) + .foregroundStyle(.secondary) + .accessibilityIdentifier("vault.unsupportedRiskCountdown") + } else { + Text("You may continue only if you accept this risk without support.") + .foregroundStyle(.secondary) + } + } + case .overview: Section("End-to-End Encryption") { Label( @@ -1400,6 +1459,16 @@ private struct EncryptedVaultSetupFlow: View { private var confirmationToolbar: some ToolbarContent { ToolbarItem(placement: .confirmationAction) { switch model.vaultSetupStep { + case .unsupportedRiskWarning: + Button("I Understand — Continue") { + isPreparingVault = true + Task { + await model.acceptEncryptedVaultRiskAndPrepare() + isPreparingVault = false + } + } + .disabled(riskWarningSecondsRemaining > 0 || isPreparingVault) + .accessibilityIdentifier("vault.unsupportedRiskContinue") case .overview: Button("Continue") { model.setVaultSetupStep(.keyAccess) } case .keyAccess: @@ -1431,6 +1500,8 @@ private struct EncryptedVaultSetupFlow: View { private var navigationTitle: String { switch model.vaultSetupStep { + case .unsupportedRiskWarning: + "Unsupported — Data Loss Risk" case .overview: "Encrypted Vault" case .keyAccess: diff --git a/potassiumProvider/ProviderUITestFixture.swift b/potassiumProvider/ProviderUITestFixture.swift index 071688f..7e0249d 100644 --- a/potassiumProvider/ProviderUITestFixture.swift +++ b/potassiumProvider/ProviderUITestFixture.swift @@ -101,7 +101,8 @@ enum ProviderUITestFixture { initialDrivesByAccountIdentifier: [ account.accountIdentifier: [configuredDrive, availableDrive], ], - initialDomains: [configuration] + initialDomains: [configuration], + encryptedVaultsEnabled: true ) if fixtureName == "setup-error-banner" { model.errorMessage = "Could not refresh kDrive details." diff --git a/potassiumProviderTests/VaultUXAppModelTests.swift b/potassiumProviderTests/VaultUXAppModelTests.swift index 9cec7d1..0826cb8 100644 --- a/potassiumProviderTests/VaultUXAppModelTests.swift +++ b/potassiumProviderTests/VaultUXAppModelTests.swift @@ -133,6 +133,7 @@ struct VaultUXAppModelTests { ) let keyStore = InMemoryVaultKeyStore() let objectStore = InMemoryOpaqueObjectStore() + var currentDate = Date(timeIntervalSince1970: 1_000) let model = PotassiumProviderAppModel( accountStore: ProviderAccountFileStore( directoryURL: directory.appendingPathComponent("Accounts") @@ -158,13 +159,32 @@ struct VaultUXAppModelTests { suiteName: "VaultUXAppModelTests.\(UUID().uuidString)" ), encryptedVaultsEnabled: true, - encryptedVaultICloudKeychainEnabled: true + encryptedVaultICloudKeychainEnabled: true, + currentDate: { currentDate } ) await model.prepareEncryptedVault( accountIdentifier: account.accountIdentifier, drive: drive ) + #expect(model.vaultSetupStep == .unsupportedRiskWarning) + #expect(model.pendingVaultProvisioning == nil) + #expect(await objectStore.allTokens().isEmpty) + + await model.acceptEncryptedVaultRiskAndPrepare() + #expect(model.vaultSetupStep == .unsupportedRiskWarning) + #expect(model.pendingVaultProvisioning == nil) + #expect(model.errorMessage?.contains("Wait five seconds") == true) + #expect(await objectStore.allTokens().isEmpty) + + currentDate.addTimeInterval(4.999) + await model.acceptEncryptedVaultRiskAndPrepare() + #expect(model.vaultSetupStep == .unsupportedRiskWarning) + #expect(model.pendingVaultProvisioning == nil) + #expect(await objectStore.allTokens().isEmpty) + + currentDate.addTimeInterval(0.001) + await model.acceptEncryptedVaultRiskAndPrepare() #expect(model.vaultSetupStep == .overview) let pending = try #require(model.pendingVaultProvisioning) #expect( diff --git a/potassiumProviderUITests/potassiumProviderUITests.swift b/potassiumProviderUITests/potassiumProviderUITests.swift index 9527e1a..ad19bc8 100644 --- a/potassiumProviderUITests/potassiumProviderUITests.swift +++ b/potassiumProviderUITests/potassiumProviderUITests.swift @@ -52,6 +52,43 @@ final class potassiumProviderUITests: XCTestCase { XCTAssertTrue(app.staticTexts["This drive is currently in maintenance."].exists) } + @MainActor + func testEncryptedVaultWarningRequiresFiveSecondWait() throws { + let app = launchSetupFixture() + openSetup(in: app) + app.buttons["setup.account.ui-account"].tap() + app.buttons["account.drive.20"].tap() + + let createVault = app.buttons["drive.createEncryptedVault"] + XCTAssertTrue(createVault.waitForExistence(timeout: 5)) + XCTAssertTrue(createVault.isEnabled) + createVault.tap() + + XCTAssertTrue( + text(containing: "complete and unrecoverable data loss", in: app) + .waitForExistence(timeout: 5) + ) + XCTAssertTrue( + text(containing: "you are entirely on your own", in: app).exists + ) + + let continueButton = app.buttons["vault.unsupportedRiskContinue"] + XCTAssertTrue(continueButton.waitForExistence(timeout: 5)) + XCTAssertFalse(continueButton.isEnabled) + XCTAssertTrue( + app.staticTexts["vault.unsupportedRiskCountdown"].exists + ) + + let enabledAfterDelay = XCTNSPredicateExpectation( + predicate: NSPredicate(format: "isEnabled == true"), + object: continueButton + ) + XCTAssertEqual( + XCTWaiter.wait(for: [enabledAfterDelay], timeout: 7), + .completed + ) + } + @MainActor func testConfiguredDrivePresentsRemovalConfirmation() throws { let app = launchSetupFixture() From a0e0839ef63b357d6cd549fcf30233905ff4ebde Mon Sep 17 00:00:00 2001 From: OpenCow Date: Sun, 2 Aug 2026 15:30:00 +0200 Subject: [PATCH 4/4] fix: harden encrypted vault safety boundaries --- .github/workflows/xcode-build.yml | 50 ++ .../EncryptedVaultService.swift | 244 +++------ .../ProviderActionRuntime.swift | 5 +- .../ProviderDomainConfiguration.swift | 20 +- .../VaultContentCipher.swift | 2 +- PotassiumProviderCore/VaultCryptography.swift | 2 +- PotassiumProviderCore/VaultJournal.swift | 444 +++++++++++++--- PotassiumProviderCore/VaultMaintenance.swift | 52 +- PotassiumProviderCore/VaultMigration.swift | 481 ------------------ PotassiumProviderCore/VaultModels.swift | 19 +- PotassiumProviderCore/VaultProvisioning.swift | 43 +- PotassiumProviderCore/VaultRecoveryKit.swift | 6 +- README.md | 16 +- doc/APP_AND_DOMAINS.md | 27 +- doc/ARCHITECTURE.md | 4 +- doc/AUTHENTICATION.md | 2 +- doc/CONFLICTS.md | 2 +- doc/CONFLICT_RESOLUTION_TRUTH_TABLE.md | 86 ++++ doc/CONTEXTUAL_ACTIONS.md | 2 +- doc/ENCRYPTED_VAULT.md | 94 ++-- doc/ENCRYPTED_VAULT_MIGRATION.md | 90 ---- doc/FILE_PROVIDER_LIFECYCLE.md | 9 +- doc/MUTATIONS.md | 2 +- doc/PERSISTENCE.md | 8 +- doc/TESTING_AND_DEVELOPMENT.md | 14 +- .../PotassiumProviderAppModel.swift | 195 +++++-- potassiumProvider/ProviderSetupView.swift | 82 +-- .../ProviderActionViews.swift | 2 +- .../FileProviderEnumerator.swift | 2 +- .../FileProviderItem.swift | 2 +- .../FileProviderRuntime.swift | 6 +- .../ProviderEventRecording.swift | 4 +- .../VaultCloudAccessTests.swift | 4 +- .../VaultCryptographyTests.swift | 84 ++- .../VaultDomainConfigurationTests.swift | 55 +- .../VaultJournalTests.swift | 171 +++++++ .../VaultMigrationTests.swift | 388 -------------- .../VaultProvisioningTests.swift | 124 ++++- .../VaultUXAppModelTests.swift | 161 +++++- 39 files changed, 1542 insertions(+), 1462 deletions(-) delete mode 100644 PotassiumProviderCore/VaultMigration.swift create mode 100644 doc/CONFLICT_RESOLUTION_TRUTH_TABLE.md delete mode 100644 doc/ENCRYPTED_VAULT_MIGRATION.md delete mode 100644 potassiumProviderTests/VaultMigrationTests.swift diff --git a/.github/workflows/xcode-build.yml b/.github/workflows/xcode-build.yml index 8f6aa09..9e105c2 100644 --- a/.github/workflows/xcode-build.yml +++ b/.github/workflows/xcode-build.yml @@ -34,3 +34,53 @@ jobs: -destination 'platform=macOS' \ MACOSX_DEPLOYMENT_TARGET=26.4 \ CODE_SIGNING_ALLOWED=NO + + ios: + name: iOS Simulator + runs-on: macos-26 + + steps: + - name: Check out repository + uses: actions/checkout@v7 + + - name: Build for iOS Simulator + run: | + xcodebuild build \ + -project potassiumProvider.xcodeproj \ + -scheme potassiumProvider \ + -destination 'platform=iOS Simulator,OS=26.5,name=iPhone 17' \ + CODE_SIGNING_ALLOWED=NO + + - name: Run unit tests on iOS Simulator + run: | + xcodebuild test \ + -project potassiumProvider.xcodeproj \ + -scheme potassiumProvider \ + -destination 'platform=iOS Simulator,OS=26.5,name=iPhone 17' \ + -only-testing:potassiumProviderTests \ + CODE_SIGNING_ALLOWED=NO + + visionos: + name: visionOS + runs-on: macos-26 + + steps: + - name: Check out repository + uses: actions/checkout@v7 + + - name: Build for visionOS + run: | + xcodebuild build \ + -project potassiumProvider.xcodeproj \ + -scheme potassiumProvider \ + -destination 'generic/platform=visionOS' \ + CODE_SIGNING_ALLOWED=NO + + - name: Run unit tests on visionOS Simulator + run: | + xcodebuild test \ + -project potassiumProvider.xcodeproj \ + -scheme potassiumProvider \ + -destination 'platform=visionOS Simulator,OS=26.5,name=Apple Vision Pro' \ + -only-testing:potassiumProviderTests \ + CODE_SIGNING_ALLOWED=NO diff --git a/PotassiumProviderCore/EncryptedVaultService.swift b/PotassiumProviderCore/EncryptedVaultService.swift index 6e9b2b8..24d76bd 100644 --- a/PotassiumProviderCore/EncryptedVaultService.swift +++ b/PotassiumProviderCore/EncryptedVaultService.swift @@ -33,63 +33,6 @@ public struct VaultItemChanges: Equatable, Sendable { } } -public struct VaultStagedContent: Codable, Equatable, Sendable { - public let itemID: VaultItemIdentifier - public let contentRevision: VaultRevision - public let objectToken: String - public let ciphertextURL: URL - public let wrappedContentKey: Data - public let noncePrefix: UInt64 - public let plaintextLength: Int64 - public let plaintextDigest: Data - public let frameCount: UInt32 - - public init( - itemID: VaultItemIdentifier, - contentRevision: VaultRevision, - objectToken: String, - ciphertextURL: URL, - wrappedContentKey: Data, - noncePrefix: UInt64, - plaintextLength: Int64, - plaintextDigest: Data, - frameCount: UInt32 - ) { - self.itemID = itemID - self.contentRevision = contentRevision - self.objectToken = objectToken - self.ciphertextURL = ciphertextURL - self.wrappedContentKey = wrappedContentKey - self.noncePrefix = noncePrefix - self.plaintextLength = plaintextLength - self.plaintextDigest = plaintextDigest - self.frameCount = frameCount - } -} - -public struct VaultUploadedContent: Codable, Equatable, Sendable { - public let staged: VaultStagedContent - public let remoteFileID: Int - - public init(staged: VaultStagedContent, remoteFileID: Int) { - self.staged = staged - self.remoteFileID = remoteFileID - } - - public var contentReference: VaultContentReference { - VaultContentReference( - encryptionItemID: staged.itemID, - objectToken: staged.objectToken, - remoteFileID: remoteFileID, - wrappedContentKey: staged.wrappedContentKey, - noncePrefix: staged.noncePrefix, - plaintextLength: staged.plaintextLength, - plaintextDigest: staged.plaintextDigest, - frameCount: staged.frameCount - ) - } -} - public enum EncryptedVaultError: Error, Equatable, LocalizedError, Sendable { case missingConfiguration case missingKey @@ -116,7 +59,7 @@ public enum EncryptedVaultError: Error, Equatable, LocalizedError, Sendable { case .notDirectory: return "The encrypted destination is not a folder." case .unsupportedNativeSharing: - return "Recipient-key sharing is not supported for encrypted vaults in version 1." + return "Recipient-key sharing is not supported for encrypted vaults in version 2." case .staleRevision: return "The encrypted item changed on another device." case .syncAnchorExpired: @@ -205,8 +148,9 @@ public actor EncryptedVaultService: EncryptedVaultProviding { keyStore: any VaultKeyStoring, temporaryDirectoryURL: URL = FileManager.default.temporaryDirectory ) throws { - guard configuration.encryptionMode == .opaqueVaultV1, + guard configuration.encryptionMode == .opaqueVaultV2, let vaultConfiguration = configuration.vault, + vaultConfiguration.formatVersion == VaultFormat.currentVersion, let layout = vaultConfiguration.remoteLayout else { throw EncryptedVaultError.missingConfiguration } @@ -269,7 +213,7 @@ public actor EncryptedVaultService: EncryptedVaultProviding { cursor = page.nextCursor } while cursor != nil - // Journal compaction is intentionally disabled in v1. Therefore every + // Journal compaction is intentionally disabled in v2. Therefore every // previously stored remote journal object must remain present in a // complete listing. Folding cached transactions into a listing that // omitted them would mask a server rollback. @@ -416,35 +360,13 @@ public actor EncryptedVaultService: EncryptedVaultProviding { throw EncryptedVaultError.staleRevision } guard let reference = item.contentReference, - let remoteFileID = reference.remoteFileID else { + reference.remoteFileID != nil else { throw EncryptedVaultError.missingContent } - let ciphertextURL = temporaryURL(prefix: "content-download") - defer { try? FileManager.default.removeItem(at: ciphertextURL) } - try await objectStore.downloadObject(fileID: remoteFileID, to: ciphertextURL) - try Task.checkCancellation() - let contentKey = try VaultCryptography.unwrapContentKey( - reference.wrappedContentKey, - objectToken: reference.objectToken, - rootKey: rootKey, - vaultID: vaultConfiguration.vaultIdentifier, - keyEpoch: vaultConfiguration.keyEpoch - ) - try VaultContentCipher.decrypt( - ciphertextURL: ciphertextURL, - plaintextURL: plaintextURL, - context: VaultContentEncryptionContext( - vaultID: vaultConfiguration.vaultIdentifier, - itemID: reference.encryptionItemID, - contentRevision: item.contentRevision, - objectToken: reference.objectToken, - keyEpoch: vaultConfiguration.keyEpoch - ), - contentKey: contentKey, - expectedNoncePrefix: reference.noncePrefix, - expectedPlaintextLength: reference.plaintextLength, - expectedPlaintextDigest: reference.plaintextDigest, - expectedFrameCount: reference.frameCount + try await downloadAndDecrypt( + reference: reference, + contentRevision: item.contentRevision, + to: plaintextURL ) try applyPlaintextFileProtection(to: plaintextURL) return item @@ -506,99 +428,6 @@ public actor EncryptedVaultService: EncryptedVaultProviding { return try await commitUpsert(item, base: nil) } - /// Migration-only staging boundary. The returned file contains ciphertext - /// and can be resumed without retaining a plaintext staging file. - public func stageFileImport( - itemID: VaultItemIdentifier = VaultItemIdentifier(), - plaintextURL: URL - ) async throws -> VaultStagedContent { - let token = try VaultCryptography.makeObjectToken( - rootKey: rootKey, - vaultID: vaultConfiguration.vaultIdentifier - ) - let revision = try VaultRevision.random() - let ciphertextURL = temporaryURL(prefix: "migration-content") - let result: VaultContentEncryptionResult - do { - result = try VaultContentCipher.encrypt( - plaintextURL: plaintextURL, - ciphertextURL: ciphertextURL, - context: VaultContentEncryptionContext( - vaultID: vaultConfiguration.vaultIdentifier, - itemID: itemID, - contentRevision: revision, - objectToken: token, - keyEpoch: vaultConfiguration.keyEpoch - ) - ) - } catch { - try? FileManager.default.removeItem(at: ciphertextURL) - throw error - } - let wrappedKey = try VaultCryptography.wrapContentKey( - result.contentKey, - objectToken: token, - rootKey: rootKey, - vaultID: vaultConfiguration.vaultIdentifier, - keyEpoch: vaultConfiguration.keyEpoch - ) - return VaultStagedContent( - itemID: itemID, - contentRevision: revision, - objectToken: token, - ciphertextURL: ciphertextURL, - wrappedContentKey: wrappedKey, - noncePrefix: result.noncePrefix, - plaintextLength: result.plaintextLength, - plaintextDigest: result.plaintextDigest, - frameCount: result.frameCount - ) - } - - public func uploadStagedFileImport( - _ staged: VaultStagedContent - ) async throws -> VaultUploadedContent { - let remote = try await uploadIdempotently( - containerID: layout.contentContainerID, - token: staged.objectToken, - fileURL: staged.ciphertextURL - ) - return VaultUploadedContent(staged: staged, remoteFileID: remote.id) - } - - public func commitUploadedFileImport( - _ uploaded: VaultUploadedContent, - parentID: VaultItemIdentifier?, - filename: String, - contentTypeIdentifier: String?, - createdAt: Date, - modifiedAt: Date - ) async throws -> VaultItem { - _ = try await synchronize() - try await validateParent(parentID) - var item = VaultItem( - id: uploaded.staged.itemID, - parentID: parentID, - filename: filename, - isDirectory: false, - contentTypeIdentifier: contentTypeIdentifier, - createdAt: createdAt, - modifiedAt: modifiedAt, - plaintextSize: uploaded.staged.plaintextLength, - contentRevision: uploaded.staged.contentRevision, - metadataRevision: uploaded.staged.contentRevision, - contentReference: uploaded.contentReference - ) - item.metadataRevision = try metadataRevision(for: item) - let committed = try await commitUpsert(item, base: nil) - try? FileManager.default.removeItem(at: uploaded.staged.ciphertextURL) - return committed - } - - public func discardStagedFileImport(_ staged: VaultStagedContent) { - try? FileManager.default.removeItem(at: staged.ciphertextURL) - } - public func modify( itemID: VaultItemIdentifier, baseContentRevision: VaultRevision, @@ -743,6 +572,18 @@ public actor EncryptedVaultService: EncryptedVaultProviding { }) else { throw EncryptedVaultError.missingContent } + let restoredPlaintextURL = temporaryURL(prefix: "version-restore") + defer { try? FileManager.default.removeItem(at: restoredPlaintextURL) } + try await downloadAndDecrypt( + reference: version.contentReference, + contentRevision: version.contentRevision, + to: restoredPlaintextURL + ) + try applyPlaintextFileProtection(to: restoredPlaintextURL) + let restoredContent = try await encryptAndUpload( + plaintextURL: restoredPlaintextURL, + itemID: itemID + ) var desired = base if let currentReference = base.contentReference { desired.versions.insert(VaultVersion( @@ -754,9 +595,9 @@ public actor EncryptedVaultService: EncryptedVaultProviding { } desired.versions.removeAll { $0.contentRevision == contentRevision } desired.versions = retainedVersions(desired.versions) - desired.contentRevision = version.contentRevision - desired.contentReference = version.contentReference - desired.plaintextSize = version.plaintextSize + desired.contentRevision = restoredContent.revision + desired.contentReference = restoredContent.reference + desired.plaintextSize = restoredContent.reference.plaintextLength desired.modifiedAt = Date() desired.metadataRevision = try metadataRevision(for: desired) return try await commitUpsert(desired, base: base) @@ -889,6 +730,43 @@ public actor EncryptedVaultService: EncryptedVaultProviding { ) } + private func downloadAndDecrypt( + reference: VaultContentReference, + contentRevision: VaultRevision, + to plaintextURL: URL + ) async throws { + guard let remoteFileID = reference.remoteFileID else { + throw EncryptedVaultError.missingContent + } + let ciphertextURL = temporaryURL(prefix: "content-download") + defer { try? FileManager.default.removeItem(at: ciphertextURL) } + try await objectStore.downloadObject(fileID: remoteFileID, to: ciphertextURL) + try Task.checkCancellation() + let contentKey = try VaultCryptography.unwrapContentKey( + reference.wrappedContentKey, + objectToken: reference.objectToken, + rootKey: rootKey, + vaultID: vaultConfiguration.vaultIdentifier, + keyEpoch: vaultConfiguration.keyEpoch + ) + try VaultContentCipher.decrypt( + ciphertextURL: ciphertextURL, + plaintextURL: plaintextURL, + context: VaultContentEncryptionContext( + vaultID: vaultConfiguration.vaultIdentifier, + itemID: reference.encryptionItemID, + contentRevision: contentRevision, + objectToken: reference.objectToken, + keyEpoch: vaultConfiguration.keyEpoch + ), + contentKey: contentKey, + expectedNoncePrefix: reference.noncePrefix, + expectedPlaintextLength: reference.plaintextLength, + expectedPlaintextDigest: reference.plaintextDigest, + expectedFrameCount: reference.frameCount + ) + } + private func uploadIdempotently( containerID: Int, token: String, diff --git a/PotassiumProviderCore/ProviderActionRuntime.swift b/PotassiumProviderCore/ProviderActionRuntime.swift index 15ac9cd..b3f1609 100644 --- a/PotassiumProviderCore/ProviderActionRuntime.swift +++ b/PotassiumProviderCore/ProviderActionRuntime.swift @@ -32,6 +32,9 @@ public struct ProviderActionRuntime: Sendable { } let tokenStore = KeychainOAuthTokenStore(accessGroup: ProviderConstants.keychainAccessGroup) + guard configuration.encryptionMode != .opaqueVaultV1 else { + throw ProviderActionRuntimeError.configurationUnavailable + } guard var token = try await tokenStore.loadToken( accountIdentifier: configuration.accountIdentifier ) else { @@ -51,7 +54,7 @@ public struct ProviderActionRuntime: Sendable { appGroupIdentifier: ProviderConstants.appGroupIdentifier ) let encryptedVault: (any EncryptedVaultProviding)? - if configuration.encryptionMode == .opaqueVaultV1 { + if configuration.encryptionMode == .opaqueVaultV2 { guard let vaultConfiguration = configuration.vault else { throw ProviderActionRuntimeError.configurationUnavailable } diff --git a/PotassiumProviderCore/ProviderDomainConfiguration.swift b/PotassiumProviderCore/ProviderDomainConfiguration.swift index ddf1925..7764812 100644 --- a/PotassiumProviderCore/ProviderDomainConfiguration.swift +++ b/PotassiumProviderCore/ProviderDomainConfiguration.swift @@ -17,8 +17,26 @@ public enum ProviderEncryptionMode: String, Codable, Equatable, Sendable { /// Compatibility mode for domains created before encrypted vault support. case legacyPlaintext - /// Version 1 opaque, client-side encrypted vault. + /// Unsupported experimental v1 vault, retained only for fail-closed decode. case opaqueVaultV1 + + /// Version 2 opaque vault. Version 1 remains recognizable only so clients + /// fail closed instead of accidentally routing its opaque objects through + /// the legacy plaintext provider. + case opaqueVaultV2 + + public var isEncryptedVault: Bool { + switch self { + case .legacyPlaintext: + false + case .opaqueVaultV1, .opaqueVaultV2: + true + } + } + + public var isSupportedEncryptedVault: Bool { + self == .opaqueVaultV2 + } } public struct ProviderVaultConfiguration: Codable, Equatable, Sendable { diff --git a/PotassiumProviderCore/VaultContentCipher.swift b/PotassiumProviderCore/VaultContentCipher.swift index fda27b6..95c3197 100644 --- a/PotassiumProviderCore/VaultContentCipher.swift +++ b/PotassiumProviderCore/VaultContentCipher.swift @@ -49,7 +49,7 @@ public struct VaultContentEncryptionResult: Equatable, Sendable { } public enum VaultContentCipher { - private static let magic = Data("KPC1".utf8) + private static let magic = Data("KPC2".utf8) private static let headerByteCount = 4 + 2 + 4 + 8 private static let authenticationTagByteCount = 16 diff --git a/PotassiumProviderCore/VaultCryptography.swift b/PotassiumProviderCore/VaultCryptography.swift index 3dfa82f..704313b 100644 --- a/PotassiumProviderCore/VaultCryptography.swift +++ b/PotassiumProviderCore/VaultCryptography.swift @@ -59,7 +59,7 @@ public enum VaultCryptoError: Error, Equatable, LocalizedError, Sendable { } public enum VaultCryptography { - private static let envelopeMagic = Data("KPE1".utf8) + private static let envelopeMagic = Data("KPE2".utf8) private static let envelopeHeaderByteCount = 4 + 2 + 1 + 4 + 16 + 20 public static func makeRootKey() throws -> VaultKeyMaterial { diff --git a/PotassiumProviderCore/VaultJournal.swift b/PotassiumProviderCore/VaultJournal.swift index b7b2366..3d6c868 100644 --- a/PotassiumProviderCore/VaultJournal.swift +++ b/PotassiumProviderCore/VaultJournal.swift @@ -10,6 +10,9 @@ public enum VaultJournalError: Error, Equatable, LocalizedError, Sendable { case transactionTooLarge(Int) case rollbackDetected case invalidMerkleProof + case invalidParentGraph(VaultItemIdentifier) + case malformedPaddedCheckpoint + case checkpointTooLarge(Int) public var errorDescription: String? { switch self { @@ -29,6 +32,12 @@ public enum VaultJournalError: Error, Equatable, LocalizedError, Sendable { return "The remote vault state does not include this device's last trusted state." case .invalidMerkleProof: return "The checkpoint did not provide a valid transaction inclusion proof." + case .invalidParentGraph: + return "The encrypted journal contains an invalid or cyclic item parent graph." + case .malformedPaddedCheckpoint: + return "The encrypted checkpoint does not use the authenticated padded format." + case .checkpointTooLarge: + return "The encrypted checkpoint exceeds the supported 256 MiB payload limit." } } } @@ -40,6 +49,7 @@ public struct VaultConflict: Codable, Equatable, Identifiable, Sendable { case siblingName case deletionRejected case folderDeletionRejected + case invalidMove } public let id: UUID @@ -135,6 +145,7 @@ public enum VaultJournalReducer { } try resolveSiblingNameCollisions(items: &items, conflicts: &conflicts) + try validateParentGraph(items) return VaultReducedState( items: items, frontier: frontier, @@ -149,22 +160,28 @@ public enum VaultJournalReducer { let byID = Dictionary(uniqueKeysWithValues: transactions.map { ($0.id, $0) }) + let childUpsertsByParent = Dictionary(grouping: transactions.compactMap { + transaction -> (UUID, VaultItemIdentifier)? in + guard case .upsert(let item) = transaction.operation, + let parentID = item.parentID, + item.isTrashed == false else { + return nil + } + return (transaction.id, parentID) + }, by: { $0.1 }) var rejected: Set = [] for deletion in transactions { guard let directoryID = directoryDeletionTarget(deletion) else { continue } - let hasNewChild = transactions.contains { candidate in - guard candidate.id != deletion.id, - case .upsert(let item) = candidate.operation, - item.parentID == directoryID, - item.isTrashed == false else { - return false - } + let causalHistory = ancestorIDs(of: deletion.id, byID: byID) + let hasNewChild = (childUpsertsByParent[directoryID] ?? []).contains { + candidate in // Children already in the deletion's causal history are normal // subtree members. A concurrent or later child preserves the // folder and rejects the stale deletion. - return isAncestor(candidate.id, of: deletion.id, byID: byID) == false + return candidate.0 != deletion.id + && causalHistory.contains(candidate.0) == false } if hasNewChild { rejected.insert(deletion.id) @@ -185,22 +202,20 @@ public enum VaultJournalReducer { } } - private static func isAncestor( - _ possibleAncestor: UUID, + private static func ancestorIDs( of transactionID: UUID, byID: [UUID: VaultTransaction] - ) -> Bool { + ) -> Set { var pending = Array(byID[transactionID]?.parents.transactionIDs ?? []) var visited: Set = [] while let candidate = pending.popLast() { - if candidate == possibleAncestor { return true } guard visited.insert(candidate).inserted, let transaction = byID[candidate] else { continue } pending.append(contentsOf: transaction.parents.transactionIDs) } - return false + return visited } public static func canonicalOrder( @@ -225,27 +240,30 @@ public enum VaultJournalReducer { } } - var remainingParents = Dictionary(uniqueKeysWithValues: transactions.map { - ($0.id, $0.parents.transactionIDs.subtracting(knownAncestorIDs)) - }) - var ready = remainingParents - .filter { $0.value.isEmpty } - .map(\.key) - .sorted(by: uuidLessThan) + var remainingParentCount: [UUID: Int] = [:] + var childrenByParent: [UUID: [UUID]] = [:] + for transaction in transactions { + let parents = transaction.parents.transactionIDs.subtracting(knownAncestorIDs) + remainingParentCount[transaction.id] = parents.count + for parentID in parents { + childrenByParent[parentID, default: []].append(transaction.id) + } + } + var ready = UUIDMinHeap( + remainingParentCount.compactMap { $0.value == 0 ? $0.key : nil } + ) var ordered: [VaultTransaction] = [] - while let next = ready.first { - ready.removeFirst() + while let next = ready.removeMinimum() { guard let transaction = byID[next] else { continue } ordered.append(transaction) - remainingParents.removeValue(forKey: next) - for transactionID in remainingParents.keys.sorted(by: uuidLessThan) { - guard remainingParents[transactionID]?.remove(next) != nil, - remainingParents[transactionID]?.isEmpty == true else { - continue + for childID in childrenByParent[next] ?? [] { + guard let count = remainingParentCount[childID], count > 0 else { continue } + let updatedCount = count - 1 + remainingParentCount[childID] = updatedCount + if updatedCount == 0 { + ready.insert(childID) } - ready.append(transactionID) - ready.sort(by: uuidLessThan) } } @@ -291,25 +309,36 @@ public enum VaultJournalReducer { return } current.isTrashed = true + current.trashRootID = itemID current.metadataRevision = try metadataRevision(for: current) items[itemID] = current if current.isDirectory { - try setDescendants( + try trashDescendants( of: itemID, - isTrashed: true, + trashRootID: itemID, items: &items ) } case .restore(let itemID, let parentID): guard var current = items[itemID] else { return } + guard parentIsValid(parentID, for: itemID, items: items) else { + conflicts.append(VaultConflict( + kind: .invalidMove, + itemID: itemID, + transactionID: transaction.id + )) + return + } + let restoredTrashRootID = current.trashRootID ?? itemID current.parentID = parentID current.isTrashed = false + current.trashRootID = nil current.metadataRevision = try metadataRevision(for: current) items[itemID] = current if current.isDirectory { - try setDescendants( + try restoreDescendants( of: itemID, - isTrashed: false, + matching: restoredTrashRootID, items: &items ) } @@ -340,25 +369,51 @@ public enum VaultJournalReducer { } } - private static func setDescendants( + private static func trashDescendants( of parentID: VaultItemIdentifier, - isTrashed: Bool, + trashRootID: VaultItemIdentifier, items: inout [VaultItemIdentifier: VaultItem] ) throws { - let childIDs = items.values - .filter { $0.parentID == parentID } - .map(\.id) - for childID in childIDs { - guard var child = items[childID] else { continue } - child.isTrashed = isTrashed - child.metadataRevision = try metadataRevision(for: child) - items[childID] = child - if child.isDirectory { - try setDescendants( - of: childID, - isTrashed: isTrashed, - items: &items - ) + var pending = [parentID] + var visited: Set = [] + while let nextParentID = pending.popLast() { + guard visited.insert(nextParentID).inserted else { continue } + let childIDs = items.values + .filter { $0.parentID == nextParentID } + .map(\.id) + pending.append(contentsOf: childIDs.filter { items[$0]?.isDirectory == true }) + for childID in childIDs { + guard var child = items[childID], child.isTrashed == false else { + continue + } + child.isTrashed = true + child.trashRootID = trashRootID + child.metadataRevision = try metadataRevision(for: child) + items[childID] = child + } + } + } + + private static func restoreDescendants( + of parentID: VaultItemIdentifier, + matching trashRootID: VaultItemIdentifier, + items: inout [VaultItemIdentifier: VaultItem] + ) throws { + var pending = [parentID] + var visited: Set = [] + while let nextParentID = pending.popLast() { + guard visited.insert(nextParentID).inserted else { continue } + let childIDs = items.values + .filter { $0.parentID == nextParentID } + .map(\.id) + pending.append(contentsOf: childIDs.filter { items[$0]?.isDirectory == true }) + for childID in childIDs { + guard var child = items[childID] else { continue } + guard child.trashRootID == trashRootID else { continue } + child.isTrashed = false + child.trashRootID = nil + child.metadataRevision = try metadataRevision(for: child) + items[childID] = child } } } @@ -367,11 +422,17 @@ public enum VaultJournalReducer { of parentID: VaultItemIdentifier, items: inout [VaultItemIdentifier: VaultItem] ) { - let childIDs = items.values - .filter { $0.parentID == parentID } - .map(\.id) - for childID in childIDs { - removeDescendants(of: childID, items: &items) + var pending = [parentID] + var descendants: Set = [] + while let nextParentID = pending.popLast() { + let childIDs = items.values + .filter { $0.parentID == nextParentID } + .map(\.id) + for childID in childIDs where descendants.insert(childID).inserted { + pending.append(childID) + } + } + for childID in descendants { items.removeValue(forKey: childID) } } @@ -392,6 +453,14 @@ public enum VaultJournalReducer { transactionID: transaction.id )) } + guard parentIsValid(desired.parentID, for: desired.id, items: items) else { + conflicts.append(VaultConflict( + kind: .invalidMove, + itemID: desired.id, + transactionID: transaction.id + )) + return + } items[desired.id] = desired return } @@ -425,11 +494,20 @@ public enum VaultJournalReducer { plaintextSize: desired.plaintextSize, isFavorite: desired.isFavorite, isTrashed: desired.isTrashed, + trashRootID: desired.trashRootID, contentRevision: desired.contentRevision, metadataRevision: desired.metadataRevision, contentReference: desired.contentReference, versions: desired.versions ) + if parentIsValid(conflictCopy.parentID, for: conflictID, items: items) == false { + conflictCopy.parentID = current.parentID + conflicts.append(VaultConflict( + kind: .invalidMove, + itemID: desired.id, + transactionID: transaction.id + )) + } conflictCopy.metadataRevision = try metadataRevision(for: conflictCopy) items[conflictID] = conflictCopy conflicts.append(VaultConflict( @@ -459,13 +537,22 @@ public enum VaultJournalReducer { } // Canonical replay means the later canonical transaction wins // conflicting metadata fields while independent content survives. - merged.parentID = desired.parentID + if parentIsValid(desired.parentID, for: desired.id, items: items) { + merged.parentID = desired.parentID + } else { + conflicts.append(VaultConflict( + kind: .invalidMove, + itemID: desired.id, + transactionID: transaction.id + )) + } merged.filename = desired.filename merged.contentTypeIdentifier = desired.contentTypeIdentifier merged.createdAt = desired.createdAt merged.isFavorite = desired.isFavorite merged.isTrashed = desired.isTrashed - merged.metadataRevision = desired.metadataRevision + merged.trashRootID = desired.trashRootID + merged.metadataRevision = try metadataRevision(for: merged) } items[desired.id] = merged } @@ -476,28 +563,96 @@ public enum VaultJournalReducer { ) throws { let visibleItems = items.values.filter { $0.isTrashed == false } let groups = Dictionary(grouping: visibleItems) { - SiblingKey(parentID: $0.parentID, normalizedName: $0.filename.precomposedStringWithCanonicalMapping.lowercased()) + siblingKey(parentID: $0.parentID, filename: $0.filename) } - + var occupied = Set(groups.keys) + var collisionItems: [VaultItem] = [] for group in groups.values where group.count > 1 { let ordered = group.sorted { $0.id.rawValue.uuidString < $1.id.rawValue.uuidString } - for item in ordered.dropFirst() { - guard var renamed = items[item.id] else { continue } - renamed.filename = conflictFilename( - renamed.filename, - transactionID: renamed.id.rawValue + collisionItems.append(contentsOf: ordered.dropFirst()) + } + + for item in collisionItems.sorted(by: { + let lhsParent = $0.parentID?.rawValue.uuidString ?? "" + let rhsParent = $1.parentID?.rawValue.uuidString ?? "" + return lhsParent == rhsParent + ? $0.id.rawValue.uuidString < $1.id.rawValue.uuidString + : lhsParent < rhsParent + }) { + guard var renamed = items[item.id] else { continue } + var collisionIndex = 1 + while true { + let candidate = conflictFilename( + item.filename, + transactionID: item.id.rawValue, + collisionIndex: collisionIndex ) - renamed.metadataRevision = try metadataRevision(for: renamed) - items[item.id] = renamed - conflicts.append(VaultConflict( - kind: .siblingName, - itemID: item.id, - transactionID: item.id.rawValue - )) + let key = siblingKey(parentID: item.parentID, filename: candidate) + if occupied.insert(key).inserted { + renamed.filename = candidate + break + } + collisionIndex += 1 + } + renamed.metadataRevision = try metadataRevision(for: renamed) + items[item.id] = renamed + conflicts.append(VaultConflict( + kind: .siblingName, + itemID: item.id, + transactionID: item.id.rawValue + )) + } + } + + private static func parentIsValid( + _ parentID: VaultItemIdentifier?, + for itemID: VaultItemIdentifier, + items: [VaultItemIdentifier: VaultItem], + requiresVisibleParent: Bool = true + ) -> Bool { + guard var candidateID = parentID else { return true } + var visited: Set = [] + while true { + guard candidateID != itemID, + visited.insert(candidateID).inserted, + let candidate = items[candidateID], + candidate.isDirectory, + requiresVisibleParent == false || candidate.isTrashed == false else { + return false + } + guard let next = candidate.parentID else { return true } + candidateID = next + } + } + + private static func validateParentGraph( + _ items: [VaultItemIdentifier: VaultItem] + ) throws { + for item in items.values { + guard item.isTrashed == (item.trashRootID != nil) else { + throw VaultJournalError.invalidParentGraph(item.id) + } + guard parentIsValid( + item.parentID, + for: item.id, + items: items, + requiresVisibleParent: false + ) else { + throw VaultJournalError.invalidParentGraph(item.id) } } } + private static func siblingKey( + parentID: VaultItemIdentifier?, + filename: String + ) -> SiblingKey { + SiblingKey( + parentID: parentID, + normalizedName: filename.precomposedStringWithCanonicalMapping.lowercased() + ) + } + private static func metadataFields(of item: VaultItem) -> MetadataFields { MetadataFields( parentID: item.parentID, @@ -506,7 +661,8 @@ public enum VaultJournalReducer { typeIdentifier: item.contentTypeIdentifier, createdAt: item.createdAt, favorite: item.isFavorite, - trashed: item.isTrashed + trashed: item.isTrashed, + trashRootID: item.trashRootID ) } @@ -528,14 +684,19 @@ public enum VaultJournalReducer { return VaultItemIdentifier(rawValue: UUID(bytes: Data(bytes))) } - private static func conflictFilename(_ filename: String, transactionID: UUID) -> String { + private static func conflictFilename( + _ filename: String, + transactionID: UUID, + collisionIndex: Int = 1 + ) -> String { let suffix = String(transactionID.uuidString.prefix(8)).lowercased() + let disambiguator = collisionIndex == 1 ? suffix : "\(suffix)-\(collisionIndex)" let pathExtension = (filename as NSString).pathExtension let base = (filename as NSString).deletingPathExtension if pathExtension.isEmpty { - return "\(base) (conflict \(suffix))" + return "\(base) (conflict \(disambiguator))" } - return "\(base) (conflict \(suffix)).\(pathExtension)" + return "\(base) (conflict \(disambiguator)).\(pathExtension)" } private static func uuidLessThan(_ lhs: UUID, _ rhs: UUID) -> Bool { @@ -550,12 +711,56 @@ public enum VaultJournalReducer { let createdAt: Date let favorite: Bool let trashed: Bool + let trashRootID: VaultItemIdentifier? } private struct SiblingKey: Hashable { let parentID: VaultItemIdentifier? let normalizedName: String } + + private struct UUIDMinHeap { + private var storage: [UUID] = [] + + init(_ values: [UUID]) { + for value in values { + insert(value) + } + } + + mutating func insert(_ value: UUID) { + storage.append(value) + var index = storage.count - 1 + while index > 0 { + let parent = (index - 1) / 2 + guard uuidLessThan(storage[index], storage[parent]) else { break } + storage.swapAt(index, parent) + index = parent + } + } + + mutating func removeMinimum() -> UUID? { + guard storage.isEmpty == false else { return nil } + if storage.count == 1 { return storage.removeLast() } + let minimum = storage[0] + storage[0] = storage.removeLast() + var index = 0 + while true { + let left = index * 2 + 1 + let right = left + 1 + guard left < storage.count else { break } + var child = left + if right < storage.count, + uuidLessThan(storage[right], storage[left]) { + child = right + } + guard uuidLessThan(storage[child], storage[index]) else { break } + storage.swapAt(index, child) + index = child + } + return minimum + } + } } public enum VaultFixedTransactionCodec { @@ -626,6 +831,97 @@ public enum VaultFixedTransactionCodec { } } +/// Checkpoints contain the complete logical index. Padding their authenticated +/// plaintext to power-of-two buckets prevents the object length from exposing +/// exact aggregate metadata growth. +public enum VaultPaddedCheckpointCodec { + private static let lengthByteCount = MemoryLayout.size + private static let envelopeOverhead = 4 + 2 + 1 + 4 + 16 + 20 + 12 + 16 + + public static func seal( + _ checkpoint: VaultCheckpoint, + objectToken: String, + rootKey: VaultKeyMaterial, + vaultID: VaultIdentifier, + keyEpoch: UInt32 = VaultFormat.currentKeyEpoch + ) throws -> Data { + let encoded = try VaultCoding.encoder.encode(checkpoint) + let requiredByteCount = lengthByteCount + encoded.count + let payloadByteCount = try bucketSize(for: requiredByteCount) + var payload = Data() + payload.appendUInt64(UInt64(encoded.count)) + payload.append(encoded) + payload.append(try VaultRandom.bytes(count: payloadByteCount - payload.count)) + let envelope = try VaultCryptography.seal( + payload, + role: .checkpoint, + objectToken: objectToken, + rootKey: rootKey, + vaultID: vaultID, + keyEpoch: keyEpoch + ) + guard envelope.count == envelopeOverhead + payloadByteCount else { + throw VaultJournalError.malformedPaddedCheckpoint + } + return envelope + } + + public static func open( + _ envelope: Data, + objectToken: String, + rootKey: VaultKeyMaterial, + vaultID: VaultIdentifier, + keyEpoch: UInt32 = VaultFormat.currentKeyEpoch + ) throws -> VaultCheckpoint { + let payloadByteCount = envelope.count - envelopeOverhead + guard payloadByteCount >= VaultFormat.minimumCheckpointPayloadSize, + payloadByteCount <= VaultFormat.maximumCheckpointPayloadSize, + payloadByteCount.nonzeroBitCount == 1 else { + throw VaultJournalError.malformedPaddedCheckpoint + } + let payload = try VaultCryptography.open( + envelope, + expectedRole: .checkpoint, + expectedObjectToken: objectToken, + rootKey: rootKey, + vaultID: vaultID, + keyEpoch: keyEpoch + ) + guard payload.count == payloadByteCount else { + throw VaultJournalError.malformedPaddedCheckpoint + } + var cursor = VaultDataCursor(data: payload) + let encodedLengthValue = try cursor.readUInt64() + guard encodedLengthValue > 0, + encodedLengthValue <= UInt64(Int.max) else { + throw VaultJournalError.malformedPaddedCheckpoint + } + let encodedLength = Int(encodedLengthValue) + guard encodedLength <= payload.count - lengthByteCount else { + throw VaultJournalError.malformedPaddedCheckpoint + } + do { + return try VaultCoding.decoder.decode( + VaultCheckpoint.self, + from: cursor.read(count: encodedLength) + ) + } catch { + throw VaultJournalError.malformedPaddedCheckpoint + } + } + + private static func bucketSize(for requiredByteCount: Int) throws -> Int { + guard requiredByteCount <= VaultFormat.maximumCheckpointPayloadSize else { + throw VaultJournalError.checkpointTooLarge(requiredByteCount) + } + var bucket = VaultFormat.minimumCheckpointPayloadSize + while bucket < requiredByteCount { + bucket *= 2 + } + return bucket + } +} + public struct VaultMerkleProof: Codable, Equatable, Sendable { public struct Step: Codable, Equatable, Sendable { public let siblingDigest: Data diff --git a/PotassiumProviderCore/VaultMaintenance.swift b/PotassiumProviderCore/VaultMaintenance.swift index 3b3e518..1b5c598 100644 --- a/PotassiumProviderCore/VaultMaintenance.swift +++ b/PotassiumProviderCore/VaultMaintenance.swift @@ -1,26 +1,26 @@ import Foundation -public struct VaultGarbageCollectionReport: Equatable, Sendable { +public struct VaultMaintenanceReport: Equatable, Sendable { public let checkpointFileID: Int public let examinedObjectCount: Int - public let deletedObjectCount: Int + public let unreferencedObjectCount: Int public init( checkpointFileID: Int, examinedObjectCount: Int, - deletedObjectCount: Int + unreferencedObjectCount: Int ) { self.checkpointFileID = checkpointFileID self.examinedObjectCount = examinedObjectCount - self.deletedObjectCount = deletedObjectCount + self.unreferencedObjectCount = unreferencedObjectCount } } -/// Conservative maintenance: an authenticated checkpoint is uploaded and -/// downloaded again before any unreferenced ciphertext can be deleted. -/// Journal objects remain immutable in v1 until Merkle-node proof retrieval has -/// passed independent review; this intentionally prefers quota use over an -/// unsafe compaction. +/// Conservative maintenance uploads and authenticates a padded checkpoint and +/// reports unreferenced ciphertext. It never deletes remote content or journal +/// objects: retention time alone cannot prove that an offline device will not +/// later publish a valid transaction that references an apparently orphaned +/// object. public actor VaultMaintenanceService { private let vaultConfiguration: ProviderVaultConfiguration private let rootKey: VaultKeyMaterial @@ -48,10 +48,9 @@ public actor VaultMaintenanceService { self.temporaryDirectoryURL = temporaryDirectoryURL } - public func checkpointAndCollectUnreferencedContent( - retentionInterval: TimeInterval = 30 * 24 * 60 * 60, + public func checkpointAndReportUnreferencedContent( now: Date = Date() - ) async throws -> VaultGarbageCollectionReport { + ) async throws -> VaultMaintenanceReport { guard let layout = vaultConfiguration.remoteLayout else { throw EncryptedVaultError.missingConfiguration } @@ -80,9 +79,8 @@ public actor VaultMaintenanceService { rootKey: rootKey, vaultID: vaultConfiguration.vaultIdentifier ) - let envelope = try VaultCryptography.seal( + let envelope = try VaultPaddedCheckpointCodec.seal( checkpoint, - role: .checkpoint, objectToken: checkpointToken, rootKey: rootKey, vaultID: vaultConfiguration.vaultIdentifier, @@ -104,11 +102,9 @@ public actor VaultMaintenanceService { fileID: remoteCheckpoint.id, to: verificationURL ) - let verified = try VaultCryptography.open( - VaultCheckpoint.self, - envelope: Data(contentsOf: verificationURL, options: .mappedIfSafe), - expectedRole: .checkpoint, - expectedObjectToken: checkpointToken, + let verified = try VaultPaddedCheckpointCodec.open( + Data(contentsOf: verificationURL, options: .mappedIfSafe), + objectToken: checkpointToken, rootKey: rootKey, vaultID: vaultConfiguration.vaultIdentifier, keyEpoch: vaultConfiguration.keyEpoch @@ -118,7 +114,6 @@ public actor VaultMaintenanceService { } let referencedTokens = Self.referencedContentTokens(in: verified.items) - let cutoff = now.addingTimeInterval(-max(0, retentionInterval)) var candidatesByToken = Dictionary(uniqueKeysWithValues: try await localStore.garbageCollectionCandidates().map { ($0.objectToken, $0) @@ -127,7 +122,6 @@ public actor VaultMaintenanceService { var observedTokens: Set = [] var cursor: String? var examined = 0 - var deleted = 0 repeat { let page = try await objectStore.listObjects( containerID: layout.contentContainerID, @@ -140,17 +134,7 @@ public actor VaultMaintenanceService { candidatesByToken[object.token] = nil continue } - if let candidate = candidatesByToken[object.token], - candidate.remoteFileID == object.id { - if candidate.firstObservedAt <= cutoff, - candidate.observedFrontier.transactionIDs.isSubset( - of: state.appliedTransactionIDs - ) { - try await objectStore.deleteObject(fileID: object.id) - candidatesByToken[object.token] = nil - deleted += 1 - } - } else { + if candidatesByToken[object.token]?.remoteFileID != object.id { candidatesByToken[object.token] = VaultGarbageCollectionCandidate( objectToken: object.token, remoteFileID: object.id, @@ -169,10 +153,10 @@ public actor VaultMaintenanceService { Array(candidatesByToken.values) ) - return VaultGarbageCollectionReport( + return VaultMaintenanceReport( checkpointFileID: remoteCheckpoint.id, examinedObjectCount: examined, - deletedObjectCount: deleted + unreferencedObjectCount: candidatesByToken.count ) } diff --git a/PotassiumProviderCore/VaultMigration.swift b/PotassiumProviderCore/VaultMigration.swift deleted file mode 100644 index de6bef6..0000000 --- a/PotassiumProviderCore/VaultMigration.swift +++ /dev/null @@ -1,481 +0,0 @@ -import CryptoKit -import Foundation - -public enum VaultMigrationState: Int, Codable, Comparable, Sendable { - case inventoried - case encrypted - case uploaded - case committed - case verified - case sourcePurged - - public static func < (lhs: VaultMigrationState, rhs: VaultMigrationState) -> Bool { - lhs.rawValue < rhs.rawValue - } -} - -public struct VaultMigrationSourceItem: Codable, Equatable, Sendable { - public let sourceIdentifier: String - public let sourceParentIdentifier: String? - public let sourceRevision: String - public let filename: String - public let isDirectory: Bool - public let contentTypeIdentifier: String? - public let createdAt: Date - public let modifiedAt: Date - public let plaintextSize: Int64 - - public init( - sourceIdentifier: String, - sourceParentIdentifier: String?, - sourceRevision: String, - filename: String, - isDirectory: Bool, - contentTypeIdentifier: String?, - createdAt: Date, - modifiedAt: Date, - plaintextSize: Int64 - ) { - self.sourceIdentifier = sourceIdentifier - self.sourceParentIdentifier = sourceParentIdentifier - self.sourceRevision = sourceRevision - self.filename = filename - self.isDirectory = isDirectory - self.contentTypeIdentifier = contentTypeIdentifier - self.createdAt = createdAt - self.modifiedAt = modifiedAt - self.plaintextSize = plaintextSize - } -} - -public struct VaultMigrationRecord: Codable, Equatable, Identifiable, Sendable { - public var id: String { source.sourceIdentifier } - public var source: VaultMigrationSourceItem - public var destinationItemID: VaultItemIdentifier - public var destinationParentID: VaultItemIdentifier? - public var state: VaultMigrationState - public var stagedContent: VaultStagedContent? - public var uploadedContent: VaultUploadedContent? - public var verifiedDigest: Data? - public var updatedAt: Date - - public init( - source: VaultMigrationSourceItem, - destinationItemID: VaultItemIdentifier = VaultItemIdentifier(), - destinationParentID: VaultItemIdentifier?, - state: VaultMigrationState = .inventoried, - stagedContent: VaultStagedContent? = nil, - uploadedContent: VaultUploadedContent? = nil, - verifiedDigest: Data? = nil, - updatedAt: Date = Date() - ) { - self.source = source - self.destinationItemID = destinationItemID - self.destinationParentID = destinationParentID - self.state = state - self.stagedContent = stagedContent - self.uploadedContent = uploadedContent - self.verifiedDigest = verifiedDigest - self.updatedAt = updatedAt - } -} - -public struct VaultMigrationPreflight: Equatable, Sendable { - public let itemCount: Int - public let plaintextByteCount: Int64 - public let estimatedCiphertextByteCount: Int64 - public let inaccessibleItemCount: Int - public let sharedItemCount: Int - public let versionCount: Int - public let ownsKnownFolders: Bool - - public init( - itemCount: Int, - plaintextByteCount: Int64, - estimatedCiphertextByteCount: Int64, - inaccessibleItemCount: Int, - sharedItemCount: Int, - versionCount: Int, - ownsKnownFolders: Bool - ) { - self.itemCount = itemCount - self.plaintextByteCount = plaintextByteCount - self.estimatedCiphertextByteCount = estimatedCiphertextByteCount - self.inaccessibleItemCount = inaccessibleItemCount - self.sharedItemCount = sharedItemCount - self.versionCount = versionCount - self.ownsKnownFolders = ownsKnownFolders - } -} - -public protocol VaultMigrationJournalStoring: Sendable { - func records() async throws -> [VaultMigrationRecord] - func record(sourceIdentifier: String) async throws -> VaultMigrationRecord? - func save(_ record: VaultMigrationRecord) async throws - func removeAll() async throws -} - -public actor InMemoryVaultMigrationJournal: VaultMigrationJournalStoring { - private var values: [String: VaultMigrationRecord] = [:] - - public init() {} - - public func records() -> [VaultMigrationRecord] { - values.values.sorted { $0.source.sourceIdentifier < $1.source.sourceIdentifier } - } - - public func record(sourceIdentifier: String) -> VaultMigrationRecord? { - values[sourceIdentifier] - } - - public func save(_ record: VaultMigrationRecord) { - values[record.source.sourceIdentifier] = record - } - - public func removeAll() { - values.removeAll() - } -} - -/// The complete migration journal is encrypted with the vault local-state key, -/// so source names and paths never appear in local support databases. -public actor VaultMigrationFileJournal: VaultMigrationJournalStoring { - private let fileURL: URL - private let rootKey: VaultKeyMaterial - private let vaultID: VaultIdentifier - private let keyEpoch: UInt32 - private var cachedRecords: [String: VaultMigrationRecord]? - - public init( - fileURL: URL, - rootKey: VaultKeyMaterial, - vaultID: VaultIdentifier, - keyEpoch: UInt32 = VaultFormat.currentKeyEpoch - ) { - self.fileURL = fileURL - self.rootKey = rootKey - self.vaultID = vaultID - self.keyEpoch = keyEpoch - } - - public func records() throws -> [VaultMigrationRecord] { - try load().values.sorted { $0.source.sourceIdentifier < $1.source.sourceIdentifier } - } - - public func record(sourceIdentifier: String) throws -> VaultMigrationRecord? { - try load()[sourceIdentifier] - } - - public func save(_ record: VaultMigrationRecord) throws { - var values = try load() - values[record.source.sourceIdentifier] = record - try persist(values) - } - - public func removeAll() throws { - cachedRecords = [:] - guard FileManager.default.fileExists(atPath: fileURL.path) else { return } - try FileManager.default.removeItem(at: fileURL) - } - - private func load() throws -> [String: VaultMigrationRecord] { - if let cachedRecords { return cachedRecords } - guard FileManager.default.fileExists(atPath: fileURL.path) else { - cachedRecords = [:] - return [:] - } - let envelope = try Data(contentsOf: fileURL, options: .mappedIfSafe) - let records = try VaultCryptography.open( - [VaultMigrationRecord].self, - envelope: envelope, - expectedRole: .localState, - expectedObjectToken: objectToken, - rootKey: rootKey, - vaultID: vaultID, - keyEpoch: keyEpoch - ) - let values = Dictionary(uniqueKeysWithValues: records.map { - ($0.source.sourceIdentifier, $0) - }) - cachedRecords = values - return values - } - - private func persist(_ records: [String: VaultMigrationRecord]) throws { - let sorted = records.values.sorted { - $0.source.sourceIdentifier < $1.source.sourceIdentifier - } - let envelope = try VaultCryptography.seal( - sorted, - role: .localState, - objectToken: objectToken, - rootKey: rootKey, - vaultID: vaultID, - keyEpoch: keyEpoch - ) - try FileManager.default.createDirectory( - at: fileURL.deletingLastPathComponent(), - withIntermediateDirectories: true - ) - try envelope.write(to: fileURL, options: [.atomic, .completeFileProtection]) - cachedRecords = records - } - - private var objectToken: String { - Data( - SHA256.hash(data: Data("migration-journal:\(vaultID.rawValue.uuidString)".utf8)) - .prefix(20) - ) - .base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") - } -} - -public protocol VaultMigrationSourceProviding: Sendable { - func currentRevision(sourceIdentifier: String) async throws -> String - func download(sourceIdentifier: String, to destinationURL: URL) async throws - func purgePlaintext(sourceIdentifier: String) async throws -} - -public protocol VaultMigrationDestinationProviding: EncryptedVaultProviding { - func stageFileImport( - itemID: VaultItemIdentifier, - plaintextURL: URL - ) async throws -> VaultStagedContent - func uploadStagedFileImport( - _ staged: VaultStagedContent - ) async throws -> VaultUploadedContent - func commitUploadedFileImport( - _ uploaded: VaultUploadedContent, - parentID: VaultItemIdentifier?, - filename: String, - contentTypeIdentifier: String?, - createdAt: Date, - modifiedAt: Date - ) async throws -> VaultItem - func discardStagedFileImport(_ staged: VaultStagedContent) async -} - -extension EncryptedVaultService: VaultMigrationDestinationProviding {} - -public enum VaultMigrationError: Error, Equatable, LocalizedError, Sendable { - case sourceChanged(String) - case verificationFailed(String) - case invalidJournalState(String) - case sourceNotVerified(String) - - public var errorDescription: String? { - switch self { - case .sourceChanged: - return "The source item changed during migration and must be recopied." - case .verificationFailed: - return "The encrypted destination did not verify against the source digest and size." - case .invalidJournalState: - return "The resumable migration journal contains an invalid transition." - case .sourceNotVerified: - return "Plaintext cannot be purged before its encrypted copy is verified." - } - } -} - -/// Resumable single-item state machine. Source purge is deliberately a -/// separate method; no copy operation can delete plaintext. -public actor VaultMigrationCoordinator { - private let source: any VaultMigrationSourceProviding - private let destination: any VaultMigrationDestinationProviding - private let journal: any VaultMigrationJournalStoring - private let temporaryDirectoryURL: URL - - public init( - source: any VaultMigrationSourceProviding, - destination: any VaultMigrationDestinationProviding, - journal: any VaultMigrationJournalStoring, - temporaryDirectoryURL: URL = FileManager.default.temporaryDirectory - ) { - self.source = source - self.destination = destination - self.journal = journal - self.temporaryDirectoryURL = temporaryDirectoryURL - } - - public func inventory( - _ sourceItem: VaultMigrationSourceItem, - destinationParentID: VaultItemIdentifier? - ) async throws -> VaultMigrationRecord { - if let existing = try await journal.record( - sourceIdentifier: sourceItem.sourceIdentifier - ), existing.source.sourceRevision == sourceItem.sourceRevision { - return existing - } - let record = VaultMigrationRecord( - source: sourceItem, - destinationParentID: destinationParentID - ) - try await journal.save(record) - return record - } - - public func resume(sourceIdentifier: String) async throws -> VaultMigrationRecord { - guard var record = try await journal.record(sourceIdentifier: sourceIdentifier) else { - throw VaultMigrationError.invalidJournalState(sourceIdentifier) - } - if record.state == .sourcePurged { return record } - guard try await source.currentRevision(sourceIdentifier: sourceIdentifier) - == record.source.sourceRevision else { - if let staged = record.stagedContent { - await destination.discardStagedFileImport(staged) - } - throw VaultMigrationError.sourceChanged(sourceIdentifier) - } - if record.state == .verified { return record } - - if record.source.isDirectory { - if record.state == .inventoried { - let item = try await destination.createDirectory( - parentID: record.destinationParentID, - filename: record.source.filename, - createdAt: record.source.createdAt - ) - record.destinationItemID = item.id - record.state = .committed - record.updatedAt = Date() - try await journal.save(record) - } - // Observe the immutable transaction back through the complete - // journal listing before treating the folder as remotely durable. - _ = try await destination.synchronize() - record.state = .verified - record.updatedAt = Date() - try await journal.save(record) - return record - } - - if record.state == .inventoried || stagedFileIsMissing(record) { - let plaintextURL = temporaryURL(prefix: "migration-plaintext") - defer { try? FileManager.default.removeItem(at: plaintextURL) } - try await source.download( - sourceIdentifier: sourceIdentifier, - to: plaintextURL - ) - try applyPlaintextProtection(to: plaintextURL) - let staged = try await destination.stageFileImport( - itemID: record.destinationItemID, - plaintextURL: plaintextURL - ) - record.stagedContent = staged - record.uploadedContent = nil - record.state = .encrypted - record.updatedAt = Date() - try await journal.save(record) - } - - if record.state == .encrypted { - guard let staged = record.stagedContent else { - throw VaultMigrationError.invalidJournalState(sourceIdentifier) - } - let uploaded = try await destination.uploadStagedFileImport(staged) - record.uploadedContent = uploaded - record.state = .uploaded - record.updatedAt = Date() - try await journal.save(record) - } - - if record.state == .uploaded { - guard try await source.currentRevision(sourceIdentifier: sourceIdentifier) - == record.source.sourceRevision, - let uploaded = record.uploadedContent else { - throw VaultMigrationError.sourceChanged(sourceIdentifier) - } - _ = try await destination.commitUploadedFileImport( - uploaded, - parentID: record.destinationParentID, - filename: record.source.filename, - contentTypeIdentifier: record.source.contentTypeIdentifier, - createdAt: record.source.createdAt, - modifiedAt: record.source.modifiedAt - ) - record.state = .committed - record.updatedAt = Date() - try await journal.save(record) - } - - if record.state == .committed { - let verificationURL = temporaryURL(prefix: "migration-verification") - defer { try? FileManager.default.removeItem(at: verificationURL) } - _ = try await destination.fetchContent( - itemID: record.destinationItemID, - expectedRevision: record.uploadedContent?.staged.contentRevision, - to: verificationURL - ) - let digest = try Self.digestAndSize(of: verificationURL) - guard digest.size == record.source.plaintextSize, - digest.digest == record.uploadedContent?.staged.plaintextDigest else { - throw VaultMigrationError.verificationFailed(sourceIdentifier) - } - guard try await source.currentRevision( - sourceIdentifier: sourceIdentifier - ) == record.source.sourceRevision else { - throw VaultMigrationError.sourceChanged(sourceIdentifier) - } - record.verifiedDigest = digest.digest - record.state = .verified - record.updatedAt = Date() - try await journal.save(record) - } - return record - } - - public func purgeVerifiedSource(sourceIdentifier: String) async throws { - guard var record = try await journal.record(sourceIdentifier: sourceIdentifier), - record.state == .verified else { - throw VaultMigrationError.sourceNotVerified(sourceIdentifier) - } - guard try await source.currentRevision( - sourceIdentifier: sourceIdentifier - ) == record.source.sourceRevision else { - throw VaultMigrationError.sourceChanged(sourceIdentifier) - } - try await source.purgePlaintext(sourceIdentifier: sourceIdentifier) - record.state = .sourcePurged - record.updatedAt = Date() - try await journal.save(record) - } - - private func stagedFileIsMissing(_ record: VaultMigrationRecord) -> Bool { - guard record.state == .encrypted, let staged = record.stagedContent else { - return false - } - return FileManager.default.fileExists(atPath: staged.ciphertextURL.path) == false - } - - private func temporaryURL(prefix: String) -> URL { - temporaryDirectoryURL.appendingPathComponent( - "\(prefix)-\(UUID().uuidString)", - isDirectory: false - ) - } - - private func applyPlaintextProtection(to url: URL) throws { - #if canImport(Darwin) - try FileManager.default.setAttributes( - [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], - ofItemAtPath: url.path - ) - #endif - } - - private static func digestAndSize(of url: URL) throws -> (digest: Data, size: Int64) { - let handle = try FileHandle(forReadingFrom: url) - defer { try? handle.close() } - var hasher = SHA256() - var size: Int64 = 0 - while let data = try handle.read(upToCount: VaultFormat.contentFrameSize), - data.isEmpty == false { - hasher.update(data: data) - size += Int64(data.count) - } - return (Data(hasher.finalize()), size) - } -} diff --git a/PotassiumProviderCore/VaultModels.swift b/PotassiumProviderCore/VaultModels.swift index 3e36ee9..18f3252 100644 --- a/PotassiumProviderCore/VaultModels.swift +++ b/PotassiumProviderCore/VaultModels.swift @@ -4,12 +4,14 @@ import Security import UniformTypeIdentifiers public enum VaultFormat { - public static let currentVersion: UInt16 = 1 + public static let currentVersion: UInt16 = 2 public static let currentKeyEpoch: UInt32 = 1 public static let contentFrameSize = 1_048_576 public static let minimumFinalFrameSize = 4_096 public static let transactionObjectSize = 65_536 - public static let fileProviderIdentifierPrefix = "ev1:" + public static let minimumCheckpointPayloadSize = 65_536 + public static let maximumCheckpointPayloadSize = 256 * 1_048_576 + public static let fileProviderIdentifierPrefix = "ev2:" } public struct VaultIdentifier: RawRepresentable, Codable, Hashable, Sendable { @@ -105,7 +107,7 @@ public struct VaultFrontier: Codable, Equatable, Sendable { } public var anchorString: String { - var material = Data("vault-frontier-v1".utf8) + var material = Data("vault-frontier-v2".utf8) for identifier in sortedTransactionIDs() { material.append(identifier.data) } @@ -171,6 +173,11 @@ public struct VaultItem: Codable, Equatable, Identifiable, Sendable { public var plaintextSize: Int64 public var isFavorite: Bool public var isTrashed: Bool + /// Identifies the top-level trash operation that hid this item. A directly + /// trashed item uses its own identifier; descendants inherit that value. + /// This lets restoring a folder preserve descendants that were already in + /// the trash independently. + public var trashRootID: VaultItemIdentifier? public var contentRevision: VaultRevision public var metadataRevision: VaultRevision public var contentReference: VaultContentReference? @@ -187,6 +194,7 @@ public struct VaultItem: Codable, Equatable, Identifiable, Sendable { plaintextSize: Int64 = 0, isFavorite: Bool = false, isTrashed: Bool = false, + trashRootID: VaultItemIdentifier? = nil, contentRevision: VaultRevision, metadataRevision: VaultRevision, contentReference: VaultContentReference? = nil, @@ -202,6 +210,7 @@ public struct VaultItem: Codable, Equatable, Identifiable, Sendable { self.plaintextSize = plaintextSize self.isFavorite = isFavorite self.isTrashed = isTrashed + self.trashRootID = trashRootID ?? (isTrashed ? id : nil) self.contentRevision = contentRevision self.metadataRevision = metadataRevision self.contentReference = contentReference @@ -226,7 +235,8 @@ public enum VaultRevisionDigests { contentTypeIdentifier: item.contentTypeIdentifier, createdAt: item.createdAt, isFavorite: item.isFavorite, - isTrashed: item.isTrashed + isTrashed: item.isTrashed, + trashRootID: item.trashRootID )) } @@ -238,6 +248,7 @@ public enum VaultRevisionDigests { let createdAt: Date let isFavorite: Bool let isTrashed: Bool + let trashRootID: VaultItemIdentifier? } } diff --git a/PotassiumProviderCore/VaultProvisioning.swift b/PotassiumProviderCore/VaultProvisioning.swift index 3379233..b6d40a4 100644 --- a/PotassiumProviderCore/VaultProvisioning.swift +++ b/PotassiumProviderCore/VaultProvisioning.swift @@ -22,7 +22,7 @@ public struct PendingVaultProvisioning: Sendable { } } -public struct PendingVaultRecoveryRotation: Sendable { +public struct PendingVaultRecoveryRewrap: Sendable { public let recoveryKit: VaultRecoveryKit public let vaultConfiguration: ProviderVaultConfiguration @@ -124,9 +124,8 @@ public struct VaultProvisioningService: Sendable { items: [], transactionMerkleRoot: VaultMerkleTree.emptyRoot ) - let checkpointEnvelope = try VaultCryptography.seal( + let checkpointEnvelope = try VaultPaddedCheckpointCodec.seal( checkpoint, - role: .checkpoint, objectToken: checkpointToken, rootKey: rootKey, vaultID: vaultID @@ -248,11 +247,9 @@ public struct VaultProvisioningService: Sendable { fileID: checkpointObject.id, to: checkpointURL ) - let checkpoint = try VaultCryptography.open( - VaultCheckpoint.self, - envelope: Data(contentsOf: checkpointURL, options: .mappedIfSafe), - expectedRole: .checkpoint, - expectedObjectToken: layout.checkpointToken, + let checkpoint = try VaultPaddedCheckpointCodec.open( + Data(contentsOf: checkpointURL, options: .mappedIfSafe), + objectToken: layout.checkpointToken, rootKey: unlocked.rootKey, vaultID: unlocked.vaultID, keyEpoch: unlocked.keyEpoch @@ -321,11 +318,9 @@ public struct VaultProvisioningService: Sendable { fileID: checkpointObject.id, to: checkpointURL ) - _ = try VaultCryptography.open( - VaultCheckpoint.self, - envelope: Data(contentsOf: checkpointURL, options: .mappedIfSafe), - expectedRole: .checkpoint, - expectedObjectToken: layout.checkpointToken, + _ = try VaultPaddedCheckpointCodec.open( + Data(contentsOf: checkpointURL, options: .mappedIfSafe), + objectToken: layout.checkpointToken, rootKey: unlocked.rootKey, vaultID: unlocked.vaultID, keyEpoch: unlocked.keyEpoch @@ -379,11 +374,9 @@ public struct VaultProvisioningService: Sendable { fileID: checkpointObject.id, to: checkpointURL ) - let checkpoint = try VaultCryptography.open( - VaultCheckpoint.self, - envelope: Data(contentsOf: checkpointURL, options: .mappedIfSafe), - expectedRole: .checkpoint, - expectedObjectToken: record.remoteLayout.checkpointToken, + let checkpoint = try VaultPaddedCheckpointCodec.open( + Data(contentsOf: checkpointURL, options: .mappedIfSafe), + objectToken: record.remoteLayout.checkpointToken, rootKey: record.rootKey, vaultID: record.vaultID, keyEpoch: record.keyEpoch @@ -422,17 +415,17 @@ public struct VaultProvisioningService: Sendable { /// Rewraps the existing root key under a fresh recovery secret. This does /// not revoke an old recovery kit while an older bootstrap object or server /// backup remains reachable; device revocation requires full rekeying. - public func prepareRecoveryRotation( + public func prepareRecoveryRewrap( configuration: ProviderVaultConfiguration, driveID: Int, currentRecoveryKitText: String - ) async throws -> PendingVaultRecoveryRotation { + ) async throws -> PendingVaultRecoveryRewrap { let currentKit = try VaultRecoveryKit(encoded: currentRecoveryKitText) guard currentKit.vaultID == configuration.vaultIdentifier, currentKit.driveID == driveID else { throw VaultProvisioningError.recoveryConfirmationMismatch } - let bootstrapURL = temporaryURL(prefix: "rotation-bootstrap-download") + let bootstrapURL = temporaryURL(prefix: "recovery-rewrap-bootstrap-download") defer { try? FileManager.default.removeItem(at: bootstrapURL) } try await objectStore.downloadObject( fileID: currentKit.vaultHeaderFileID, @@ -459,7 +452,7 @@ public struct VaultProvisioningService: Sendable { recoverySecret: newRecoverySecret, remoteLayout: layout ) - let uploadURL = temporaryURL(prefix: "rotation-bootstrap") + let uploadURL = temporaryURL(prefix: "recovery-rewrap-bootstrap") defer { try? FileManager.default.removeItem(at: uploadURL) } try newBootstrap.write(to: uploadURL, options: [.atomic]) let header = try await objectStore.uploadObject( @@ -469,7 +462,7 @@ public struct VaultProvisioningService: Sendable { ) var rotatedConfiguration = configuration rotatedConfiguration.vaultHeaderFileID = header.id - return PendingVaultRecoveryRotation( + return PendingVaultRecoveryRewrap( recoveryKit: VaultRecoveryKit( vaultID: configuration.vaultIdentifier, driveID: driveID, @@ -481,8 +474,8 @@ public struct VaultProvisioningService: Sendable { ) } - public func confirmRecoveryRotation( - _ pending: PendingVaultRecoveryRotation, + public func confirmRecoveryRewrap( + _ pending: PendingVaultRecoveryRewrap, recoveryKitConfirmation: String ) throws -> ProviderVaultConfiguration { guard let confirmation = try? VaultRecoveryKit( diff --git a/PotassiumProviderCore/VaultRecoveryKit.swift b/PotassiumProviderCore/VaultRecoveryKit.swift index 6d297d7..29a636c 100644 --- a/PotassiumProviderCore/VaultRecoveryKit.swift +++ b/PotassiumProviderCore/VaultRecoveryKit.swift @@ -2,8 +2,8 @@ import CryptoKit import Foundation public struct VaultRecoveryKit: Equatable, Sendable { - public static let prefix = "KPV1" - private static let magic = Data("KPR1".utf8) + public static let prefix = "KPV2" + private static let magic = Data("KPR2".utf8) private static let payloadByteCount = 4 + 2 + 16 + 8 + 8 + 8 + VaultKeyMaterial.byteCount private static let checksumByteCount = 5 @@ -104,7 +104,7 @@ public struct VaultRecoveryKit: Equatable, Sendable { } public enum VaultBootstrap { - private static let magic = Data("KPB1".utf8) + private static let magic = Data("KPB2".utf8) private static let headerByteCount = 4 + 2 + 4 + 16 public struct RemoteLayout: Codable, Equatable, Sendable { diff --git a/README.md b/README.md index 8bcf662..c78866c 100644 --- a/README.md +++ b/README.md @@ -25,13 +25,12 @@ data. state, validation, and remaining manual release gates. - [Architecture](doc/ARCHITECTURE.md): targets, modules, persistence, runtime boundaries, and high-level data flow. -- [Encrypted Vault Format v1](doc/ENCRYPTED_VAULT.md): threat model, leakage, +- [Encrypted Vault Format v2](doc/ENCRYPTED_VAULT.md): threat model, leakage, device-local and optional iCloud Keychain custody, guided recovery, binary formats, opaque synchronization, rollback behavior, and security-review feature gates. -- [Encrypted Vault Migration](doc/ENCRYPTED_VAULT_MIGRATION.md): resumable - encrypted migration journal, verification-before-purge invariant, and - Desktop/Documents cutover. +- [Conflict Resolution Truth Table](doc/CONFLICT_RESOLUTION_TRUTH_TABLE.md): + normative encrypted-vault data-safety decisions, evidence, and open gates. - [App And Domains](doc/APP_AND_DOMAINS.md): SwiftUI setup app, kDrive loading, File Provider domain registration, and macOS Desktop & Documents controls. - [Authentication](doc/AUTHENTICATION.md): OAuth PKCE, manual token entry, @@ -148,8 +147,9 @@ local Xcode requires a more specific variant. links, or user data. - Encrypted vaults are experimental and disabled by default pending independent cryptographic review. The feature flag is not a production-readiness claim. -- Creation starts with a mandatory unsupported-feature and complete-data-loss - warning whose continuation remains disabled for five seconds. +- Every encrypted-vault activation route starts with a mandatory + unsupported-feature and complete-data-loss warning whose continuation remains + disabled for five seconds. - Encrypted-vault onboarding always requires a verified offline recovery kit. Optional iCloud Keychain access is a separately gated convenience: it can open a vault on another trusted Apple device, but it does not replace offline @@ -161,8 +161,8 @@ local Xcode requires a more specific variant. - On macOS 15 or later, Desktop & Documents protection is an explicit action. Encrypted domains preflight ownership and local key availability before presenting Apple's consent UI, then upload only opaque vault ciphertext. - A legacy plaintext Potassium owner must complete verified encrypted - migration before its known-folder claim can move. + A legacy plaintext Potassium owner blocks the encrypted known-folder claim. + Safe migration and destructive source purge are not implemented. ## License diff --git a/doc/APP_AND_DOMAINS.md b/doc/APP_AND_DOMAINS.md index 9094e73..5be7e0a 100644 --- a/doc/APP_AND_DOMAINS.md +++ b/doc/APP_AND_DOMAINS.md @@ -183,9 +183,10 @@ reports key availability, kDrive reachability, current ownership, and quota when exposed by the API. The UI distinguishes preparing, awaiting consent, connected/uploading, up to date, quota blocked, and attention required. -A legacy plaintext Potassium owner blocks direct claiming until verified -encrypted migration completes. Another provider can be handed off through -macOS consent, with a warning that its previous remote copies are not purged. +A legacy plaintext Potassium owner blocks direct claiming because safe +encrypted migration is not implemented. Another provider can be handed off +through macOS consent, with a warning that its previous remote copies are not +purged. ## Removing A Domain @@ -251,15 +252,17 @@ token or expiration, so reconnecting may be required when it stops working. ## Encrypted vault domains When the security-review feature flag is enabled, drive management offers -Create Encrypted Vault and Open Existing Vault. Before creation performs any -remote preparation, it displays a mandatory warning that the unsupported -experimental feature may cause complete, unrecoverable data loss and that the -user proceeds entirely on their own. The acknowledgement button remains -disabled for five seconds. Creation then shows a one-time text and QR recovery -kit and requires exact confirmation before saving the device key or registering -the domain. Existing plaintext domains remain separately registered migration -sources. Normal removal/logout retains vault keys; the separate Forget Key -workflow requires the matching recovery kit. +Create Encrypted Vault and Open Existing Vault. Before creation, recovery-kit +open, or iCloud Keychain open performs any activation side effect, it displays +a mandatory warning that the unsupported experimental feature may cause +complete, unrecoverable data loss and that the user proceeds entirely on their +own. The acknowledgement button remains disabled for five seconds measured with +system uptime. Creation then shows a one-time text and QR recovery kit and +requires exact confirmation before saving the device key or registering the +domain. Existing plaintext domains remain separately registered; no cross-vault +migration or source-purge workflow is implemented. Normal removal/logout +retains vault keys; the separate Forget Key workflow requires the matching +recovery kit. Creation is a guided flow: threat-boundary overview, device-only versus optional iCloud Keychain custody, recovery confirmation, durable registration, diff --git a/doc/ARCHITECTURE.md b/doc/ARCHITECTURE.md index a5cd0e7..00197c7 100644 --- a/doc/ARCHITECTURE.md +++ b/doc/ARCHITECTURE.md @@ -83,9 +83,9 @@ and should not be treated as part of this product's build graph. ## Encrypted vault boundary -For `opaqueVaultV1`, runtime loading adds the root key and trusted frontier from +For `opaqueVaultV2`, runtime loading adds the root key and trusted frontier from Keychain, an encrypted UUID-keyed SQLite generation, and `KDriveObjectStoreProviding` plus `EncryptedVaultProviding`. Provider-facing code receives only `VaultItem`; `KDriveRemoteItem` remains physical-object metadata and cannot construct Finder metadata. See -[Encrypted Vault Format v1](ENCRYPTED_VAULT.md). +[Encrypted Vault Format v2](ENCRYPTED_VAULT.md). diff --git a/doc/AUTHENTICATION.md b/doc/AUTHENTICATION.md index c7d92f1..634d33a 100644 --- a/doc/AUTHENTICATION.md +++ b/doc/AUTHENTICATION.md @@ -59,7 +59,7 @@ with the account identifier. Encrypted vault root keys use a separate Keychain service and accounts keyed by vault UUID. They use the shared access group, Data Protection Keychain, `AfterFirstUnlockThisDeviceOnly`, and no synchronization. The recovery secret is -not stored. See [Encrypted Vault Format v1](ENCRYPTED_VAULT.md). +not stored. See [Encrypted Vault Format v2](ENCRYPTED_VAULT.md). ## Optional iCloud Keychain Vault Access diff --git a/doc/CONFLICTS.md b/doc/CONFLICTS.md index a0c4cd8..2271dae 100644 --- a/doc/CONFLICTS.md +++ b/doc/CONFLICTS.md @@ -357,4 +357,4 @@ an authenticated transaction DAG in canonical order, preserve concurrent content edits as deterministic conflict copies, merge independent content/metadata changes, reject stale deletion, protect concurrent children, and retain sibling collisions with deterministic suffixes. Encrypted conflict -rows are opaque. See [Encrypted Vault Format v1](ENCRYPTED_VAULT.md). +rows are opaque. See [Encrypted Vault Format v2](ENCRYPTED_VAULT.md). diff --git a/doc/CONFLICT_RESOLUTION_TRUTH_TABLE.md b/doc/CONFLICT_RESOLUTION_TRUTH_TABLE.md new file mode 100644 index 0000000..bebd29c --- /dev/null +++ b/doc/CONFLICT_RESOLUTION_TRUTH_TABLE.md @@ -0,0 +1,86 @@ +# Conflict Resolution Truth Table + +Status: normative data-safety register for encrypted vault format v2 on +`codex/encrypted-kdrive-vault`. Last reviewed: 2026-08-02. + +This table is a release gate. Any change to encrypted File Provider mutations, +versions, conflict policy, journal replay, trash, rollback, cleanup, recovery, +or activation must update the applicable row and its regression evidence in +the same pull request. An inaccurate row is a release-blocking defect. + +The table deliberately does not claim that unmerged legacy-plaintext conflict +hardening exists on this branch. Legacy plaintext behavior remains documented +in [CONFLICTS.md](CONFLICTS.md); encrypted v2 must never route through that +implementation. + +## Normative decisions + +| Scenario | Required deterministic result | Destructive action permitted? | Regression evidence | +|---|---|---:|---| +| Concurrent edits change the same file content from one base | Canonical winner retains the logical UUID; every loser becomes a stable conflict copy with independently authenticated metadata. Replay order cannot change the result. | No | `VaultJournalTests.concurrentContentEditsConvergeForEveryReplayOrder` | +| One concurrent edit changes content and another changes metadata | Merge both independent changes. | No | `VaultJournalTests.independentConcurrentContentAndMetadataEditsMerge` | +| Concurrent metadata edits disagree | Canonical transaction ordering selects the visible metadata and emits an opaque metadata conflict. | No | Existing randomized reducer coverage; dedicated expansion remains desirable. | +| Move targets itself, a non-directory, a missing parent, a trashed parent, or a descendant | Reject the invalid parent change and emit `invalidMove`; preserve the last valid parent graph. | No | Parent-graph validation plus `VaultJournalTests.concurrentDirectoryMovesCannotCreateAParentCycle` | +| Two concurrent moves would jointly create a directory cycle | Apply only the canonically first valid move; reject the move that would close the cycle. Every replay permutation converges. | No | `VaultJournalTests.concurrentDirectoryMovesCannotCreateAParentCycle` | +| Folder deletion races with a new visible child outside its causal history | Preserve the folder and child; emit `folderDeletionRejected`. | No | `VaultJournalTests.staleDeleteLosesToEditAndFolderDeleteLosesToChild` | +| File deletion races with an edit | Preserve the edit and emit `deletionRejected`. | No | `VaultJournalTests.staleDeleteLosesToEditAndFolderDeleteLosesToChild` | +| A child is trashed independently, then an ancestor is trashed and restored | Restore only descendants carrying the ancestor trash operation's provenance. Keep the independently trashed child in trash. | No | `VaultJournalTests.restoringFolderPreservesIndependentlyTrashedDescendant` | +| Purge is requested with stale content or metadata revisions | Reject purge and preserve the item. | No | Reducer revision guards; dedicated end-to-end expansion remains desirable. | +| A historical file version is restored | Authenticate and decrypt the selected immutable revision, then encrypt and publish it as a fresh content object with a fresh logical revision. A stale client holding the historical revision must not pass an ABA check. | No old ciphertext deletion | `VaultProvisioningTests.restoringVersionPublishesFreshRevisionAndRejectsABAStaleWrite` | +| Siblings normalize to the same filename, including a pre-existing generated conflict name | Keep every item. Reserve all existing normalized names, then allocate deterministic numbered suffixes until unique. | No | `VaultJournalTests.siblingConflictAllocatorSkipsExistingGeneratedName` | +| Remote journal omits a transaction previously trusted by this device | Reject synchronization as rollback; never fill the omission from cache and call it current. | No | `VaultProvisioningTests.returningDeviceRejectsOmittedRemoteJournalObject` | +| Maintenance observes ciphertext unreferenced by this device's current state | Record/report it only. Never delete it because an offline device may later publish a valid reference. | **No** | `VaultProvisioningTests.maintenanceNeverDeletesCiphertextThatAnOfflineDeviceMayReference` | +| A checkpoint is uploaded or opened | Use authenticated 64 KiB–256 MiB power-of-two padding. Reject unpadded, malformed, tampered, or out-of-range objects. | No | `VaultCryptographyTests.checkpointsHideExactMetadataSizeAndRejectUnpaddedObjects` | +| User creates a vault or opens one with a recovery kit or iCloud Keychain | Show the complete-data-loss/no-support warning and enforce at least five seconds of monotonic elapsed time before any activation side effect. | No side effect before delay | `VaultUXAppModelTests.failedCloudPublicationKeepsRegisteredVaultAndRecoveryBoundary`, `recoveryAndICloudOpenCannotBypassRiskDelay`, and the warning UI test | +| Saved configuration identifies experimental vault v1 | Recognize it only to report unsupported format. Do not re-register, enumerate, mutate, claim known folders, or route through plaintext code. Leave an already registered system domain inert until explicit user removal. | **No** | Runtime and embedded-format guards, `VaultUXAppModelTests.reloadDoesNotReactivateAnUnsupportedV1Domain`, and configuration tests; full extension-host regression remains open. | + +## Finding register + +| ID | Severity | State | Finding and disposition | +|---|---:|---|---| +| EV-001 | Critical | Resolved in v2 | Concurrent reciprocal directory moves could create a parent cycle. Parent changes are now validated during canonical replay and the final graph is checked. | +| EV-002 | Critical | Resolved in v2 | Version restore reused an old content revision, permitting an ABA stale-write match. Restore now publishes fresh ciphertext and a fresh revision. | +| EV-003 | High | Resolved in v2 | Restoring a folder revived descendants trashed independently. Trash-root provenance now scopes recursive restore. | +| EV-004 | High | Resolved in v2 | Generated conflict names could collide with existing siblings. Allocation now reserves all normalized names and increments deterministically. | +| EV-005 | High | Resolved in v2 | Checkpoint ciphertext length exposed exact aggregate metadata size. Checkpoints now use authenticated power-of-two padding. | +| EV-006 | Critical | Resolved in v2 | Recovery-kit and iCloud activation bypassed the mandatory risk warning; iCloud could also bypass the main gate. All activation routes now share one monotonic five-second gate. | +| EV-007 | Critical | Resolved by removal | Retention-based deletion could destroy ciphertext referenced later by an offline device. Remote content and journal deletion are disabled. | +| EV-008 | Critical | Resolved by removal | Documentation presented an incomplete migration/purge coordinator as a safe product workflow. The coordinator, purge path, UI claims, and migration document were removed. | +| EV-009 | High | Open release gate | A new device has no independent witness for history hidden before first trust. Document the limitation and design an external witness before any production claim. | +| EV-010 | High | Open release gate | Journal growth is unbounded because safe remote compaction is not implemented. Complete large-scale benchmarks and design reviewed immutable proof retrieval before deletion. | +| EV-011 | High | Open release gate | Recovery rewrapping does not revoke old bootstraps, backups, or devices. Full root-key epoch rotation and reachable-content re-encryption are not implemented. | +| EV-012 | High | Open release gate | Safe plaintext-to-vault migration and destructive source purge are unavailable. Do not offer ownership cutover from a legacy Potassium domain. | +| EV-013 | High | Open release gate | Account/drive identity, object counts and buckets, timing, IP metadata, access patterns, and fetched-object linkage remain visible to the service. This is an accepted architectural limitation, not zero-knowledge storage. | +| EV-014 | Critical | Open release gate | Independent cryptographic and adversarial synchronization review has not approved v2. Both feature flags must remain off by default. | + +## Audit evidence + +Local success does not close EV-014. + +- macOS: `xcodebuild build-for-testing -destination 'platform=macOS'` succeeded + with signing and indexing disabled. Direct execution then passed 33 focused + tests: all journal, provisioning/maintenance, cryptography, and domain-format + suites. The normal local macOS test host ran the activation-model assertions + but hung while finalizing its Xcode result bundle, so this evidence does not + claim a clean full-host exit. +- iOS Simulator: the complete `potassiumProviderTests` target passed on + `platform=iOS Simulator,OS=26.5,name=iPhone 17` with signing and indexing + disabled. +- visionOS Simulator: the complete `potassiumProviderTests` target passed on + `platform=visionOS Simulator,OS=26.5,name=Apple Vision Pro`; the format, + cryptography, and activation-model suites were rerun successfully after the + final fail-closed guards. +- Builds: iOS Simulator and generic visionOS builds succeeded with signing + disabled. +- UI warning automation: the targeted macOS UI test passed and verifies the + warning copy and disabled continuation for creation; route unification and + the monotonic delay are covered in unit tests. +- Independent security review: not completed. + +## Maintenance rule + +For each affected row, reviewers must verify canonical replay convergence, +failure atomicity, stale-base behavior, File Provider error mapping, recovery +path, and absence of remote deletion. New destructive behavior requires a new +explicit row, adversarial regression coverage, documentation, and independent +security approval before it can be enabled. diff --git a/doc/CONTEXTUAL_ACTIONS.md b/doc/CONTEXTUAL_ACTIONS.md index 68f7229..b1da139 100644 --- a/doc/CONTEXTUAL_ACTIONS.md +++ b/doc/CONTEXTUAL_ACTIONS.md @@ -94,4 +94,4 @@ semantics remain unchanged. For encrypted items, favorite, duplicate, trash restore, and logical version restore call `EncryptedVaultProviding`. Thumbnails and versions are local authenticated vault operations. Share-link panels stop before any kDrive -sharing call and explain that recipient-key sharing is not supported in v1. +sharing call and explain that recipient-key sharing is not supported in v2. diff --git a/doc/ENCRYPTED_VAULT.md b/doc/ENCRYPTED_VAULT.md index f4f0a70..751c972 100644 --- a/doc/ENCRYPTED_VAULT.md +++ b/doc/ENCRYPTED_VAULT.md @@ -1,9 +1,17 @@ -# Encrypted Vault Format v1 +# Encrypted Vault Format v2 Status: implemented behind `EncryptedVaultsEnabled`; **not approved for production use**. Independent cryptographic review and resolution of all high-severity findings are release gates. This document is the versioned format -specification for format version 1. +specification for format version 2. Experimental v1 vaults are intentionally +incompatible: clients recognize their saved configuration only to fail closed +and will not activate, enumerate, or mutate them. + +The app does not re-register a saved v1 configuration. A v1 domain that the +operating system already registered remains inert until the user explicitly +removes it: every extension entry point rejects the configuration before token, +key, local-state, or network access. Automatic removal is intentionally avoided +because it could discard pending materialized changes. ## Security boundary @@ -33,7 +41,7 @@ frontier in the Keychain and rejects a state that omits that frontier. A new device cannot detect history hidden before its first trusted checkpoint without an independent external witness. -Because journal compaction is disabled in v1, a returning device also requires +Because journal compaction is disabled in v2, a returning device also requires every remote journal object in its local trusted cache to remain present in a complete server listing. It never fills an omitted server listing from cache and silently calls the result current. @@ -54,8 +62,8 @@ info = UTF-8("net.weavee.potassiumProvider.vault." || label) Labels are domain separated by object role and key epoch. Content-key wrapping uses `content-wrap.epoch.`. Encrypted object envelopes use -`object..epoch.`. Local SQLite and migration records use the -local-state role and are independently authenticated. +`object..epoch.`. Local SQLite records use the local-state +role and are independently authenticated. Every content revision has a fresh random 256-bit data-encryption key, random object token, random content revision, and random 64-bit frame nonce prefix. @@ -85,11 +93,11 @@ A separate random 256-bit recovery secret derives the bootstrap wrapping key. Only an AES-256-GCM-wrapped root key and encrypted remote layout are uploaded. The recovery secret never leaves the setup/recovery UI. -The recovery kit is grouped, checksummed Base32 beginning with `KPV1`. Its +The recovery kit is grouped, checksummed Base32 beginning with `KPV2`. Its payload contains: ```text -magic "KPR1" +magic "KPR2" format version (u16, big endian) vault UUID (16 bytes) drive ID (signed value encoded in u64, big endian) @@ -99,6 +107,9 @@ recovery secret (32 bytes) SHA-256 checksum prefix (5 bytes) ``` +The remotely stored bootstrap begins with `KPB2`, carries format version 2 in +its authenticated header, and wraps the root key plus opaque remote layout. + The app displays text and a locally generated QR code once. The user must paste the complete kit back before the root key is committed and the File Provider domain is registered. Opening an existing vault downloads and authenticates the @@ -107,13 +118,12 @@ The existing-vault verification and Forget Key actions also authenticate the remote encrypted header and checkpoint; they do not accept locator matching as proof and never transmit or persist the recovery material. -Recovery rotation uploads a new bootstrap wrapped by a new recovery secret and +Recovery rewrapping uploads a new bootstrap wrapped by a new recovery secret and requires confirmation of the new kit. It does **not** revoke old server versions, backups, or an old bootstrap that still wraps the same root key. Revoking a lost device requires a fresh root-key epoch and re-encryption of all -reachable content. The supported safe rekey model is a replacement vault plus -the verified migration state machine; destruction of the old vault is a -separate purge decision. +reachable content. Neither full rekey nor safe cross-vault migration is +implemented. Destructive source purge is therefore not exposed. Loss of every device key and the recovery kit is intentionally unrecoverable. @@ -153,7 +163,7 @@ conflict-as-error behavior, making retry lookup idempotent. All integers are big endian: ```text -"KPE1" (4) +"KPE2" (4) format version u16 object role u8 key epoch u32 @@ -175,7 +185,7 @@ roles, vaults, tokens, epochs, lengths, and malformed decrypted values. Content is independently authenticated in 1 MiB frames: ```text -"KPC1" (4) +"KPC2" (4) format version u16 frame size u32 (= 1,048,576) random nonce prefix u64 @@ -200,7 +210,7 @@ and final digest, and removes partial plaintext on any failure or cancellation. ## Logical model and synchronization Logical identifiers are random UUIDs unrelated to kDrive IDs. File Provider -identifiers are `ev1:`. A `VaultItem` contains encrypted +identifiers are `ev2:`. A `VaultItem` contains encrypted parent UUID, filename, type, dates, exact size, favorite/trash flags, content and metadata revision digests, wrapped content key, opaque blob reference, and logical versions. @@ -241,10 +251,14 @@ trash, restore, purge, and logical version restore are vault transactions. Native kDrive share links are disabled with an explicit recipient-key-sharing message. -## Checkpoints, rollback, and collection +## Checkpoints, rollback, and conservative maintenance Encrypted checkpoints contain the reconstructed logical index, causal frontier, -and a Merkle root over compacted transactions. The current implementation +and a Merkle root over the complete current transaction set. Before encryption, each +checkpoint is length-prefixed and padded with random bytes to a power-of-two +bucket from 64 KiB through 256 MiB. Exact aggregate metadata size is therefore +not exposed by ciphertext length. Unpadded or out-of-range checkpoints fail +closed. The current implementation retains immutable transaction objects and therefore always validates returning frontiers directly. Merkle proof construction/verification is implemented and tested; each leaf binds both the authenticated transaction digest and its UUID @@ -253,17 +267,15 @@ journal deletion remains deliberately disabled until immutable Merkle-node retrieval and independent review are complete. Maintenance first synchronizes the complete journal, uploads a new random -immutable checkpoint, and downloads and authenticates it. An unreferenced -content object then becomes an encrypted local garbage-collection candidate; -the server timestamp is not trusted as its age. Deletion is possible only after -the candidate remains unreferenced through a later synchronized, authenticated -checkpoint beyond the configured retention window. Candidate receipts are -encrypted in the local vault SQLite store. Defaults retain at least 10 versions -and 30 days. +immutable padded checkpoint, and downloads and authenticates it. It may record +and report apparently unreferenced content objects in encrypted local state, +but it never deletes remote content or journal objects. Retention time and a +single device's checkpoints cannot prove that an offline device will not later +publish a valid transaction referencing that ciphertext. This conservative boundary means the first reviewed release may use more quota, -but cannot destroy journal ancestry merely because a server cursor or timestamp -is misleading. +but cannot destroy content or journal ancestry merely because a server cursor, +timestamp, or incomplete device history is misleading. ## Product limitations @@ -274,26 +286,40 @@ namespace occur only in encrypted transactions. Onboarding offers Desktop & Documents as a separate macOS consent step after durable registration. Preflight checks the local key, remote reachability, and -current owner. A legacy plaintext Potassium owner blocks direct claiming until -verified migration; an external owner triggers a warning that prior remote -copies are outside this vault's purge boundary. Transfer UI uses phases and -Finder per-item progress rather than a fabricated percentage. +current owner. A legacy plaintext Potassium owner blocks direct claiming because +safe migration is not implemented; an external owner triggers a warning that +prior remote copies remain outside this vault and are not deleted. Transfer UI +uses phases and Finder per-item progress rather than a fabricated percentage. + +### Activation warning and feature gates -The feature flag defaults off: +Every activation route—new-vault creation, recovery-kit open, and iCloud +Keychain open—first displays the same warning that the unsupported feature can +cause complete, unrecoverable data loss, has no support, and leaves the user on +their own. The continuation control remains disabled for at least five seconds +measured with system uptime. Before that delay expires, activation performs no +remote preparation, device authentication, key import, or domain registration. + +The main feature flag defaults off: ```sh defaults write net.weavee.potassiumProvider EncryptedVaultsEnabled -bool YES ``` -Optional iCloud Keychain custody has a second independent gate: +This gate controls activation UI only. It is not a runtime kill switch for an +already registered v2 domain and it is not a production-readiness assertion. + +Optional iCloud Keychain custody has a second additive gate: ```sh defaults write net.weavee.potassiumProvider EncryptedVaultICloudKeychainEnabled -bool YES ``` -Enabling it is for development and security review, not a confidentiality -claim. The rollout gate is: format/crypto tests, read-only prototype, -single-device mutation testing, multi-device conflict testing, migration pilot, +The iCloud route also requires the main vault gate; enabling only the +convenience gate cannot bypass the vault gate or the warning. Both flags are for +development and security review, not a confidentiality claim. The rollout gate +is: format/crypto tests, read-only prototype, single-device mutation testing, +multi-device conflict testing, a designed and reviewed migration/rekey story, independent security audit, then explicit default enablement. ## Apple platform references diff --git a/doc/ENCRYPTED_VAULT_MIGRATION.md b/doc/ENCRYPTED_VAULT_MIGRATION.md deleted file mode 100644 index e7b14ea..0000000 --- a/doc/ENCRYPTED_VAULT_MIGRATION.md +++ /dev/null @@ -1,90 +0,0 @@ -# Encrypted Vault Migration - -Migration never converts a plaintext File Provider domain in place. The source -remains a separately registered legacy domain while a new encrypted domain gets -new logical UUIDs and random physical kDrive objects. - -## Preflight - -Before copying, inventory item and version counts, plaintext bytes, estimated -padding overhead, available quota, inaccessible/shared items, and active -Desktop/Documents ownership. Shared items and historical versions may require -separate access and purge decisions. Do not start a known-folder cutover until -both source and destination can be reconciled. - -The resumable migration journal is itself encrypted with the destination -vault's local-state key. Each source item moves monotonically through: - -```text -inventoried → encrypted → uploaded → committed → verified → source-purged -``` - -The record uses an opaque source identifier and stable destination UUID. -Ciphertext staging details, logical names, revisions, and digests are inside the -encrypted journal. A missing local ciphertext stage causes safe re-download and -re-encryption. Opaque upload tokens make retries idempotent. A transaction -commit retried after an interrupted local journal update is an idempotent -base-less upsert of the same logical item. - -## Copy and verification - -For a file: - -1. Download source plaintext to a File Provider/protected temporary file. -2. Stream-encrypt it into authenticated frames and immediately remove plaintext. -3. Upload randomized ciphertext. -4. Publish its encrypted logical transaction. -5. Download/decrypt the committed object to a separate protected temporary - file. -6. Compare exact size and SHA-256 against the authenticated staged result. -7. Mark the record verified. - -The coordinator checks the source revision before staging, before commit, after -the authenticated destination round-trip, and immediately before a separately -confirmed source purge. A changed source raises `sourceChanged`; the caller -re-inventories and recopies from the new base. Directories are committed before -their children and are marked verified only after their immutable transaction -is observed back through a complete journal synchronization. - -The copy/resume API has no source-deletion path. `purgeVerifiedSource` is a -separate explicit operation, refuses every state except `verified`, and -revalidates the exact source revision. Therefore source deletion cannot precede -an authenticated round-trip or erase an edit made after verification. - -## Desktop and Documents - -Known-folder migration must: - -1. pause/coordinate source ownership; -2. copy the initial tree; -3. reconcile a final source delta; -4. claim the encrypted logical `Private//Desktop` and `Documents`; -5. verify local availability through File Provider; -6. only then offer source purge. - -The encrypted-domain known-folder resolver creates these names as logical vault -transactions. No physical `Private/` path is sent to kDrive. - -## Plaintext purge - -Purge is separately confirmed and best effort. It should disable reachable -share links, remove accessible versions, trash and permanently delete live -items, and then re-inventory to report anything still reachable. kDrive APIs may -not expose server backups or every historical copy. - -Migration cannot retroactively hide prior server observations, backups, deleted -versions, external shares, downloaded copies, or recipient copies. Recovery -rotation does not revoke old root-key wrappers. Lost-device revocation requires -a replacement root-key epoch, complete verified re-encryption, and explicit -purge of the old vault where possible. - -## Failure policy - -- Quota exhaustion pauses without changing or deleting the source. -- Verification failure keeps both source and ciphertext for diagnosis/retry. -- Cancellation removes partial plaintext and leaves the last durable journal - state. -- Uncommitted ciphertext remains invisible and becomes eligible for - checkpoint-covered garbage collection after retention. -- Source purge failures remain visible as `verified`, never as - `source-purged`. diff --git a/doc/FILE_PROVIDER_LIFECYCLE.md b/doc/FILE_PROVIDER_LIFECYCLE.md index 0841351..9f1fc48 100644 --- a/doc/FILE_PROVIDER_LIFECYCLE.md +++ b/doc/FILE_PROVIDER_LIFECYCLE.md @@ -44,10 +44,11 @@ documentation is the source of truth for this callback and transition behavior. For encrypted domains, preflight requires a device root key, authenticated remote synchronization, and safe known-folder ownership before claiming. A -legacy plaintext Potassium owner requires verified migration instead of direct -claiming. Once macOS consents, ordinary create and modify callbacks encrypt each -revision before its opaque object-store upload. The app reports transfer phases -without treating a successful claim as proof that every initial file uploaded. +legacy plaintext Potassium owner blocks the claim because safe migration is not +implemented. Once macOS consents, ordinary create and modify callbacks encrypt +each revision before its opaque object-store upload. The app reports transfer +phases without treating a successful claim as proof that every initial file +uploaded. File Provider reuses existing directory children or creates them at those locations, keeps its default binary-compatibility symlink behavior, and manages diff --git a/doc/MUTATIONS.md b/doc/MUTATIONS.md index 7d12aa6..78a3117 100644 --- a/doc/MUTATIONS.md +++ b/doc/MUTATIONS.md @@ -152,4 +152,4 @@ Encrypted domains stream content into a new random-key ciphertext object first; a fixed-size authenticated transaction is published last as the visibility point. Create, modify, move, rename, favorite, duplicate, trash, restore, purge, and version restore operate on logical UUID items. Retry uses the opaque object -token for idempotency. See [Encrypted Vault Format v1](ENCRYPTED_VAULT.md). +token for idempotency. See [Encrypted Vault Format v2](ENCRYPTED_VAULT.md). diff --git a/doc/PERSISTENCE.md b/doc/PERSISTENCE.md index e200ed6..9e0f2cd 100644 --- a/doc/PERSISTENCE.md +++ b/doc/PERSISTENCE.md @@ -343,11 +343,11 @@ tests and fallback callers aligned with SQLite behavior. Encrypted domains use `EncryptedVaults.sqlite3` generations keyed by logical UUID strings and authenticated journal frontiers. Item and generation payloads -are AEAD-encrypted with the vault local-state key. The resumable migration -journal is an authenticated encrypted file. Activity and conflict rows retain -only opaque `ev1:` identifiers and fixed summaries. Domain JSON stores +are AEAD-encrypted with the vault local-state key. Activity and conflict rows +retain only opaque `ev2:` identifiers and fixed summaries. Domain JSON stores non-secret vault locators, format version, and key epoch, never root or recovery -keys. +keys. Cross-vault migration state is not persisted because migration and +destructive source purge are not implemented. Optional iCloud Keychain access is a separate synchronizable Keychain item, not app-group JSON or SQLite. It contains the root key and opaque remote diff --git a/doc/TESTING_AND_DEVELOPMENT.md b/doc/TESTING_AND_DEVELOPMENT.md index f60a69e..29c194d 100644 --- a/doc/TESTING_AND_DEVELOPMENT.md +++ b/doc/TESTING_AND_DEVELOPMENT.md @@ -215,9 +215,11 @@ Finder presentation and process RSS behavior that unit tests cannot establish. ## Encrypted vault gates Run cryptographic known-answer, envelope/frame tamper, fixed transaction, -randomized DAG replay, Merkle/rollback, streaming cancellation, migration -interruption, recovery, and request-leakage tests before enabling the -development flag. Capture all mocked requests and reject known logical names, -paths, types, dates, hashes, device names, or plaintext bytes. Benchmark 100,000 -items, 10,000 siblings, and multi-gigabyte files. Independent review with all -high-severity findings resolved is required before default enablement. +randomized DAG replay, cyclic-move rejection, trash provenance, collision-name +allocation, Merkle/rollback, padded-checkpoint, streaming cancellation, +fresh-revision restore, activation-warning, recovery, and request-leakage tests +before enabling the development flag. Capture all mocked requests and reject +known logical names, paths, types, dates, hashes, device names, or plaintext +bytes. Benchmark 100,000 items, 10,000 siblings, and multi-gigabyte files. Safe +migration/rekey design and independent review with all high-severity findings +resolved are required before default enablement. diff --git a/potassiumProvider/PotassiumProviderAppModel.swift b/potassiumProvider/PotassiumProviderAppModel.swift index f08b28a..e92bbb2 100644 --- a/potassiumProvider/PotassiumProviderAppModel.swift +++ b/potassiumProvider/PotassiumProviderAppModel.swift @@ -21,6 +21,20 @@ enum ProviderDriveAction: Equatable, Sendable { case syncingNow } +private enum PendingEncryptedVaultActivation { + case create(accountIdentifier: String, drive: KDriveDriveSummary) + case recoveryKit( + accountIdentifier: String, + drive: KDriveDriveSummary, + recoveryKitText: String + ) + case iCloud( + accountIdentifier: String, + drive: KDriveDriveSummary, + vaultID: VaultIdentifier + ) +} + @MainActor final class PotassiumProviderAppModel: ObservableObject { private static let log = ProviderLog.app @@ -74,11 +88,12 @@ final class PotassiumProviderAppModel: ObservableObject { private let vaultUserPresenceAuthorizer: any VaultUserPresenceAuthorizing private let vaultUXDefaults: UserDefaults private let computerNameProvider: @Sendable () throws -> String - private let currentDate: () -> Date + private let currentUptime: () -> TimeInterval private var pendingVaultAccountIdentifier: String? private var pendingVaultDriveID: Int? private var pendingVaultDriveName: String? - private var vaultRiskWarningStartedAt: Date? + private var pendingVaultActivation: PendingEncryptedVaultActivation? + private var vaultRiskWarningStartedAtUptime: TimeInterval? private var automaticallyLoadedDriveAccountIdentifiers: Set = [] private var fileProviderDomainChangeCancellable: AnyCancellable? @@ -110,7 +125,9 @@ final class PotassiumProviderAppModel: ObservableObject { forKey: ProviderConstants.encryptedVaultICloudKeychainFeatureFlag ), computerNameProvider: @escaping @Sendable () throws -> String = { try KDriveMachineNamespaceName.current() }, - currentDate: @escaping () -> Date = Date.init + currentUptime: @escaping () -> TimeInterval = { + ProcessInfo.processInfo.systemUptime + } ) { self.accountStore = accountStore ?? Self.makeDefaultAccountStore() self.domainStore = domainStore ?? Self.makeDefaultDomainStore() @@ -143,7 +160,7 @@ final class PotassiumProviderAppModel: ObservableObject { self.encryptedVaultICloudKeychainEnabled = encryptedVaultICloudKeychainEnabled self.computerNameProvider = computerNameProvider - self.currentDate = currentDate + self.currentUptime = currentUptime accounts = initialAccounts drivesByAccountIdentifier = initialDrivesByAccountIdentifier domains = initialDomains @@ -516,6 +533,43 @@ final class PotassiumProviderAppModel: ObservableObject { accountIdentifier: String, drive: KDriveDriveSummary ) async { + beginEncryptedVaultActivation(.create( + accountIdentifier: accountIdentifier, + drive: drive + )) + } + + func prepareOpenEncryptedVault( + accountIdentifier: String, + drive: KDriveDriveSummary, + recoveryKitText: String + ) { + beginEncryptedVaultActivation(.recoveryKit( + accountIdentifier: accountIdentifier, + drive: drive, + recoveryKitText: recoveryKitText + )) + } + + func prepareOpenEncryptedVaultFromICloud( + accountIdentifier: String, + drive: KDriveDriveSummary, + vaultID: VaultIdentifier + ) { + guard encryptedVaultICloudKeychainEnabled else { + errorMessage = "iCloud Keychain vault access is disabled until its security review is complete." + return + } + beginEncryptedVaultActivation(.iCloud( + accountIdentifier: accountIdentifier, + drive: drive, + vaultID: vaultID + )) + } + + private func beginEncryptedVaultActivation( + _ activation: PendingEncryptedVaultActivation + ) { guard encryptedVaultsEnabled else { errorMessage = "Encrypted vaults are disabled until the format passes the configured security-review gate." statusMessage = nil @@ -525,10 +579,16 @@ final class PotassiumProviderAppModel: ObservableObject { errorMessage = "Finish or cancel the current vault setup first." return } - pendingVaultAccountIdentifier = accountIdentifier - pendingVaultDriveID = drive.id - pendingVaultDriveName = drive.name - vaultRiskWarningStartedAt = currentDate() + pendingVaultActivation = activation + switch activation { + case .create(let accountIdentifier, let drive), + .recoveryKit(let accountIdentifier, let drive, _), + .iCloud(let accountIdentifier, let drive, _): + pendingVaultAccountIdentifier = accountIdentifier + pendingVaultDriveID = drive.id + pendingVaultDriveName = drive.name + } + vaultRiskWarningStartedAtUptime = currentUptime() vaultSetupStep = .unsupportedRiskWarning vaultSetupOutcome = VaultSetupOutcome() errorMessage = nil @@ -540,18 +600,43 @@ final class PotassiumProviderAppModel: ObservableObject { /// until `confirmEncryptedVault` succeeds. func acceptEncryptedVaultRiskAndPrepare() async { guard vaultSetupStep == .unsupportedRiskWarning, - let warningStartedAt = vaultRiskWarningStartedAt, - currentDate().timeIntervalSince(warningStartedAt) + let warningStartedAt = vaultRiskWarningStartedAtUptime, + currentUptime() - warningStartedAt >= Self.encryptedVaultRiskWarningDelaySeconds else { errorMessage = "Wait five seconds before continuing with this unsupported feature." return } - guard let accountIdentifier = pendingVaultAccountIdentifier, - let driveID = pendingVaultDriveID, - pendingVaultDriveName != nil else { + guard let activation = pendingVaultActivation else { errorMessage = "There is no pending encrypted vault setup." return } + vaultRiskWarningStartedAtUptime = nil + switch activation { + case .create(let accountIdentifier, let drive): + await prepareNewEncryptedVaultAfterRiskAcceptance( + accountIdentifier: accountIdentifier, + drive: drive + ) + case .recoveryKit(let accountIdentifier, let drive, let recoveryKitText): + await openEncryptedVaultAfterRiskAcceptance( + accountIdentifier: accountIdentifier, + drive: drive, + recoveryKitText: recoveryKitText + ) + case .iCloud(let accountIdentifier, let drive, let vaultID): + await openEncryptedVaultFromICloudAfterRiskAcceptance( + accountIdentifier: accountIdentifier, + drive: drive, + vaultID: vaultID + ) + } + } + + private func prepareNewEncryptedVaultAfterRiskAcceptance( + accountIdentifier: String, + drive: KDriveDriveSummary + ) async { + let driveID = drive.id let key = ProviderDriveKey(accountIdentifier: accountIdentifier, driveID: driveID) guard beginDriveAction(.addingToFiles, for: key) else { return } defer { endDriveAction(for: key) } @@ -564,13 +649,14 @@ final class PotassiumProviderAppModel: ObservableObject { ) let pending = try await service.prepareNewVault(driveID: driveID) pendingVaultProvisioning = pending - vaultRiskWarningStartedAt = nil vaultSetupStep = .overview vaultSetupOutcome = VaultSetupOutcome() errorMessage = nil statusMessage = "Review encrypted-vault protection before saving the recovery kit." } catch { vaultSetupStep = .unsupportedRiskWarning + vaultRiskWarningStartedAtUptime = + currentUptime() - Self.encryptedVaultRiskWarningDelaySeconds errorMessage = "Could not prepare the encrypted vault: \(error.localizedDescription)" statusMessage = nil } @@ -704,7 +790,7 @@ final class PotassiumProviderAppModel: ObservableObject { } func resumeVaultSetup(for configuration: ProviderDomainConfiguration) { - guard configuration.encryptionMode == .opaqueVaultV1, + guard configuration.encryptionMode == .opaqueVaultV2, let vault = configuration.vault else { errorMessage = "The selected domain is not an encrypted vault." return @@ -730,15 +816,11 @@ final class PotassiumProviderAppModel: ObservableObject { errorMessage = nil } - func openEncryptedVault( + private func openEncryptedVaultAfterRiskAcceptance( accountIdentifier: String, drive: KDriveDriveSummary, recoveryKitText: String ) async { - guard encryptedVaultsEnabled else { - errorMessage = "Encrypted vaults are disabled until the format passes the configured security-review gate." - return - } let key = ProviderDriveKey(accountIdentifier: accountIdentifier, driveID: drive.id) guard beginDriveAction(.addingToFiles, for: key) else { return } defer { endDriveAction(for: key) } @@ -760,23 +842,30 @@ final class PotassiumProviderAppModel: ObservableObject { vaultConfiguration: vaultConfiguration ) await refreshVaultAccessState() + vaultSetupOutcome = VaultSetupOutcome( + configuration: domains.first(where: { + $0.vault?.vaultIdentifier == vaultConfiguration.vaultIdentifier + }), + cloudAccessStatus: .disabled, + recoveryKitVerified: true + ) + clearPendingVault() + vaultSetupStep = .complete statusMessage = "Opened the encrypted vault on this device." errorMessage = nil } catch { + vaultRiskWarningStartedAtUptime = + currentUptime() - Self.encryptedVaultRiskWarningDelaySeconds errorMessage = "Could not open the encrypted vault: \(error.localizedDescription)" statusMessage = nil } } - func openEncryptedVaultFromICloud( + private func openEncryptedVaultFromICloudAfterRiskAcceptance( accountIdentifier: String, drive: KDriveDriveSummary, vaultID: VaultIdentifier ) async { - guard encryptedVaultICloudKeychainEnabled else { - errorMessage = "iCloud Keychain vault access is disabled until its security review is complete." - return - } let key = ProviderDriveKey( accountIdentifier: accountIdentifier, driveID: drive.id @@ -809,9 +898,20 @@ final class PotassiumProviderAppModel: ObservableObject { vaultConfiguration: vaultConfiguration ) await refreshVaultAccessState() + vaultSetupOutcome = VaultSetupOutcome( + configuration: domains.first(where: { + $0.vault?.vaultIdentifier == vaultConfiguration.vaultIdentifier + }), + cloudAccessStatus: .available, + recoveryKitVerified: true + ) + clearPendingVault() + vaultSetupStep = .complete statusMessage = "Authenticated the iCloud Keychain record and opened the vault on this device." errorMessage = nil } catch { + vaultRiskWarningStartedAtUptime = + currentUptime() - Self.encryptedVaultRiskWarningDelaySeconds errorMessage = "Could not open the vault from iCloud Keychain: \(error.localizedDescription)" statusMessage = nil } @@ -1096,7 +1196,7 @@ final class PotassiumProviderAppModel: ObservableObject { try await domainStore.save(namespacedConfiguration) replaceDomainConfiguration(namespacedConfiguration) - if configuration.encryptionMode == .opaqueVaultV1 { + if configuration.encryptionMode == .opaqueVaultV2 { let vault = try await makeEncryptedVaultService( configuration: namespacedConfiguration, accessToken: token.accessToken @@ -1140,7 +1240,7 @@ final class PotassiumProviderAppModel: ObservableObject { knownFolderTransferPhasesByDomainIdentifier[ configuration.domainIdentifier ] = .connectedUploading - if configuration.encryptionMode == .opaqueVaultV1 { + if configuration.encryptionMode == .opaqueVaultV2 { statusMessage = "Desktop and Documents are connected to \(configuration.displayName). Finder shows initial encrypted-upload progress." } else { statusMessage = "Desktop and Documents now sync with \(configuration.displayName) in kDrive /Private/\(plaintextNamespaceName ?? "")." @@ -1236,6 +1336,12 @@ final class PotassiumProviderAppModel: ObservableObject { } func userVisibleRootURL(for configuration: ProviderDomainConfiguration) async -> URL? { + guard configuration.encryptionMode != .opaqueVaultV1 else { + errorMessage = KnownFolderSetupError.unsupportedEncryptedVaultFormat + .localizedDescription + statusMessage = nil + return nil + } let key = driveKey(for: configuration) guard beginDriveAction(.showingInFiles, for: key) else { return nil } defer { endDriveAction(for: key) } @@ -1262,6 +1368,12 @@ final class PotassiumProviderAppModel: ObservableObject { } func syncNow(_ configuration: ProviderDomainConfiguration) async { + guard configuration.encryptionMode != .opaqueVaultV1 else { + errorMessage = KnownFolderSetupError.unsupportedEncryptedVaultFormat + .localizedDescription + statusMessage = nil + return + } let key = driveKey(for: configuration) guard beginDriveAction(.syncingNow, for: key) else { return } defer { endDriveAction(for: key) } @@ -1479,7 +1591,7 @@ final class PotassiumProviderAppModel: ObservableObject { driveID: driveID, driveName: driveName, knownFolderLayout: .machineNamespace, - encryptionMode: .opaqueVaultV1, + encryptionMode: .opaqueVaultV2, vault: vaultConfiguration, createdAt: now, updatedAt: now @@ -1561,7 +1673,8 @@ final class PotassiumProviderAppModel: ObservableObject { pendingVaultAccountIdentifier = nil pendingVaultDriveID = nil pendingVaultDriveName = nil - vaultRiskWarningStartedAt = nil + pendingVaultActivation = nil + vaultRiskWarningStartedAtUptime = nil } private func advanceVaultSetupAfterRegistration( @@ -1596,6 +1709,9 @@ final class PotassiumProviderAppModel: ObservableObject { private func evaluateKnownFolderPreflight( for configuration: ProviderDomainConfiguration ) async throws -> KnownFolderPreflight { + guard configuration.encryptionMode != .opaqueVaultV1 else { + throw KnownFolderSetupError.unsupportedEncryptedVaultFormat + } let owner = try await domainRegistrar.knownFolderOwner() let ownership: KnownFolderPreflight.Ownership if let owner { @@ -1618,7 +1734,7 @@ final class PotassiumProviderAppModel: ObservableObject { let vaultIsUnlocked: Bool let remoteIsReachable: Bool - if configuration.encryptionMode == .opaqueVaultV1, + if configuration.encryptionMode == .opaqueVaultV2, let vaultID = configuration.vault?.vaultIdentifier { let rootKey = try await vaultKeyStore.loadRootKey(vaultID: vaultID) vaultIsUnlocked = rootKey != nil @@ -1678,7 +1794,7 @@ final class PotassiumProviderAppModel: ObservableObject { ) var localStatuses: [VaultIdentifier: VaultLocalKeyStatus] = [:] - for configuration in domains where configuration.encryptionMode == .opaqueVaultV1 { + for configuration in domains where configuration.encryptionMode == .opaqueVaultV2 { guard let vaultID = configuration.vault?.vaultIdentifier else { continue } @@ -1722,7 +1838,7 @@ final class PotassiumProviderAppModel: ObservableObject { } var statuses: [VaultIdentifier: VaultCloudAccessStatus] = [:] - for configuration in domains where configuration.encryptionMode == .opaqueVaultV1 { + for configuration in domains where configuration.encryptionMode == .opaqueVaultV2 { guard let vault = configuration.vault else { continue } let matching = groupedByVault[vault.vaultIdentifier] ?? [] guard matching.count <= 1 else { @@ -2001,6 +2117,14 @@ final class PotassiumProviderAppModel: ObservableObject { try await domainStore.save(configurations[index]) } + // Preserve the saved record so the user can explicitly remove it, + // but never re-register an incompatible v1 domain with File + // Provider. An already registered system domain remains inert + // because every extension runtime load also fails closed. + guard configurations[index].encryptionMode != .opaqueVaultV1 else { + continue + } + do { try await domainRegistrar.addDomain(for: configurations[index]) } catch { @@ -2022,7 +2146,7 @@ final class PotassiumProviderAppModel: ObservableObject { ) -> [String: String] { let baseNames = Dictionary(uniqueKeysWithValues: configurations.map { let driveName = ProviderDomainConfiguration.finderDisplayName(forDriveName: $0.driveName) - let displayName = $0.encryptionMode == .opaqueVaultV1 + let displayName = $0.encryptionMode == .opaqueVaultV2 ? "\(driveName) — Encrypted" : driveName return ($0.domainIdentifier, displayName) @@ -2191,15 +2315,18 @@ private enum KnownFolderSetupError: Error, LocalizedError { case legacyMigrationRequired case partialClaimRequiresRepair case preflightFailed + case unsupportedEncryptedVaultFormat var errorDescription: String? { switch self { case .legacyMigrationRequired: - return "Desktop and Documents are owned by a legacy plaintext Potassium domain. Complete verified encrypted migration before switching ownership." + return "Desktop and Documents are owned by a legacy plaintext Potassium domain. Safe encrypted migration is not implemented, so ownership cannot switch to this vault." case .partialClaimRequiresRepair: return "Only one known folder is currently claimed. Stop the partial configuration before enabling both folders again." case .preflightFailed: return "Desktop and Documents protection did not pass its unlock and reachability checks." + case .unsupportedEncryptedVaultFormat: + return "This experimental encrypted-vault format is unsupported. Export any recoverable data with an older build; this app will not activate or mutate it." } } } diff --git a/potassiumProvider/ProviderSetupView.swift b/potassiumProvider/ProviderSetupView.swift index a88ed02..c687729 100644 --- a/potassiumProvider/ProviderSetupView.swift +++ b/potassiumProvider/ProviderSetupView.swift @@ -20,12 +20,13 @@ struct ProviderDriveDescriptor: Identifiable, Equatable { let configurations: [ProviderDomainConfiguration] var configuration: ProviderDomainConfiguration? { - configurations.first(where: { $0.encryptionMode == .opaqueVaultV1 }) + configurations.first(where: { $0.encryptionMode == .opaqueVaultV2 }) + ?? configurations.first(where: { $0.encryptionMode == .opaqueVaultV1 }) ?? configurations.first } var encryptedConfiguration: ProviderDomainConfiguration? { - configurations.first { $0.encryptionMode == .opaqueVaultV1 } + configurations.first { $0.encryptionMode == .opaqueVaultV2 } } var legacyConfigurations: [ProviderDomainConfiguration] { @@ -723,13 +724,11 @@ private struct ProviderDriveManagementView: View { if model.encryptedVaultICloudKeychainEnabled { ForEach(model.cloudAccessCandidates(driveID: remote.id)) { candidate in Button { - Task { - await model.openEncryptedVaultFromICloud( - accountIdentifier: key.accountIdentifier, - drive: remote, - vaultID: candidate.vaultID - ) - } + model.prepareOpenEncryptedVaultFromICloud( + accountIdentifier: key.accountIdentifier, + drive: remote, + vaultID: candidate.vaultID + ) } label: { Label { VStack(alignment: .leading, spacing: 2) { @@ -745,7 +744,7 @@ private struct ProviderDriveManagementView: View { } } .buttonStyle(.borderedProminent) - .disabled(isBusy) + .disabled(isBusy || model.encryptedVaultsEnabled == false) .accessibilityIdentifier("drive.openICloudVault") } @@ -792,10 +791,15 @@ private struct ProviderDriveManagementView: View { if let configuration = descriptor.configuration { LabeledContent( "Storage", - value: configuration.encryptionMode == .opaqueVaultV1 - ? "End-to-end encrypted vault" - : "Legacy plaintext migration source" + value: storageDescription(for: configuration) ) + if configuration.encryptionMode == .opaqueVaultV1 { + Label( + "Experimental vault v1 is unsupported and is blocked from activation and mutation.", + systemImage: "lock.trianglebadge.exclamationmark" + ) + .foregroundStyle(.red) + } Button { Task { if let url = await model.userVisibleRootURL(for: configuration) { @@ -817,7 +821,9 @@ private struct ProviderDriveManagementView: View { ) #endif } - .disabled(isBusy) + .disabled( + isBusy || configuration.encryptionMode == .opaqueVaultV1 + ) .accessibilityIdentifier("drive.showInFiles") Button { @@ -829,7 +835,9 @@ private struct ProviderDriveManagementView: View { action: .syncingNow ) } - .disabled(isBusy) + .disabled( + isBusy || configuration.encryptionMode == .opaqueVaultV1 + ) .accessibilityIdentifier("drive.syncNow") } @@ -849,14 +857,15 @@ private struct ProviderDriveManagementView: View { .foregroundStyle(.orange) } } header: { - Text("Migration Sources") + Text("Legacy Plaintext Domains") } footer: { - Text("Legacy domains remain available while encrypted migration is verified. Source purge is a separate destructive workflow.") + Text("Safe cross-vault migration and destructive source purge are not implemented. Legacy domains remain separate.") } } #if os(macOS) - if let configuration = descriptor.configuration { + if let configuration = descriptor.configuration, + configuration.encryptionMode != .opaqueVaultV1 { knownFolderSection(configuration) } #endif @@ -1011,6 +1020,19 @@ private struct ProviderDriveManagementView: View { } } + private func storageDescription( + for configuration: ProviderDomainConfiguration + ) -> String { + switch configuration.encryptionMode { + case .legacyPlaintext: + "Legacy plaintext domain" + case .opaqueVaultV1: + "Unsupported experimental encrypted vault v1" + case .opaqueVaultV2: + "End-to-end encrypted vault v2" + } + } + #if os(macOS) private func knownFolderSection(_ configuration: ProviderDomainConfiguration) -> some View { let state = model.knownFolderSyncState(for: configuration) @@ -1058,7 +1080,7 @@ private struct ProviderDriveManagementView: View { } header: { Text("Desktop & Documents") } footer: { - Text(configuration.encryptionMode == .opaqueVaultV1 + Text(configuration.encryptionMode == .opaqueVaultV2 ? "macOS manages both folders together. Their contents are encrypted before kDrive upload; Finder shows per-item transfer progress." : "macOS manages Desktop and Documents together under kDrive \(remotePath).") } @@ -1250,6 +1272,10 @@ private struct EncryptedVaultSetupFlow: View { ) Text("If you decide to continue, you are entirely on your own.") .fontWeight(.semibold) + Text( + "On a new device, the app cannot independently prove that kDrive presented the newest vault history because no external history witness exists." + ) + .foregroundStyle(.secondary) } .accessibilityIdentifier("vault.unsupportedRiskWarning") @@ -1564,15 +1590,13 @@ private struct VaultOpenView: View { } ToolbarItem(placement: .confirmationAction) { Button("Open") { - Task { - await model.openEncryptedVault( - accountIdentifier: accountIdentifier, - drive: drive, - recoveryKitText: recoveryKit - ) - if model.errorMessage == nil { - dismiss() - } + model.prepareOpenEncryptedVault( + accountIdentifier: accountIdentifier, + drive: drive, + recoveryKitText: recoveryKit + ) + if model.errorMessage == nil { + dismiss() } } .disabled(recoveryKit.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) @@ -1726,7 +1750,7 @@ private struct KnownFolderPreflightSummary: View { .foregroundStyle(.orange) case .legacyPotassium: Label( - "Complete verified encrypted migration before releasing the plaintext Potassium domain.", + "Safe encrypted migration is not implemented. Keep the plaintext Potassium domain as owner.", systemImage: "lock.trianglebadge.exclamationmark" ) .foregroundStyle(.orange) diff --git a/potassiumProviderActions/ProviderActionViews.swift b/potassiumProviderActions/ProviderActionViews.swift index f2a3a0f..746bef1 100644 --- a/potassiumProviderActions/ProviderActionViews.swift +++ b/potassiumProviderActions/ProviderActionViews.swift @@ -23,7 +23,7 @@ struct ProviderActionRootView: View { ContentUnavailableView( "Encrypted Sharing Unavailable", systemImage: "person.crop.circle.badge.xmark", - description: Text("Recipient-key sharing is not supported for encrypted vaults in version 1.") + description: Text("Recipient-key sharing is not supported for encrypted vaults in version 2.") ) case .versionHistory: VaultVersionHistoryActionView(model: model, item: item) diff --git a/potassiumProviderFileProvider/FileProviderEnumerator.swift b/potassiumProviderFileProvider/FileProviderEnumerator.swift index a8a8e90..170486f 100644 --- a/potassiumProviderFileProvider/FileProviderEnumerator.swift +++ b/potassiumProviderFileProvider/FileProviderEnumerator.swift @@ -78,7 +78,7 @@ final class FileProviderEnumerator: NSObject, NSFileProviderEnumerator { let configuration = try await FileProviderRuntime.loadConfiguration(domain: domain) domainIdentifier = configuration.domainIdentifier driveID = configuration.driveID - if configuration.encryptionMode == .opaqueVaultV1 { + if configuration.encryptionMode == .opaqueVaultV2 { let runtime = try await FileProviderRuntime.load(domain: domain) guard let vault = runtime.encryptedVault else { throw NSFileProviderError(.notAuthenticated) diff --git a/potassiumProviderFileProvider/FileProviderItem.swift b/potassiumProviderFileProvider/FileProviderItem.swift index 0acdbcd..7aa18f3 100644 --- a/potassiumProviderFileProvider/FileProviderItem.swift +++ b/potassiumProviderFileProvider/FileProviderItem.swift @@ -35,7 +35,7 @@ final class FileProviderItem: NSObject, NSFileProviderItemProtocol { self.filename = configuration.displayName self.contentType = .folder if let vault = configuration.vault, - configuration.encryptionMode == .opaqueVaultV1 { + configuration.encryptionMode == .opaqueVaultV2 { self.itemVersion = NSFileProviderItemVersion( contentVersion: VaultRevision( hashing: Data("root-content:\(vault.vaultIdentifier.rawValue.uuidString)".utf8) diff --git a/potassiumProviderFileProvider/FileProviderRuntime.swift b/potassiumProviderFileProvider/FileProviderRuntime.swift index 068e391..fcf477d 100644 --- a/potassiumProviderFileProvider/FileProviderRuntime.swift +++ b/potassiumProviderFileProvider/FileProviderRuntime.swift @@ -65,7 +65,7 @@ struct FileProviderRuntime: Sendable { let sqliteStore = try makeSQLiteStore() let remote = PotassiumKDriveService(bearerToken: token.accessToken) let encryptedVault: (any EncryptedVaultProviding)? - if configuration.encryptionMode == .opaqueVaultV1 { + if configuration.encryptionMode == .opaqueVaultV2 { guard let vaultConfiguration = configuration.vault, vaultConfiguration.formatVersion == VaultFormat.currentVersion, vaultConfiguration.remoteLayout != nil else { @@ -136,6 +136,10 @@ struct FileProviderRuntime: Sendable { FileProviderLog.runtime.error("missing configuration for domain(\(domain.identifier.rawValue, privacy: .public)); returning notAuthenticated") throw NSFileProviderError(.notAuthenticated) } + guard configuration.encryptionMode != .opaqueVaultV1 else { + FileProviderLog.runtime.error("unsupported experimental encrypted vault v1 for domain(\(domain.identifier.rawValue, privacy: .public)); returning cannotSynchronize") + throw NSFileProviderError(.cannotSynchronize) + } FileProviderLog.runtime.debug("loaded configuration for domain(\(configuration.domainIdentifier, privacy: .public)) driveID(\(configuration.driveID, privacy: .public)) displayName(\(configuration.displayName, privacy: .private))") return configuration } diff --git a/potassiumProviderFileProvider/ProviderEventRecording.swift b/potassiumProviderFileProvider/ProviderEventRecording.swift index 604504f..2856df7 100644 --- a/potassiumProviderFileProvider/ProviderEventRecording.swift +++ b/potassiumProviderFileProvider/ProviderEventRecording.swift @@ -7,7 +7,7 @@ enum ProviderEventRecorder { static func saveConflict(_ event: KDriveConflictEvent, runtime: FileProviderRuntime) async { guard let eventStore = runtime.eventStore else { return } var event = event - if runtime.configuration.encryptionMode == .opaqueVaultV1 { + if runtime.configuration.encryptionMode == .opaqueVaultV2 { event.originalItemName = nil event.originalItemPath = nil event.conflictItemName = nil @@ -50,7 +50,7 @@ enum ProviderEventRecorder { remoteRequestID: String? = nil ) async { let storesOpaqueSummaryOnly = - runtime.configuration.encryptionMode == .opaqueVaultV1 + runtime.configuration.encryptionMode == .opaqueVaultV2 await recordActivity( kind: kind, eventStore: runtime.eventStore, diff --git a/potassiumProviderTests/VaultCloudAccessTests.swift b/potassiumProviderTests/VaultCloudAccessTests.swift index 23c5904..9dc02df 100644 --- a/potassiumProviderTests/VaultCloudAccessTests.swift +++ b/potassiumProviderTests/VaultCloudAccessTests.swift @@ -175,7 +175,7 @@ struct VaultCloudAccessTests { displayName: "Encrypted", driveID: 42, driveName: "Drive", - encryptionMode: .opaqueVaultV1, + encryptionMode: .opaqueVaultV2, vault: pending.vaultConfiguration ) let localStore = try VaultSQLiteStore( @@ -312,7 +312,7 @@ struct VaultCloudAccessTests { displayName: "Encrypted", driveID: 42, driveName: "Drive", - encryptionMode: .opaqueVaultV1, + encryptionMode: .opaqueVaultV2, vault: pending.vaultConfiguration ) let localStore = try VaultSQLiteStore( diff --git a/potassiumProviderTests/VaultCryptographyTests.swift b/potassiumProviderTests/VaultCryptographyTests.swift index 93f0ed9..9086c42 100644 --- a/potassiumProviderTests/VaultCryptographyTests.swift +++ b/potassiumProviderTests/VaultCryptographyTests.swift @@ -41,7 +41,7 @@ struct VaultCryptographyTests { == identifier ) #expect(VaultItemIdentifier(fileProviderIdentifier: "42") == nil) - #expect(VaultItemIdentifier(fileProviderIdentifier: "ev1:not-base64") == nil) + #expect(VaultItemIdentifier(fileProviderIdentifier: "ev2:not-base64") == nil) } @Test func encryptedEnvelopeRoundTripsAndBindsContext() throws { @@ -158,6 +158,7 @@ struct VaultCryptographyTests { rootKey: rootKey, recoverySecret: recoverySecret ) + #expect(bootstrap.starts(with: Data("KPB2".utf8))) let unlocked = try VaultBootstrap.unlock( bootstrap, @@ -197,7 +198,7 @@ struct VaultCryptographyTests { let decoded = try VaultRecoveryKit(encoded: kit.encoded.lowercased()) #expect(decoded == kit) - #expect(kit.encoded.hasPrefix("KPV1-")) + #expect(kit.encoded.hasPrefix("KPV2-")) var mistyped = kit.encoded let index = mistyped.index(before: mistyped.endIndex) @@ -217,6 +218,85 @@ struct VaultCryptographyTests { #expect(detectsTypingError) } + @Test func checkpointsHideExactMetadataSizeAndRejectUnpaddedObjects() throws { + let vaultID = VaultIdentifier() + let rootKey = try VaultKeyMaterial.random() + let token = try VaultCryptography.makeObjectToken() + let empty = VaultCheckpoint( + frontier: VaultFrontier(), + items: [], + transactionMerkleRoot: Data(repeating: 0x11, count: 32), + createdAt: Date(timeIntervalSince1970: 1) + ) + let small = VaultCheckpoint( + frontier: VaultFrontier(), + items: [VaultItem( + parentID: nil, + filename: "private-name.txt", + isDirectory: false, + createdAt: Date(timeIntervalSince1970: 1), + modifiedAt: Date(timeIntervalSince1970: 1), + contentRevision: VaultRevision( + data: Data(repeating: 0x22, count: VaultRevision.byteCount) + )!, + metadataRevision: VaultRevision( + data: Data(repeating: 0x33, count: VaultRevision.byteCount) + )! + )], + transactionMerkleRoot: Data(repeating: 0x44, count: 32), + createdAt: Date(timeIntervalSince1970: 2) + ) + + let emptyEnvelope = try VaultPaddedCheckpointCodec.seal( + empty, + objectToken: token, + rootKey: rootKey, + vaultID: vaultID + ) + let smallEnvelope = try VaultPaddedCheckpointCodec.seal( + small, + objectToken: token, + rootKey: rootKey, + vaultID: vaultID + ) + #expect(emptyEnvelope.count == smallEnvelope.count) + #expect(emptyEnvelope.count > VaultFormat.minimumCheckpointPayloadSize) + #expect(emptyEnvelope.range(of: Data("private-name.txt".utf8)) == nil) + #expect(try VaultPaddedCheckpointCodec.open( + smallEnvelope, + objectToken: token, + rootKey: rootKey, + vaultID: vaultID + ) == small) + + let unpaddedEnvelope = try VaultCryptography.seal( + small, + role: .checkpoint, + objectToken: token, + rootKey: rootKey, + vaultID: vaultID + ) + #expect(throws: VaultJournalError.malformedPaddedCheckpoint) { + try VaultPaddedCheckpointCodec.open( + unpaddedEnvelope, + objectToken: token, + rootKey: rootKey, + vaultID: vaultID + ) + } + + var tampered = smallEnvelope + tampered[tampered.index(before: tampered.endIndex)] ^= 0x01 + #expect(throws: VaultCryptoError.authenticationFailed) { + try VaultPaddedCheckpointCodec.open( + tampered, + objectToken: token, + rootKey: rootKey, + vaultID: vaultID + ) + } + } + @Test( arguments: [ Data(), diff --git a/potassiumProviderTests/VaultDomainConfigurationTests.swift b/potassiumProviderTests/VaultDomainConfigurationTests.swift index 25a0b1b..47e0cf0 100644 --- a/potassiumProviderTests/VaultDomainConfigurationTests.swift +++ b/potassiumProviderTests/VaultDomainConfigurationTests.swift @@ -3,6 +3,14 @@ import Foundation import Testing struct VaultDomainConfigurationTests { + @Test func v2FormatMarkersAreCurrentAndV1ModeIsFailClosed() { + #expect(VaultFormat.currentVersion == 2) + #expect(VaultFormat.fileProviderIdentifierPrefix == "ev2:") + #expect(ProviderEncryptionMode.opaqueVaultV2.isSupportedEncryptedVault) + #expect(ProviderEncryptionMode.opaqueVaultV1.isEncryptedVault) + #expect(ProviderEncryptionMode.opaqueVaultV1.isSupportedEncryptedVault == false) + } + @Test func legacyConfigurationDefaultsToPlaintextMode() throws { let json = """ { @@ -44,7 +52,7 @@ struct VaultDomainConfigurationTests { displayName: "Private", driveID: 9, driveName: "Drive", - encryptionMode: .opaqueVaultV1, + encryptionMode: .opaqueVaultV2, vault: vault, createdAt: Date(timeIntervalSince1970: 100), updatedAt: Date(timeIntervalSince1970: 200) @@ -55,4 +63,49 @@ struct VaultDomainConfigurationTests { #expect(loaded == configuration) #expect(loaded?.vault == vault) } + + @Test func v2ModeCannotOverrideAnIncompatibleEmbeddedFormat() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("VaultDomainConfigurationTests-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: directory) } + let vaultID = VaultIdentifier() + let rootKey = try VaultKeyMaterial.random() + let configuration = ProviderDomainConfiguration( + domainIdentifier: "mismatched-format-domain", + displayName: "Unsupported", + driveID: 9, + driveName: "Drive", + encryptionMode: .opaqueVaultV2, + vault: ProviderVaultConfiguration( + vaultIdentifier: vaultID, + vaultRootFileID: 100, + vaultHeaderFileID: 101, + formatVersion: 1, + remoteLayout: VaultBootstrap.RemoteLayout( + contentContainerID: 102, + journalContainerID: 103, + checkpointContainerID: 104, + checkpointToken: "unsupported-format-token" + ) + ) + ) + let localStore = try VaultSQLiteStore( + databaseURL: directory.appendingPathComponent("vault.sqlite3"), + domainIdentifier: configuration.domainIdentifier, + vaultID: vaultID, + rootKey: rootKey + ) + + #expect(throws: EncryptedVaultError.missingConfiguration) { + _ = try EncryptedVaultService( + configuration: configuration, + rootKey: rootKey, + deviceID: UUID(), + objectStore: InMemoryOpaqueObjectStore(), + localStore: localStore, + keyStore: InMemoryVaultKeyStore(), + temporaryDirectoryURL: directory + ) + } + } } diff --git a/potassiumProviderTests/VaultJournalTests.swift b/potassiumProviderTests/VaultJournalTests.swift index 37d900e..418cd5f 100644 --- a/potassiumProviderTests/VaultJournalTests.swift +++ b/potassiumProviderTests/VaultJournalTests.swift @@ -189,6 +189,177 @@ struct VaultJournalTests { }) } + @Test func concurrentDirectoryMovesCannotCreateAParentCycle() throws { + let firstDirectory = Self.item( + named: "First", + id: VaultItemIdentifier( + rawValue: UUID(uuidString: "AAAAAAAA-0000-0000-0000-000000000001")! + ), + isDirectory: true + ) + let secondDirectory = Self.item( + named: "Second", + id: VaultItemIdentifier( + rawValue: UUID(uuidString: "BBBBBBBB-0000-0000-0000-000000000001")! + ), + isDirectory: true + ) + let createFirst = VaultTransaction( + id: UUID(uuidString: "10000000-0000-0000-0000-000000000001")!, + parents: VaultFrontier(), + deviceID: UUID(), + operation: .upsert(firstDirectory) + ) + let createSecond = VaultTransaction( + id: UUID(uuidString: "20000000-0000-0000-0000-000000000001")!, + parents: VaultFrontier(), + deviceID: UUID(), + operation: .upsert(secondDirectory) + ) + let sharedFrontier = VaultFrontier(transactionIDs: [ + createFirst.id, + createSecond.id, + ]) + var firstMoved = firstDirectory + firstMoved.parentID = secondDirectory.id + firstMoved.metadataRevision = try VaultRevisionDigests.metadata(for: firstMoved) + var secondMoved = secondDirectory + secondMoved.parentID = firstDirectory.id + secondMoved.metadataRevision = try VaultRevisionDigests.metadata(for: secondMoved) + let moveFirst = VaultTransaction( + id: UUID(uuidString: "30000000-0000-0000-0000-000000000001")!, + parents: sharedFrontier, + deviceID: UUID(), + baseItem: firstDirectory, + operation: .upsert(firstMoved) + ) + let moveSecond = VaultTransaction( + id: UUID(uuidString: "40000000-0000-0000-0000-000000000001")!, + parents: sharedFrontier, + deviceID: UUID(), + baseItem: secondDirectory, + operation: .upsert(secondMoved) + ) + + let expected = try VaultJournalReducer.reduce([ + createFirst, + createSecond, + moveFirst, + moveSecond, + ]) + for replayOrder in [ + [moveSecond, createFirst, moveFirst, createSecond], + [createSecond, moveFirst, createFirst, moveSecond], + ] { + #expect(try VaultJournalReducer.reduce(replayOrder) == expected) + } + #expect(expected.items[firstDirectory.id]?.parentID == secondDirectory.id) + #expect(expected.items[secondDirectory.id]?.parentID == nil) + #expect(expected.conflicts.contains { + $0.kind == .invalidMove && $0.itemID == secondDirectory.id + }) + } + + @Test func restoringFolderPreservesIndependentlyTrashedDescendant() throws { + let folder = Self.item(named: "Folder", isDirectory: true) + let child = Self.item(named: "child.txt", parentID: folder.id) + let createFolder = VaultTransaction( + id: UUID(uuidString: "10000000-0000-0000-0000-000000000010")!, + parents: VaultFrontier(), + deviceID: UUID(), + operation: .upsert(folder) + ) + let createChild = VaultTransaction( + id: UUID(uuidString: "20000000-0000-0000-0000-000000000010")!, + parents: VaultFrontier(transactionIDs: [createFolder.id]), + deviceID: UUID(), + operation: .upsert(child) + ) + let trashChild = VaultTransaction( + id: UUID(uuidString: "30000000-0000-0000-0000-000000000010")!, + parents: VaultFrontier(transactionIDs: [createChild.id]), + deviceID: UUID(), + baseItem: child, + operation: .trash( + itemID: child.id, + baseContentRevision: child.contentRevision, + baseMetadataRevision: child.metadataRevision + ) + ) + let trashFolder = VaultTransaction( + id: UUID(uuidString: "40000000-0000-0000-0000-000000000010")!, + parents: VaultFrontier(transactionIDs: [trashChild.id]), + deviceID: UUID(), + baseItem: folder, + operation: .trash( + itemID: folder.id, + baseContentRevision: folder.contentRevision, + baseMetadataRevision: folder.metadataRevision + ) + ) + let restoreFolder = VaultTransaction( + id: UUID(uuidString: "50000000-0000-0000-0000-000000000010")!, + parents: VaultFrontier(transactionIDs: [trashFolder.id]), + deviceID: UUID(), + operation: .restore(itemID: folder.id, parentID: nil) + ) + + let state = try VaultJournalReducer.reduce([ + restoreFolder, + createChild, + trashFolder, + createFolder, + trashChild, + ]) + #expect(state.items[folder.id]?.isTrashed == false) + #expect(state.items[folder.id]?.trashRootID == nil) + #expect(state.items[child.id]?.isTrashed == true) + #expect(state.items[child.id]?.trashRootID == child.id) + } + + @Test func siblingConflictAllocatorSkipsExistingGeneratedName() throws { + let winner = Self.item( + named: "report.txt", + id: VaultItemIdentifier( + rawValue: UUID(uuidString: "AAAAAAAA-0000-0000-0000-000000000020")! + ) + ) + let loser = Self.item( + named: "report.txt", + id: VaultItemIdentifier( + rawValue: UUID(uuidString: "BBBBBBBB-0000-0000-0000-000000000020")! + ) + ) + let reserved = Self.item( + named: "report (conflict bbbbbbbb).txt", + id: VaultItemIdentifier( + rawValue: UUID(uuidString: "CCCCCCCC-0000-0000-0000-000000000020")! + ) + ) + let transactionIDs = [ + UUID(uuidString: "10000000-0000-0000-0000-000000000020")!, + UUID(uuidString: "20000000-0000-0000-0000-000000000020")!, + UUID(uuidString: "30000000-0000-0000-0000-000000000020")!, + ] + let transactions = zip(transactionIDs, [winner, loser, reserved]).map { + transactionID, item in + VaultTransaction( + id: transactionID, + parents: VaultFrontier(), + deviceID: UUID(), + operation: .upsert(item) + ) + } + + let expected = try VaultJournalReducer.reduce(transactions) + #expect(expected.items[loser.id]?.filename == "report (conflict bbbbbbbb-2).txt") + let normalizedNames = expected.items.values.map { + $0.filename.precomposedStringWithCanonicalMapping.lowercased() + } + #expect(Set(normalizedNames).count == normalizedNames.count) + #expect(try VaultJournalReducer.reduce(Array(transactions.reversed())) == expected) + } + @Test func merkleProofAllowsTrustedFrontierCompactionButRejectsWrongRoot() throws { let transaction = VaultTransaction( parents: VaultFrontier(), diff --git a/potassiumProviderTests/VaultMigrationTests.swift b/potassiumProviderTests/VaultMigrationTests.swift deleted file mode 100644 index fe80490..0000000 --- a/potassiumProviderTests/VaultMigrationTests.swift +++ /dev/null @@ -1,388 +0,0 @@ -import CryptoKit -import Foundation -@testable import PotassiumProviderCore -import Testing - -struct VaultMigrationTests { - @Test func encryptedJournalDoesNotExposeSourceMetadata() async throws { - let directory = Self.temporaryDirectory() - defer { try? FileManager.default.removeItem(at: directory) } - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - let fileURL = directory.appendingPathComponent("migration.bin") - let key = try VaultKeyMaterial.random() - let vaultID = VaultIdentifier() - let journal = VaultMigrationFileJournal( - fileURL: fileURL, - rootKey: key, - vaultID: vaultID - ) - let record = VaultMigrationRecord( - source: Self.sourceItem(filename: "secret-name.txt"), - destinationParentID: nil, - updatedAt: Date(timeIntervalSince1970: 100) - ) - try await journal.save(record) - - let stored = try Data(contentsOf: fileURL) - #expect(stored.range(of: Data("secret-name.txt".utf8)) == nil) - let reopened = VaultMigrationFileJournal( - fileURL: fileURL, - rootKey: key, - vaultID: vaultID - ) - #expect(try await reopened.record(sourceIdentifier: "source-1") == record) - - let wrongKey = VaultMigrationFileJournal( - fileURL: fileURL, - rootKey: try VaultKeyMaterial.random(), - vaultID: vaultID - ) - await #expect(throws: VaultCryptoError.authenticationFailed) { - try await wrongKey.records() - } - } - - @Test func sourcePurgeCannotPrecedeAuthenticatedVerification() async throws { - let directory = Self.temporaryDirectory() - defer { try? FileManager.default.removeItem(at: directory) } - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - let plaintext = Data("highly private migration bytes".utf8) - let source = FakeMigrationSource(data: plaintext) - let destination = FakeMigrationDestination(directory: directory) - let journal = InMemoryVaultMigrationJournal() - let coordinator = VaultMigrationCoordinator( - source: source, - destination: destination, - journal: journal, - temporaryDirectoryURL: directory - ) - _ = try await coordinator.inventory( - Self.sourceItem(filename: "private.txt", size: Int64(plaintext.count)), - destinationParentID: Optional.none - ) - - await #expect(throws: VaultMigrationError.sourceNotVerified("source-1")) { - try await coordinator.purgeVerifiedSource(sourceIdentifier: "source-1") - } - #expect(await source.purgeCount() == 0) - - let verified = try await coordinator.resume(sourceIdentifier: "source-1") - #expect(verified.state == VaultMigrationState.verified) - #expect(verified.verifiedDigest == Data(SHA256.hash(data: plaintext))) - #expect(await source.purgeCount() == 0) - - try await coordinator.purgeVerifiedSource(sourceIdentifier: "source-1") - #expect(await source.purgeCount() == 1) - #expect( - try await journal.record(sourceIdentifier: "source-1")?.state - == VaultMigrationState.sourcePurged - ) - } - - @Test func changedSourceFailsClosedBeforeCommit() async throws { - let directory = Self.temporaryDirectory() - defer { try? FileManager.default.removeItem(at: directory) } - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - let source = FakeMigrationSource(data: Data("first".utf8)) - let coordinator = VaultMigrationCoordinator( - source: source, - destination: FakeMigrationDestination(directory: directory), - journal: InMemoryVaultMigrationJournal(), - temporaryDirectoryURL: directory - ) - _ = try await coordinator.inventory( - Self.sourceItem(filename: "changing.txt", size: 5), - destinationParentID: Optional.none - ) - await source.setRevision("revision-2") - - await #expect(throws: VaultMigrationError.sourceChanged("source-1")) { - try await coordinator.resume(sourceIdentifier: "source-1") - } - #expect(await source.purgeCount() == 0) - } - - @Test func sourceChangeAfterVerificationStillBlocksPlaintextPurge() async throws { - let directory = Self.temporaryDirectory() - defer { try? FileManager.default.removeItem(at: directory) } - try FileManager.default.createDirectory( - at: directory, - withIntermediateDirectories: true - ) - let plaintext = Data("verified, then changed".utf8) - let source = FakeMigrationSource(data: plaintext) - let coordinator = VaultMigrationCoordinator( - source: source, - destination: FakeMigrationDestination(directory: directory), - journal: InMemoryVaultMigrationJournal(), - temporaryDirectoryURL: directory - ) - _ = try await coordinator.inventory( - Self.sourceItem( - filename: "changed-after-verification.txt", - size: Int64(plaintext.count) - ), - destinationParentID: Optional.none - ) - let verified = try await coordinator.resume(sourceIdentifier: "source-1") - #expect(verified.state == .verified) - - await source.setRevision("revision-2") - await #expect(throws: VaultMigrationError.sourceChanged("source-1")) { - try await coordinator.purgeVerifiedSource(sourceIdentifier: "source-1") - } - #expect(await source.purgeCount() == 0) - } - - private static func sourceItem( - filename: String, - size: Int64 = 12 - ) -> VaultMigrationSourceItem { - VaultMigrationSourceItem( - sourceIdentifier: "source-1", - sourceParentIdentifier: nil, - sourceRevision: "revision-1", - filename: filename, - isDirectory: false, - contentTypeIdentifier: "public.data", - createdAt: Date(timeIntervalSince1970: 10), - modifiedAt: Date(timeIntervalSince1970: 20), - plaintextSize: size - ) - } - - private static func temporaryDirectory() -> URL { - FileManager.default.temporaryDirectory.appendingPathComponent( - "VaultMigrationTests-\(UUID().uuidString)", - isDirectory: true - ) - } -} - -private actor FakeMigrationSource: VaultMigrationSourceProviding { - private let data: Data - private var revision = "revision-1" - private var purges = 0 - - init(data: Data) { - self.data = data - } - - func currentRevision(sourceIdentifier: String) -> String { - revision - } - - func download(sourceIdentifier: String, to destinationURL: URL) throws { - try data.write(to: destinationURL) - } - - func purgePlaintext(sourceIdentifier: String) { - purges += 1 - } - - func setRevision(_ value: String) { - revision = value - } - - func purgeCount() -> Int { - purges - } -} - -private actor FakeMigrationDestination: VaultMigrationDestinationProviding { - private let directory: URL - private var contentsByItemID: [VaultItemIdentifier: Data] = [:] - private var items: [VaultItemIdentifier: VaultItem] = [:] - - init(directory: URL) { - self.directory = directory - } - - func synchronize() -> VaultFrontier { VaultFrontier() } - - func item(_ identifier: VaultItemIdentifier) throws -> VaultItem { - guard let item = items[identifier] else { throw EncryptedVaultError.itemNotFound } - return item - } - - func children( - of parentID: VaultItemIdentifier?, - trashed: Bool, - cursor: String?, - limit: Int - ) -> VaultItemPage { - VaultItemPage(items: [], nextCursor: nil) - } - - func workingSet(limit: Int) -> [VaultItem] { Array(items.values) } - - func changes( - since anchorString: String, - scope: VaultChangeScope - ) -> VaultItemChanges { - VaultItemChanges( - updated: [], - deleted: [], - frontier: VaultFrontier() - ) - } - - func fetchContent( - itemID: VaultItemIdentifier, - expectedRevision: VaultRevision?, - to plaintextURL: URL - ) throws -> VaultItem { - guard let item = items[itemID], let data = contentsByItemID[itemID] else { - throw EncryptedVaultError.itemNotFound - } - try data.write(to: plaintextURL) - return item - } - - func createDirectory( - parentID: VaultItemIdentifier?, - filename: String, - createdAt: Date - ) -> VaultItem { - let revision = VaultRevision(hashing: Data(filename.utf8)) - let item = VaultItem( - parentID: parentID, - filename: filename, - isDirectory: true, - createdAt: createdAt, - modifiedAt: createdAt, - contentRevision: revision, - metadataRevision: revision - ) - items[item.id] = item - return item - } - - func stageFileImport( - itemID: VaultItemIdentifier, - plaintextURL: URL - ) throws -> VaultStagedContent { - let data = try Data(contentsOf: plaintextURL) - let ciphertextURL = directory.appendingPathComponent("stage-\(itemID.rawValue)") - try data.write(to: ciphertextURL) - contentsByItemID[itemID] = data - return VaultStagedContent( - itemID: itemID, - contentRevision: VaultRevision(hashing: data), - objectToken: Data(repeating: 7, count: 20).base64URL, - ciphertextURL: ciphertextURL, - wrappedContentKey: Data(repeating: 8, count: 60), - noncePrefix: 9, - plaintextLength: Int64(data.count), - plaintextDigest: Data(SHA256.hash(data: data)), - frameCount: 1 - ) - } - - func uploadStagedFileImport( - _ staged: VaultStagedContent - ) -> VaultUploadedContent { - VaultUploadedContent(staged: staged, remoteFileID: 99) - } - - func commitUploadedFileImport( - _ uploaded: VaultUploadedContent, - parentID: VaultItemIdentifier?, - filename: String, - contentTypeIdentifier: String?, - createdAt: Date, - modifiedAt: Date - ) -> VaultItem { - let item = VaultItem( - id: uploaded.staged.itemID, - parentID: parentID, - filename: filename, - isDirectory: false, - contentTypeIdentifier: contentTypeIdentifier, - createdAt: createdAt, - modifiedAt: modifiedAt, - plaintextSize: uploaded.staged.plaintextLength, - contentRevision: uploaded.staged.contentRevision, - metadataRevision: uploaded.staged.contentRevision, - contentReference: uploaded.contentReference - ) - items[item.id] = item - return item - } - - func discardStagedFileImport(_ staged: VaultStagedContent) { - try? FileManager.default.removeItem(at: staged.ciphertextURL) - } - - func createFile( - parentID: VaultItemIdentifier?, - filename: String, - contentTypeIdentifier: String?, - plaintextURL: URL, - modifiedAt: Date - ) throws -> VaultItem { - throw FakeMigrationError.unsupported - } - - func modify( - itemID: VaultItemIdentifier, - baseContentRevision: VaultRevision, - baseMetadataRevision: VaultRevision, - parentID: VaultItemIdentifier?, - filename: String, - favorite: Bool, - plaintextURL: URL?, - modifiedAt: Date - ) throws -> VaultItem { - throw FakeMigrationError.unsupported - } - - func trash( - itemID: VaultItemIdentifier, - baseContentRevision: VaultRevision, - baseMetadataRevision: VaultRevision - ) throws { - throw FakeMigrationError.unsupported - } - - func restore( - itemID: VaultItemIdentifier, - parentID: VaultItemIdentifier? - ) throws -> VaultItem { - throw FakeMigrationError.unsupported - } - - func purge( - itemID: VaultItemIdentifier, - baseContentRevision: VaultRevision, - baseMetadataRevision: VaultRevision - ) throws { - throw FakeMigrationError.unsupported - } - - func duplicate(itemID: VaultItemIdentifier) throws -> VaultItem { - throw FakeMigrationError.unsupported - } - - func versions(itemID: VaultItemIdentifier) -> [VaultVersion] { [] } - - func restoreVersion( - itemID: VaultItemIdentifier, - contentRevision: VaultRevision - ) throws -> VaultItem { - throw FakeMigrationError.unsupported - } -} - -private enum FakeMigrationError: Error { - case unsupported -} - -private extension Data { - var base64URL: String { - base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") - } -} diff --git a/potassiumProviderTests/VaultProvisioningTests.swift b/potassiumProviderTests/VaultProvisioningTests.swift index 896ff6b..052177e 100644 --- a/potassiumProviderTests/VaultProvisioningTests.swift +++ b/potassiumProviderTests/VaultProvisioningTests.swift @@ -20,7 +20,7 @@ struct VaultProvisioningTests { await #expect(throws: VaultProvisioningError.recoveryConfirmationMismatch) { try await service.confirm( pending, - recoveryKitConfirmation: "KPV1-NOT-A-RECOVERY-KIT" + recoveryKitConfirmation: "KPV2-NOT-A-RECOVERY-KIT" ) } #expect(try await keyStore.loadRootKey(vaultID: pending.vaultID) == nil) @@ -127,7 +127,7 @@ struct VaultProvisioningTests { displayName: "Encrypted", driveID: 5, driveName: "Drive", - encryptionMode: .opaqueVaultV1, + encryptionMode: .opaqueVaultV2, vault: ProviderVaultConfiguration( vaultIdentifier: vaultID, vaultRootFileID: 1, @@ -178,6 +178,97 @@ struct VaultProvisioningTests { #expect(try Data(contentsOf: openedURL) == plaintext) } + @Test func restoringVersionPublishesFreshRevisionAndRejectsABAStaleWrite() async throws { + let directory = Self.temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let objectStore = InMemoryOpaqueObjectStore() + let keyStore = InMemoryVaultKeyStore() + let provisioning = VaultProvisioningService( + objectStore: objectStore, + keyStore: keyStore, + temporaryDirectoryURL: directory + ) + let pending = try await provisioning.prepareNewVault(driveID: 6) + _ = try await provisioning.confirm( + pending, + recoveryKitConfirmation: pending.recoveryKit.encoded + ) + let configuration = ProviderDomainConfiguration( + domainIdentifier: "version-domain", + displayName: "Encrypted", + driveID: 6, + driveName: "Drive", + encryptionMode: .opaqueVaultV2, + vault: pending.vaultConfiguration + ) + let localStore = try VaultSQLiteStore( + databaseURL: directory.appendingPathComponent("versions.sqlite3"), + domainIdentifier: configuration.domainIdentifier, + vaultID: pending.vaultID, + rootKey: pending.rootKey + ) + let vault = try EncryptedVaultService( + configuration: configuration, + rootKey: pending.rootKey, + deviceID: UUID(), + objectStore: objectStore, + localStore: localStore, + keyStore: keyStore, + temporaryDirectoryURL: directory + ) + let firstURL = directory.appendingPathComponent("first") + let secondURL = directory.appendingPathComponent("second") + try Data("first revision".utf8).write(to: firstURL) + try Data("second revision".utf8).write(to: secondURL) + let original = try await vault.createFile( + parentID: nil, + filename: "document.txt", + contentTypeIdentifier: "public.plain-text", + plaintextURL: firstURL, + modifiedAt: Date(timeIntervalSince1970: 100) + ) + let modified = try await vault.modify( + itemID: original.id, + baseContentRevision: original.contentRevision, + baseMetadataRevision: original.metadataRevision, + parentID: original.parentID, + filename: original.filename, + favorite: original.isFavorite, + plaintextURL: secondURL, + modifiedAt: Date(timeIntervalSince1970: 200) + ) + + let restored = try await vault.restoreVersion( + itemID: original.id, + contentRevision: original.contentRevision + ) + #expect(restored.contentRevision != original.contentRevision) + #expect(restored.contentRevision != modified.contentRevision) + #expect(restored.contentReference?.objectToken != original.contentReference?.objectToken) + + let openedURL = directory.appendingPathComponent("restored") + _ = try await vault.fetchContent( + itemID: restored.id, + expectedRevision: restored.contentRevision, + to: openedURL + ) + #expect(try Data(contentsOf: openedURL) == Data("first revision".utf8)) + + await #expect(throws: EncryptedVaultError.staleRevision) { + try await vault.modify( + itemID: original.id, + baseContentRevision: original.contentRevision, + baseMetadataRevision: original.metadataRevision, + parentID: original.parentID, + filename: original.filename, + favorite: original.isFavorite, + plaintextURL: secondURL, + modifiedAt: Date(timeIntervalSince1970: 300) + ) + } + } + @Test func returningDeviceRejectsOmittedRemoteJournalObject() async throws { let directory = Self.temporaryDirectory() defer { try? FileManager.default.removeItem(at: directory) } @@ -199,7 +290,7 @@ struct VaultProvisioningTests { displayName: "Encrypted", driveID: 8, driveName: "Drive", - encryptionMode: .opaqueVaultV1, + encryptionMode: .opaqueVaultV2, vault: pending.vaultConfiguration ) let localStore = try VaultSQLiteStore( @@ -238,7 +329,7 @@ struct VaultProvisioningTests { } } - @Test func garbageCollectionRequiresTwoVerifiedCheckpointsAndLocalAge() async throws { + @Test func maintenanceNeverDeletesCiphertextThatAnOfflineDeviceMayReference() async throws { let directory = Self.temporaryDirectory() defer { try? FileManager.default.removeItem(at: directory) } try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) @@ -259,7 +350,7 @@ struct VaultProvisioningTests { displayName: "Encrypted", driveID: 12, driveName: "Drive", - encryptionMode: .opaqueVaultV1, + encryptionMode: .opaqueVaultV2, vault: pending.vaultConfiguration ) let localStore = try VaultSQLiteStore( @@ -308,26 +399,23 @@ struct VaultProvisioningTests { temporaryDirectoryURL: directory ) - let first = try await maintenance.checkpointAndCollectUnreferencedContent( - retentionInterval: 100, + let first = try await maintenance.checkpointAndReportUnreferencedContent( now: Date(timeIntervalSince1970: 1_000) ) - #expect(first.deletedObjectCount == 0) + #expect(first.unreferencedObjectCount == 1) #expect(await objectStore.contains(fileID: orphan.id)) - let stillYoung = try await maintenance.checkpointAndCollectUnreferencedContent( - retentionInterval: 100, - now: Date(timeIntervalSince1970: 1_099) + let later = try await maintenance.checkpointAndReportUnreferencedContent( + now: Date(timeIntervalSince1970: 10_000) ) - #expect(stillYoung.deletedObjectCount == 0) + #expect(later.unreferencedObjectCount == 1) #expect(await objectStore.contains(fileID: orphan.id)) - let collected = try await maintenance.checkpointAndCollectUnreferencedContent( - retentionInterval: 100, - now: Date(timeIntervalSince1970: 1_101) + let muchLater = try await maintenance.checkpointAndReportUnreferencedContent( + now: Date(timeIntervalSince1970: 1_000_000) ) - #expect(collected.deletedObjectCount == 1) - #expect(await objectStore.contains(fileID: orphan.id) == false) + #expect(muchLater.unreferencedObjectCount == 1) + #expect(await objectStore.contains(fileID: orphan.id)) #expect(await objectStore.contains(fileID: referencedFileID)) } @@ -352,7 +440,7 @@ struct VaultProvisioningTests { displayName: "Encrypted", driveID: 13, driveName: "Drive", - encryptionMode: .opaqueVaultV1, + encryptionMode: .opaqueVaultV2, vault: pending.vaultConfiguration ) let localStore = try VaultSQLiteStore( diff --git a/potassiumProviderTests/VaultUXAppModelTests.swift b/potassiumProviderTests/VaultUXAppModelTests.swift index 0826cb8..a2adfb7 100644 --- a/potassiumProviderTests/VaultUXAppModelTests.swift +++ b/potassiumProviderTests/VaultUXAppModelTests.swift @@ -133,7 +133,7 @@ struct VaultUXAppModelTests { ) let keyStore = InMemoryVaultKeyStore() let objectStore = InMemoryOpaqueObjectStore() - var currentDate = Date(timeIntervalSince1970: 1_000) + var currentUptime: TimeInterval = 1_000 let model = PotassiumProviderAppModel( accountStore: ProviderAccountFileStore( directoryURL: directory.appendingPathComponent("Accounts") @@ -160,7 +160,7 @@ struct VaultUXAppModelTests { ), encryptedVaultsEnabled: true, encryptedVaultICloudKeychainEnabled: true, - currentDate: { currentDate } + currentUptime: { currentUptime } ) await model.prepareEncryptedVault( @@ -177,13 +177,13 @@ struct VaultUXAppModelTests { #expect(model.errorMessage?.contains("Wait five seconds") == true) #expect(await objectStore.allTokens().isEmpty) - currentDate.addTimeInterval(4.999) + currentUptime += 4.999 await model.acceptEncryptedVaultRiskAndPrepare() #expect(model.vaultSetupStep == .unsupportedRiskWarning) #expect(model.pendingVaultProvisioning == nil) #expect(await objectStore.allTokens().isEmpty) - currentDate.addTimeInterval(0.001) + currentUptime += 0.001 await model.acceptEncryptedVaultRiskAndPrepare() #expect(model.vaultSetupStep == .overview) let pending = try #require(model.pendingVaultProvisioning) @@ -221,7 +221,120 @@ struct VaultUXAppModelTests { #expect(model.errorMessage == nil) } - private func makeContext() async throws -> VaultUXContext { + @Test func recoveryAndICloudOpenCannotBypassRiskDelay() async throws { + var currentUptime: TimeInterval = 2_000 + let context = try await makeContext(currentUptime: { currentUptime }) + defer { try? FileManager.default.removeItem(at: context.directory) } + let drive = KDriveDriveSummary( + id: context.configuration.driveID, + name: context.configuration.driveName, + accountID: 1, + role: "admin", + status: "active", + isInMaintenance: false + ) + let initialTokens = await context.objectStore.allTokens() + + context.model.prepareOpenEncryptedVault( + accountIdentifier: context.configuration.accountIdentifier, + drive: drive, + recoveryKitText: context.recoveryKit.encoded + ) + #expect(context.model.vaultSetupStep == .unsupportedRiskWarning) + await context.model.acceptEncryptedVaultRiskAndPrepare() + #expect(context.model.vaultSetupStep == .unsupportedRiskWarning) + #expect(await context.objectStore.allTokens() == initialTokens) + context.model.finishVaultSetup() + + context.model.prepareOpenEncryptedVaultFromICloud( + accountIdentifier: context.configuration.accountIdentifier, + drive: drive, + vaultID: context.vaultID + ) + #expect(context.model.vaultSetupStep == .unsupportedRiskWarning) + currentUptime += 4.999 + await context.model.acceptEncryptedVaultRiskAndPrepare() + #expect(context.model.vaultSetupStep == .unsupportedRiskWarning) + #expect(context.authorizer.authorizationCount == 0) + #expect(await context.objectStore.allTokens() == initialTokens) + } + + @Test func iCloudConvenienceGateCannotBypassDisabledVaultGate() async throws { + let context = try await makeContext(encryptedVaultsEnabled: false) + defer { try? FileManager.default.removeItem(at: context.directory) } + let drive = KDriveDriveSummary( + id: context.configuration.driveID, + name: context.configuration.driveName, + accountID: 1, + role: "admin", + status: "active", + isInMaintenance: false + ) + + context.model.prepareOpenEncryptedVaultFromICloud( + accountIdentifier: context.configuration.accountIdentifier, + drive: drive, + vaultID: context.vaultID + ) + + #expect(context.model.vaultSetupStep == nil) + #expect(context.model.errorMessage?.contains("Encrypted vaults are disabled") == true) + #expect(context.authorizer.authorizationCount == 0) + } + + @Test func reloadDoesNotReactivateAnUnsupportedV1Domain() async throws { + let directory = temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let domainStore = DomainConfigurationFileStore( + directoryURL: directory.appendingPathComponent("Domains") + ) + let configuration = ProviderDomainConfiguration( + domainIdentifier: "unsupported-v1-domain", + accountIdentifier: "account", + displayName: "Unsupported", + driveID: 42, + driveName: "Drive", + encryptionMode: .opaqueVaultV1, + vault: ProviderVaultConfiguration( + vaultIdentifier: VaultIdentifier(), + vaultRootFileID: 100, + vaultHeaderFileID: 101, + formatVersion: 1 + ) + ) + try await domainStore.save(configuration) + let registrar = RecordingVaultUXDomainRegistrar() + let model = PotassiumProviderAppModel( + accountStore: ProviderAccountFileStore( + directoryURL: directory.appendingPathComponent("Accounts") + ), + domainStore: domainStore, + tokenStore: InMemoryOAuthTokenStore(), + oauthAuthenticator: VaultUXOAuthAuthenticator(), + domainRegistrar: registrar, + automaticallyReloadStoredState: false, + fileProviderFactory: { _ in VaultUXFileProvider() }, + vaultKeyStore: InMemoryVaultKeyStore(), + vaultDeviceIdentityStore: VaultUXDeviceIdentityStore(), + vaultCloudAccessStore: InMemoryVaultCloudAccessStore(), + vaultUserPresenceAuthorizer: AllowVaultUserPresenceAuthorizer(), + vaultUXDefaults: UserDefaults( + suiteName: "VaultUXAppModelTests.\(UUID().uuidString)" + ) + ) + + await model.reloadStoredState() + + #expect(registrar.addedDomainIdentifiers.isEmpty) + #expect(model.domains.map(\.domainIdentifier) == [configuration.domainIdentifier]) + } + + private func makeContext( + encryptedVaultsEnabled: Bool = true, + currentUptime: @escaping () -> TimeInterval = { + ProcessInfo.processInfo.systemUptime + } + ) async throws -> VaultUXContext { let directory = temporaryDirectory() try FileManager.default.createDirectory( at: directory, @@ -245,10 +358,11 @@ struct VaultUXAppModelTests { displayName: "Encrypted", driveID: 42, driveName: "Drive", - encryptionMode: .opaqueVaultV1, + encryptionMode: .opaqueVaultV2, vault: vault ) let cloudStore = InMemoryVaultCloudAccessStore() + let authorizer = CountingVaultUserPresenceAuthorizer() let tokenStore = InMemoryOAuthTokenStore() try await tokenStore.saveToken( KDriveOAuthToken( @@ -285,12 +399,13 @@ struct VaultUXAppModelTests { vaultKeyStore: keyStore, vaultDeviceIdentityStore: VaultUXDeviceIdentityStore(), vaultCloudAccessStore: cloudStore, - vaultUserPresenceAuthorizer: AllowVaultUserPresenceAuthorizer(), + vaultUserPresenceAuthorizer: authorizer, vaultUXDefaults: UserDefaults( suiteName: "VaultUXAppModelTests.\(UUID().uuidString)" ), - encryptedVaultsEnabled: true, - encryptedVaultICloudKeychainEnabled: true + encryptedVaultsEnabled: encryptedVaultsEnabled, + encryptedVaultICloudKeychainEnabled: true, + currentUptime: currentUptime ) return VaultUXContext( directory: directory, @@ -300,7 +415,9 @@ struct VaultUXAppModelTests { rootKey: pending.rootKey, recoveryKit: pending.recoveryKit, keyStore: keyStore, - cloudStore: cloudStore + cloudStore: cloudStore, + objectStore: objectStore, + authorizer: authorizer ) } @@ -321,6 +438,8 @@ private struct VaultUXContext { let recoveryKit: VaultRecoveryKit let keyStore: InMemoryVaultKeyStore let cloudStore: InMemoryVaultCloudAccessStore + let objectStore: InMemoryOpaqueObjectStore + let authorizer: CountingVaultUserPresenceAuthorizer } @MainActor @@ -330,6 +449,17 @@ private struct AllowVaultUserPresenceAuthorizer: func authorize(reason: String) async throws {} } +@MainActor +private final class CountingVaultUserPresenceAuthorizer: + VaultUserPresenceAuthorizing +{ + private(set) var authorizationCount = 0 + + func authorize(reason: String) async throws { + authorizationCount += 1 + } +} + private actor VaultUXDeviceIdentityStore: VaultDeviceIdentityStoring { func loadOrCreateDeviceID(vaultID: VaultIdentifier) -> UUID { UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE")! @@ -351,6 +481,17 @@ private struct VaultUXDomainRegistrar: ProviderDomainRegistering { func removeDomain(for configuration: ProviderDomainConfiguration) async throws {} } +@MainActor +private final class RecordingVaultUXDomainRegistrar: ProviderDomainRegistering { + private(set) var addedDomainIdentifiers: [String] = [] + + func addDomain(for configuration: ProviderDomainConfiguration) async throws { + addedDomainIdentifiers.append(configuration.domainIdentifier) + } + + func removeDomain(for configuration: ProviderDomainConfiguration) async throws {} +} + private final class VaultUXOAuthAuthenticator: KDriveOAuthAuthenticating { func authenticate() async throws -> KDriveOAuthToken { throw VaultUXTestError.unsupported