diff --git a/Nextcloud.xcodeproj/project.pbxproj b/Nextcloud.xcodeproj/project.pbxproj index fdaf4e233b..d6859ef987 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 */; }; @@ -1218,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 = ""; }; @@ -2197,6 +2215,8 @@ AA52EB442D42AC500089C348 /* NextcloudUnitTests */ = { isa = PBXGroup; children = ( + A1E2EE010000000000000009 /* NCEndToEndKeySetArchiveTests.swift */, + A1E2EE020000000000000007 /* NCEndToEndKeySetResolverTests.swift */, AABBCC0330A8000100F0A001 /* NCMediaPlaybackOptionsTests.swift */, AABBCC0730A8000100F0A001 /* NCVideoPlaybackPresentationContextTests.swift */, AABBCC0930A8000100F0A001 /* NCMediaViewerModelTests.swift */, @@ -2822,6 +2842,7 @@ F768820D2C0DD1E7001CF441 /* E2EE */ = { isa = PBXGroup; children = ( + A1E2EE010000000000000001 /* NCEndToEndKeySet.swift */, F71070AA2F7E49E100AEE58A /* NCEndToEndSetup.swift */, F76882102C0DD1E7001CF441 /* NCManageE2EEModel.swift */, F768820E2C0DD1E7001CF441 /* NCManageE2EEView.swift */, @@ -3278,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 */, @@ -4475,6 +4498,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 +4515,8 @@ isa = PBXSourcesBuildPhase; 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 */, @@ -4547,6 +4573,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 */, @@ -4657,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 */, @@ -4688,6 +4717,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 +4767,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 +4858,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 +4887,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 */, @@ -5035,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 */, @@ -5294,6 +5329,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 */, @@ -6347,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; @@ -6374,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 = ""; @@ -6415,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; @@ -6440,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 = ""; @@ -6685,7 +6721,7 @@ repositoryURL = "https://github.com/nextcloud/NextcloudKit"; requirement = { kind = exactVersion; - version = 7.4.3; + 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 cd727d782e..9d42305a60 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" } }, { @@ -195,8 +195,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/nextcloud/NextcloudKit", "state" : { - "revision" : "d192518544768f3c86354ef94ce6d699019e9487", - "version" : "7.4.3" + "revision" : "2c86f8b3af59b51f60bfcc8620f59f845a5554fb", + "version" : "7.5.0" } }, { 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/Tests/NextcloudUnitTests/NCEndToEndKeySetArchiveTests.swift b/Tests/NextcloudUnitTests/NCEndToEndKeySetArchiveTests.swift new file mode 100644 index 0000000000..15d03c4f60 --- /dev/null +++ b/Tests/NextcloudUnitTests/NCEndToEndKeySetArchiveTests.swift @@ -0,0 +1,110 @@ +// 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 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)" + 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("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)" + 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/Tests/NextcloudUnitTests/NCEndToEndKeySetResolverTests.swift b/Tests/NextcloudUnitTests/NCEndToEndKeySetResolverTests.swift new file mode 100644 index 0000000000..4652052db8 --- /dev/null +++ b/Tests/NextcloudUnitTests/NCEndToEndKeySetResolverTests.swift @@ -0,0 +1,148 @@ +// 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) + #expect(access.writeAccessError == .success) + } + + @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") + 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")) + 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) + #expect(access.writeAccessError.errorCode == NCGlobal.shared.errorE2EEReadOnly) + } + + @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) + #expect(access.writeAccessError.errorCode == NCGlobal.shared.errorE2EENoUserFound) + } + + @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 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( + 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, + 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..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) @@ -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) @@ -306,6 +325,23 @@ 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 + ) + endToEndKeySetAccess = storedAccess + + guard storedAccess.writeAccessError == .success else { + return storedAccess.writeAccessError + } + } catch { + return NKError( + errorCode: global.errorInternalError, + errorDescription: error.localizedDescription + ) + } + 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 +358,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..c5cb7146d0 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? @@ -660,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 @@ -681,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/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/Menu/NCContextMenuMain.swift b/iOSClient/Menu/NCContextMenuMain.swift index d8b673ca85..6bbf644948 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().validateFolderWriteAccess( + 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/Menu/NCContextMenuPlus.swift b/iOSClient/Menu/NCContextMenuPlus.swift index 2bd1a99f7b..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, @@ -644,7 +643,8 @@ 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 } @@ -656,6 +656,10 @@ class NCContextMenuPlus: NSObject { return true } + guard viewController.endToEndKeySetAccess.canWrite else { + return false + } + return NextcloudKit.shared.isNetworkReachable() } 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/NCGlobal.swift b/iOSClient/NCGlobal.swift index f0483b2d98..13e7b65e06 100644 --- a/iOSClient/NCGlobal.swift +++ b/iOSClient/NCGlobal.swift @@ -187,6 +187,8 @@ final class NCGlobal: Sendable { let errorE2EEUploadInProgress: Int = -98018 let errorE2EEKeyDirectoryTop: Int = -98019 let errorE2EESendMetadata: Int = -98020 + let errorE2EEReadOnly: Int = -98021 + let errorE2EEServerKeyChanged: Int = -98022 // Selector 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 new file mode 100644 index 0000000000..720c723e7d --- /dev/null +++ b/iOSClient/Networking/E2EE/NCEndToEndKeySetAccess.swift @@ -0,0 +1,66 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// 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 { + 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 + } + + /// 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 { + 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/NCEndToEndKeySetResolver.swift b/iOSClient/Networking/E2EE/NCEndToEndKeySetResolver.swift new file mode 100644 index 0000000000..b2360c5141 --- /dev/null +++ b/iOSClient/Networking/E2EE/NCEndToEndKeySetResolver.swift @@ -0,0 +1,169 @@ +// 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, + rootEncryptedMetadataKey: String? = nil + ) 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 { canDecryptLegacyMetadataKey($0, with: keySet) } + } + ) + } + + if let metadataV12 = try? JSONDecoder().decode(NCEndToEndMetadata.E2eeV12.self, from: data) { + return try resolve( + currentKeySet: currentKeySet, + account: account, + canDecrypt: { keySet in + canDecryptLegacyMetadataKey(metadataV12.metadata.metadataKey, with: keySet) + } + ) + } + + if let metadataV2 = try? JSONDecoder().decode(NCEndToEndMetadata.E2eeV2.self, from: data) { + guard let encryptedMetadataKey = encryptedMetadataKey( + users: metadataV2.users, + userId: userId, + rootEncryptedMetadataKey: rootEncryptedMetadataKey + ) else { + return .unavailable + } + + return try resolve( + currentKeySet: currentKeySet, + account: account, + canDecrypt: { keySet in + canDecryptMetadataKey(encryptedMetadataKey, with: keySet) + } + ) + } + + return .unavailable + } + + func resolve(encryptedMetadataKey: String, account: String) throws -> NCEndToEndKeySetAccess { + try resolve( + currentKeySet: currentKeySet(account: account), + account: account, + canDecrypt: { keySet in + canDecryptMetadataKey(encryptedMetadataKey, with: keySet) + } + ) + } + + /// 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, !users.isEmpty { + 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( + 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 + ) + } + + func currentKeySet(account: String) -> NCEndToEndKeySet? { + guard preferences.isEndToEndEnabled(account: account), + !preferences.isEndToEndServerKeyStale(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 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), + let decodedData = Data(base64Encoded: decryptedData), + let decodedKey = String(data: decodedData, encoding: .utf8) else { + return false + } + + return !decodedKey.isEmpty + } + + 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 { + return false + } + + return !decryptedData.isEmpty + } +} diff --git a/iOSClient/Networking/E2EE/NCEndToEndMetadata.swift b/iOSClient/Networking/E2EE/NCEndToEndMetadata.swift index c8414d9c5f..d5d9955674 100644 --- a/iOSClient/Networking/E2EE/NCEndToEndMetadata.swift +++ b/iOSClient/Networking/E2EE/NCEndToEndMetadata.swift @@ -34,32 +34,155 @@ 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 metadataV2 = try? JSONDecoder().decode(E2eeV2.self, from: data) + let isMetadataV2 = metadataV2 != 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 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( + 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) + } + + func resolveKeySetAccess( + _ metadata: String, + serverUrl: String, + session: NCSession.Session + ) async throws -> NCEndToEndKeySetAccess { + var childRootEncryptedMetadataKey: String? + if let data = metadata.data(using: .utf8), + let metadataV2 = try? JSONDecoder().decode(E2eeV2.self, from: data), + metadataV2.users?.isEmpty != false, + let directoryTop = await utilityFileSystem.getMetadataE2EETopAsync( + serverUrl: serverUrl, + session: session + ), + serverUrl != directoryTop.serverUrlFileName { + childRootEncryptedMetadataKey = try await storedRootEncryptedMetadataKey( + serverUrl: serverUrl, + session: session + ) + } + + return try NCEndToEndKeySetResolver().resolve( + metadata: metadata, + account: session.account, + userId: session.userId, + rootEncryptedMetadataKey: childRootEncryptedMetadataKey + ) + } + + 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/NCEndToEndMetadataV1.swift b/iOSClient/Networking/E2EE/NCEndToEndMetadataV1.swift index d84a5c760d..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 { @@ -177,7 +176,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 +190,7 @@ extension NCEndToEndMetadata { } let decoder = JSONDecoder() - let privateKey = NCPreferences().getEndToEndPrivateKey(account: session.account) + let privateKey = keySet.privateKey var metadataVersion: Double = 0 var metadataKey = "" @@ -232,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)) { @@ -291,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)) { @@ -333,7 +336,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 +354,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 +368,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..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) @@ -214,7 +213,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 +274,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 +302,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 { @@ -310,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) } } @@ -327,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 @@ -373,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) { @@ -405,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) @@ -460,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 @@ -476,7 +456,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] @@ -496,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 644fc78d28..08863c5aed 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 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] = [:] + + 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,6 +88,202 @@ class NCNetworkingE2EE: NSObject { return NKRequestOptions(version: version) } + /// 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() + 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 + /// 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 localCertificate = preferences.getEndToEndCertificate(account: account), + !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: "") + ) + } + + 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 + ) { + nkLog( + tag: NCGlobal.shared.logTagE2EE, + message: "Validating the active user key against the server." + ) + return await Self.performServerKeyValidation( + account: account, + localCertificate: localCertificate, + serverPublicKey: serverPublicKey + ) + } + } + + private static func performServerKeyValidation( + account: 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.getE2EECertificateAsync( + account: account, + options: networkingE2EE.getOptions(account: account, capabilities: capabilities) + ) + + 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, + preferences: preferences + ) + } + + 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 + } + + guard let remoteCertificate = result.certificate, !remoteCertificate.isEmpty, + 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) 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, + preferences: preferences + ) + } + + preferences.setEndToEndServerKeyStale(account: account, stale: false) + nkLog( + tag: NCGlobal.shared.logTagE2EE, + message: "The active user key matches the server." + ) + 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: .newlines) + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty && !$0.hasPrefix("-----") } + .joined() + } + + let firstPayload = payload(first) + let secondPayload = payload(second) + return !firstPayload.isEmpty && firstPayload == secondPayload + } + + private static func markCurrentServerKeyAsStale( + account: 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.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 + ) + } + } + + 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, @@ -125,12 +350,55 @@ 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 { + let serverKeyError = await validateCurrentServerKey(account: account) + guard serverKeyError == .success else { + return serverKeyError + } + + return await uploadMetadata( + serverUrl: serverUrl, + addUserId: addUserId, + removeUserId: removeUserId, + updateVersionV1V2: updateVersionV1V2, + mode: .updateOrRecover, + account: account + ) + } + + @discardableResult + func createInitialMetadata(serverUrl: String, account: String) async -> NKError { + let serverKeyError = await validateCurrentServerKey(account: account) + guard serverKeyError == .success else { + return serverKeyError + } + + return 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) @@ -163,17 +431,49 @@ 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 validateMetadataWriteAccess( + e2eMetadata: e2eMetadata, + serverUrl: serverUrl, + session: session + ) + guard accessError == .success else { + await unlock(account: session.account, serverUrl: serverUrl) + return accessError + } method = "PUT" - } 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 + } 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) + return resultsGetE2EEMetadata.error == .success + ? .invalidData + : resultsGetE2EEMetadata.error } // UPLOAD METADATA @@ -200,6 +500,66 @@ class NCNetworkingE2EE: NSObject { return NKError() } + func validateFolderWriteAccess(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, + 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 validateMetadataWriteAccess( + e2eMetadata: e2eMetadata, + serverUrl: serverUrl, + session: session + ) + } else { + error = metadataResult.error == .success + ? .invalidData + : metadataResult.error + } + + await unlock(account: account, serverUrl: serverUrl) + return error + } + + private func validateMetadataWriteAccess( + 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 + ) + } + + 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, ocIdServerUrl: String, fileId: String, @@ -244,18 +604,35 @@ 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 } - 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 } - return NKError() + return decodeResult.access.writeAccessError } // MARK: - diff --git a/iOSClient/Networking/E2EE/NCNetworkingE2EECreateFolder.swift b/iOSClient/Networking/E2EE/NCNetworkingE2EECreateFolder.swift index 6ab7d4ec14..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) @@ -140,7 +145,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..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: "")) @@ -33,6 +39,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 +89,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..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 @@ -71,7 +76,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 } 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/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/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..45ec08deed 100644 --- a/iOSClient/Settings/E2EE/NCEndToEndSetup.swift +++ b/iOSClient/Settings/E2EE/NCEndToEndSetup.swift @@ -47,21 +47,134 @@ class NCEndToEndSetup { /// Starts the E2EE initialization pipeline. /// /// Flow: - /// 1. Clear all keys e2ee in preferences + /// 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 { - // Clear all keys - preference.clearAllKeysEndToEnd(account: session.account) - // get version E2EE + // Preserve the previous key space before replacing the active keys. + // A Keychain failure stops setup before any key material is removed. + 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 + 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) + 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 + } + } + + /// 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 { + 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) - try await getPublicKey() - try await getPrivateKey() + let serverPublicKeyResult = await NextcloudKit.shared.getE2EEPublicKeyAsync( + account: session.account, + options: options + ) + + 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 { + nkLog(tag: global.logTagE2EE, message: "No server user certificate found; starting E2EE setup.") + try await start() + return + } + + 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: serverPublicKey) 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: serverPublicKey) + 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) + nkLog(tag: global.logTagE2EE, message: "Changed server-key reconciliation completed successfully.") } /// Ensures that a valid user certificate is available. @@ -158,7 +271,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, @@ -166,25 +280,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: @@ -206,10 +322,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 @@ -234,6 +351,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( @@ -244,42 +372,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 { @@ -293,14 +411,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, @@ -309,6 +427,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 @@ -318,11 +458,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 ) @@ -419,7 +562,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) @@ -444,7 +587,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 } @@ -476,6 +619,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/E2EE/NCManageE2EEModel.swift b/iOSClient/Settings/E2EE/NCManageE2EEModel.swift index f200deb493..e303e86233 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 @@ -65,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_") @@ -153,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 })) diff --git a/iOSClient/Settings/E2EE/NCManageE2EEView.swift b/iOSClient/Settings/E2EE/NCManageE2EEView.swift index 1245027864..0d45c0facc 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,26 @@ 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.isEmpty ? nil : password + + password = "" + passwordCompletion = nil + + Task { + await completion(submittedPassword) + } + } + } } @ViewBuilder @@ -212,9 +236,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 +268,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, @@ -261,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") @@ -276,9 +300,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 +325,12 @@ struct NCManageE2EEView: View { return Date() >= warningDate ? .orange : .primary } + + private func requestPassword(action: @escaping @MainActor (String?) async -> Void) { + password = "" + passwordCompletion = action + showPasswordPrompt = true + } } #Preview { diff --git a/iOSClient/Settings/NCPreferences.swift b/iOSClient/Settings/NCPreferences.swift index c3b4d5d66e..caab8d27b8 100644 --- a/iOSClient/Settings/NCPreferences.swift +++ b/iOSClient/Settings/NCPreferences.swift @@ -493,13 +493,114 @@ final class NCPreferences: NSObject { return true } - func clearAllKeysEndToEnd(account: String) { + /// 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 + /// 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) + setEndToEndServerKeyStale(account: account, stale: false) + + 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? { 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 } } diff --git a/iOSClient/Supporting Files/en.lproj/Localizable.strings b/iOSClient/Supporting Files/en.lproj/Localizable.strings index 7ee6281a71..de72392c05 100644 --- a/iOSClient/Supporting Files/en.lproj/Localizable.strings +++ b/iOSClient/Supporting Files/en.lproj/Localizable.strings @@ -824,6 +824,9 @@ 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_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";