From 921e7c369e6546635de1eac7a87f7acbbbbffd2a Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Thu, 20 Aug 2026 09:16:47 +0200 Subject: [PATCH 01/18] feat: preserve E2EE key sets during setup Archive active E2EE credentials in the Keychain before setup resets or certificate renewal, while deduplicating snapshots and preserving explicit full cleanup. Add unit coverage for complete, duplicate, and partial key-set archives, and update resolved package versions. Signed-off-by: Marino Faggiana --- Nextcloud.xcodeproj/project.pbxproj | 20 +++++ .../xcshareddata/swiftpm/Package.resolved | 17 +--- .../NCEndToEndKeySetArchiveTests.swift | 81 +++++++++++++++++ .../Settings/E2EE/NCEndToEndKeySet.swift | 59 +++++++++++++ iOSClient/Settings/E2EE/NCEndToEndSetup.swift | 11 ++- iOSClient/Settings/NCPreferences.swift | 86 ++++++++++++++++++- 6 files changed, 257 insertions(+), 17 deletions(-) create mode 100644 Tests/NextcloudUnitTests/NCEndToEndKeySetArchiveTests.swift create mode 100644 iOSClient/Settings/E2EE/NCEndToEndKeySet.swift diff --git a/Nextcloud.xcodeproj/project.pbxproj b/Nextcloud.xcodeproj/project.pbxproj index 91d1ff2fe4..795bbb0a8b 100644 --- a/Nextcloud.xcodeproj/project.pbxproj +++ b/Nextcloud.xcodeproj/project.pbxproj @@ -44,6 +44,14 @@ AABBCC0630A8000100F0A001 /* NCVideoPlaybackPresentationContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = AABBCC0530A8000100F0A001 /* NCVideoPlaybackPresentationContext.swift */; }; AABBCC0830A8000100F0A001 /* NCVideoPlaybackPresentationContextTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AABBCC0730A8000100F0A001 /* NCVideoPlaybackPresentationContextTests.swift */; }; AABBCC0A30A8000100F0A001 /* NCMediaViewerModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AABBCC0930A8000100F0A001 /* NCMediaViewerModelTests.swift */; }; + A1E2EE010000000000000002 /* NCEndToEndKeySet.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1E2EE010000000000000001 /* NCEndToEndKeySet.swift */; }; + A1E2EE010000000000000003 /* NCEndToEndKeySet.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1E2EE010000000000000001 /* NCEndToEndKeySet.swift */; }; + A1E2EE010000000000000004 /* NCEndToEndKeySet.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1E2EE010000000000000001 /* NCEndToEndKeySet.swift */; }; + A1E2EE010000000000000005 /* NCEndToEndKeySet.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1E2EE010000000000000001 /* NCEndToEndKeySet.swift */; }; + A1E2EE010000000000000006 /* NCEndToEndKeySet.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1E2EE010000000000000001 /* NCEndToEndKeySet.swift */; }; + A1E2EE010000000000000007 /* NCEndToEndKeySet.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1E2EE010000000000000001 /* NCEndToEndKeySet.swift */; }; + A1E2EE010000000000000008 /* NCEndToEndKeySet.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1E2EE010000000000000001 /* NCEndToEndKeySet.swift */; }; + A1E2EE01000000000000000A /* NCEndToEndKeySetArchiveTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1E2EE010000000000000009 /* NCEndToEndKeySetArchiveTests.swift */; }; AABD0C8A2D5F67A400F009E6 /* XCUIElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = AABD0C892D5F67A200F009E6 /* XCUIElement.swift */; }; AAE330042D2ED20200B04903 /* NCShareNavigationTitleSetting.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAE330032D2ED1FF00B04903 /* NCShareNavigationTitleSetting.swift */; }; AAFC0D042F9AA10000F0A001 /* NCFocusedAutoUploadIntroView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAFC0D012F9AA10000F0A001 /* NCFocusedAutoUploadIntroView.swift */; }; @@ -1295,6 +1303,8 @@ AABBCC0530A8000100F0A001 /* NCVideoPlaybackPresentationContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCVideoPlaybackPresentationContext.swift; sourceTree = ""; }; AABBCC0730A8000100F0A001 /* NCVideoPlaybackPresentationContextTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCVideoPlaybackPresentationContextTests.swift; sourceTree = ""; }; AABBCC0930A8000100F0A001 /* NCMediaViewerModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCMediaViewerModelTests.swift; sourceTree = ""; }; + A1E2EE010000000000000001 /* NCEndToEndKeySet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCEndToEndKeySet.swift; sourceTree = ""; }; + A1E2EE010000000000000009 /* NCEndToEndKeySetArchiveTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCEndToEndKeySetArchiveTests.swift; sourceTree = ""; }; AABD0C862D5F58C400F009E6 /* Server.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = Server.sh; sourceTree = ""; }; AABD0C892D5F67A200F009E6 /* XCUIElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCUIElement.swift; sourceTree = ""; }; AACCAB522CFE041F00DA1786 /* sl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sl; path = sl.lproj/Intent.strings; sourceTree = ""; }; @@ -2197,6 +2207,7 @@ AA52EB442D42AC500089C348 /* NextcloudUnitTests */ = { isa = PBXGroup; children = ( + A1E2EE010000000000000009 /* NCEndToEndKeySetArchiveTests.swift */, AABBCC0330A8000100F0A001 /* NCMediaPlaybackOptionsTests.swift */, AABBCC0730A8000100F0A001 /* NCVideoPlaybackPresentationContextTests.swift */, AABBCC0930A8000100F0A001 /* NCMediaViewerModelTests.swift */, @@ -2822,6 +2833,7 @@ F768820D2C0DD1E7001CF441 /* E2EE */ = { isa = PBXGroup; children = ( + A1E2EE010000000000000001 /* NCEndToEndKeySet.swift */, F71070AA2F7E49E100AEE58A /* NCEndToEndSetup.swift */, F76882102C0DD1E7001CF441 /* NCManageE2EEModel.swift */, F768820E2C0DD1E7001CF441 /* NCManageE2EEView.swift */, @@ -4475,6 +4487,7 @@ F7817CFF29802D1A00FFBC65 /* NCPushNotificationEncryption.m in Sources */, F798F0EC2588060A000DAFFD /* UIColor+Extension.swift in Sources */, F76882372C0DD22F001CF441 /* NCPreferences.swift in Sources */, + A1E2EE010000000000000002 /* NCEndToEndKeySet.swift in Sources */, F73EF7E52B02266D0087E6E9 /* NCManageDatabase+Trash.swift in Sources */, F7CAFE232F17A37C00DB35A5 /* ProgressQuantizer.swift in Sources */, F764C3E42FFB7DFA00029FD5 /* NCManageDatabase+MediaMetadataBackfill.swift in Sources */, @@ -4491,6 +4504,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + A1E2EE01000000000000000A /* NCEndToEndKeySetArchiveTests.swift in Sources */, AABBCC0430A8000100F0A001 /* NCMediaPlaybackOptionsTests.swift in Sources */, AABBCC0830A8000100F0A001 /* NCVideoPlaybackPresentationContextTests.swift in Sources */, AABBCC0A30A8000100F0A001 /* NCMediaViewerModelTests.swift in Sources */, @@ -4547,6 +4561,7 @@ F763413B2EBE5DB10056F538 /* FileProviderExtension.swift in Sources */, F763413D2EBE5DBB0056F538 /* FileProviderExtension+Thumbnail.swift in Sources */, F76882382C0DD22F001CF441 /* NCPreferences.swift in Sources */, + A1E2EE010000000000000003 /* NCEndToEndKeySet.swift in Sources */, F70716E62987F81500E72C1D /* DocumentActionViewController.swift in Sources */, F763412A2EBE10F00056F538 /* NCUtilityFileSystem.swift in Sources */, F760A4942FE95D33001B212E /* NetworkingTasks.swift in Sources */, @@ -4688,6 +4703,7 @@ F763D2A02A249C4500A3C901 /* NCManageDatabase+Capabilities.swift in Sources */, F757CC8529E7F88B00F31428 /* NCManageDatabase+Groupfolders.swift in Sources */, F768823A2C0DD230001CF441 /* NCPreferences.swift in Sources */, + A1E2EE010000000000000004 /* NCEndToEndKeySet.swift in Sources */, F737DA9E2B7B893C0063BAFC /* NCPasscode.swift in Sources */, F7C30DF7291BC0D30017149B /* NCNetworkingE2EEUpload.swift in Sources */, F7C687EC2D22BDE5004757BC /* NCManageDatabase+RecommendedFiles.swift in Sources */, @@ -4737,6 +4753,7 @@ F7BDC1D6300F440A00C5D9FA /* NCManageDatabase+MediaPreviewBackfill.swift in Sources */, F72EA95A28B7BD0D00C88F0C /* FilesWidgetView.swift in Sources */, F768823C2C0DD231001CF441 /* NCPreferences.swift in Sources */, + A1E2EE010000000000000005 /* NCEndToEndKeySet.swift in Sources */, F71F6D082B6A6A5E00F1EB15 /* ThreadSafeArray.swift in Sources */, F78302FE28B4C44700B84583 /* NCBrand.swift in Sources */, F749B64B297B0CBB00087535 /* NCManageDatabase+Share.swift in Sources */, @@ -4827,6 +4844,7 @@ F71F6D0B2B6A6A5E00F1EB15 /* ThreadSafeArray.swift in Sources */, F771E3F820E239B500AFB62D /* FileProviderExtension+Thumbnail.swift in Sources */, F76882392C0DD230001CF441 /* NCPreferences.swift in Sources */, + A1E2EE010000000000000006 /* NCEndToEndKeySet.swift in Sources */, F77DD6AC2C5CC093009448FB /* NCSession.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -4855,6 +4873,7 @@ F32FADA92D1176E3007035E2 /* UIButton+Extension.swift in Sources */, F7DF7B3F2F1A2EF900514020 /* WarningBannerView.swift in Sources */, F768822C2C0DD1E7001CF441 /* NCPreferences.swift in Sources */, + A1E2EE010000000000000007 /* NCEndToEndKeySet.swift in Sources */, F7CAFE1D2F17A35F00DB35A5 /* ProgressQuantizer.swift in Sources */, F7EDBB4B2FA89F6800098C42 /* NCLivePhotoViewerContentView.swift in Sources */, F3754A7D2CF87D600009312E /* SetupPasscodeView.swift in Sources */, @@ -5294,6 +5313,7 @@ F7A8D73C28F181BC008BBE1C /* NCBrand.swift in Sources */, F711A4DE2AF92CAE00095DD8 /* NCUtility+Date.swift in Sources */, F768823B2C0DD231001CF441 /* NCPreferences.swift in Sources */, + A1E2EE010000000000000008 /* NCEndToEndKeySet.swift in Sources */, AA8D31532D41052300FE2775 /* NCManageDatabase+DownloadLimit.swift in Sources */, F7A8D74228F18261008BBE1C /* NCUtility.swift in Sources */, F7E250002FE1000000000006 /* NCDocumentEditorSupport.swift in Sources */, diff --git a/Nextcloud.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Nextcloud.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 6260720869..cd727d782e 100644 --- a/Nextcloud.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Nextcloud.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -78,8 +78,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/GoogleAppMeasurement.git", "state" : { - "revision" : "f04760d460296cc0fa430935a7be212e5bd67fc5", - "version" : "12.18.0" + "revision" : "fceaffa07d22dcd5624d3639fd970351a4a5ad8c", + "version" : "12.17.0" } }, { @@ -195,8 +195,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/nextcloud/NextcloudKit", "state" : { - "revision" : "6d21f1258d1fb67b510c3b66129e73d86114fe14", - "version" : "7.4.1" + "revision" : "d192518544768f3c86354ef94ce6d699019e9487", + "version" : "7.4.3" } }, { @@ -316,15 +316,6 @@ "version" : "3.7.2" } }, - { - "identity" : "swiftui-introspect", - "kind" : "remoteSourceControl", - "location" : "https://github.com/siteline/swiftui-introspect", - "state" : { - "revision" : "26986a57e31c813f98262ddb62d4b07d40008535", - "version" : "26.0.1" - } - }, { "identity" : "swiftyjson", "kind" : "remoteSourceControl", diff --git a/Tests/NextcloudUnitTests/NCEndToEndKeySetArchiveTests.swift b/Tests/NextcloudUnitTests/NCEndToEndKeySetArchiveTests.swift new file mode 100644 index 0000000000..1c537c11ca --- /dev/null +++ b/Tests/NextcloudUnitTests/NCEndToEndKeySetArchiveTests.swift @@ -0,0 +1,81 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation +import Testing +@testable import Nextcloud + +@Suite("End-to-end key set archive") +struct NCEndToEndKeySetArchiveTests { + @Test("Archiving preserves active credentials after they are cleared") + func preservesActiveCredentials() throws { + let account = "e2ee-archive-\(UUID().uuidString)" + let preferences = NCPreferences() + defer { + preferences.clearAllKeysEndToEnd(account: account) + } + + setKeySet(on: preferences, account: account) + let archivedKeySet = try preferences.archiveCurrentEndToEndKeySet(account: account) + let snapshot = try #require(archivedKeySet) + + preferences.clearCurrentKeysEndToEnd(account: account) + + #expect(preferences.getEndToEndCertificate(account: account) == nil) + #expect(preferences.getEndToEndPrivateKey(account: account) == nil) + #expect(preferences.getEndToEndPublicKey(account: account) == nil) + #expect(preferences.getEndToEndPassphrase(account: account) == nil) + + let archivedKeySets = try preferences.getArchivedEndToEndKeySets(account: account) + #expect(archivedKeySets == [snapshot]) + #expect(snapshot.certificate == "certificate") + #expect(snapshot.privateKey == "private-key") + #expect(snapshot.publicKey == "public-key") + #expect(snapshot.passphrase == "passphrase") + } + + @Test("Archiving the same credentials does not create duplicates") + func avoidsDuplicates() throws { + let account = "e2ee-archive-\(UUID().uuidString)" + let preferences = NCPreferences() + defer { + preferences.clearAllKeysEndToEnd(account: account) + } + + setKeySet(on: preferences, account: account) + let firstArchivedKeySet = try preferences.archiveCurrentEndToEndKeySet(account: account) + let secondArchivedKeySet = try preferences.archiveCurrentEndToEndKeySet(account: account) + let firstSnapshot = try #require(firstArchivedKeySet) + let secondSnapshot = try #require(secondArchivedKeySet) + + #expect(secondSnapshot.identifier == firstSnapshot.identifier) + #expect(try preferences.getArchivedEndToEndKeySets(account: account).count == 1) + } + + @Test("A partial setup is archived when it contains usable key material") + func preservesPartialKeyMaterial() throws { + let account = "e2ee-archive-\(UUID().uuidString)" + let preferences = NCPreferences() + defer { + preferences.clearAllKeysEndToEnd(account: account) + } + + preferences.setEndToEndPrivateKey(account: account, privateKey: "private-key") + + let archivedKeySet = try preferences.archiveCurrentEndToEndKeySet(account: account) + let snapshot = try #require(archivedKeySet) + + #expect(snapshot.privateKey == "private-key") + #expect(snapshot.certificate == nil) + #expect(snapshot.publicKey == nil) + #expect(snapshot.passphrase == nil) + } + + private func setKeySet(on preferences: NCPreferences, account: String) { + preferences.setEndToEndCertificate(account: account, certificate: "certificate") + preferences.setEndToEndPrivateKey(account: account, privateKey: "private-key") + preferences.setEndToEndPublicKey(account: account, publicKey: "public-key") + preferences.setEndToEndPassphrase(account: account, passphrase: "passphrase") + } +} diff --git a/iOSClient/Settings/E2EE/NCEndToEndKeySet.swift b/iOSClient/Settings/E2EE/NCEndToEndKeySet.swift new file mode 100644 index 0000000000..cfe1cf334a --- /dev/null +++ b/iOSClient/Settings/E2EE/NCEndToEndKeySet.swift @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation + +/// An immutable snapshot of the local E2EE credentials for an account. +/// +/// Individual values are optional because older setup attempts may have been +/// interrupted after storing only part of the key set. Keeping that partial +/// state can still preserve key material needed to read existing encrypted +/// content. +struct NCEndToEndKeySet: Codable, Equatable, Sendable { + let identifier: String + let archivedAt: Date + let certificate: String? + let privateKey: String? + let publicKey: String? + let passphrase: String? + + init?( + certificate: String?, + privateKey: String?, + publicKey: String?, + passphrase: String?, + identifier: String = UUID().uuidString, + archivedAt: Date = Date() + ) { + let certificate = Self.nonEmpty(certificate) + let privateKey = Self.nonEmpty(privateKey) + let publicKey = Self.nonEmpty(publicKey) + let passphrase = Self.nonEmpty(passphrase) + + guard certificate != nil || privateKey != nil || publicKey != nil || passphrase != nil else { + return nil + } + + self.identifier = identifier + self.archivedAt = archivedAt + self.certificate = certificate + self.privateKey = privateKey + self.publicKey = publicKey + self.passphrase = passphrase + } + + func containsSameKeyMaterial(as other: NCEndToEndKeySet) -> Bool { + certificate == other.certificate && + privateKey == other.privateKey && + publicKey == other.publicKey && + passphrase == other.passphrase + } + + private static func nonEmpty(_ value: String?) -> String? { + guard let value, !value.isEmpty else { + return nil + } + return value + } +} diff --git a/iOSClient/Settings/E2EE/NCEndToEndSetup.swift b/iOSClient/Settings/E2EE/NCEndToEndSetup.swift index 4328cd1714..22e54df176 100644 --- a/iOSClient/Settings/E2EE/NCEndToEndSetup.swift +++ b/iOSClient/Settings/E2EE/NCEndToEndSetup.swift @@ -47,14 +47,16 @@ class NCEndToEndSetup { /// Starts the E2EE initialization pipeline. /// /// Flow: - /// 1. Clear all keys e2ee in preferences + /// 1. Archive the current E2EE key set and clear only the active keys /// 2. Ensure a valid certificate exists (fetch or create/sign) /// 3. Ensure a valid private key exists (fetch or create) /// /// - Throws: `NKError` if any step fails (network, crypto, validation, or user cancellation) func start() async throws { - // Clear all keys - preference.clearAllKeysEndToEnd(account: session.account) + // Preserve the previous key space before replacing the active keys. + // A Keychain failure stops setup before any key material is removed. + try preference.archiveCurrentEndToEndKeySet(account: session.account) + preference.clearCurrentKeysEndToEnd(account: session.account) // get version E2EE let capabilities = await NKCapabilities.shared.getCapabilities(for: session.account) options = networkingE2EE.getOptions(account: session.account, capabilities: capabilities) @@ -476,6 +478,9 @@ class NCEndToEndSetup { ) } + // Certificate renewal replaces part of the active key set, so preserve + // the previous version before updating it. + try preference.archiveCurrentEndToEndKeySet(account: session.account) preference.setEndToEndCertificate(account: session.account, certificate: certificate) return certificate diff --git a/iOSClient/Settings/NCPreferences.swift b/iOSClient/Settings/NCPreferences.swift index c3b4d5d66e..7b9536eb3f 100644 --- a/iOSClient/Settings/NCPreferences.swift +++ b/iOSClient/Settings/NCPreferences.swift @@ -493,13 +493,97 @@ final class NCPreferences: NSObject { return true } - func clearAllKeysEndToEnd(account: String) { + /// Archives the current E2EE credentials as an immutable Keychain item. + /// + /// Repeated attempts with unchanged credentials reuse the existing + /// snapshot. The archive index is written only after the snapshot itself, + /// so callers can safely stop before clearing the active credentials if + /// either Keychain operation fails. + @discardableResult + func archiveCurrentEndToEndKeySet(account: String) throws -> NCEndToEndKeySet? { + guard let candidate = NCEndToEndKeySet( + certificate: getEndToEndCertificate(account: account), + privateKey: getEndToEndPrivateKey(account: account), + publicKey: getEndToEndPublicKey(account: account), + passphrase: getEndToEndPassphrase(account: account) + ) else { + return nil + } + + let archivedKeySets = try getArchivedEndToEndKeySets(account: account) + if let existingKeySet = archivedKeySets.first(where: { $0.containsSameKeyMaterial(as: candidate) }) { + return existingKeySet + } + + let snapshotKey = archivedEndToEndKeySetKey(account: account, identifier: candidate.identifier) + let snapshotData = try JSONEncoder().encode(candidate) + try keychain.set(snapshotData, key: snapshotKey) + + do { + let identifiers = archivedKeySets.map(\.identifier) + [candidate.identifier] + let indexData = try JSONEncoder().encode(identifiers) + try keychain.set(indexData, key: archivedEndToEndKeySetIndexKey(account: account)) + } catch { + try? keychain.remove(snapshotKey) + throw error + } + + return candidate + } + + /// Returns E2EE snapshots in archival order without exposing mutation APIs. + func getArchivedEndToEndKeySets(account: String) throws -> [NCEndToEndKeySet] { + let indexKey = archivedEndToEndKeySetIndexKey(account: account) + guard let indexData = try keychain.getData(indexKey) else { + return [] + } + + let identifiers = try JSONDecoder().decode([String].self, from: indexData) + return try identifiers.map { identifier in + let snapshotKey = archivedEndToEndKeySetKey(account: account, identifier: identifier) + guard let snapshotData = try keychain.getData(snapshotKey) else { + throw CocoaError(.fileReadCorruptFile) + } + + let keySet = try JSONDecoder().decode(NCEndToEndKeySet.self, from: snapshotData) + guard keySet.identifier == identifier else { + throw CocoaError(.fileReadCorruptFile) + } + return keySet + } + } + + /// Clears only the active credentials, preserving archived key sets. + func clearCurrentKeysEndToEnd(account: String) { setEndToEndCertificate(account: account, certificate: nil) setEndToEndPrivateKey(account: account, privateKey: nil) setEndToEndPublicKey(account: account, publicKey: nil) setEndToEndPassphrase(account: account, passphrase: nil) } + /// Clears active and archived E2EE credentials for explicit local removal. + func clearAllKeysEndToEnd(account: String) { + clearCurrentKeysEndToEnd(account: account) + + let snapshotPrefix = archivedEndToEndKeySetPrefix(account: account) + for key in keychain.allKeys().filter({ $0.hasPrefix(snapshotPrefix) }) { + try? keychain.remove(key) + } + try? keychain.remove(archivedEndToEndKeySetIndexKey(account: account)) + } + + private func archivedEndToEndKeySetIndexKey(account: String) -> String { + "EndToEndArchivedKeySetIndex_" + account + } + + private func archivedEndToEndKeySetPrefix(account: String) -> String { + "EndToEndArchivedKeySet_" + account + "_" + } + + private func archivedEndToEndKeySetKey(account: String, identifier: String) -> String { + archivedEndToEndKeySetPrefix(account: account) + identifier + } + // MARK: - PUSH NOTIFICATION func getPushNotificationPrivateKey(account: String) -> Data? { From 0190ffa9f2f3312bb26683ad8ff4b4fb8b2d3ffc Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Thu, 20 Aug 2026 09:51:58 +0200 Subject: [PATCH 02/18] feat: support read-only access with archived E2EE keys Signed-off-by: Marino Faggiana --- Nextcloud.xcodeproj/project.pbxproj | 16 ++ .../NCEndToEndKeySetResolverTests.swift | 67 +++++++++ iOSClient/Files/NCFiles.swift | 20 ++- .../NCCollectionViewCommon.swift | 2 + iOSClient/Menu/NCContextMenuPlus.swift | 7 +- iOSClient/NCGlobal.swift | 1 + .../E2EE/NCEndToEndKeySetAccess.swift | 35 +++++ .../E2EE/NCEndToEndKeySetResolver.swift | 138 ++++++++++++++++++ .../Networking/E2EE/NCEndToEndMetadata.swift | 95 +++++++++--- .../E2EE/NCEndToEndMetadataV1.swift | 22 ++- .../E2EE/NCEndToEndMetadataV2.swift | 7 +- .../Networking/E2EE/NCNetworkingE2EE.swift | 13 +- .../en.lproj/Localizable.strings | 1 + 13 files changed, 386 insertions(+), 38 deletions(-) create mode 100644 Tests/NextcloudUnitTests/NCEndToEndKeySetResolverTests.swift create mode 100644 iOSClient/Networking/E2EE/NCEndToEndKeySetAccess.swift create mode 100644 iOSClient/Networking/E2EE/NCEndToEndKeySetResolver.swift diff --git a/Nextcloud.xcodeproj/project.pbxproj b/Nextcloud.xcodeproj/project.pbxproj index 795bbb0a8b..d0b2ffcae1 100644 --- a/Nextcloud.xcodeproj/project.pbxproj +++ b/Nextcloud.xcodeproj/project.pbxproj @@ -52,6 +52,11 @@ A1E2EE010000000000000007 /* NCEndToEndKeySet.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1E2EE010000000000000001 /* NCEndToEndKeySet.swift */; }; A1E2EE010000000000000008 /* NCEndToEndKeySet.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1E2EE010000000000000001 /* NCEndToEndKeySet.swift */; }; A1E2EE01000000000000000A /* NCEndToEndKeySetArchiveTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1E2EE010000000000000009 /* NCEndToEndKeySetArchiveTests.swift */; }; + A1E2EE020000000000000003 /* NCEndToEndKeySetAccess.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1E2EE020000000000000001 /* NCEndToEndKeySetAccess.swift */; }; + A1E2EE020000000000000004 /* NCEndToEndKeySetAccess.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1E2EE020000000000000001 /* NCEndToEndKeySetAccess.swift */; }; + A1E2EE020000000000000005 /* NCEndToEndKeySetResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1E2EE020000000000000002 /* NCEndToEndKeySetResolver.swift */; }; + A1E2EE020000000000000006 /* NCEndToEndKeySetResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1E2EE020000000000000002 /* NCEndToEndKeySetResolver.swift */; }; + A1E2EE020000000000000008 /* NCEndToEndKeySetResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1E2EE020000000000000007 /* NCEndToEndKeySetResolverTests.swift */; }; AABD0C8A2D5F67A400F009E6 /* XCUIElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = AABD0C892D5F67A200F009E6 /* XCUIElement.swift */; }; AAE330042D2ED20200B04903 /* NCShareNavigationTitleSetting.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAE330032D2ED1FF00B04903 /* NCShareNavigationTitleSetting.swift */; }; AAFC0D042F9AA10000F0A001 /* NCFocusedAutoUploadIntroView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAFC0D012F9AA10000F0A001 /* NCFocusedAutoUploadIntroView.swift */; }; @@ -1305,6 +1310,9 @@ AABBCC0930A8000100F0A001 /* NCMediaViewerModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCMediaViewerModelTests.swift; sourceTree = ""; }; A1E2EE010000000000000001 /* NCEndToEndKeySet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCEndToEndKeySet.swift; sourceTree = ""; }; A1E2EE010000000000000009 /* NCEndToEndKeySetArchiveTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCEndToEndKeySetArchiveTests.swift; sourceTree = ""; }; + A1E2EE020000000000000001 /* NCEndToEndKeySetAccess.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCEndToEndKeySetAccess.swift; sourceTree = ""; }; + A1E2EE020000000000000002 /* NCEndToEndKeySetResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCEndToEndKeySetResolver.swift; sourceTree = ""; }; + A1E2EE020000000000000007 /* NCEndToEndKeySetResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCEndToEndKeySetResolverTests.swift; sourceTree = ""; }; AABD0C862D5F58C400F009E6 /* Server.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = Server.sh; sourceTree = ""; }; AABD0C892D5F67A200F009E6 /* XCUIElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCUIElement.swift; sourceTree = ""; }; AACCAB522CFE041F00DA1786 /* sl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sl; path = sl.lproj/Intent.strings; sourceTree = ""; }; @@ -2208,6 +2216,7 @@ isa = PBXGroup; children = ( A1E2EE010000000000000009 /* NCEndToEndKeySetArchiveTests.swift */, + A1E2EE020000000000000007 /* NCEndToEndKeySetResolverTests.swift */, AABBCC0330A8000100F0A001 /* NCMediaPlaybackOptionsTests.swift */, AABBCC0730A8000100F0A001 /* NCVideoPlaybackPresentationContextTests.swift */, AABBCC0930A8000100F0A001 /* NCMediaViewerModelTests.swift */, @@ -3290,6 +3299,8 @@ children = ( F70CAE381F8CF31A008125FD /* NCEndToEndEncryption.h */, F70CAE391F8CF31A008125FD /* NCEndToEndEncryption.m */, + A1E2EE020000000000000001 /* NCEndToEndKeySetAccess.swift */, + A1E2EE020000000000000002 /* NCEndToEndKeySetResolver.swift */, F7F878AD1FB9E3B900599E4F /* NCEndToEndMetadata.swift */, F72944F12A84246400246839 /* NCEndToEndMetadataV2.swift */, F72944F42A8424F800246839 /* NCEndToEndMetadataV1.swift */, @@ -4505,6 +4516,7 @@ buildActionMask = 2147483647; files = ( A1E2EE01000000000000000A /* NCEndToEndKeySetArchiveTests.swift in Sources */, + A1E2EE020000000000000008 /* NCEndToEndKeySetResolverTests.swift in Sources */, AABBCC0430A8000100F0A001 /* NCMediaPlaybackOptionsTests.swift in Sources */, AABBCC0830A8000100F0A001 /* NCVideoPlaybackPresentationContextTests.swift in Sources */, AABBCC0A30A8000100F0A001 /* NCMediaViewerModelTests.swift in Sources */, @@ -4672,6 +4684,8 @@ F7A573692E190387009C9257 /* NCShareExtensionData.swift in Sources */, F7BDC1D4300F440A00C5D9FA /* NCManageDatabase+MediaPreviewBackfill.swift in Sources */, F7BD71E62636EAFC00643C34 /* NCNetworkingE2EE.swift in Sources */, + A1E2EE020000000000000003 /* NCEndToEndKeySetAccess.swift in Sources */, + A1E2EE020000000000000005 /* NCEndToEndKeySetResolver.swift in Sources */, F7F878AF1FB9E3B900599E4F /* NCEndToEndMetadata.swift in Sources */, F7327E3B2B73B8D600A462C7 /* Array+Extension.swift in Sources */, F7D7A76C2DCDD437003D2007 /* NCManageDatabase+AutoUpload.swift in Sources */, @@ -5054,6 +5068,8 @@ F3CA337D2D0B2B6C00672333 /* AlbumModel.swift in Sources */, AF93471A27E2361E002537EE /* NCShareHeader.swift in Sources */, F7F878AE1FB9E3B900599E4F /* NCEndToEndMetadata.swift in Sources */, + A1E2EE020000000000000004 /* NCEndToEndKeySetAccess.swift in Sources */, + A1E2EE020000000000000006 /* NCEndToEndKeySetResolver.swift in Sources */, F7A7FDDD2C2DBD6200E9A93A /* NCDeepLinkHandler.swift in Sources */, F778231E2C42C07C001BB94F /* NCCollectionViewCommon+MediaLayout.swift in Sources */, F710D1F52405770F00A6033D /* NCViewerPDF.swift in Sources */, diff --git a/Tests/NextcloudUnitTests/NCEndToEndKeySetResolverTests.swift b/Tests/NextcloudUnitTests/NCEndToEndKeySetResolverTests.swift new file mode 100644 index 0000000000..ba0c43fbdd --- /dev/null +++ b/Tests/NextcloudUnitTests/NCEndToEndKeySetResolverTests.swift @@ -0,0 +1,67 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation +import Testing +@testable import Nextcloud + +@Suite("End-to-end key set resolver") +struct NCEndToEndKeySetResolverTests { + @Test("The active key set has priority over archived key sets") + func activeKeySetHasPriority() throws { + let active = try #require(makeKeySet(identifier: "active")) + let archived = try #require(makeKeySet(identifier: "archived")) + + let access = NCEndToEndKeySetResolver().select( + currentKeySet: active, + archivedKeySets: [archived], + canDecrypt: { _ in true } + ) + + #expect(access == .active(active)) + #expect(access.canWrite) + } + + @Test("The most recently archived matching key set is selected") + func newestArchivedKeySetHasPriority() throws { + let oldest = try #require(makeKeySet(identifier: "oldest")) + let newest = try #require(makeKeySet(identifier: "newest")) + + let access = NCEndToEndKeySetResolver().select( + currentKeySet: nil, + archivedKeySets: [oldest, newest], + canDecrypt: { _ in true } + ) + + #expect(access == .archived(newest)) + #expect(access.isReadOnly) + } + + @Test("Access is unavailable when no key set can decrypt metadata") + func unavailableWithoutMatchingKeySet() throws { + let active = try #require(makeKeySet(identifier: "active")) + let archived = try #require(makeKeySet(identifier: "archived")) + + let access = NCEndToEndKeySetResolver().select( + currentKeySet: active, + archivedKeySets: [archived], + canDecrypt: { _ in false } + ) + + #expect(access == .unavailable) + #expect(!access.canWrite) + #expect(!access.isReadOnly) + } + + private func makeKeySet(identifier: String) -> NCEndToEndKeySet? { + NCEndToEndKeySet( + certificate: "certificate-" + identifier, + privateKey: "private-key-" + identifier, + publicKey: "public-key-" + identifier, + passphrase: "passphrase-" + identifier, + identifier: identifier, + archivedAt: Date(timeIntervalSince1970: 0) + ) + } +} diff --git a/iOSClient/Files/NCFiles.swift b/iOSClient/Files/NCFiles.swift index 16e6c0ff9e..97bb20e01d 100644 --- a/iOSClient/Files/NCFiles.swift +++ b/iOSClient/Files/NCFiles.swift @@ -306,6 +306,9 @@ class NCFiles: NCCollectionViewCommon { if result.error != .success { // Metadata not found ? Try to resend it if result.error.errorCode == NCGlobal.shared.errorResourceNotFound { + guard !endToEndKeySetAccess.isReadOnly else { + return result.error + } nkLog(tag: self.global.logTagE2EE, message: "E2ee metadata not found, resend.") await NCNetworkingE2EE().uploadMetadata(serverUrl: serverUrl, account: session.account) result = await NCNetworkingE2EE().getMetadata(fileId: ocId, e2eToken: lock?.e2eToken, account: session.account) @@ -322,20 +325,25 @@ class NCFiles: NCCollectionViewCommon { } // Decode metadata - returnError = await NCEndToEndMetadata().decodeMetadata(e2eMetadata, - signature: result.signature, - serverUrl: serverUrl, - session: self.session) + let decodeResult = await NCEndToEndMetadata().decodeMetadata( + e2eMetadata, + signature: result.signature, + serverUrl: serverUrl, + session: self.session + ) + returnError = decodeResult.error + endToEndKeySetAccess = decodeResult.access // Old protocolo V1 ? -> Conversion - if returnError == .success { + if returnError == .success, decodeResult.access.canWrite { let capabilities = await NKCapabilities.shared.getCapabilities(for: self.session.account) if version == "v1", capabilities.e2EEApiVersion.hasPrefix("2.") { nkLog(tag: self.global.logTagE2EE, message: "E2ee Conversion v1 to v2.") returnError = await NCNetworkingE2EE().uploadMetadata(serverUrl: serverUrl, updateVersionV1V2: true, account: session.account) } // Checksums error ? (Desktop bug) - } else if returnError.errorCode == global.errorE2EEKeyChecksums || returnError.errorCode == global.errorE2EEKeyChecksumsEmpty { + } else if decodeResult.access.canWrite, + returnError.errorCode == global.errorE2EEKeyChecksums || returnError.errorCode == global.errorE2EEKeyChecksumsEmpty { let shouldContinue = await UIAlertController.showAlert( from: self, title: "_e2ee_checksum_error_title_", diff --git a/iOSClient/Main/Collection Common/NCCollectionViewCommon.swift b/iOSClient/Main/Collection Common/NCCollectionViewCommon.swift index 7333784f8e..da7605022a 100644 --- a/iOSClient/Main/Collection Common/NCCollectionViewCommon.swift +++ b/iOSClient/Main/Collection Common/NCCollectionViewCommon.swift @@ -33,6 +33,8 @@ class NCCollectionViewCommon: UIViewController, NCAccountSettingsModelDelegate, internal var isEditMode = false // whether the displayed folder is E2EE; refreshed on each collection view data-source pass internal var isCurrentDirectoryE2EE = false + // whether the displayed E2EE folder was decoded with active or archived keys + internal var endToEndKeySetAccess: NCEndToEndKeySetAccess = .unavailable internal var fileSelect: [String] = [] internal var metadataFolder: tableMetadata? internal var richWorkspaceText: String? diff --git a/iOSClient/Menu/NCContextMenuPlus.swift b/iOSClient/Menu/NCContextMenuPlus.swift index 93e53ad6b6..5e429e0c27 100644 --- a/iOSClient/Menu/NCContextMenuPlus.swift +++ b/iOSClient/Menu/NCContextMenuPlus.swift @@ -571,10 +571,15 @@ class NCContextMenuPlus: NSObject { } private func isPlusButtonEnabled(for controller: NCMainTabBarController) -> Bool { - guard let metadataFolder = (controller.currentViewController() as? NCCollectionViewCommon)?.metadataFolder else { + guard let viewController = controller.currentViewController() as? NCCollectionViewCommon, + let metadataFolder = viewController.metadataFolder else { return true } + guard !viewController.endToEndKeySetAccess.isReadOnly else { + return false + } + guard metadataFolder.isCreatable else { return false } diff --git a/iOSClient/NCGlobal.swift b/iOSClient/NCGlobal.swift index f0483b2d98..c916f68522 100644 --- a/iOSClient/NCGlobal.swift +++ b/iOSClient/NCGlobal.swift @@ -187,6 +187,7 @@ final class NCGlobal: Sendable { let errorE2EEUploadInProgress: Int = -98018 let errorE2EEKeyDirectoryTop: Int = -98019 let errorE2EESendMetadata: Int = -98020 + let errorE2EEReadOnly: Int = -98021 // Selector diff --git a/iOSClient/Networking/E2EE/NCEndToEndKeySetAccess.swift b/iOSClient/Networking/E2EE/NCEndToEndKeySetAccess.swift new file mode 100644 index 0000000000..11c7894086 --- /dev/null +++ b/iOSClient/Networking/E2EE/NCEndToEndKeySetAccess.swift @@ -0,0 +1,35 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation + +/// Describes which local E2EE key set can read an encrypted storage space. +enum NCEndToEndKeySetAccess: Equatable, Sendable { + case active(NCEndToEndKeySet) + case archived(NCEndToEndKeySet) + case unavailable + + var keySet: NCEndToEndKeySet? { + switch self { + case .active(let keySet), .archived(let keySet): + return keySet + case .unavailable: + return nil + } + } + + var canWrite: Bool { + if case .active = self { + return true + } + return false + } + + var isReadOnly: Bool { + if case .archived = self { + return true + } + return false + } +} diff --git a/iOSClient/Networking/E2EE/NCEndToEndKeySetResolver.swift b/iOSClient/Networking/E2EE/NCEndToEndKeySetResolver.swift new file mode 100644 index 0000000000..0aac1a49f8 --- /dev/null +++ b/iOSClient/Networking/E2EE/NCEndToEndKeySetResolver.swift @@ -0,0 +1,138 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation + +/// Selects the E2EE key set that can decrypt a metadata payload. +/// +/// Selection only probes the asymmetric metadata key. It does not update the +/// database, counters, metadata, or files while trying candidate key sets. +struct NCEndToEndKeySetResolver { + private let preferences: NCPreferences + + init(preferences: NCPreferences = NCPreferences()) { + self.preferences = preferences + } + + func resolve(metadata: String, account: String, userId: String) throws -> NCEndToEndKeySetAccess { + guard let data = metadata.data(using: .utf8) else { + return .unavailable + } + + let currentKeySet = currentKeySet(account: account) + + if let metadataV1 = try? JSONDecoder().decode(NCEndToEndMetadata.E2eeV1.self, from: data) { + let encryptedMetadataKeys = Array(metadataV1.metadata.metadataKeys.values) + return try resolve( + currentKeySet: currentKeySet, + account: account, + canDecrypt: { keySet in + encryptedMetadataKeys.contains { decryptsLegacyMetadataKey($0, with: keySet) } + } + ) + } + + if let metadataV12 = try? JSONDecoder().decode(NCEndToEndMetadata.E2eeV12.self, from: data) { + return try resolve( + currentKeySet: currentKeySet, + account: account, + canDecrypt: { keySet in + decryptsLegacyMetadataKey(metadataV12.metadata.metadataKey, with: keySet) + } + ) + } + + if let metadataV2 = try? JSONDecoder().decode(NCEndToEndMetadata.E2eeV2.self, from: data) { + guard let encryptedMetadataKey = metadataV2.users? + .first(where: { $0.userId == userId })? + .encryptedMetadataKey else { + // Child V2 folders omit users and reuse the metadata key that + // was decrypted for their encrypted root. Until the root-to- + // snapshot association is persisted, preserve current behavior. + return currentKeySet.map(NCEndToEndKeySetAccess.active) ?? .unavailable + } + + return try resolve( + currentKeySet: currentKeySet, + account: account, + canDecrypt: { keySet in + decryptsMetadataKey(encryptedMetadataKey, with: keySet) + } + ) + } + + return .unavailable + } + + /// Kept internal so candidate ordering can be verified independently of + /// the platform crypto implementation. + func select( + currentKeySet: NCEndToEndKeySet?, + archivedKeySets: [NCEndToEndKeySet], + canDecrypt: (NCEndToEndKeySet) -> Bool + ) -> NCEndToEndKeySetAccess { + if let currentKeySet, canDecrypt(currentKeySet) { + return .active(currentKeySet) + } + + for keySet in archivedKeySets.reversed() where canDecrypt(keySet) { + return .archived(keySet) + } + + return .unavailable + } + + private func resolve( + currentKeySet: NCEndToEndKeySet?, + account: String, + canDecrypt: (NCEndToEndKeySet) -> Bool + ) throws -> NCEndToEndKeySetAccess { + if let currentKeySet, canDecrypt(currentKeySet) { + return .active(currentKeySet) + } + + return select( + currentKeySet: nil, + archivedKeySets: try preferences.getArchivedEndToEndKeySets(account: account), + canDecrypt: canDecrypt + ) + } + + private func currentKeySet(account: String) -> NCEndToEndKeySet? { + guard preferences.isEndToEndEnabled(account: account) else { + return nil + } + + return NCEndToEndKeySet( + certificate: preferences.getEndToEndCertificate(account: account), + privateKey: preferences.getEndToEndPrivateKey(account: account), + publicKey: preferences.getEndToEndPublicKey(account: account), + passphrase: preferences.getEndToEndPassphrase(account: account), + identifier: "active:" + account, + archivedAt: .distantFuture + ) + } + + private func decryptsLegacyMetadataKey(_ encryptedMetadataKey: String, with keySet: NCEndToEndKeySet) -> Bool { + guard let privateKey = keySet.privateKey, + let encryptedData = Data(base64Encoded: encryptedMetadataKey), + let decryptedData = NCEndToEndEncryption.shared().decryptAsymmetricData(encryptedData, privateKey: privateKey), + let decodedData = Data(base64Encoded: decryptedData), + let decodedKey = String(data: decodedData, encoding: .utf8) else { + return false + } + + return !decodedKey.isEmpty + } + + private func decryptsMetadataKey(_ encryptedMetadataKey: String, with keySet: NCEndToEndKeySet) -> Bool { + guard let privateKey = keySet.privateKey, + let encryptedData = Data(base64Encoded: encryptedMetadataKey), + let decryptedData = NCEndToEndEncryption.shared().decryptAsymmetricData(encryptedData, privateKey: privateKey) else { + return false + } + + return !decryptedData.isEmpty + } +} diff --git a/iOSClient/Networking/E2EE/NCEndToEndMetadata.swift b/iOSClient/Networking/E2EE/NCEndToEndMetadata.swift index c8414d9c5f..00775194ad 100644 --- a/iOSClient/Networking/E2EE/NCEndToEndMetadata.swift +++ b/iOSClient/Networking/E2EE/NCEndToEndMetadata.swift @@ -34,32 +34,87 @@ class NCEndToEndMetadata: NSObject { // MARK: Decode JSON Metadata Bridge // -------------------------------------------------------------------------------------------- - func decodeMetadata(_ metadata: String, signature: String?, serverUrl: String, session: NCSession.Session) async -> NKError { + func decodeMetadata( + _ metadata: String, + signature: String?, + serverUrl: String, + session: NCSession.Session + ) async -> (error: NKError, access: NCEndToEndKeySetAccess) { guard let data = metadata.data(using: .utf8), let directory = self.database.getTableDirectory(predicate: NSPredicate(format: "account == %@ AND serverUrl == %@", session.account, serverUrl)) else { - return (NKError(errorCode: NCGlobal.shared.errorE2EEJSon, errorDescription: "Unable to decode the metadata file")) + return ( + NKError(errorCode: NCGlobal.shared.errorE2EEJSon, errorDescription: "Unable to decode the metadata file"), + .unavailable + ) } data.printJson() - if (try? JSONDecoder().decode(E2eeV1.self, from: data)) != nil { - return await decodeMetadataV1(metadata, - serverUrl: serverUrl, - ocIdServerUrl: directory.ocId, - session: session) - } else if (try? JSONDecoder().decode(E2eeV12.self, from: data)) != nil { - return await decodeMetadataV12(metadata, - serverUrl: serverUrl, - ocIdServerUrl: directory.ocId, - session: session) - } else if (try? JSONDecoder().decode(E2eeV2.self, from: data)) != nil { - return await decodeMetadataV2(metadata, - signature: signature, - serverUrl: serverUrl, - ocIdServerUrl: directory.ocId, - session: session) + let isMetadataV1 = (try? JSONDecoder().decode(E2eeV1.self, from: data)) != nil + let isMetadataV12 = (try? JSONDecoder().decode(E2eeV12.self, from: data)) != nil + let isMetadataV2 = (try? JSONDecoder().decode(E2eeV2.self, from: data)) != nil + + guard isMetadataV1 || isMetadataV12 || isMetadataV2 else { + return ( + NKError( + errorCode: NCGlobal.shared.errorE2EEVersion, + errorDescription: "Unable to decode the metadata file" + ), + .unavailable + ) + } + + let access: NCEndToEndKeySetAccess + do { + access = try NCEndToEndKeySetResolver().resolve( + metadata: metadata, + account: session.account, + userId: session.userId + ) + } catch { + return ( + NKError(errorCode: NCGlobal.shared.errorInternalError, errorDescription: error.localizedDescription), + .unavailable + ) + } + + guard let keySet = access.keySet else { + return ( + NKError( + errorCode: NCGlobal.shared.errorE2EENoUserFound, + errorDescription: NSLocalizedString("_e2ee_no_metadataKey_found_", comment: "") + ), + .unavailable + ) + } + + let error: NKError + if isMetadataV1 { + error = await decodeMetadataV1( + metadata, + serverUrl: serverUrl, + ocIdServerUrl: directory.ocId, + session: session, + keySet: keySet + ) + } else if isMetadataV12 { + error = await decodeMetadataV12( + metadata, + serverUrl: serverUrl, + ocIdServerUrl: directory.ocId, + session: session, + keySet: keySet + ) } else { - return NKError(errorCode: NCGlobal.shared.errorE2EEVersion, - errorDescription: "Unable to decode the metadata file") + error = await decodeMetadataV2( + metadata, + signature: signature, + serverUrl: serverUrl, + ocIdServerUrl: directory.ocId, + session: session, + keySet: keySet + ) } + + return (error, access) } } diff --git a/iOSClient/Networking/E2EE/NCEndToEndMetadataV1.swift b/iOSClient/Networking/E2EE/NCEndToEndMetadataV1.swift index d84a5c760d..3f87c432ea 100644 --- a/iOSClient/Networking/E2EE/NCEndToEndMetadataV1.swift +++ b/iOSClient/Networking/E2EE/NCEndToEndMetadataV1.swift @@ -177,7 +177,13 @@ extension NCEndToEndMetadata { // MARK: Decode JSON Metadata V1.2 // -------------------------------------------------------------------------------------------- - func decodeMetadataV12(_ json: String, serverUrl: String, ocIdServerUrl: String, session: NCSession.Session) async -> NKError { + func decodeMetadataV12( + _ json: String, + serverUrl: String, + ocIdServerUrl: String, + session: NCSession.Session, + keySet: NCEndToEndKeySet + ) async -> NKError { guard let data = json.data(using: .utf8) else { return NKError(errorCode: NCGlobal.shared.errorE2EEJSon, @@ -185,7 +191,7 @@ extension NCEndToEndMetadata { } let decoder = JSONDecoder() - let privateKey = NCPreferences().getEndToEndPrivateKey(account: session.account) + let privateKey = keySet.privateKey var metadataVersion: Double = 0 var metadataKey = "" @@ -333,7 +339,7 @@ extension NCEndToEndMetadata { } // verify checksum - let passphrase = NCPreferences().getEndToEndPassphrase(account: session.account)?.replacingOccurrences(of: " ", with: "") ?? "" + let passphrase = keySet.passphrase?.replacingOccurrences(of: " ", with: "") ?? "" let dataChecksum = (passphrase + fileNameIdentifiers.sorted().joined() + metadata.metadataKey).data(using: .utf8) let checksum = NCEndToEndEncryption.shared().createSHA256(dataChecksum) if metadata.checksum != checksum { @@ -351,7 +357,13 @@ extension NCEndToEndMetadata { // MARK: Decode JSON Metadata V1.1 // -------------------------------------------------------------------------------------------- - func decodeMetadataV1(_ json: String, serverUrl: String, ocIdServerUrl: String, session: NCSession.Session) async -> NKError { + func decodeMetadataV1( + _ json: String, + serverUrl: String, + ocIdServerUrl: String, + session: NCSession.Session, + keySet: NCEndToEndKeySet + ) async -> NKError { guard let data = json.data(using: .utf8) else { return NKError(errorCode: NCGlobal.shared.errorE2EEJSon, @@ -359,7 +371,7 @@ extension NCEndToEndMetadata { } let decoder = JSONDecoder() - let privateKey = NCPreferences().getEndToEndPrivateKey(account: session.account) + let privateKey = keySet.privateKey var metadataVersion: Double = 0 do { diff --git a/iOSClient/Networking/E2EE/NCEndToEndMetadataV2.swift b/iOSClient/Networking/E2EE/NCEndToEndMetadataV2.swift index 828db255dd..16bcdf41b1 100644 --- a/iOSClient/Networking/E2EE/NCEndToEndMetadataV2.swift +++ b/iOSClient/Networking/E2EE/NCEndToEndMetadataV2.swift @@ -214,7 +214,8 @@ extension NCEndToEndMetadata { signature: String?, serverUrl: String, ocIdServerUrl: String, - session: NCSession.Session) async -> NKError { + session: NCSession.Session, + keySet: NCEndToEndKeySet) async -> NKError { guard let data = json.data(using: .utf8), let directoryTop = await utilityFileSystem.getMetadataE2EETopAsync(serverUrl: serverUrl, session: session) else { return NKError(errorCode: NCGlobal.shared.errorE2EEKeyDirectoryTop, @@ -274,7 +275,7 @@ extension NCEndToEndMetadata { var metadataKey: Data? if let encryptedMetadataKey = user.encryptedMetadataKey { let data = Data(base64Encoded: encryptedMetadataKey) - if let decrypted = NCEndToEndEncryption.shared().decryptAsymmetricData(data, privateKey: NCPreferences().getEndToEndPrivateKey(account: session.account)) { + if let decrypted = NCEndToEndEncryption.shared().decryptAsymmetricData(data, privateKey: keySet.privateKey) { metadataKey = decrypted } } @@ -302,7 +303,7 @@ extension NCEndToEndMetadata { let authenticationTag = filedop.value.authenticationTag for user in filedop.value.users where user.userId == session.userId { let data = Data(base64Encoded: user.encryptedFiledropKey) - if let decryptedFiledropKey = NCEndToEndEncryption.shared().decryptAsymmetricData(data, privateKey: NCPreferences().getEndToEndPrivateKey(account: session.account)) { + if let decryptedFiledropKey = NCEndToEndEncryption.shared().decryptAsymmetricData(data, privateKey: keySet.privateKey) { let filedropKey = decryptedFiledropKey.base64EncodedString() guard let decryptedFiledrop = NCEndToEndEncryption.shared().decryptPayloadFile(ciphertext, key: filedropKey, initializationVector: nonce, authenticationTag: authenticationTag), decryptedFiledrop.isGzipped else { diff --git a/iOSClient/Networking/E2EE/NCNetworkingE2EE.swift b/iOSClient/Networking/E2EE/NCNetworkingE2EE.swift index 644fc78d28..e4916c4ef1 100644 --- a/iOSClient/Networking/E2EE/NCNetworkingE2EE.swift +++ b/iOSClient/Networking/E2EE/NCNetworkingE2EE.swift @@ -248,11 +248,18 @@ class NCNetworkingE2EE: NSObject { return resultsGetE2EEMetadata.error } - let resultsDecodeMetadataError = await NCEndToEndMetadata().decodeMetadata(e2eMetadata, signature: resultsGetE2EEMetadata.signature, serverUrl: serverUrl, session: session) - guard resultsDecodeMetadataError == .success else { + let decodeResult = await NCEndToEndMetadata().decodeMetadata(e2eMetadata, signature: resultsGetE2EEMetadata.signature, serverUrl: serverUrl, session: session) + guard decodeResult.error == .success else { // Client Diagnostic await self.database.addDiagnosticAsync(account: session.account, issue: NCGlobal.shared.diagnosticIssueE2eeErrors) - return resultsDecodeMetadataError + return decodeResult.error + } + + guard decodeResult.access.canWrite else { + return NKError( + errorCode: NCGlobal.shared.errorE2EEReadOnly, + errorDescription: NSLocalizedString("_e2ee_read_only_", comment: "") + ) } return NKError() diff --git a/iOSClient/Supporting Files/en.lproj/Localizable.strings b/iOSClient/Supporting Files/en.lproj/Localizable.strings index edc26aaeae..d12d132182 100644 --- a/iOSClient/Supporting Files/en.lproj/Localizable.strings +++ b/iOSClient/Supporting Files/en.lproj/Localizable.strings @@ -823,6 +823,7 @@ You can stop it at any time, adjust the settings, and enable it again."; "_e2ee_no_certificate_" = "End-to-end encryption error, cannot get certificate"; "_e2ee_no_generate_key_" = "End-to-end encryption error, cannot generate key"; "_e2ee_no_metadataKey_found_" = "End-to-end encryption error, cannot decrypt metadata key"; +"_e2ee_read_only_" = "This encrypted folder uses an archived key set and is available as read-only."; "_e2ee_no_user_found_" = "End-to-end encryption error, user not found"; "_e2ee_no_signature_found_" = "End-to-end encryption error, no signature found"; "_e2ee_counter_check_" = "End-to-end encryption error, the counter is lower than the previous one"; From e9d15b6f63ff4fefe688bf30c05acd88aad05e8c Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Thu, 20 Aug 2026 10:07:02 +0200 Subject: [PATCH 03/18] fix: reuse root metadata key for E2EE v2 child folders Signed-off-by: Marino Faggiana --- .../NCEndToEndKeySetResolverTests.swift | 28 +++++++++++++++ .../NCCollectionViewCommon.swift | 2 ++ .../E2EE/NCEndToEndKeySetResolver.swift | 36 ++++++++++++++----- .../Networking/E2EE/NCEndToEndMetadata.swift | 18 ++++++++-- 4 files changed, 74 insertions(+), 10 deletions(-) diff --git a/Tests/NextcloudUnitTests/NCEndToEndKeySetResolverTests.swift b/Tests/NextcloudUnitTests/NCEndToEndKeySetResolverTests.swift index ba0c43fbdd..a763764a1e 100644 --- a/Tests/NextcloudUnitTests/NCEndToEndKeySetResolverTests.swift +++ b/Tests/NextcloudUnitTests/NCEndToEndKeySetResolverTests.swift @@ -54,6 +54,34 @@ struct NCEndToEndKeySetResolverTests { #expect(!access.isReadOnly) } + @Test("A V2 child folder inherits its encrypted root metadata key") + func childFolderUsesRootEncryptedMetadataKey() { + let encryptedMetadataKey = NCEndToEndKeySetResolver().encryptedMetadataKey( + users: nil, + userId: "user", + rootEncryptedMetadataKey: "root-key" + ) + + #expect(encryptedMetadataKey == "root-key") + } + + @Test("A V2 root never falls back to a previously saved user key") + func rootWithoutCurrentUserDoesNotUseSavedKey() { + let otherUser = NCEndToEndMetadata.E2eeV2.Users( + userId: "other-user", + certificate: "certificate", + encryptedMetadataKey: "other-key" + ) + + let encryptedMetadataKey = NCEndToEndKeySetResolver().encryptedMetadataKey( + users: [otherUser], + userId: "user", + rootEncryptedMetadataKey: "saved-root-key" + ) + + #expect(encryptedMetadataKey == nil) + } + private func makeKeySet(identifier: String) -> NCEndToEndKeySet? { NCEndToEndKeySet( certificate: "certificate-" + identifier, diff --git a/iOSClient/Main/Collection Common/NCCollectionViewCommon.swift b/iOSClient/Main/Collection Common/NCCollectionViewCommon.swift index da7605022a..c5cb7146d0 100644 --- a/iOSClient/Main/Collection Common/NCCollectionViewCommon.swift +++ b/iOSClient/Main/Collection Common/NCCollectionViewCommon.swift @@ -662,6 +662,7 @@ class NCCollectionViewCommon: UIViewController, NCAccountSettingsModelDelegate, $0.navigationController === navigationController && $0.serverUrl == serverUrlPush }) { let viewController = existingEntry.viewController + viewController.endToEndKeySetAccess = endToEndKeySetAccess if navigationController.topViewController === viewController { return @@ -683,6 +684,7 @@ class NCCollectionViewCommon: UIViewController, NCAccountSettingsModelDelegate, viewController.serverUrl = serverUrlPush viewController.titlePreviusFolder = navigationItem.title viewController.titleCurrentFolder = metadata.fileNameView + viewController.endToEndKeySetAccess = endToEndKeySetAccess navigationCollectionViewCommon.append( NavigationCollectionViewCommon( diff --git a/iOSClient/Networking/E2EE/NCEndToEndKeySetResolver.swift b/iOSClient/Networking/E2EE/NCEndToEndKeySetResolver.swift index 0aac1a49f8..b3099cd224 100644 --- a/iOSClient/Networking/E2EE/NCEndToEndKeySetResolver.swift +++ b/iOSClient/Networking/E2EE/NCEndToEndKeySetResolver.swift @@ -15,7 +15,12 @@ struct NCEndToEndKeySetResolver { self.preferences = preferences } - func resolve(metadata: String, account: String, userId: String) throws -> NCEndToEndKeySetAccess { + func resolve( + metadata: String, + account: String, + userId: String, + rootEncryptedMetadataKey: String? = nil + ) throws -> NCEndToEndKeySetAccess { guard let data = metadata.data(using: .utf8) else { return .unavailable } @@ -44,13 +49,12 @@ struct NCEndToEndKeySetResolver { } if let metadataV2 = try? JSONDecoder().decode(NCEndToEndMetadata.E2eeV2.self, from: data) { - guard let encryptedMetadataKey = metadataV2.users? - .first(where: { $0.userId == userId })? - .encryptedMetadataKey else { - // Child V2 folders omit users and reuse the metadata key that - // was decrypted for their encrypted root. Until the root-to- - // snapshot association is persisted, preserve current behavior. - return currentKeySet.map(NCEndToEndKeySetAccess.active) ?? .unavailable + guard let encryptedMetadataKey = encryptedMetadataKey( + users: metadataV2.users, + userId: userId, + rootEncryptedMetadataKey: rootEncryptedMetadataKey + ) else { + return .unavailable } return try resolve( @@ -65,6 +69,22 @@ struct NCEndToEndKeySetResolver { return .unavailable } + /// Child V2 metadata omits users and therefore reuses the encrypted key + /// saved when its encrypted root was decoded. + func encryptedMetadataKey( + users: [NCEndToEndMetadata.E2eeV2.Users]?, + userId: String, + rootEncryptedMetadataKey: String? + ) -> String? { + if let users { + return users + .first(where: { $0.userId == userId })? + .encryptedMetadataKey + } + + return rootEncryptedMetadataKey + } + /// Kept internal so candidate ordering can be verified independently of /// the platform crypto implementation. func select( diff --git a/iOSClient/Networking/E2EE/NCEndToEndMetadata.swift b/iOSClient/Networking/E2EE/NCEndToEndMetadata.swift index 00775194ad..ff0324fc3f 100644 --- a/iOSClient/Networking/E2EE/NCEndToEndMetadata.swift +++ b/iOSClient/Networking/E2EE/NCEndToEndMetadata.swift @@ -51,7 +51,8 @@ class NCEndToEndMetadata: NSObject { let isMetadataV1 = (try? JSONDecoder().decode(E2eeV1.self, from: data)) != nil let isMetadataV12 = (try? JSONDecoder().decode(E2eeV12.self, from: data)) != nil - let isMetadataV2 = (try? JSONDecoder().decode(E2eeV2.self, from: data)) != nil + let metadataV2 = try? JSONDecoder().decode(E2eeV2.self, from: data) + let isMetadataV2 = metadataV2 != nil guard isMetadataV1 || isMetadataV12 || isMetadataV2 else { return ( @@ -63,12 +64,25 @@ class NCEndToEndMetadata: NSObject { ) } + var rootEncryptedMetadataKey: String? + if let metadataV2, + metadataV2.users == nil, + let directoryTop = await utilityFileSystem.getMetadataE2EETopAsync(serverUrl: serverUrl, session: session), + let tableUser = await database.getE2EUserAsync( + account: session.account, + directoryTopOcId: directoryTop.ocId, + userId: session.userId + ) { + rootEncryptedMetadataKey = tableUser.encryptedMetadataKey + } + let access: NCEndToEndKeySetAccess do { access = try NCEndToEndKeySetResolver().resolve( metadata: metadata, account: session.account, - userId: session.userId + userId: session.userId, + rootEncryptedMetadataKey: rootEncryptedMetadataKey ) } catch { return ( From 462e11aef69f92ee872eaa2248722f6142867fc6 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Thu, 20 Aug 2026 10:19:17 +0200 Subject: [PATCH 04/18] fix: enforce E2EE write access before metadata changes Signed-off-by: Marino Faggiana --- iOSClient/Files/NCFiles.swift | 20 ++++- iOSClient/Menu/NCContextMenuMain.swift | 13 +++ .../E2EE/NCEndToEndKeySetResolver.swift | 10 +++ .../Networking/E2EE/NCEndToEndMetadata.swift | 78 ++++++++++++---- .../Networking/E2EE/NCNetworkingE2EE.swift | 90 +++++++++++++++++-- .../Advanced/NCShareAdvancePermission.swift | 1 + 6 files changed, 185 insertions(+), 27 deletions(-) diff --git a/iOSClient/Files/NCFiles.swift b/iOSClient/Files/NCFiles.swift index 97bb20e01d..b11528db8c 100644 --- a/iOSClient/Files/NCFiles.swift +++ b/iOSClient/Files/NCFiles.swift @@ -306,8 +306,26 @@ class NCFiles: NCCollectionViewCommon { if result.error != .success { // Metadata not found ? Try to resend it if result.error.errorCode == NCGlobal.shared.errorResourceNotFound { + do { + let storedAccess = try await NCEndToEndMetadata().resolveStoredRootKeySetAccess( + serverUrl: serverUrl, + session: session + ) + if storedAccess.keySet != nil { + endToEndKeySetAccess = storedAccess + } + } catch { + return NKError( + errorCode: global.errorInternalError, + errorDescription: error.localizedDescription + ) + } + guard !endToEndKeySetAccess.isReadOnly else { - return result.error + return NKError( + errorCode: global.errorE2EEReadOnly, + errorDescription: NSLocalizedString("_e2ee_read_only_", comment: "") + ) } nkLog(tag: self.global.logTagE2EE, message: "E2ee metadata not found, resend.") await NCNetworkingE2EE().uploadMetadata(serverUrl: serverUrl, account: session.account) diff --git a/iOSClient/Menu/NCContextMenuMain.swift b/iOSClient/Menu/NCContextMenuMain.swift index d8b673ca85..e822f5b7ca 100644 --- a/iOSClient/Menu/NCContextMenuMain.swift +++ b/iOSClient/Menu/NCContextMenuMain.swift @@ -223,6 +223,19 @@ class NCContextMenuMain: NSObject { image: utility.loadImage(named: "lock", colors: [NCBrandColor.shared.iconImageColor]) ) { _ in Task { + let accessError = await NCNetworkingE2EE().validateWriteAccess( + serverUrl: metadata.serverUrlFileName, + account: metadata.account + ) + guard accessError == .success else { + await showErrorBanner( + windowScene: self.windowScene, + text: accessError.errorDescription, + errorCode: accessError.errorCode + ) + return + } + let results = await NextcloudKit.shared.markE2EEFolderAsync( fileId: metadata.fileId, delete: true, diff --git a/iOSClient/Networking/E2EE/NCEndToEndKeySetResolver.swift b/iOSClient/Networking/E2EE/NCEndToEndKeySetResolver.swift index b3099cd224..8746bb28a6 100644 --- a/iOSClient/Networking/E2EE/NCEndToEndKeySetResolver.swift +++ b/iOSClient/Networking/E2EE/NCEndToEndKeySetResolver.swift @@ -69,6 +69,16 @@ struct NCEndToEndKeySetResolver { return .unavailable } + func resolve(encryptedMetadataKey: String, account: String) throws -> NCEndToEndKeySetAccess { + try resolve( + currentKeySet: currentKeySet(account: account), + account: account, + canDecrypt: { keySet in + decryptsMetadataKey(encryptedMetadataKey, with: keySet) + } + ) + } + /// Child V2 metadata omits users and therefore reuses the encrypted key /// saved when its encrypted root was decoded. func encryptedMetadataKey( diff --git a/iOSClient/Networking/E2EE/NCEndToEndMetadata.swift b/iOSClient/Networking/E2EE/NCEndToEndMetadata.swift index ff0324fc3f..3482f80a93 100644 --- a/iOSClient/Networking/E2EE/NCEndToEndMetadata.swift +++ b/iOSClient/Networking/E2EE/NCEndToEndMetadata.swift @@ -64,26 +64,9 @@ class NCEndToEndMetadata: NSObject { ) } - var rootEncryptedMetadataKey: String? - if let metadataV2, - metadataV2.users == nil, - let directoryTop = await utilityFileSystem.getMetadataE2EETopAsync(serverUrl: serverUrl, session: session), - let tableUser = await database.getE2EUserAsync( - account: session.account, - directoryTopOcId: directoryTop.ocId, - userId: session.userId - ) { - rootEncryptedMetadataKey = tableUser.encryptedMetadataKey - } - let access: NCEndToEndKeySetAccess do { - access = try NCEndToEndKeySetResolver().resolve( - metadata: metadata, - account: session.account, - userId: session.userId, - rootEncryptedMetadataKey: rootEncryptedMetadataKey - ) + access = try await resolveKeySetAccess(metadata, serverUrl: serverUrl, session: session) } catch { return ( NKError(errorCode: NCGlobal.shared.errorInternalError, errorDescription: error.localizedDescription), @@ -131,4 +114,63 @@ class NCEndToEndMetadata: NSObject { return (error, access) } + + func resolveKeySetAccess( + _ metadata: String, + serverUrl: String, + session: NCSession.Session + ) async throws -> NCEndToEndKeySetAccess { + var rootEncryptedMetadataKey: String? + if let data = metadata.data(using: .utf8), + let metadataV2 = try? JSONDecoder().decode(E2eeV2.self, from: data), + metadataV2.users == nil { + rootEncryptedMetadataKey = try await storedRootEncryptedMetadataKey( + serverUrl: serverUrl, + session: session + ) + } + + return try NCEndToEndKeySetResolver().resolve( + metadata: metadata, + account: session.account, + userId: session.userId, + rootEncryptedMetadataKey: rootEncryptedMetadataKey + ) + } + + func resolveStoredRootKeySetAccess( + serverUrl: String, + session: NCSession.Session + ) async throws -> NCEndToEndKeySetAccess { + guard let encryptedMetadataKey = try await storedRootEncryptedMetadataKey( + serverUrl: serverUrl, + session: session + ) else { + return .unavailable + } + + return try NCEndToEndKeySetResolver().resolve( + encryptedMetadataKey: encryptedMetadataKey, + account: session.account + ) + } + + private func storedRootEncryptedMetadataKey( + serverUrl: String, + session: NCSession.Session + ) async throws -> String? { + guard let directoryTop = await utilityFileSystem.getMetadataE2EETopAsync( + serverUrl: serverUrl, + session: session + ), + let tableUser = await database.getE2EUserAsync( + account: session.account, + directoryTopOcId: directoryTop.ocId, + userId: session.userId + ) else { + return nil + } + + return tableUser.encryptedMetadataKey + } } diff --git a/iOSClient/Networking/E2EE/NCNetworkingE2EE.swift b/iOSClient/Networking/E2EE/NCNetworkingE2EE.swift index e4916c4ef1..c19c658583 100644 --- a/iOSClient/Networking/E2EE/NCNetworkingE2EE.swift +++ b/iOSClient/Networking/E2EE/NCNetworkingE2EE.swift @@ -163,17 +163,29 @@ class NCNetworkingE2EE: NSObject { return resultsLock.error } - // METHOD + // METHOD + WRITE ACCESS // - if updateVersionV1V2 { + let resultsGetE2EEMetadata = await getMetadata(fileId: fileId, e2eToken: e2eToken, account: session.account) + if resultsGetE2EEMetadata.error == .success, + let e2eMetadata = resultsGetE2EEMetadata.e2eMetadata { + let accessError = await validateWriteAccess( + e2eMetadata: e2eMetadata, + serverUrl: serverUrl, + session: session + ) + guard accessError == .success else { + await unlock(account: session.account, serverUrl: serverUrl) + return accessError + } method = "PUT" + } else if resultsGetE2EEMetadata.error.errorCode == NCGlobal.shared.errorResourceNotFound, + !updateVersionV1V2 { + method = "POST" } else { - let resultsGetE2EEMetadata = await getMetadata(fileId: fileId, e2eToken: e2eToken, account: session.account) - if resultsGetE2EEMetadata.error == .success { - method = "PUT" - } else if resultsGetE2EEMetadata.error.errorCode != NCGlobal.shared.errorResourceNotFound { - return resultsGetE2EEMetadata.error - } + await unlock(account: session.account, serverUrl: serverUrl) + return resultsGetE2EEMetadata.error == .success + ? .invalidData + : resultsGetE2EEMetadata.error } // UPLOAD METADATA @@ -200,6 +212,68 @@ class NCNetworkingE2EE: NSObject { return NKError() } + func validateWriteAccess(serverUrl: String, account: String) async -> NKError { + let session = NCSession.shared.getSession(account: account) + let resultsLock = await lock(account: account, serverUrl: serverUrl) + guard resultsLock.error == .success, + let e2eToken = resultsLock.e2eToken, + let fileId = resultsLock.fileId else { + return resultsLock.error + } + + let metadataResult = await getMetadata(fileId: fileId, e2eToken: e2eToken, account: account) + let error: NKError + if metadataResult.error == .success, + let e2eMetadata = metadataResult.e2eMetadata { + error = await validateWriteAccess( + e2eMetadata: e2eMetadata, + serverUrl: serverUrl, + session: session + ) + } else { + error = metadataResult.error == .success + ? .invalidData + : metadataResult.error + } + + await unlock(account: account, serverUrl: serverUrl) + return error + } + + private func validateWriteAccess( + e2eMetadata: String, + serverUrl: String, + session: NCSession.Session + ) async -> NKError { + let access: NCEndToEndKeySetAccess + do { + access = try await NCEndToEndMetadata().resolveKeySetAccess( + e2eMetadata, + serverUrl: serverUrl, + session: session + ) + } catch { + return NKError( + errorCode: NCGlobal.shared.errorInternalError, + errorDescription: error.localizedDescription + ) + } + + if access.canWrite { + return .success + } else if access.isReadOnly { + return NKError( + errorCode: NCGlobal.shared.errorE2EEReadOnly, + errorDescription: NSLocalizedString("_e2ee_read_only_", comment: "") + ) + } else { + return NKError( + errorCode: NCGlobal.shared.errorE2EENoUserFound, + errorDescription: NSLocalizedString("_e2ee_no_metadataKey_found_", comment: "") + ) + } + } + func uploadMetadata(serverUrl: String, ocIdServerUrl: String, fileId: String, diff --git a/iOSClient/Share/Advanced/NCShareAdvancePermission.swift b/iOSClient/Share/Advanced/NCShareAdvancePermission.swift index 890c88a9bd..d26cb53f58 100644 --- a/iOSClient/Share/Advanced/NCShareAdvancePermission.swift +++ b/iOSClient/Share/Advanced/NCShareAdvancePermission.swift @@ -259,6 +259,7 @@ class NCShareAdvancePermission: UITableViewController, NCShareAdvanceFooterDeleg if error != .success { await showErrorBanner(windowScene: windowScene, error: error) + return } } From 24a69485cc8dac1e287bf1a17ca9a118e7214983 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Thu, 20 Aug 2026 11:08:03 +0200 Subject: [PATCH 05/18] fix: handle empty users in E2EE v2 metadata Reuse the encrypted root key only for child metadata and verify empty-user signatures with the current certificate. Update Firebase dependencies to 12.18.0. Signed-off-by: Marino Faggiana --- .../xcshareddata/swiftpm/Package.resolved | 8 ++++---- .../NCEndToEndKeySetResolverTests.swift | 11 +++++++++++ .../Networking/E2EE/NCEndToEndKeySetResolver.swift | 6 +++--- iOSClient/Networking/E2EE/NCEndToEndMetadata.swift | 13 +++++++++---- .../Networking/E2EE/NCEndToEndMetadataV2.swift | 2 +- 5 files changed, 28 insertions(+), 12 deletions(-) diff --git a/Nextcloud.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Nextcloud.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index cd727d782e..2f5bf1751d 100644 --- a/Nextcloud.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Nextcloud.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -60,8 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/firebase/firebase-ios-sdk", "state" : { - "revision" : "33a468adfdb75b53f05a37e7c886ca7c962b5c17", - "version" : "12.17.0" + "revision" : "346daa9f46316aa372b35b317e18224acc2e9063", + "version" : "12.18.0" } }, { @@ -78,8 +78,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/GoogleAppMeasurement.git", "state" : { - "revision" : "fceaffa07d22dcd5624d3639fd970351a4a5ad8c", - "version" : "12.17.0" + "revision" : "f04760d460296cc0fa430935a7be212e5bd67fc5", + "version" : "12.18.0" } }, { diff --git a/Tests/NextcloudUnitTests/NCEndToEndKeySetResolverTests.swift b/Tests/NextcloudUnitTests/NCEndToEndKeySetResolverTests.swift index a763764a1e..739286b2c4 100644 --- a/Tests/NextcloudUnitTests/NCEndToEndKeySetResolverTests.swift +++ b/Tests/NextcloudUnitTests/NCEndToEndKeySetResolverTests.swift @@ -65,6 +65,17 @@ struct NCEndToEndKeySetResolverTests { #expect(encryptedMetadataKey == "root-key") } + @Test("A V2 child folder with an empty users array inherits its encrypted root metadata key") + func childFolderWithEmptyUsersUsesRootEncryptedMetadataKey() { + let encryptedMetadataKey = NCEndToEndKeySetResolver().encryptedMetadataKey( + users: [], + userId: "user", + rootEncryptedMetadataKey: "root-key" + ) + + #expect(encryptedMetadataKey == "root-key") + } + @Test("A V2 root never falls back to a previously saved user key") func rootWithoutCurrentUserDoesNotUseSavedKey() { let otherUser = NCEndToEndMetadata.E2eeV2.Users( diff --git a/iOSClient/Networking/E2EE/NCEndToEndKeySetResolver.swift b/iOSClient/Networking/E2EE/NCEndToEndKeySetResolver.swift index 8746bb28a6..ba562aaaa3 100644 --- a/iOSClient/Networking/E2EE/NCEndToEndKeySetResolver.swift +++ b/iOSClient/Networking/E2EE/NCEndToEndKeySetResolver.swift @@ -79,14 +79,14 @@ struct NCEndToEndKeySetResolver { ) } - /// Child V2 metadata omits users and therefore reuses the encrypted key - /// saved when its encrypted root was decoded. + /// Child V2 metadata omits users, or encodes them as an empty array, and + /// therefore reuses the encrypted key saved when its encrypted root was decoded. func encryptedMetadataKey( users: [NCEndToEndMetadata.E2eeV2.Users]?, userId: String, rootEncryptedMetadataKey: String? ) -> String? { - if let users { + if let users, !users.isEmpty { return users .first(where: { $0.userId == userId })? .encryptedMetadataKey diff --git a/iOSClient/Networking/E2EE/NCEndToEndMetadata.swift b/iOSClient/Networking/E2EE/NCEndToEndMetadata.swift index 3482f80a93..73da37ac91 100644 --- a/iOSClient/Networking/E2EE/NCEndToEndMetadata.swift +++ b/iOSClient/Networking/E2EE/NCEndToEndMetadata.swift @@ -120,11 +120,16 @@ class NCEndToEndMetadata: NSObject { serverUrl: String, session: NCSession.Session ) async throws -> NCEndToEndKeySetAccess { - var rootEncryptedMetadataKey: String? + var childRootEncryptedMetadataKey: String? if let data = metadata.data(using: .utf8), let metadataV2 = try? JSONDecoder().decode(E2eeV2.self, from: data), - metadataV2.users == nil { - rootEncryptedMetadataKey = try await storedRootEncryptedMetadataKey( + metadataV2.users?.isEmpty != false, + let directoryTop = await utilityFileSystem.getMetadataE2EETopAsync( + serverUrl: serverUrl, + session: session + ), + serverUrl != directoryTop.serverUrlFileName { + childRootEncryptedMetadataKey = try await storedRootEncryptedMetadataKey( serverUrl: serverUrl, session: session ) @@ -134,7 +139,7 @@ class NCEndToEndMetadata: NSObject { metadata: metadata, account: session.account, userId: session.userId, - rootEncryptedMetadataKey: rootEncryptedMetadataKey + rootEncryptedMetadataKey: childRootEncryptedMetadataKey ) } diff --git a/iOSClient/Networking/E2EE/NCEndToEndMetadataV2.swift b/iOSClient/Networking/E2EE/NCEndToEndMetadataV2.swift index 16bcdf41b1..c91608f64c 100644 --- a/iOSClient/Networking/E2EE/NCEndToEndMetadataV2.swift +++ b/iOSClient/Networking/E2EE/NCEndToEndMetadataV2.swift @@ -477,7 +477,7 @@ extension NCEndToEndMetadata { usersSignatureCodable.append(E2eeV2Signature.Users(userId: user.userId, certificate: user.certificate, encryptedMetadataKey: user.encryptedMetadataKey)) } signatureCodable = E2eeV2Signature(metadata: E2eeV2Signature.Metadata(ciphertext: metadata.ciphertext, nonce: metadata.nonce, authenticationTag: metadata.authenticationTag), users: usersSignatureCodable, version: version) - certificates = users.map { $0.certificate } + certificates = users.isEmpty ? [certificate] : users.map { $0.certificate } } else { signatureCodable = E2eeV2Signature(metadata: E2eeV2Signature.Metadata(ciphertext: metadata.ciphertext, nonce: metadata.nonce, authenticationTag: metadata.authenticationTag), users: nil, version: version) certificates = [certificate] From 9525d3133903cb4b8491e8509fdf4eb9b46571a5 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Thu, 20 Aug 2026 12:09:43 +0200 Subject: [PATCH 06/18] fix: enforce E2EE key set write access Block metadata recovery and destructive operations when only archived or unavailable keys can decrypt the folder. Allow explicit initial metadata creation for newly encrypted folders. Signed-off-by: Marino Faggiana --- .../NCEndToEndKeySetResolverTests.swift | 3 + iOSClient/Files/NCFiles.swift | 12 +-- iOSClient/Menu/NCContextMenuPlus.swift | 8 +- .../E2EE/NCEndToEndKeySetAccess.swift | 19 ++++ .../Networking/E2EE/NCNetworkingE2EE.swift | 95 ++++++++++++++----- .../E2EE/NCNetworkingE2EECreateFolder.swift | 5 +- .../E2EE/NCNetworkingE2EEDelete.swift | 21 ++-- .../E2EE/NCNetworkingE2EEMarkFolder.swift | 5 +- 8 files changed, 124 insertions(+), 44 deletions(-) diff --git a/Tests/NextcloudUnitTests/NCEndToEndKeySetResolverTests.swift b/Tests/NextcloudUnitTests/NCEndToEndKeySetResolverTests.swift index 739286b2c4..4835651d8b 100644 --- a/Tests/NextcloudUnitTests/NCEndToEndKeySetResolverTests.swift +++ b/Tests/NextcloudUnitTests/NCEndToEndKeySetResolverTests.swift @@ -21,6 +21,7 @@ struct NCEndToEndKeySetResolverTests { #expect(access == .active(active)) #expect(access.canWrite) + #expect(access.writeAccessError == .success) } @Test("The most recently archived matching key set is selected") @@ -36,6 +37,7 @@ struct NCEndToEndKeySetResolverTests { #expect(access == .archived(newest)) #expect(access.isReadOnly) + #expect(access.writeAccessError.errorCode == NCGlobal.shared.errorE2EEReadOnly) } @Test("Access is unavailable when no key set can decrypt metadata") @@ -52,6 +54,7 @@ struct NCEndToEndKeySetResolverTests { #expect(access == .unavailable) #expect(!access.canWrite) #expect(!access.isReadOnly) + #expect(access.writeAccessError.errorCode == NCGlobal.shared.errorE2EENoUserFound) } @Test("A V2 child folder inherits its encrypted root metadata key") diff --git a/iOSClient/Files/NCFiles.swift b/iOSClient/Files/NCFiles.swift index b11528db8c..d5ed166f24 100644 --- a/iOSClient/Files/NCFiles.swift +++ b/iOSClient/Files/NCFiles.swift @@ -311,8 +311,10 @@ class NCFiles: NCCollectionViewCommon { serverUrl: serverUrl, session: session ) - if storedAccess.keySet != nil { - endToEndKeySetAccess = storedAccess + endToEndKeySetAccess = storedAccess + + guard storedAccess.writeAccessError == .success else { + return storedAccess.writeAccessError } } catch { return NKError( @@ -321,12 +323,6 @@ class NCFiles: NCCollectionViewCommon { ) } - guard !endToEndKeySetAccess.isReadOnly else { - return NKError( - errorCode: global.errorE2EEReadOnly, - errorDescription: NSLocalizedString("_e2ee_read_only_", comment: "") - ) - } nkLog(tag: self.global.logTagE2EE, message: "E2ee metadata not found, resend.") await NCNetworkingE2EE().uploadMetadata(serverUrl: serverUrl, account: session.account) result = await NCNetworkingE2EE().getMetadata(fileId: ocId, e2eToken: lock?.e2eToken, account: session.account) diff --git a/iOSClient/Menu/NCContextMenuPlus.swift b/iOSClient/Menu/NCContextMenuPlus.swift index 5e429e0c27..a0771be95a 100644 --- a/iOSClient/Menu/NCContextMenuPlus.swift +++ b/iOSClient/Menu/NCContextMenuPlus.swift @@ -576,10 +576,6 @@ class NCContextMenuPlus: NSObject { return true } - guard !viewController.endToEndKeySetAccess.isReadOnly else { - return false - } - guard metadataFolder.isCreatable else { return false } @@ -588,6 +584,10 @@ class NCContextMenuPlus: NSObject { return true } + guard viewController.endToEndKeySetAccess.canWrite else { + return false + } + return NextcloudKit.shared.isNetworkReachable() } diff --git a/iOSClient/Networking/E2EE/NCEndToEndKeySetAccess.swift b/iOSClient/Networking/E2EE/NCEndToEndKeySetAccess.swift index 11c7894086..a0f7185d40 100644 --- a/iOSClient/Networking/E2EE/NCEndToEndKeySetAccess.swift +++ b/iOSClient/Networking/E2EE/NCEndToEndKeySetAccess.swift @@ -3,6 +3,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later import Foundation +import NextcloudKit /// Describes which local E2EE key set can read an encrypted storage space. enum NCEndToEndKeySetAccess: Equatable, Sendable { @@ -32,4 +33,22 @@ enum NCEndToEndKeySetAccess: Equatable, Sendable { } return false } + + /// A write is allowed only when the active key set decrypts the storage space. + var writeAccessError: NKError { + switch self { + case .active: + return .success + case .archived: + return NKError( + errorCode: NCGlobal.shared.errorE2EEReadOnly, + errorDescription: NSLocalizedString("_e2ee_read_only_", comment: "") + ) + case .unavailable: + return NKError( + errorCode: NCGlobal.shared.errorE2EENoUserFound, + errorDescription: NSLocalizedString("_e2ee_no_metadataKey_found_", comment: "") + ) + } + } } diff --git a/iOSClient/Networking/E2EE/NCNetworkingE2EE.swift b/iOSClient/Networking/E2EE/NCNetworkingE2EE.swift index c19c658583..a102689f93 100644 --- a/iOSClient/Networking/E2EE/NCNetworkingE2EE.swift +++ b/iOSClient/Networking/E2EE/NCNetworkingE2EE.swift @@ -125,12 +125,45 @@ class NCNetworkingE2EE: NSObject { // MARK: - + private enum MetadataUploadMode { + case updateOrRecover + case initialCreation + } + @discardableResult func uploadMetadata(serverUrl: String, addUserId: String? = nil, removeUserId: String? = nil, updateVersionV1V2: Bool = false, account: String) async -> NKError { + await uploadMetadata( + serverUrl: serverUrl, + addUserId: addUserId, + removeUserId: removeUserId, + updateVersionV1V2: updateVersionV1V2, + mode: .updateOrRecover, + account: account + ) + } + + @discardableResult + func createInitialMetadata(serverUrl: String, account: String) async -> NKError { + await uploadMetadata( + serverUrl: serverUrl, + addUserId: nil, + removeUserId: nil, + updateVersionV1V2: false, + mode: .initialCreation, + account: account + ) + } + + private func uploadMetadata(serverUrl: String, + addUserId: String?, + removeUserId: String?, + updateVersionV1V2: Bool, + mode: MetadataUploadMode, + account: String) async -> NKError { var addCertificate: String? var method = "POST" let session = NCSession.shared.getSession(account: account) @@ -180,6 +213,26 @@ class NCNetworkingE2EE: NSObject { method = "PUT" } else if resultsGetE2EEMetadata.error.errorCode == NCGlobal.shared.errorResourceNotFound, !updateVersionV1V2 { + if case .updateOrRecover = mode { + let storedAccess: NCEndToEndKeySetAccess + do { + storedAccess = try await NCEndToEndMetadata().resolveStoredRootKeySetAccess( + serverUrl: serverUrl, + session: session + ) + } catch { + await unlock(account: session.account, serverUrl: serverUrl) + return NKError( + errorCode: NCGlobal.shared.errorInternalError, + errorDescription: error.localizedDescription + ) + } + + guard storedAccess.writeAccessError == .success else { + await unlock(account: session.account, serverUrl: serverUrl) + return storedAccess.writeAccessError + } + } method = "POST" } else { await unlock(account: session.account, serverUrl: serverUrl) @@ -259,19 +312,7 @@ class NCNetworkingE2EE: NSObject { ) } - if access.canWrite { - return .success - } else if access.isReadOnly { - return NKError( - errorCode: NCGlobal.shared.errorE2EEReadOnly, - errorDescription: NSLocalizedString("_e2ee_read_only_", comment: "") - ) - } else { - return NKError( - errorCode: NCGlobal.shared.errorE2EENoUserFound, - errorDescription: NSLocalizedString("_e2ee_no_metadataKey_found_", comment: "") - ) - } + return access.writeAccessError } func uploadMetadata(serverUrl: String, @@ -318,7 +359,24 @@ class NCNetworkingE2EE: NSObject { e2eToken: String, session: NCSession.Session) async -> NKError { let resultsGetE2EEMetadata = await getMetadata(fileId: fileId, e2eToken: e2eToken, account: session.account) - guard resultsGetE2EEMetadata.error == .success, let e2eMetadata = resultsGetE2EEMetadata.e2eMetadata else { + guard resultsGetE2EEMetadata.error == .success, + let e2eMetadata = resultsGetE2EEMetadata.e2eMetadata else { + if resultsGetE2EEMetadata.error.errorCode == NCGlobal.shared.errorResourceNotFound { + do { + let storedAccess = try await NCEndToEndMetadata().resolveStoredRootKeySetAccess( + serverUrl: serverUrl, + session: session + ) + guard storedAccess.writeAccessError == .success else { + return storedAccess.writeAccessError + } + } catch { + return NKError( + errorCode: NCGlobal.shared.errorInternalError, + errorDescription: error.localizedDescription + ) + } + } return resultsGetE2EEMetadata.error } @@ -329,14 +387,7 @@ class NCNetworkingE2EE: NSObject { return decodeResult.error } - guard decodeResult.access.canWrite else { - return NKError( - errorCode: NCGlobal.shared.errorE2EEReadOnly, - errorDescription: NSLocalizedString("_e2ee_read_only_", comment: "") - ) - } - - return NKError() + return decodeResult.access.writeAccessError } // MARK: - diff --git a/iOSClient/Networking/E2EE/NCNetworkingE2EECreateFolder.swift b/iOSClient/Networking/E2EE/NCNetworkingE2EECreateFolder.swift index 6ab7d4ec14..f97df1c67c 100644 --- a/iOSClient/Networking/E2EE/NCNetworkingE2EECreateFolder.swift +++ b/iOSClient/Networking/E2EE/NCNetworkingE2EECreateFolder.swift @@ -140,7 +140,10 @@ class NCNetworkingE2EECreateFolder: NSObject { await self.database.createDirectory(metadata: metadata) // SEND METADATA FOR THE NEW FOLDER - await NCNetworkingE2EE().uploadMetadata(serverUrl: serverUrlFileName, account: session.account) + await NCNetworkingE2EE().createInitialMetadata( + serverUrl: serverUrlFileName, + account: session.account + ) await NCNetworking.shared.transferDispatcher.notifyAllDelegates { delegate in delegate.transferChange(networkingStatus: self.global.networkingStatusCreateFolder, diff --git a/iOSClient/Networking/E2EE/NCNetworkingE2EEDelete.swift b/iOSClient/Networking/E2EE/NCNetworkingE2EEDelete.swift index 351bcf2645..e4c31a13b7 100644 --- a/iOSClient/Networking/E2EE/NCNetworkingE2EEDelete.swift +++ b/iOSClient/Networking/E2EE/NCNetworkingE2EEDelete.swift @@ -33,6 +33,19 @@ class NCNetworkingE2EEDelete: NSObject { return resultsLock.error } + // VERIFY WRITE ACCESS + DOWNLOAD METADATA + // + let errorDownloadMetadata = await networkingE2EE.downloadMetadata( + serverUrl: metadata.serverUrl, + fileId: fileId, + e2eToken: e2eToken, + session: session + ) + guard errorDownloadMetadata == .success else { + await networkingE2EE.unlock(account: metadata.account, serverUrl: metadata.serverUrl) + return errorDownloadMetadata + } + // DELETE FILE // let serverUrlFileName = self.utilityFileSystem.createServerUrl(serverUrl: metadata.serverUrl, fileName: metadata.fileName) @@ -70,14 +83,6 @@ class NCNetworkingE2EEDelete: NSObject { return result.error } - // DOWNLOAD METADATA - // - let errorDownloadMetadata = await networkingE2EE.downloadMetadata(serverUrl: metadata.serverUrl, fileId: fileId, e2eToken: e2eToken, session: session) - guard errorDownloadMetadata == .success else { - await networkingE2EE.unlock(account: metadata.account, serverUrl: metadata.serverUrl) - return errorDownloadMetadata - } - // UPDATE DB // await self.database.deleteE2eEncryptionAsync(predicate: NSPredicate(format: "account == %@ AND serverUrl == %@ AND fileNameIdentifier == %@", diff --git a/iOSClient/Networking/E2EE/NCNetworkingE2EEMarkFolder.swift b/iOSClient/Networking/E2EE/NCNetworkingE2EEMarkFolder.swift index 7fa70e3ffb..b3c45ead96 100644 --- a/iOSClient/Networking/E2EE/NCNetworkingE2EEMarkFolder.swift +++ b/iOSClient/Networking/E2EE/NCNetworkingE2EEMarkFolder.swift @@ -71,7 +71,10 @@ class NCNetworkingE2EEMarkFolder: NSObject { await self.database.updateCounterE2eMetadataAsync(account: account, ocIdServerUrl: metadata.ocId, counter: 0) // upload e2ee metadata - error = await NCNetworkingE2EE().uploadMetadata(serverUrl: serverUrlFileName, account: account) + error = await NCNetworkingE2EE().createInitialMetadata( + serverUrl: serverUrlFileName, + account: account + ) guard error == .success else { return error } From ce4d2634dbc1604496508be24feac486c9b605ff Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Thu, 20 Aug 2026 15:46:10 +0200 Subject: [PATCH 07/18] test: verify E2EE archiving preserves active credentials Pin Firebase and GoogleAppMeasurement to 12.17.0. Signed-off-by: Marino Faggiana --- .../xcshareddata/swiftpm/Package.resolved | 8 ++++---- .../NCEndToEndKeySetArchiveTests.swift | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/Nextcloud.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Nextcloud.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 2f5bf1751d..cd727d782e 100644 --- a/Nextcloud.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Nextcloud.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -60,8 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/firebase/firebase-ios-sdk", "state" : { - "revision" : "346daa9f46316aa372b35b317e18224acc2e9063", - "version" : "12.18.0" + "revision" : "33a468adfdb75b53f05a37e7c886ca7c962b5c17", + "version" : "12.17.0" } }, { @@ -78,8 +78,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/GoogleAppMeasurement.git", "state" : { - "revision" : "f04760d460296cc0fa430935a7be212e5bd67fc5", - "version" : "12.18.0" + "revision" : "fceaffa07d22dcd5624d3639fd970351a4a5ad8c", + "version" : "12.17.0" } }, { diff --git a/Tests/NextcloudUnitTests/NCEndToEndKeySetArchiveTests.swift b/Tests/NextcloudUnitTests/NCEndToEndKeySetArchiveTests.swift index 1c537c11ca..7a28746cee 100644 --- a/Tests/NextcloudUnitTests/NCEndToEndKeySetArchiveTests.swift +++ b/Tests/NextcloudUnitTests/NCEndToEndKeySetArchiveTests.swift @@ -8,6 +8,24 @@ import Testing @Suite("End-to-end key set archive") struct NCEndToEndKeySetArchiveTests { + @Test("Archiving does not change the active credentials") + func preservesNormalActiveCredentials() throws { + let account = "e2ee-archive-\(UUID().uuidString)" + let preferences = NCPreferences() + defer { + preferences.clearAllKeysEndToEnd(account: account) + } + + setKeySet(on: preferences, account: account) + try preferences.archiveCurrentEndToEndKeySet(account: account) + + #expect(preferences.isEndToEndEnabled(account: account)) + #expect(preferences.getEndToEndCertificate(account: account) == "certificate") + #expect(preferences.getEndToEndPrivateKey(account: account) == "private-key") + #expect(preferences.getEndToEndPublicKey(account: account) == "public-key") + #expect(preferences.getEndToEndPassphrase(account: account) == "passphrase") + } + @Test("Archiving preserves active credentials after they are cleared") func preservesActiveCredentials() throws { let account = "e2ee-archive-\(UUID().uuidString)" From b4daac2870ddafd5e610602157499eb00179b762 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Thu, 20 Aug 2026 16:13:56 +0200 Subject: [PATCH 08/18] fix: handle changed E2EE server keys safely Detect and replace stale server key sets while preserving archived keys for read-only access. Validate write operations and route encrypted media folder deletion through the E2EE flow. Signed-off-by: Marino Faggiana --- .../NCEndToEndKeySetArchiveTests.swift | 11 ++ .../NCEndToEndKeySetResolverTests.swift | 37 +++++ iOSClient/Files/NCFiles.swift | 19 +++ iOSClient/Media/NCMedia+Command.swift | 25 +++- iOSClient/NCGlobal.swift | 1 + .../E2EE/NCEndToEndKeySetResolver.swift | 5 +- .../Networking/E2EE/NCNetworkingE2EE.swift | 96 ++++++++++++- .../E2EE/NCNetworkingE2EEMarkFolder.swift | 5 + iOSClient/Settings/E2EE/NCEndToEndSetup.swift | 130 ++++++++++++++++-- .../Settings/E2EE/NCManageE2EEModel.swift | 6 +- iOSClient/Settings/NCPreferences.swift | 17 +++ .../en.lproj/Localizable.strings | 2 + 12 files changed, 334 insertions(+), 20 deletions(-) diff --git a/Tests/NextcloudUnitTests/NCEndToEndKeySetArchiveTests.swift b/Tests/NextcloudUnitTests/NCEndToEndKeySetArchiveTests.swift index 7a28746cee..15d03c4f60 100644 --- a/Tests/NextcloudUnitTests/NCEndToEndKeySetArchiveTests.swift +++ b/Tests/NextcloudUnitTests/NCEndToEndKeySetArchiveTests.swift @@ -53,6 +53,17 @@ struct NCEndToEndKeySetArchiveTests { #expect(snapshot.passphrase == "passphrase") } + @Test("Explicit local removal also clears stale server-key state") + func removalClearsStaleState() { + let account = "e2ee-archive-\(UUID().uuidString)" + let preferences = NCPreferences() + + preferences.setEndToEndServerKeyStale(account: account, stale: true) + preferences.clearAllKeysEndToEnd(account: account) + + #expect(!preferences.isEndToEndServerKeyStale(account: account)) + } + @Test("Archiving the same credentials does not create duplicates") func avoidsDuplicates() throws { let account = "e2ee-archive-\(UUID().uuidString)" diff --git a/Tests/NextcloudUnitTests/NCEndToEndKeySetResolverTests.swift b/Tests/NextcloudUnitTests/NCEndToEndKeySetResolverTests.swift index 4835651d8b..6f3c963682 100644 --- a/Tests/NextcloudUnitTests/NCEndToEndKeySetResolverTests.swift +++ b/Tests/NextcloudUnitTests/NCEndToEndKeySetResolverTests.swift @@ -24,6 +24,43 @@ struct NCEndToEndKeySetResolverTests { #expect(access.writeAccessError == .success) } + @Test("Equivalent PEM formatting identifies the same server key") + func equivalentServerPublicKeysMatch() { + let compact = "-----BEGIN PUBLIC KEY-----\nYWJjZA==\n-----END PUBLIC KEY-----" + let wrapped = "-----BEGIN PUBLIC KEY-----\nYWJj\nZA==\n-----END PUBLIC KEY-----\n" + + #expect(NCNetworkingE2EE().publicKeysMatch(compact, wrapped)) + #expect(!NCNetworkingE2EE().publicKeysMatch(compact, "different-key")) + } + + @Test("A stale local key is excluded from active write access") + func staleKeyIsNotActive() throws { + let account = "e2ee-stale-\(UUID().uuidString)" + let preferences = NCPreferences() + defer { + preferences.clearAllKeysEndToEnd(account: account) + } + + preferences.setEndToEndCertificate(account: account, certificate: "certificate") + preferences.setEndToEndPrivateKey(account: account, privateKey: "private-key") + preferences.setEndToEndPublicKey(account: account, publicKey: "public-key") + preferences.setEndToEndPassphrase(account: account, passphrase: "passphrase") + try preferences.archiveCurrentEndToEndKeySet(account: account) + preferences.setEndToEndServerKeyStale(account: account, stale: true) + + let resolver = NCEndToEndKeySetResolver(preferences: preferences) + let archivedKeySets = try preferences.getArchivedEndToEndKeySets(account: account) + let archivedKeySet = try #require(archivedKeySets.last) + let access = resolver.select( + currentKeySet: resolver.currentKeySet(account: account), + archivedKeySets: archivedKeySets, + canDecrypt: { _ in true } + ) + + #expect(access == .archived(archivedKeySet)) + #expect(!access.canWrite) + } + @Test("The most recently archived matching key set is selected") func newestArchivedKeySetHasPriority() throws { let oldest = try #require(makeKeySet(identifier: "oldest")) diff --git a/iOSClient/Files/NCFiles.swift b/iOSClient/Files/NCFiles.swift index d5ed166f24..c7b28da7df 100644 --- a/iOSClient/Files/NCFiles.swift +++ b/iOSClient/Files/NCFiles.swift @@ -299,6 +299,25 @@ class NCFiles: NCCollectionViewCommon { private func sectionE2ee(ocId: String) async -> NKError { var returnError = NKError() + // Reconcile the account key before classifying this storage space. + // Read access remains possible with archived keys if the user cancels + // or cannot yet provide the new passphrase. + let serverKeyError = await NCNetworkingE2EE().validateCurrentServerKey( + account: session.account + ) + if serverKeyError.errorCode == global.errorE2EEServerKeyChanged { + do { + try await NCEndToEndSetup(controller: controller).updateChangedServerKey() + } catch let error as NKError where error.errorCode == NSUserCancelledError { + // Continue: the previous key was archived and can still offer + // read-only access to storage spaces encrypted with it. + } catch let error as NKError { + await showErrorBanner(windowScene: windowScene, text: error.errorDescription) + } catch { + await showErrorBanner(windowScene: windowScene, text: error.localizedDescription) + } + } + // Get Metadata let lock = await self.database.getE2ETokenLockAsync(account: session.account, serverUrl: serverUrl) var result = await NCNetworkingE2EE().getMetadata(fileId: ocId, e2eToken: lock?.e2eToken, account: session.account) diff --git a/iOSClient/Media/NCMedia+Command.swift b/iOSClient/Media/NCMedia+Command.swift index 49ca20374f..f52daa16cf 100644 --- a/iOSClient/Media/NCMedia+Command.swift +++ b/iOSClient/Media/NCMedia+Command.swift @@ -285,16 +285,27 @@ extension NCMedia: NCMediaSelectTabBarDelegate { return } - let resultsDeleteFileOrFolder = await NextcloudKit.shared.deleteFileOrFolderAsync(serverUrlFileName: metadata.serverUrlFileName, account: metadata.account) { task in - Task { - let identifier = await NCNetworking.shared.networkingTasks.createIdentifier(account: metadata.account, - path: metadata.serverUrlFileName, - name: "deleteFileOrFolder") - await NCNetworking.shared.networkingTasks.track(identifier: identifier, task: task) + let deleteError: NKError + if metadata.isDirectoryE2EE { + deleteError = await NCNetworkingE2EEDelete().delete(metadata: metadata) + } else { + let result = await NextcloudKit.shared.deleteFileOrFolderAsync( + serverUrlFileName: metadata.serverUrlFileName, + account: metadata.account + ) { task in + Task { + let identifier = await NCNetworking.shared.networkingTasks.createIdentifier( + account: metadata.account, + path: metadata.serverUrlFileName, + name: "deleteFileOrFolder" + ) + await NCNetworking.shared.networkingTasks.track(identifier: identifier, task: task) + } } + deleteError = result.error } - guard resultsDeleteFileOrFolder.error == .success || resultsDeleteFileOrFolder.error.errorCode == self.global.errorResourceNotFound else { + guard deleteError == .success || deleteError.errorCode == self.global.errorResourceNotFound else { return } diff --git a/iOSClient/NCGlobal.swift b/iOSClient/NCGlobal.swift index c916f68522..13e7b65e06 100644 --- a/iOSClient/NCGlobal.swift +++ b/iOSClient/NCGlobal.swift @@ -188,6 +188,7 @@ final class NCGlobal: Sendable { let errorE2EEKeyDirectoryTop: Int = -98019 let errorE2EESendMetadata: Int = -98020 let errorE2EEReadOnly: Int = -98021 + let errorE2EEServerKeyChanged: Int = -98022 // Selector diff --git a/iOSClient/Networking/E2EE/NCEndToEndKeySetResolver.swift b/iOSClient/Networking/E2EE/NCEndToEndKeySetResolver.swift index ba562aaaa3..57f1b8cf45 100644 --- a/iOSClient/Networking/E2EE/NCEndToEndKeySetResolver.swift +++ b/iOSClient/Networking/E2EE/NCEndToEndKeySetResolver.swift @@ -129,8 +129,9 @@ struct NCEndToEndKeySetResolver { ) } - private func currentKeySet(account: String) -> NCEndToEndKeySet? { - guard preferences.isEndToEndEnabled(account: account) else { + func currentKeySet(account: String) -> NCEndToEndKeySet? { + guard preferences.isEndToEndEnabled(account: account), + !preferences.isEndToEndServerKeyStale(account: account) else { return nil } diff --git a/iOSClient/Networking/E2EE/NCNetworkingE2EE.swift b/iOSClient/Networking/E2EE/NCNetworkingE2EE.swift index a102689f93..fb0e7ddd39 100644 --- a/iOSClient/Networking/E2EE/NCNetworkingE2EE.swift +++ b/iOSClient/Networking/E2EE/NCNetworkingE2EE.swift @@ -59,6 +59,78 @@ class NCNetworkingE2EE: NSObject { return NKRequestOptions(version: version) } + /// Verifies that the locally active key set is still the key set published + /// by the server. A mismatch is persisted immediately so the old key can + /// only be selected through the archived, read-only path. + func validateCurrentServerKey(account: String) async -> NKError { + let preferences = NCPreferences() + guard let localPublicKey = preferences.getEndToEndPublicKey(account: account), + !localPublicKey.isEmpty else { + return NKError( + errorCode: NCGlobal.shared.errorE2EENotEnabled, + errorDescription: NSLocalizedString("_e2ee_no_metadataKey_found_", comment: "") + ) + } + + let capabilities = await NKCapabilities.shared.getCapabilities(for: account) + let result = await NextcloudKit.shared.getE2EEPublicKeyAsync( + account: account, + options: getOptions(account: account, capabilities: capabilities) + ) + + if result.error.errorCode == NCGlobal.shared.errorResourceNotFound { + return markCurrentServerKeyAsStale(account: account, preferences: preferences) + } + + guard result.error == .success else { + return result.error + } + + guard let serverPublicKey = result.publicKey, !serverPublicKey.isEmpty else { + return .invalidData + } + + guard publicKeysMatch(localPublicKey, serverPublicKey) else { + return markCurrentServerKeyAsStale(account: account, preferences: preferences) + } + + preferences.setEndToEndServerKeyStale(account: account, stale: false) + return .success + } + + /// Compares the PEM payload rather than its formatting. Kept internal so + /// normal and changed-key behavior can be covered by unit tests. + func publicKeysMatch(_ first: String, _ second: String) -> Bool { + func payload(_ key: String) -> String { + key.components(separatedBy: .whitespacesAndNewlines) + .filter { !$0.isEmpty && !$0.hasPrefix("-----") } + .joined() + } + + let firstPayload = payload(first) + let secondPayload = payload(second) + return !firstPayload.isEmpty && firstPayload == secondPayload + } + + private func markCurrentServerKeyAsStale( + account: String, + preferences: NCPreferences + ) -> NKError { + do { + try preferences.archiveCurrentEndToEndKeySet(account: account) + preferences.setEndToEndServerKeyStale(account: account, stale: true) + return NKError( + errorCode: NCGlobal.shared.errorE2EEServerKeyChanged, + errorDescription: NSLocalizedString("_e2ee_server_key_changed_", comment: "") + ) + } catch { + return NKError( + errorCode: NCGlobal.shared.errorInternalError, + errorDescription: error.localizedDescription + ) + } + } + // MARK: - func getMetadata(fileId: String, e2eToken: String?, account: String) async -> (account: String, @@ -136,7 +208,12 @@ class NCNetworkingE2EE: NSObject { removeUserId: String? = nil, updateVersionV1V2: Bool = false, account: String) async -> NKError { - await uploadMetadata( + let serverKeyError = await validateCurrentServerKey(account: account) + guard serverKeyError == .success else { + return serverKeyError + } + + return await uploadMetadata( serverUrl: serverUrl, addUserId: addUserId, removeUserId: removeUserId, @@ -148,7 +225,12 @@ class NCNetworkingE2EE: NSObject { @discardableResult func createInitialMetadata(serverUrl: String, account: String) async -> NKError { - await uploadMetadata( + let serverKeyError = await validateCurrentServerKey(account: account) + guard serverKeyError == .success else { + return serverKeyError + } + + return await uploadMetadata( serverUrl: serverUrl, addUserId: nil, removeUserId: nil, @@ -266,6 +348,11 @@ class NCNetworkingE2EE: NSObject { } func validateWriteAccess(serverUrl: String, account: String) async -> NKError { + let serverKeyError = await validateCurrentServerKey(account: account) + guard serverKeyError == .success else { + return serverKeyError + } + let session = NCSession.shared.getSession(account: account) let resultsLock = await lock(account: account, serverUrl: serverUrl) guard resultsLock.error == .success, @@ -358,6 +445,11 @@ class NCNetworkingE2EE: NSObject { fileId: String, e2eToken: String, session: NCSession.Session) async -> NKError { + let serverKeyError = await validateCurrentServerKey(account: session.account) + guard serverKeyError == .success else { + return serverKeyError + } + let resultsGetE2EEMetadata = await getMetadata(fileId: fileId, e2eToken: e2eToken, account: session.account) guard resultsGetE2EEMetadata.error == .success, let e2eMetadata = resultsGetE2EEMetadata.e2eMetadata else { diff --git a/iOSClient/Networking/E2EE/NCNetworkingE2EEMarkFolder.swift b/iOSClient/Networking/E2EE/NCNetworkingE2EEMarkFolder.swift index b3c45ead96..e1bafde76a 100644 --- a/iOSClient/Networking/E2EE/NCNetworkingE2EEMarkFolder.swift +++ b/iOSClient/Networking/E2EE/NCNetworkingE2EEMarkFolder.swift @@ -26,6 +26,11 @@ class NCNetworkingE2EEMarkFolder: NSObject { } } + let serverKeyError = await NCNetworkingE2EE().validateCurrentServerKey(account: account) + guard serverKeyError == .success else { + return serverKeyError + } + // BANNER // #if !EXTENSION diff --git a/iOSClient/Settings/E2EE/NCEndToEndSetup.swift b/iOSClient/Settings/E2EE/NCEndToEndSetup.swift index 22e54df176..2fcfafa023 100644 --- a/iOSClient/Settings/E2EE/NCEndToEndSetup.swift +++ b/iOSClient/Settings/E2EE/NCEndToEndSetup.swift @@ -47,23 +47,134 @@ class NCEndToEndSetup { /// Starts the E2EE initialization pipeline. /// /// Flow: - /// 1. Archive the current E2EE key set and clear only the active keys + /// 1. Archive the current E2EE key set and temporarily clear the active keys /// 2. Ensure a valid certificate exists (fetch or create/sign) /// 3. Ensure a valid private key exists (fetch or create) + /// 4. Restore the previous active state if any step fails /// /// - Throws: `NKError` if any step fails (network, crypto, validation, or user cancellation) func start() async throws { // Preserve the previous key space before replacing the active keys. // A Keychain failure stops setup before any key material is removed. - try preference.archiveCurrentEndToEndKeySet(account: session.account) + let previousKeySet = try preference.archiveCurrentEndToEndKeySet(account: session.account) + let wasServerKeyStale = preference.isEndToEndServerKeyStale(account: session.account) preference.clearCurrentKeysEndToEnd(account: session.account) - // get version E2EE + + do { + // get version E2EE + let capabilities = await NKCapabilities.shared.getCapabilities(for: session.account) + options = networkingE2EE.getOptions(account: session.account, capabilities: capabilities) + + try await getPublicKey() + try await getPrivateKey() + preference.setEndToEndServerKeyStale(account: session.account, stale: false) + } catch { + restoreCurrentKeySet(previousKeySet, serverKeyStale: wasServerKeyStale) + throw error + } + } + + /// Restores the exact local state that existed before setup started. + /// The archived snapshot remains available and immutable. + private func restoreCurrentKeySet(_ keySet: NCEndToEndKeySet?, serverKeyStale: Bool) { + preference.setEndToEndCertificate(account: session.account, certificate: keySet?.certificate) + preference.setEndToEndPrivateKey(account: session.account, privateKey: keySet?.privateKey) + preference.setEndToEndPublicKey(account: session.account, publicKey: keySet?.publicKey) + preference.setEndToEndPassphrase(account: session.account, passphrase: keySet?.passphrase) + preference.setEndToEndServerKeyStale(account: session.account, stale: serverKeyStale) + } + + /// Replaces a stale local key set with the key set currently published by + /// the server. All remote values are fetched and cryptographically checked + /// before any active Keychain value is replaced. + func updateChangedServerKey() async throws { let capabilities = await NKCapabilities.shared.getCapabilities(for: session.account) options = networkingE2EE.getOptions(account: session.account, capabilities: capabilities) - try await getPublicKey() - try await getPrivateKey() + let publicKeyResult = await NextcloudKit.shared.getE2EEPublicKeyAsync( + account: session.account, + options: options + ) + + if publicKeyResult.error.errorCode == global.errorResourceNotFound { + try await start() + return + } + + guard publicKeyResult.error == .success, + let publicKey = publicKeyResult.publicKey, + !publicKey.isEmpty else { + throw publicKeyResult.error == .success ? NKError.invalidData : publicKeyResult.error + } + + let certificateResult = await NextcloudKit.shared.getE2EECertificateAsync( + account: session.account, + options: options + ) + guard certificateResult.error == .success, + let certificate = certificateResult.certificate, + !certificate.isEmpty else { + throw certificateResult.error == .success ? NKError.invalidData : certificateResult.error + } + + let privateKeyResult = await NextcloudKit.shared.getE2EEPrivateKeyAsync( + account: session.account, + options: options + ) + guard privateKeyResult.error == .success, + let privateKeyCipher = privateKeyResult.privateKey, + !privateKeyCipher.isEmpty else { + throw privateKeyResult.error == .success ? NKError.invalidData : privateKeyResult.error + } + + let passphrase = try await requestPassphraseAsync( + title: NSLocalizedString("_e2ee_server_key_changed_title_", comment: ""), + message: NSLocalizedString("_e2ee_server_key_changed_", comment: "") + ) + guard let privateKeyData = endToEndEncryption?.decryptPrivateKey( + privateKeyCipher, + passphrase: passphrase + ), + let decodedPrivateKeyData = Data(base64Encoded: privateKeyData), + let privateKey = String(data: decodedPrivateKeyData, encoding: .utf8), + !privateKey.isEmpty else { + throw NKError( + errorCode: global.errorInternalError, + errorDescription: NSLocalizedString("_e2ee_setup_passphrase_error_", comment: "") + ) + } + guard let endToEndEncryption, + endToEndEncryption.verifyCertificate(certificate, publicKey: publicKey) else { + throw NKError( + errorCode: global.errorInternalError, + errorDescription: NSLocalizedString("_e2ee_setup_verify_publickey_", comment: "") + ) + } + + let certificateSigningRequest = try networkingE2EE.createCertificateSigningRequest( + privateKeyPEM: privateKey, + commonName: session.userId + ) + guard let privateKeyPublicKey = endToEndEncryption.extractPublicKey( + fromCertificateSigningRequest: certificateSigningRequest + ), + networkingE2EE.publicKeysMatch(privateKeyPublicKey, publicKey) else { + throw NKError( + errorCode: global.errorInternalError, + errorDescription: NSLocalizedString("_e2ee_setup_verify_publickey_", comment: "") + ) + } + + // The new set is complete and verified. Only now preserve and replace + // the previous active values. + try preference.archiveCurrentEndToEndKeySet(account: session.account) + preference.setEndToEndCertificate(account: session.account, certificate: certificate) + preference.setEndToEndPrivateKey(account: session.account, privateKey: privateKey) + preference.setEndToEndPublicKey(account: session.account, publicKey: publicKey) + preference.setEndToEndPassphrase(account: session.account, passphrase: passphrase) + preference.setEndToEndServerKeyStale(account: session.account, stale: false) + NCManageDatabase.shared.clearTablesE2EE(account: session.account) } /// Ensures that a valid user certificate is available. @@ -320,11 +431,14 @@ class NCEndToEndSetup { /// /// - Note: /// - Always executed on MainActor due to UIKit usage - private func requestPassphraseAsync() async throws -> String { + private func requestPassphraseAsync( + title: String = NSLocalizedString("_e2e_passphrase_request_title_", comment: ""), + message: String = NSLocalizedString("_e2e_passphrase_request_message_", comment: "") + ) async throws -> String { try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in let alertController = UIAlertController( - title: NSLocalizedString("_e2e_passphrase_request_title_", comment: ""), - message: NSLocalizedString("_e2e_passphrase_request_message_", comment: ""), + title: title, + message: message, preferredStyle: .alert ) diff --git a/iOSClient/Settings/E2EE/NCManageE2EEModel.swift b/iOSClient/Settings/E2EE/NCManageE2EEModel.swift index f200deb493..e3c090bae8 100644 --- a/iOSClient/Settings/E2EE/NCManageE2EEModel.swift +++ b/iOSClient/Settings/E2EE/NCManageE2EEModel.swift @@ -47,7 +47,11 @@ class NCManageE2EE: NSObject, ObservableObject, ViewOnAppearHandling, TOPasscode if let certificate = preference.getEndToEndCertificate(account: session.account) { self.certificateValidity = networkingE2EE.getX509CertificateValidity(from: certificate) } - statusOfService = NSLocalizedString("_status_e2ee_configured_", comment: "") + if preference.isEndToEndServerKeyStale(account: session.account) { + statusOfService = NSLocalizedString("_e2ee_server_key_changed_", comment: "") + } else { + statusOfService = NSLocalizedString("_status_e2ee_configured_", comment: "") + } } else { let options = networkingE2EE.getOptions(account: session.account, capabilities: capabilities) NextcloudKit.shared.getE2EECertificate(account: session.account, options: options) { _ in diff --git a/iOSClient/Settings/NCPreferences.swift b/iOSClient/Settings/NCPreferences.swift index 7b9536eb3f..caab8d27b8 100644 --- a/iOSClient/Settings/NCPreferences.swift +++ b/iOSClient/Settings/NCPreferences.swift @@ -493,6 +493,22 @@ final class NCPreferences: NSObject { return true } + /// Indicates that the locally active E2EE key set no longer matches the + /// key currently published by the server. The key material is retained so + /// it can continue to decrypt older storage spaces, but it must not write. + func isEndToEndServerKeyStale(account: String) -> Bool { + getBoolPreference( + key: "EndToEndServerKeyStale", + account: account, + defaultValue: false + ) + } + + func setEndToEndServerKeyStale(account: String, stale: Bool) { + let key = "EndToEndServerKeyStale_\(account)" + setUserDefaults(stale, forKey: key) + } + /// Archives the current E2EE credentials as an immutable Keychain item. /// /// Repeated attempts with unchanged credentials reuse the existing @@ -564,6 +580,7 @@ final class NCPreferences: NSObject { /// Clears active and archived E2EE credentials for explicit local removal. func clearAllKeysEndToEnd(account: String) { clearCurrentKeysEndToEnd(account: account) + setEndToEndServerKeyStale(account: account, stale: false) let snapshotPrefix = archivedEndToEndKeySetPrefix(account: account) for key in keychain.allKeys().filter({ $0.hasPrefix(snapshotPrefix) }) { diff --git a/iOSClient/Supporting Files/en.lproj/Localizable.strings b/iOSClient/Supporting Files/en.lproj/Localizable.strings index d12d132182..b27fc6ddf2 100644 --- a/iOSClient/Supporting Files/en.lproj/Localizable.strings +++ b/iOSClient/Supporting Files/en.lproj/Localizable.strings @@ -824,6 +824,8 @@ You can stop it at any time, adjust the settings, and enable it again."; "_e2ee_no_generate_key_" = "End-to-end encryption error, cannot generate key"; "_e2ee_no_metadataKey_found_" = "End-to-end encryption error, cannot decrypt metadata key"; "_e2ee_read_only_" = "This encrypted folder uses an archived key set and is available as read-only."; +"_e2ee_server_key_changed_" = "The end-to-end encryption keys for this account changed on another device. Enter the new passphrase to update this device. Storage spaces associated with previous keys will remain available as read-only."; +"_e2ee_server_key_changed_title_" = "Encryption keys changed"; "_e2ee_no_user_found_" = "End-to-end encryption error, user not found"; "_e2ee_no_signature_found_" = "End-to-end encryption error, no signature found"; "_e2ee_counter_check_" = "End-to-end encryption error, the counter is lower than the previous one"; From 8764ce6d4688e4a1596f1d9c8b42e6ed99306a3c Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Fri, 21 Aug 2026 08:33:51 +0200 Subject: [PATCH 09/18] fix: validate E2EE server keys once per active cycle Signed-off-by: Marino Faggiana --- Share/NCShareExtension.swift | 4 + iOSClient/Menu/NCContextMenuMain.swift | 2 +- iOSClient/NCAppStateManager.swift | 6 + .../Networking/E2EE/NCNetworkingE2EE.swift | 118 ++++++++++++++---- .../E2EE/NCNetworkingE2EECreateFolder.swift | 5 + .../E2EE/NCNetworkingE2EEDelete.swift | 6 + .../E2EE/NCNetworkingE2EERename.swift | 5 + .../E2EE/NCNetworkingE2EEUpload.swift | 5 + iOSClient/Settings/E2EE/NCEndToEndSetup.swift | 2 + 9 files changed, 131 insertions(+), 22 deletions(-) diff --git a/Share/NCShareExtension.swift b/Share/NCShareExtension.swift index 655c30460f..224b81f0bd 100644 --- a/Share/NCShareExtension.swift +++ b/Share/NCShareExtension.swift @@ -381,6 +381,10 @@ extension NCShareExtension { @MainActor func uploadAndExit() async { + // A Share extension process can be reused by the system. Treat each + // explicit share operation as a new server-key validation cycle. + await NCNetworkingE2EE.beginNewServerKeyValidationCycle() + var error: NKError? guard let window = self.view.window else { return diff --git a/iOSClient/Menu/NCContextMenuMain.swift b/iOSClient/Menu/NCContextMenuMain.swift index e822f5b7ca..6bbf644948 100644 --- a/iOSClient/Menu/NCContextMenuMain.swift +++ b/iOSClient/Menu/NCContextMenuMain.swift @@ -223,7 +223,7 @@ class NCContextMenuMain: NSObject { image: utility.loadImage(named: "lock", colors: [NCBrandColor.shared.iconImageColor]) ) { _ in Task { - let accessError = await NCNetworkingE2EE().validateWriteAccess( + let accessError = await NCNetworkingE2EE().validateFolderWriteAccess( serverUrl: metadata.serverUrlFileName, account: metadata.account ) diff --git a/iOSClient/NCAppStateManager.swift b/iOSClient/NCAppStateManager.swift index 533e71b2ba..0db91a8079 100644 --- a/iOSClient/NCAppStateManager.swift +++ b/iOSClient/NCAppStateManager.swift @@ -39,6 +39,12 @@ final class NCAppStateManager { nkLog(debug: "Application did become active") } + NotificationCenter.default.addObserver(forName: UIApplication.willResignActiveNotification, object: nil, queue: .main) { _ in + Task { + await NCNetworkingE2EE.beginNewServerKeyValidationCycle() + } + } + NotificationCenter.default.addObserver(forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: .main) { _ in let appDelegate = UIApplication.shared.delegate as? AppDelegate diff --git a/iOSClient/Networking/E2EE/NCNetworkingE2EE.swift b/iOSClient/Networking/E2EE/NCNetworkingE2EE.swift index fb0e7ddd39..23dc6bebf5 100644 --- a/iOSClient/Networking/E2EE/NCNetworkingE2EE.swift +++ b/iOSClient/Networking/E2EE/NCNetworkingE2EE.swift @@ -12,6 +12,35 @@ class NCNetworkingE2EE: NSObject { let e2EEApiVersion1 = "v1" let e2EEApiVersion2 = "v2" + /// Executes at most one server-key request per account while the app + /// remains active. Concurrent callers share the same in-flight request. + private actor ServerKeyValidationGate { + private var validations: [String: Task] = [:] + + func beginNewActiveCycle() { + validations.removeAll() + } + + func validate( + account: String, + operation: @escaping @Sendable () async -> NKError + ) async -> NKError { + if let validation = validations[account] { + return await validation.value + } + + let validation = Task { await operation() } + validations[account] = validation + return await validation.value + } + + func markValidated(account: String) { + validations[account] = Task { .success } + } + } + + private static let serverKeyValidationGate = ServerKeyValidationGate() + public struct X509CertificateValidity { let notBefore: Date let notAfter: Date @@ -59,9 +88,22 @@ class NCNetworkingE2EE: NSObject { return NKRequestOptions(version: version) } - /// Verifies that the locally active key set is still the key set published - /// by the server. A mismatch is persisted immediately so the old key can - /// only be selected through the archived, read-only path. + /// Invalidates the result of the current foreground period. The first + /// subsequent E2EE access will perform one new server validation. + static func beginNewServerKeyValidationCycle() async { + await serverKeyValidationGate.beginNewActiveCycle() + } + + /// Records a key set that was already fetched and cryptographically + /// verified by setup, avoiding a redundant request in the same cycle. + static func markServerKeyAsValidated(account: String) async { + await serverKeyValidationGate.markValidated(account: account) + } + + /// Verifies once per active app cycle that the locally active key set is + /// still the key set published by the server. A mismatch is persisted + /// immediately so the old key can only be selected through the archived, + /// read-only path. func validateCurrentServerKey(account: String) async -> NKError { let preferences = NCPreferences() guard let localPublicKey = preferences.getEndToEndPublicKey(account: account), @@ -72,14 +114,38 @@ class NCNetworkingE2EE: NSObject { ) } + if preferences.isEndToEndServerKeyStale(account: account) { + return Self.serverKeyChangedError + } + + return await Self.serverKeyValidationGate.validate( + account: account + ) { + await Self.performServerKeyValidation( + account: account, + localPublicKey: localPublicKey + ) + } + } + + private static func performServerKeyValidation( + account: String, + localPublicKey: String + ) async -> NKError { + let networkingE2EE = NCNetworkingE2EE() + let preferences = NCPreferences() let capabilities = await NKCapabilities.shared.getCapabilities(for: account) let result = await NextcloudKit.shared.getE2EEPublicKeyAsync( account: account, - options: getOptions(account: account, capabilities: capabilities) + options: networkingE2EE.getOptions(account: account, capabilities: capabilities) ) if result.error.errorCode == NCGlobal.shared.errorResourceNotFound { - return markCurrentServerKeyAsStale(account: account, preferences: preferences) + return markCurrentServerKeyAsStale( + account: account, + expectedPublicKey: localPublicKey, + preferences: preferences + ) } guard result.error == .success else { @@ -90,8 +156,12 @@ class NCNetworkingE2EE: NSObject { return .invalidData } - guard publicKeysMatch(localPublicKey, serverPublicKey) else { - return markCurrentServerKeyAsStale(account: account, preferences: preferences) + guard networkingE2EE.publicKeysMatch(localPublicKey, serverPublicKey) else { + return markCurrentServerKeyAsStale( + account: account, + expectedPublicKey: localPublicKey, + preferences: preferences + ) } preferences.setEndToEndServerKeyStale(account: account, stale: false) @@ -112,17 +182,21 @@ class NCNetworkingE2EE: NSObject { return !firstPayload.isEmpty && firstPayload == secondPayload } - private func markCurrentServerKeyAsStale( + private static func markCurrentServerKeyAsStale( account: String, + expectedPublicKey: String, preferences: NCPreferences ) -> NKError { + // Setup may have replaced the active set while this request was in + // flight. An obsolete response must never mark the new set as stale. + guard preferences.getEndToEndPublicKey(account: account) == expectedPublicKey else { + return .success + } + do { try preferences.archiveCurrentEndToEndKeySet(account: account) preferences.setEndToEndServerKeyStale(account: account, stale: true) - return NKError( - errorCode: NCGlobal.shared.errorE2EEServerKeyChanged, - errorDescription: NSLocalizedString("_e2ee_server_key_changed_", comment: "") - ) + return serverKeyChangedError } catch { return NKError( errorCode: NCGlobal.shared.errorInternalError, @@ -131,6 +205,13 @@ class NCNetworkingE2EE: NSObject { } } + private static var serverKeyChangedError: NKError { + NKError( + errorCode: NCGlobal.shared.errorE2EEServerKeyChanged, + errorDescription: NSLocalizedString("_e2ee_server_key_changed_", comment: "") + ) + } + // MARK: - func getMetadata(fileId: String, e2eToken: String?, account: String) async -> (account: String, @@ -283,7 +364,7 @@ class NCNetworkingE2EE: NSObject { let resultsGetE2EEMetadata = await getMetadata(fileId: fileId, e2eToken: e2eToken, account: session.account) if resultsGetE2EEMetadata.error == .success, let e2eMetadata = resultsGetE2EEMetadata.e2eMetadata { - let accessError = await validateWriteAccess( + let accessError = await validateMetadataWriteAccess( e2eMetadata: e2eMetadata, serverUrl: serverUrl, session: session @@ -347,7 +428,7 @@ class NCNetworkingE2EE: NSObject { return NKError() } - func validateWriteAccess(serverUrl: String, account: String) async -> NKError { + func validateFolderWriteAccess(serverUrl: String, account: String) async -> NKError { let serverKeyError = await validateCurrentServerKey(account: account) guard serverKeyError == .success else { return serverKeyError @@ -365,7 +446,7 @@ class NCNetworkingE2EE: NSObject { let error: NKError if metadataResult.error == .success, let e2eMetadata = metadataResult.e2eMetadata { - error = await validateWriteAccess( + error = await validateMetadataWriteAccess( e2eMetadata: e2eMetadata, serverUrl: serverUrl, session: session @@ -380,7 +461,7 @@ class NCNetworkingE2EE: NSObject { return error } - private func validateWriteAccess( + private func validateMetadataWriteAccess( e2eMetadata: String, serverUrl: String, session: NCSession.Session @@ -445,11 +526,6 @@ class NCNetworkingE2EE: NSObject { fileId: String, e2eToken: String, session: NCSession.Session) async -> NKError { - let serverKeyError = await validateCurrentServerKey(account: session.account) - guard serverKeyError == .success else { - return serverKeyError - } - let resultsGetE2EEMetadata = await getMetadata(fileId: fileId, e2eToken: e2eToken, account: session.account) guard resultsGetE2EEMetadata.error == .success, let e2eMetadata = resultsGetE2EEMetadata.e2eMetadata else { diff --git a/iOSClient/Networking/E2EE/NCNetworkingE2EECreateFolder.swift b/iOSClient/Networking/E2EE/NCNetworkingE2EECreateFolder.swift index f97df1c67c..4ff03814f7 100644 --- a/iOSClient/Networking/E2EE/NCNetworkingE2EECreateFolder.swift +++ b/iOSClient/Networking/E2EE/NCNetworkingE2EECreateFolder.swift @@ -31,6 +31,11 @@ class NCNetworkingE2EECreateFolder: NSObject { } } + error = await networkingE2EE.validateCurrentServerKey(account: session.account) + guard error == .success else { + return error + } + let capabilities = await NKCapabilities.shared.getCapabilities(for: session.account) var fileNameFolder = FileAutoRenamer.rename(fileName, isFolderPath: true, capabilities: capabilities) diff --git a/iOSClient/Networking/E2EE/NCNetworkingE2EEDelete.swift b/iOSClient/Networking/E2EE/NCNetworkingE2EEDelete.swift index e4c31a13b7..c47603b8a2 100644 --- a/iOSClient/Networking/E2EE/NCNetworkingE2EEDelete.swift +++ b/iOSClient/Networking/E2EE/NCNetworkingE2EEDelete.swift @@ -13,6 +13,12 @@ class NCNetworkingE2EEDelete: NSObject { func delete(metadata: tableMetadata) async -> NKError { let session = NCSession.shared.getSession(account: metadata.account) + + let serverKeyError = await networkingE2EE.validateCurrentServerKey(account: metadata.account) + guard serverKeyError == .success else { + return serverKeyError + } + guard let directory = await self.database.getTableDirectoryAsync(predicate: NSPredicate(format: "account == %@ AND serverUrl == %@", metadata.account, metadata.serverUrl)) else { return NKError(errorCode: NCGlobal.shared.errorUnexpectedResponseFromDB, errorDescription: NSLocalizedString("_e2ee_no_dir_", comment: "")) diff --git a/iOSClient/Networking/E2EE/NCNetworkingE2EERename.swift b/iOSClient/Networking/E2EE/NCNetworkingE2EERename.swift index d0cb02b79e..54dc7d6b89 100644 --- a/iOSClient/Networking/E2EE/NCNetworkingE2EERename.swift +++ b/iOSClient/Networking/E2EE/NCNetworkingE2EERename.swift @@ -29,6 +29,11 @@ class NCNetworkingE2EERename: NSObject { } } + error = await networkingE2EE.validateCurrentServerKey(account: metadata.account) + guard error == .success else { + return error + } + // verify if exists the new fileName if await self.database.getE2eEncryptionAsync(predicate: NSPredicate(format: "account == %@ AND serverUrl == %@ AND fileName == %@", metadata.account, metadata.serverUrl, fileNameNew)) != nil { error = NKError(errorCode: NCGlobal.shared.errorUnexpectedResponseFromDB, errorDescription: "_file_already_exists_") diff --git a/iOSClient/Networking/E2EE/NCNetworkingE2EEUpload.swift b/iOSClient/Networking/E2EE/NCNetworkingE2EEUpload.swift index 3b20eff7f1..be525efe95 100644 --- a/iOSClient/Networking/E2EE/NCNetworkingE2EEUpload.swift +++ b/iOSClient/Networking/E2EE/NCNetworkingE2EEUpload.swift @@ -41,6 +41,11 @@ class NCNetworkingE2EEUpload: NSObject { errorDescription: NSLocalizedString("_e2ee_no_session_", comment: "")) } + finalError = await networkingE2EE.validateCurrentServerKey(account: session.account) + guard finalError == .success else { + return finalError + } + defer { if finalError != .success { Task { diff --git a/iOSClient/Settings/E2EE/NCEndToEndSetup.swift b/iOSClient/Settings/E2EE/NCEndToEndSetup.swift index 2fcfafa023..e980387ec1 100644 --- a/iOSClient/Settings/E2EE/NCEndToEndSetup.swift +++ b/iOSClient/Settings/E2EE/NCEndToEndSetup.swift @@ -68,6 +68,7 @@ class NCEndToEndSetup { try await getPublicKey() try await getPrivateKey() preference.setEndToEndServerKeyStale(account: session.account, stale: false) + await NCNetworkingE2EE.markServerKeyAsValidated(account: session.account) } catch { restoreCurrentKeySet(previousKeySet, serverKeyStale: wasServerKeyStale) throw error @@ -174,6 +175,7 @@ class NCEndToEndSetup { preference.setEndToEndPublicKey(account: session.account, publicKey: publicKey) preference.setEndToEndPassphrase(account: session.account, passphrase: passphrase) preference.setEndToEndServerKeyStale(account: session.account, stale: false) + await NCNetworkingE2EE.markServerKeyAsValidated(account: session.account) NCManageDatabase.shared.clearTablesE2EE(account: session.account) } From c96d77467f57d9878b22397da603943fdf78b08f Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Fri, 21 Aug 2026 08:48:45 +0200 Subject: [PATCH 10/18] refactor: clarify metadata key decryption checks Signed-off-by: Marino Faggiana --- .../Networking/E2EE/NCEndToEndKeySetResolver.swift | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/iOSClient/Networking/E2EE/NCEndToEndKeySetResolver.swift b/iOSClient/Networking/E2EE/NCEndToEndKeySetResolver.swift index 57f1b8cf45..b2360c5141 100644 --- a/iOSClient/Networking/E2EE/NCEndToEndKeySetResolver.swift +++ b/iOSClient/Networking/E2EE/NCEndToEndKeySetResolver.swift @@ -33,7 +33,7 @@ struct NCEndToEndKeySetResolver { currentKeySet: currentKeySet, account: account, canDecrypt: { keySet in - encryptedMetadataKeys.contains { decryptsLegacyMetadataKey($0, with: keySet) } + encryptedMetadataKeys.contains { canDecryptLegacyMetadataKey($0, with: keySet) } } ) } @@ -43,7 +43,7 @@ struct NCEndToEndKeySetResolver { currentKeySet: currentKeySet, account: account, canDecrypt: { keySet in - decryptsLegacyMetadataKey(metadataV12.metadata.metadataKey, with: keySet) + canDecryptLegacyMetadataKey(metadataV12.metadata.metadataKey, with: keySet) } ) } @@ -61,7 +61,7 @@ struct NCEndToEndKeySetResolver { currentKeySet: currentKeySet, account: account, canDecrypt: { keySet in - decryptsMetadataKey(encryptedMetadataKey, with: keySet) + canDecryptMetadataKey(encryptedMetadataKey, with: keySet) } ) } @@ -74,7 +74,7 @@ struct NCEndToEndKeySetResolver { currentKeySet: currentKeySet(account: account), account: account, canDecrypt: { keySet in - decryptsMetadataKey(encryptedMetadataKey, with: keySet) + canDecryptMetadataKey(encryptedMetadataKey, with: keySet) } ) } @@ -145,7 +145,7 @@ struct NCEndToEndKeySetResolver { ) } - private func decryptsLegacyMetadataKey(_ encryptedMetadataKey: String, with keySet: NCEndToEndKeySet) -> Bool { + private func canDecryptLegacyMetadataKey(_ encryptedMetadataKey: String, with keySet: NCEndToEndKeySet) -> Bool { guard let privateKey = keySet.privateKey, let encryptedData = Data(base64Encoded: encryptedMetadataKey), let decryptedData = NCEndToEndEncryption.shared().decryptAsymmetricData(encryptedData, privateKey: privateKey), @@ -157,7 +157,7 @@ struct NCEndToEndKeySetResolver { return !decodedKey.isEmpty } - private func decryptsMetadataKey(_ encryptedMetadataKey: String, with keySet: NCEndToEndKeySet) -> Bool { + private func canDecryptMetadataKey(_ encryptedMetadataKey: String, with keySet: NCEndToEndKeySet) -> Bool { guard let privateKey = keySet.privateKey, let encryptedData = Data(base64Encoded: encryptedMetadataKey), let decryptedData = NCEndToEndEncryption.shared().decryptAsymmetricData(encryptedData, privateKey: privateKey) else { From dbf0ff23656609e2802749883d92f5127383ae09 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Mon, 24 Aug 2026 09:05:15 +0200 Subject: [PATCH 11/18] fix: require password for E2EE key deletion Prompt for the account password before deleting E2EE keys or renewing certificates. Update NextcloudKit to the password-parameter branch. Signed-off-by: Marino Faggiana --- Nextcloud.xcodeproj/project.pbxproj | 40 ++++++++-------- .../xcshareddata/swiftpm/Package.resolved | 4 +- iOSClient/Settings/E2EE/NCEndToEndSetup.swift | 4 +- .../Settings/E2EE/NCManageE2EEModel.swift | 4 +- .../Settings/E2EE/NCManageE2EEView.swift | 47 +++++++++++++++---- 5 files changed, 65 insertions(+), 34 deletions(-) diff --git a/Nextcloud.xcodeproj/project.pbxproj b/Nextcloud.xcodeproj/project.pbxproj index 234bfec247..94d5c47ad2 100644 --- a/Nextcloud.xcodeproj/project.pbxproj +++ b/Nextcloud.xcodeproj/project.pbxproj @@ -13,6 +13,19 @@ 2C33C48623E2C475005F963B /* Notification Service Extension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 2C33C47F23E2C475005F963B /* Notification Service Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 370D26AF248A3D7A00121797 /* NCCellMain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 370D26AE248A3D7A00121797 /* NCCellMain.swift */; }; A1B2C3D430B0000100ABCDEF /* NCMediaViewerLoadingPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D330B0000100ABCDEF /* NCMediaViewerLoadingPolicy.swift */; }; + A1E2EE010000000000000002 /* NCEndToEndKeySet.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1E2EE010000000000000001 /* NCEndToEndKeySet.swift */; }; + A1E2EE010000000000000003 /* NCEndToEndKeySet.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1E2EE010000000000000001 /* NCEndToEndKeySet.swift */; }; + A1E2EE010000000000000004 /* NCEndToEndKeySet.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1E2EE010000000000000001 /* NCEndToEndKeySet.swift */; }; + A1E2EE010000000000000005 /* NCEndToEndKeySet.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1E2EE010000000000000001 /* NCEndToEndKeySet.swift */; }; + A1E2EE010000000000000006 /* NCEndToEndKeySet.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1E2EE010000000000000001 /* NCEndToEndKeySet.swift */; }; + A1E2EE010000000000000007 /* NCEndToEndKeySet.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1E2EE010000000000000001 /* NCEndToEndKeySet.swift */; }; + A1E2EE010000000000000008 /* NCEndToEndKeySet.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1E2EE010000000000000001 /* NCEndToEndKeySet.swift */; }; + A1E2EE01000000000000000A /* NCEndToEndKeySetArchiveTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1E2EE010000000000000009 /* NCEndToEndKeySetArchiveTests.swift */; }; + A1E2EE020000000000000003 /* NCEndToEndKeySetAccess.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1E2EE020000000000000001 /* NCEndToEndKeySetAccess.swift */; }; + A1E2EE020000000000000004 /* NCEndToEndKeySetAccess.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1E2EE020000000000000001 /* NCEndToEndKeySetAccess.swift */; }; + A1E2EE020000000000000005 /* NCEndToEndKeySetResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1E2EE020000000000000002 /* NCEndToEndKeySetResolver.swift */; }; + A1E2EE020000000000000006 /* NCEndToEndKeySetResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1E2EE020000000000000002 /* NCEndToEndKeySetResolver.swift */; }; + A1E2EE020000000000000008 /* NCEndToEndKeySetResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1E2EE020000000000000007 /* NCEndToEndKeySetResolverTests.swift */; }; A5A87F9E4B0E4441A6A4BC20 /* NCContextMenuProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB7697C94BA14450A0867940 /* NCContextMenuProfile.swift */; }; AA3C85E82D36B08C00F74F12 /* UITestBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA3C85E72D36B08C00F74F12 /* UITestBackend.swift */; }; AA3C85EB2D36BBFB00F74F12 /* OCSResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA3C85EA2D36BBF400F74F12 /* OCSResponse.swift */; }; @@ -45,19 +58,6 @@ AABBCC0630A8000100F0A001 /* NCVideoPlaybackPresentationContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = AABBCC0530A8000100F0A001 /* NCVideoPlaybackPresentationContext.swift */; }; AABBCC0830A8000100F0A001 /* NCVideoPlaybackPresentationContextTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AABBCC0730A8000100F0A001 /* NCVideoPlaybackPresentationContextTests.swift */; }; AABBCC0A30A8000100F0A001 /* NCMediaViewerModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AABBCC0930A8000100F0A001 /* NCMediaViewerModelTests.swift */; }; - A1E2EE010000000000000002 /* NCEndToEndKeySet.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1E2EE010000000000000001 /* NCEndToEndKeySet.swift */; }; - A1E2EE010000000000000003 /* NCEndToEndKeySet.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1E2EE010000000000000001 /* NCEndToEndKeySet.swift */; }; - A1E2EE010000000000000004 /* NCEndToEndKeySet.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1E2EE010000000000000001 /* NCEndToEndKeySet.swift */; }; - A1E2EE010000000000000005 /* NCEndToEndKeySet.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1E2EE010000000000000001 /* NCEndToEndKeySet.swift */; }; - A1E2EE010000000000000006 /* NCEndToEndKeySet.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1E2EE010000000000000001 /* NCEndToEndKeySet.swift */; }; - A1E2EE010000000000000007 /* NCEndToEndKeySet.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1E2EE010000000000000001 /* NCEndToEndKeySet.swift */; }; - A1E2EE010000000000000008 /* NCEndToEndKeySet.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1E2EE010000000000000001 /* NCEndToEndKeySet.swift */; }; - A1E2EE01000000000000000A /* NCEndToEndKeySetArchiveTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1E2EE010000000000000009 /* NCEndToEndKeySetArchiveTests.swift */; }; - A1E2EE020000000000000003 /* NCEndToEndKeySetAccess.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1E2EE020000000000000001 /* NCEndToEndKeySetAccess.swift */; }; - A1E2EE020000000000000004 /* NCEndToEndKeySetAccess.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1E2EE020000000000000001 /* NCEndToEndKeySetAccess.swift */; }; - A1E2EE020000000000000005 /* NCEndToEndKeySetResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1E2EE020000000000000002 /* NCEndToEndKeySetResolver.swift */; }; - A1E2EE020000000000000006 /* NCEndToEndKeySetResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1E2EE020000000000000002 /* NCEndToEndKeySetResolver.swift */; }; - A1E2EE020000000000000008 /* NCEndToEndKeySetResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1E2EE020000000000000007 /* NCEndToEndKeySetResolverTests.swift */; }; AABD0C8A2D5F67A400F009E6 /* XCUIElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = AABD0C892D5F67A200F009E6 /* XCUIElement.swift */; }; AAE330042D2ED20200B04903 /* NCShareNavigationTitleSetting.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAE330032D2ED1FF00B04903 /* NCShareNavigationTitleSetting.swift */; }; AAFC0D042F9AA10000F0A001 /* NCFocusedAutoUploadIntroView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAFC0D012F9AA10000F0A001 /* NCFocusedAutoUploadIntroView.swift */; }; @@ -1231,6 +1231,11 @@ 370D26AE248A3D7A00121797 /* NCCellMain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCCellMain.swift; sourceTree = ""; }; 8932E90EC4278026D86CCCC9 /* NCContextMenuComment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCContextMenuComment.swift; sourceTree = ""; }; A1B2C3D330B0000100ABCDEF /* NCMediaViewerLoadingPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCMediaViewerLoadingPolicy.swift; sourceTree = ""; }; + A1E2EE010000000000000001 /* NCEndToEndKeySet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCEndToEndKeySet.swift; sourceTree = ""; }; + A1E2EE010000000000000009 /* NCEndToEndKeySetArchiveTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCEndToEndKeySetArchiveTests.swift; sourceTree = ""; }; + A1E2EE020000000000000001 /* NCEndToEndKeySetAccess.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCEndToEndKeySetAccess.swift; sourceTree = ""; }; + A1E2EE020000000000000002 /* NCEndToEndKeySetResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCEndToEndKeySetResolver.swift; sourceTree = ""; }; + A1E2EE020000000000000007 /* NCEndToEndKeySetResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCEndToEndKeySetResolverTests.swift; sourceTree = ""; }; AA3C85E72D36B08C00F74F12 /* UITestBackend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITestBackend.swift; sourceTree = ""; }; AA3C85EA2D36BBF400F74F12 /* OCSResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OCSResponse.swift; sourceTree = ""; }; AA3C85ED2D36BCCB00F74F12 /* SharesResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharesResponse.swift; sourceTree = ""; }; @@ -1309,11 +1314,6 @@ AABBCC0530A8000100F0A001 /* NCVideoPlaybackPresentationContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCVideoPlaybackPresentationContext.swift; sourceTree = ""; }; AABBCC0730A8000100F0A001 /* NCVideoPlaybackPresentationContextTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCVideoPlaybackPresentationContextTests.swift; sourceTree = ""; }; AABBCC0930A8000100F0A001 /* NCMediaViewerModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCMediaViewerModelTests.swift; sourceTree = ""; }; - A1E2EE010000000000000001 /* NCEndToEndKeySet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCEndToEndKeySet.swift; sourceTree = ""; }; - A1E2EE010000000000000009 /* NCEndToEndKeySetArchiveTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCEndToEndKeySetArchiveTests.swift; sourceTree = ""; }; - A1E2EE020000000000000001 /* NCEndToEndKeySetAccess.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCEndToEndKeySetAccess.swift; sourceTree = ""; }; - A1E2EE020000000000000002 /* NCEndToEndKeySetResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCEndToEndKeySetResolver.swift; sourceTree = ""; }; - A1E2EE020000000000000007 /* NCEndToEndKeySetResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCEndToEndKeySetResolverTests.swift; sourceTree = ""; }; AABD0C862D5F58C400F009E6 /* Server.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = Server.sh; sourceTree = ""; }; AABD0C892D5F67A200F009E6 /* XCUIElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCUIElement.swift; sourceTree = ""; }; AACCAB522CFE041F00DA1786 /* sl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sl; path = sl.lproj/Intent.strings; sourceTree = ""; }; @@ -6720,8 +6720,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/nextcloud/NextcloudKit"; requirement = { - kind = exactVersion; - version = 7.4.3; + branch = "password-parameter"; + kind = branch; }; }; F788ECC5263AAAF900ADC67F /* XCRemoteSwiftPackageReference "MarkdownKit" */ = { diff --git a/Nextcloud.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Nextcloud.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index cd727d782e..4ae4409a5f 100644 --- a/Nextcloud.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Nextcloud.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -195,8 +195,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/nextcloud/NextcloudKit", "state" : { - "revision" : "d192518544768f3c86354ef94ce6d699019e9487", - "version" : "7.4.3" + "branch" : "password-parameter", + "revision" : "cfc307e5611a9294713a71640c13b3170be0c606" } }, { diff --git a/iOSClient/Settings/E2EE/NCEndToEndSetup.swift b/iOSClient/Settings/E2EE/NCEndToEndSetup.swift index e980387ec1..6b7e1b9170 100644 --- a/iOSClient/Settings/E2EE/NCEndToEndSetup.swift +++ b/iOSClient/Settings/E2EE/NCEndToEndSetup.swift @@ -537,7 +537,7 @@ class NCEndToEndSetup { /// - Throws: An error if the private key is missing, CSR creation fails, /// a server request fails, or the returned certificate does not contain /// the expected public key. - func renewCertificate() async throws -> String { + func renewCertificate(password: String) async throws -> String { let capabilities = await NKCapabilities.shared.getCapabilities(for: session.account) options = networkingE2EE.getOptions(account: session.account, capabilities: capabilities) @@ -562,7 +562,7 @@ class NCEndToEndSetup { ) } - let deleteError = await NextcloudKit.shared.deleteE2EEPublicKeyAsync(account: session.account, options: options).error + let deleteError = await NextcloudKit.shared.deleteE2EEPublicKeyAsync(account: session.account, password: password, options: options).error guard deleteError == .success else { throw deleteError } diff --git a/iOSClient/Settings/E2EE/NCManageE2EEModel.swift b/iOSClient/Settings/E2EE/NCManageE2EEModel.swift index e3c090bae8..1995901211 100644 --- a/iOSClient/Settings/E2EE/NCManageE2EEModel.swift +++ b/iOSClient/Settings/E2EE/NCManageE2EEModel.swift @@ -69,10 +69,10 @@ class NCManageE2EE: NSObject, ObservableObject, ViewOnAppearHandling, TOPasscode } @MainActor - func renewCertificate() async { + func renewCertificate(password: String) async { do { let e2ee = NCEndToEndSetup(controller: controller) - let certificate = try await e2ee.renewCertificate() + let certificate = try await e2ee.renewCertificate(password: password) self.certificateValidity = networkingE2EE.getX509CertificateValidity(from: certificate) await showInfoBanner(windowScene: windowScene, text: "_e2e_renew_certificate_success_") diff --git a/iOSClient/Settings/E2EE/NCManageE2EEView.swift b/iOSClient/Settings/E2EE/NCManageE2EEView.swift index 1245027864..119758bc9b 100644 --- a/iOSClient/Settings/E2EE/NCManageE2EEView.swift +++ b/iOSClient/Settings/E2EE/NCManageE2EEView.swift @@ -9,6 +9,10 @@ struct NCManageE2EEView: View { @ObservedObject var model: NCManageE2EE @Environment(\.presentationMode) var presentationMode + @State private var showPasswordPrompt = false + @State private var password = "" + @State private var passwordCompletion: (@MainActor (String) async -> Void)? + var body: some View { VStack { if model.isEndToEndEnabled { @@ -130,8 +134,8 @@ struct NCManageE2EEView: View { } .contentShape(Rectangle()) .onTapGesture { - Task { - await model.renewCertificate() + requestPassword { password in + await model.renewCertificate(password: password) } } } header: { @@ -188,6 +192,27 @@ struct NCManageE2EEView: View { presentationMode.wrappedValue.dismiss() } } + .alert(NSLocalizedString("_password_", comment: ""), isPresented: $showPasswordPrompt) { + SecureField(NSLocalizedString("_enter_password_", comment: ""), text: $password) + + Button(NSLocalizedString("_cancel_", comment: ""), role: .cancel) { + password = "" + passwordCompletion = nil + } + + Button(NSLocalizedString("_confirm_", comment: "")) { + guard let completion = passwordCompletion else { return } + let submittedPassword = password + + password = "" + passwordCompletion = nil + + Task { + await completion(submittedPassword) + } + } + .disabled(password.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } } @ViewBuilder @@ -212,9 +237,9 @@ struct NCManageE2EEView: View { } .contentShape(Rectangle()) .onTapGesture { - Task { + requestPassword { password in let options = NCNetworkingE2EE().getOptions(account: model.session.account, capabilities: model.capabilities) - let results = await NextcloudKit.shared.deleteE2EEPublicKeyAsync(account: model.session.account, options: options) + let results = await NextcloudKit.shared.deleteE2EEPublicKeyAsync(account: model.session.account, password: password, options: options) if results.error == .success { await showInfoBanner(windowScene: model.windowScene, @@ -244,9 +269,9 @@ struct NCManageE2EEView: View { } .contentShape(Rectangle()) .onTapGesture { - Task { + requestPassword { password in let options = NCNetworkingE2EE().getOptions(account: model.session.account, capabilities: model.capabilities) - let results = await NextcloudKit.shared.deleteE2EEPrivateKeyAsync(account: model.session.account, options: options) + let results = await NextcloudKit.shared.deleteE2EEPrivateKeyAsync(account: model.session.account, password: password, options: options) if results.error == .success { await showInfoBanner(windowScene: model.windowScene, @@ -276,9 +301,9 @@ struct NCManageE2EEView: View { } .contentShape(Rectangle()) .onTapGesture { - Task { + requestPassword { password in let options = NCNetworkingE2EE().getOptions(account: model.session.account, capabilities: model.capabilities) - let results = await NextcloudKit.shared.deleteE2EEKeysAsync(account: model.session.account, options: options) + let results = await NextcloudKit.shared.deleteE2EEKeysAsync(account: model.session.account, password: password, options: options) if results.error == .success { await showInfoBanner(windowScene: model.windowScene, text: "E2E delete Keys from FS") @@ -301,6 +326,12 @@ struct NCManageE2EEView: View { return Date() >= warningDate ? .orange : .primary } + + private func requestPassword(action: @escaping @MainActor (String) async -> Void) { + password = "" + passwordCompletion = action + showPasswordPrompt = true + } } #Preview { From bf9d458cb3f6bdd2e7ad7aa8ae89bb0c30a67fc3 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Mon, 24 Aug 2026 09:38:06 +0200 Subject: [PATCH 12/18] fix: allow passwordless E2EE key management Update NextcloudKit and Firebase package revisions. Signed-off-by: Marino Faggiana --- .../xcshareddata/swiftpm/Package.resolved | 10 +++++----- iOSClient/Settings/E2EE/NCEndToEndSetup.swift | 4 ++-- iOSClient/Settings/E2EE/NCManageE2EEModel.swift | 2 +- iOSClient/Settings/E2EE/NCManageE2EEView.swift | 11 +++++------ 4 files changed, 13 insertions(+), 14 deletions(-) diff --git a/Nextcloud.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Nextcloud.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 4ae4409a5f..0467d5f8fe 100644 --- a/Nextcloud.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Nextcloud.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -60,8 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/firebase/firebase-ios-sdk", "state" : { - "revision" : "33a468adfdb75b53f05a37e7c886ca7c962b5c17", - "version" : "12.17.0" + "revision" : "346daa9f46316aa372b35b317e18224acc2e9063", + "version" : "12.18.0" } }, { @@ -78,8 +78,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/GoogleAppMeasurement.git", "state" : { - "revision" : "fceaffa07d22dcd5624d3639fd970351a4a5ad8c", - "version" : "12.17.0" + "revision" : "f04760d460296cc0fa430935a7be212e5bd67fc5", + "version" : "12.18.0" } }, { @@ -196,7 +196,7 @@ "location" : "https://github.com/nextcloud/NextcloudKit", "state" : { "branch" : "password-parameter", - "revision" : "cfc307e5611a9294713a71640c13b3170be0c606" + "revision" : "97b0c0c42dde30e72fd7c8c0f6dc17823113c5ce" } }, { diff --git a/iOSClient/Settings/E2EE/NCEndToEndSetup.swift b/iOSClient/Settings/E2EE/NCEndToEndSetup.swift index 6b7e1b9170..645bc77675 100644 --- a/iOSClient/Settings/E2EE/NCEndToEndSetup.swift +++ b/iOSClient/Settings/E2EE/NCEndToEndSetup.swift @@ -537,7 +537,7 @@ class NCEndToEndSetup { /// - Throws: An error if the private key is missing, CSR creation fails, /// a server request fails, or the returned certificate does not contain /// the expected public key. - func renewCertificate(password: String) async throws -> String { + func renewCertificate(password: String?) async throws -> String { let capabilities = await NKCapabilities.shared.getCapabilities(for: session.account) options = networkingE2EE.getOptions(account: session.account, capabilities: capabilities) @@ -562,7 +562,7 @@ class NCEndToEndSetup { ) } - let deleteError = await NextcloudKit.shared.deleteE2EEPublicKeyAsync(account: session.account, password: password, options: options).error + let deleteError = await NextcloudKit.shared.deleteE2EEPublicKeyAsync(account: session.account, password: password ?? "", options: options).error guard deleteError == .success else { throw deleteError } diff --git a/iOSClient/Settings/E2EE/NCManageE2EEModel.swift b/iOSClient/Settings/E2EE/NCManageE2EEModel.swift index 1995901211..1b1c329ab8 100644 --- a/iOSClient/Settings/E2EE/NCManageE2EEModel.swift +++ b/iOSClient/Settings/E2EE/NCManageE2EEModel.swift @@ -69,7 +69,7 @@ class NCManageE2EE: NSObject, ObservableObject, ViewOnAppearHandling, TOPasscode } @MainActor - func renewCertificate(password: String) async { + func renewCertificate(password: String?) async { do { let e2ee = NCEndToEndSetup(controller: controller) let certificate = try await e2ee.renewCertificate(password: password) diff --git a/iOSClient/Settings/E2EE/NCManageE2EEView.swift b/iOSClient/Settings/E2EE/NCManageE2EEView.swift index 119758bc9b..dfa112b091 100644 --- a/iOSClient/Settings/E2EE/NCManageE2EEView.swift +++ b/iOSClient/Settings/E2EE/NCManageE2EEView.swift @@ -11,7 +11,7 @@ struct NCManageE2EEView: View { @State private var showPasswordPrompt = false @State private var password = "" - @State private var passwordCompletion: (@MainActor (String) async -> Void)? + @State private var passwordCompletion: (@MainActor (String?) async -> Void)? var body: some View { VStack { @@ -202,7 +202,7 @@ struct NCManageE2EEView: View { Button(NSLocalizedString("_confirm_", comment: "")) { guard let completion = passwordCompletion else { return } - let submittedPassword = password + let submittedPassword = password.isEmpty ? nil : password password = "" passwordCompletion = nil @@ -211,7 +211,6 @@ struct NCManageE2EEView: View { await completion(submittedPassword) } } - .disabled(password.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } } @@ -271,7 +270,7 @@ struct NCManageE2EEView: View { .onTapGesture { requestPassword { password in let options = NCNetworkingE2EE().getOptions(account: model.session.account, capabilities: model.capabilities) - let results = await NextcloudKit.shared.deleteE2EEPrivateKeyAsync(account: model.session.account, password: password, options: options) + let results = await NextcloudKit.shared.deleteE2EEPrivateKeyAsync(account: model.session.account, password: password ?? "", options: options) if results.error == .success { await showInfoBanner(windowScene: model.windowScene, @@ -303,7 +302,7 @@ struct NCManageE2EEView: View { .onTapGesture { requestPassword { password in let options = NCNetworkingE2EE().getOptions(account: model.session.account, capabilities: model.capabilities) - let results = await NextcloudKit.shared.deleteE2EEKeysAsync(account: model.session.account, password: password, options: options) + let results = await NextcloudKit.shared.deleteE2EEKeysAsync(account: model.session.account, password: password ?? "", options: options) if results.error == .success { await showInfoBanner(windowScene: model.windowScene, text: "E2E delete Keys from FS") @@ -327,7 +326,7 @@ struct NCManageE2EEView: View { return Date() >= warningDate ? .orange : .primary } - private func requestPassword(action: @escaping @MainActor (String) async -> Void) { + private func requestPassword(action: @escaping @MainActor (String?) async -> Void) { password = "" passwordCompletion = action showPasswordPrompt = true From 70d0b0a516649fa757049d47d69dbd4265d0649a Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Mon, 24 Aug 2026 16:10:32 +0200 Subject: [PATCH 13/18] cleaning Signed-off-by: Marino Faggiana --- iOSClient/Settings/E2EE/NCEndToEndSetup.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/iOSClient/Settings/E2EE/NCEndToEndSetup.swift b/iOSClient/Settings/E2EE/NCEndToEndSetup.swift index 645bc77675..f108946fb4 100644 --- a/iOSClient/Settings/E2EE/NCEndToEndSetup.swift +++ b/iOSClient/Settings/E2EE/NCEndToEndSetup.swift @@ -562,7 +562,7 @@ class NCEndToEndSetup { ) } - let deleteError = await NextcloudKit.shared.deleteE2EEPublicKeyAsync(account: session.account, password: password ?? "", options: options).error + let deleteError = await NextcloudKit.shared.deleteE2EEPublicKeyAsync(account: session.account, password: password, options: options).error guard deleteError == .success else { throw deleteError } From 05b64a22339173913b065f1a44c74937b304f8f2 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Tue, 25 Aug 2026 07:43:12 +0200 Subject: [PATCH 14/18] rename Signed-off-by: Marino Faggiana --- iOSClient/Settings/E2EE/NCManageE2EEView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/iOSClient/Settings/E2EE/NCManageE2EEView.swift b/iOSClient/Settings/E2EE/NCManageE2EEView.swift index dfa112b091..0d45c0facc 100644 --- a/iOSClient/Settings/E2EE/NCManageE2EEView.swift +++ b/iOSClient/Settings/E2EE/NCManageE2EEView.swift @@ -285,7 +285,7 @@ struct NCManageE2EEView: View { HStack { Label { - Text("Delete Keys from FS") + Text("Delete Keys and files") .cappedFont(.body, maxDynamicType: .accessibility2) } icon: { Image(systemName: "exclamationmark.triangle") From 48357d16e260d97dc9a457ff9c7c8ee2cc2863ec Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Tue, 25 Aug 2026 08:36:37 +0200 Subject: [PATCH 15/18] fix: validate E2EE keys against user certificates Use the server public key only to authenticate certificates, and compare embedded user keys when detecting key changes. Verify decrypted private keys match the user certificate before storing or uploading them. Signed-off-by: Marino Faggiana --- .../NCEndToEndKeySetResolverTests.swift | 6 +- .../Networking/E2EE/NCNetworkingE2EE.swift | 43 +++--- iOSClient/Settings/E2EE/NCEndToEndSetup.swift | 130 ++++++++++-------- 3 files changed, 103 insertions(+), 76 deletions(-) diff --git a/Tests/NextcloudUnitTests/NCEndToEndKeySetResolverTests.swift b/Tests/NextcloudUnitTests/NCEndToEndKeySetResolverTests.swift index 6f3c963682..4652052db8 100644 --- a/Tests/NextcloudUnitTests/NCEndToEndKeySetResolverTests.swift +++ b/Tests/NextcloudUnitTests/NCEndToEndKeySetResolverTests.swift @@ -24,13 +24,15 @@ struct NCEndToEndKeySetResolverTests { #expect(access.writeAccessError == .success) } - @Test("Equivalent PEM formatting identifies the same server key") - func equivalentServerPublicKeysMatch() { + @Test("Equivalent PEM formatting identifies the same public key") + func equivalentPublicKeysMatch() { let compact = "-----BEGIN PUBLIC KEY-----\nYWJjZA==\n-----END PUBLIC KEY-----" let wrapped = "-----BEGIN PUBLIC KEY-----\nYWJj\nZA==\n-----END PUBLIC KEY-----\n" + let empty = "-----BEGIN PUBLIC KEY-----\n-----END PUBLIC KEY-----" #expect(NCNetworkingE2EE().publicKeysMatch(compact, wrapped)) #expect(!NCNetworkingE2EE().publicKeysMatch(compact, "different-key")) + #expect(!NCNetworkingE2EE().publicKeysMatch(empty, empty)) } @Test("A stale local key is excluded from active write access") diff --git a/iOSClient/Networking/E2EE/NCNetworkingE2EE.swift b/iOSClient/Networking/E2EE/NCNetworkingE2EE.swift index 23dc6bebf5..ae821d542d 100644 --- a/iOSClient/Networking/E2EE/NCNetworkingE2EE.swift +++ b/iOSClient/Networking/E2EE/NCNetworkingE2EE.swift @@ -12,7 +12,7 @@ class NCNetworkingE2EE: NSObject { let e2EEApiVersion1 = "v1" let e2EEApiVersion2 = "v2" - /// Executes at most one server-key request per account while the app + /// Executes at most one user-certificate request per account while the app /// remains active. Concurrent callers share the same in-flight request. private actor ServerKeyValidationGate { private var validations: [String: Task] = [:] @@ -100,14 +100,16 @@ class NCNetworkingE2EE: NSObject { await serverKeyValidationGate.markValidated(account: account) } - /// Verifies once per active app cycle that the locally active key set is - /// still the key set published by the server. A mismatch is persisted - /// immediately so the old key can only be selected through the archived, - /// read-only path. + /// Verifies once per active app cycle that the locally active user key is + /// still the key published by the server. The server public key only + /// authenticates the remote certificate; the embedded user public keys + /// determine whether the user's key pair changed. func validateCurrentServerKey(account: String) async -> NKError { let preferences = NCPreferences() - guard let localPublicKey = preferences.getEndToEndPublicKey(account: account), - !localPublicKey.isEmpty else { + guard let localCertificate = preferences.getEndToEndCertificate(account: account), + !localCertificate.isEmpty, + let serverPublicKey = preferences.getEndToEndPublicKey(account: account), + !serverPublicKey.isEmpty else { return NKError( errorCode: NCGlobal.shared.errorE2EENotEnabled, errorDescription: NSLocalizedString("_e2ee_no_metadataKey_found_", comment: "") @@ -123,19 +125,21 @@ class NCNetworkingE2EE: NSObject { ) { await Self.performServerKeyValidation( account: account, - localPublicKey: localPublicKey + localCertificate: localCertificate, + serverPublicKey: serverPublicKey ) } } private static func performServerKeyValidation( account: String, - localPublicKey: String + localCertificate: String, + serverPublicKey: String ) async -> NKError { let networkingE2EE = NCNetworkingE2EE() let preferences = NCPreferences() let capabilities = await NKCapabilities.shared.getCapabilities(for: account) - let result = await NextcloudKit.shared.getE2EEPublicKeyAsync( + let result = await NextcloudKit.shared.getE2EECertificateAsync( account: account, options: networkingE2EE.getOptions(account: account, capabilities: capabilities) ) @@ -143,7 +147,7 @@ class NCNetworkingE2EE: NSObject { if result.error.errorCode == NCGlobal.shared.errorResourceNotFound { return markCurrentServerKeyAsStale( account: account, - expectedPublicKey: localPublicKey, + expectedCertificate: localCertificate, preferences: preferences ) } @@ -152,14 +156,18 @@ class NCNetworkingE2EE: NSObject { return result.error } - guard let serverPublicKey = result.publicKey, !serverPublicKey.isEmpty else { + guard let remoteCertificate = result.certificate, !remoteCertificate.isEmpty, + let endToEndEncryption = NCEndToEndEncryption.shared(), + let localUserPublicKey = endToEndEncryption.extractPublicKey(fromCertificate: localCertificate), + let remoteUserPublicKey = endToEndEncryption.extractPublicKey(fromCertificate: remoteCertificate) else { return .invalidData } - guard networkingE2EE.publicKeysMatch(localPublicKey, serverPublicKey) else { + guard endToEndEncryption.verifyCertificate(remoteCertificate, publicKey: serverPublicKey), + networkingE2EE.publicKeysMatch(localUserPublicKey, remoteUserPublicKey) else { return markCurrentServerKeyAsStale( account: account, - expectedPublicKey: localPublicKey, + expectedCertificate: localCertificate, preferences: preferences ) } @@ -172,7 +180,8 @@ class NCNetworkingE2EE: NSObject { /// normal and changed-key behavior can be covered by unit tests. func publicKeysMatch(_ first: String, _ second: String) -> Bool { func payload(_ key: String) -> String { - key.components(separatedBy: .whitespacesAndNewlines) + key.components(separatedBy: .newlines) + .map { $0.trimmingCharacters(in: .whitespaces) } .filter { !$0.isEmpty && !$0.hasPrefix("-----") } .joined() } @@ -184,12 +193,12 @@ class NCNetworkingE2EE: NSObject { private static func markCurrentServerKeyAsStale( account: String, - expectedPublicKey: String, + expectedCertificate: String, preferences: NCPreferences ) -> NKError { // Setup may have replaced the active set while this request was in // flight. An obsolete response must never mark the new set as stale. - guard preferences.getEndToEndPublicKey(account: account) == expectedPublicKey else { + guard preferences.getEndToEndCertificate(account: account) == expectedCertificate else { return .success } diff --git a/iOSClient/Settings/E2EE/NCEndToEndSetup.swift b/iOSClient/Settings/E2EE/NCEndToEndSetup.swift index f108946fb4..e4ebf4103d 100644 --- a/iOSClient/Settings/E2EE/NCEndToEndSetup.swift +++ b/iOSClient/Settings/E2EE/NCEndToEndSetup.swift @@ -92,26 +92,27 @@ class NCEndToEndSetup { let capabilities = await NKCapabilities.shared.getCapabilities(for: session.account) options = networkingE2EE.getOptions(account: session.account, capabilities: capabilities) - let publicKeyResult = await NextcloudKit.shared.getE2EEPublicKeyAsync( + let serverPublicKeyResult = await NextcloudKit.shared.getE2EEPublicKeyAsync( account: session.account, options: options ) - if publicKeyResult.error.errorCode == global.errorResourceNotFound { - try await start() - return - } - - guard publicKeyResult.error == .success, - let publicKey = publicKeyResult.publicKey, - !publicKey.isEmpty else { - throw publicKeyResult.error == .success ? NKError.invalidData : publicKeyResult.error + guard serverPublicKeyResult.error == .success, + let serverPublicKey = serverPublicKeyResult.publicKey, + !serverPublicKey.isEmpty else { + throw serverPublicKeyResult.error == .success ? NKError.invalidData : serverPublicKeyResult.error } let certificateResult = await NextcloudKit.shared.getE2EECertificateAsync( account: session.account, options: options ) + + if certificateResult.error.errorCode == global.errorResourceNotFound { + try await start() + return + } + guard certificateResult.error == .success, let certificate = certificateResult.certificate, !certificate.isEmpty else { @@ -146,33 +147,21 @@ class NCEndToEndSetup { } guard let endToEndEncryption, - endToEndEncryption.verifyCertificate(certificate, publicKey: publicKey) else { + endToEndEncryption.verifyCertificate(certificate, publicKey: serverPublicKey) else { throw NKError( errorCode: global.errorInternalError, errorDescription: NSLocalizedString("_e2ee_setup_verify_publickey_", comment: "") ) } - let certificateSigningRequest = try networkingE2EE.createCertificateSigningRequest( - privateKeyPEM: privateKey, - commonName: session.userId - ) - guard let privateKeyPublicKey = endToEndEncryption.extractPublicKey( - fromCertificateSigningRequest: certificateSigningRequest - ), - networkingE2EE.publicKeysMatch(privateKeyPublicKey, publicKey) else { - throw NKError( - errorCode: global.errorInternalError, - errorDescription: NSLocalizedString("_e2ee_setup_verify_publickey_", comment: "") - ) - } + try verifyPrivateKey(privateKey, matches: certificate) // The new set is complete and verified. Only now preserve and replace // the previous active values. try preference.archiveCurrentEndToEndKeySet(account: session.account) preference.setEndToEndCertificate(account: session.account, certificate: certificate) preference.setEndToEndPrivateKey(account: session.account, privateKey: privateKey) - preference.setEndToEndPublicKey(account: session.account, publicKey: publicKey) + preference.setEndToEndPublicKey(account: session.account, publicKey: serverPublicKey) preference.setEndToEndPassphrase(account: session.account, passphrase: passphrase) preference.setEndToEndServerKeyStale(account: session.account, stale: false) await NCNetworkingE2EE.markServerKeyAsValidated(account: session.account) @@ -273,7 +262,8 @@ class NCEndToEndSetup { guard let privateKeyData = endToEndEncryption?.decryptPrivateKey(privateKeyCipher, passphrase: passphrase), let keyData = Data(base64Encoded: privateKeyData), - let privateKey = String(data: keyData, encoding: .utf8) + let privateKey = String(data: keyData, encoding: .utf8), + let certificate = preference.getEndToEndCertificate(account: session.account) else { throw NKError( errorCode: global.errorInternalError, @@ -281,25 +271,27 @@ class NCEndToEndSetup { ) } + try verifyPrivateKey(privateKey, matches: certificate) + // Save preference.setEndToEndPrivateKey(account: session.account, privateKey: privateKey) preference.setEndToEndPassphrase(account: session.account, passphrase: passphrase) - let results = await NextcloudKit.shared.getE2EEPublicKeyAsync(account: self.session.account, options: options) - guard results.error == .success, - let publicKey = results.publicKey + let serverPublicKeyResult = await NextcloudKit.shared.getE2EEPublicKeyAsync(account: self.session.account, options: options) + guard serverPublicKeyResult.error == .success, + let serverPublicKey = serverPublicKeyResult.publicKey else { - throw results.error == .success + throw serverPublicKeyResult.error == .success ? NKError( errorCode: global.errorInternalError, errorDescription: NSLocalizedString("_e2ee_setup_get_publickey_", comment: "") ) - : results.error + : serverPublicKeyResult.error } - try verifyPublicKey(publicKey) + try verifyCertificate(usingServerPublicKey: serverPublicKey) - preference.setEndToEndPublicKey(account: self.session.account, publicKey: publicKey) + preference.setEndToEndPublicKey(account: self.session.account, publicKey: serverPublicKey) NCManageDatabase.shared.clearTablesE2EE(account: self.session.account) case NCGlobal.shared.errorResourceNotFound: @@ -321,10 +313,11 @@ class NCEndToEndSetup { /// /// Steps: /// 1. Generates a new encrypted private key using the provided passphrase - /// 2. Uploads the encrypted key (cipher) to the server - /// 3. Stores the plaintext private key and passphrase locally - /// 4. Fetches and verifies the server public key - /// 5. Finalizes E2EE setup (clears metadata tables) + /// 2. Verifies that the private key belongs to the user certificate + /// 3. Uploads the encrypted key (cipher) to the server + /// 4. Stores the plaintext private key and passphrase locally + /// 5. Fetches and verifies the server public key + /// 6. Finalizes E2EE setup (clears metadata tables) /// /// - Parameters: /// - e2ePassphrase: User-generated passphrase @@ -349,6 +342,17 @@ class NCEndToEndSetup { ) } + guard let privateKeyString, + let certificate = preference.getEndToEndCertificate(account: session.account) else { + throw NKError( + errorCode: global.errorInternalError, + errorDescription: NSLocalizedString("_e2ee_setup_store_privatekey_", comment: "") + ) + } + + let privateKey = String(privateKeyString) + try verifyPrivateKey(privateKey, matches: certificate) + // Store cipher on server let storeResults = await NextcloudKit.shared.storeE2EEPrivateKeyAsync( @@ -359,42 +363,32 @@ class NCEndToEndSetup { switch storeResults.error.errorCode { case .zero: - - guard let privateKeyString else { - throw NKError( - errorCode: global.errorInternalError, - errorDescription: NSLocalizedString("_e2ee_setup_store_privatekey_", comment: "") - ) - } - - let privateKey = String(privateKeyString) - // Save locally preference.setEndToEndPrivateKey(account: session.account, privateKey: privateKey) preference.setEndToEndPassphrase(account: session.account, passphrase: e2ePassphrase) // Fetch server public key - let publicKeyResults = await NextcloudKit.shared.getE2EEPublicKeyAsync(account: session.account, options: options) + let serverPublicKeyResult = await NextcloudKit.shared.getE2EEPublicKeyAsync(account: session.account, options: options) - guard publicKeyResults.error == .success, - let publicKey = publicKeyResults.publicKey + guard serverPublicKeyResult.error == .success, + let serverPublicKey = serverPublicKeyResult.publicKey else { - throw publicKeyResults.error == .success + throw serverPublicKeyResult.error == .success ? NKError( errorCode: global.errorInternalError, errorDescription: NSLocalizedString("_e2ee_setup_get_publickey_", comment: "") ) - : publicKeyResults.error + : serverPublicKeyResult.error } // Verify - try verifyPublicKey(publicKey) + try verifyCertificate(usingServerPublicKey: serverPublicKey) // Finalize - preference.setEndToEndPublicKey(account: session.account, publicKey: publicKey) + preference.setEndToEndPublicKey(account: session.account, publicKey: serverPublicKey) NCManageDatabase.shared.clearTablesE2EE(account: session.account) if copyPassphrase { @@ -408,14 +402,14 @@ class NCEndToEndSetup { /// Verifies that the server public key matches the locally stored certificate. /// - /// - Parameter publicKey: Public key retrieved from the server + /// - Parameter serverPublicKey: Public key retrieved from the server-key endpoint /// /// - Throws: /// - `NKError` if certificate is missing or validation fails - private func verifyPublicKey(_ publicKey: String) throws { + private func verifyCertificate(usingServerPublicKey serverPublicKey: String) throws { guard let certificate = preference.getEndToEndCertificate(account: session.account), let endToEndEncryption, - endToEndEncryption.verifyCertificate(certificate, publicKey: publicKey) + endToEndEncryption.verifyCertificate(certificate, publicKey: serverPublicKey) else { throw NKError( errorCode: global.errorInternalError, @@ -424,6 +418,28 @@ class NCEndToEndSetup { } } + /// Verifies that a decrypted private key belongs to the supplied user certificate. + private func verifyPrivateKey(_ privateKey: String, matches certificate: String) throws { + let certificateSigningRequest = try networkingE2EE.createCertificateSigningRequest( + privateKeyPEM: privateKey, + commonName: session.userId + ) + + guard let endToEndEncryption, + let privateKeyPublicKey = endToEndEncryption.extractPublicKey( + fromCertificateSigningRequest: certificateSigningRequest + ), + let certificatePublicKey = endToEndEncryption.extractPublicKey( + fromCertificate: certificate + ), + networkingE2EE.publicKeysMatch(privateKeyPublicKey, certificatePublicKey) else { + throw NKError( + errorCode: global.errorInternalError, + errorDescription: NSLocalizedString("_e2ee_setup_verify_publickey_", comment: "") + ) + } + } + /// Presents a secure alert asking the user for the E2EE passphrase. /// /// - Returns: The user-entered passphrase From 10e384c628d5d34150e72cdb16bec80f99ea237b Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Tue, 25 Aug 2026 08:42:53 +0200 Subject: [PATCH 16/18] fix: refresh plus menu when E2EE preference changes Signed-off-by: Marino Faggiana --- iOSClient/Menu/NCContextMenuPlus.swift | 27 +++++++++++++------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/iOSClient/Menu/NCContextMenuPlus.swift b/iOSClient/Menu/NCContextMenuPlus.swift index a9b8792d9d..601d89f73e 100644 --- a/iOSClient/Menu/NCContextMenuPlus.swift +++ b/iOSClient/Menu/NCContextMenuPlus.swift @@ -17,7 +17,7 @@ class NCContextMenuPlus: NSObject { let menuPlusButton: UIButton? let controller: NCMainTabBarController? - private var capabilitiesSignature: String? + private var menuSignature: String? internal var windowScene: UIWindowScene? { SceneManager.shared.getWindowScene(controller: controller) @@ -54,16 +54,14 @@ class NCContextMenuPlus: NSObject { let isDirectoryE2EE = await NCUtilityFileSystem().isDirectoryE2EEAsync(serverUrl: serverUrl, urlBase: session.urlBase, userId: session.userId, account: session.account) let directory = await NCManageDatabase.shared.getTableDirectoryAsync(predicate: NSPredicate(format: "account == %@ AND serverUrl == %@", session.account, serverUrl)) let isNetworkReachable = NextcloudKit.shared.isNetworkReachable() + let isEndToEndEnabled = NCPreferences().isEndToEndEnabled(account: session.account) let titleCreateFolder = isDirectoryE2EE ? NSLocalizedString("_create_folder_e2ee_", comment: "") : NSLocalizedString("_create_folder_", comment: "") let imageCreateFolder = isDirectoryE2EE ? NCImageCache.shared.getFolderEncrypted(account: session.account) : NCImageCache.shared.getFolder(account: session.account) let creatorsByEditor = Dictionary(grouping: capabilities.directEditingCreators, by: \.editor) - let currentCapabilitiesSignature = makeCapabilitiesSignature( - capabilities: capabilities, - account: session.account, - serverUrl: serverUrl - ) - let capabilitiesChanged = capabilitiesSignature != currentCapabilitiesSignature - capabilitiesSignature = currentCapabilitiesSignature + let currentMenuSignature = makeMenuSignature(capabilities: capabilities, account: session.account, + serverUrl: serverUrl, isEndToEndEnabled: isEndToEndEnabled) + let menuChanged = menuSignature != currentMenuSignature + menuSignature = currentMenuSignature var menuUploadElements: [UIMenuElement] = [] var menuCaptureElements: [UIMenuElement] = [] @@ -139,7 +137,7 @@ class NCContextMenuPlus: NSObject { // E2EE // if serverUrl == utilityFileSystem.getHomeServer(session: session), - NCPreferences().isEndToEndEnabled(account: session.account), + isEndToEndEnabled, isNetworkReachable { menuFolderElements.append(UIAction(title: NSLocalizedString("_create_folder_e2ee_", comment: ""), image: NCImageCache.shared.getFolderEncrypted(account: session.account)) { _ in @@ -502,7 +500,7 @@ class NCContextMenuPlus: NSObject { updatePlusButtonEnabled(session: session) if menuPlusButton.menu != nil, - !capabilitiesChanged { + !menuChanged { return } @@ -584,9 +582,10 @@ class NCContextMenuPlus: NSObject { } } - private func makeCapabilitiesSignature(capabilities: NKCapabilities.Capabilities, - account: String, - serverUrl: String) -> String { + private func makeMenuSignature(capabilities: NKCapabilities.Capabilities, + account: String, + serverUrl: String, + isEndToEndEnabled: Bool) -> String { let creators = capabilities.directEditingCreators .sorted { $0.identifier < $1.identifier } .map { "\($0.identifier)|\($0.editor)|\($0.ext)|\($0.mimetype)|\($0.templates)" } @@ -596,7 +595,7 @@ class NCContextMenuPlus: NSObject { .map { "\($0.identifier)|\($0.name)" } .joined(separator: ";") - return "\(account)|\(serverUrl)|\(capabilities.richDocumentsEnabled)|\(creators)|\(editors)" + return "\(account)|\(serverUrl)|\(isEndToEndEnabled)|\(capabilities.richDocumentsEnabled)|\(creators)|\(editors)" } private func createLegacyCollaboraFile(templateType: String, From 08d0a52f052df30fdcc8580a8da26e78485d6c07 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Tue, 25 Aug 2026 16:32:03 +0200 Subject: [PATCH 17/18] chore: prepare 34.2.0 release Pin NextcloudKit to 7.5.0 and reset the build number. Signed-off-by: Marino Faggiana --- Nextcloud.xcodeproj/project.pbxproj | 12 ++++++------ .../xcshareddata/swiftpm/Package.resolved | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Nextcloud.xcodeproj/project.pbxproj b/Nextcloud.xcodeproj/project.pbxproj index 94d5c47ad2..d6859ef987 100644 --- a/Nextcloud.xcodeproj/project.pbxproj +++ b/Nextcloud.xcodeproj/project.pbxproj @@ -6383,7 +6383,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 0; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = NKUJUXUJ3B; @@ -6410,7 +6410,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 34.1.4; + MARKETING_VERSION = 34.2.0; ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = "-v"; OTHER_LDFLAGS = ""; @@ -6451,7 +6451,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 0; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = NKUJUXUJ3B; @@ -6476,7 +6476,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 34.1.4; + MARKETING_VERSION = 34.2.0; ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = "-v"; OTHER_LDFLAGS = ""; @@ -6720,8 +6720,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/nextcloud/NextcloudKit"; requirement = { - branch = "password-parameter"; - kind = branch; + kind = exactVersion; + version = 7.5.0; }; }; F788ECC5263AAAF900ADC67F /* XCRemoteSwiftPackageReference "MarkdownKit" */ = { diff --git a/Nextcloud.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Nextcloud.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 0467d5f8fe..9d42305a60 100644 --- a/Nextcloud.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Nextcloud.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -195,8 +195,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/nextcloud/NextcloudKit", "state" : { - "branch" : "password-parameter", - "revision" : "97b0c0c42dde30e72fd7c8c0f6dc17823113c5ce" + "revision" : "2c86f8b3af59b51f60bfcc8620f59f845a5554fb", + "version" : "7.5.0" } }, { From ed6405bcee7f793c9fe418fadabd70698d500c2b Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Tue, 25 Aug 2026 17:16:36 +0200 Subject: [PATCH 18/18] fix: prevent sensitive E2EE data from being logged Replace raw key and metadata output with non-sensitive E2EE diagnostics. Signed-off-by: Marino Faggiana --- iOSClient/Files/NCFiles.swift | 2 +- .../Networking/E2EE/NCEndToEndEncryption.m | 21 +---- .../E2EE/NCEndToEndKeySetAccess.swift | 12 +++ .../Networking/E2EE/NCEndToEndMetadata.swift | 11 ++- .../E2EE/NCEndToEndMetadataV1.swift | 3 - .../E2EE/NCEndToEndMetadataV2.swift | 25 +----- .../Networking/E2EE/NCNetworkingE2EE.swift | 76 ++++++++++++++++++- .../NCPushNotificationEncryption.m | 2 - iOSClient/Settings/E2EE/NCEndToEndSetup.swift | 9 +++ .../Settings/E2EE/NCManageE2EEModel.swift | 1 - 10 files changed, 106 insertions(+), 56 deletions(-) diff --git a/iOSClient/Files/NCFiles.swift b/iOSClient/Files/NCFiles.swift index c7b28da7df..81141ab3c1 100644 --- a/iOSClient/Files/NCFiles.swift +++ b/iOSClient/Files/NCFiles.swift @@ -137,7 +137,7 @@ class NCFiles: NCCollectionViewCommon { self.richWorkspaceText = tblDirectory.richWorkspace } if let metadataFolder { - nkLog(info: "Inside metadata folder \(metadataFolder.fileName) with permissions: \(metadataFolder.permissions)") + nkLog(info: "Inside metadata folder with permissions: \(metadataFolder.permissions)") // disable + button if no create permission or E2EE offline self.mainNavigationController?.menuPlus?.updatePlusButtonEnabled(session: self.session) diff --git a/iOSClient/Networking/E2EE/NCEndToEndEncryption.m b/iOSClient/Networking/E2EE/NCEndToEndEncryption.m index c168f51226..48d8b40f03 100644 --- a/iOSClient/Networking/E2EE/NCEndToEndEncryption.m +++ b/iOSClient/Networking/E2EE/NCEndToEndEncryption.m @@ -14,7 +14,7 @@ #import #import -#define addName(field, value) X509_NAME_add_entry_by_txt(name, field, MBSTRING_ASC, (unsigned char *)value, -1, -1, 0); NSLog(@"%s: %s", field, value); +#define addName(field, value) X509_NAME_add_entry_by_txt(name, field, MBSTRING_ASC, (unsigned char *)value, -1, -1, 0); #define IV_DELIMITER_ENCODED_OLD @"fA==" #define IV_DELIMITER_ENCODED @"|" @@ -158,8 +158,6 @@ - (BOOL)generateCertificateX509WithUserId:(NSString *)userId directory:(NSString return NO; } - X509_print_fp(stdout, x509); - // Extract CSR, publicKey, privateKey int len; char *keyBytes; @@ -174,7 +172,6 @@ - (BOOL)generateCertificateX509WithUserId:(NSString *)userId directory:(NSString BIO_read(csrBIO, keyBytes, len); _csrData = [NSData dataWithBytes:keyBytes length:len]; - NSLog(@"[INFO] \n%@", [[NSString alloc] initWithData:_csrData encoding:NSUTF8StringEncoding]); // PublicKey BIO *publicKeyBIO = BIO_new(BIO_s_mem()); @@ -186,7 +183,6 @@ - (BOOL)generateCertificateX509WithUserId:(NSString *)userId directory:(NSString BIO_read(publicKeyBIO, keyBytes, len); _publicKeyData = [NSData dataWithBytes:keyBytes length:len]; self.generatedPublicKey = [[NSString alloc] initWithData:_publicKeyData encoding:NSUTF8StringEncoding]; - NSLog(@"[INFO] \n%@", self.generatedPublicKey); // PrivateKey BIO *privateKeyBIO = BIO_new(BIO_s_mem()); @@ -198,7 +194,6 @@ - (BOOL)generateCertificateX509WithUserId:(NSString *)userId directory:(NSString BIO_read(privateKeyBIO, keyBytes, len); _privateKeyData = [NSData dataWithBytes:keyBytes length:len]; self.generatedPrivateKey = [[NSString alloc] initWithData:_privateKeyData encoding:NSUTF8StringEncoding]; - NSLog(@"[INFO] \n%@", self.generatedPrivateKey); if(keyBytes) free(keyBytes); @@ -232,7 +227,6 @@ - (NSString *)extractPublicKeyFromCertificate:(NSString *)pemCertificate BIO_free(certBio); X509_free(certX509); - NSLog(@"[INFO] \n%@", publicKey); return publicKey; } @@ -276,8 +270,6 @@ - (NSString *)extractPublicKeyFromCertificateSigningRequest:(NSString *)pemCSR X509_REQ_free(csr); BIO_free(csrBio); - NSLog(@"[INFO] \n%@", publicKey); - return publicKey; } @@ -293,7 +285,6 @@ - (BOOL)saveToDiskPEMWithCert:(X509 *)x509 key:(EVP_PKEY *)pkey directory:(NSStr fclose(f); return NO; } - NSLog(@"[INFO] Saved cert to %@", certificatePath); fclose(f); // PublicKey @@ -304,7 +295,6 @@ - (BOOL)saveToDiskPEMWithCert:(X509 *)x509 key:(EVP_PKEY *)pkey directory:(NSStr fclose(f); return NO; } - NSLog(@"[INFO] Saved publicKey to %@", publicKeyPath); fclose(f); // Here you write the private key (pkey) to disk. OpenSSL will encrypt the @@ -319,7 +309,6 @@ - (BOOL)saveToDiskPEMWithCert:(X509 *)x509 key:(EVP_PKEY *)pkey directory:(NSStr fclose(f); return NO; } - NSLog(@"[INFO] Saved privatekey to %@", privatekeyPath); fclose(f); // CSR Request sha256 @@ -331,7 +320,6 @@ - (BOOL)saveToDiskPEMWithCert:(X509 *)x509 key:(EVP_PKEY *)pkey directory:(NSStr fclose(f); return NO; } - NSLog(@"[INFO] Saved csr to %@", csrPath); fclose(f); return YES; @@ -350,7 +338,6 @@ - (BOOL)saveP12WithCert:(X509 *)x509 key:(EVP_PKEY *)pkey directory:(NSString *) fclose(f); return NO; } - NSLog(@"[INFO] Saved p12 to %@", path); fclose(f); return YES; @@ -541,12 +528,6 @@ - (NSData *)decryptPrivateKey:(NSString *)privateKey passphrase:(NSString *)pass return nil; } - NSLog(@"🔐 Decrypting private key:"); - NSLog(@"• Cipher (len=%lu): %@", (unsigned long)cipher.length, cipher); - NSLog(@"• Tag (len=%lu): %@", (unsigned long)authenticationTag.length, authenticationTag); - NSLog(@"• IV (len=%lu): %@", (unsigned long)initializationVector.length, initializationVector); - NSLog(@"• Salt (len=%lu): %@", (unsigned long)salt.length, salt); - // AES-GCM decryption BOOL success = [self decryptData:cipher plain:&plain key:key keyLen:AES_KEY_256_LENGTH initializationVector:initializationVector authenticationTag:authenticationTag]; diff --git a/iOSClient/Networking/E2EE/NCEndToEndKeySetAccess.swift b/iOSClient/Networking/E2EE/NCEndToEndKeySetAccess.swift index a0f7185d40..720c723e7d 100644 --- a/iOSClient/Networking/E2EE/NCEndToEndKeySetAccess.swift +++ b/iOSClient/Networking/E2EE/NCEndToEndKeySetAccess.swift @@ -34,6 +34,18 @@ enum NCEndToEndKeySetAccess: Equatable, Sendable { return false } + /// A non-sensitive value suitable for diagnostics. Never include key material here. + var diagnosticDescription: String { + switch self { + case .active: + return "active" + case .archived: + return "archived" + case .unavailable: + return "unavailable" + } + } + /// A write is allowed only when the active key set decrypts the storage space. var writeAccessError: NKError { switch self { diff --git a/iOSClient/Networking/E2EE/NCEndToEndMetadata.swift b/iOSClient/Networking/E2EE/NCEndToEndMetadata.swift index 73da37ac91..d5d9955674 100644 --- a/iOSClient/Networking/E2EE/NCEndToEndMetadata.swift +++ b/iOSClient/Networking/E2EE/NCEndToEndMetadata.swift @@ -47,8 +47,6 @@ class NCEndToEndMetadata: NSObject { ) } - data.printJson() - let isMetadataV1 = (try? JSONDecoder().decode(E2eeV1.self, from: data)) != nil let isMetadataV12 = (try? JSONDecoder().decode(E2eeV12.self, from: data)) != nil let metadataV2 = try? JSONDecoder().decode(E2eeV2.self, from: data) @@ -68,12 +66,21 @@ class NCEndToEndMetadata: NSObject { do { access = try await resolveKeySetAccess(metadata, serverUrl: serverUrl, session: session) } catch { + nkLog( + tag: NCGlobal.shared.logTagE2EE, + message: "Unable to resolve E2EE key-set access." + ) return ( NKError(errorCode: NCGlobal.shared.errorInternalError, errorDescription: error.localizedDescription), .unavailable ) } + nkLog( + tag: NCGlobal.shared.logTagE2EE, + message: "Resolved E2EE key-set access: \(access.diagnosticDescription)." + ) + guard let keySet = access.keySet else { return ( NKError( diff --git a/iOSClient/Networking/E2EE/NCEndToEndMetadataV1.swift b/iOSClient/Networking/E2EE/NCEndToEndMetadataV1.swift index 3f87c432ea..fda556f48a 100644 --- a/iOSClient/Networking/E2EE/NCEndToEndMetadataV1.swift +++ b/iOSClient/Networking/E2EE/NCEndToEndMetadataV1.swift @@ -161,7 +161,6 @@ extension NCEndToEndMetadata { let e2ee = E2eeV12(metadata: metadata, files: filesCodable, filedrop: filedropCodable) do { let data = try encoder.encode(e2ee) - data.printJson() let jsonString = String(data: data, encoding: .utf8) // Updated metadata to 1.2 if await self.database.getE2eMetadataAsync(account: account, serverUrl: serverUrl) == nil { @@ -238,7 +237,6 @@ extension NCEndToEndMetadata { if let decrypted = NCEndToEndEncryption.shared().decryptPayloadFile(encrypted, key: metadataKey), let decryptedData = Data(base64Encoded: decrypted) { do { - decryptedData.printJson() let encrypted = try decoder.decode(E2eeV12.Encrypted.self, from: decryptedData) if let metadata = self.database.getMetadata(predicate: NSPredicate(format: "account == %@ AND fileName == %@", session.account, fileNameIdentifier)) { @@ -297,7 +295,6 @@ extension NCEndToEndMetadata { if let decrypted = NCEndToEndEncryption.shared().decryptPayloadFile(filedrop.encrypted, key: metadataKeyFiledrop, initializationVector: filedrop.encryptedInitializationVector, authenticationTag: filedrop.encryptedTag), let decryptedData = Data(base64Encoded: decrypted) { do { - decryptedData.printJson() let encrypted = try decoder.decode(E2eeV1.Encrypted.self, from: decryptedData) if let metadata = self.database.getMetadata(predicate: NSPredicate(format: "account == %@ AND fileName == %@", session.account, fileNameIdentifier)) { diff --git a/iOSClient/Networking/E2EE/NCEndToEndMetadataV2.swift b/iOSClient/Networking/E2EE/NCEndToEndMetadataV2.swift index c91608f64c..0e5a9710b6 100644 --- a/iOSClient/Networking/E2EE/NCEndToEndMetadataV2.swift +++ b/iOSClient/Networking/E2EE/NCEndToEndMetadataV2.swift @@ -194,7 +194,6 @@ extension NCEndToEndMetadata { let metadataCodable = E2eeV2.Metadata(ciphertext: ciphertextMetadata, nonce: initializationVector, authenticationTag: authenticationTag) let e2eeCodable = E2eeV2(metadata: metadataCodable, users: usersCodable, filedrop: nil, version: capabilities.e2EEApiVersion) let e2eeData = try JSONEncoder().encode(e2eeCodable) - e2eeData.printJson() let e2eeJson = String(data: e2eeData, encoding: .utf8) let signature = createSignature(metadata: metadataCodable, users: usersCodable, version: capabilities.e2EEApiVersion, certificate: certificate, session: session) @@ -311,9 +310,7 @@ extension NCEndToEndMetadata { errorDescription: NSLocalizedString("_e2ee_filedrop_ciphertext_", comment: "")) } let data = try decryptedFiledrop.gunzipped() - if let jsonText = String(data: data, encoding: .utf8) { print(jsonText) } let file = try JSONDecoder().decode(E2eeV2.Metadata.ciphertext.Files.self, from: data) - print(file) await addE2eEncryption(fileNameIdentifier: fileNameIdentifier, fileName: file.filename, authenticationTag: file.authenticationTag, key: file.key, initializationVector: file.nonce, metadataKey: filedropKey, mimetype: file.mimetype) } } @@ -328,8 +325,6 @@ extension NCEndToEndMetadata { errorDescription: NSLocalizedString("_e2ee_key_ciphertext_", comment: "")) } let data = try decryptedMetadata.gunzipped() - // DEBUG - // if let jsonText = String(data: data, encoding: .utf8) { print(jsonText) } let jsonCiphertextMetadata = try JSONDecoder().decode(E2eeV2.Metadata.ciphertext.self, from: data) // SIGNATURE CHECK @@ -374,9 +369,6 @@ extension NCEndToEndMetadata { } } - print("\n\nCOUNTER ---------------------") - print("Counter: \(jsonCiphertextMetadata.counter)") - // COUNTER +1 // if let resultCounter = await self.database.getCounterE2eMetadataAsync(account: session.account, ocIdServerUrl: ocIdServerUrl) { @@ -406,30 +398,17 @@ extension NCEndToEndMetadata { version: version) if let files = jsonCiphertextMetadata.files { - print("\nFILES -----------------------\n") for file in files { await addE2eEncryption(fileNameIdentifier: file.key, fileName: file.value.filename, authenticationTag: file.value.authenticationTag, key: file.value.key, initializationVector: file.value.nonce, metadataKey: metadataKey, mimetype: file.value.mimetype) - - print("filename: \(file.value.filename)") - print("fileNameIdentifier: \(file.key)") - print("mimetype: \(file.value.mimetype)") - print("\n") } } if let folders = jsonCiphertextMetadata.folders, !folders.isEmpty { - print("FOLDERS----------------------\n") for folder in folders { await addE2eEncryption(fileNameIdentifier: folder.key, fileName: folder.value, authenticationTag: metadata.authenticationTag, key: metadataKey, initializationVector: metadata.nonce, metadataKey: metadataKey, mimetype: "httpd/unix-directory") - - print("filename: \(folder.value)") - print("fileNameIdentifier: \(folder.key)") - print("\n") } } - print("DECODE SUCCESS ------------------------\n\n") - } catch let error { return NKError(errorCode: NCGlobal.shared.errorE2EEJSon, errorDescription: error.localizedDescription) @@ -461,7 +440,7 @@ extension NCEndToEndMetadata { return signatureData.base64EncodedString() } } catch { - print("Error: \(error.localizedDescription)") + nkLog(tag: NCGlobal.shared.logTagE2EE, message: "Unable to create the E2EE metadata signature.") } return nil @@ -497,7 +476,7 @@ extension NCEndToEndMetadata { } } catch { - print("Error: \(error.localizedDescription)") + nkLog(tag: NCGlobal.shared.logTagE2EE, message: "Unable to verify the E2EE metadata signature.") } return false diff --git a/iOSClient/Networking/E2EE/NCNetworkingE2EE.swift b/iOSClient/Networking/E2EE/NCNetworkingE2EE.swift index ae821d542d..08863c5aed 100644 --- a/iOSClient/Networking/E2EE/NCNetworkingE2EE.swift +++ b/iOSClient/Networking/E2EE/NCNetworkingE2EE.swift @@ -92,12 +92,20 @@ class NCNetworkingE2EE: NSObject { /// subsequent E2EE access will perform one new server validation. static func beginNewServerKeyValidationCycle() async { await serverKeyValidationGate.beginNewActiveCycle() + nkLog( + tag: NCGlobal.shared.logTagE2EE, + message: "Started a new server-key validation cycle." + ) } /// Records a key set that was already fetched and cryptographically /// verified by setup, avoiding a redundant request in the same cycle. static func markServerKeyAsValidated(account: String) async { await serverKeyValidationGate.markValidated(account: account) + nkLog( + tag: NCGlobal.shared.logTagE2EE, + message: "Server key marked as validated by E2EE setup." + ) } /// Verifies once per active app cycle that the locally active user key is @@ -110,6 +118,10 @@ class NCNetworkingE2EE: NSObject { !localCertificate.isEmpty, let serverPublicKey = preferences.getEndToEndPublicKey(account: account), !serverPublicKey.isEmpty else { + nkLog( + tag: NCGlobal.shared.logTagE2EE, + message: "Server-key validation skipped because the active key set is incomplete." + ) return NKError( errorCode: NCGlobal.shared.errorE2EENotEnabled, errorDescription: NSLocalizedString("_e2ee_no_metadataKey_found_", comment: "") @@ -117,13 +129,21 @@ class NCNetworkingE2EE: NSObject { } if preferences.isEndToEndServerKeyStale(account: account) { + nkLog( + tag: NCGlobal.shared.logTagE2EE, + message: "Server-key validation blocked because the active key set is stale." + ) return Self.serverKeyChangedError } return await Self.serverKeyValidationGate.validate( account: account ) { - await Self.performServerKeyValidation( + nkLog( + tag: NCGlobal.shared.logTagE2EE, + message: "Validating the active user key against the server." + ) + return await Self.performServerKeyValidation( account: account, localCertificate: localCertificate, serverPublicKey: serverPublicKey @@ -145,6 +165,10 @@ class NCNetworkingE2EE: NSObject { ) if result.error.errorCode == NCGlobal.shared.errorResourceNotFound { + nkLog( + tag: NCGlobal.shared.logTagE2EE, + message: "The server user certificate is missing." + ) return markCurrentServerKeyAsStale( account: account, expectedCertificate: localCertificate, @@ -153,6 +177,10 @@ class NCNetworkingE2EE: NSObject { } guard result.error == .success else { + nkLog( + tag: NCGlobal.shared.logTagE2EE, + message: "Server-key validation request failed with error code \(result.error.errorCode)." + ) return result.error } @@ -160,11 +188,30 @@ class NCNetworkingE2EE: NSObject { let endToEndEncryption = NCEndToEndEncryption.shared(), let localUserPublicKey = endToEndEncryption.extractPublicKey(fromCertificate: localCertificate), let remoteUserPublicKey = endToEndEncryption.extractPublicKey(fromCertificate: remoteCertificate) else { + nkLog( + tag: NCGlobal.shared.logTagE2EE, + message: "Server-key validation received invalid certificate data." + ) return .invalidData } - guard endToEndEncryption.verifyCertificate(remoteCertificate, publicKey: serverPublicKey), - networkingE2EE.publicKeysMatch(localUserPublicKey, remoteUserPublicKey) else { + guard endToEndEncryption.verifyCertificate(remoteCertificate, publicKey: serverPublicKey) else { + nkLog( + tag: NCGlobal.shared.logTagE2EE, + message: "Server-key validation detected an invalid remote certificate." + ) + return markCurrentServerKeyAsStale( + account: account, + expectedCertificate: localCertificate, + preferences: preferences + ) + } + + guard networkingE2EE.publicKeysMatch(localUserPublicKey, remoteUserPublicKey) else { + nkLog( + tag: NCGlobal.shared.logTagE2EE, + message: "Server-key validation detected a changed user key." + ) return markCurrentServerKeyAsStale( account: account, expectedCertificate: localCertificate, @@ -173,6 +220,10 @@ class NCNetworkingE2EE: NSObject { } preferences.setEndToEndServerKeyStale(account: account, stale: false) + nkLog( + tag: NCGlobal.shared.logTagE2EE, + message: "The active user key matches the server." + ) return .success } @@ -199,14 +250,26 @@ class NCNetworkingE2EE: NSObject { // Setup may have replaced the active set while this request was in // flight. An obsolete response must never mark the new set as stale. guard preferences.getEndToEndCertificate(account: account) == expectedCertificate else { + nkLog( + tag: NCGlobal.shared.logTagE2EE, + message: "Ignored an obsolete server-key validation response." + ) return .success } do { try preferences.archiveCurrentEndToEndKeySet(account: account) preferences.setEndToEndServerKeyStale(account: account, stale: true) + nkLog( + tag: NCGlobal.shared.logTagE2EE, + message: "Archived the active key set and marked it as stale." + ) return serverKeyChangedError } catch { + nkLog( + tag: NCGlobal.shared.logTagE2EE, + message: "Unable to archive the active key set after server-key validation." + ) return NKError( errorCode: NCGlobal.shared.errorInternalError, errorDescription: error.localizedDescription @@ -489,7 +552,12 @@ class NCNetworkingE2EE: NSObject { ) } - return access.writeAccessError + let writeAccessError = access.writeAccessError + nkLog( + tag: NCGlobal.shared.logTagE2EE, + message: "E2EE write access \(writeAccessError == .success ? "granted" : "denied") using the \(access.diagnosticDescription) key set." + ) + return writeAccessError } func uploadMetadata(serverUrl: String, diff --git a/iOSClient/PushNotification/NCPushNotificationEncryption.m b/iOSClient/PushNotification/NCPushNotificationEncryption.m index f904648e09..47b9ab8819 100644 --- a/iOSClient/PushNotification/NCPushNotificationEncryption.m +++ b/iOSClient/PushNotification/NCPushNotificationEncryption.m @@ -68,7 +68,6 @@ - (NCPushKeyPair *)generatePushNotificationsKeyPair BIO_read(publicKeyBIO, keyBytes, len); NSData *publicKey = [NSData dataWithBytes:keyBytes length:len]; - NSLog(@"Push Notifications public Key generated: \n%@", [[NSString alloc] initWithData:publicKey encoding:NSUTF8StringEncoding]); // PrivateKey BIO *privateKeyBIO = BIO_new(BIO_s_mem()); @@ -79,7 +78,6 @@ - (NCPushKeyPair *)generatePushNotificationsKeyPair BIO_read(privateKeyBIO, keyBytes, len); NSData *privateKey = [NSData dataWithBytes:keyBytes length:len]; - NSLog(@"Push Notifications private Key generated: \n%@", [[NSString alloc] initWithData:privateKey encoding:NSUTF8StringEncoding]); EVP_PKEY_free(pkey); EVP_PKEY_CTX_free(ctx); diff --git a/iOSClient/Settings/E2EE/NCEndToEndSetup.swift b/iOSClient/Settings/E2EE/NCEndToEndSetup.swift index e4ebf4103d..45ec08deed 100644 --- a/iOSClient/Settings/E2EE/NCEndToEndSetup.swift +++ b/iOSClient/Settings/E2EE/NCEndToEndSetup.swift @@ -59,6 +59,10 @@ class NCEndToEndSetup { let previousKeySet = try preference.archiveCurrentEndToEndKeySet(account: session.account) let wasServerKeyStale = preference.isEndToEndServerKeyStale(account: session.account) preference.clearCurrentKeysEndToEnd(account: session.account) + nkLog( + tag: global.logTagE2EE, + message: "E2EE setup started; previous active key set archived: \(previousKeySet != nil)." + ) do { // get version E2EE @@ -69,8 +73,10 @@ class NCEndToEndSetup { try await getPrivateKey() preference.setEndToEndServerKeyStale(account: session.account, stale: false) await NCNetworkingE2EE.markServerKeyAsValidated(account: session.account) + nkLog(tag: global.logTagE2EE, message: "E2EE setup completed successfully.") } catch { restoreCurrentKeySet(previousKeySet, serverKeyStale: wasServerKeyStale) + nkLog(tag: global.logTagE2EE, message: "E2EE setup failed; restored the previous active key state.") throw error } } @@ -89,6 +95,7 @@ class NCEndToEndSetup { /// the server. All remote values are fetched and cryptographically checked /// before any active Keychain value is replaced. func updateChangedServerKey() async throws { + nkLog(tag: global.logTagE2EE, message: "Changed server-key reconciliation started.") let capabilities = await NKCapabilities.shared.getCapabilities(for: session.account) options = networkingE2EE.getOptions(account: session.account, capabilities: capabilities) @@ -109,6 +116,7 @@ class NCEndToEndSetup { ) if certificateResult.error.errorCode == global.errorResourceNotFound { + nkLog(tag: global.logTagE2EE, message: "No server user certificate found; starting E2EE setup.") try await start() return } @@ -166,6 +174,7 @@ class NCEndToEndSetup { preference.setEndToEndServerKeyStale(account: session.account, stale: false) await NCNetworkingE2EE.markServerKeyAsValidated(account: session.account) NCManageDatabase.shared.clearTablesE2EE(account: session.account) + nkLog(tag: global.logTagE2EE, message: "Changed server-key reconciliation completed successfully.") } /// Ensures that a valid user certificate is available. diff --git a/iOSClient/Settings/E2EE/NCManageE2EEModel.swift b/iOSClient/Settings/E2EE/NCManageE2EEModel.swift index 1b1c329ab8..e303e86233 100644 --- a/iOSClient/Settings/E2EE/NCManageE2EEModel.swift +++ b/iOSClient/Settings/E2EE/NCManageE2EEModel.swift @@ -157,7 +157,6 @@ class NCManageE2EE: NSObject, ObservableObject, ViewOnAppearHandling, TOPasscode } case "readPassphrase": if let e2ePassphrase = NCPreferences().getEndToEndPassphrase(account: session.account) { - print("[INFO]Passphrase: " + e2ePassphrase) let message = "\n" + NSLocalizedString("_e2e_settings_the_passphrase_is_", comment: "") + "\n\n\n" + e2ePassphrase let alertController = UIAlertController(title: NSLocalizedString("_info_", comment: ""), message: message, preferredStyle: .alert) alertController.addAction(UIAlertAction(title: NSLocalizedString("_ok_", comment: ""), style: .default, handler: { _ in }))