From 54df79c718cbb14b6a12e4a25f942381c4b383e5 Mon Sep 17 00:00:00 2001
From: Isla <5048549+islathehut@users.noreply.github.com>
Date: Wed, 18 Feb 2026 13:53:50 -0500
Subject: [PATCH 01/92] add NSE target and add keychain capability to NSE and
Quiet
---
.../ios/NotificationAppExtension/Info.plist | 13 ++
.../NotificationAppExtension.entitlements | 10 +
.../NotificationService.swift | 35 ++++
.../ios/Quiet.xcodeproj/project.pbxproj | 187 +++++++++++++++++-
packages/mobile/ios/Quiet/Quiet.entitlements | 4 +
.../mobile/ios/Quiet/QuietDebug.entitlements | 4 +
6 files changed, 252 insertions(+), 1 deletion(-)
create mode 100644 packages/mobile/ios/NotificationAppExtension/Info.plist
create mode 100644 packages/mobile/ios/NotificationAppExtension/NotificationAppExtension.entitlements
create mode 100644 packages/mobile/ios/NotificationAppExtension/NotificationService.swift
diff --git a/packages/mobile/ios/NotificationAppExtension/Info.plist b/packages/mobile/ios/NotificationAppExtension/Info.plist
new file mode 100644
index 0000000000..57421ebf9b
--- /dev/null
+++ b/packages/mobile/ios/NotificationAppExtension/Info.plist
@@ -0,0 +1,13 @@
+
+
+
+
+ NSExtension
+
+ NSExtensionPointIdentifier
+ com.apple.usernotifications.service
+ NSExtensionPrincipalClass
+ $(PRODUCT_MODULE_NAME).NotificationService
+
+
+
diff --git a/packages/mobile/ios/NotificationAppExtension/NotificationAppExtension.entitlements b/packages/mobile/ios/NotificationAppExtension/NotificationAppExtension.entitlements
new file mode 100644
index 0000000000..7ac57ebda7
--- /dev/null
+++ b/packages/mobile/ios/NotificationAppExtension/NotificationAppExtension.entitlements
@@ -0,0 +1,10 @@
+
+
+
+
+ keychain-access-groups
+
+ $(AppIdentifierPrefix)com.quietmobile
+
+
+
diff --git a/packages/mobile/ios/NotificationAppExtension/NotificationService.swift b/packages/mobile/ios/NotificationAppExtension/NotificationService.swift
new file mode 100644
index 0000000000..0f337992d4
--- /dev/null
+++ b/packages/mobile/ios/NotificationAppExtension/NotificationService.swift
@@ -0,0 +1,35 @@
+//
+// NotificationService.swift
+// NotificationAppExtension
+//
+// Created by Isla Koenigsknecht on 2/18/26.
+//
+
+import UserNotifications
+
+class NotificationService: UNNotificationServiceExtension {
+
+ var contentHandler: ((UNNotificationContent) -> Void)?
+ var bestAttemptContent: UNMutableNotificationContent?
+
+ override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
+ self.contentHandler = contentHandler
+ bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent)
+
+ if let bestAttemptContent = bestAttemptContent {
+ // Modify the notification content here...
+ bestAttemptContent.title = "\(bestAttemptContent.title) [modified]"
+
+ contentHandler(bestAttemptContent)
+ }
+ }
+
+ override func serviceExtensionTimeWillExpire() {
+ // Called just before the extension will be terminated by the system.
+ // Use this as an opportunity to deliver your "best attempt" at modified content, otherwise the original push payload will be used.
+ if let contentHandler = contentHandler, let bestAttemptContent = bestAttemptContent {
+ contentHandler(bestAttemptContent)
+ }
+ }
+
+}
diff --git a/packages/mobile/ios/Quiet.xcodeproj/project.pbxproj b/packages/mobile/ios/Quiet.xcodeproj/project.pbxproj
index 79efc9d819..9f813eb6a8 100644
--- a/packages/mobile/ios/Quiet.xcodeproj/project.pbxproj
+++ b/packages/mobile/ios/Quiet.xcodeproj/project.pbxproj
@@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
- objectVersion = 54;
+ objectVersion = 70;
objects = {
/* Begin PBXBuildFile section */
@@ -56,6 +56,7 @@
18FD2A3F296F009E00A2B8C0 /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 18FD2A38296F009E00A2B8C0 /* Images.xcassets */; };
18FD2A40296F009E00A2B8C0 /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 18FD2A39296F009E00A2B8C0 /* main.m */; };
38AD376324628C6E27E70991 /* libPods-Quiet-QuietTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 02AFA323BA7196A67E7A1133 /* libPods-Quiet-QuietTests.a */; };
+ 665E5BC42F46402C005D2086 /* NotificationAppExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 665E5BBD2F46402C005D2086 /* NotificationAppExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
955DC7582BD930B30014725B /* WebsocketSingleton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 955DC7572BD930B30014725B /* WebsocketSingleton.swift */; };
D3239FB5EFA85E780E1AD201 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 84F12DFE2A5B0E05C2C41286 /* PrivacyInfo.xcprivacy */; };
E479975299F0932FF690F805 /* libPods-Quiet.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 104C0D0FA8EF00192C60CAD7 /* libPods-Quiet.a */; };
@@ -69,6 +70,13 @@
remoteGlobalIDString = 13B07F861A680F5B00A75B9A;
remoteInfo = QuietMobile;
};
+ 665E5BC22F46402C005D2086 /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 83CBB9F71A601CBA00E9B192 /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = 665E5BBC2F46402C005D2086;
+ remoteInfo = NotificationAppExtension;
+ };
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
@@ -84,6 +92,17 @@
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
};
+ 665E5BC52F46402D005D2086 /* Embed Foundation Extensions */ = {
+ isa = PBXCopyFilesBuildPhase;
+ buildActionMask = 2147483647;
+ dstPath = "";
+ dstSubfolderSpec = 13;
+ files = (
+ 665E5BC42F46402C005D2086 /* NotificationAppExtension.appex in Embed Foundation Extensions */,
+ );
+ name = "Embed Foundation Extensions";
+ runOnlyForDeploymentPostprocessing = 0;
+ };
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
@@ -621,12 +640,27 @@
18FD2A3A296F009E00A2B8C0 /* Quiet.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; name = Quiet.entitlements; path = Quiet/Quiet.entitlements; sourceTree = ""; };
18FD2A3B296F009E00A2B8C0 /* QuietDebug.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; name = QuietDebug.entitlements; path = Quiet/QuietDebug.entitlements; sourceTree = ""; };
2877B6088D1D81EF869D71D9 /* Pods-Quiet-QuietTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Quiet-QuietTests.release.xcconfig"; path = "Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests.release.xcconfig"; sourceTree = ""; };
+ 665E5BBD2F46402C005D2086 /* NotificationAppExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NotificationAppExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
7F75CE6D1C7D3DA9F5BA6A76 /* Pods-Quiet-QuietTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Quiet-QuietTests.debug.xcconfig"; path = "Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests.debug.xcconfig"; sourceTree = ""; };
84F12DFE2A5B0E05C2C41286 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = Quiet/PrivacyInfo.xcprivacy; sourceTree = ""; };
955DC7572BD930B30014725B /* WebsocketSingleton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebsocketSingleton.swift; sourceTree = ""; };
E727FE0446E9E46CDECEC7FE /* Pods-Quiet.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Quiet.debug.xcconfig"; path = "Target Support Files/Pods-Quiet/Pods-Quiet.debug.xcconfig"; sourceTree = ""; };
/* End PBXFileReference section */
+/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
+ 665E5BC82F46402D005D2086 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
+ isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
+ membershipExceptions = (
+ Info.plist,
+ );
+ target = 665E5BBC2F46402C005D2086 /* NotificationAppExtension */;
+ };
+/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
+
+/* Begin PBXFileSystemSynchronizedRootGroup section */
+ 665E5BBE2F46402C005D2086 /* NotificationAppExtension */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (665E5BC82F46402D005D2086 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = NotificationAppExtension; sourceTree = ""; };
+/* End PBXFileSystemSynchronizedRootGroup section */
+
/* Begin PBXFrameworksBuildPhase section */
00E356EB1AD99517003FC87E /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
@@ -647,6 +681,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ 665E5BBA2F46402C005D2086 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
@@ -4755,6 +4796,7 @@
13B07FAE1A68108700A75B9A /* Quiet */,
832341AE1AAA6A7D00B99B32 /* Libraries */,
00E356EF1AD99517003FC87E /* QuietTests */,
+ 665E5BBE2F46402C005D2086 /* NotificationAppExtension */,
83CBBA001A601CBA00E9B192 /* Products */,
2D16E6871FA4F8E400B85C8A /* Frameworks */,
624523FCC5994B7E9869E9CF /* Resources */,
@@ -4770,6 +4812,7 @@
children = (
13B07F961A680F5B00A75B9A /* Quiet.app */,
00E356EE1AD99517003FC87E /* QuietTests.xctest */,
+ 665E5BBD2F46402C005D2086 /* NotificationAppExtension.appex */,
);
name = Products;
sourceTree = "";
@@ -4816,16 +4859,40 @@
1868C095292F8FE2001D6D5E /* Embed Frameworks */,
7253A52F091FC8122D730B2E /* [CP] Embed Pods Frameworks */,
A1AE2657AE5C40D18941D671 /* [CP] Copy Pods Resources */,
+ 665E5BC52F46402D005D2086 /* Embed Foundation Extensions */,
);
buildRules = (
);
dependencies = (
+ 665E5BC32F46402C005D2086 /* PBXTargetDependency */,
);
name = Quiet;
productName = QuietMobile;
productReference = 13B07F961A680F5B00A75B9A /* Quiet.app */;
productType = "com.apple.product-type.application";
};
+ 665E5BBC2F46402C005D2086 /* NotificationAppExtension */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 665E5BC92F46402D005D2086 /* Build configuration list for PBXNativeTarget "NotificationAppExtension" */;
+ buildPhases = (
+ 665E5BB92F46402C005D2086 /* Sources */,
+ 665E5BBA2F46402C005D2086 /* Frameworks */,
+ 665E5BBB2F46402C005D2086 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ fileSystemSynchronizedGroups = (
+ 665E5BBE2F46402C005D2086 /* NotificationAppExtension */,
+ );
+ name = NotificationAppExtension;
+ packageProductDependencies = (
+ );
+ productName = NotificationAppExtension;
+ productReference = 665E5BBD2F46402C005D2086 /* NotificationAppExtension.appex */;
+ productType = "com.apple.product-type.app-extension";
+ };
/* End PBXNativeTarget section */
/* Begin PBXProject section */
@@ -4833,6 +4900,7 @@
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
+ LastSwiftUpdateCheck = 1620;
LastUpgradeCheck = 2610;
TargetAttributes = {
00E356ED1AD99517003FC87E = {
@@ -4842,6 +4910,9 @@
13B07F861A680F5B00A75B9A = {
LastSwiftMigration = 1250;
};
+ 665E5BBC2F46402C005D2086 = {
+ CreatedOnToolsVersion = 16.2;
+ };
};
};
buildConfigurationList = 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "Quiet" */;
@@ -4859,6 +4930,7 @@
targets = (
13B07F861A680F5B00A75B9A /* Quiet */,
00E356ED1AD99517003FC87E /* QuietTests */,
+ 665E5BBC2F46402C005D2086 /* NotificationAppExtension */,
);
};
/* End PBXProject section */
@@ -4909,6 +4981,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ 665E5BBB2F46402C005D2086 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
@@ -5182,6 +5261,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ 665E5BB92F46402C005D2086 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
@@ -5190,6 +5276,11 @@
target = 13B07F861A680F5B00A75B9A /* Quiet */;
targetProxy = 00E356F41AD99517003FC87E /* PBXContainerItemProxy */;
};
+ 665E5BC32F46402C005D2086 /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = 665E5BBC2F46402C005D2086 /* NotificationAppExtension */;
+ targetProxy = 665E5BC22F46402C005D2086 /* PBXContainerItemProxy */;
+ };
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
@@ -5453,6 +5544,91 @@
};
name = Release;
};
+ 665E5BC62F46402D005D2086 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CODE_SIGN_ENTITLEMENTS = NotificationAppExtension/NotificationAppExtension.entitlements;
+ CODE_SIGN_IDENTITY = "Apple Development";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEBUG_INFORMATION_FORMAT = dwarf;
+ DEVELOPMENT_TEAM = CTYKSWN9T4;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu17;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = NotificationAppExtension/Info.plist;
+ INFOPLIST_KEY_CFBundleDisplayName = NotificationAppExtension;
+ INFOPLIST_KEY_NSHumanReadableCopyright = "";
+ IPHONEOS_DEPLOYMENT_TARGET = 18.2;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ "@executable_path/../../Frameworks",
+ );
+ LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
+ MARKETING_VERSION = 1.0;
+ MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
+ MTL_FAST_MATH = YES;
+ PRODUCT_BUNDLE_IDENTIFIER = com.quietmobile.NotificationAppExtension;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SKIP_INSTALL = YES;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Debug;
+ };
+ 665E5BC72F46402D005D2086 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CODE_SIGN_ENTITLEMENTS = NotificationAppExtension/NotificationAppExtension.entitlements;
+ CODE_SIGN_IDENTITY = "Apple Development";
+ CODE_SIGN_STYLE = Automatic;
+ COPY_PHASE_STRIP = NO;
+ CURRENT_PROJECT_VERSION = 1;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ DEVELOPMENT_TEAM = CTYKSWN9T4;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu17;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = NotificationAppExtension/Info.plist;
+ INFOPLIST_KEY_CFBundleDisplayName = NotificationAppExtension;
+ INFOPLIST_KEY_NSHumanReadableCopyright = "";
+ IPHONEOS_DEPLOYMENT_TARGET = 18.2;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ "@executable_path/../../Frameworks",
+ );
+ LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
+ MARKETING_VERSION = 1.0;
+ MTL_FAST_MATH = YES;
+ PRODUCT_BUNDLE_IDENTIFIER = com.quietmobile.NotificationAppExtension;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ PROVISIONING_PROFILE_SPECIFIER = "";
+ SKIP_INSTALL = YES;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Release;
+ };
83CBBA201A601CBA00E9B192 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
@@ -5622,6 +5798,15 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
+ 665E5BC92F46402D005D2086 /* Build configuration list for PBXNativeTarget "NotificationAppExtension" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 665E5BC62F46402D005D2086 /* Debug */,
+ 665E5BC72F46402D005D2086 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "Quiet" */ = {
isa = XCConfigurationList;
buildConfigurations = (
diff --git a/packages/mobile/ios/Quiet/Quiet.entitlements b/packages/mobile/ios/Quiet/Quiet.entitlements
index 903def2af5..599333ab05 100644
--- a/packages/mobile/ios/Quiet/Quiet.entitlements
+++ b/packages/mobile/ios/Quiet/Quiet.entitlements
@@ -4,5 +4,9 @@
aps-environment
development
+ keychain-access-groups
+
+ $(AppIdentifierPrefix)com.quietmobile
+
diff --git a/packages/mobile/ios/Quiet/QuietDebug.entitlements b/packages/mobile/ios/Quiet/QuietDebug.entitlements
index 903def2af5..599333ab05 100644
--- a/packages/mobile/ios/Quiet/QuietDebug.entitlements
+++ b/packages/mobile/ios/Quiet/QuietDebug.entitlements
@@ -4,5 +4,9 @@
aps-environment
development
+ keychain-access-groups
+
+ $(AppIdentifierPrefix)com.quietmobile
+
From 97f1731b7688f83b88be3cba9bc2c946f72ed366 Mon Sep 17 00:00:00 2001
From: Isla <5048549+islathehut@users.noreply.github.com>
Date: Wed, 25 Feb 2026 11:37:56 -0500
Subject: [PATCH 02/92] Pass keys from backend to state manager and write to
keychain
---
.../auth/services/crypto/crypto.service.ts | 19 +-
.../backend/src/nest/auth/sigchain.service.ts | 67 ++++++-
packages/mobile/ios/CommunicationModule.swift | 15 ++
packages/mobile/ios/KeychainHandler.swift | 188 ++++++++++++++++++
.../ios/Quiet.xcodeproj/project.pbxproj | 4 +
.../mobile/src/store/keys/keys.master.saga.ts | 20 ++
.../mobile/src/store/keys/keys.selectors..ts | 15 ++
packages/mobile/src/store/keys/keys.slice.ts | 28 +++
.../mobile/src/store/keys/keys.transform.ts | 14 ++
packages/mobile/src/store/keys/keys.type.ts | 11 +
.../saveKeysInKeychain.saga.ts | 64 ++++++
packages/mobile/src/store/root.reducer.ts | 2 +
packages/mobile/src/store/root.saga.ts | 2 +
packages/mobile/src/store/store.keys.ts | 1 +
packages/types/src/index.ts | 1 +
packages/types/src/keys.ts | 12 ++
packages/types/src/socket.ts | 3 +
17 files changed, 457 insertions(+), 9 deletions(-)
create mode 100644 packages/mobile/ios/KeychainHandler.swift
create mode 100644 packages/mobile/src/store/keys/keys.master.saga.ts
create mode 100644 packages/mobile/src/store/keys/keys.selectors..ts
create mode 100644 packages/mobile/src/store/keys/keys.slice.ts
create mode 100644 packages/mobile/src/store/keys/keys.transform.ts
create mode 100644 packages/mobile/src/store/keys/keys.type.ts
create mode 100644 packages/mobile/src/store/keys/saveKeysInKeychain/saveKeysInKeychain.saga.ts
create mode 100644 packages/types/src/keys.ts
diff --git a/packages/backend/src/nest/auth/services/crypto/crypto.service.ts b/packages/backend/src/nest/auth/services/crypto/crypto.service.ts
index 63af9863da..495aeb61ef 100644
--- a/packages/backend/src/nest/auth/services/crypto/crypto.service.ts
+++ b/packages/backend/src/nest/auth/services/crypto/crypto.service.ts
@@ -14,9 +14,10 @@ import {
import { ChainServiceBase } from '../chainServiceBase'
import { SigChain } from '../../sigchain'
import { asymmetric, Keyset, Member, SignedEnvelope, EncryptStreamTeamPayload } from '@localfirst/auth'
+import { KeyMap } from '@localfirst/auth/team/selectors'
import { DEFAULT_SEARCH_OPTIONS, MemberSearchOptions } from '../members/types'
import { createLogger } from '../../../common/logger'
-import { KeyMetadata } from '3rd-party/auth/packages/crdx/dist'
+import { KeyMetadata } from '@localfirst/crdx'
const logger = createLogger('auth:cryptoService')
@@ -36,6 +37,22 @@ class CryptoService extends ChainServiceBase {
})
}
+ public getPublicKeysForAllMembers(includeSelf: boolean = false): Keyset[] {
+ const members = this.sigChain.users.getAllUsers()
+ const keysByMember = []
+ for (const member of members) {
+ if (member.userId === this.sigChain.context.user.userId && !includeSelf) {
+ continue
+ }
+ keysByMember.push(member.keys)
+ }
+ return keysByMember
+ }
+
+ public getAllKeys(): KeyMap {
+ return this.sigChain.team!.allKeys()
+ }
+
public sign(message: any): SignedEnvelope {
return this.sigChain.team!.sign(message)
}
diff --git a/packages/backend/src/nest/auth/sigchain.service.ts b/packages/backend/src/nest/auth/sigchain.service.ts
index dd417ad981..d197aa98c6 100644
--- a/packages/backend/src/nest/auth/sigchain.service.ts
+++ b/packages/backend/src/nest/auth/sigchain.service.ts
@@ -1,6 +1,17 @@
-import { Inject, Injectable, OnModuleInit } from '@nestjs/common'
+import { Inject, Injectable } from '@nestjs/common'
import { SigChain } from './sigchain'
-import { Connection, InviteeMemberContext, Keyring, LocalUserContext, MemberContext, Team } from '@localfirst/auth'
+import {
+ Connection,
+ Hash,
+ InviteeMemberContext,
+ Keyring,
+ LocalUserContext,
+ MemberContext,
+ Team,
+ UserWithSecrets,
+ DeviceWithSecrets,
+} from '@localfirst/auth'
+import { KeyMetadata } from '@localfirst/crdx'
import { LocalDbService } from '../local-db/local-db.service'
import { createLogger } from '../common/logger'
import { SocketService } from '../socket/socket.service'
@@ -10,12 +21,11 @@ import { type DeviceService } from './services/members/device.service'
import { type InviteService } from './services/invites/invite.service'
import { type UserService } from './services/members/user.service'
import { type CryptoService } from './services/crypto/crypto.service'
-import { type UserWithSecrets } from '@localfirst/auth'
-import { type DeviceWithSecrets } from '@localfirst/auth'
import { SERVER_IO_PROVIDER } from '../const'
import { ServerIoProviderTypes } from '../types'
import EventEmitter from 'events'
import { GetChainFilter } from './types'
+import { KeysUpdatedEvent, KeyWithMetadata } from 'packages/types/src/keys'
@Injectable()
export class SigChainService extends EventEmitter {
@@ -23,6 +33,7 @@ export class SigChainService extends EventEmitter {
private readonly logger = createLogger(SigChainService.name)
private chains: Map = new Map()
public connections: Map = new Map()
+ private lastUpdatedLink: Hash
constructor(
@Inject(SERVER_IO_PROVIDER) public readonly serverIoProvider: ServerIoProviderTypes,
@@ -133,6 +144,14 @@ export class SigChainService extends EventEmitter {
}
private handleChainUpdate = () => {
+ this._updateUsersOnChainUpdate()
+ this._updateKeysOnChainUpdate()
+ this.emit('updated')
+ this.saveChain(this.activeChainTeamName!)
+ this.logger.info('Chain updated, emitted updated event')
+ }
+
+ private _updateUsersOnChainUpdate() {
const users = this.getActiveChain()
.team?.members()
.map(user => ({
@@ -141,10 +160,42 @@ export class SigChainService extends EventEmitter {
isRegistered: true,
isDuplicated: false,
})) as User[]
- this.socketService.emit(SocketEvents.USERS_UPDATED, { users })
- this.emit('updated')
- this.saveChain(this.activeChainTeamName!)
- this.logger.info('Chain updated, emitted updated event')
+ this.serverIoProvider.io.emit(SocketEvents.USERS_UPDATED, { users })
+ }
+
+ // TODO: only fetch keys that have been updated recently
+ private _updateKeysOnChainUpdate() {
+ const secretKeys: KeyWithMetadata[] = []
+ const sigKeys: KeyWithMetadata[] = []
+ const userPublicKeys: KeyWithMetadata[] = []
+ const allKeys = this.getActiveChain().crypto.getAllKeys()
+ for (const keyData of Object.values(allKeys)) {
+ for (const keyTypeData of Object.values(keyData)) {
+ for (const keyTypeGenData of Object.values(keyTypeData)) {
+ secretKeys.push({
+ scope: { name: keyTypeGenData.name, type: keyTypeGenData.type, generation: keyTypeGenData.generation },
+ key: keyTypeGenData.secretKey,
+ })
+ }
+ }
+ }
+ // TODO: update to pull all generations of user public/sig keys
+ const allUserPublicKeys = this.getActiveChain().crypto.getPublicKeysForAllMembers(false)
+ for (const keySet of allUserPublicKeys) {
+ userPublicKeys.push({
+ scope: { name: keySet.name, type: keySet.type, generation: keySet.generation },
+ key: keySet.encryption,
+ })
+ sigKeys.push({
+ scope: { name: keySet.name, type: keySet.type, generation: keySet.generation },
+ key: keySet.signature,
+ })
+ }
+ this.serverIoProvider.io.emit(SocketEvents.KEYS_UPDATED, {
+ secretKeys,
+ sigKeys,
+ userPublicKeys,
+ } as KeysUpdatedEvent)
}
private attachSocketListeners(chain: SigChain): void {
diff --git a/packages/mobile/ios/CommunicationModule.swift b/packages/mobile/ios/CommunicationModule.swift
index f6961cdbfe..b579d2e964 100644
--- a/packages/mobile/ios/CommunicationModule.swift
+++ b/packages/mobile/ios/CommunicationModule.swift
@@ -12,6 +12,9 @@ class CommunicationModule: RCTEventEmitter {
static let DEVICE_TOKEN_RECEIVED = "deviceTokenReceived"
static let WEBSOCKET_CONNECTION_CHANNEL = "_WEBSOCKET_CONNECTION_"
+ private static let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "CommunicationModule")
+
+ let keychainHandler = KeychainHandler()
@objc
func sendDataPort(port: UInt16, socketIOSecret: String) {
@@ -56,6 +59,18 @@ class CommunicationModule: RCTEventEmitter {
}
}
+ @objc
+ func saveKeysInKeychain(newKeys: KeyWithScope[]) {
+ CommunicationModule.logger.debug("Saving \(newKeys.count) keys in keychain")
+ for key in newKeys {
+ do {
+ self.keychainHandler.addLfaKey(scope: key.scope, key: key.key)
+ } catch {
+ CommunicationModule.logger.error("Error while saving key in keychain", error)
+ }
+ }
+ }
+
@objc
func checkNotificationPermission() {
UNUserNotificationCenter.current().getNotificationSettings { settings in
diff --git a/packages/mobile/ios/KeychainHandler.swift b/packages/mobile/ios/KeychainHandler.swift
new file mode 100644
index 0000000000..49a928e70c
--- /dev/null
+++ b/packages/mobile/ios/KeychainHandler.swift
@@ -0,0 +1,188 @@
+//import CryptoKit
+//import Security
+//import CoreData
+
+public enum KeychainError: Error {
+ case noPassword
+ case unexpectedPasswordData
+ case unexpectedItemData
+ case unhandledError(status: OSStatus)
+}
+
+public enum ConversionError: Error {
+ case stringToBytesError
+}
+
+public enum KeychainHandlerError: Error {
+ case noKeyFound
+ case malformedKey
+ case unhandledError(reason: Any)
+}
+
+public struct KeyScope {
+ var name: String
+ var generation: Int
+ var type: String
+ var keyType: String
+}
+
+public enum KeyAddStatus {
+ case success
+ case duplicateScope
+}
+
+public struct KeyWithScope {
+ var scope: KeyScope
+ var key: String
+}
+
+@objc(KeychainHandler)
+class KeychainHandler: NSObject {
+ private let masterKeyName: String = "quiet_master_key"
+ private let keychainGroupName: String = "com.quietmobile"
+
+ public func getMasterKey() throws -> SymmetricKey {
+ do {
+ let password: String = try _getKeyImpl(keyName: masterKeyName)
+ let passwordBytes: ContiguousBytes = try _stringToBytes(str: password)
+ return SymmetricKey(data: passwordBytes)
+ } catch KeychainError.noPassword {
+ throw KeychainHandlerError.noKeyFound
+ } catch KeychainError.unexpectedPasswordData {
+ throw KeychainHandlerError.malformedKey
+ } catch ConversionError.stringToBytesError {
+ throw KeychainHandlerError.malformedKey
+ } catch {
+ throw KeychainHandlerError.unhandledError(reason: error)
+ }
+ }
+
+ public func getLfaKeyString(scope: KeyScope) throws -> String {
+ do {
+ let keyName: String = _keyScopeToKeyName(scope: scope)
+ let password: String = try _getKeyImpl(keyName: keyName)
+ return password
+ } catch KeychainError.noPassword {
+ throw KeychainHandlerError.noKeyFound
+ } catch KeychainError.unexpectedPasswordData {
+ throw KeychainHandlerError.malformedKey
+ } catch ConversionError.stringToBytesError {
+ throw KeychainHandlerError.malformedKey
+ } catch {
+ throw KeychainHandlerError.unhandledError(reason: error)
+ }
+ }
+
+ public func createMasterKey() throws -> SymmetricKey {
+ var existingKey: SymmetricKey?
+ do {
+ existingKey = try getMasterKey()
+ } catch KeychainHandlerError.noKeyFound {
+ existingKey = nil
+ } catch {
+ throw error
+ }
+
+ guard existingKey == nil else { return existingKey! }
+ do {
+ let newKey: SymmetricKey = _generateAESKey()
+ let keyData: Data = _symmetricKeyToData(key: newKey)
+ let addStatus: KeyAddStatus = try _addKeyToKeychainImpl(keyName: masterKeyName, keyData: keyData)
+ guard addStatus == KeyAddStatus.success else { throw KeychainHandlerError.unhandledError(reason: addStatus) }
+ return newKey
+ } catch {
+ throw KeychainHandlerError.unhandledError(reason: error)
+ }
+ }
+
+ public func addLfaKey(scope: KeyScope, key: String) throws -> KeyAddStatus {
+ var existingKey: String?
+ do {
+ existingKey = try getLfaKeyString(scope: scope)
+ } catch KeychainHandlerError.noKeyFound {
+ existingKey = nil
+ } catch KeychainHandlerError.malformedKey {
+ existingKey = nil
+ } catch {
+ throw error
+ }
+
+ guard existingKey == nil else {
+ guard existingKey == key else { return KeyAddStatus.duplicateScope }
+ return KeyAddStatus.success
+ }
+
+ do {
+ let keyName: String = _keyScopeToKeyName(scope: scope)
+ let keyData: Data = try _stringToBytes(str: key)
+ let addStatus: KeyAddStatus = try _addKeyToKeychainImpl(keyName: keyName, keyData: keyData)
+ return addStatus
+ } catch {
+ throw KeychainHandlerError.unhandledError(reason: error)
+ }
+ }
+
+ private func _getKeyImpl(keyName: String) throws -> String {
+ var existingKey: CFTypeRef?
+ let query: [String: Any] = [
+ kSecClass as String: kSecSharedPassword,
+ kSecAttrService as String: keychainGroupName,
+ kSecAttrAccount as String: keyName,
+ kSecMatchLimit as String: kSecMatchLimitOne,
+ kSecReturnAttributes as String: true,
+ kSecReturnData as String: true
+ ]
+ let status: OSStatus = SecItemCopyMatching(query as CFDictionary, &existingKey)
+ guard status != errSecItemNotFound else { throw KeychainError.noPassword }
+ guard status == errSecSuccess else { throw KeychainError.unhandledError(status: status) }
+ guard let existingItem: [String : Any] = existingKey as? [String : Any],
+ let passwordData = existingItem[kSecValueData as String] as? Data,
+ let password = String(data: passwordData, encoding: String.Encoding.utf8),
+ let account = existingItem[kSecAttrAccount as String] as? String
+ else {
+ throw KeychainError.unexpectedPasswordData
+ }
+ return password
+ }
+
+ private func _addKeyToKeychainImpl(keyName: String, keyData: Data) throws -> KeyAddStatus {
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrAccount as String: keyName,
+ kSecAttrService as String: keychainGroupName,
+ kSecValueData as String: keyData,
+ kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock
+ ]
+
+ let status: OSStatus = SecItemAdd(query as CFDictionary, nil)
+ if status == errSecSuccess {
+ return KeyAddStatus.success
+ } else if status == errSecDuplicateItem {
+ return KeyAddStatus.duplicateScope
+ } else {
+ throw KeychainError.unhandledError(status: status)
+ }
+ }
+
+ private func _generateAESKey() -> SymmetricKey {
+ let key: SymmetricKey = SymmetricKey(size: .bits256)
+ return key
+ }
+
+ private func _stringToBytes(str: String) throws -> Data {
+ let bytes: Data? = str.data(using: .utf8)
+ guard bytes != nil else { throw ConversionError.stringToBytesError }
+ return bytes!
+ }
+
+ private func _symmetricKeyToData(key: SymmetricKey) -> Data {
+ let keyData: Data = key.withUnsafeBytes { body in
+ Data(body)
+ }
+ return keyData
+ }
+
+ private func _keyScopeToKeyName(scope: KeyScope) -> String {
+ return "quiet_\(scope.type)_\(scope.name)_\(scope.generation)_\(scope.keyType)"
+ }
+}
diff --git a/packages/mobile/ios/Quiet.xcodeproj/project.pbxproj b/packages/mobile/ios/Quiet.xcodeproj/project.pbxproj
index 9f813eb6a8..3503c544f8 100644
--- a/packages/mobile/ios/Quiet.xcodeproj/project.pbxproj
+++ b/packages/mobile/ios/Quiet.xcodeproj/project.pbxproj
@@ -57,6 +57,7 @@
18FD2A40296F009E00A2B8C0 /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 18FD2A39296F009E00A2B8C0 /* main.m */; };
38AD376324628C6E27E70991 /* libPods-Quiet-QuietTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 02AFA323BA7196A67E7A1133 /* libPods-Quiet-QuietTests.a */; };
665E5BC42F46402C005D2086 /* NotificationAppExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 665E5BBD2F46402C005D2086 /* NotificationAppExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
+ 6681DD3A2F4F53BF005D2086 /* KeychainHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6681DD392F4F53BF005D2086 /* KeychainHandler.swift */; };
955DC7582BD930B30014725B /* WebsocketSingleton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 955DC7572BD930B30014725B /* WebsocketSingleton.swift */; };
D3239FB5EFA85E780E1AD201 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 84F12DFE2A5B0E05C2C41286 /* PrivacyInfo.xcprivacy */; };
E479975299F0932FF690F805 /* libPods-Quiet.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 104C0D0FA8EF00192C60CAD7 /* libPods-Quiet.a */; };
@@ -641,6 +642,7 @@
18FD2A3B296F009E00A2B8C0 /* QuietDebug.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; name = QuietDebug.entitlements; path = Quiet/QuietDebug.entitlements; sourceTree = ""; };
2877B6088D1D81EF869D71D9 /* Pods-Quiet-QuietTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Quiet-QuietTests.release.xcconfig"; path = "Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests.release.xcconfig"; sourceTree = ""; };
665E5BBD2F46402C005D2086 /* NotificationAppExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NotificationAppExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
+ 6681DD392F4F53BF005D2086 /* KeychainHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainHandler.swift; sourceTree = ""; };
7F75CE6D1C7D3DA9F5BA6A76 /* Pods-Quiet-QuietTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Quiet-QuietTests.debug.xcconfig"; path = "Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests.debug.xcconfig"; sourceTree = ""; };
84F12DFE2A5B0E05C2C41286 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = Quiet/PrivacyInfo.xcprivacy; sourceTree = ""; };
955DC7572BD930B30014725B /* WebsocketSingleton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebsocketSingleton.swift; sourceTree = ""; };
@@ -711,6 +713,7 @@
13B07FAE1A68108700A75B9A /* Quiet */ = {
isa = PBXGroup;
children = (
+ 6681DD392F4F53BF005D2086 /* KeychainHandler.swift */,
180E120A2AEFB7F900804659 /* Utils.swift */,
18FD2A36296F009E00A2B8C0 /* AppDelegate.h */,
18FD2A37296F009E00A2B8C0 /* AppDelegate.m */,
@@ -5253,6 +5256,7 @@
1868BCEE292E9212001D6D5E /* RNNodeJsMobile.m in Sources */,
1868C4382930D7D6001D6D5E /* DataDirectory.swift in Sources */,
1868C43E2930EAEA001D6D5E /* CommunicationBridge.m in Sources */,
+ 6681DD3A2F4F53BF005D2086 /* KeychainHandler.swift in Sources */,
1868C43A2930D859001D6D5E /* FindFreePort.swift in Sources */,
18FD2A3E296F009E00A2B8C0 /* AppDelegate.m in Sources */,
1868BCED292E9212001D6D5E /* NodeRunner.mm in Sources */,
diff --git a/packages/mobile/src/store/keys/keys.master.saga.ts b/packages/mobile/src/store/keys/keys.master.saga.ts
new file mode 100644
index 0000000000..a35dff4a2a
--- /dev/null
+++ b/packages/mobile/src/store/keys/keys.master.saga.ts
@@ -0,0 +1,20 @@
+import { takeEvery, cancelled } from 'redux-saga/effects'
+import { all } from 'typed-redux-saga'
+import { type Socket } from '@quiet/state-manager/src/types'
+import { keysActions } from './keys.slice'
+import { saveKeysInKeychainSaga } from './saveKeysInKeychain/saveKeysInKeychain.saga'
+import { createLogger } from '../../utils/logger'
+
+const logger = createLogger('keysMasterSaga')
+
+export function* keysMasterSaga(): Generator {
+ logger.info('keysMasterSaga starting')
+ try {
+ yield all([takeEvery(keysActions.saveKeysInKeychain.type, saveKeysInKeychainSaga)])
+ } finally {
+ logger.info('keysMasterSaga stopping')
+ if (yield cancelled()) {
+ logger.info('keysMasterSaga cancelled')
+ }
+ }
+}
diff --git a/packages/mobile/src/store/keys/keys.selectors..ts b/packages/mobile/src/store/keys/keys.selectors..ts
new file mode 100644
index 0000000000..e6016ba4c7
--- /dev/null
+++ b/packages/mobile/src/store/keys/keys.selectors..ts
@@ -0,0 +1,15 @@
+import { createSelector } from 'reselect'
+import { StoreKeys } from '../store.keys'
+import { CreatedSelectors, StoreState } from '../store.types'
+
+const keysSlice: CreatedSelectors[StoreKeys.Keys] = (state: StoreState) => state[StoreKeys.Keys]
+
+export const allKeys = createSelector(keysSlice, state => ({
+ secretKeys: state.secretKeys,
+ userPublicKeys: state.userPublicKeys,
+ sigKeys: state.sigKeys,
+}))
+
+export const keysSelectors = {
+ allKeys,
+}
diff --git a/packages/mobile/src/store/keys/keys.slice.ts b/packages/mobile/src/store/keys/keys.slice.ts
new file mode 100644
index 0000000000..c69ef0ec9b
--- /dev/null
+++ b/packages/mobile/src/store/keys/keys.slice.ts
@@ -0,0 +1,28 @@
+import { createSlice, type PayloadAction } from '@reduxjs/toolkit'
+import { StoreKeys } from '../store.keys'
+import { KeysUpdatedEvent, KeyWithMetadata } from '@quiet/types'
+import { createLogger } from '../../utils/logger'
+
+const logger = createLogger('keysSlice')
+
+export class KeysState {
+ public secretKeys: KeyWithMetadata[] = []
+ public userPublicKeys: KeyWithMetadata[] = []
+ public sigKeys: KeyWithMetadata[] = []
+}
+
+export const keysSlice = createSlice({
+ initialState: { ...new KeysState() },
+ name: StoreKeys.Keys,
+ reducers: {
+ setKeys: (state, action: PayloadAction) => {
+ state.secretKeys = action.payload.secretKeys
+ state.sigKeys = action.payload.sigKeys
+ state.userPublicKeys = action.payload.userPublicKeys
+ },
+ saveKeysInKeychain: (state, _action: PayloadAction) => state,
+ },
+})
+
+export const keysActions = keysSlice.actions
+export const keysReducer = keysSlice.reducer
diff --git a/packages/mobile/src/store/keys/keys.transform.ts b/packages/mobile/src/store/keys/keys.transform.ts
new file mode 100644
index 0000000000..797b644871
--- /dev/null
+++ b/packages/mobile/src/store/keys/keys.transform.ts
@@ -0,0 +1,14 @@
+import { createTransform } from 'redux-persist'
+import { StoreKeys } from '../store.keys'
+import { KeysState } from './keys.slice'
+
+export const KeysTransform = createTransform(
+ (inboundState: KeysState, _key: any) => {
+ return inboundState
+ },
+ (outboundState: KeysState, _key: any) => {
+ // TODO: determine if we still need this transform
+ return outboundState
+ },
+ { whitelist: [StoreKeys.Keys] }
+)
diff --git a/packages/mobile/src/store/keys/keys.type.ts b/packages/mobile/src/store/keys/keys.type.ts
new file mode 100644
index 0000000000..b0e994fcfa
--- /dev/null
+++ b/packages/mobile/src/store/keys/keys.type.ts
@@ -0,0 +1,11 @@
+export type ExtendedKeyScope = {
+ type: string
+ name: string
+ generation: number
+ keyType: string
+}
+
+export interface StorableKey {
+ scope: ExtendedKeyScope
+ key: string
+}
diff --git a/packages/mobile/src/store/keys/saveKeysInKeychain/saveKeysInKeychain.saga.ts b/packages/mobile/src/store/keys/saveKeysInKeychain/saveKeysInKeychain.saga.ts
new file mode 100644
index 0000000000..b669b966aa
--- /dev/null
+++ b/packages/mobile/src/store/keys/saveKeysInKeychain/saveKeysInKeychain.saga.ts
@@ -0,0 +1,64 @@
+import { type PayloadAction } from '@reduxjs/toolkit'
+import { call, select, put } from 'typed-redux-saga'
+import { KeysUpdatedEvent } from '@quiet/types'
+import { createLogger } from '../../../utils/logger'
+import { keysActions } from '../keys.slice'
+import { keysSelectors } from '../keys.selectors.'
+
+import _ from 'lodash'
+import { NativeModules } from 'react-native'
+import { StorableKey } from '../keys.type'
+
+const logger = createLogger('saveKeysInKeychainSaga')
+
+export function* saveKeysInKeychainSaga(action: PayloadAction): Generator {
+ logger.debug('Storing keys in ios keychain')
+ const existingKeys = yield* select(keysSelectors.allKeys)
+ const newSecretKeys = _.differenceBy(action.payload.secretKeys, existingKeys.secretKeys, 'key')
+ const newUserPublicKeys = _.differenceBy(action.payload.userPublicKeys, existingKeys.userPublicKeys, 'key')
+ const newSigKeys = _.differenceBy(action.payload.sigKeys, existingKeys.sigKeys, 'key')
+ logger.debug('Updating keys state')
+ yield* put(keysActions.setKeys(action.payload))
+
+ const newKeysPayload: KeysUpdatedEvent = {
+ secretKeys: newSecretKeys,
+ userPublicKeys: newUserPublicKeys,
+ sigKeys: newSigKeys,
+ }
+ const keysToSave: StorableKey[] = newSecretKeys.map(
+ keyWithMetadata =>
+ ({
+ scope: {
+ ...keyWithMetadata.scope,
+ keyType: 'secret',
+ },
+ key: keyWithMetadata.key,
+ } as StorableKey)
+ )
+ keysToSave.push(
+ ...newUserPublicKeys.map(
+ keyWithMetadata =>
+ ({
+ scope: {
+ ...keyWithMetadata.scope,
+ keyType: 'userPublic',
+ },
+ key: keyWithMetadata.key,
+ } as StorableKey)
+ )
+ )
+ keysToSave.push(
+ ...newSigKeys.map(
+ keyWithMetadata =>
+ ({
+ scope: {
+ ...keyWithMetadata.scope,
+ keyType: 'userSig',
+ },
+ key: keyWithMetadata.key,
+ } as StorableKey)
+ )
+ )
+ logger.debug('Putting new keys in keychain', keysToSave)
+ yield* call(NativeModules.CommunicationModule.saveKeysInKeychain, newKeysPayload)
+}
diff --git a/packages/mobile/src/store/root.reducer.ts b/packages/mobile/src/store/root.reducer.ts
index f5570fc579..0ffbbbb304 100644
--- a/packages/mobile/src/store/root.reducer.ts
+++ b/packages/mobile/src/store/root.reducer.ts
@@ -5,6 +5,7 @@ import { initReducer } from './init/init.slice'
import { navigationReducer } from './navigation/navigation.slice'
import { nativeServicesReducer, nativeServicesActions } from './nativeServices/nativeServices.slice'
import { pushNotificationsReducer } from './pushNotifications/pushNotifications.slice'
+import { keysReducer } from './keys/keys.slice'
export const reducers = {
...stateManagerReducers.reducers,
@@ -12,6 +13,7 @@ export const reducers = {
[StoreKeys.Navigation]: navigationReducer,
[StoreKeys.NativeServices]: nativeServicesReducer,
[StoreKeys.PushNotifications]: pushNotificationsReducer,
+ [StoreKeys.Keys]: keysReducer,
}
export const allReducers = combineReducers(reducers)
diff --git a/packages/mobile/src/store/root.saga.ts b/packages/mobile/src/store/root.saga.ts
index c17191aa3d..98a149f6a6 100644
--- a/packages/mobile/src/store/root.saga.ts
+++ b/packages/mobile/src/store/root.saga.ts
@@ -9,6 +9,7 @@ import { clearReduxStore } from './nativeServices/leaveCommunity/leaveCommunity.
import { pushNotificationsMasterSaga } from './pushNotifications/pushNotifications.master.saga'
import { setEngine, CryptoEngine } from 'pkijs'
import { createLogger } from '../utils/logger'
+import { keysMasterSaga } from './keys/keys.master.saga'
const logger = createLogger('root')
@@ -54,6 +55,7 @@ function* storeReadySaga(): Generator {
fork(navigationMasterSaga),
fork(nativeServicesMasterSaga),
fork(pushNotificationsMasterSaga),
+ fork(keysMasterSaga),
// Below line is reponsible for displaying notifications about messages from channels other than currently viewing one
takeEvery(publicChannels.actions.markUnreadChannel.type, showNotificationSaga),
takeLeading(initActions.canceledRootTask.type, clearReduxStore),
diff --git a/packages/mobile/src/store/store.keys.ts b/packages/mobile/src/store/store.keys.ts
index fdb67eed97..9389713392 100644
--- a/packages/mobile/src/store/store.keys.ts
+++ b/packages/mobile/src/store/store.keys.ts
@@ -3,4 +3,5 @@ export enum StoreKeys {
Navigation = 'Navigation',
NativeServices = 'NativeServices',
PushNotifications = 'PushNotifications',
+ Keys = 'Keys',
}
diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts
index 2d92a64467..a795c26e15 100644
--- a/packages/types/src/index.ts
+++ b/packages/types/src/index.ts
@@ -13,3 +13,4 @@ export * from './network'
export * from './test'
export * from './captcha'
export * from './serializer'
+export * from './keys'
diff --git a/packages/types/src/keys.ts b/packages/types/src/keys.ts
new file mode 100644
index 0000000000..95e24fe0e2
--- /dev/null
+++ b/packages/types/src/keys.ts
@@ -0,0 +1,12 @@
+import { Base58, KeyMetadata } from '@localfirst/crdx'
+
+export interface KeyWithMetadata {
+ scope: KeyMetadata
+ key: string | Base58
+}
+
+export interface KeysUpdatedEvent {
+ secretKeys: KeyWithMetadata[]
+ userPublicKeys: KeyWithMetadata[]
+ sigKeys: KeyWithMetadata[]
+}
diff --git a/packages/types/src/socket.ts b/packages/types/src/socket.ts
index d6ac8999f0..e1a7e0ec6a 100644
--- a/packages/types/src/socket.ts
+++ b/packages/types/src/socket.ts
@@ -39,6 +39,7 @@ import {
} from './community'
import { ErrorPayload } from './errors'
import { HCaptchaChallengeRequest, HCaptchaFormResponse, HCaptchaRequest } from './captcha'
+import { KeysUpdatedEvent } from './keys'
// -----------------------------------------------------------------------------
// SocketActions: These are the actions the frontend emits to the backend
@@ -128,6 +129,7 @@ export enum SocketEvents {
USERS_UPDATED = 'usersUpdated',
USERS_REMOVED = 'usersRemoved',
USER_PROFILES_STORED = 'userProfilesStored',
+ KEYS_UPDATED = 'keysUpdated',
// ====== Files ======
FILE_ATTACHED = 'fileUploaded',
@@ -229,6 +231,7 @@ export interface SocketEventsMap {
[SocketEvents.USERS_UPDATED]: EmitEvent
[SocketEvents.USERS_REMOVED]: EmitEvent
[SocketEvents.USER_PROFILES_STORED]: EmitEvent
+ [SocketEvents.KEYS_UPDATED]: EmitEvent
// ====== Files ======
[SocketEvents.FILE_ATTACHED]: EmitEvent
From 7700b4f2077b08ca2f7bfbdfe4a80a4906542bb4 Mon Sep 17 00:00:00 2001
From: Isla <5048549+islathehut@users.noreply.github.com>
Date: Wed, 4 Mar 2026 11:02:23 -0500
Subject: [PATCH 03/92] Properly store keys in ios keychain
---
.../backend/src/nest/auth/sigchain.service.ts | 20 +-
packages/mobile/ios/CommunicationBridge.m | 1 +
packages/mobile/ios/CommunicationModule.swift | 18 +-
packages/mobile/ios/KeychainHandler.swift | 60 ++--
.../ios/NotificationAppExtension/Info.plist | 13 -
.../NotificationAppExtension.entitlements | 10 -
.../NotificationService.swift | 35 ---
.../ios/Quiet.xcodeproj/project.pbxproj | 287 ++++--------------
.../startConnection/startConnection.saga.ts | 11 +-
packages/mobile/src/store/keys/keys.type.ts | 1 +
.../saveKeysInKeychain.saga.ts | 75 +++--
packages/types/src/keys.ts | 1 +
12 files changed, 162 insertions(+), 370 deletions(-)
delete mode 100644 packages/mobile/ios/NotificationAppExtension/Info.plist
delete mode 100644 packages/mobile/ios/NotificationAppExtension/NotificationAppExtension.entitlements
delete mode 100644 packages/mobile/ios/NotificationAppExtension/NotificationService.swift
diff --git a/packages/backend/src/nest/auth/sigchain.service.ts b/packages/backend/src/nest/auth/sigchain.service.ts
index d197aa98c6..1b8392425d 100644
--- a/packages/backend/src/nest/auth/sigchain.service.ts
+++ b/packages/backend/src/nest/auth/sigchain.service.ts
@@ -11,7 +11,7 @@ import {
UserWithSecrets,
DeviceWithSecrets,
} from '@localfirst/auth'
-import { KeyMetadata } from '@localfirst/crdx'
+import { KeyMetadata, KeyType } from '@localfirst/crdx'
import { LocalDbService } from '../local-db/local-db.service'
import { createLogger } from '../common/logger'
import { SocketService } from '../socket/socket.service'
@@ -25,7 +25,9 @@ import { SERVER_IO_PROVIDER } from '../const'
import { ServerIoProviderTypes } from '../types'
import EventEmitter from 'events'
import { GetChainFilter } from './types'
-import { KeysUpdatedEvent, KeyWithMetadata } from 'packages/types/src/keys'
+import { KeysUpdatedEvent, KeyWithMetadata } from '@quiet/types'
+import { EncryptionScopeType } from './services/crypto/types'
+import * as os from 'os'
@Injectable()
export class SigChainService extends EventEmitter {
@@ -165,6 +167,11 @@ export class SigChainService extends EventEmitter {
// TODO: only fetch keys that have been updated recently
private _updateKeysOnChainUpdate() {
+ if ((process.platform as string) !== 'ios') {
+ this.logger.trace('Skipping key update because we are not on ios, current platform =', process.platform)
+ return
+ }
+
const secretKeys: KeyWithMetadata[] = []
const sigKeys: KeyWithMetadata[] = []
const userPublicKeys: KeyWithMetadata[] = []
@@ -180,7 +187,7 @@ export class SigChainService extends EventEmitter {
}
}
// TODO: update to pull all generations of user public/sig keys
- const allUserPublicKeys = this.getActiveChain().crypto.getPublicKeysForAllMembers(false)
+ const allUserPublicKeys = this.getActiveChain().crypto.getPublicKeysForAllMembers(true)
for (const keySet of allUserPublicKeys) {
userPublicKeys.push({
scope: { name: keySet.name, type: keySet.type, generation: keySet.generation },
@@ -191,11 +198,13 @@ export class SigChainService extends EventEmitter {
key: keySet.signature,
})
}
- this.serverIoProvider.io.emit(SocketEvents.KEYS_UPDATED, {
+ const keyUpdateEvent: KeysUpdatedEvent = {
secretKeys,
sigKeys,
userPublicKeys,
- } as KeysUpdatedEvent)
+ teamId: this.activeChain.team!.id,
+ }
+ this.serverIoProvider.io.emit(SocketEvents.KEYS_UPDATED, keyUpdateEvent)
}
private attachSocketListeners(chain: SigChain): void {
@@ -259,6 +268,7 @@ export class SigChainService extends EventEmitter {
const sigChain = SigChain.create(teamName, username)
this.addChain(sigChain, setActive, teamName)
await this.saveChain(teamName)
+ this.handleChainUpdate()
return sigChain
}
diff --git a/packages/mobile/ios/CommunicationBridge.m b/packages/mobile/ios/CommunicationBridge.m
index 254cfa1975..5f0e6155ef 100644
--- a/packages/mobile/ios/CommunicationBridge.m
+++ b/packages/mobile/ios/CommunicationBridge.m
@@ -5,4 +5,5 @@ @interface RCT_EXTERN_MODULE(CommunicationModule, RCTEventEmitter)
RCT_EXTERN_METHOD(handleIncomingEvents:(NSString *)event payload:(NSString *)payload extra:(NSString *)extra)
RCT_EXTERN_METHOD(requestNotificationPermission)
RCT_EXTERN_METHOD(checkNotificationPermission)
+RCT_EXTERN_METHOD(saveKeysInKeychain:(NSArray *)newKeys)
@end
diff --git a/packages/mobile/ios/CommunicationModule.swift b/packages/mobile/ios/CommunicationModule.swift
index b579d2e964..71cb912914 100644
--- a/packages/mobile/ios/CommunicationModule.swift
+++ b/packages/mobile/ios/CommunicationModule.swift
@@ -1,4 +1,5 @@
import UserNotifications
+import OSLog
@objc(CommunicationModule)
class CommunicationModule: RCTEventEmitter {
@@ -58,15 +59,20 @@ class CommunicationModule: RCTEventEmitter {
}
}
}
-
+
@objc
- func saveKeysInKeychain(newKeys: KeyWithScope[]) {
- CommunicationModule.logger.debug("Saving \(newKeys.count) keys in keychain")
- for key in newKeys {
+ func saveKeysInKeychain(_ newKeys: NSArray) {
+ let decoder = JSONDecoder()
+ for keyAsAny in newKeys {
do {
- self.keychainHandler.addLfaKey(scope: key.scope, key: key.key)
+ let keyAsString: String = keyAsAny as! String
+ let data = Data(keyAsString.utf8)
+ let decodedKeyWithScope = try decoder.decode(KeyWithScope.self, from: data)
+ try self.keychainHandler.addLfaKey(keyWithScope: decodedKeyWithScope)
+ let stored = try self.keychainHandler.getLfaKeyString(teamId: decodedKeyWithScope.teamId, scope: decodedKeyWithScope.scope)
+ CommunicationModule.logger.info("Stored key matches? \(stored == decodedKeyWithScope.key) \(String(describing: decodedKeyWithScope.scope))")
} catch {
- CommunicationModule.logger.error("Error while saving key in keychain", error)
+ CommunicationModule.logger.error("Error while saving key in keychain: \(error)")
}
}
}
diff --git a/packages/mobile/ios/KeychainHandler.swift b/packages/mobile/ios/KeychainHandler.swift
index 49a928e70c..cad13112bf 100644
--- a/packages/mobile/ios/KeychainHandler.swift
+++ b/packages/mobile/ios/KeychainHandler.swift
@@ -1,6 +1,15 @@
-//import CryptoKit
-//import Security
-//import CoreData
+//
+// KeychainError.swift
+// Quiet
+//
+// Created by Isla Koenigsknecht on 2/25/26.
+//
+
+
+import CryptoKit
+import Security
+import CoreData
+import OSLog
public enum KeychainError: Error {
case noPassword
@@ -19,11 +28,11 @@ public enum KeychainHandlerError: Error {
case unhandledError(reason: Any)
}
-public struct KeyScope {
- var name: String
- var generation: Int
- var type: String
- var keyType: String
+public struct KeyScope: Codable {
+ let name: String
+ let generation: Int
+ let type: String
+ let keyType: String
}
public enum KeyAddStatus {
@@ -31,15 +40,18 @@ public enum KeyAddStatus {
case duplicateScope
}
-public struct KeyWithScope {
- var scope: KeyScope
- var key: String
+public struct KeyWithScope: Codable {
+ let scope: KeyScope
+ let key: String
+ let teamId: String
}
@objc(KeychainHandler)
class KeychainHandler: NSObject {
private let masterKeyName: String = "quiet_master_key"
private let keychainGroupName: String = "com.quietmobile"
+
+ private static let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "KeychainHandler")
public func getMasterKey() throws -> SymmetricKey {
do {
@@ -57,9 +69,9 @@ class KeychainHandler: NSObject {
}
}
- public func getLfaKeyString(scope: KeyScope) throws -> String {
+ public func getLfaKeyString(teamId: String, scope: KeyScope) throws -> String {
do {
- let keyName: String = _keyScopeToKeyName(scope: scope)
+ let keyName: String = _keyScopeToKeyName(teamId: teamId, scope: scope)
let password: String = try _getKeyImpl(keyName: keyName)
return password
} catch KeychainError.noPassword {
@@ -95,26 +107,27 @@ class KeychainHandler: NSObject {
}
}
- public func addLfaKey(scope: KeyScope, key: String) throws -> KeyAddStatus {
+ public func addLfaKey(keyWithScope: KeyWithScope) throws -> KeyAddStatus {
var existingKey: String?
do {
- existingKey = try getLfaKeyString(scope: scope)
+ existingKey = try getLfaKeyString(teamId: keyWithScope.teamId, scope: keyWithScope.scope)
} catch KeychainHandlerError.noKeyFound {
existingKey = nil
} catch KeychainHandlerError.malformedKey {
existingKey = nil
} catch {
+ KeychainHandler.logger.error("Error while getting existing LFA key for scope \(String(describing: keyWithScope.scope)): \(error)")
throw error
}
guard existingKey == nil else {
- guard existingKey == key else { return KeyAddStatus.duplicateScope }
- return KeyAddStatus.success
+ guard existingKey == keyWithScope.key else { return KeyAddStatus.duplicateScope }
+ return KeyAddStatus.success
}
do {
- let keyName: String = _keyScopeToKeyName(scope: scope)
- let keyData: Data = try _stringToBytes(str: key)
+ let keyName: String = _keyScopeToKeyName(teamId: keyWithScope.teamId, scope: keyWithScope.scope)
+ let keyData: Data = try _stringToBytes(str: keyWithScope.key)
let addStatus: KeyAddStatus = try _addKeyToKeychainImpl(keyName: keyName, keyData: keyData)
return addStatus
} catch {
@@ -125,7 +138,7 @@ class KeychainHandler: NSObject {
private func _getKeyImpl(keyName: String) throws -> String {
var existingKey: CFTypeRef?
let query: [String: Any] = [
- kSecClass as String: kSecSharedPassword,
+ kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainGroupName,
kSecAttrAccount as String: keyName,
kSecMatchLimit as String: kSecMatchLimitOne,
@@ -137,8 +150,7 @@ class KeychainHandler: NSObject {
guard status == errSecSuccess else { throw KeychainError.unhandledError(status: status) }
guard let existingItem: [String : Any] = existingKey as? [String : Any],
let passwordData = existingItem[kSecValueData as String] as? Data,
- let password = String(data: passwordData, encoding: String.Encoding.utf8),
- let account = existingItem[kSecAttrAccount as String] as? String
+ let password = String(data: passwordData, encoding: String.Encoding.utf8)
else {
throw KeychainError.unexpectedPasswordData
}
@@ -182,7 +194,7 @@ class KeychainHandler: NSObject {
return keyData
}
- private func _keyScopeToKeyName(scope: KeyScope) -> String {
- return "quiet_\(scope.type)_\(scope.name)_\(scope.generation)_\(scope.keyType)"
+ private func _keyScopeToKeyName(teamId: String, scope: KeyScope) -> String {
+ return "quiet_\(teamId)_\(scope.type)_\(scope.name)_\(scope.generation)_\(scope.keyType)"
}
}
diff --git a/packages/mobile/ios/NotificationAppExtension/Info.plist b/packages/mobile/ios/NotificationAppExtension/Info.plist
deleted file mode 100644
index 57421ebf9b..0000000000
--- a/packages/mobile/ios/NotificationAppExtension/Info.plist
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
-
- NSExtension
-
- NSExtensionPointIdentifier
- com.apple.usernotifications.service
- NSExtensionPrincipalClass
- $(PRODUCT_MODULE_NAME).NotificationService
-
-
-
diff --git a/packages/mobile/ios/NotificationAppExtension/NotificationAppExtension.entitlements b/packages/mobile/ios/NotificationAppExtension/NotificationAppExtension.entitlements
deleted file mode 100644
index 7ac57ebda7..0000000000
--- a/packages/mobile/ios/NotificationAppExtension/NotificationAppExtension.entitlements
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-
-
- keychain-access-groups
-
- $(AppIdentifierPrefix)com.quietmobile
-
-
-
diff --git a/packages/mobile/ios/NotificationAppExtension/NotificationService.swift b/packages/mobile/ios/NotificationAppExtension/NotificationService.swift
deleted file mode 100644
index 0f337992d4..0000000000
--- a/packages/mobile/ios/NotificationAppExtension/NotificationService.swift
+++ /dev/null
@@ -1,35 +0,0 @@
-//
-// NotificationService.swift
-// NotificationAppExtension
-//
-// Created by Isla Koenigsknecht on 2/18/26.
-//
-
-import UserNotifications
-
-class NotificationService: UNNotificationServiceExtension {
-
- var contentHandler: ((UNNotificationContent) -> Void)?
- var bestAttemptContent: UNMutableNotificationContent?
-
- override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
- self.contentHandler = contentHandler
- bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent)
-
- if let bestAttemptContent = bestAttemptContent {
- // Modify the notification content here...
- bestAttemptContent.title = "\(bestAttemptContent.title) [modified]"
-
- contentHandler(bestAttemptContent)
- }
- }
-
- override func serviceExtensionTimeWillExpire() {
- // Called just before the extension will be terminated by the system.
- // Use this as an opportunity to deliver your "best attempt" at modified content, otherwise the original push payload will be used.
- if let contentHandler = contentHandler, let bestAttemptContent = bestAttemptContent {
- contentHandler(bestAttemptContent)
- }
- }
-
-}
diff --git a/packages/mobile/ios/Quiet.xcodeproj/project.pbxproj b/packages/mobile/ios/Quiet.xcodeproj/project.pbxproj
index 3503c544f8..dc4625a9bb 100644
--- a/packages/mobile/ios/Quiet.xcodeproj/project.pbxproj
+++ b/packages/mobile/ios/Quiet.xcodeproj/project.pbxproj
@@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
- objectVersion = 70;
+ objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
@@ -55,12 +55,11 @@
18FD2A3E296F009E00A2B8C0 /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 18FD2A37296F009E00A2B8C0 /* AppDelegate.m */; };
18FD2A3F296F009E00A2B8C0 /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 18FD2A38296F009E00A2B8C0 /* Images.xcassets */; };
18FD2A40296F009E00A2B8C0 /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 18FD2A39296F009E00A2B8C0 /* main.m */; };
- 38AD376324628C6E27E70991 /* libPods-Quiet-QuietTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 02AFA323BA7196A67E7A1133 /* libPods-Quiet-QuietTests.a */; };
- 665E5BC42F46402C005D2086 /* NotificationAppExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 665E5BBD2F46402C005D2086 /* NotificationAppExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
- 6681DD3A2F4F53BF005D2086 /* KeychainHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6681DD392F4F53BF005D2086 /* KeychainHandler.swift */; };
+ 36A82BC8FCE690814036F90F /* libPods-Quiet.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 827250ED268E9BB42322847A /* libPods-Quiet.a */; };
+ 665587CA2F4F5ECD005D2086 /* KeychainHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 665587C92F4F5ECD005D2086 /* KeychainHandler.swift */; };
955DC7582BD930B30014725B /* WebsocketSingleton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 955DC7572BD930B30014725B /* WebsocketSingleton.swift */; };
D3239FB5EFA85E780E1AD201 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 84F12DFE2A5B0E05C2C41286 /* PrivacyInfo.xcprivacy */; };
- E479975299F0932FF690F805 /* libPods-Quiet.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 104C0D0FA8EF00192C60CAD7 /* libPods-Quiet.a */; };
+ E527867D4810B57F475B8709 /* libPods-Quiet-QuietTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = FFB5AD1B1D1B895AC31572D9 /* libPods-Quiet-QuietTests.a */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -71,13 +70,6 @@
remoteGlobalIDString = 13B07F861A680F5B00A75B9A;
remoteInfo = QuietMobile;
};
- 665E5BC22F46402C005D2086 /* PBXContainerItemProxy */ = {
- isa = PBXContainerItemProxy;
- containerPortal = 83CBB9F71A601CBA00E9B192 /* Project object */;
- proxyType = 1;
- remoteGlobalIDString = 665E5BBC2F46402C005D2086;
- remoteInfo = NotificationAppExtension;
- };
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
@@ -93,17 +85,6 @@
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
};
- 665E5BC52F46402D005D2086 /* Embed Foundation Extensions */ = {
- isa = PBXCopyFilesBuildPhase;
- buildActionMask = 2147483647;
- dstPath = "";
- dstSubfolderSpec = 13;
- files = (
- 665E5BC42F46402C005D2086 /* NotificationAppExtension.appex in Embed Foundation Extensions */,
- );
- name = "Embed Foundation Extensions";
- runOnlyForDeploymentPostprocessing = 0;
- };
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
@@ -112,7 +93,6 @@
00E356EE1AD99517003FC87E /* QuietTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = QuietTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
00E356F11AD99517003FC87E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
00E356F21AD99517003FC87E /* QuietTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = QuietTests.m; sourceTree = ""; };
- 02AFA323BA7196A67E7A1133 /* libPods-Quiet-QuietTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Quiet-QuietTests.a"; sourceTree = BUILT_PRODUCTS_DIR; };
03B673F92E6103DC00A86655 /* Rubik-Black.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = "Rubik-Black.ttf"; path = "../src/assets/fonts/Rubik-Black.ttf"; sourceTree = SOURCE_ROOT; };
03B673FA2E6103DC00A86655 /* Rubik-BlackItalic.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = "Rubik-BlackItalic.ttf"; path = "../src/assets/fonts/Rubik-BlackItalic.ttf"; sourceTree = SOURCE_ROOT; };
03B673FB2E6103DC00A86655 /* Rubik-Bold.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = "Rubik-Bold.ttf"; path = "../src/assets/fonts/Rubik-Bold.ttf"; sourceTree = SOURCE_ROOT; };
@@ -127,8 +107,6 @@
03B674042E6103DC00A86655 /* Rubik-Regular.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = "Rubik-Regular.ttf"; path = "../src/assets/fonts/Rubik-Regular.ttf"; sourceTree = SOURCE_ROOT; };
03B674052E6103DC00A86655 /* Rubik-SemiBold.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = "Rubik-SemiBold.ttf"; path = "../src/assets/fonts/Rubik-SemiBold.ttf"; sourceTree = SOURCE_ROOT; };
03B674062E6103DC00A86655 /* Rubik-SemiBoldItalic.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = "Rubik-SemiBoldItalic.ttf"; path = "../src/assets/fonts/Rubik-SemiBoldItalic.ttf"; sourceTree = SOURCE_ROOT; };
- 104C0D0FA8EF00192C60CAD7 /* libPods-Quiet.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Quiet.a"; sourceTree = BUILT_PRODUCTS_DIR; };
- 116749B7D5552021F04BE739 /* Pods-Quiet.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Quiet.release.xcconfig"; path = "Target Support Files/Pods-Quiet/Pods-Quiet.release.xcconfig"; sourceTree = ""; };
13B07F961A680F5B00A75B9A /* Quiet.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Quiet.app; sourceTree = BUILT_PRODUCTS_DIR; };
180E120A2AEFB7F900804659 /* Utils.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Utils.swift; sourceTree = ""; };
1827A9E129783D6E00245FD3 /* classic-level.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; path = "classic-level.framework"; sourceTree = ""; };
@@ -640,36 +618,24 @@
18FD2A39296F009E00A2B8C0 /* main.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = main.m; path = Quiet/main.m; sourceTree = ""; };
18FD2A3A296F009E00A2B8C0 /* Quiet.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; name = Quiet.entitlements; path = Quiet/Quiet.entitlements; sourceTree = ""; };
18FD2A3B296F009E00A2B8C0 /* QuietDebug.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; name = QuietDebug.entitlements; path = Quiet/QuietDebug.entitlements; sourceTree = ""; };
- 2877B6088D1D81EF869D71D9 /* Pods-Quiet-QuietTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Quiet-QuietTests.release.xcconfig"; path = "Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests.release.xcconfig"; sourceTree = ""; };
- 665E5BBD2F46402C005D2086 /* NotificationAppExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NotificationAppExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
- 6681DD392F4F53BF005D2086 /* KeychainHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainHandler.swift; sourceTree = ""; };
- 7F75CE6D1C7D3DA9F5BA6A76 /* Pods-Quiet-QuietTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Quiet-QuietTests.debug.xcconfig"; path = "Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests.debug.xcconfig"; sourceTree = ""; };
+ 665587C92F4F5ECD005D2086 /* KeychainHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainHandler.swift; sourceTree = ""; };
+ 827250ED268E9BB42322847A /* libPods-Quiet.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Quiet.a"; sourceTree = BUILT_PRODUCTS_DIR; };
84F12DFE2A5B0E05C2C41286 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = Quiet/PrivacyInfo.xcprivacy; sourceTree = ""; };
955DC7572BD930B30014725B /* WebsocketSingleton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebsocketSingleton.swift; sourceTree = ""; };
- E727FE0446E9E46CDECEC7FE /* Pods-Quiet.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Quiet.debug.xcconfig"; path = "Target Support Files/Pods-Quiet/Pods-Quiet.debug.xcconfig"; sourceTree = ""; };
+ A0A640C5FB25A885E7EA3D28 /* Pods-Quiet.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Quiet.debug.xcconfig"; path = "Target Support Files/Pods-Quiet/Pods-Quiet.debug.xcconfig"; sourceTree = ""; };
+ A5B75BBA2D210014081EFE0C /* Pods-Quiet-QuietTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Quiet-QuietTests.release.xcconfig"; path = "Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests.release.xcconfig"; sourceTree = ""; };
+ C7B1A71CADBC729F3E550DF1 /* Pods-Quiet.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Quiet.release.xcconfig"; path = "Target Support Files/Pods-Quiet/Pods-Quiet.release.xcconfig"; sourceTree = ""; };
+ FB3F0E6671064CAF2C159B5D /* Pods-Quiet-QuietTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Quiet-QuietTests.debug.xcconfig"; path = "Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests.debug.xcconfig"; sourceTree = ""; };
+ FFB5AD1B1D1B895AC31572D9 /* libPods-Quiet-QuietTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Quiet-QuietTests.a"; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
-/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
- 665E5BC82F46402D005D2086 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
- isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
- membershipExceptions = (
- Info.plist,
- );
- target = 665E5BBC2F46402C005D2086 /* NotificationAppExtension */;
- };
-/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
-
-/* Begin PBXFileSystemSynchronizedRootGroup section */
- 665E5BBE2F46402C005D2086 /* NotificationAppExtension */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (665E5BC82F46402D005D2086 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = NotificationAppExtension; sourceTree = ""; };
-/* End PBXFileSystemSynchronizedRootGroup section */
-
/* Begin PBXFrameworksBuildPhase section */
00E356EB1AD99517003FC87E /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
1827A9E229783D6E00245FD3 /* classic-level.framework in Frameworks */,
- 38AD376324628C6E27E70991 /* libPods-Quiet-QuietTests.a in Frameworks */,
+ E527867D4810B57F475B8709 /* libPods-Quiet-QuietTests.a in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -679,14 +645,7 @@
files = (
00A416342EC2EAA900ACC877 /* NodeMobile.xcframework in Frameworks */,
1827A9E329783D7600245FD3 /* classic-level.framework in Frameworks */,
- E479975299F0932FF690F805 /* libPods-Quiet.a in Frameworks */,
- );
- runOnlyForDeploymentPostprocessing = 0;
- };
- 665E5BBA2F46402C005D2086 /* Frameworks */ = {
- isa = PBXFrameworksBuildPhase;
- buildActionMask = 2147483647;
- files = (
+ 36A82BC8FCE690814036F90F /* libPods-Quiet.a in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -713,7 +672,7 @@
13B07FAE1A68108700A75B9A /* Quiet */ = {
isa = PBXGroup;
children = (
- 6681DD392F4F53BF005D2086 /* KeychainHandler.swift */,
+ 665587C92F4F5ECD005D2086 /* KeychainHandler.swift */,
180E120A2AEFB7F900804659 /* Utils.swift */,
18FD2A36296F009E00A2B8C0 /* AppDelegate.h */,
18FD2A37296F009E00A2B8C0 /* AppDelegate.m */,
@@ -4745,10 +4704,10 @@
1CEEDB4F07B9978C125775C5 /* Pods */ = {
isa = PBXGroup;
children = (
- E727FE0446E9E46CDECEC7FE /* Pods-Quiet.debug.xcconfig */,
- 116749B7D5552021F04BE739 /* Pods-Quiet.release.xcconfig */,
- 7F75CE6D1C7D3DA9F5BA6A76 /* Pods-Quiet-QuietTests.debug.xcconfig */,
- 2877B6088D1D81EF869D71D9 /* Pods-Quiet-QuietTests.release.xcconfig */,
+ A0A640C5FB25A885E7EA3D28 /* Pods-Quiet.debug.xcconfig */,
+ C7B1A71CADBC729F3E550DF1 /* Pods-Quiet.release.xcconfig */,
+ FB3F0E6671064CAF2C159B5D /* Pods-Quiet-QuietTests.debug.xcconfig */,
+ A5B75BBA2D210014081EFE0C /* Pods-Quiet-QuietTests.release.xcconfig */,
);
path = Pods;
sourceTree = "";
@@ -4758,8 +4717,8 @@
children = (
00A416332EC2EAA900ACC877 /* NodeMobile.xcframework */,
1827A9E129783D6E00245FD3 /* classic-level.framework */,
- 104C0D0FA8EF00192C60CAD7 /* libPods-Quiet.a */,
- 02AFA323BA7196A67E7A1133 /* libPods-Quiet-QuietTests.a */,
+ 827250ED268E9BB42322847A /* libPods-Quiet.a */,
+ FFB5AD1B1D1B895AC31572D9 /* libPods-Quiet-QuietTests.a */,
);
name = Frameworks;
sourceTree = "";
@@ -4799,7 +4758,6 @@
13B07FAE1A68108700A75B9A /* Quiet */,
832341AE1AAA6A7D00B99B32 /* Libraries */,
00E356EF1AD99517003FC87E /* QuietTests */,
- 665E5BBE2F46402C005D2086 /* NotificationAppExtension */,
83CBBA001A601CBA00E9B192 /* Products */,
2D16E6871FA4F8E400B85C8A /* Frameworks */,
624523FCC5994B7E9869E9CF /* Resources */,
@@ -4815,7 +4773,6 @@
children = (
13B07F961A680F5B00A75B9A /* Quiet.app */,
00E356EE1AD99517003FC87E /* QuietTests.xctest */,
- 665E5BBD2F46402C005D2086 /* NotificationAppExtension.appex */,
);
name = Products;
sourceTree = "";
@@ -4827,12 +4784,12 @@
isa = PBXNativeTarget;
buildConfigurationList = 00E357021AD99517003FC87E /* Build configuration list for PBXNativeTarget "QuietTests" */;
buildPhases = (
- 58352AE7BD90BC0845FC78DE /* [CP] Check Pods Manifest.lock */,
+ 69006B3CE96CD13A5F5A5226 /* [CP] Check Pods Manifest.lock */,
00E356EA1AD99517003FC87E /* Sources */,
00E356EB1AD99517003FC87E /* Frameworks */,
00E356EC1AD99517003FC87E /* Resources */,
- EF4E9879B3A5060ADF995D6A /* [CP] Embed Pods Frameworks */,
- 4E18C6AEAD8D524EAD836F51 /* [CP] Copy Pods Resources */,
+ E49E02522FA800C59F2F014D /* [CP] Embed Pods Frameworks */,
+ C3AB99BB8D1CD10850476367 /* [CP] Copy Pods Resources */,
);
buildRules = (
);
@@ -4848,7 +4805,7 @@
isa = PBXNativeTarget;
buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "Quiet" */;
buildPhases = (
- 715B735C8E7FA602A967EAF0 /* [CP] Check Pods Manifest.lock */,
+ 626C6584D894673F46A360BF /* [CP] Check Pods Manifest.lock */,
FD10A7F022414F080027D42C /* Start Packager */,
13B07F871A680F5B00A75B9A /* Sources */,
13B07F8C1A680F5B00A75B9A /* Frameworks */,
@@ -4860,42 +4817,18 @@
18FD2A32296D736300A2B8C0 /* [CUSTOM NODEJS MOBILE] Remove Python3 Binaries */,
1827A9E0297837FE00245FD3 /* [CUSTOM NODEJS MOBILE] Remove prebuilds */,
1868C095292F8FE2001D6D5E /* Embed Frameworks */,
- 7253A52F091FC8122D730B2E /* [CP] Embed Pods Frameworks */,
- A1AE2657AE5C40D18941D671 /* [CP] Copy Pods Resources */,
- 665E5BC52F46402D005D2086 /* Embed Foundation Extensions */,
+ D141249F8994850B1DC7FF46 /* [CP] Embed Pods Frameworks */,
+ 51FD2183E5DD9C7BD49270BD /* [CP] Copy Pods Resources */,
);
buildRules = (
);
dependencies = (
- 665E5BC32F46402C005D2086 /* PBXTargetDependency */,
);
name = Quiet;
productName = QuietMobile;
productReference = 13B07F961A680F5B00A75B9A /* Quiet.app */;
productType = "com.apple.product-type.application";
};
- 665E5BBC2F46402C005D2086 /* NotificationAppExtension */ = {
- isa = PBXNativeTarget;
- buildConfigurationList = 665E5BC92F46402D005D2086 /* Build configuration list for PBXNativeTarget "NotificationAppExtension" */;
- buildPhases = (
- 665E5BB92F46402C005D2086 /* Sources */,
- 665E5BBA2F46402C005D2086 /* Frameworks */,
- 665E5BBB2F46402C005D2086 /* Resources */,
- );
- buildRules = (
- );
- dependencies = (
- );
- fileSystemSynchronizedGroups = (
- 665E5BBE2F46402C005D2086 /* NotificationAppExtension */,
- );
- name = NotificationAppExtension;
- packageProductDependencies = (
- );
- productName = NotificationAppExtension;
- productReference = 665E5BBD2F46402C005D2086 /* NotificationAppExtension.appex */;
- productType = "com.apple.product-type.app-extension";
- };
/* End PBXNativeTarget section */
/* Begin PBXProject section */
@@ -4903,7 +4836,6 @@
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
- LastSwiftUpdateCheck = 1620;
LastUpgradeCheck = 2610;
TargetAttributes = {
00E356ED1AD99517003FC87E = {
@@ -4913,9 +4845,6 @@
13B07F861A680F5B00A75B9A = {
LastSwiftMigration = 1250;
};
- 665E5BBC2F46402C005D2086 = {
- CreatedOnToolsVersion = 16.2;
- };
};
};
buildConfigurationList = 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "Quiet" */;
@@ -4933,7 +4862,6 @@
targets = (
13B07F861A680F5B00A75B9A /* Quiet */,
00E356ED1AD99517003FC87E /* QuietTests */,
- 665E5BBC2F46402C005D2086 /* NotificationAppExtension */,
);
};
/* End PBXProject section */
@@ -4984,13 +4912,6 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
- 665E5BBB2F46402C005D2086 /* Resources */ = {
- isa = PBXResourcesBuildPhase;
- buildActionMask = 2147483647;
- files = (
- );
- runOnlyForDeploymentPostprocessing = 0;
- };
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
@@ -5006,7 +4927,7 @@
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
- shellScript = "set -e\n\n# Fix for machines using nvm\nif [[ -s \"$HOME/.nvm/nvm.sh\" ]]; then\n. \"$HOME/.nvm/nvm.sh\"\nelif [[ -x \"$(command -v brew)\" && -s \"$(brew --prefix nvm)/nvm.sh\" ]]; then\n. \"$(brew --prefix nvm)/nvm.sh\"\nfi\n\nexport NODE_BINARY=node\n../node_modules/react-native/scripts/react-native-xcode.sh\n";
+ shellScript = "set -e\n\nexport NODE_BINARY=/Users/isla/.volta/bin/node\n../node_modules/react-native/scripts/react-native-xcode.sh\n";
};
03B673F82E60FE0000A86655 /* Inject Feature Flags */ = {
isa = PBXShellScriptBuildPhase;
@@ -5102,24 +5023,24 @@
shellPath = /bin/sh;
shellScript = "find \"$CODESIGNING_FOLDER_PATH/nodejs-project/node_modules/\" -name \"python3\" | xargs rm\n";
};
- 4E18C6AEAD8D524EAD836F51 /* [CP] Copy Pods Resources */ = {
+ 51FD2183E5DD9C7BD49270BD /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
- "${PODS_ROOT}/Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests-resources-${CONFIGURATION}-input-files.xcfilelist",
+ "${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-resources-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
- "${PODS_ROOT}/Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests-resources-${CONFIGURATION}-output-files.xcfilelist",
+ "${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-resources-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
- shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests-resources.sh\"\n";
+ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-resources.sh\"\n";
showEnvVarsInLog = 0;
};
- 58352AE7BD90BC0845FC78DE /* [CP] Check Pods Manifest.lock */ = {
+ 626C6584D894673F46A360BF /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
@@ -5134,14 +5055,14 @@
outputFileListPaths = (
);
outputPaths = (
- "$(DERIVED_FILE_DIR)/Pods-Quiet-QuietTests-checkManifestLockResult.txt",
+ "$(DERIVED_FILE_DIR)/Pods-Quiet-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
- 715B735C8E7FA602A967EAF0 /* [CP] Check Pods Manifest.lock */ = {
+ 69006B3CE96CD13A5F5A5226 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
@@ -5156,48 +5077,48 @@
outputFileListPaths = (
);
outputPaths = (
- "$(DERIVED_FILE_DIR)/Pods-Quiet-checkManifestLockResult.txt",
+ "$(DERIVED_FILE_DIR)/Pods-Quiet-QuietTests-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
- 7253A52F091FC8122D730B2E /* [CP] Embed Pods Frameworks */ = {
+ C3AB99BB8D1CD10850476367 /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
- "${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-frameworks-${CONFIGURATION}-input-files.xcfilelist",
+ "${PODS_ROOT}/Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests-resources-${CONFIGURATION}-input-files.xcfilelist",
);
- name = "[CP] Embed Pods Frameworks";
+ name = "[CP] Copy Pods Resources";
outputFileListPaths = (
- "${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-frameworks-${CONFIGURATION}-output-files.xcfilelist",
+ "${PODS_ROOT}/Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests-resources-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
- shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-frameworks.sh\"\n";
+ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests-resources.sh\"\n";
showEnvVarsInLog = 0;
};
- A1AE2657AE5C40D18941D671 /* [CP] Copy Pods Resources */ = {
+ D141249F8994850B1DC7FF46 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
- "${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-resources-${CONFIGURATION}-input-files.xcfilelist",
+ "${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
- name = "[CP] Copy Pods Resources";
+ name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
- "${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-resources-${CONFIGURATION}-output-files.xcfilelist",
+ "${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
- shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-resources.sh\"\n";
+ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
- EF4E9879B3A5060ADF995D6A /* [CP] Embed Pods Frameworks */ = {
+ E49E02522FA800C59F2F014D /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
@@ -5256,7 +5177,7 @@
1868BCEE292E9212001D6D5E /* RNNodeJsMobile.m in Sources */,
1868C4382930D7D6001D6D5E /* DataDirectory.swift in Sources */,
1868C43E2930EAEA001D6D5E /* CommunicationBridge.m in Sources */,
- 6681DD3A2F4F53BF005D2086 /* KeychainHandler.swift in Sources */,
+ 665587CA2F4F5ECD005D2086 /* KeychainHandler.swift in Sources */,
1868C43A2930D859001D6D5E /* FindFreePort.swift in Sources */,
18FD2A3E296F009E00A2B8C0 /* AppDelegate.m in Sources */,
1868BCED292E9212001D6D5E /* NodeRunner.mm in Sources */,
@@ -5265,13 +5186,6 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
- 665E5BB92F46402C005D2086 /* Sources */ = {
- isa = PBXSourcesBuildPhase;
- buildActionMask = 2147483647;
- files = (
- );
- runOnlyForDeploymentPostprocessing = 0;
- };
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
@@ -5280,17 +5194,12 @@
target = 13B07F861A680F5B00A75B9A /* Quiet */;
targetProxy = 00E356F41AD99517003FC87E /* PBXContainerItemProxy */;
};
- 665E5BC32F46402C005D2086 /* PBXTargetDependency */ = {
- isa = PBXTargetDependency;
- target = 665E5BBC2F46402C005D2086 /* NotificationAppExtension */;
- targetProxy = 665E5BC22F46402C005D2086 /* PBXContainerItemProxy */;
- };
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
00E356F61AD99517003FC87E /* Debug */ = {
isa = XCBuildConfiguration;
- baseConfigurationReference = 7F75CE6D1C7D3DA9F5BA6A76 /* Pods-Quiet-QuietTests.debug.xcconfig */;
+ baseConfigurationReference = FB3F0E6671064CAF2C159B5D /* Pods-Quiet-QuietTests.debug.xcconfig */;
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
BUNDLE_LOADER = "$(TEST_HOST)";
@@ -5323,7 +5232,7 @@
};
00E356F71AD99517003FC87E /* Release */ = {
isa = XCBuildConfiguration;
- baseConfigurationReference = 2877B6088D1D81EF869D71D9 /* Pods-Quiet-QuietTests.release.xcconfig */;
+ baseConfigurationReference = A5B75BBA2D210014081EFE0C /* Pods-Quiet-QuietTests.release.xcconfig */;
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
BUNDLE_LOADER = "$(TEST_HOST)";
@@ -5353,7 +5262,7 @@
};
13B07F941A680F5B00A75B9A /* Debug */ = {
isa = XCBuildConfiguration;
- baseConfigurationReference = E727FE0446E9E46CDECEC7FE /* Pods-Quiet.debug.xcconfig */;
+ baseConfigurationReference = A0A640C5FB25A885E7EA3D28 /* Pods-Quiet.debug.xcconfig */;
buildSettings = {
ARCHS = "$(ARCHS_STANDARD)";
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
@@ -5454,7 +5363,7 @@
};
13B07F951A680F5B00A75B9A /* Release */ = {
isa = XCBuildConfiguration;
- baseConfigurationReference = 116749B7D5552021F04BE739 /* Pods-Quiet.release.xcconfig */;
+ baseConfigurationReference = C7B1A71CADBC729F3E550DF1 /* Pods-Quiet.release.xcconfig */;
buildSettings = {
ARCHS = "$(ARCHS_STANDARD)";
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
@@ -5548,91 +5457,6 @@
};
name = Release;
};
- 665E5BC62F46402D005D2086 /* Debug */ = {
- isa = XCBuildConfiguration;
- buildSettings = {
- ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
- CLANG_ANALYZER_NONNULL = YES;
- CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
- CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
- CLANG_ENABLE_OBJC_WEAK = YES;
- CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
- CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
- CODE_SIGN_ENTITLEMENTS = NotificationAppExtension/NotificationAppExtension.entitlements;
- CODE_SIGN_IDENTITY = "Apple Development";
- CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 1;
- DEBUG_INFORMATION_FORMAT = dwarf;
- DEVELOPMENT_TEAM = CTYKSWN9T4;
- ENABLE_USER_SCRIPT_SANDBOXING = YES;
- GCC_C_LANGUAGE_STANDARD = gnu17;
- GENERATE_INFOPLIST_FILE = YES;
- INFOPLIST_FILE = NotificationAppExtension/Info.plist;
- INFOPLIST_KEY_CFBundleDisplayName = NotificationAppExtension;
- INFOPLIST_KEY_NSHumanReadableCopyright = "";
- IPHONEOS_DEPLOYMENT_TARGET = 18.2;
- LD_RUNPATH_SEARCH_PATHS = (
- "$(inherited)",
- "@executable_path/Frameworks",
- "@executable_path/../../Frameworks",
- );
- LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
- MARKETING_VERSION = 1.0;
- MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
- MTL_FAST_MATH = YES;
- PRODUCT_BUNDLE_IDENTIFIER = com.quietmobile.NotificationAppExtension;
- PRODUCT_NAME = "$(TARGET_NAME)";
- SKIP_INSTALL = YES;
- SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
- SWIFT_EMIT_LOC_STRINGS = YES;
- SWIFT_OPTIMIZATION_LEVEL = "-Onone";
- SWIFT_VERSION = 5.0;
- TARGETED_DEVICE_FAMILY = "1,2";
- };
- name = Debug;
- };
- 665E5BC72F46402D005D2086 /* Release */ = {
- isa = XCBuildConfiguration;
- buildSettings = {
- ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
- CLANG_ANALYZER_NONNULL = YES;
- CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
- CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
- CLANG_ENABLE_OBJC_WEAK = YES;
- CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
- CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
- CODE_SIGN_ENTITLEMENTS = NotificationAppExtension/NotificationAppExtension.entitlements;
- CODE_SIGN_IDENTITY = "Apple Development";
- CODE_SIGN_STYLE = Automatic;
- COPY_PHASE_STRIP = NO;
- CURRENT_PROJECT_VERSION = 1;
- DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
- DEVELOPMENT_TEAM = CTYKSWN9T4;
- ENABLE_USER_SCRIPT_SANDBOXING = YES;
- GCC_C_LANGUAGE_STANDARD = gnu17;
- GENERATE_INFOPLIST_FILE = YES;
- INFOPLIST_FILE = NotificationAppExtension/Info.plist;
- INFOPLIST_KEY_CFBundleDisplayName = NotificationAppExtension;
- INFOPLIST_KEY_NSHumanReadableCopyright = "";
- IPHONEOS_DEPLOYMENT_TARGET = 18.2;
- LD_RUNPATH_SEARCH_PATHS = (
- "$(inherited)",
- "@executable_path/Frameworks",
- "@executable_path/../../Frameworks",
- );
- LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
- MARKETING_VERSION = 1.0;
- MTL_FAST_MATH = YES;
- PRODUCT_BUNDLE_IDENTIFIER = com.quietmobile.NotificationAppExtension;
- PRODUCT_NAME = "$(TARGET_NAME)";
- PROVISIONING_PROFILE_SPECIFIER = "";
- SKIP_INSTALL = YES;
- SWIFT_EMIT_LOC_STRINGS = YES;
- SWIFT_VERSION = 5.0;
- TARGETED_DEVICE_FAMILY = "1,2";
- };
- name = Release;
- };
83CBBA201A601CBA00E9B192 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
@@ -5802,15 +5626,6 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
- 665E5BC92F46402D005D2086 /* Build configuration list for PBXNativeTarget "NotificationAppExtension" */ = {
- isa = XCConfigurationList;
- buildConfigurations = (
- 665E5BC62F46402D005D2086 /* Debug */,
- 665E5BC72F46402D005D2086 /* Release */,
- );
- defaultConfigurationIsVisible = 0;
- defaultConfigurationName = Release;
- };
83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "Quiet" */ = {
isa = XCConfigurationList;
buildConfigurations = (
diff --git a/packages/mobile/src/store/init/startConnection/startConnection.saga.ts b/packages/mobile/src/store/init/startConnection/startConnection.saga.ts
index 10c9b3370f..6a83e68746 100644
--- a/packages/mobile/src/store/init/startConnection/startConnection.saga.ts
+++ b/packages/mobile/src/store/init/startConnection/startConnection.saga.ts
@@ -16,9 +16,10 @@ import { PayloadAction } from '@reduxjs/toolkit'
import { socket as stateManager, Socket } from '@quiet/state-manager'
import { initActions, WebsocketConnectionPayload } from '../init.slice'
import { eventChannel } from 'redux-saga'
-import { SocketActions } from '@quiet/types'
+import { KeysUpdatedEvent, SocketActions, SocketEvents } from '@quiet/types'
import { createLogger } from '../../../utils/logger'
import { initSelectors } from '../init.selectors'
+import { keysActions } from '../../keys/keys.slice'
const logger = createLogger('startConnection')
@@ -76,7 +77,9 @@ function subscribeSocketLifecycle(socket: Socket, socketIOData: WebsocketConnect
let socket_id: string | undefined
return eventChannel<
- ReturnType | ReturnType
+ | ReturnType
+ | ReturnType
+ | ReturnType
>(emit => {
socket.on('connect', async () => {
socket_id = socket.id
@@ -87,6 +90,10 @@ function subscribeSocketLifecycle(socket: Socket, socketIOData: WebsocketConnect
logger.warn('client: Closing socket connection', socket_id, reason)
emit(initActions.suspendWebsocketConnection())
})
+ socket.on(SocketEvents.KEYS_UPDATED, async (payload: KeysUpdatedEvent) => {
+ logger.info('Keys updated, writing to keychain')
+ emit(keysActions.saveKeysInKeychain(payload))
+ })
return () => {}
})
}
diff --git a/packages/mobile/src/store/keys/keys.type.ts b/packages/mobile/src/store/keys/keys.type.ts
index b0e994fcfa..5148b443a9 100644
--- a/packages/mobile/src/store/keys/keys.type.ts
+++ b/packages/mobile/src/store/keys/keys.type.ts
@@ -8,4 +8,5 @@ export type ExtendedKeyScope = {
export interface StorableKey {
scope: ExtendedKeyScope
key: string
+ teamId: string
}
diff --git a/packages/mobile/src/store/keys/saveKeysInKeychain/saveKeysInKeychain.saga.ts b/packages/mobile/src/store/keys/saveKeysInKeychain/saveKeysInKeychain.saga.ts
index b669b966aa..3e34095ef3 100644
--- a/packages/mobile/src/store/keys/saveKeysInKeychain/saveKeysInKeychain.saga.ts
+++ b/packages/mobile/src/store/keys/saveKeysInKeychain/saveKeysInKeychain.saga.ts
@@ -12,53 +12,50 @@ import { StorableKey } from '../keys.type'
const logger = createLogger('saveKeysInKeychainSaga')
export function* saveKeysInKeychainSaga(action: PayloadAction): Generator {
- logger.debug('Storing keys in ios keychain')
+ logger.info('Storing keys in ios keychain')
const existingKeys = yield* select(keysSelectors.allKeys)
const newSecretKeys = _.differenceBy(action.payload.secretKeys, existingKeys.secretKeys, 'key')
const newUserPublicKeys = _.differenceBy(action.payload.userPublicKeys, existingKeys.userPublicKeys, 'key')
const newSigKeys = _.differenceBy(action.payload.sigKeys, existingKeys.sigKeys, 'key')
- logger.debug('Updating keys state')
+ logger.info('Updating keys state')
yield* put(keysActions.setKeys(action.payload))
- const newKeysPayload: KeysUpdatedEvent = {
- secretKeys: newSecretKeys,
- userPublicKeys: newUserPublicKeys,
- sigKeys: newSigKeys,
- }
- const keysToSave: StorableKey[] = newSecretKeys.map(
- keyWithMetadata =>
- ({
- scope: {
- ...keyWithMetadata.scope,
- keyType: 'secret',
- },
- key: keyWithMetadata.key,
- } as StorableKey)
- )
+ const keysToSave: StorableKey[] = newSecretKeys.map(keyWithMetadata => ({
+ scope: {
+ ...keyWithMetadata.scope,
+ keyType: 'secret',
+ },
+ key: keyWithMetadata.key,
+ teamId: action.payload.teamId,
+ }))
keysToSave.push(
- ...newUserPublicKeys.map(
- keyWithMetadata =>
- ({
- scope: {
- ...keyWithMetadata.scope,
- keyType: 'userPublic',
- },
- key: keyWithMetadata.key,
- } as StorableKey)
- )
+ ...newUserPublicKeys.map(keyWithMetadata => ({
+ scope: {
+ ...keyWithMetadata.scope,
+ keyType: 'userPublic',
+ },
+ key: keyWithMetadata.key,
+ teamId: action.payload.teamId,
+ }))
)
keysToSave.push(
- ...newSigKeys.map(
- keyWithMetadata =>
- ({
- scope: {
- ...keyWithMetadata.scope,
- keyType: 'userSig',
- },
- key: keyWithMetadata.key,
- } as StorableKey)
- )
+ ...newSigKeys.map(keyWithMetadata => ({
+ scope: {
+ ...keyWithMetadata.scope,
+ keyType: 'userSig',
+ },
+ key: keyWithMetadata.key,
+ teamId: action.payload.teamId,
+ }))
)
- logger.debug('Putting new keys in keychain', keysToSave)
- yield* call(NativeModules.CommunicationModule.saveKeysInKeychain, newKeysPayload)
+
+ logger.info('Putting new keys in keychain', keysToSave)
+ try {
+ yield* call(
+ NativeModules.CommunicationModule.saveKeysInKeychain,
+ keysToSave.map(key => JSON.stringify(key))
+ )
+ } catch (e) {
+ logger.error('Error while updating keys on keychain', e)
+ }
}
diff --git a/packages/types/src/keys.ts b/packages/types/src/keys.ts
index 95e24fe0e2..c4e78fcc98 100644
--- a/packages/types/src/keys.ts
+++ b/packages/types/src/keys.ts
@@ -9,4 +9,5 @@ export interface KeysUpdatedEvent {
secretKeys: KeyWithMetadata[]
userPublicKeys: KeyWithMetadata[]
sigKeys: KeyWithMetadata[]
+ teamId: string
}
From 76565d75417bbcbbbb0396e204bceb2bbe51f2f4 Mon Sep 17 00:00:00 2001
From: Isla <5048549+islathehut@users.noreply.github.com>
Date: Wed, 4 Mar 2026 14:15:58 -0500
Subject: [PATCH 04/92] Don't store in state manager, simplify model that is
sent to frontend, decrease size of stored send data
---
.../backend/src/nest/auth/sigchain.service.ts | 63 +++++---
packages/backend/src/nest/auth/types.ts | 6 +
.../src/nest/local-db/local-db.service.ts | 13 ++
.../src/nest/local-db/local-db.types.ts | 3 +
packages/mobile/ios/CommunicationModule.swift | 8 +-
packages/mobile/ios/KeychainHandler.swift | 32 ++---
.../ios/Quiet.xcodeproj/project.pbxproj | 136 +++++++++---------
.../mobile/src/store/keys/keys.selectors..ts | 11 +-
packages/mobile/src/store/keys/keys.slice.ts | 13 +-
.../saveKeysInKeychain.saga.ts | 43 +-----
packages/types/src/keys.ts | 9 +-
11 files changed, 151 insertions(+), 186 deletions(-)
diff --git a/packages/backend/src/nest/auth/sigchain.service.ts b/packages/backend/src/nest/auth/sigchain.service.ts
index 1b8392425d..7870aa3d67 100644
--- a/packages/backend/src/nest/auth/sigchain.service.ts
+++ b/packages/backend/src/nest/auth/sigchain.service.ts
@@ -11,11 +11,11 @@ import {
UserWithSecrets,
DeviceWithSecrets,
} from '@localfirst/auth'
-import { KeyMetadata, KeyType } from '@localfirst/crdx'
+import { KeyMetadata } from '@localfirst/crdx'
import { LocalDbService } from '../local-db/local-db.service'
import { createLogger } from '../common/logger'
import { SocketService } from '../socket/socket.service'
-import { SocketEvents, User } from '@quiet/types'
+import { SocketEvents, StorableKey, User } from '@quiet/types'
import { type RoleService } from './services/roles/role.service'
import { type DeviceService } from './services/members/device.service'
import { type InviteService } from './services/invites/invite.service'
@@ -24,10 +24,8 @@ import { type CryptoService } from './services/crypto/crypto.service'
import { SERVER_IO_PROVIDER } from '../const'
import { ServerIoProviderTypes } from '../types'
import EventEmitter from 'events'
-import { GetChainFilter } from './types'
-import { KeysUpdatedEvent, KeyWithMetadata } from '@quiet/types'
-import { EncryptionScopeType } from './services/crypto/types'
-import * as os from 'os'
+import { GetChainFilter, StoredKeyType } from './types'
+import { KeysUpdatedEvent } from '@quiet/types'
@Injectable()
export class SigChainService extends EventEmitter {
@@ -166,44 +164,63 @@ export class SigChainService extends EventEmitter {
}
// TODO: only fetch keys that have been updated recently
- private _updateKeysOnChainUpdate() {
+ private async _updateKeysOnChainUpdate() {
if ((process.platform as string) !== 'ios') {
this.logger.trace('Skipping key update because we are not on ios, current platform =', process.platform)
return
}
- const secretKeys: KeyWithMetadata[] = []
- const sigKeys: KeyWithMetadata[] = []
- const userPublicKeys: KeyWithMetadata[] = []
+ const generateKeyName = (teamId: string, keyType: string, scope: KeyMetadata): string => {
+ return `quiet_${teamId}_${scope.type}_${scope.name}_${scope.generation}_${keyType}`
+ }
+
+ const teamId = this.activeChain.team!.id
+ const alreadySentKeys: Set = new Set(await this.localDbService.getKeysStoredInKeychain(teamId))
+ const keysToSend: StorableKey[] = []
+ const keyNamesSent: string[] = []
const allKeys = this.getActiveChain().crypto.getAllKeys()
for (const keyData of Object.values(allKeys)) {
for (const keyTypeData of Object.values(keyData)) {
for (const keyTypeGenData of Object.values(keyTypeData)) {
- secretKeys.push({
- scope: { name: keyTypeGenData.name, type: keyTypeGenData.type, generation: keyTypeGenData.generation },
- key: keyTypeGenData.secretKey,
+ const keyName = generateKeyName(teamId, StoredKeyType.SECRET, {
+ name: keyTypeGenData.name,
+ type: keyTypeGenData.type,
+ generation: keyTypeGenData.generation,
})
+ if (!alreadySentKeys.has(keyName)) {
+ keysToSend.push({ key: keyTypeGenData.secretKey, keyName })
+ keyNamesSent.push(keyName)
+ }
}
}
}
// TODO: update to pull all generations of user public/sig keys
const allUserPublicKeys = this.getActiveChain().crypto.getPublicKeysForAllMembers(true)
for (const keySet of allUserPublicKeys) {
- userPublicKeys.push({
- scope: { name: keySet.name, type: keySet.type, generation: keySet.generation },
- key: keySet.encryption,
+ const publicKeyName = generateKeyName(teamId, StoredKeyType.USER_PUBLIC, {
+ name: keySet.name,
+ type: keySet.type,
+ generation: keySet.generation,
})
- sigKeys.push({
- scope: { name: keySet.name, type: keySet.type, generation: keySet.generation },
- key: keySet.signature,
+ if (!alreadySentKeys.has(publicKeyName)) {
+ keysToSend.push({ key: keySet.encryption, keyName: publicKeyName })
+ keyNamesSent.push(publicKeyName)
+ }
+
+ const sigKeyName = generateKeyName(teamId, StoredKeyType.USER_SIG, {
+ name: keySet.name,
+ type: keySet.type,
+ generation: keySet.generation,
})
+ if (!alreadySentKeys.has(sigKeyName)) {
+ keysToSend.push({ key: keySet.signature, keyName: sigKeyName })
+ keyNamesSent.push(sigKeyName)
+ }
}
const keyUpdateEvent: KeysUpdatedEvent = {
- secretKeys,
- sigKeys,
- userPublicKeys,
- teamId: this.activeChain.team!.id,
+ keys: keysToSend,
}
+ await this.localDbService.updateKeysStoredInKeychain(teamId, keyNamesSent)
this.serverIoProvider.io.emit(SocketEvents.KEYS_UPDATED, keyUpdateEvent)
}
diff --git a/packages/backend/src/nest/auth/types.ts b/packages/backend/src/nest/auth/types.ts
index 438c6b0d46..42d7eda60a 100644
--- a/packages/backend/src/nest/auth/types.ts
+++ b/packages/backend/src/nest/auth/types.ts
@@ -17,3 +17,9 @@ export type GetChainFilter = {
teamId?: string
teamName?: string
}
+
+export enum StoredKeyType {
+ SECRET = 'secret',
+ USER_PUBLIC = 'userPublic',
+ USER_SIG = 'userSig',
+}
diff --git a/packages/backend/src/nest/local-db/local-db.service.ts b/packages/backend/src/nest/local-db/local-db.service.ts
index b1cd84f38d..040c0af71d 100644
--- a/packages/backend/src/nest/local-db/local-db.service.ts
+++ b/packages/backend/src/nest/local-db/local-db.service.ts
@@ -605,4 +605,17 @@ export class LocalDbService extends EventEmitter {
}
return count
}
+
+ public async updateKeysStoredInKeychain(teamId: string, keyNames: string[]): Promise {
+ const key = `${LocalDBKeys.KEYS_STORED_KEYCHAIN}:${teamId}`
+ const arr: string[] = (await this.get(key)) || []
+ arr.push(...keyNames)
+ await this.put(key, arr)
+ }
+
+ public async getKeysStoredInKeychain(teamId: string): Promise {
+ const key = `${LocalDBKeys.KEYS_STORED_KEYCHAIN}:${teamId}`
+ const arr: string[] = (await this.get(key)) || []
+ return arr
+ }
}
diff --git a/packages/backend/src/nest/local-db/local-db.types.ts b/packages/backend/src/nest/local-db/local-db.types.ts
index e0ccf2236b..1ddf242ae5 100644
--- a/packages/backend/src/nest/local-db/local-db.types.ts
+++ b/packages/backend/src/nest/local-db/local-db.types.ts
@@ -38,6 +38,9 @@ export enum LocalDBKeys {
// exists in the Community object.
OWNER_ORBIT_DB_IDENTITY = 'ownerOrbitDbIdentity',
+ // Keys from sigchain that have been stored in keychain
+ KEYS_STORED_KEYCHAIN = 'keysStoredInKeychain',
+
SIGCHAINS = 'sigchains:',
USER_CONTEXTS = 'userContexts',
KEYRINGS = 'keyrings',
diff --git a/packages/mobile/ios/CommunicationModule.swift b/packages/mobile/ios/CommunicationModule.swift
index 71cb912914..daa596255b 100644
--- a/packages/mobile/ios/CommunicationModule.swift
+++ b/packages/mobile/ios/CommunicationModule.swift
@@ -67,10 +67,10 @@ class CommunicationModule: RCTEventEmitter {
do {
let keyAsString: String = keyAsAny as! String
let data = Data(keyAsString.utf8)
- let decodedKeyWithScope = try decoder.decode(KeyWithScope.self, from: data)
- try self.keychainHandler.addLfaKey(keyWithScope: decodedKeyWithScope)
- let stored = try self.keychainHandler.getLfaKeyString(teamId: decodedKeyWithScope.teamId, scope: decodedKeyWithScope.scope)
- CommunicationModule.logger.info("Stored key matches? \(stored == decodedKeyWithScope.key) \(String(describing: decodedKeyWithScope.scope))")
+ let decodedNamedKey = try decoder.decode(NamedKey.self, from: data)
+ try self.keychainHandler.addLfaKey(namedKey: decodedNamedKey)
+ let stored = try self.keychainHandler.getLfaKeyString(keyName: decodedNamedKey.keyName)
+ CommunicationModule.logger.info("Stored key matches? \(stored == decodedNamedKey.key) \(decodedNamedKey.keyName)")
} catch {
CommunicationModule.logger.error("Error while saving key in keychain: \(error)")
}
diff --git a/packages/mobile/ios/KeychainHandler.swift b/packages/mobile/ios/KeychainHandler.swift
index cad13112bf..3b86f1e5e9 100644
--- a/packages/mobile/ios/KeychainHandler.swift
+++ b/packages/mobile/ios/KeychainHandler.swift
@@ -28,22 +28,14 @@ public enum KeychainHandlerError: Error {
case unhandledError(reason: Any)
}
-public struct KeyScope: Codable {
- let name: String
- let generation: Int
- let type: String
- let keyType: String
-}
-
public enum KeyAddStatus {
case success
case duplicateScope
}
-public struct KeyWithScope: Codable {
- let scope: KeyScope
+public struct NamedKey: Codable {
+ let keyName: String
let key: String
- let teamId: String
}
@objc(KeychainHandler)
@@ -69,9 +61,8 @@ class KeychainHandler: NSObject {
}
}
- public func getLfaKeyString(teamId: String, scope: KeyScope) throws -> String {
+ public func getLfaKeyString(keyName: String) throws -> String {
do {
- let keyName: String = _keyScopeToKeyName(teamId: teamId, scope: scope)
let password: String = try _getKeyImpl(keyName: keyName)
return password
} catch KeychainError.noPassword {
@@ -107,28 +98,27 @@ class KeychainHandler: NSObject {
}
}
- public func addLfaKey(keyWithScope: KeyWithScope) throws -> KeyAddStatus {
+ public func addLfaKey(namedKey: NamedKey) throws -> KeyAddStatus {
var existingKey: String?
do {
- existingKey = try getLfaKeyString(teamId: keyWithScope.teamId, scope: keyWithScope.scope)
+ existingKey = try getLfaKeyString(keyName: namedKey.keyName)
} catch KeychainHandlerError.noKeyFound {
existingKey = nil
} catch KeychainHandlerError.malformedKey {
existingKey = nil
} catch {
- KeychainHandler.logger.error("Error while getting existing LFA key for scope \(String(describing: keyWithScope.scope)): \(error)")
+ KeychainHandler.logger.error("Error while getting existing LFA key for name \(namedKey.keyName): \(error)")
throw error
}
guard existingKey == nil else {
- guard existingKey == keyWithScope.key else { return KeyAddStatus.duplicateScope }
+ guard existingKey == namedKey.key else { return KeyAddStatus.duplicateScope }
return KeyAddStatus.success
}
do {
- let keyName: String = _keyScopeToKeyName(teamId: keyWithScope.teamId, scope: keyWithScope.scope)
- let keyData: Data = try _stringToBytes(str: keyWithScope.key)
- let addStatus: KeyAddStatus = try _addKeyToKeychainImpl(keyName: keyName, keyData: keyData)
+ let keyData: Data = try _stringToBytes(str: namedKey.key)
+ let addStatus: KeyAddStatus = try _addKeyToKeychainImpl(keyName: namedKey.keyName, keyData: keyData)
return addStatus
} catch {
throw KeychainHandlerError.unhandledError(reason: error)
@@ -193,8 +183,4 @@ class KeychainHandler: NSObject {
}
return keyData
}
-
- private func _keyScopeToKeyName(teamId: String, scope: KeyScope) -> String {
- return "quiet_\(teamId)_\(scope.type)_\(scope.name)_\(scope.generation)_\(scope.keyType)"
- }
}
diff --git a/packages/mobile/ios/Quiet.xcodeproj/project.pbxproj b/packages/mobile/ios/Quiet.xcodeproj/project.pbxproj
index dc4625a9bb..e1d226dc60 100644
--- a/packages/mobile/ios/Quiet.xcodeproj/project.pbxproj
+++ b/packages/mobile/ios/Quiet.xcodeproj/project.pbxproj
@@ -55,11 +55,11 @@
18FD2A3E296F009E00A2B8C0 /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 18FD2A37296F009E00A2B8C0 /* AppDelegate.m */; };
18FD2A3F296F009E00A2B8C0 /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 18FD2A38296F009E00A2B8C0 /* Images.xcassets */; };
18FD2A40296F009E00A2B8C0 /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 18FD2A39296F009E00A2B8C0 /* main.m */; };
- 36A82BC8FCE690814036F90F /* libPods-Quiet.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 827250ED268E9BB42322847A /* libPods-Quiet.a */; };
665587CA2F4F5ECD005D2086 /* KeychainHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 665587C92F4F5ECD005D2086 /* KeychainHandler.swift */; };
+ 85137B01156B661E82184B11 /* libPods-Quiet.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 287A03B12772D68AAF979A77 /* libPods-Quiet.a */; };
955DC7582BD930B30014725B /* WebsocketSingleton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 955DC7572BD930B30014725B /* WebsocketSingleton.swift */; };
D3239FB5EFA85E780E1AD201 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 84F12DFE2A5B0E05C2C41286 /* PrivacyInfo.xcprivacy */; };
- E527867D4810B57F475B8709 /* libPods-Quiet-QuietTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = FFB5AD1B1D1B895AC31572D9 /* libPods-Quiet-QuietTests.a */; };
+ FFD33AE2521CEA2E0D3134ED /* libPods-Quiet-QuietTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = CA54D67D8C7A248C6A22C0F8 /* libPods-Quiet-QuietTests.a */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -618,15 +618,15 @@
18FD2A39296F009E00A2B8C0 /* main.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = main.m; path = Quiet/main.m; sourceTree = ""; };
18FD2A3A296F009E00A2B8C0 /* Quiet.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; name = Quiet.entitlements; path = Quiet/Quiet.entitlements; sourceTree = ""; };
18FD2A3B296F009E00A2B8C0 /* QuietDebug.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; name = QuietDebug.entitlements; path = Quiet/QuietDebug.entitlements; sourceTree = ""; };
+ 287A03B12772D68AAF979A77 /* libPods-Quiet.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Quiet.a"; sourceTree = BUILT_PRODUCTS_DIR; };
+ 2F63F73503B4A62481768267 /* Pods-Quiet.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Quiet.debug.xcconfig"; path = "Target Support Files/Pods-Quiet/Pods-Quiet.debug.xcconfig"; sourceTree = ""; };
665587C92F4F5ECD005D2086 /* KeychainHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainHandler.swift; sourceTree = ""; };
- 827250ED268E9BB42322847A /* libPods-Quiet.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Quiet.a"; sourceTree = BUILT_PRODUCTS_DIR; };
+ 677CCEF15F36760C73BC26A6 /* Pods-Quiet.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Quiet.release.xcconfig"; path = "Target Support Files/Pods-Quiet/Pods-Quiet.release.xcconfig"; sourceTree = ""; };
+ 70BDAC54548DB3505251D3DB /* Pods-Quiet-QuietTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Quiet-QuietTests.debug.xcconfig"; path = "Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests.debug.xcconfig"; sourceTree = ""; };
84F12DFE2A5B0E05C2C41286 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = Quiet/PrivacyInfo.xcprivacy; sourceTree = ""; };
+ 88972EF0A58F6401DC66730D /* Pods-Quiet-QuietTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Quiet-QuietTests.release.xcconfig"; path = "Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests.release.xcconfig"; sourceTree = ""; };
955DC7572BD930B30014725B /* WebsocketSingleton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebsocketSingleton.swift; sourceTree = ""; };
- A0A640C5FB25A885E7EA3D28 /* Pods-Quiet.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Quiet.debug.xcconfig"; path = "Target Support Files/Pods-Quiet/Pods-Quiet.debug.xcconfig"; sourceTree = ""; };
- A5B75BBA2D210014081EFE0C /* Pods-Quiet-QuietTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Quiet-QuietTests.release.xcconfig"; path = "Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests.release.xcconfig"; sourceTree = ""; };
- C7B1A71CADBC729F3E550DF1 /* Pods-Quiet.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Quiet.release.xcconfig"; path = "Target Support Files/Pods-Quiet/Pods-Quiet.release.xcconfig"; sourceTree = ""; };
- FB3F0E6671064CAF2C159B5D /* Pods-Quiet-QuietTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Quiet-QuietTests.debug.xcconfig"; path = "Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests.debug.xcconfig"; sourceTree = ""; };
- FFB5AD1B1D1B895AC31572D9 /* libPods-Quiet-QuietTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Quiet-QuietTests.a"; sourceTree = BUILT_PRODUCTS_DIR; };
+ CA54D67D8C7A248C6A22C0F8 /* libPods-Quiet-QuietTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Quiet-QuietTests.a"; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -635,7 +635,7 @@
buildActionMask = 2147483647;
files = (
1827A9E229783D6E00245FD3 /* classic-level.framework in Frameworks */,
- E527867D4810B57F475B8709 /* libPods-Quiet-QuietTests.a in Frameworks */,
+ FFD33AE2521CEA2E0D3134ED /* libPods-Quiet-QuietTests.a in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -645,7 +645,7 @@
files = (
00A416342EC2EAA900ACC877 /* NodeMobile.xcframework in Frameworks */,
1827A9E329783D7600245FD3 /* classic-level.framework in Frameworks */,
- 36A82BC8FCE690814036F90F /* libPods-Quiet.a in Frameworks */,
+ 85137B01156B661E82184B11 /* libPods-Quiet.a in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -4704,10 +4704,10 @@
1CEEDB4F07B9978C125775C5 /* Pods */ = {
isa = PBXGroup;
children = (
- A0A640C5FB25A885E7EA3D28 /* Pods-Quiet.debug.xcconfig */,
- C7B1A71CADBC729F3E550DF1 /* Pods-Quiet.release.xcconfig */,
- FB3F0E6671064CAF2C159B5D /* Pods-Quiet-QuietTests.debug.xcconfig */,
- A5B75BBA2D210014081EFE0C /* Pods-Quiet-QuietTests.release.xcconfig */,
+ 2F63F73503B4A62481768267 /* Pods-Quiet.debug.xcconfig */,
+ 677CCEF15F36760C73BC26A6 /* Pods-Quiet.release.xcconfig */,
+ 70BDAC54548DB3505251D3DB /* Pods-Quiet-QuietTests.debug.xcconfig */,
+ 88972EF0A58F6401DC66730D /* Pods-Quiet-QuietTests.release.xcconfig */,
);
path = Pods;
sourceTree = "";
@@ -4717,8 +4717,8 @@
children = (
00A416332EC2EAA900ACC877 /* NodeMobile.xcframework */,
1827A9E129783D6E00245FD3 /* classic-level.framework */,
- 827250ED268E9BB42322847A /* libPods-Quiet.a */,
- FFB5AD1B1D1B895AC31572D9 /* libPods-Quiet-QuietTests.a */,
+ 287A03B12772D68AAF979A77 /* libPods-Quiet.a */,
+ CA54D67D8C7A248C6A22C0F8 /* libPods-Quiet-QuietTests.a */,
);
name = Frameworks;
sourceTree = "";
@@ -4784,12 +4784,12 @@
isa = PBXNativeTarget;
buildConfigurationList = 00E357021AD99517003FC87E /* Build configuration list for PBXNativeTarget "QuietTests" */;
buildPhases = (
- 69006B3CE96CD13A5F5A5226 /* [CP] Check Pods Manifest.lock */,
+ 95F7BA6D16EB3A1B27BAA786 /* [CP] Check Pods Manifest.lock */,
00E356EA1AD99517003FC87E /* Sources */,
00E356EB1AD99517003FC87E /* Frameworks */,
00E356EC1AD99517003FC87E /* Resources */,
- E49E02522FA800C59F2F014D /* [CP] Embed Pods Frameworks */,
- C3AB99BB8D1CD10850476367 /* [CP] Copy Pods Resources */,
+ 911E369A1C6D85907F7F0302 /* [CP] Embed Pods Frameworks */,
+ E7F2776DE3547BE1502BC947 /* [CP] Copy Pods Resources */,
);
buildRules = (
);
@@ -4805,7 +4805,7 @@
isa = PBXNativeTarget;
buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "Quiet" */;
buildPhases = (
- 626C6584D894673F46A360BF /* [CP] Check Pods Manifest.lock */,
+ BB9294C6673D9CCB22CBA0B8 /* [CP] Check Pods Manifest.lock */,
FD10A7F022414F080027D42C /* Start Packager */,
13B07F871A680F5B00A75B9A /* Sources */,
13B07F8C1A680F5B00A75B9A /* Frameworks */,
@@ -4817,8 +4817,8 @@
18FD2A32296D736300A2B8C0 /* [CUSTOM NODEJS MOBILE] Remove Python3 Binaries */,
1827A9E0297837FE00245FD3 /* [CUSTOM NODEJS MOBILE] Remove prebuilds */,
1868C095292F8FE2001D6D5E /* Embed Frameworks */,
- D141249F8994850B1DC7FF46 /* [CP] Embed Pods Frameworks */,
- 51FD2183E5DD9C7BD49270BD /* [CP] Copy Pods Resources */,
+ 85BBD2CD345055BC7227A72B /* [CP] Embed Pods Frameworks */,
+ 00462B5D3156F56526040199 /* [CP] Copy Pods Resources */,
);
buildRules = (
);
@@ -4915,6 +4915,23 @@
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
+ 00462B5D3156F56526040199 /* [CP] Copy Pods Resources */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputFileListPaths = (
+ "${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-resources-${CONFIGURATION}-input-files.xcfilelist",
+ );
+ name = "[CP] Copy Pods Resources";
+ outputFileListPaths = (
+ "${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-resources-${CONFIGURATION}-output-files.xcfilelist",
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-resources.sh\"\n";
+ showEnvVarsInLog = 0;
+ };
00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
@@ -5023,46 +5040,41 @@
shellPath = /bin/sh;
shellScript = "find \"$CODESIGNING_FOLDER_PATH/nodejs-project/node_modules/\" -name \"python3\" | xargs rm\n";
};
- 51FD2183E5DD9C7BD49270BD /* [CP] Copy Pods Resources */ = {
+ 85BBD2CD345055BC7227A72B /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
- "${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-resources-${CONFIGURATION}-input-files.xcfilelist",
+ "${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
- name = "[CP] Copy Pods Resources";
+ name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
- "${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-resources-${CONFIGURATION}-output-files.xcfilelist",
+ "${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
- shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-resources.sh\"\n";
+ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
- 626C6584D894673F46A360BF /* [CP] Check Pods Manifest.lock */ = {
+ 911E369A1C6D85907F7F0302 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
+ "${PODS_ROOT}/Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
- inputPaths = (
- "${PODS_PODFILE_DIR_PATH}/Podfile.lock",
- "${PODS_ROOT}/Manifest.lock",
- );
- name = "[CP] Check Pods Manifest.lock";
+ name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
- );
- outputPaths = (
- "$(DERIVED_FILE_DIR)/Pods-Quiet-checkManifestLockResult.txt",
+ "${PODS_ROOT}/Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
- shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
+ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
- 69006B3CE96CD13A5F5A5226 /* [CP] Check Pods Manifest.lock */ = {
+ 95F7BA6D16EB3A1B27BAA786 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
@@ -5084,55 +5096,43 @@
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
- C3AB99BB8D1CD10850476367 /* [CP] Copy Pods Resources */ = {
+ BB9294C6673D9CCB22CBA0B8 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
- "${PODS_ROOT}/Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests-resources-${CONFIGURATION}-input-files.xcfilelist",
- );
- name = "[CP] Copy Pods Resources";
- outputFileListPaths = (
- "${PODS_ROOT}/Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests-resources-${CONFIGURATION}-output-files.xcfilelist",
- );
- runOnlyForDeploymentPostprocessing = 0;
- shellPath = /bin/sh;
- shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests-resources.sh\"\n";
- showEnvVarsInLog = 0;
- };
- D141249F8994850B1DC7FF46 /* [CP] Embed Pods Frameworks */ = {
- isa = PBXShellScriptBuildPhase;
- buildActionMask = 2147483647;
- files = (
);
- inputFileListPaths = (
- "${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-frameworks-${CONFIGURATION}-input-files.xcfilelist",
+ inputPaths = (
+ "${PODS_PODFILE_DIR_PATH}/Podfile.lock",
+ "${PODS_ROOT}/Manifest.lock",
);
- name = "[CP] Embed Pods Frameworks";
+ name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
- "${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-frameworks-${CONFIGURATION}-output-files.xcfilelist",
+ );
+ outputPaths = (
+ "$(DERIVED_FILE_DIR)/Pods-Quiet-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
- shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-frameworks.sh\"\n";
+ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
- E49E02522FA800C59F2F014D /* [CP] Embed Pods Frameworks */ = {
+ E7F2776DE3547BE1502BC947 /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
- "${PODS_ROOT}/Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests-frameworks-${CONFIGURATION}-input-files.xcfilelist",
+ "${PODS_ROOT}/Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests-resources-${CONFIGURATION}-input-files.xcfilelist",
);
- name = "[CP] Embed Pods Frameworks";
+ name = "[CP] Copy Pods Resources";
outputFileListPaths = (
- "${PODS_ROOT}/Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests-frameworks-${CONFIGURATION}-output-files.xcfilelist",
+ "${PODS_ROOT}/Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests-resources-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
- shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests-frameworks.sh\"\n";
+ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests-resources.sh\"\n";
showEnvVarsInLog = 0;
};
FD10A7F022414F080027D42C /* Start Packager */ = {
@@ -5199,7 +5199,7 @@
/* Begin XCBuildConfiguration section */
00E356F61AD99517003FC87E /* Debug */ = {
isa = XCBuildConfiguration;
- baseConfigurationReference = FB3F0E6671064CAF2C159B5D /* Pods-Quiet-QuietTests.debug.xcconfig */;
+ baseConfigurationReference = 70BDAC54548DB3505251D3DB /* Pods-Quiet-QuietTests.debug.xcconfig */;
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
BUNDLE_LOADER = "$(TEST_HOST)";
@@ -5232,7 +5232,7 @@
};
00E356F71AD99517003FC87E /* Release */ = {
isa = XCBuildConfiguration;
- baseConfigurationReference = A5B75BBA2D210014081EFE0C /* Pods-Quiet-QuietTests.release.xcconfig */;
+ baseConfigurationReference = 88972EF0A58F6401DC66730D /* Pods-Quiet-QuietTests.release.xcconfig */;
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
BUNDLE_LOADER = "$(TEST_HOST)";
@@ -5262,7 +5262,7 @@
};
13B07F941A680F5B00A75B9A /* Debug */ = {
isa = XCBuildConfiguration;
- baseConfigurationReference = A0A640C5FB25A885E7EA3D28 /* Pods-Quiet.debug.xcconfig */;
+ baseConfigurationReference = 2F63F73503B4A62481768267 /* Pods-Quiet.debug.xcconfig */;
buildSettings = {
ARCHS = "$(ARCHS_STANDARD)";
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
@@ -5363,7 +5363,7 @@
};
13B07F951A680F5B00A75B9A /* Release */ = {
isa = XCBuildConfiguration;
- baseConfigurationReference = C7B1A71CADBC729F3E550DF1 /* Pods-Quiet.release.xcconfig */;
+ baseConfigurationReference = 677CCEF15F36760C73BC26A6 /* Pods-Quiet.release.xcconfig */;
buildSettings = {
ARCHS = "$(ARCHS_STANDARD)";
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
diff --git a/packages/mobile/src/store/keys/keys.selectors..ts b/packages/mobile/src/store/keys/keys.selectors..ts
index e6016ba4c7..647e429649 100644
--- a/packages/mobile/src/store/keys/keys.selectors..ts
+++ b/packages/mobile/src/store/keys/keys.selectors..ts
@@ -1,15 +1,6 @@
-import { createSelector } from 'reselect'
import { StoreKeys } from '../store.keys'
import { CreatedSelectors, StoreState } from '../store.types'
const keysSlice: CreatedSelectors[StoreKeys.Keys] = (state: StoreState) => state[StoreKeys.Keys]
-export const allKeys = createSelector(keysSlice, state => ({
- secretKeys: state.secretKeys,
- userPublicKeys: state.userPublicKeys,
- sigKeys: state.sigKeys,
-}))
-
-export const keysSelectors = {
- allKeys,
-}
+export const keysSelectors = {}
diff --git a/packages/mobile/src/store/keys/keys.slice.ts b/packages/mobile/src/store/keys/keys.slice.ts
index c69ef0ec9b..884b147bcf 100644
--- a/packages/mobile/src/store/keys/keys.slice.ts
+++ b/packages/mobile/src/store/keys/keys.slice.ts
@@ -1,25 +1,16 @@
import { createSlice, type PayloadAction } from '@reduxjs/toolkit'
import { StoreKeys } from '../store.keys'
-import { KeysUpdatedEvent, KeyWithMetadata } from '@quiet/types'
+import { KeysUpdatedEvent } from '@quiet/types'
import { createLogger } from '../../utils/logger'
const logger = createLogger('keysSlice')
-export class KeysState {
- public secretKeys: KeyWithMetadata[] = []
- public userPublicKeys: KeyWithMetadata[] = []
- public sigKeys: KeyWithMetadata[] = []
-}
+export class KeysState {}
export const keysSlice = createSlice({
initialState: { ...new KeysState() },
name: StoreKeys.Keys,
reducers: {
- setKeys: (state, action: PayloadAction) => {
- state.secretKeys = action.payload.secretKeys
- state.sigKeys = action.payload.sigKeys
- state.userPublicKeys = action.payload.userPublicKeys
- },
saveKeysInKeychain: (state, _action: PayloadAction) => state,
},
})
diff --git a/packages/mobile/src/store/keys/saveKeysInKeychain/saveKeysInKeychain.saga.ts b/packages/mobile/src/store/keys/saveKeysInKeychain/saveKeysInKeychain.saga.ts
index 3e34095ef3..ae94020c36 100644
--- a/packages/mobile/src/store/keys/saveKeysInKeychain/saveKeysInKeychain.saga.ts
+++ b/packages/mobile/src/store/keys/saveKeysInKeychain/saveKeysInKeychain.saga.ts
@@ -2,8 +2,6 @@ import { type PayloadAction } from '@reduxjs/toolkit'
import { call, select, put } from 'typed-redux-saga'
import { KeysUpdatedEvent } from '@quiet/types'
import { createLogger } from '../../../utils/logger'
-import { keysActions } from '../keys.slice'
-import { keysSelectors } from '../keys.selectors.'
import _ from 'lodash'
import { NativeModules } from 'react-native'
@@ -12,48 +10,11 @@ import { StorableKey } from '../keys.type'
const logger = createLogger('saveKeysInKeychainSaga')
export function* saveKeysInKeychainSaga(action: PayloadAction): Generator {
- logger.info('Storing keys in ios keychain')
- const existingKeys = yield* select(keysSelectors.allKeys)
- const newSecretKeys = _.differenceBy(action.payload.secretKeys, existingKeys.secretKeys, 'key')
- const newUserPublicKeys = _.differenceBy(action.payload.userPublicKeys, existingKeys.userPublicKeys, 'key')
- const newSigKeys = _.differenceBy(action.payload.sigKeys, existingKeys.sigKeys, 'key')
- logger.info('Updating keys state')
- yield* put(keysActions.setKeys(action.payload))
-
- const keysToSave: StorableKey[] = newSecretKeys.map(keyWithMetadata => ({
- scope: {
- ...keyWithMetadata.scope,
- keyType: 'secret',
- },
- key: keyWithMetadata.key,
- teamId: action.payload.teamId,
- }))
- keysToSave.push(
- ...newUserPublicKeys.map(keyWithMetadata => ({
- scope: {
- ...keyWithMetadata.scope,
- keyType: 'userPublic',
- },
- key: keyWithMetadata.key,
- teamId: action.payload.teamId,
- }))
- )
- keysToSave.push(
- ...newSigKeys.map(keyWithMetadata => ({
- scope: {
- ...keyWithMetadata.scope,
- keyType: 'userSig',
- },
- key: keyWithMetadata.key,
- teamId: action.payload.teamId,
- }))
- )
-
- logger.info('Putting new keys in keychain', keysToSave)
+ logger.info('Storing keys in ios keychain', action.payload.keys)
try {
yield* call(
NativeModules.CommunicationModule.saveKeysInKeychain,
- keysToSave.map(key => JSON.stringify(key))
+ action.payload.keys.map(key => JSON.stringify(key))
)
} catch (e) {
logger.error('Error while updating keys on keychain', e)
diff --git a/packages/types/src/keys.ts b/packages/types/src/keys.ts
index c4e78fcc98..7939aac759 100644
--- a/packages/types/src/keys.ts
+++ b/packages/types/src/keys.ts
@@ -1,13 +1,10 @@
import { Base58, KeyMetadata } from '@localfirst/crdx'
-export interface KeyWithMetadata {
- scope: KeyMetadata
+export interface StorableKey {
+ keyName: string
key: string | Base58
}
export interface KeysUpdatedEvent {
- secretKeys: KeyWithMetadata[]
- userPublicKeys: KeyWithMetadata[]
- sigKeys: KeyWithMetadata[]
- teamId: string
+ keys: StorableKey[]
}
From 12d20d2e6623c992689087b442e6d0a243f57b99 Mon Sep 17 00:00:00 2001
From: Isla <5048549+islathehut@users.noreply.github.com>
Date: Thu, 5 Mar 2026 10:51:42 -0500
Subject: [PATCH 05/92] Add comments
---
.../backend/src/nest/auth/sigchain.service.ts | 18 ++++++++++++++++--
.../src/nest/local-db/local-db.service.ts | 12 ++++++++++++
.../saveKeysInKeychain.saga.ts | 8 +++-----
3 files changed, 31 insertions(+), 7 deletions(-)
diff --git a/packages/backend/src/nest/auth/sigchain.service.ts b/packages/backend/src/nest/auth/sigchain.service.ts
index 7870aa3d67..bed41eff38 100644
--- a/packages/backend/src/nest/auth/sigchain.service.ts
+++ b/packages/backend/src/nest/auth/sigchain.service.ts
@@ -151,6 +151,9 @@ export class SigChainService extends EventEmitter {
this.logger.info('Chain updated, emitted updated event')
}
+ /**
+ * Send updated list of users to the state manager on chain update
+ */
private _updateUsersOnChainUpdate() {
const users = this.getActiveChain()
.team?.members()
@@ -163,8 +166,10 @@ export class SigChainService extends EventEmitter {
this.serverIoProvider.io.emit(SocketEvents.USERS_UPDATED, { users })
}
- // TODO: only fetch keys that have been updated recently
- private async _updateKeysOnChainUpdate() {
+ /**
+ * Update the IOS keychain with any new keys on chain update
+ */
+ private async _updateKeysOnChainUpdate(): Promise {
if ((process.platform as string) !== 'ios') {
this.logger.trace('Skipping key update because we are not on ios, current platform =', process.platform)
return
@@ -178,6 +183,7 @@ export class SigChainService extends EventEmitter {
const alreadySentKeys: Set = new Set(await this.localDbService.getKeysStoredInKeychain(teamId))
const keysToSend: StorableKey[] = []
const keyNamesSent: string[] = []
+ // get all secret keys that this user has that haven't been added to the keychain
const allKeys = this.getActiveChain().crypto.getAllKeys()
for (const keyData of Object.values(allKeys)) {
for (const keyTypeData of Object.values(keyData)) {
@@ -195,6 +201,7 @@ export class SigChainService extends EventEmitter {
}
}
// TODO: update to pull all generations of user public/sig keys
+ // get all user public keys that haven't been added to the keychain
const allUserPublicKeys = this.getActiveChain().crypto.getPublicKeysForAllMembers(true)
for (const keySet of allUserPublicKeys) {
const publicKeyName = generateKeyName(teamId, StoredKeyType.USER_PUBLIC, {
@@ -217,6 +224,13 @@ export class SigChainService extends EventEmitter {
keyNamesSent.push(sigKeyName)
}
}
+
+ if (keysToSend.length === 0) {
+ this.logger.trace('Skipping IOS keychain update, no new keys')
+ return
+ }
+
+ // send new keys to the state manager to add to the keychain and update list of key names in local DB
const keyUpdateEvent: KeysUpdatedEvent = {
keys: keysToSend,
}
diff --git a/packages/backend/src/nest/local-db/local-db.service.ts b/packages/backend/src/nest/local-db/local-db.service.ts
index 040c0af71d..d1209bcb12 100644
--- a/packages/backend/src/nest/local-db/local-db.service.ts
+++ b/packages/backend/src/nest/local-db/local-db.service.ts
@@ -606,6 +606,12 @@ export class LocalDbService extends EventEmitter {
return count
}
+ /**
+ * Update list of kys for a given team ID that were stored in the IOS keychain
+ *
+ * @param teamId LFA team ID
+ * @param keyNames Names of keys that were added to IOS keychain
+ */
public async updateKeysStoredInKeychain(teamId: string, keyNames: string[]): Promise {
const key = `${LocalDBKeys.KEYS_STORED_KEYCHAIN}:${teamId}`
const arr: string[] = (await this.get(key)) || []
@@ -613,6 +619,12 @@ export class LocalDbService extends EventEmitter {
await this.put(key, arr)
}
+ /**
+ * Get the list of key names for a given team ID that have been stored in the IOS keychain
+ *
+ * @param teamId LFA team ID
+ * @returns List of key names
+ */
public async getKeysStoredInKeychain(teamId: string): Promise {
const key = `${LocalDBKeys.KEYS_STORED_KEYCHAIN}:${teamId}`
const arr: string[] = (await this.get(key)) || []
diff --git a/packages/mobile/src/store/keys/saveKeysInKeychain/saveKeysInKeychain.saga.ts b/packages/mobile/src/store/keys/saveKeysInKeychain/saveKeysInKeychain.saga.ts
index ae94020c36..45c6465713 100644
--- a/packages/mobile/src/store/keys/saveKeysInKeychain/saveKeysInKeychain.saga.ts
+++ b/packages/mobile/src/store/keys/saveKeysInKeychain/saveKeysInKeychain.saga.ts
@@ -1,12 +1,10 @@
import { type PayloadAction } from '@reduxjs/toolkit'
-import { call, select, put } from 'typed-redux-saga'
+import { call } from 'typed-redux-saga'
+import { NativeModules } from 'react-native'
+
import { KeysUpdatedEvent } from '@quiet/types'
import { createLogger } from '../../../utils/logger'
-import _ from 'lodash'
-import { NativeModules } from 'react-native'
-import { StorableKey } from '../keys.type'
-
const logger = createLogger('saveKeysInKeychainSaga')
export function* saveKeysInKeychainSaga(action: PayloadAction): Generator {
From c4a2a23faa2b025ae8faf1888c29ec93d7c4a974 Mon Sep 17 00:00:00 2001
From: Isla <5048549+islathehut@users.noreply.github.com>
Date: Thu, 5 Mar 2026 10:53:26 -0500
Subject: [PATCH 06/92] Update CommunicationModule.swift
---
packages/mobile/ios/CommunicationModule.swift | 1 +
1 file changed, 1 insertion(+)
diff --git a/packages/mobile/ios/CommunicationModule.swift b/packages/mobile/ios/CommunicationModule.swift
index daa596255b..09c7b62330 100644
--- a/packages/mobile/ios/CommunicationModule.swift
+++ b/packages/mobile/ios/CommunicationModule.swift
@@ -72,6 +72,7 @@ class CommunicationModule: RCTEventEmitter {
let stored = try self.keychainHandler.getLfaKeyString(keyName: decodedNamedKey.keyName)
CommunicationModule.logger.info("Stored key matches? \(stored == decodedNamedKey.key) \(decodedNamedKey.keyName)")
} catch {
+ // TODO: send a message to the backend with any keys that weren't stored
CommunicationModule.logger.error("Error while saving key in keychain: \(error)")
}
}
From 62978afaa735e2f851af5b1e382d8b21a1fc3b9a Mon Sep 17 00:00:00 2001
From: Isla <5048549+islathehut@users.noreply.github.com>
Date: Thu, 5 Mar 2026 10:59:04 -0500
Subject: [PATCH 07/92] Update KeychainHandler.swift
---
packages/mobile/ios/KeychainHandler.swift | 52 +----------------------
1 file changed, 1 insertion(+), 51 deletions(-)
diff --git a/packages/mobile/ios/KeychainHandler.swift b/packages/mobile/ios/KeychainHandler.swift
index 3b86f1e5e9..d478d84948 100644
--- a/packages/mobile/ios/KeychainHandler.swift
+++ b/packages/mobile/ios/KeychainHandler.swift
@@ -38,29 +38,13 @@ public struct NamedKey: Codable {
let key: String
}
+// TODO: add string to key object conversion (e.g. string to SymmetricKey)
@objc(KeychainHandler)
class KeychainHandler: NSObject {
- private let masterKeyName: String = "quiet_master_key"
private let keychainGroupName: String = "com.quietmobile"
private static let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "KeychainHandler")
- public func getMasterKey() throws -> SymmetricKey {
- do {
- let password: String = try _getKeyImpl(keyName: masterKeyName)
- let passwordBytes: ContiguousBytes = try _stringToBytes(str: password)
- return SymmetricKey(data: passwordBytes)
- } catch KeychainError.noPassword {
- throw KeychainHandlerError.noKeyFound
- } catch KeychainError.unexpectedPasswordData {
- throw KeychainHandlerError.malformedKey
- } catch ConversionError.stringToBytesError {
- throw KeychainHandlerError.malformedKey
- } catch {
- throw KeychainHandlerError.unhandledError(reason: error)
- }
- }
-
public func getLfaKeyString(keyName: String) throws -> String {
do {
let password: String = try _getKeyImpl(keyName: keyName)
@@ -76,28 +60,6 @@ class KeychainHandler: NSObject {
}
}
- public func createMasterKey() throws -> SymmetricKey {
- var existingKey: SymmetricKey?
- do {
- existingKey = try getMasterKey()
- } catch KeychainHandlerError.noKeyFound {
- existingKey = nil
- } catch {
- throw error
- }
-
- guard existingKey == nil else { return existingKey! }
- do {
- let newKey: SymmetricKey = _generateAESKey()
- let keyData: Data = _symmetricKeyToData(key: newKey)
- let addStatus: KeyAddStatus = try _addKeyToKeychainImpl(keyName: masterKeyName, keyData: keyData)
- guard addStatus == KeyAddStatus.success else { throw KeychainHandlerError.unhandledError(reason: addStatus) }
- return newKey
- } catch {
- throw KeychainHandlerError.unhandledError(reason: error)
- }
- }
-
public func addLfaKey(namedKey: NamedKey) throws -> KeyAddStatus {
var existingKey: String?
do {
@@ -166,21 +128,9 @@ class KeychainHandler: NSObject {
}
}
- private func _generateAESKey() -> SymmetricKey {
- let key: SymmetricKey = SymmetricKey(size: .bits256)
- return key
- }
-
private func _stringToBytes(str: String) throws -> Data {
let bytes: Data? = str.data(using: .utf8)
guard bytes != nil else { throw ConversionError.stringToBytesError }
return bytes!
}
-
- private func _symmetricKeyToData(key: SymmetricKey) -> Data {
- let keyData: Data = key.withUnsafeBytes { body in
- Data(body)
- }
- return keyData
- }
}
From 10f5549956156280b7c1452e398cad86f4fab418 Mon Sep 17 00:00:00 2001
From: Isla <5048549+islathehut@users.noreply.github.com>
Date: Thu, 5 Mar 2026 11:00:32 -0500
Subject: [PATCH 08/92] Update CHANGELOG.md
---
CHANGELOG.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b875a72d32..9c85856f40 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,6 +14,7 @@
* Self-assign the member role when joining with QSS [#3058](https://github.com/TryQuiet/quiet/issues/3058)
* Use LFA-based identity in OrbitDB
* Requests iOS notification permissions when app launches [#3079](https://github.com/TryQuiet/quiet/issues/3079)
+* Store LFA keys in IOS keychain for notifications [#3091](https://github.com/TryQuiet/quiet/issues/3091)
### Fixes
From dcc4bfcb86cbd0db17a5a7afd75394d6d8fd1304 Mon Sep 17 00:00:00 2001
From: Isla <5048549+islathehut@users.noreply.github.com>
Date: Thu, 5 Mar 2026 11:42:23 -0500
Subject: [PATCH 09/92] Pass actual team name since updates can happen when
creating a chain before actually setting it active
---
.../backend/src/nest/auth/sigchain.service.ts | 47 +++++++++++--------
packages/backend/src/nest/qss/qss.service.ts | 8 ++--
2 files changed, 32 insertions(+), 23 deletions(-)
diff --git a/packages/backend/src/nest/auth/sigchain.service.ts b/packages/backend/src/nest/auth/sigchain.service.ts
index bed41eff38..cdaacd35ee 100644
--- a/packages/backend/src/nest/auth/sigchain.service.ts
+++ b/packages/backend/src/nest/auth/sigchain.service.ts
@@ -14,7 +14,6 @@ import {
import { KeyMetadata } from '@localfirst/crdx'
import { LocalDbService } from '../local-db/local-db.service'
import { createLogger } from '../common/logger'
-import { SocketService } from '../socket/socket.service'
import { SocketEvents, StorableKey, User } from '@quiet/types'
import { type RoleService } from './services/roles/role.service'
import { type DeviceService } from './services/members/device.service'
@@ -33,12 +32,10 @@ export class SigChainService extends EventEmitter {
private readonly logger = createLogger(SigChainService.name)
private chains: Map = new Map()
public connections: Map = new Map()
- private lastUpdatedLink: Hash
constructor(
@Inject(SERVER_IO_PROVIDER) public readonly serverIoProvider: ServerIoProviderTypes,
- private readonly localDbService: LocalDbService,
- private readonly socketService: SocketService
+ private readonly localDbService: LocalDbService
) {
super()
}
@@ -143,19 +140,19 @@ export class SigChainService extends EventEmitter {
this.attachSocketListeners(this.getChain({ teamName }))
}
- private handleChainUpdate = () => {
- this._updateUsersOnChainUpdate()
- this._updateKeysOnChainUpdate()
- this.emit('updated')
- this.saveChain(this.activeChainTeamName!)
+ private handleChainUpdate = (teamName: string) => {
+ this._updateUsersOnChainUpdate(teamName)
+ this._updateKeysOnChainUpdate(teamName)
+ this.emit('updated', teamName)
+ this.saveChain(teamName)
this.logger.info('Chain updated, emitted updated event')
}
/**
* Send updated list of users to the state manager on chain update
*/
- private _updateUsersOnChainUpdate() {
- const users = this.getActiveChain()
+ private _updateUsersOnChainUpdate(teamName: string) {
+ const users = this.getChain({ teamName })
.team?.members()
.map(user => ({
userId: user.userId,
@@ -169,7 +166,7 @@ export class SigChainService extends EventEmitter {
/**
* Update the IOS keychain with any new keys on chain update
*/
- private async _updateKeysOnChainUpdate(): Promise {
+ private async _updateKeysOnChainUpdate(teamName: string): Promise {
if ((process.platform as string) !== 'ios') {
this.logger.trace('Skipping key update because we are not on ios, current platform =', process.platform)
return
@@ -179,12 +176,18 @@ export class SigChainService extends EventEmitter {
return `quiet_${teamId}_${scope.type}_${scope.name}_${scope.generation}_${keyType}`
}
- const teamId = this.activeChain.team!.id
+ const sigchain = this.getChain({ teamName })
+ if (sigchain == null) {
+ this.logger.error('No chain for name found', teamName)
+ return
+ }
+
+ const teamId = sigchain.team!.id
const alreadySentKeys: Set = new Set(await this.localDbService.getKeysStoredInKeychain(teamId))
const keysToSend: StorableKey[] = []
const keyNamesSent: string[] = []
// get all secret keys that this user has that haven't been added to the keychain
- const allKeys = this.getActiveChain().crypto.getAllKeys()
+ const allKeys = sigchain.crypto.getAllKeys()
for (const keyData of Object.values(allKeys)) {
for (const keyTypeData of Object.values(keyData)) {
for (const keyTypeGenData of Object.values(keyTypeData)) {
@@ -202,7 +205,7 @@ export class SigChainService extends EventEmitter {
}
// TODO: update to pull all generations of user public/sig keys
// get all user public keys that haven't been added to the keychain
- const allUserPublicKeys = this.getActiveChain().crypto.getPublicKeysForAllMembers(true)
+ const allUserPublicKeys = sigchain.crypto.getPublicKeysForAllMembers(true)
for (const keySet of allUserPublicKeys) {
const publicKeyName = generateKeyName(teamId, StoredKeyType.USER_PUBLIC, {
name: keySet.name,
@@ -230,7 +233,7 @@ export class SigChainService extends EventEmitter {
return
}
- // send new keys to the state manager to add to the keychain and update list of key names in local DB
+ // send new keys to the state manager to add to the keychain and update list of key names in
const keyUpdateEvent: KeysUpdatedEvent = {
keys: keysToSend,
}
@@ -240,12 +243,18 @@ export class SigChainService extends EventEmitter {
private attachSocketListeners(chain: SigChain): void {
this.logger.info('Attaching socket listeners')
- chain.on('updated', this.handleChainUpdate)
+ const _onTeamUpdate = (): void => {
+ this.handleChainUpdate(chain.team!.teamName)
+ }
+ chain.on('updated', _onTeamUpdate)
}
private detachSocketListeners(chain: SigChain): void {
this.logger.info('Detaching socket listeners')
- chain.removeListener('updated', this.handleChainUpdate)
+ const _onTeamUpdate = (): void => {
+ this.handleChainUpdate(chain.team!.teamName)
+ }
+ chain.removeListener('updated', _onTeamUpdate)
}
/**
@@ -299,7 +308,7 @@ export class SigChainService extends EventEmitter {
const sigChain = SigChain.create(teamName, username)
this.addChain(sigChain, setActive, teamName)
await this.saveChain(teamName)
- this.handleChainUpdate()
+ this.handleChainUpdate(teamName)
return sigChain
}
diff --git a/packages/backend/src/nest/qss/qss.service.ts b/packages/backend/src/nest/qss/qss.service.ts
index 9a06a8b120..0a67374320 100644
--- a/packages/backend/src/nest/qss/qss.service.ts
+++ b/packages/backend/src/nest/qss/qss.service.ts
@@ -105,7 +105,7 @@ export class QSSService extends EventEmitter implements OnModuleDestroy, OnModul
this._deadLetterQueueProcessor = setInterval(this.processDeadLetterQueue, 30_000)
this.connect = this.connect.bind(this)
this._configureEventHandlers()
- this.sigChainService.on('updated', () => void this.processDLQDecrypt())
+ this.sigChainService.on('updated', (teamName: string) => void this.processDLQDecrypt(teamName))
}
public onModuleDestroy() {
@@ -907,14 +907,14 @@ export class QSSService extends EventEmitter implements OnModuleDestroy, OnModul
/**
* Process the decryption dead letter queue when sigchain updates (new keys arrive)
*/
- private async processDLQDecrypt(): Promise {
+ private async processDLQDecrypt(teamName: string): Promise {
if (this._dlqDecryptInFlight) {
this.logger.debug('DLQ decrypt already in progress, requesting retry')
this._dlqDecryptRetryRequested = true
return
}
- const activeChain = this.sigChainService.getActiveChain(false)
+ const activeChain = this.sigChainService.getChain({ teamName })
if (!activeChain?.team) {
return
}
@@ -981,7 +981,7 @@ export class QSSService extends EventEmitter implements OnModuleDestroy, OnModul
// If a sigchain update occurred while processing, retry with new keys
if (this._dlqDecryptRetryRequested) {
this.logger.debug('Retrying DLQ decrypt after sigchain update during processing')
- await this.processDLQDecrypt()
+ await this.processDLQDecrypt(teamName)
}
}
From 406ef6b5b5757bd3de0ed5aa20f7bd8d895d9b30 Mon Sep 17 00:00:00 2001
From: Isla <5048549+islathehut@users.noreply.github.com>
Date: Wed, 11 Mar 2026 14:23:18 -0400
Subject: [PATCH 10/92] Move update user profiles to a separate saga
---
.../userProfile/updateUserProfiles.saga.ts | 52 +++++++++++++++++++
.../src/sagas/users/users.master.saga.ts | 2 +
.../src/sagas/users/users.slice.ts | 41 +--------------
3 files changed, 55 insertions(+), 40 deletions(-)
create mode 100644 packages/state-manager/src/sagas/users/userProfile/updateUserProfiles.saga.ts
diff --git a/packages/state-manager/src/sagas/users/userProfile/updateUserProfiles.saga.ts b/packages/state-manager/src/sagas/users/userProfile/updateUserProfiles.saga.ts
new file mode 100644
index 0000000000..0b8cc40197
--- /dev/null
+++ b/packages/state-manager/src/sagas/users/userProfile/updateUserProfiles.saga.ts
@@ -0,0 +1,52 @@
+import { PayloadAction } from '@reduxjs/toolkit'
+import { createLogger } from '../../../utils/logger'
+import { put, select } from 'typed-redux-saga'
+import { userProfileSelectors } from './userProfile.selectors'
+import { UserProfile } from '@quiet/types'
+import { Socket } from '../../../types'
+import { usersActions } from '../users.slice'
+
+const logger = createLogger('updateUserProfilesSaga')
+
+export function* updateUserProfilesSaga(socket: Socket, action: PayloadAction): Generator {
+ logger.info(`Updating user profiles (profile count = ${action.payload.length}`)
+ const userProfiles = yield* select(userProfileSelectors.userProfiles)
+ const updates = { ...userProfiles }
+ for (const userProfile of action.payload) {
+ if (updates[userProfile.userId]) {
+ const existingProfile = updates[userProfile.userId]
+
+ const updatedProfile = {
+ ...existingProfile,
+ ...userProfile,
+ }
+
+ // If CID is the same, preserve the existing path
+ if (
+ userProfile.profilePhoto?.cid &&
+ existingProfile.profilePhoto?.cid === userProfile.profilePhoto.cid &&
+ existingProfile.profilePhoto?.path
+ ) {
+ updatedProfile.profilePhoto = {
+ ...userProfile.profilePhoto,
+ path: existingProfile.profilePhoto.path,
+ }
+ }
+
+ // If CID changed, ensure path is null (it should be null from userProfile anyway, but let's be explicit)
+ if (userProfile.profilePhoto?.cid && existingProfile.profilePhoto?.cid !== userProfile.profilePhoto.cid) {
+ updatedProfile.profilePhoto = {
+ ...userProfile.profilePhoto,
+ path: null,
+ }
+ }
+
+ updates[userProfile.userId] = updatedProfile
+ } else {
+ updates[userProfile.userId] = userProfile
+ }
+ }
+ logger.debug(`Updating user profiles in redux store`)
+ yield* put(usersActions.setUserProfiles(Object.values(updates)))
+ logger.debug(`Done`)
+}
diff --git a/packages/state-manager/src/sagas/users/users.master.saga.ts b/packages/state-manager/src/sagas/users/users.master.saga.ts
index 533dde98e1..cbbe6e0639 100644
--- a/packages/state-manager/src/sagas/users/users.master.saga.ts
+++ b/packages/state-manager/src/sagas/users/users.master.saga.ts
@@ -5,6 +5,7 @@ import { usersActions } from './users.slice'
import { saveUserProfileSaga } from './userProfile/saveUserProfile.saga'
import { downloadProfilePhotosSaga } from './userProfile/downloadProfilePhotos.saga'
import { createLogger } from '../../utils/logger'
+import { updateUserProfilesSaga } from './userProfile/updateUserProfiles.saga'
const logger = createLogger('usersMasterSaga')
@@ -13,6 +14,7 @@ export function* usersMasterSaga(socket: Socket): Generator {
try {
yield all([
takeEvery(usersActions.saveUserProfile.type, saveUserProfileSaga, socket),
+ takeEvery(usersActions.updateUserProfiles.type, updateUserProfilesSaga, socket),
takeEvery(usersActions.updateUserProfiles.type, downloadProfilePhotosSaga),
])
} finally {
diff --git a/packages/state-manager/src/sagas/users/users.slice.ts b/packages/state-manager/src/sagas/users/users.slice.ts
index d253d51f37..54064031b7 100644
--- a/packages/state-manager/src/sagas/users/users.slice.ts
+++ b/packages/state-manager/src/sagas/users/users.slice.ts
@@ -40,46 +40,7 @@ export const usersSlice = createSlice({
}
return state
},
- updateUserProfiles: (state, action: PayloadAction) => {
- if (!state.userProfiles) {
- state.userProfiles = {}
- }
- for (const userProfile of action.payload) {
- if (state.userProfiles[userProfile.userId]) {
- const existingProfile = state.userProfiles[userProfile.userId]
-
- const updatedProfile = {
- ...existingProfile,
- ...userProfile,
- }
-
- // If CID is the same, preserve the existing path
- if (
- userProfile.profilePhoto?.cid &&
- existingProfile.profilePhoto?.cid === userProfile.profilePhoto.cid &&
- existingProfile.profilePhoto?.path
- ) {
- updatedProfile.profilePhoto = {
- ...userProfile.profilePhoto,
- path: existingProfile.profilePhoto.path,
- }
- }
-
- // If CID changed, ensure path is null (it should be null from userProfile anyway, but let's be explicit)
- if (userProfile.profilePhoto?.cid && existingProfile.profilePhoto?.cid !== userProfile.profilePhoto.cid) {
- updatedProfile.profilePhoto = {
- ...userProfile.profilePhoto,
- path: null,
- }
- }
-
- state.userProfiles[userProfile.userId] = updatedProfile
- } else {
- state.userProfiles[userProfile.userId] = userProfile
- }
- }
- return state
- },
+ updateUserProfiles: (state, _action: PayloadAction) => state,
// Sets a single user profile, overwriting the existing one
setUserProfile: (state, action: PayloadAction) => {
// Creating user profiles object for backwards compatibility with 2.0.1
From f9391fcb98ead84a0c75f2982106b37beebbcb34 Mon Sep 17 00:00:00 2001
From: Isla <5048549+islathehut@users.noreply.github.com>
Date: Wed, 11 Mar 2026 17:30:01 -0400
Subject: [PATCH 11/92] Pass updated user profiles to native ios code
---
.../connections-manager.service.ts | 6 +++++
.../backend/src/nest/socket/socket.service.ts | 6 +++++
packages/mobile/ios/CommunicationBridge.m | 1 +
packages/mobile/ios/CommunicationModule.swift | 24 +++++++++++++++++++
.../ios/Quiet.xcodeproj/project.pbxproj | 4 ++++
packages/mobile/ios/UserMetadataHandler.swift | 24 +++++++++++++++++++
.../startConnection/startConnection.saga.ts | 8 ++++++-
packages/mobile/src/store/root.reducer.ts | 2 ++
packages/mobile/src/store/root.saga.ts | 4 +++-
packages/mobile/src/store/store.keys.ts | 1 +
.../saveUserMetadataNatively.saga.ts | 18 ++++++++++++++
.../userMetadata/usersMetadata.master.saga.ts | 19 +++++++++++++++
.../userMetadata/usersMetadata.selectors..ts | 7 ++++++
.../store/userMetadata/usersMetadata.slice.ts | 19 +++++++++++++++
.../userMetadata/usersMetadata.transform.ts | 14 +++++++++++
.../store/userMetadata/usersMetadata.types.ts | 12 ++++++++++
.../startConnection/startConnection.saga.ts | 1 +
.../userProfile/updateUserProfiles.saga.ts | 23 ++++++++++++------
packages/types/src/socket.ts | 5 ++++
packages/types/src/user.ts | 4 ++++
20 files changed, 193 insertions(+), 9 deletions(-)
create mode 100644 packages/mobile/ios/UserMetadataHandler.swift
create mode 100644 packages/mobile/src/store/userMetadata/saveUserMetadataNatively/saveUserMetadataNatively.saga.ts
create mode 100644 packages/mobile/src/store/userMetadata/usersMetadata.master.saga.ts
create mode 100644 packages/mobile/src/store/userMetadata/usersMetadata.selectors..ts
create mode 100644 packages/mobile/src/store/userMetadata/usersMetadata.slice.ts
create mode 100644 packages/mobile/src/store/userMetadata/usersMetadata.transform.ts
create mode 100644 packages/mobile/src/store/userMetadata/usersMetadata.types.ts
diff --git a/packages/backend/src/nest/connections-manager/connections-manager.service.ts b/packages/backend/src/nest/connections-manager/connections-manager.service.ts
index ff03f1a5ec..45aa25c4fc 100644
--- a/packages/backend/src/nest/connections-manager/connections-manager.service.ts
+++ b/packages/backend/src/nest/connections-manager/connections-manager.service.ts
@@ -50,6 +50,7 @@ import {
SetUserProfilePayload,
InvitationData,
SetUserProfileResponse,
+ UserProfilesUpdatedPayload,
} from '@quiet/types'
import { CONFIG_OPTIONS, QSS_ALLOWED, QSS_ENDPOINT, SERVER_IO_PROVIDER, SOCKS_PROXY_AGENT } from '../const'
import { Libp2pService, Libp2pState } from '../libp2p/libp2p.service'
@@ -809,6 +810,11 @@ export class ConnectionsManagerService extends EventEmitter implements OnModuleI
}
)
+ this.socketService.on(SocketActions.USER_PROFILES_UPDATED, (payload: UserProfilesUpdatedPayload) => {
+ this.logger.info(`Forwarding ${SocketActions.USER_PROFILES_UPDATED} back to state manager`)
+ this.serverIoProvider.io.emit(SocketEvents.USER_PROFILES_UPDATED, payload)
+ })
+
this.socketService.on(SocketActions.TOGGLE_P2P, async (payload: boolean, callback: (response: boolean) => void) => {
try {
if (payload) {
diff --git a/packages/backend/src/nest/socket/socket.service.ts b/packages/backend/src/nest/socket/socket.service.ts
index 18506f70d0..aefd7aeaa8 100644
--- a/packages/backend/src/nest/socket/socket.service.ts
+++ b/packages/backend/src/nest/socket/socket.service.ts
@@ -24,6 +24,7 @@ import {
SetUserProfilePayload,
type HCaptchaFormResponse,
InviteResultWithSalt,
+ UserProfilesUpdatedPayload,
} from '@quiet/types'
import EventEmitter from 'events'
import { CONFIG_OPTIONS, SERVER_IO_PROVIDER } from '../const'
@@ -199,6 +200,11 @@ export class SocketService extends EventEmitter implements OnModuleInit {
}
)
+ socket.on(SocketActions.USER_PROFILES_UPDATED, (payload: UserProfilesUpdatedPayload) => {
+ this.logger.info(`Emitting ${SocketActions.USER_PROFILES_UPDATED}`)
+ this.emit(SocketActions.USER_PROFILES_UPDATED, payload)
+ })
+
// ====== Local First Auth ======
socket.on(
diff --git a/packages/mobile/ios/CommunicationBridge.m b/packages/mobile/ios/CommunicationBridge.m
index 5f0e6155ef..9212ba937e 100644
--- a/packages/mobile/ios/CommunicationBridge.m
+++ b/packages/mobile/ios/CommunicationBridge.m
@@ -6,4 +6,5 @@ @interface RCT_EXTERN_MODULE(CommunicationModule, RCTEventEmitter)
RCT_EXTERN_METHOD(requestNotificationPermission)
RCT_EXTERN_METHOD(checkNotificationPermission)
RCT_EXTERN_METHOD(saveKeysInKeychain:(NSArray *)newKeys)
+RCT_EXTERN_METHOD(saveUserMetadata:(NSArray *)updatedMetadata)
@end
diff --git a/packages/mobile/ios/CommunicationModule.swift b/packages/mobile/ios/CommunicationModule.swift
index 09c7b62330..6614ceddcc 100644
--- a/packages/mobile/ios/CommunicationModule.swift
+++ b/packages/mobile/ios/CommunicationModule.swift
@@ -16,6 +16,7 @@ class CommunicationModule: RCTEventEmitter {
private static let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "CommunicationModule")
let keychainHandler = KeychainHandler()
+ let userMetadataHandler = UserMetadataHandler()
@objc
func sendDataPort(port: UInt16, socketIOSecret: String) {
@@ -78,6 +79,29 @@ class CommunicationModule: RCTEventEmitter {
}
}
+ @objc
+ func saveUserMetadata(_ updatedMetadata: NSArray) {
+ let decoder = JSONDecoder()
+ var userMetadata: [UserMetadata] = []
+ for metadataAsAny in updatedMetadata {
+ do {
+ let metadataAsString: String = metadataAsAny as! String
+ let data = Data(metadataAsString.utf8)
+ let decodedMetadata = try decoder.decode(UserMetadata.self, from: data)
+ CommunicationModule.logger.info("Decoded user metadata: \(String(describing: decodedMetadata))")
+ userMetadata.append(decodedMetadata)
+ } catch {
+ CommunicationModule.logger.error("Error while decoding user metadata: \(error)")
+ }
+ }
+
+ do {
+ try self.userMetadataHandler.saveUserMetadata(updatedMetadata: userMetadata)
+ } catch {
+ CommunicationModule.logger.error("Error while saving user metadata: \(error)")
+ }
+ }
+
@objc
func checkNotificationPermission() {
UNUserNotificationCenter.current().getNotificationSettings { settings in
diff --git a/packages/mobile/ios/Quiet.xcodeproj/project.pbxproj b/packages/mobile/ios/Quiet.xcodeproj/project.pbxproj
index e1d226dc60..9260b8638c 100644
--- a/packages/mobile/ios/Quiet.xcodeproj/project.pbxproj
+++ b/packages/mobile/ios/Quiet.xcodeproj/project.pbxproj
@@ -55,6 +55,7 @@
18FD2A3E296F009E00A2B8C0 /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 18FD2A37296F009E00A2B8C0 /* AppDelegate.m */; };
18FD2A3F296F009E00A2B8C0 /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 18FD2A38296F009E00A2B8C0 /* Images.xcassets */; };
18FD2A40296F009E00A2B8C0 /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 18FD2A39296F009E00A2B8C0 /* main.m */; };
+ 663DC8C12F621139005D2086 /* UserMetadataHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 663DC8C02F621134005D2086 /* UserMetadataHandler.swift */; };
665587CA2F4F5ECD005D2086 /* KeychainHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 665587C92F4F5ECD005D2086 /* KeychainHandler.swift */; };
85137B01156B661E82184B11 /* libPods-Quiet.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 287A03B12772D68AAF979A77 /* libPods-Quiet.a */; };
955DC7582BD930B30014725B /* WebsocketSingleton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 955DC7572BD930B30014725B /* WebsocketSingleton.swift */; };
@@ -620,6 +621,7 @@
18FD2A3B296F009E00A2B8C0 /* QuietDebug.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; name = QuietDebug.entitlements; path = Quiet/QuietDebug.entitlements; sourceTree = ""; };
287A03B12772D68AAF979A77 /* libPods-Quiet.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Quiet.a"; sourceTree = BUILT_PRODUCTS_DIR; };
2F63F73503B4A62481768267 /* Pods-Quiet.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Quiet.debug.xcconfig"; path = "Target Support Files/Pods-Quiet/Pods-Quiet.debug.xcconfig"; sourceTree = ""; };
+ 663DC8C02F621134005D2086 /* UserMetadataHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserMetadataHandler.swift; sourceTree = ""; };
665587C92F4F5ECD005D2086 /* KeychainHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainHandler.swift; sourceTree = ""; };
677CCEF15F36760C73BC26A6 /* Pods-Quiet.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Quiet.release.xcconfig"; path = "Target Support Files/Pods-Quiet/Pods-Quiet.release.xcconfig"; sourceTree = ""; };
70BDAC54548DB3505251D3DB /* Pods-Quiet-QuietTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Quiet-QuietTests.debug.xcconfig"; path = "Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests.debug.xcconfig"; sourceTree = ""; };
@@ -672,6 +674,7 @@
13B07FAE1A68108700A75B9A /* Quiet */ = {
isa = PBXGroup;
children = (
+ 663DC8C02F621134005D2086 /* UserMetadataHandler.swift */,
665587C92F4F5ECD005D2086 /* KeychainHandler.swift */,
180E120A2AEFB7F900804659 /* Utils.swift */,
18FD2A36296F009E00A2B8C0 /* AppDelegate.h */,
@@ -5174,6 +5177,7 @@
955DC7582BD930B30014725B /* WebsocketSingleton.swift in Sources */,
1889CA4E26E763E1004ECFBD /* Extensions.swift in Sources */,
1898143A2934CF70001F39E7 /* TorHandler.swift in Sources */,
+ 663DC8C12F621139005D2086 /* UserMetadataHandler.swift in Sources */,
1868BCEE292E9212001D6D5E /* RNNodeJsMobile.m in Sources */,
1868C4382930D7D6001D6D5E /* DataDirectory.swift in Sources */,
1868C43E2930EAEA001D6D5E /* CommunicationBridge.m in Sources */,
diff --git a/packages/mobile/ios/UserMetadataHandler.swift b/packages/mobile/ios/UserMetadataHandler.swift
new file mode 100644
index 0000000000..6e15a410c2
--- /dev/null
+++ b/packages/mobile/ios/UserMetadataHandler.swift
@@ -0,0 +1,24 @@
+//
+// UserMetadataHandler.swift
+// Quiet
+//
+// Created by Isla Koenigsknecht on 2/25/26.
+//
+
+import CoreData
+import OSLog
+
+public struct UserMetadata: Codable {
+ let userId: String
+ let nickname: String
+ let photo: String?
+}
+
+@objc(UserMetadataHandler)
+class UserMetadataHandler: NSObject {
+ private static let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "UserMetadataHandler")
+
+ public func saveUserMetadata(updatedMetadata: [UserMetadata]) throws -> Void {
+ UserMetadataHandler.logger.info("Storing user metadata")
+ }
+}
diff --git a/packages/mobile/src/store/init/startConnection/startConnection.saga.ts b/packages/mobile/src/store/init/startConnection/startConnection.saga.ts
index 6a83e68746..4c12d91f62 100644
--- a/packages/mobile/src/store/init/startConnection/startConnection.saga.ts
+++ b/packages/mobile/src/store/init/startConnection/startConnection.saga.ts
@@ -16,10 +16,11 @@ import { PayloadAction } from '@reduxjs/toolkit'
import { socket as stateManager, Socket } from '@quiet/state-manager'
import { initActions, WebsocketConnectionPayload } from '../init.slice'
import { eventChannel } from 'redux-saga'
-import { KeysUpdatedEvent, SocketActions, SocketEvents } from '@quiet/types'
+import { KeysUpdatedEvent, SocketActions, SocketEvents, UserProfilesUpdatedPayload } from '@quiet/types'
import { createLogger } from '../../../utils/logger'
import { initSelectors } from '../init.selectors'
import { keysActions } from '../../keys/keys.slice'
+import { usersMetadataActions } from '../../userMetadata/usersMetadata.slice'
const logger = createLogger('startConnection')
@@ -80,6 +81,7 @@ function subscribeSocketLifecycle(socket: Socket, socketIOData: WebsocketConnect
| ReturnType
| ReturnType
| ReturnType
+ | ReturnType
>(emit => {
socket.on('connect', async () => {
socket_id = socket.id
@@ -94,6 +96,10 @@ function subscribeSocketLifecycle(socket: Socket, socketIOData: WebsocketConnect
logger.info('Keys updated, writing to keychain')
emit(keysActions.saveKeysInKeychain(payload))
})
+ socket.on(SocketEvents.USER_PROFILES_UPDATED, async (payload: UserProfilesUpdatedPayload) => {
+ logger.info('User profiles updated, saving in ios native storage')
+ emit(usersMetadataActions.saveUserMetadataNatively(payload))
+ })
return () => {}
})
}
diff --git a/packages/mobile/src/store/root.reducer.ts b/packages/mobile/src/store/root.reducer.ts
index 0ffbbbb304..bfc6037d4d 100644
--- a/packages/mobile/src/store/root.reducer.ts
+++ b/packages/mobile/src/store/root.reducer.ts
@@ -6,6 +6,7 @@ import { navigationReducer } from './navigation/navigation.slice'
import { nativeServicesReducer, nativeServicesActions } from './nativeServices/nativeServices.slice'
import { pushNotificationsReducer } from './pushNotifications/pushNotifications.slice'
import { keysReducer } from './keys/keys.slice'
+import { usersMetadataReducer } from './userMetadata/usersMetadata.slice'
export const reducers = {
...stateManagerReducers.reducers,
@@ -14,6 +15,7 @@ export const reducers = {
[StoreKeys.NativeServices]: nativeServicesReducer,
[StoreKeys.PushNotifications]: pushNotificationsReducer,
[StoreKeys.Keys]: keysReducer,
+ [StoreKeys.UsersMetadata]: usersMetadataReducer,
}
export const allReducers = combineReducers(reducers)
diff --git a/packages/mobile/src/store/root.saga.ts b/packages/mobile/src/store/root.saga.ts
index 98a149f6a6..5a818fbf6d 100644
--- a/packages/mobile/src/store/root.saga.ts
+++ b/packages/mobile/src/store/root.saga.ts
@@ -3,13 +3,14 @@ import { nativeServicesMasterSaga } from './nativeServices/nativeServices.master
import { navigationMasterSaga } from './navigation/navigation.master.saga'
import { initMasterSaga } from './init/init.master.saga'
import { initActions } from './init/init.slice'
-import { publicChannels } from '@quiet/state-manager'
+import { publicChannels, Socket } from '@quiet/state-manager'
import { showNotificationSaga } from './nativeServices/showNotification/showNotification.saga'
import { clearReduxStore } from './nativeServices/leaveCommunity/leaveCommunity.saga'
import { pushNotificationsMasterSaga } from './pushNotifications/pushNotifications.master.saga'
import { setEngine, CryptoEngine } from 'pkijs'
import { createLogger } from '../utils/logger'
import { keysMasterSaga } from './keys/keys.master.saga'
+import { usersMetadataMasterSaga } from './userMetadata/usersMetadata.master.saga'
const logger = createLogger('root')
@@ -56,6 +57,7 @@ function* storeReadySaga(): Generator {
fork(nativeServicesMasterSaga),
fork(pushNotificationsMasterSaga),
fork(keysMasterSaga),
+ fork(usersMetadataMasterSaga),
// Below line is reponsible for displaying notifications about messages from channels other than currently viewing one
takeEvery(publicChannels.actions.markUnreadChannel.type, showNotificationSaga),
takeLeading(initActions.canceledRootTask.type, clearReduxStore),
diff --git a/packages/mobile/src/store/store.keys.ts b/packages/mobile/src/store/store.keys.ts
index 9389713392..d015f270f8 100644
--- a/packages/mobile/src/store/store.keys.ts
+++ b/packages/mobile/src/store/store.keys.ts
@@ -4,4 +4,5 @@ export enum StoreKeys {
NativeServices = 'NativeServices',
PushNotifications = 'PushNotifications',
Keys = 'Keys',
+ UsersMetadata = 'UsersMetadata',
}
diff --git a/packages/mobile/src/store/userMetadata/saveUserMetadataNatively/saveUserMetadataNatively.saga.ts b/packages/mobile/src/store/userMetadata/saveUserMetadataNatively/saveUserMetadataNatively.saga.ts
new file mode 100644
index 0000000000..577219221e
--- /dev/null
+++ b/packages/mobile/src/store/userMetadata/saveUserMetadataNatively/saveUserMetadataNatively.saga.ts
@@ -0,0 +1,18 @@
+import { type PayloadAction } from '@reduxjs/toolkit'
+import { call } from 'typed-redux-saga'
+import { NativeModules } from 'react-native'
+
+import { UserProfilesUpdatedPayload } from '@quiet/types'
+import { createLogger } from '../../../utils/logger'
+
+const logger = createLogger('saveUserMetadataNativelySaga')
+
+export function* saveUserMetadataNativelySaga(action: PayloadAction): Generator {
+ logger.info('Storing user metadata in ios native storage', action.payload.profiles)
+ try {
+ const updates: string[] = action.payload.profiles.map(profile => JSON.stringify(profile))
+ yield* call(NativeModules.CommunicationModule.saveUserMetadata, updates)
+ } catch (e) {
+ logger.error('Error while updating user metadata in ios native storage', e)
+ }
+}
diff --git a/packages/mobile/src/store/userMetadata/usersMetadata.master.saga.ts b/packages/mobile/src/store/userMetadata/usersMetadata.master.saga.ts
new file mode 100644
index 0000000000..1be76b3594
--- /dev/null
+++ b/packages/mobile/src/store/userMetadata/usersMetadata.master.saga.ts
@@ -0,0 +1,19 @@
+import { takeEvery, cancelled } from 'redux-saga/effects'
+import { all } from 'typed-redux-saga'
+import { saveUserMetadataNativelySaga } from './saveUserMetadataNatively/saveUserMetadataNatively.saga'
+import { createLogger } from '../../utils/logger'
+import { usersMetadataActions } from './usersMetadata.slice'
+
+const logger = createLogger('usersMetadataMasterSaga')
+
+export function* usersMetadataMasterSaga(): Generator {
+ logger.info('usersMetadataMasterSaga starting')
+ try {
+ yield all([takeEvery(usersMetadataActions.saveUserMetadataNatively.type, saveUserMetadataNativelySaga)])
+ } finally {
+ logger.info('usersMetadataMasterSaga stopping')
+ if (yield cancelled()) {
+ logger.info('usersMetadataMasterSaga cancelled')
+ }
+ }
+}
diff --git a/packages/mobile/src/store/userMetadata/usersMetadata.selectors..ts b/packages/mobile/src/store/userMetadata/usersMetadata.selectors..ts
new file mode 100644
index 0000000000..513827bdda
--- /dev/null
+++ b/packages/mobile/src/store/userMetadata/usersMetadata.selectors..ts
@@ -0,0 +1,7 @@
+import { StoreKeys } from '../store.keys'
+import { CreatedSelectors, StoreState } from '../store.types'
+
+const usersMetadataSlice: CreatedSelectors[StoreKeys.UsersMetadata] = (state: StoreState) =>
+ state[StoreKeys.UsersMetadata]
+
+export const usersMetadataSelectors = {}
diff --git a/packages/mobile/src/store/userMetadata/usersMetadata.slice.ts b/packages/mobile/src/store/userMetadata/usersMetadata.slice.ts
new file mode 100644
index 0000000000..7c415e8c50
--- /dev/null
+++ b/packages/mobile/src/store/userMetadata/usersMetadata.slice.ts
@@ -0,0 +1,19 @@
+import { createSlice, type PayloadAction } from '@reduxjs/toolkit'
+import { StoreKeys } from '../store.keys'
+import { UserProfilesUpdatedPayload } from '@quiet/types'
+import { createLogger } from '../../utils/logger'
+
+const logger = createLogger('keysSlice')
+
+export class UsersMetadataState {}
+
+export const usersMetadataSlice = createSlice({
+ initialState: { ...new UsersMetadataState() },
+ name: StoreKeys.Keys,
+ reducers: {
+ saveUserMetadataNatively: (state, _action: PayloadAction) => state,
+ },
+})
+
+export const usersMetadataActions = usersMetadataSlice.actions
+export const usersMetadataReducer = usersMetadataSlice.reducer
diff --git a/packages/mobile/src/store/userMetadata/usersMetadata.transform.ts b/packages/mobile/src/store/userMetadata/usersMetadata.transform.ts
new file mode 100644
index 0000000000..8bb98ffe53
--- /dev/null
+++ b/packages/mobile/src/store/userMetadata/usersMetadata.transform.ts
@@ -0,0 +1,14 @@
+import { createTransform } from 'redux-persist'
+import { StoreKeys } from '../store.keys'
+import { UsersMetadataState } from './usersMetadata.slice'
+
+export const UsersMetadataTransform = createTransform(
+ (inboundState: UsersMetadataState, _key: any) => {
+ return inboundState
+ },
+ (outboundState: UsersMetadataState, _key: any) => {
+ // TODO: determine if we still need this transform
+ return outboundState
+ },
+ { whitelist: [StoreKeys.UsersMetadata] }
+)
diff --git a/packages/mobile/src/store/userMetadata/usersMetadata.types.ts b/packages/mobile/src/store/userMetadata/usersMetadata.types.ts
new file mode 100644
index 0000000000..5148b443a9
--- /dev/null
+++ b/packages/mobile/src/store/userMetadata/usersMetadata.types.ts
@@ -0,0 +1,12 @@
+export type ExtendedKeyScope = {
+ type: string
+ name: string
+ generation: number
+ keyType: string
+}
+
+export interface StorableKey {
+ scope: ExtendedKeyScope
+ key: string
+ teamId: string
+}
diff --git a/packages/state-manager/src/sagas/socket/startConnection/startConnection.saga.ts b/packages/state-manager/src/sagas/socket/startConnection/startConnection.saga.ts
index 4215fdf693..e988254ddf 100644
--- a/packages/state-manager/src/sagas/socket/startConnection/startConnection.saga.ts
+++ b/packages/state-manager/src/sagas/socket/startConnection/startConnection.saga.ts
@@ -41,6 +41,7 @@ import {
HCaptchaRequest,
HCaptchaChallengeRequest,
InviteResultWithSalt,
+ UserProfilesUpdatedPayload,
} from '@quiet/types'
import { createLogger } from '../../../utils/logger'
diff --git a/packages/state-manager/src/sagas/users/userProfile/updateUserProfiles.saga.ts b/packages/state-manager/src/sagas/users/userProfile/updateUserProfiles.saga.ts
index 0b8cc40197..b46f698d33 100644
--- a/packages/state-manager/src/sagas/users/userProfile/updateUserProfiles.saga.ts
+++ b/packages/state-manager/src/sagas/users/userProfile/updateUserProfiles.saga.ts
@@ -1,15 +1,15 @@
import { PayloadAction } from '@reduxjs/toolkit'
import { createLogger } from '../../../utils/logger'
-import { put, select } from 'typed-redux-saga'
+import { apply, call, put, select } from 'typed-redux-saga'
import { userProfileSelectors } from './userProfile.selectors'
-import { UserProfile } from '@quiet/types'
-import { Socket } from '../../../types'
+import { SocketActions, SocketEvents, SocketEventsMap, UserProfile } from '@quiet/types'
+import { applyEmitParams, Socket } from '../../../types'
import { usersActions } from '../users.slice'
const logger = createLogger('updateUserProfilesSaga')
export function* updateUserProfilesSaga(socket: Socket, action: PayloadAction): Generator {
- logger.info(`Updating user profiles (profile count = ${action.payload.length}`)
+ logger.info(`Updating user profiles (profile count = ${action.payload.length})`)
const userProfiles = yield* select(userProfileSelectors.userProfiles)
const updates = { ...userProfiles }
for (const userProfile of action.payload) {
@@ -46,7 +46,16 @@ export function* updateUserProfilesSaga(socket: Socket, action: PayloadAction void>
[SocketActions.LOAD_MIGRATION_DATA]: EmitEvent>
+ [SocketActions.USER_PROFILES_UPDATED]: EmitEvent
// ====== Local First Auth ======
[SocketActions.VALIDATE_OR_CREATE_LONG_LIVED_LFA_INVITE]: EmitEvent<
@@ -238,6 +242,7 @@ export interface SocketEventsMap {
[SocketEvents.USERS_REMOVED]: EmitEvent
[SocketEvents.USER_PROFILES_STORED]: EmitEvent
[SocketEvents.KEYS_UPDATED]: EmitEvent
+ [SocketEvents.USER_PROFILES_UPDATED]: EmitEvent
// ====== Files ======
[SocketEvents.FILE_ATTACHED]: EmitEvent
diff --git a/packages/types/src/user.ts b/packages/types/src/user.ts
index 4319c7d3af..72197c39ac 100644
--- a/packages/types/src/user.ts
+++ b/packages/types/src/user.ts
@@ -59,6 +59,10 @@ export interface UserProfilesStoredEvent {
profiles: UserProfile[]
}
+export interface UserProfilesUpdatedPayload {
+ profiles: UserProfile[]
+}
+
export interface UsersUpdatedEvent {
users: User[]
}
From 1497029d3223aba2fdec4875eb8721edcdc912a2 Mon Sep 17 00:00:00 2001
From: Isla <5048549+islathehut@users.noreply.github.com>
Date: Fri, 13 Mar 2026 11:13:33 -0400
Subject: [PATCH 12/92] Persist user metadata in native storage
---
packages/mobile/ios/CommunicationModule.swift | 13 +-
packages/mobile/ios/KeychainHandler.swift | 2 +-
packages/mobile/ios/UserMetadataHandler.swift | 173 +++++++++++++++++-
.../saveUserMetadataNatively.saga.ts | 7 +-
packages/state-manager/package-lock.json | 30 ++-
packages/state-manager/package.json | 2 +
.../userProfile/updateUserProfiles.saga.ts | 34 ++--
packages/types/src/user.ts | 3 +-
8 files changed, 233 insertions(+), 31 deletions(-)
diff --git a/packages/mobile/ios/CommunicationModule.swift b/packages/mobile/ios/CommunicationModule.swift
index 6614ceddcc..1c25606062 100644
--- a/packages/mobile/ios/CommunicationModule.swift
+++ b/packages/mobile/ios/CommunicationModule.swift
@@ -82,12 +82,12 @@ class CommunicationModule: RCTEventEmitter {
@objc
func saveUserMetadata(_ updatedMetadata: NSArray) {
let decoder = JSONDecoder()
- var userMetadata: [UserMetadata] = []
+ var userMetadata: [UserMetadataStruct] = []
for metadataAsAny in updatedMetadata {
do {
let metadataAsString: String = metadataAsAny as! String
let data = Data(metadataAsString.utf8)
- let decodedMetadata = try decoder.decode(UserMetadata.self, from: data)
+ let decodedMetadata = try decoder.decode(UserMetadataStruct.self, from: data)
CommunicationModule.logger.info("Decoded user metadata: \(String(describing: decodedMetadata))")
userMetadata.append(decodedMetadata)
} catch {
@@ -100,6 +100,15 @@ class CommunicationModule: RCTEventEmitter {
} catch {
CommunicationModule.logger.error("Error while saving user metadata: \(error)")
}
+
+ for metadata in userMetadata {
+ do {
+ let stored = try self.userMetadataHandler.fetchUserMetadataById(userId: metadata.userId)
+ CommunicationModule.logger.info("Passed: \(String(describing: metadata)), Stored: \(String(describing: stored?.toStruct()))")
+ } catch {
+ // do nothing
+ }
+ }
}
@objc
diff --git a/packages/mobile/ios/KeychainHandler.swift b/packages/mobile/ios/KeychainHandler.swift
index d478d84948..355365f045 100644
--- a/packages/mobile/ios/KeychainHandler.swift
+++ b/packages/mobile/ios/KeychainHandler.swift
@@ -5,7 +5,7 @@
// Created by Isla Koenigsknecht on 2/25/26.
//
-
+import Foundation
import CryptoKit
import Security
import CoreData
diff --git a/packages/mobile/ios/UserMetadataHandler.swift b/packages/mobile/ios/UserMetadataHandler.swift
index 6e15a410c2..fad190915c 100644
--- a/packages/mobile/ios/UserMetadataHandler.swift
+++ b/packages/mobile/ios/UserMetadataHandler.swift
@@ -5,20 +5,185 @@
// Created by Isla Koenigsknecht on 2/25/26.
//
+import Foundation
import CoreData
import OSLog
+import SwiftData
-public struct UserMetadata: Codable {
+public enum UserMetadataError: Error {
+ case missingModelContext
+ case noModelFound(id: String)
+ case incorrectModelCount(expected: Int, actual: Int)
+ case unhandledError(reason: Error)
+}
+
+public struct ProfilePhotoStruct: Codable {
+ let ext: String
+ let path: String?
+ let size: Int
+ let width: Int
+ let height: Int
+}
+
+public struct UserMetadataStruct: Codable {
let userId: String
let nickname: String
- let photo: String?
+ let profilePhoto: ProfilePhotoStruct?
+}
+
+@Model
+class ProfilePhoto: Identifiable {
+ var id: String
+ var ext: String
+ var path: String?
+ var size: Int
+ var width: Int
+ var height: Int
+ var userMetadata: UserMetadata?
+ var createdAt: Date?
+
+ init(id: String, ext: String, path: String?, size: Int, width: Int, height: Int, createdAt: Date?) {
+ self.id = id
+ self.ext = ext
+ self.path = path
+ self.size = size
+ self.width = width
+ self.height = height
+ self.createdAt = createdAt
+ }
+
+ public func toStruct() -> ProfilePhotoStruct {
+ return ProfilePhotoStruct(ext: self.ext, path: self.path, size: self.size, width: self.width, height: self.height)
+ }
+}
+
+@Model
+class UserMetadata: Identifiable {
+ var id: String
+ var nickname: String
+ var createdAt: Date? = nil
+
+ @Relationship(deleteRule: .cascade, inverse: \ProfilePhoto.userMetadata)
+ var profilePhoto: ProfilePhoto?
+
+ init(id: String, nickname: String, profilePhoto: ProfilePhotoStruct?, createdAt: Date?) {
+ var profilePhotoModel: ProfilePhoto? = nil
+ if (profilePhoto != nil) {
+ profilePhotoModel = ProfilePhoto(id: id, ext: profilePhoto!.ext, path: profilePhoto!.path, size: profilePhoto!.size, width: profilePhoto!.width, height: profilePhoto!.height, createdAt: createdAt)
+ }
+ self.id = id
+ self.nickname = nickname
+ self.profilePhoto = profilePhotoModel
+ self.createdAt = createdAt
+ }
+
+ public static func fromStruct(userMetadata: UserMetadataStruct, createdAt: Date?) -> UserMetadata {
+ return UserMetadata(id: userMetadata.userId, nickname: userMetadata.nickname, profilePhoto: userMetadata.profilePhoto, createdAt: createdAt)
+ }
+
+ public func toStruct() -> UserMetadataStruct {
+ return UserMetadataStruct(
+ userId: self.id,
+ nickname: self.nickname,
+ profilePhoto: self.profilePhoto?.toStruct()
+ )
+ }
}
@objc(UserMetadataHandler)
class UserMetadataHandler: NSObject {
private static let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "UserMetadataHandler")
+ private var container: ModelContainer?
+ private var modelContext: ModelContext?
- public func saveUserMetadata(updatedMetadata: [UserMetadata]) throws -> Void {
- UserMetadataHandler.logger.info("Storing user metadata")
+ public func initContainer() throws -> Void {
+ if (self.container != nil) {
+ UserMetadataHandler.logger.debug("Container already initialized, skipping...")
+ return
+ }
+
+ do {
+ self.container = try ModelContainer(for: UserMetadata.self, configurations: .init(isStoredInMemoryOnly: false))
+ self.modelContext = ModelContext(self.container!)
+ } catch {
+ UserMetadataHandler.logger.error("Error while initializing UserMetadata ModelContainer: \(error)")
+ throw error
+ }
+ }
+
+ public func saveUserMetadata(updatedMetadata: [UserMetadataStruct]) throws -> Void {
+ do {
+ try self.initContainer()
+ } catch {
+ throw error
+ }
+
+ guard let context = self.modelContext else {
+ throw UserMetadataError.missingModelContext
+ }
+
+ UserMetadataHandler.logger.info("Inserting user metadata")
+ for metadata in updatedMetadata {
+ UserMetadataHandler.logger.debug("Inserting data: \(String(describing: metadata))")
+ let model = UserMetadata.fromStruct(userMetadata: metadata, createdAt: Date.now)
+ let found = try self.fetchUserMetadataById(userId: model.id)
+ if (found != nil) {
+ UserMetadataHandler.logger.debug("Replacing existing metadata for \(model.id)")
+ try self.deleteUserMetadata(model: model)
+ }
+ context.insert(model)
+ }
+
+ UserMetadataHandler.logger.info("Persisting user metadata")
+ do {
+ try context.save()
+ } catch {
+ UserMetadataHandler.logger.error("Error while persisting UserMetadata model(s) to disk: \(error)")
+ throw KeychainHandlerError.unhandledError(reason: error)
+ }
+ }
+
+ public func fetchUserMetadataById(userId: String) throws -> UserMetadata? {
+ UserMetadataHandler.logger.info("Fetching UserMetadata by ID: \(userId)")
+
+ guard let context = self.modelContext else {
+ throw UserMetadataError.missingModelContext
+ }
+
+ do {
+ let descriptor = FetchDescriptor(
+ predicate: #Predicate { $0.id == userId }
+ )
+ let models = try context.fetch(descriptor)
+ guard models.count > 0 else {
+ return nil
+ }
+ guard models.count == 1 else {
+ for model in models {
+ UserMetadataHandler.logger.warning("Found: \(String(describing: model.toStruct()))")
+ }
+ throw UserMetadataError.incorrectModelCount(expected: 1, actual: models.count)
+ }
+ return models[0]
+ } catch {
+ UserMetadataHandler.logger.error("Error while fetching UserMetadata for ID \(userId): \(error)")
+ throw UserMetadataError.unhandledError(reason: error)
+ }
+ }
+
+ public func deleteUserMetadata(model: UserMetadata) throws -> Void {
+ UserMetadataHandler.logger.info("Deleting UserMetadata record: \(String(describing: model.toStruct()))")
+
+ guard let context = self.modelContext else {
+ throw UserMetadataError.missingModelContext
+ }
+
+ do {
+ context.delete(model)
+ try context.save()
+ } catch {
+ UserMetadataHandler.logger.error("Error while deleting UserMetadata \(String(describing: model.toStruct())): \(error)")
+ throw UserMetadataError.unhandledError(reason: error)
+ }
}
}
diff --git a/packages/mobile/src/store/userMetadata/saveUserMetadataNatively/saveUserMetadataNatively.saga.ts b/packages/mobile/src/store/userMetadata/saveUserMetadataNatively/saveUserMetadataNatively.saga.ts
index 577219221e..dba157954a 100644
--- a/packages/mobile/src/store/userMetadata/saveUserMetadataNatively/saveUserMetadataNatively.saga.ts
+++ b/packages/mobile/src/store/userMetadata/saveUserMetadataNatively/saveUserMetadataNatively.saga.ts
@@ -8,9 +8,12 @@ import { createLogger } from '../../../utils/logger'
const logger = createLogger('saveUserMetadataNativelySaga')
export function* saveUserMetadataNativelySaga(action: PayloadAction): Generator {
- logger.info('Storing user metadata in ios native storage', action.payload.profiles)
+ logger.info('Storing user metadata in ios native storage', action.payload.new, action.payload.updates)
try {
- const updates: string[] = action.payload.profiles.map(profile => JSON.stringify(profile))
+ const updates: string[] = [
+ ...action.payload.new.map(profile => JSON.stringify(profile)),
+ ...action.payload.updates.map(profile => JSON.stringify(profile)),
+ ]
yield* call(NativeModules.CommunicationModule.saveUserMetadata, updates)
} catch (e) {
logger.error('Error while updating user metadata in ios native storage', e)
diff --git a/packages/state-manager/package-lock.json b/packages/state-manager/package-lock.json
index 190d9cfa2d..db39da7acc 100644
--- a/packages/state-manager/package-lock.json
+++ b/packages/state-manager/package-lock.json
@@ -14,6 +14,7 @@
"@reduxjs/toolkit": "^1.9.1",
"factory-girl": "^5.0.4",
"get-port": "^5.1.1",
+ "lodash": "^4.17.23",
"luxon": "^2.0.2",
"redux": "^4.1.1",
"redux-persist": "^6.0.0",
@@ -31,6 +32,7 @@
"@peculiar/webcrypto": "1.4.3",
"@types/factory-girl": "^5.0.12",
"@types/jest": "^26.0.24",
+ "@types/lodash": "^4.17.24",
"@types/luxon": "^2.0.0",
"@types/redux-saga": "^0.10.5",
"babel-jest": "^29.3.1",
@@ -4922,6 +4924,13 @@
"pretty-format": "^26.0.0"
}
},
+ "node_modules/@types/lodash": {
+ "version": "4.17.24",
+ "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz",
+ "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/luxon": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-2.0.0.tgz",
@@ -11378,10 +11387,10 @@
}
},
"node_modules/lodash": {
- "version": "4.17.21",
- "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
- "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
- "dev": true
+ "version": "4.17.23",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
+ "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
+ "license": "MIT"
},
"node_modules/lodash.isequal": {
"version": "4.5.0",
@@ -16579,6 +16588,12 @@
"pretty-format": "^26.0.0"
}
},
+ "@types/lodash": {
+ "version": "4.17.24",
+ "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz",
+ "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==",
+ "dev": true
+ },
"@types/luxon": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-2.0.0.tgz",
@@ -21516,10 +21531,9 @@
}
},
"lodash": {
- "version": "4.17.21",
- "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
- "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
- "dev": true
+ "version": "4.17.23",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
+ "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="
},
"lodash.isequal": {
"version": "4.5.0",
diff --git a/packages/state-manager/package.json b/packages/state-manager/package.json
index a8279f49d1..6b0cb60265 100644
--- a/packages/state-manager/package.json
+++ b/packages/state-manager/package.json
@@ -33,6 +33,7 @@
"@reduxjs/toolkit": "^1.9.1",
"factory-girl": "^5.0.4",
"get-port": "^5.1.1",
+ "lodash": "^4.17.23",
"luxon": "^2.0.2",
"redux": "^4.1.1",
"redux-persist": "^6.0.0",
@@ -54,6 +55,7 @@
"@types/factory-girl": "^5.0.12",
"@quiet/node-common": "^4.0.3",
"@types/jest": "^26.0.24",
+ "@types/lodash": "^4.17.24",
"@types/luxon": "^2.0.0",
"@types/redux-saga": "^0.10.5",
"babel-jest": "^29.3.1",
diff --git a/packages/state-manager/src/sagas/users/userProfile/updateUserProfiles.saga.ts b/packages/state-manager/src/sagas/users/userProfile/updateUserProfiles.saga.ts
index b46f698d33..b29a8e6abc 100644
--- a/packages/state-manager/src/sagas/users/userProfile/updateUserProfiles.saga.ts
+++ b/packages/state-manager/src/sagas/users/userProfile/updateUserProfiles.saga.ts
@@ -2,19 +2,24 @@ import { PayloadAction } from '@reduxjs/toolkit'
import { createLogger } from '../../../utils/logger'
import { apply, call, put, select } from 'typed-redux-saga'
import { userProfileSelectors } from './userProfile.selectors'
-import { SocketActions, SocketEvents, SocketEventsMap, UserProfile } from '@quiet/types'
+import { SocketActions, SocketEvents, SocketEventsMap, UserProfile, UserProfilesUpdatedPayload } from '@quiet/types'
import { applyEmitParams, Socket } from '../../../types'
import { usersActions } from '../users.slice'
+import * as _ from 'lodash'
const logger = createLogger('updateUserProfilesSaga')
export function* updateUserProfilesSaga(socket: Socket, action: PayloadAction): Generator {
logger.info(`Updating user profiles (profile count = ${action.payload.length})`)
- const userProfiles = yield* select(userProfileSelectors.userProfiles)
- const updates = { ...userProfiles }
+ const existingProfiles = yield* select(userProfileSelectors.userProfiles)
+ const output: UserProfilesUpdatedPayload = {
+ new: [],
+ updates: [],
+ }
+ const updates = { ...existingProfiles }
for (const userProfile of action.payload) {
- if (updates[userProfile.userId]) {
- const existingProfile = updates[userProfile.userId]
+ if (existingProfiles[userProfile.userId]) {
+ const existingProfile = existingProfiles[userProfile.userId]
const updatedProfile = {
...existingProfile,
@@ -42,20 +47,23 @@ export function* updateUserProfilesSaga(socket: Socket, action: PayloadAction 0 || output.updates.length > 0) {
+ logger.info(`Emitting user profiles updated event`, output.new, output.updates)
+ yield* apply(socket, socket.emit, applyEmitParams(SocketActions.USER_PROFILES_UPDATED, output))
+ } else {
+ logger.trace('Skipping user profile updated event, no new or updated profiles')
+ }
logger.info(`Done`)
}
diff --git a/packages/types/src/user.ts b/packages/types/src/user.ts
index 72197c39ac..be8bd1da7b 100644
--- a/packages/types/src/user.ts
+++ b/packages/types/src/user.ts
@@ -60,7 +60,8 @@ export interface UserProfilesStoredEvent {
}
export interface UserProfilesUpdatedPayload {
- profiles: UserProfile[]
+ new: UserProfile[]
+ updates: UserProfile[]
}
export interface UsersUpdatedEvent {
From 35cb3c184248e26b7a58d81350b0df056b9b6e0f Mon Sep 17 00:00:00 2001
From: Isla <5048549+islathehut@users.noreply.github.com>
Date: Fri, 13 Mar 2026 13:27:06 -0400
Subject: [PATCH 13/92] Clean up logs and remove testing code
---
packages/mobile/ios/CommunicationModule.swift | 10 -------
packages/mobile/ios/UserMetadataHandler.swift | 28 +++++++++++--------
.../saveUserMetadataNatively.saga.ts | 4 ++-
.../userProfile/updateUserProfiles.saga.ts | 4 +--
4 files changed, 22 insertions(+), 24 deletions(-)
diff --git a/packages/mobile/ios/CommunicationModule.swift b/packages/mobile/ios/CommunicationModule.swift
index 1c25606062..b4eae33805 100644
--- a/packages/mobile/ios/CommunicationModule.swift
+++ b/packages/mobile/ios/CommunicationModule.swift
@@ -88,7 +88,6 @@ class CommunicationModule: RCTEventEmitter {
let metadataAsString: String = metadataAsAny as! String
let data = Data(metadataAsString.utf8)
let decodedMetadata = try decoder.decode(UserMetadataStruct.self, from: data)
- CommunicationModule.logger.info("Decoded user metadata: \(String(describing: decodedMetadata))")
userMetadata.append(decodedMetadata)
} catch {
CommunicationModule.logger.error("Error while decoding user metadata: \(error)")
@@ -100,15 +99,6 @@ class CommunicationModule: RCTEventEmitter {
} catch {
CommunicationModule.logger.error("Error while saving user metadata: \(error)")
}
-
- for metadata in userMetadata {
- do {
- let stored = try self.userMetadataHandler.fetchUserMetadataById(userId: metadata.userId)
- CommunicationModule.logger.info("Passed: \(String(describing: metadata)), Stored: \(String(describing: stored?.toStruct()))")
- } catch {
- // do nothing
- }
- }
}
@objc
diff --git a/packages/mobile/ios/UserMetadataHandler.swift b/packages/mobile/ios/UserMetadataHandler.swift
index fad190915c..0f8141858d 100644
--- a/packages/mobile/ios/UserMetadataHandler.swift
+++ b/packages/mobile/ios/UserMetadataHandler.swift
@@ -52,6 +52,10 @@ class ProfilePhoto: Identifiable {
self.createdAt = createdAt
}
+ public static func fromStruct(id: String, profilePhoto: ProfilePhotoStruct, createdAt: Date?) -> ProfilePhoto {
+ return ProfilePhoto(id: id, ext: profilePhoto.ext, path: profilePhoto.path, size: profilePhoto.size, width: profilePhoto.width, height: profilePhoto.height, createdAt: createdAt)
+ }
+
public func toStruct() -> ProfilePhotoStruct {
return ProfilePhotoStruct(ext: self.ext, path: self.path, size: self.size, width: self.width, height: self.height)
}
@@ -68,9 +72,10 @@ class UserMetadata: Identifiable {
init(id: String, nickname: String, profilePhoto: ProfilePhotoStruct?, createdAt: Date?) {
var profilePhotoModel: ProfilePhoto? = nil
- if (profilePhoto != nil) {
- profilePhotoModel = ProfilePhoto(id: id, ext: profilePhoto!.ext, path: profilePhoto!.path, size: profilePhoto!.size, width: profilePhoto!.width, height: profilePhoto!.height, createdAt: createdAt)
+ if let unwrappedProfilePhoto = profilePhoto {
+ profilePhotoModel = ProfilePhoto.fromStruct(id: id, profilePhoto: unwrappedProfilePhoto, createdAt: createdAt)
}
+
self.id = id
self.nickname = nickname
self.profilePhoto = profilePhotoModel
@@ -124,13 +129,13 @@ class UserMetadataHandler: NSObject {
UserMetadataHandler.logger.info("Inserting user metadata")
for metadata in updatedMetadata {
- UserMetadataHandler.logger.debug("Inserting data: \(String(describing: metadata))")
- let model = UserMetadata.fromStruct(userMetadata: metadata, createdAt: Date.now)
- let found = try self.fetchUserMetadataById(userId: model.id)
- if (found != nil) {
- UserMetadataHandler.logger.debug("Replacing existing metadata for \(model.id)")
- try self.deleteUserMetadata(model: model)
+ UserMetadataHandler.logger.debug("Inserting data for \(metadata.userId)")
+ let found = try self.fetchUserMetadataById(userId: metadata.userId)
+ if let unwrappedFound = found {
+ UserMetadataHandler.logger.debug("Replacing existing metadata for \(metadata.userId)")
+ try self.deleteUserMetadata(model: unwrappedFound)
}
+ let model = UserMetadata.fromStruct(userMetadata: metadata, createdAt: Date.now)
context.insert(model)
}
@@ -159,8 +164,9 @@ class UserMetadataHandler: NSObject {
return nil
}
guard models.count == 1 else {
+ UserMetadataHandler.logger.warning("Found \(models.count) stored metadata records for \(userId)")
for model in models {
- UserMetadataHandler.logger.warning("Found: \(String(describing: model.toStruct()))")
+ UserMetadataHandler.logger.warning("Found \(userId) created at \(model.createdAt?.ISO8601Format() ?? "NO CREATEDAT")")
}
throw UserMetadataError.incorrectModelCount(expected: 1, actual: models.count)
}
@@ -172,7 +178,7 @@ class UserMetadataHandler: NSObject {
}
public func deleteUserMetadata(model: UserMetadata) throws -> Void {
- UserMetadataHandler.logger.info("Deleting UserMetadata record: \(String(describing: model.toStruct()))")
+ UserMetadataHandler.logger.info("Deleting UserMetadata record for \(model.id)")
guard let context = self.modelContext else {
throw UserMetadataError.missingModelContext
@@ -182,7 +188,7 @@ class UserMetadataHandler: NSObject {
context.delete(model)
try context.save()
} catch {
- UserMetadataHandler.logger.error("Error while deleting UserMetadata \(String(describing: model.toStruct())): \(error)")
+ UserMetadataHandler.logger.error("Error while deleting UserMetadata for \(model.id): \(error)")
throw UserMetadataError.unhandledError(reason: error)
}
}
diff --git a/packages/mobile/src/store/userMetadata/saveUserMetadataNatively/saveUserMetadataNatively.saga.ts b/packages/mobile/src/store/userMetadata/saveUserMetadataNatively/saveUserMetadataNatively.saga.ts
index dba157954a..f10b5b9123 100644
--- a/packages/mobile/src/store/userMetadata/saveUserMetadataNatively/saveUserMetadataNatively.saga.ts
+++ b/packages/mobile/src/store/userMetadata/saveUserMetadataNatively/saveUserMetadataNatively.saga.ts
@@ -8,7 +8,9 @@ import { createLogger } from '../../../utils/logger'
const logger = createLogger('saveUserMetadataNativelySaga')
export function* saveUserMetadataNativelySaga(action: PayloadAction): Generator {
- logger.info('Storing user metadata in ios native storage', action.payload.new, action.payload.updates)
+ logger.info(
+ `Storing user metadata in ios native storage (new count = ${action.payload.new.length}, update count = ${action.payload.updates.length})`
+ )
try {
const updates: string[] = [
...action.payload.new.map(profile => JSON.stringify(profile)),
diff --git a/packages/state-manager/src/sagas/users/userProfile/updateUserProfiles.saga.ts b/packages/state-manager/src/sagas/users/userProfile/updateUserProfiles.saga.ts
index b29a8e6abc..1a143ecb0c 100644
--- a/packages/state-manager/src/sagas/users/userProfile/updateUserProfiles.saga.ts
+++ b/packages/state-manager/src/sagas/users/userProfile/updateUserProfiles.saga.ts
@@ -56,11 +56,11 @@ export function* updateUserProfilesSaga(socket: Socket, action: PayloadAction 0 || output.updates.length > 0) {
- logger.info(`Emitting user profiles updated event`, output.new, output.updates)
+ logger.debug(`Emitting user profiles updated event`)
yield* apply(socket, socket.emit, applyEmitParams(SocketActions.USER_PROFILES_UPDATED, output))
} else {
logger.trace('Skipping user profile updated event, no new or updated profiles')
From 71896802a85e3f067bedd16efbeaa0a1d338059b Mon Sep 17 00:00:00 2001
From: Isla <5048549+islathehut@users.noreply.github.com>
Date: Fri, 13 Mar 2026 14:05:26 -0400
Subject: [PATCH 14/92] Fix tests
---
packages/backend/src/nest/qss/qss.service.spec.ts | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/packages/backend/src/nest/qss/qss.service.spec.ts b/packages/backend/src/nest/qss/qss.service.spec.ts
index 7b9e2e451e..31e5dff651 100644
--- a/packages/backend/src/nest/qss/qss.service.spec.ts
+++ b/packages/backend/src/nest/qss/qss.service.spec.ts
@@ -1035,7 +1035,7 @@ describe('QSSService', () => {
const ingestSpy = jest.spyOn(orbitDbService, 'ingestEntries').mockResolvedValue()
// Trigger sigchain update which should process DLQ
- sigchainService.emit('updated')
+ sigchainService.emit('updated', sigchainService.activeChainTeamName)
// Wait for async processing
await waitForExpect(async () => {
@@ -1071,10 +1071,10 @@ describe('QSSService', () => {
const processSpy = jest.spyOn(qssService, 'processDLQDecrypt')
// Trigger first update
- sigchainService.emit('updated')
+ sigchainService.emit('updated', sigchainService.activeChainTeamName)
// Immediately trigger second update while first is processing
- sigchainService.emit('updated')
+ sigchainService.emit('updated', sigchainService.activeChainTeamName)
await waitForExpect(async () => {
const remainingCount = await localDbService.getDLQDecryptCount(teamId)
@@ -1097,7 +1097,7 @@ describe('QSSService', () => {
const ingestSpy = jest.spyOn(orbitDbService, 'ingestEntries')
// Trigger sigchain update
- sigchainService.emit('updated')
+ sigchainService.emit('updated', sigchainService.activeChainTeamName)
// Give it time to process
await new Promise(resolve => setTimeout(resolve, 100))
From 7476ffce68ab8d1fa788a517f5c58ee944fd3e29 Mon Sep 17 00:00:00 2001
From: Isla <5048549+islathehut@users.noreply.github.com>
Date: Fri, 13 Mar 2026 14:07:28 -0400
Subject: [PATCH 15/92] Update CHANGELOG.md
---
CHANGELOG.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 78d6b4c930..5b0bd4db0d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,6 +15,7 @@
* Use LFA-based identity in OrbitDB
* Requests iOS notification permissions when app launches [#3079](https://github.com/TryQuiet/quiet/issues/3079)
* Store LFA keys in IOS keychain for notifications [#3091](https://github.com/TryQuiet/quiet/issues/3091)
+* Store user metadata in IOS native storage for notifications [#3091](https://github.com/TryQuiet/quiet/issues/3091)
### Fixes
From fabb2a78b166ae06db10be510c5400add2f01cf5 Mon Sep 17 00:00:00 2001
From: Isla <5048549+islathehut@users.noreply.github.com>
Date: Fri, 13 Mar 2026 14:15:26 -0400
Subject: [PATCH 16/92] Fix tests
---
packages/backend/src/nest/qss/qss.service.spec.ts | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/packages/backend/src/nest/qss/qss.service.spec.ts b/packages/backend/src/nest/qss/qss.service.spec.ts
index 7b9e2e451e..31e5dff651 100644
--- a/packages/backend/src/nest/qss/qss.service.spec.ts
+++ b/packages/backend/src/nest/qss/qss.service.spec.ts
@@ -1035,7 +1035,7 @@ describe('QSSService', () => {
const ingestSpy = jest.spyOn(orbitDbService, 'ingestEntries').mockResolvedValue()
// Trigger sigchain update which should process DLQ
- sigchainService.emit('updated')
+ sigchainService.emit('updated', sigchainService.activeChainTeamName)
// Wait for async processing
await waitForExpect(async () => {
@@ -1071,10 +1071,10 @@ describe('QSSService', () => {
const processSpy = jest.spyOn(qssService, 'processDLQDecrypt')
// Trigger first update
- sigchainService.emit('updated')
+ sigchainService.emit('updated', sigchainService.activeChainTeamName)
// Immediately trigger second update while first is processing
- sigchainService.emit('updated')
+ sigchainService.emit('updated', sigchainService.activeChainTeamName)
await waitForExpect(async () => {
const remainingCount = await localDbService.getDLQDecryptCount(teamId)
@@ -1097,7 +1097,7 @@ describe('QSSService', () => {
const ingestSpy = jest.spyOn(orbitDbService, 'ingestEntries')
// Trigger sigchain update
- sigchainService.emit('updated')
+ sigchainService.emit('updated', sigchainService.activeChainTeamName)
// Give it time to process
await new Promise(resolve => setTimeout(resolve, 100))
From edc9d9293c43f38f1d133049975b8b5b2c405faf Mon Sep 17 00:00:00 2001
From: Isla <5048549+islathehut@users.noreply.github.com>
Date: Fri, 13 Mar 2026 17:27:11 -0400
Subject: [PATCH 17/92] Update and move updateUserProfiles tests
---
.../updateUserProfiles.saga.test.ts | 186 ++++++++++++++++++
.../src/sagas/users/users.slice.test.ts | 89 ---------
.../src/utils/tests/factories.ts | 32 ++-
packages/types/src/files.ts | 18 +-
4 files changed, 227 insertions(+), 98 deletions(-)
create mode 100644 packages/state-manager/src/sagas/users/userProfile/updateUserProfiles.saga.test.ts
delete mode 100644 packages/state-manager/src/sagas/users/users.slice.test.ts
diff --git a/packages/state-manager/src/sagas/users/userProfile/updateUserProfiles.saga.test.ts b/packages/state-manager/src/sagas/users/userProfile/updateUserProfiles.saga.test.ts
new file mode 100644
index 0000000000..48d176d5be
--- /dev/null
+++ b/packages/state-manager/src/sagas/users/userProfile/updateUserProfiles.saga.test.ts
@@ -0,0 +1,186 @@
+import { expectSaga } from 'redux-saga-test-plan'
+import { type FactoryGirl } from 'factory-girl'
+import { UserProfile, SocketActions, Identity } from '@quiet/types'
+
+import { usersActions } from '../users.slice'
+import { updateUserProfilesSaga } from './updateUserProfiles.saga'
+import { MockedSocket } from '../../../utils/tests/mockedSocket'
+import { Socket } from '../../../types'
+import { Store } from '../../store.types'
+import { prepareStore, testReducers } from '../../../utils/tests/prepareStore'
+import { getBaseTypesFactory } from '../../../utils/tests/factories'
+import { combineReducers } from 'redux'
+import { userProfileSelectors } from './userProfile.selectors'
+import { createLogger } from '../../../utils/logger'
+
+describe('updateUserProfilesSaga', () => {
+ let store: Store
+ let baseTypesFactory: FactoryGirl
+ let socket: MockedSocket
+ let userProfile: UserProfile
+ let userId: string
+
+ const logger = createLogger('updateUserProfilesSaga:test')
+
+ beforeEach(async () => {
+ socket = new MockedSocket()
+ store = prepareStore().store
+ baseTypesFactory = await getBaseTypesFactory()
+
+ userProfile = await baseTypesFactory.create('UserProfile')
+ userId = userProfile.userId
+ })
+
+ it('should clear profilePhoto.path if CID changes', async () => {
+ const newCid = 'new-cid'
+
+ const existingProfiles = {
+ [userId]: userProfile,
+ }
+
+ const updatedProfile: UserProfile = {
+ ...userProfile,
+ profilePhoto: {
+ ...userProfile.profilePhoto!,
+ cid: newCid,
+ path: null,
+ },
+ }
+
+ let userProfilesSelectCalls = 0
+
+ await expectSaga(
+ updateUserProfilesSaga,
+ socket as unknown as Socket,
+ // @ts-ignore
+ usersActions.updateUserProfiles([updatedProfile])
+ )
+ .withReducer(combineReducers(testReducers))
+ .withState(store.getState())
+ .provide([
+ {
+ select: ({ selector }: any, next: any) => {
+ if (selector === userProfileSelectors.userProfiles) {
+ userProfilesSelectCalls += 1
+ return existingProfiles
+ }
+ return next()
+ },
+ },
+ ])
+ .apply.like({
+ context: socket,
+ fn: socket.emit,
+ args: [
+ SocketActions.USER_PROFILES_UPDATED,
+ {
+ new: [],
+ updates: [updatedProfile],
+ },
+ ],
+ })
+ .put.like({
+ action: {
+ type: usersActions.setUserProfiles.type,
+ payload: [updatedProfile],
+ },
+ })
+ .run()
+ })
+
+ it('should NOT clear profilePhoto.path if CID is the same', async () => {
+ const existingProfiles = {
+ [userId]: userProfile,
+ }
+
+ const updatedProfile: UserProfile = {
+ ...userProfile,
+ profilePhoto: {
+ ...userProfile.profilePhoto!,
+ path: null,
+ },
+ }
+
+ let userProfilesSelectCalls = 0
+
+ await expectSaga(
+ updateUserProfilesSaga,
+ socket as unknown as Socket,
+ // @ts-ignore
+ usersActions.updateUserProfiles([updatedProfile])
+ )
+ .withReducer(combineReducers(testReducers))
+ .withState(store.getState())
+ .provide([
+ {
+ select: ({ selector }: any, next: any) => {
+ if (selector === userProfileSelectors.userProfiles) {
+ userProfilesSelectCalls += 1
+ return existingProfiles
+ }
+ return next()
+ },
+ },
+ ])
+ // since we aren't updating the profile we aren't sending the socket event to ios
+ .not.apply.like({
+ context: socket,
+ fn: socket.emit,
+ })
+ .put.like({
+ action: {
+ type: usersActions.setUserProfiles.type,
+ payload: [userProfile],
+ },
+ })
+ .run()
+ })
+
+ it('should send new profile via socket to ios', async () => {
+ const existingProfiles = {
+ [userId]: userProfile,
+ }
+
+ const newProfile = await baseTypesFactory.create('UserProfile')
+
+ let userProfilesSelectCalls = 0
+
+ await expectSaga(
+ updateUserProfilesSaga,
+ socket as unknown as Socket,
+ // @ts-ignore
+ { payload: [userProfile, newProfile] }
+ )
+ .withReducer(combineReducers(testReducers))
+ .withState(store.getState())
+ .provide([
+ {
+ select: ({ selector }: any, next: any) => {
+ if (selector === userProfileSelectors.userProfiles) {
+ userProfilesSelectCalls += 1
+ return existingProfiles
+ }
+ return next()
+ },
+ },
+ ])
+ .apply.like({
+ context: socket,
+ fn: socket.emit,
+ args: [
+ SocketActions.USER_PROFILES_UPDATED,
+ {
+ new: [newProfile],
+ updates: [],
+ },
+ ],
+ })
+ .put.like({
+ action: {
+ type: usersActions.setUserProfiles.type,
+ payload: [userProfile, newProfile],
+ },
+ })
+ .run()
+ })
+})
diff --git a/packages/state-manager/src/sagas/users/users.slice.test.ts b/packages/state-manager/src/sagas/users/users.slice.test.ts
deleted file mode 100644
index 915ece4adf..0000000000
--- a/packages/state-manager/src/sagas/users/users.slice.test.ts
+++ /dev/null
@@ -1,89 +0,0 @@
-import { usersSlice, UsersState } from './users.slice'
-import { UserProfile, FileMetadata } from '@quiet/types'
-
-describe('usersSlice', () => {
- describe('updateUserProfiles', () => {
- it('should clear profilePhoto.path if CID changes', () => {
- const userId = 'user-1'
- const oldCid = 'old-cid'
- const newCid = 'new-cid'
-
- const initialState: UsersState = {
- userProfiles: {
- [userId]: {
- userId,
- nickname: 'alice',
- profilePhoto: {
- cid: oldCid,
- path: '/path/to/old/photo.png',
- name: 'photo',
- ext: '.png',
- message: { id: 'mid-1', channelId: 'PROFILE_PHOTO_CHANNEL_ID' },
- } as FileMetadata,
- } as UserProfile,
- },
- users: {},
- saveUserProfileError: null,
- }
-
- const updatedProfile: UserProfile = {
- userId,
- nickname: 'alice',
- profilePhoto: {
- cid: newCid,
- path: null, // Backend sends null path
- name: 'photo',
- ext: '.png',
- message: { id: 'mid-2', channelId: 'PROFILE_PHOTO_CHANNEL_ID' },
- } as FileMetadata,
- }
-
- const nextState = usersSlice.reducer(initialState, usersSlice.actions.updateUserProfiles([updatedProfile]))
-
- expect(nextState.userProfiles[userId].profilePhoto?.cid).toBe(newCid)
- expect(nextState.userProfiles[userId].profilePhoto?.path).toBeNull()
- })
-
- it('should NOT clear profilePhoto.path if CID is the same', () => {
- const userId = 'user-1'
- const cid = 'same-cid'
- const path = '/path/to/photo.png'
-
- const initialState: UsersState = {
- userProfiles: {
- [userId]: {
- userId,
- nickname: 'alice',
- profilePhoto: {
- cid,
- path,
- name: 'photo',
- ext: '.png',
- message: { id: 'mid-1', channelId: 'PROFILE_PHOTO_CHANNEL_ID' },
- } as FileMetadata,
- } as UserProfile,
- },
- users: {},
- saveUserProfileError: null,
- }
-
- const updatedProfile: UserProfile = {
- userId,
- nickname: 'alice-updated',
- profilePhoto: {
- cid,
- path: null, // Backend might send null path even if we have it locally
- name: 'photo',
- ext: '.png',
- message: { id: 'mid-1', channelId: 'PROFILE_PHOTO_CHANNEL_ID' },
- } as FileMetadata,
- }
-
- const nextState = usersSlice.reducer(initialState, usersSlice.actions.updateUserProfiles([updatedProfile]))
-
- expect(nextState.userProfiles[userId].nickname).toBe('alice-updated')
- expect(nextState.userProfiles[userId].profilePhoto?.cid).toBe(cid)
- expect(nextState.userProfiles[userId].profilePhoto?.path).toBe(path)
- })
- })
-})
diff --git a/packages/state-manager/src/utils/tests/factories.ts b/packages/state-manager/src/utils/tests/factories.ts
index 7659a6c395..57067a6a39 100644
--- a/packages/state-manager/src/utils/tests/factories.ts
+++ b/packages/state-manager/src/utils/tests/factories.ts
@@ -49,6 +49,8 @@ import {
HCaptchaFormResponse,
HCaptchaRequest,
InviteResultWithSalt,
+ FileMessage,
+ FileEncryptionMetadata,
} from '@quiet/types'
import { InviteResult } from '@localfirst/auth'
import { createLogger } from '../logger'
@@ -66,6 +68,7 @@ import { errorsActions } from '../../sagas/errors/errors.slice'
import { errorsSelectors } from '../../sagas/errors/errors.selectors'
import { connectionActions } from '../../sagas/appConnection/connection.slice'
import { connectionSelectors } from '../../sagas/appConnection/connection.selectors'
+import { randomBytes } from 'crypto'
const logger = createLogger('factories')
@@ -125,11 +128,38 @@ export const getBaseTypesFactory = async () => {
bio: factory.sequence('UserProfileDisplayData.bio', (n: number) => `bio_${n}`),
})
+ factory.define('FileMessage', Object, {
+ id: factory.sequence('FileMessage.id', (n: number) => `profile-photo-user-profile-photo-cid-${n}-${n}`),
+ channelId: '__profile-photo__',
+ })
+
+ factory.define('FileEncryptionMetadata', Object, {
+ header: factory.sequence('FileEncryptionMetadata.header', (n: number) => randomBytes(32).toString('base64')),
+ recipient: {
+ generation: 0,
+ type: 'ROLE',
+ name: 'MEMBER',
+ },
+ })
+
+ factory.define('FileMetadata', Object, {
+ cid: factory.sequence('FileMetadata.cid', (n: number) => `user-profile-photo-cid-${n}`),
+ path: factory.sequence('FileMetadata.path', (n: number) => `/foo/bar/user-profile-photo-cid-${n}.png`),
+ ext: '.png',
+ name: factory.sequence('FileMetadata.name', (n: number) => `user-profile-photo-name-${n}`),
+ message: factory.assoc('FileMessage'),
+ size: factory.sequence('FileMetadata.size', (n: number) => 1024 + n),
+ width: factory.sequence('FileMetadata.width', (n: number) => 100 + n),
+ height: factory.sequence('FileMetadata.height', (n: number) => 100 + n),
+ enc: factory.assoc('FileEncryptionMetadata'),
+ })
+
factory.define('UserProfile', Object, {
userId: factory.sequence('UserProfile.userId', (n: number) => `userId_${n}`),
nickname: factory.sequence('UserProfile.nickname', (n: number) => `userProfile.nickname_${n}`),
- photo: 'dGVzdAo=',
+ photo: undefined,
bio: factory.sequence('UserProfile.bio', (n: number) => `bio_${n}`),
+ profilePhoto: factory.assoc('FileMetadata'),
})
factory.define('User', Object, {
diff --git a/packages/types/src/files.ts b/packages/types/src/files.ts
index 4997a9e7d5..cd7b823f27 100644
--- a/packages/types/src/files.ts
+++ b/packages/types/src/files.ts
@@ -11,20 +11,22 @@ export interface FilePreviewData {
[id: string]: FileContent
}
+export interface FileEncryptionMetadata {
+ header: string
+ recipient: {
+ generation: number
+ type: string
+ name: string
+ }
+}
+
export interface FileMetadata extends FileContent {
cid: string
message: FileMessage
size?: number
width?: number
height?: number
- enc?: {
- header: string
- recipient: {
- generation: number
- type: string
- name: string
- }
- }
+ enc?: FileEncryptionMetadata
}
export interface AttachFilePayload {
From 06bb47df0c555da5e81e63794092d6cf1527eea0 Mon Sep 17 00:00:00 2001
From: Isla <5048549+islathehut@users.noreply.github.com>
Date: Fri, 13 Mar 2026 17:29:06 -0400
Subject: [PATCH 18/92] Fix factories test
---
.../state-manager/src/utils/tests/factories.ts | 15 +++++++++++++++
1 file changed, 15 insertions(+)
diff --git a/packages/state-manager/src/utils/tests/factories.ts b/packages/state-manager/src/utils/tests/factories.ts
index 57067a6a39..74f5bd6f97 100644
--- a/packages/state-manager/src/utils/tests/factories.ts
+++ b/packages/state-manager/src/utils/tests/factories.ts
@@ -620,6 +620,21 @@ export const getSocketFactory = async () => {
},
})
+ factory.define(SocketActions.USER_PROFILES_UPDATED, Object, [
+ {
+ profile: {
+ userId: 'user-id',
+ nickname: 'Test User',
+ photo: 'dGVzdAo=',
+ bio: 'This is a test user profile',
+ userData: {
+ onionAddress: 'test.onion',
+ peerId: 'peer-id',
+ },
+ },
+ },
+ ])
+
factory.define(`${SocketActions.SET_USER_PROFILE}_response`, Object, {
success: true,
error: undefined,
From e7965b68dc8345c74189d802fbbc4c0922f966c0 Mon Sep 17 00:00:00 2001
From: taea
Date: Mon, 16 Mar 2026 11:30:42 -0400
Subject: [PATCH 19/92] fix listener leak
---
.../src/nest/auth/sigchain.service.spec.ts | 38 +++++++++++++++++++
.../backend/src/nest/auth/sigchain.service.ts | 17 ++++++---
2 files changed, 50 insertions(+), 5 deletions(-)
diff --git a/packages/backend/src/nest/auth/sigchain.service.spec.ts b/packages/backend/src/nest/auth/sigchain.service.spec.ts
index dd9f4730aa..5972869d83 100644
--- a/packages/backend/src/nest/auth/sigchain.service.spec.ts
+++ b/packages/backend/src/nest/auth/sigchain.service.spec.ts
@@ -6,6 +6,7 @@ import { LocalDbService } from '../local-db/local-db.service'
import { LocalDbModule } from '../local-db/local-db.module'
import { TestModule } from '../common/test.module'
import { SigChainModule } from './sigchain.service.module'
+import { SigChain } from './sigchain'
const logger = createLogger('auth:sigchainManager.spec')
@@ -91,3 +92,40 @@ describe('SigChainService', () => {
expect(sigChainService.getChain({ teamName: TEAM_NAME2 })).toBeDefined()
})
})
+
+describe('SigChainService - listener lifecycle', () => {
+ let module: TestingModule
+ let sigChainService: SigChainService
+ let localDbService: LocalDbService
+
+ beforeAll(async () => {
+ module = await Test.createTestingModule({
+ imports: [TestModule, SigChainModule, LocalDbModule],
+ }).compile()
+ sigChainService = await module.resolve(SigChainService)
+ localDbService = await module.resolve(LocalDbService)
+ await localDbService.open()
+ })
+
+ afterAll(async () => {
+ await localDbService.close()
+ await module.close()
+ })
+
+ it('does not accumulate listeners on chains when switching active chain', async () => {
+ const chainA: SigChain = await sigChainService.createChain('leakA', 'alice', true)
+ // chainA is active: one listener attached
+ expect(chainA.listenerCount('updated')).toBe(1)
+
+ const chainB: SigChain = await sigChainService.createChain('leakB', 'bob', true)
+ // Active switched A → B. detachSocketListeners(A) must have removed A's listener.
+ expect(chainA.listenerCount('updated')).toBe(0)
+ expect(chainB.listenerCount('updated')).toBe(1)
+
+ sigChainService.setActiveChain('leakA')
+ // Active switched B → A. detachSocketListeners(B) must have removed B's listener,
+ // and attachSocketListeners(A) adds exactly one to A.
+ expect(chainA.listenerCount('updated')).toBe(1)
+ expect(chainB.listenerCount('updated')).toBe(0)
+ })
+})
diff --git a/packages/backend/src/nest/auth/sigchain.service.ts b/packages/backend/src/nest/auth/sigchain.service.ts
index cdaacd35ee..cfff887d84 100644
--- a/packages/backend/src/nest/auth/sigchain.service.ts
+++ b/packages/backend/src/nest/auth/sigchain.service.ts
@@ -32,6 +32,7 @@ export class SigChainService extends EventEmitter {
private readonly logger = createLogger(SigChainService.name)
private chains: Map = new Map()
public connections: Map = new Map()
+ private readonly _chainListeners: Map void> = new Map()
constructor(
@Inject(SERVER_IO_PROVIDER) public readonly serverIoProvider: ServerIoProviderTypes,
@@ -243,18 +244,20 @@ export class SigChainService extends EventEmitter {
private attachSocketListeners(chain: SigChain): void {
this.logger.info('Attaching socket listeners')
- const _onTeamUpdate = (): void => {
+ const listener = (): void => {
this.handleChainUpdate(chain.team!.teamName)
}
- chain.on('updated', _onTeamUpdate)
+ this._chainListeners.set(chain, listener)
+ chain.on('updated', listener)
}
private detachSocketListeners(chain: SigChain): void {
this.logger.info('Detaching socket listeners')
- const _onTeamUpdate = (): void => {
- this.handleChainUpdate(chain.team!.teamName)
+ const listener = this._chainListeners.get(chain)
+ if (listener) {
+ chain.removeListener('updated', listener)
+ this._chainListeners.delete(chain)
}
- chain.removeListener('updated', _onTeamUpdate)
}
/**
@@ -285,6 +288,10 @@ export class SigChainService extends EventEmitter {
* @param fromDisk Whether to delete the chain from disk as well
*/
async deleteChain(teamName: string, fromDisk: boolean): Promise {
+ const chain = this.chains.get(teamName)
+ if (chain) {
+ this.detachSocketListeners(chain)
+ }
if (fromDisk) {
this.localDbService.deleteSigChain(teamName)
}
From 461342d21711e2d588153a01ade63edc3b2e8a00 Mon Sep 17 00:00:00 2001
From: Isla <5048549+islathehut@users.noreply.github.com>
Date: Mon, 16 Mar 2026 11:49:32 -0400
Subject: [PATCH 20/92] Fix state manager stuff
---
packages/state-manager/src/utils/tests/factories.ts | 12 +++++++-----
1 file changed, 7 insertions(+), 5 deletions(-)
diff --git a/packages/state-manager/src/utils/tests/factories.ts b/packages/state-manager/src/utils/tests/factories.ts
index 74f5bd6f97..11fea428ba 100644
--- a/packages/state-manager/src/utils/tests/factories.ts
+++ b/packages/state-manager/src/utils/tests/factories.ts
@@ -51,6 +51,7 @@ import {
InviteResultWithSalt,
FileMessage,
FileEncryptionMetadata,
+ UserProfilesUpdatedPayload,
} from '@quiet/types'
import { InviteResult } from '@localfirst/auth'
import { createLogger } from '../logger'
@@ -620,9 +621,9 @@ export const getSocketFactory = async () => {
},
})
- factory.define(SocketActions.USER_PROFILES_UPDATED, Object, [
- {
- profile: {
+ factory.define(SocketActions.USER_PROFILES_UPDATED, Object, {
+ new: [
+ {
userId: 'user-id',
nickname: 'Test User',
photo: 'dGVzdAo=',
@@ -632,8 +633,9 @@ export const getSocketFactory = async () => {
peerId: 'peer-id',
},
},
- },
- ])
+ ],
+ updates: [],
+ })
factory.define(`${SocketActions.SET_USER_PROFILE}_response`, Object, {
success: true,
From 0350734d9d6598da7644df76db2c8dc5079c3d1b Mon Sep 17 00:00:00 2001
From: taea
Date: Mon, 23 Mar 2026 16:49:34 -0400
Subject: [PATCH 21/92] add a default title and body so that message is not
treated as silent
---
packages/desktop/.env.development | 1 +
packages/desktop/src/main/main.ts | 1 +
packages/mobile/ios/CommunicationModule.swift | 14 ++++++++++++++
3 files changed, 16 insertions(+)
diff --git a/packages/desktop/.env.development b/packages/desktop/.env.development
index 69254342b9..31c1255c0f 100644
--- a/packages/desktop/.env.development
+++ b/packages/desktop/.env.development
@@ -2,6 +2,7 @@
COLORIZE=true
QSS_ALLOWED=true
QPS_ALLOWED=true
+QPS_ENABLED=true
QSS_ENDPOINT=ws://127.0.0.1:3003
LOG_TO_FILE=true
ENVFILE=.env.development
diff --git a/packages/desktop/src/main/main.ts b/packages/desktop/src/main/main.ts
index 196dae3ac1..2f7a11a9a9 100644
--- a/packages/desktop/src/main/main.ts
+++ b/packages/desktop/src/main/main.ts
@@ -586,6 +586,7 @@ app.on('ready', async () => {
STATIC_LOG_ID: process.env.STATIC_LOG_ID,
QSS_ALLOWED: process.env.QSS_ALLOWED ?? 'false',
QSS_ENDPOINT: process.env.QSS_ENDPOINT,
+ QPS_ALLOWED: process.env.QPS_ALLOWED ?? 'false',
HCAPTCHA_TEMPLATE_PATH: path.join(__dirname, 'captcha.html'),
HCAPTCHA_FORWARD_ENDPOINT: process.env.HCAPTCHA_FORWARD_ENDPOINT,
IS_E2E: process.env.IS_E2E ?? 'false',
diff --git a/packages/mobile/ios/CommunicationModule.swift b/packages/mobile/ios/CommunicationModule.swift
index f6961cdbfe..816228d2d1 100644
--- a/packages/mobile/ios/CommunicationModule.swift
+++ b/packages/mobile/ios/CommunicationModule.swift
@@ -12,6 +12,8 @@ class CommunicationModule: RCTEventEmitter {
static let DEVICE_TOKEN_RECEIVED = "deviceTokenReceived"
static let WEBSOCKET_CONNECTION_CHANNEL = "_WEBSOCKET_CONNECTION_"
+
+ private var hasListeners = false
@objc
func sendDataPort(port: UInt16, socketIOSecret: String) {
@@ -85,9 +87,21 @@ class CommunicationModule: RCTEventEmitter {
@objc
func sendDeviceToken(_ token: String) {
+ guard hasListeners else {
+ NSLog("Skipping deviceTokenReceived emit because no JS listeners are attached; JS will fetch the current FCM token when ready.")
+ return
+ }
self.sendEvent(withName: CommunicationModule.DEVICE_TOKEN_RECEIVED, body: ["token": token])
}
+ override func startObserving() {
+ hasListeners = true
+ }
+
+ override func stopObserving() {
+ hasListeners = false
+ }
+
override func supportedEvents() -> [String]! {
return [
CommunicationModule.BACKEND_EVENT_IDENTIFIER,
From f05c0860cd050a7f4846b07ab54e2ac3d6702e08 Mon Sep 17 00:00:00 2001
From: taea
Date: Mon, 23 Mar 2026 16:59:06 -0400
Subject: [PATCH 22/92] fix fcm sending token before js sets up
---
.../ios/Quiet/FirebaseMessagingModule.m | 7 +--
.../ios/Quiet/FirebaseMessagingModule.swift | 24 +-------
packages/mobile/src/setupTests.tsx | 3 +
.../pushNotifications.master.saga.ts | 59 ++++++++++++++++---
4 files changed, 57 insertions(+), 36 deletions(-)
diff --git a/packages/mobile/ios/Quiet/FirebaseMessagingModule.m b/packages/mobile/ios/Quiet/FirebaseMessagingModule.m
index 3afff6151f..d365441814 100644
--- a/packages/mobile/ios/Quiet/FirebaseMessagingModule.m
+++ b/packages/mobile/ios/Quiet/FirebaseMessagingModule.m
@@ -1,7 +1,6 @@
#import
-#import
-@interface RCT_EXTERN_MODULE(FirebaseMessagingModule, RCTEventEmitter)
+@interface RCT_EXTERN_MODULE(FirebaseMessagingModule, NSObject)
RCT_EXTERN_METHOD(getToken:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject)
@@ -19,8 +18,4 @@ @interface RCT_EXTERN_MODULE(FirebaseMessagingModule, RCTEventEmitter)
RCT_EXTERN_METHOD(setEncryptionKey:(NSString *)key)
-RCT_EXTERN_METHOD(onTokenReceived:(NSString *)token)
-
-RCT_EXTERN_METHOD(onTokenRefreshed:(NSString *)token)
-
@end
diff --git a/packages/mobile/ios/Quiet/FirebaseMessagingModule.swift b/packages/mobile/ios/Quiet/FirebaseMessagingModule.swift
index 38c4e66f61..adcaeb6174 100644
--- a/packages/mobile/ios/Quiet/FirebaseMessagingModule.swift
+++ b/packages/mobile/ios/Quiet/FirebaseMessagingModule.swift
@@ -3,22 +3,12 @@ import React
import FirebaseMessaging
@objc(FirebaseMessagingModule)
-class FirebaseMessagingModule: RCTEventEmitter {
+class FirebaseMessagingModule: NSObject {
- static let FCM_TOKEN_RECEIVED = "fcmTokenReceived"
- static let FCM_TOKEN_REFRESHED = "fcmTokenRefreshed"
-
- override static func requiresMainQueueSetup() -> Bool {
+ @objc static func requiresMainQueueSetup() -> Bool {
return true
}
- override func supportedEvents() -> [String]! {
- return [
- FirebaseMessagingModule.FCM_TOKEN_RECEIVED,
- FirebaseMessagingModule.FCM_TOKEN_REFRESHED
- ]
- }
-
@objc
func getToken(_ resolve: @escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock) {
Messaging.messaging().token { token, error in
@@ -68,14 +58,4 @@ class FirebaseMessagingModule: RCTEventEmitter {
@objc
func setEncryptionKey(_ key: String) {
}
-
- @objc
- func onTokenReceived(_ token: String) {
- self.sendEvent(withName: FirebaseMessagingModule.FCM_TOKEN_RECEIVED, body: ["token": token])
- }
-
- @objc
- func onTokenRefreshed(_ token: String) {
- self.sendEvent(withName: FirebaseMessagingModule.FCM_TOKEN_REFRESHED, body: ["token": token])
- }
}
diff --git a/packages/mobile/src/setupTests.tsx b/packages/mobile/src/setupTests.tsx
index 729f2fc908..c668908f41 100644
--- a/packages/mobile/src/setupTests.tsx
+++ b/packages/mobile/src/setupTests.tsx
@@ -48,6 +48,9 @@ jest.mock('react-native', () => {
checkNotificationPermission: jest.fn(),
handleIncomingEvents: jest.fn(),
}
+ rn.NativeModules.FirebaseMessagingModule = {
+ getToken: jest.fn(),
+ }
return rn
})
diff --git a/packages/mobile/src/store/pushNotifications/pushNotifications.master.saga.ts b/packages/mobile/src/store/pushNotifications/pushNotifications.master.saga.ts
index e29f7fcb60..d6ea1539cb 100644
--- a/packages/mobile/src/store/pushNotifications/pushNotifications.master.saga.ts
+++ b/packages/mobile/src/store/pushNotifications/pushNotifications.master.saga.ts
@@ -8,6 +8,7 @@ import {
handlePermissionResultSaga,
PermissionResultPayload,
} from './handlePermissionResult/handlePermissionResult.saga'
+import { NotificationPermissionStatus } from './pushNotifications.types'
import { pushNotifications } from '@quiet/state-manager'
import { initSelectors } from '../init/init.selectors'
import { createLogger } from '../../utils/logger'
@@ -18,6 +19,8 @@ const logger = createLogger('pushNotificationsMasterSaga')
const NOTIFICATION_PERMISSION_RESULT = 'notificationPermissionResult'
const DEVICE_TOKEN_RECEIVED = 'deviceTokenReceived'
+const firebaseMessagingModule = NativeModules.FirebaseMessagingModule
+
function* requestPermissionSaga(): Generator {
logger.info('Requesting iOS notification permission')
yield* call(NativeModules.CommunicationModule.requestNotificationPermission)
@@ -53,12 +56,59 @@ function createDeviceTokenChannel() {
})
}
+function hasGrantedPermission(payload: PermissionResultPayload): boolean {
+ if (payload.status) {
+ return payload.status === NotificationPermissionStatus.Granted
+ }
+
+ return payload.granted === true
+}
+
+function* waitForWebsocketConnectionSaga(): Generator {
+ while (true) {
+ const connected = yield* select(initSelectors.isWebsocketConnected)
+ if (connected) break
+ yield* delay(500)
+ }
+}
+
+function* sendDeviceTokenToBackendSaga(token: string): Generator {
+ logger.info('Waiting for websocket connection before sending FCM token')
+ yield* call(waitForWebsocketConnectionSaga)
+ logger.info('Sending FCM token to backend')
+ yield* put(pushNotifications.actions.sendDeviceTokenToBackend(token))
+}
+
+function* syncCurrentDeviceTokenSaga(): Generator {
+ if (!firebaseMessagingModule?.getToken) {
+ logger.warn('FirebaseMessagingModule.getToken is unavailable, skipping initial token sync')
+ return
+ }
+
+ try {
+ const token = (yield* call([firebaseMessagingModule, firebaseMessagingModule.getToken])) as string | null
+
+ if (!token) {
+ logger.info('No current FCM token available yet')
+ return
+ }
+
+ logger.info('Fetched current FCM token from native module')
+ yield* call(sendDeviceTokenToBackendSaga, token)
+ } catch (error) {
+ logger.error('Failed to fetch current FCM token', error)
+ }
+}
+
function* watchPermissionResults(): Generator {
const channel = yield* call(createPermissionResultChannel)
try {
while (true) {
const payload = yield* take(channel)
yield* call(handlePermissionResultSaga, payload)
+ if (hasGrantedPermission(payload)) {
+ yield* call(syncCurrentDeviceTokenSaga)
+ }
}
} finally {
if (yield cancelled()) {
@@ -96,14 +146,7 @@ function* watchDeviceToken(): Generator {
try {
while (true) {
const { token } = yield* take(channel)
- logger.info('Received device token, waiting for websocket connection')
- while (true) {
- const connected = yield* select(initSelectors.isWebsocketConnected)
- if (connected) break
- yield* delay(500)
- }
- logger.info('Sending device token to backend')
- yield* put(pushNotifications.actions.sendDeviceTokenToBackend(token))
+ yield* call(sendDeviceTokenToBackendSaga, token)
}
} finally {
if (yield cancelled()) {
From 55129648612b51607f1ba625f7d053e9741d2468 Mon Sep 17 00:00:00 2001
From: taea
Date: Mon, 23 Mar 2026 20:32:32 -0400
Subject: [PATCH 23/92] fix first join token flush
---
.../backend/src/nest/qps/qps.service.spec.ts | 34 +++++++++++++------
packages/backend/src/nest/qps/qps.service.ts | 27 ++++++++++++++-
2 files changed, 49 insertions(+), 12 deletions(-)
diff --git a/packages/backend/src/nest/qps/qps.service.spec.ts b/packages/backend/src/nest/qps/qps.service.spec.ts
index 76959ccf41..9f73d0d4eb 100644
--- a/packages/backend/src/nest/qps/qps.service.spec.ts
+++ b/packages/backend/src/nest/qps/qps.service.spec.ts
@@ -24,6 +24,13 @@ class MockSigChainService extends EventEmitter {
}
}
+class MockQSSService extends EventEmitter {
+ on = this.addListener
+ emitEvent(event: QSSEvents, payload?: any) {
+ this.emit(event, payload)
+ }
+}
+
class MockSocketService extends EventEmitter {}
class MockNotificationTokensStore {
@@ -34,6 +41,7 @@ class MockNotificationTokensStore {
describe('QPSService', () => {
let qpsService: QPSService
let qssClient: MockQSSClient
+ let qssService: MockQSSService
let sigChainService: MockSigChainService
let socketService: MockSocketService
let notificationTokensStore: MockNotificationTokensStore
@@ -64,6 +72,7 @@ describe('QPSService', () => {
beforeEach(() => {
jest.clearAllMocks()
qssClient = new MockQSSClient()
+ qssService = new MockQSSService()
sigChainService = new MockSigChainService()
socketService = new MockSocketService()
notificationTokensStore = new MockNotificationTokensStore()
@@ -72,6 +81,7 @@ describe('QPSService', () => {
true, // qpsAllowed
socketService as any,
qssClient as any,
+ qssService as any,
sigChainService as any,
notificationTokensStore as any
)
@@ -123,6 +133,7 @@ describe('QPSService', () => {
false,
socketService as any,
qssClient as any,
+ qssService as any,
sigChainService as any,
notificationTokensStore as any
)
@@ -166,7 +177,7 @@ describe('QPSService', () => {
// Now become ready and flush
setReady()
- qssClient.emit(QSSEvents.QSS_CONNECTED)
+ qssService.emitEvent(QSSEvents.QSS_FULLY_JOINED)
// Wait for async flush
await new Promise(resolve => setTimeout(resolve, 10))
@@ -219,33 +230,33 @@ describe('QPSService', () => {
})
})
- describe('flush on sigchain updated', () => {
- it('flushes cached token when sigchain joins and QSS is connected', async () => {
+ describe('flush on QSS_FULLY_JOINED', () => {
+ it('flushes cached token when QSS fully joins and QSS is connected', async () => {
// Cache token: QSS connected but no sigchain
qssClient.connected = true
sigChainService.activeChain = null
await qpsService.register(TOKEN)
expect(qssClient.sendMessage).not.toHaveBeenCalled()
- // Sigchain joins
+ // QSS completes the join flow and the sigchain is now ready
setReady()
- sigChainService.emit('updated')
+ qssService.emitEvent(QSSEvents.QSS_FULLY_JOINED)
await new Promise(resolve => setTimeout(resolve, 10))
expect(qssClient.sendMessage).toHaveBeenCalledTimes(1)
})
- it('does not flush when sigchain updates but QSS is not connected', async () => {
+ it('does not flush when QSS fully joins but QSS is not connected', async () => {
qssClient.connected = false
sigChainService.activeChain = null
await qpsService.register(TOKEN)
- // Sigchain joins but QSS still disconnected
+ // Join completes but the transport is still disconnected
sigChainService.activeChain = {
team: { id: TEAM_ID },
roles: { amIMemberOfRole: () => true },
}
- sigChainService.emit('updated')
+ qssService.emitEvent(QSSEvents.QSS_FULLY_JOINED)
await new Promise(resolve => setTimeout(resolve, 10))
expect(qssClient.sendMessage).not.toHaveBeenCalled()
@@ -259,13 +270,13 @@ describe('QPSService', () => {
await qpsService.register(TOKEN)
setReady()
- qssClient.emit(QSSEvents.QSS_CONNECTED)
+ qssService.emitEvent(QSSEvents.QSS_FULLY_JOINED)
await new Promise(resolve => setTimeout(resolve, 10))
expect(qssClient.sendMessage).toHaveBeenCalledTimes(1)
- // Second event should not trigger another send
- sigChainService.emit('updated')
+ // Second fully-joined event should not trigger another send
+ qssService.emitEvent(QSSEvents.QSS_FULLY_JOINED)
await new Promise(resolve => setTimeout(resolve, 10))
expect(qssClient.sendMessage).toHaveBeenCalledTimes(1)
@@ -302,6 +313,7 @@ describe('QPSService', () => {
false,
socketService as any,
qssClient as any,
+ qssService as any,
sigChainService as any,
notificationTokensStore as any
)
diff --git a/packages/backend/src/nest/qps/qps.service.ts b/packages/backend/src/nest/qps/qps.service.ts
index b41f16ff0b..071e6d15c0 100644
--- a/packages/backend/src/nest/qps/qps.service.ts
+++ b/packages/backend/src/nest/qps/qps.service.ts
@@ -16,6 +16,7 @@ import {
import { SigChainService } from '../auth/sigchain.service'
import { RoleName } from '../auth/services/roles/roles'
import { NotificationTokensStore } from '../storage/notifications/notificationTokens.store'
+import { QSSService } from '../qss/qss.service'
const BUNDLE_ID = 'com.quietmobile'
const PUSH_BATCH_SIZE = 500 // FCM allows up to 500 tokens per batch request
@@ -29,6 +30,7 @@ export class QPSService implements OnModuleInit {
@Inject(QPS_ALLOWED) private readonly qpsAllowed: boolean,
private readonly socketService: SocketService,
private readonly qssClient: QSSClient,
+ private readonly qssService: QSSService,
private readonly sigChainService: SigChainService,
private readonly notificationTokensStore: NotificationTokensStore
) {}
@@ -47,6 +49,7 @@ export class QPSService implements OnModuleInit {
await this.register(payload.deviceToken)
})
+ this.qssService.on(QSSEvents.QSS_FULLY_JOINED, () => this._flushPendingToken())
this.qssClient.on(QSSEvents.QSS_CONNECTED, () => this._flushPendingToken())
this.qssClient.on(QSSEvents.QSS_LOG_SYNCED, (teamId: string) => void this.sendBatchPush(teamId))
this.sigChainService.on('updated', () => this._flushPendingToken())
@@ -73,7 +76,29 @@ export class QPSService implements OnModuleInit {
}
private async _flushPendingToken(): Promise {
- if (this._pendingDeviceToken == undefined || !this.ready) {
+ this.logger.debug('Checking if pending device token can be flushed')
+ const hasPendingToken = this._pendingDeviceToken != undefined
+ const qssConnected = this.qssClient.connected
+ const hasMemberKey = this._hasMemberKey()
+
+ if (!hasPendingToken || !qssConnected || !hasMemberKey) {
+ const reasons: string[] = []
+ if (!hasPendingToken) {
+ reasons.push('no pending device token')
+ }
+ if (!qssConnected) {
+ reasons.push('QSS not connected')
+ }
+ if (!hasMemberKey) {
+ reasons.push('sigchain member key unavailable')
+ }
+
+ this.logger.debug(`Skipping cached device token flush: ${reasons.join(', ')}`)
+ return
+ }
+
+ if (!this._pendingDeviceToken) {
+ this.logger.warn('No pending device token found during flush')
return
}
From f90554e8c0bc1447c82f7938cf0165ad1b0e572f Mon Sep 17 00:00:00 2001
From: taea
Date: Tue, 24 Mar 2026 14:29:32 -0400
Subject: [PATCH 24/92] merge nse-test: NSE business logic + orbitDb helper
- Replace stub NotificationService with full fetch/auth/badge impl
- Add NSEAuthService, NSECryptoService, NSEKeychainHelper, NSEModels, NSENetworkClient
- Add getTeamLogEntriesSince to OrbitDbService
Co-Authored-By: Claude Sonnet 4.6
---
.../nest/storage/orbitDb/orbitDb.service.ts | 23 +++
.../NSEAuthService.swift | 107 ++++++++++++++
.../NSECryptoService.swift | 139 ++++++++++++++++++
.../NSEKeychainHelper.swift | 82 +++++++++++
.../NSEModels.swift | 94 ++++++++++++
.../NSENetworkClient.swift | 111 ++++++++++++++
.../NotificationService.swift | 98 +++++++++---
7 files changed, 632 insertions(+), 22 deletions(-)
create mode 100644 packages/mobile/ios/QuietNotificationServiceExtension/NSEAuthService.swift
create mode 100644 packages/mobile/ios/QuietNotificationServiceExtension/NSECryptoService.swift
create mode 100644 packages/mobile/ios/QuietNotificationServiceExtension/NSEKeychainHelper.swift
create mode 100644 packages/mobile/ios/QuietNotificationServiceExtension/NSEModels.swift
create mode 100644 packages/mobile/ios/QuietNotificationServiceExtension/NSENetworkClient.swift
diff --git a/packages/backend/src/nest/storage/orbitDb/orbitDb.service.ts b/packages/backend/src/nest/storage/orbitDb/orbitDb.service.ts
index 3b0bf8b44d..e26aaff46b 100644
--- a/packages/backend/src/nest/storage/orbitDb/orbitDb.service.ts
+++ b/packages/backend/src/nest/storage/orbitDb/orbitDb.service.ts
@@ -304,6 +304,29 @@ export class OrbitDbService {
}
}
+ /**
+ * Returns all EncryptedMessage entries for a given teamId where createdAt >= since.
+ * Used by the NSE auth endpoint.
+ */
+ public async getTeamLogEntriesSince(teamId: string, since: number): Promise {
+ if (this.orbitDbInstance == undefined) {
+ throw new Error('OrbitDB instance is not initialized. Call create() first.')
+ }
+
+ const results: any[] = []
+ for (const store of Object.values(this.stores)) {
+ if (store.meta?.['teamId'] !== teamId) continue
+ for await (const entry of (store.log as LogType).iterator()) {
+ const value = (entry as any).payload?.value
+ if (value && typeof value.createdAt === 'number' && value.createdAt >= since) {
+ results.push(value)
+ }
+ }
+ }
+
+ return results
+ }
+
public static updateMetadata(db: DatabaseType, metadata: Record): void {
db.meta = {
...(db.meta ?? {}),
diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/NSEAuthService.swift b/packages/mobile/ios/QuietNotificationServiceExtension/NSEAuthService.swift
new file mode 100644
index 0000000000..5ab93d9b64
--- /dev/null
+++ b/packages/mobile/ios/QuietNotificationServiceExtension/NSEAuthService.swift
@@ -0,0 +1,107 @@
+import Foundation
+
+class NSEAuthService {
+ private let client: NSENetworkClient
+ private let crypto: DeviceCryptography
+
+ private var tokenCache: [String: (token: String, expiry: Date)] = [:]
+
+ init(client: NSENetworkClient, crypto: DeviceCryptography) {
+ self.client = client
+ self.crypto = crypto
+ }
+
+ // MARK: - Full auth flow
+
+ func authenticate(deviceId: String, teamId: String) async throws -> String {
+ if let cached = tokenCache[teamId], cached.expiry > Date() {
+ return cached.token
+ }
+
+ let challengeResp = try await client.requestChallenge(deviceId: deviceId, teamId: teamId)
+
+ // Sign the challenge using msgpack serialization (matching TypeScript identity.prove())
+ let privateKeyData = try NSEKeychainHelper.getDevicePrivateKey(deviceId: deviceId)
+ let proof = try crypto.signChallengePayload(challengeResp.challenge, privateKeyData: privateKeyData)
+
+ let tokenResp = try await client.requestToken(
+ challengeId: challengeResp.challengeId,
+ deviceId: deviceId,
+ proof: proof
+ )
+
+ tokenCache[teamId] = (token: tokenResp.token, expiry: Date().addingTimeInterval(TimeInterval(tokenResp.expiresIn) - 30))
+
+ return tokenResp.token
+ }
+
+ // MARK: - Fetch log entries
+
+ func fetchNewEntries(teamId: String, since: Int64) async throws -> [LogEntry] {
+ let deviceId = try NSEKeychainHelper.getDeviceId()
+ let token = try await authenticate(deviceId: deviceId, teamId: teamId)
+ let resp = try await client.fetchLogEntries(teamId: teamId, since: since, token: token)
+ return resp.entries
+ }
+}
+
+// MARK: - Base58 encoder (Bitcoin alphabet)
+
+enum Base58 {
+ static let alphabet = Array("123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz")
+
+ static func encode(_ data: Data) -> String {
+ let bytes = [UInt8](data)
+ var leadingZeros = 0
+ for b in bytes {
+ if b == 0 { leadingZeros += 1 } else { break }
+ }
+
+ var result = [UInt8]()
+ for byte in bytes {
+ var carry = Int(byte)
+ for i in 0.. 0 {
+ result.append(UInt8(carry % 58))
+ carry /= 58
+ }
+ }
+
+ let leading = String(repeating: "1", count: leadingZeros)
+ let encoded = result.reversed().map { alphabet[Int($0)] }
+ return leading + String(encoded)
+ }
+
+ static func decode(_ string: String) -> Data? {
+ let alphabetMap: [Character: Int] = Dictionary(
+ uniqueKeysWithValues: alphabet.enumerated().map { ($1, $0) }
+ )
+
+ var leadingZeros = 0
+ for c in string {
+ if c == "1" { leadingZeros += 1 } else { break }
+ }
+
+ var result = [UInt8]()
+ for c in string {
+ guard let digit = alphabetMap[c] else { return nil }
+ var carry = digit
+ for i in 0..>= 8
+ }
+ while carry > 0 {
+ result.append(UInt8(carry & 0xFF))
+ carry >>= 8
+ }
+ }
+
+ let leading = [UInt8](repeating: 0, count: leadingZeros)
+ return Data(leading + result.reversed())
+ }
+}
diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/NSECryptoService.swift b/packages/mobile/ios/QuietNotificationServiceExtension/NSECryptoService.swift
new file mode 100644
index 0000000000..5bfc26425c
--- /dev/null
+++ b/packages/mobile/ios/QuietNotificationServiceExtension/NSECryptoService.swift
@@ -0,0 +1,139 @@
+import CryptoKit
+import Foundation
+
+// MARK: - Protocol
+
+protocol DeviceCryptography {
+ /// Sign message bytes with the device Ed25519 private key.
+ /// Returns raw 64-byte signature.
+ func sign(message: Data) throws -> Data
+
+ /// Signs a challenge payload exactly as `identity.prove()` does in TypeScript.
+ func signChallengePayload(_ challenge: ChallengePayload, privateKeyData: Data) throws -> ProofPayload
+}
+
+extension DeviceCryptography {
+ func signChallengePayload(_ challenge: ChallengePayload, privateKeyData: Data) throws -> ProofPayload {
+ let payloadBytes = try NSEMsgpack.encode(challenge)
+ let signatureBytes = try signBytes(payloadBytes, privateKeyData: privateKeyData)
+ return ProofPayload(signature: Base58.encode(signatureBytes))
+ }
+
+ func signBytes(_ message: Data, privateKeyData: Data) throws -> Data {
+ guard privateKeyData.count == 64 || privateKeyData.count == 32 else {
+ throw NSECryptoError.invalidKeyLength(expected: 64, got: privateKeyData.count)
+ }
+ let seed = privateKeyData.prefix(32)
+ let privateKey = try Curve25519.Signing.PrivateKey(rawRepresentation: seed)
+ return try privateKey.signature(for: message)
+ }
+}
+
+// MARK: - Errors
+
+enum NSECryptoError: Error, LocalizedError {
+ case invalidKeyLength(expected: Int, got: Int)
+ case invalidBase58
+ case signingFailed
+
+ var errorDescription: String? {
+ switch self {
+ case .invalidKeyLength(let e, let g): return "Invalid key length: expected \(e), got \(g)"
+ case .invalidBase58: return "Invalid base58 encoding"
+ case .signingFailed: return "Signing failed"
+ }
+ }
+}
+
+// MARK: - NSECryptoService
+
+/// Mirrors `@localfirst/crypto` signing used in `identity.prove()`:
+/// 1. msgpack-serialize the challenge object (same encoding as msgpackr)
+/// 2. Sign with `crypto_sign_detached` (Ed25519, libsodium 64-byte key)
+/// 3. base58-encode the 64-byte signature
+class NSECryptoService: DeviceCryptography {
+
+ /// Signs raw bytes using the device Ed25519 private key.
+ /// The 64-byte libsodium secret key = 32-byte seed ++ 32-byte public key.
+ /// CryptoKit only needs the 32-byte seed.
+ func sign(message: Data) throws -> Data {
+ // Requires private key externally; use signBytes(_:privateKeyData:) directly.
+ throw NSECryptoError.signingFailed
+ }
+}
+
+// MARK: - Msgpack encoder
+// Encodes the ChallengePayload object in the same format as msgpackr.pack():
+// { type: string, name: string, nonce: string, timestamp: number }
+// Field order must exactly match the JS object insertion order.
+
+enum NSEMsgpack {
+ enum MsgpackError: Error { case unsupportedType, stringTooLong }
+
+ /// Encodes a ChallengePayload in the same byte format as msgpackr.pack().
+ static func encode(_ challenge: ChallengePayload) throws -> Data {
+ var out = Data()
+ // fixmap with 4 elements: 0x84
+ out.append(0x84)
+ // key: "type" value: challenge.type
+ try appendString("type", to: &out)
+ try appendString(challenge.type, to: &out)
+ // key: "name" value: challenge.name
+ try appendString("name", to: &out)
+ try appendString(challenge.name, to: &out)
+ // key: "nonce" value: challenge.nonce
+ try appendString("nonce", to: &out)
+ try appendString(challenge.nonce, to: &out)
+ // key: "timestamp" value: challenge.timestamp (Unix ms, Int64)
+ try appendString("timestamp", to: &out)
+ appendUInt64(UInt64(bitPattern: Int64(challenge.timestamp)), to: &out)
+ return out
+ }
+
+ private static func appendString(_ s: String, to out: inout Data) throws {
+ guard let bytes = s.data(using: .utf8) else { throw MsgpackError.unsupportedType }
+ let len = bytes.count
+ if len < 32 {
+ // fixstr: 0xa0 | len
+ out.append(UInt8(0xa0 | len))
+ } else if len <= 0xFF {
+ // str8: 0xd9, len
+ out.append(0xd9)
+ out.append(UInt8(len))
+ } else if len <= 0xFFFF {
+ // str16: 0xda, len_hi, len_lo
+ out.append(0xda)
+ out.append(UInt8((len >> 8) & 0xFF))
+ out.append(UInt8(len & 0xFF))
+ } else {
+ throw MsgpackError.stringTooLong
+ }
+ out.append(contentsOf: bytes)
+ }
+
+ /// Encodes a positive integer as uint64 (0xcf + 8 bytes BE).
+ /// msgpackr uses uint64 for integers that exceed 2^32.
+ private static func appendUInt64(_ v: UInt64, to out: inout Data) {
+ if v <= 0x7F {
+ out.append(UInt8(v)) // positive fixint
+ } else if v <= 0xFF {
+ out.append(0xcc); out.append(UInt8(v)) // uint8
+ } else if v <= 0xFFFF {
+ out.append(0xcd)
+ out.append(UInt8((v >> 8) & 0xFF))
+ out.append(UInt8(v & 0xFF))
+ } else if v <= 0xFFFFFFFF {
+ out.append(0xce)
+ out.append(UInt8((v >> 24) & 0xFF))
+ out.append(UInt8((v >> 16) & 0xFF))
+ out.append(UInt8((v >> 8) & 0xFF))
+ out.append(UInt8(v & 0xFF))
+ } else {
+ // uint64: 0xcf + 8 bytes big-endian (used for Date.now() timestamps)
+ out.append(0xcf)
+ for shift in stride(from: 56, through: 0, by: -8) {
+ out.append(UInt8((v >> shift) & 0xFF))
+ }
+ }
+ }
+}
diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/NSEKeychainHelper.swift b/packages/mobile/ios/QuietNotificationServiceExtension/NSEKeychainHelper.swift
new file mode 100644
index 0000000000..16d3dfd2e4
--- /dev/null
+++ b/packages/mobile/ios/QuietNotificationServiceExtension/NSEKeychainHelper.swift
@@ -0,0 +1,82 @@
+import Foundation
+import Security
+
+struct NSEKeychainHelper {
+
+ // MARK: - Key names (must match what the main app stores)
+
+ private static let devicePrivateKeyPrefix = "quiet.device.privateKey."
+ private static let deviceIdKey = "quiet.device.id"
+ private static let teamIdKey = "quiet.team.id"
+ private static let lastSyncKey = "quiet.nse.lastSyncTimestamp"
+
+ // MARK: - Device private key
+
+ static func getDevicePrivateKey(deviceId: String) throws -> Data {
+ let account = devicePrivateKeyPrefix + deviceId
+ return try readData(account: account, label: "device private key")
+ }
+
+ // MARK: - Device ID
+
+ static func getDeviceId() throws -> String {
+ let data = try readData(account: deviceIdKey, label: "device ID")
+ guard let str = String(data: data, encoding: .utf8) else {
+ throw NSEAuthError.keychainError("device ID is not valid UTF-8")
+ }
+ return str
+ }
+
+ // MARK: - Team ID
+
+ static func getTeamId() throws -> String {
+ let data = try readData(account: teamIdKey, label: "team ID")
+ guard let str = String(data: data, encoding: .utf8) else {
+ throw NSEAuthError.keychainError("team ID is not valid UTF-8")
+ }
+ return str
+ }
+
+ // MARK: - Last sync timestamp (UserDefaults — not sensitive)
+
+ static func getLastSyncTimestamp() -> Int64 {
+ return Int64(UserDefaults.standard.double(forKey: lastSyncKey))
+ }
+
+ static func saveLastSyncTimestamp(_ ts: Int64) {
+ UserDefaults.standard.set(Double(ts), forKey: lastSyncKey)
+ }
+
+ // MARK: - Private helpers
+
+ // Must match the App Group entitlement in both the main app and NSE targets
+ // (Signing & Capabilities → App Groups → group.com.quietmobile).
+ private static let accessGroup = "group.com.quietmobile"
+
+ private static func readData(account: String, label: String) throws -> Data {
+ // Main app must write with kSecAttrAccessibleAfterFirstUnlock for NSE to read while device is locked
+ let query: [CFString: Any] = [
+ kSecClass: kSecClassGenericPassword,
+ kSecAttrAccount: account,
+ kSecAttrAccessGroup: accessGroup,
+ kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlock,
+ kSecReturnData: true,
+ kSecMatchLimit: kSecMatchLimitOne,
+ ]
+
+ var result: CFTypeRef?
+ let status = SecItemCopyMatching(query as CFDictionary, &result)
+
+ switch status {
+ case errSecSuccess:
+ guard let data = result as? Data else {
+ throw NSEAuthError.keychainError("Unexpected type for \(label)")
+ }
+ return data
+ case errSecItemNotFound:
+ throw NSEAuthError.missingCredentials(label)
+ default:
+ throw NSEAuthError.keychainError("SecItemCopyMatching failed for \(label): \(status)")
+ }
+ }
+}
diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/NSEModels.swift b/packages/mobile/ios/QuietNotificationServiceExtension/NSEModels.swift
new file mode 100644
index 0000000000..5ee3af1ea4
--- /dev/null
+++ b/packages/mobile/ios/QuietNotificationServiceExtension/NSEModels.swift
@@ -0,0 +1,94 @@
+import Foundation
+
+// MARK: - Error Types
+
+enum NSEAuthError: Error, LocalizedError {
+ case challengeRequestFailed(statusCode: Int)
+ case tokenRequestFailed(statusCode: Int)
+ case logFetchFailed(statusCode: Int)
+ case invalidResponse
+ case decodingFailed(Error)
+ case signingFailed
+ case keychainError(String)
+ case missingCredentials(String)
+ case networkError(Error)
+
+ var errorDescription: String? {
+ switch self {
+ case .challengeRequestFailed(let code): return "Challenge request failed with status \(code)"
+ case .tokenRequestFailed(let code): return "Token request failed with status \(code)"
+ case .logFetchFailed(let code): return "Log fetch failed with status \(code)"
+ case .invalidResponse: return "Invalid or unexpected server response"
+ case .decodingFailed(let err): return "Decoding failed: \(err.localizedDescription)"
+ case .signingFailed: return "Failed to sign challenge"
+ case .keychainError(let msg): return "Keychain error: \(msg)"
+ case .missingCredentials(let field): return "Missing credential: \(field)"
+ case .networkError(let err): return "Network error: \(err.localizedDescription)"
+ }
+ }
+}
+
+// MARK: - Challenge
+
+struct ChallengePayload: Codable {
+ let type: String
+ let name: String
+ let nonce: String
+ let timestamp: Int64 // Unix ms, from identity.challenge() in TypeScript
+}
+
+struct ChallengeResponse: Codable {
+ let challengeId: String
+ let challenge: ChallengePayload
+}
+
+// MARK: - Token
+
+struct ProofPayload: Codable {
+ let signature: String
+}
+
+struct TokenRequest: Codable {
+ let challengeId: String
+ let deviceId: String
+ let proof: ProofPayload
+}
+
+struct TokenResponse: Codable {
+ let token: String
+ let expiresIn: Int
+}
+
+// MARK: - Log Entries
+
+// Matches QSS LogSyncEntry from LogEntrySyncStorageService
+struct LogEntry: Decodable {
+ let cid: String // OrbitDB entry hash/CID
+ let hashedDbId: String // Hashed OrbitDB log ID
+ let communityId: String // Team ID
+ let entry: Data // Raw EncryptedAndSignedPayload bytes
+ let receivedAt: String // ISO 8601 UTC string
+
+ private enum CodingKeys: String, CodingKey {
+ case cid, hashedDbId, communityId, entry, receivedAt
+ }
+
+ // Node.js Buffer serializes to JSON as {"type":"Buffer","data":[byte,...]}
+ private struct NodeBuffer: Decodable {
+ let data: [UInt8]
+ }
+
+ init(from decoder: Decoder) throws {
+ let c = try decoder.container(keyedBy: CodingKeys.self)
+ cid = try c.decode(String.self, forKey: .cid)
+ hashedDbId = try c.decode(String.self, forKey: .hashedDbId)
+ communityId = try c.decode(String.self, forKey: .communityId)
+ receivedAt = try c.decode(String.self, forKey: .receivedAt)
+ let buffer = try c.decode(NodeBuffer.self, forKey: .entry)
+ entry = Data(buffer.data)
+ }
+}
+
+struct LogEntriesResponse: Decodable {
+ let entries: [LogEntry]
+}
diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/NSENetworkClient.swift b/packages/mobile/ios/QuietNotificationServiceExtension/NSENetworkClient.swift
new file mode 100644
index 0000000000..e946130e6d
--- /dev/null
+++ b/packages/mobile/ios/QuietNotificationServiceExtension/NSENetworkClient.swift
@@ -0,0 +1,111 @@
+import Foundation
+
+class NSENetworkClient {
+ let baseURL: URL
+ let session: URLSession
+
+ // Shared encoder/decoder to avoid reallocating on every call.
+ private static let encoder: JSONEncoder = {
+ let e = JSONEncoder()
+ e.outputFormatting = .sortedKeys
+ return e
+ }()
+
+ private static let decoder = JSONDecoder()
+
+ // Dedicated session with tight timeouts suitable for an NSE (30-second budget).
+ private static let defaultSession: URLSession = {
+ let config = URLSessionConfiguration.ephemeral
+ config.timeoutIntervalForRequest = 10
+ config.timeoutIntervalForResource = 20
+ return URLSession(configuration: config)
+ }()
+
+ init(baseURL: URL, session: URLSession? = nil) {
+ self.baseURL = baseURL
+ self.session = session ?? NSENetworkClient.defaultSession
+ }
+
+ // MARK: - POST /nse-auth/challenge
+
+ func requestChallenge(deviceId: String, teamId: String) async throws -> ChallengeResponse {
+ let url = baseURL.appendingPathComponent("nse-auth/challenge")
+ var request = URLRequest(url: url)
+ request.httpMethod = "POST"
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+
+ let body = ["deviceId": deviceId, "teamId": teamId]
+ request.httpBody = try Self.encoder.encode(body)
+
+ return try await perform(request: request, as: ChallengeResponse.self) { code in
+ throw NSEAuthError.challengeRequestFailed(statusCode: code)
+ }
+ }
+
+ // MARK: - POST /nse-auth/token
+
+ func requestToken(challengeId: String, deviceId: String, proof: ProofPayload) async throws -> TokenResponse {
+ let url = baseURL.appendingPathComponent("nse-auth/token")
+ var request = URLRequest(url: url)
+ request.httpMethod = "POST"
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+
+ let body = TokenRequest(challengeId: challengeId, deviceId: deviceId, proof: proof)
+ request.httpBody = try Self.encoder.encode(body)
+
+ return try await perform(request: request, as: TokenResponse.self) { code in
+ throw NSEAuthError.tokenRequestFailed(statusCode: code)
+ }
+ }
+
+ // MARK: - GET /nse-auth/logs/:teamId
+
+ func fetchLogEntries(teamId: String, since: Int64, token: String) async throws -> LogEntriesResponse {
+ guard let urlComponents = URLComponents(
+ url: baseURL.appendingPathComponent("nse-auth/logs/\(teamId)"),
+ resolvingAgainstBaseURL: false
+ ) else { throw NSEAuthError.invalidResponse }
+ var components = urlComponents
+ components.queryItems = [URLQueryItem(name: "since", value: String(since))]
+
+ guard let url = components.url else { throw NSEAuthError.invalidResponse }
+
+ var request = URLRequest(url: url)
+ request.httpMethod = "GET"
+ request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
+
+ return try await perform(request: request, as: LogEntriesResponse.self) { code in
+ throw NSEAuthError.logFetchFailed(statusCode: code)
+ }
+ }
+
+ // MARK: - Private helper
+
+ private func perform(
+ request: URLRequest,
+ as type: T.Type,
+ onError: (Int) throws -> Void
+ ) async throws -> T {
+ let (data, response): (Data, URLResponse)
+ do {
+ (data, response) = try await session.data(for: request)
+ } catch {
+ throw NSEAuthError.networkError(error)
+ }
+
+ guard let http = response as? HTTPURLResponse else {
+ throw NSEAuthError.invalidResponse
+ }
+
+ guard (200..<300).contains(http.statusCode) else {
+ try onError(http.statusCode)
+ throw NSEAuthError.invalidResponse // unreachable; onError always throws
+ }
+
+ do {
+ return try Self.decoder.decode(T.self, from: data)
+ } catch {
+ throw NSEAuthError.decodingFailed(error)
+ }
+ }
+}
diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/NotificationService.swift b/packages/mobile/ios/QuietNotificationServiceExtension/NotificationService.swift
index 479f08a346..65a3d38369 100644
--- a/packages/mobile/ios/QuietNotificationServiceExtension/NotificationService.swift
+++ b/packages/mobile/ios/QuietNotificationServiceExtension/NotificationService.swift
@@ -2,42 +2,96 @@
// NotificationService.swift
// QuietNotificationServiceExtension
//
-// Created by jakebot on 2026-03-10.
+// Created by Taea Vogel on 3/12/26.
//
import UserNotifications
+import os.log
+
+private let nseLog = OSLog(subsystem: "com.quietmobile.QuietNotificationServiceExtension", category: "NotificationService")
-/// Notification Service Extension
-/// This extension runs when a notification is received and can modify it before displaying
-///
-/// For Quiet: We use notifications as wake-up signals to tell the app to fetch new content
-/// No sensitive data is sent through notifications - just metadata
class NotificationService: UNNotificationServiceExtension {
var contentHandler: ((UNNotificationContent) -> Void)?
var bestAttemptContent: UNMutableNotificationContent?
+ var fetchTask: Task?
+
+ private static let iso8601 = ISO8601DateFormatter()
+ private let crypto = NSECryptoService()
+ private var authCache: [URL: NSEAuthService] = [:]
- override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
+ override func didReceive(
+ _ request: UNNotificationRequest,
+ withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
+ ) {
self.contentHandler = contentHandler
- bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent)
-
- if let bestAttemptContent = bestAttemptContent {
- print("Notification received in service extension")
- bestAttemptContent.title = "Quiet"
- bestAttemptContent.body = "You have new activity"
- bestAttemptContent.sound = .default
-
- print("Notification updated: \(bestAttemptContent.title)")
-
- contentHandler(bestAttemptContent)
+ bestAttemptContent = request.content.mutableCopy() as? UNMutableNotificationContent
+
+ fetchTask = Task {
+ await fetchAndUpdate(userInfo: request.content.userInfo)
}
}
override func serviceExtensionTimeWillExpire() {
- // Called just before the extension will be terminated by the system
- if let contentHandler = contentHandler, let bestAttemptContent = bestAttemptContent {
- print("Extension time expiring - delivering notification")
- contentHandler(bestAttemptContent)
+ fetchTask?.cancel()
+ deliver()
+ }
+
+ // MARK: - Private
+
+ private func fetchAndUpdate(userInfo: [AnyHashable: Any]) async {
+ defer { deliver() }
+
+ guard
+ let teamId = userInfo["teamId"] as? String,
+ let qssUrlString = userInfo["qssUrl"] as? String,
+ let qssUrl = URL(string: qssUrlString)
+ else {
+ return
}
+
+ do {
+ let auth: NSEAuthService
+ if let cached = authCache[qssUrl] {
+ auth = cached
+ } else {
+ let client = NSENetworkClient(baseURL: qssUrl)
+ let newAuth = NSEAuthService(client: client, crypto: crypto)
+ authCache[qssUrl] = newAuth
+ auth = newAuth
+ }
+
+ let since = NSEKeychainHelper.getLastSyncTimestamp()
+ let entries = try await auth.fetchNewEntries(teamId: teamId, since: since)
+
+ guard !Task.isCancelled else { return }
+
+ if !entries.isEmpty {
+ let newTs = entries.lazy
+ .compactMap { Self.iso8601.date(from: $0.receivedAt) }
+ .map { Int64($0.timeIntervalSince1970 * 1000) }
+ .max()
+ if let newTs {
+ NSEKeychainHelper.saveLastSyncTimestamp(newTs)
+ } else {
+ // All receivedAt failed to parse — advance by 1ms to avoid reprocessing
+ os_log("All receivedAt timestamps failed to parse; advancing sync pointer", log: nseLog, type: .fault)
+ NSEKeychainHelper.saveLastSyncTimestamp(NSEKeychainHelper.getLastSyncTimestamp() + 1)
+ }
+
+ guard let content = bestAttemptContent else { return }
+ content.badge = ((content.badge?.intValue ?? 0) + entries.count) as NSNumber
+ }
+ } catch {
+ os_log("fetchAndUpdate failed: %{public}@", log: nseLog, type: .error, String(describing: error))
+ }
+ }
+
+ private func deliver() {
+ guard let handler = contentHandler, let content = bestAttemptContent else { return }
+ // Nil contentHandler first to prevent double-delivery if serviceExtensionTimeWillExpire
+ // races with task completion — both paths call deliver(), only the first wins.
+ contentHandler = nil
+ handler(content)
}
}
From a08be816d7f5c6b39c3daa97c93b2ed01abd119e Mon Sep 17 00:00:00 2001
From: taea
Date: Tue, 24 Mar 2026 19:08:33 -0400
Subject: [PATCH 25/92] fix NSE UserDefaults app group + add NSE_JWT_SECRET env
placeholder
- NSEKeychainHelper lastSyncTimestamp now uses shared
UserDefaults(suiteName:"group.com.quietmobile") so main app can
seed initial value and NSE reads same store
- Worklog session 1 appended
NSE_JWT_SECRET added in qss submodule (.env.local.docker);
committed separately via submodule.
Co-Authored-By: Claude Sonnet 4.6
---
CLAUDE_WORKLOG.md | 139 ++++++++++++++++++
.../NSEKeychainHelper.swift | 14 +-
2 files changed, 150 insertions(+), 3 deletions(-)
create mode 100644 CLAUDE_WORKLOG.md
diff --git a/CLAUDE_WORKLOG.md b/CLAUDE_WORKLOG.md
new file mode 100644
index 0000000000..982eb4d5fd
--- /dev/null
+++ b/CLAUDE_WORKLOG.md
@@ -0,0 +1,139 @@
+You are continuing implementation of iOS push notifications for the
+ Quiet app (branch: fix/notification-mvp-tweaks in
+ /Users/taea/dev/quiet).
+
+ ## What this feature does
+ When a Quiet community member sends a message, FCM fires a push
+ notification to all iOS devices. The iOS Notification Service
+ Extension (NSE) intercepts it, authenticates with QSS, fetches new
+ OrbitDB log entries, and increments the badge count — all before the
+ notification is displayed.
+
+ ## Session 0
+
+ ### QSS backend (3rd-party/qss/)
+ - NEW: `app/src/nest/nse-auth/` module with three REST endpoints:
+ - `POST /nse-auth/challenge` — issues LFA-style challenge
+ `{type:'DEVICE', name:deviceId, nonce, timestamp}`
+ - `POST /nse-auth/token` — verifies Ed25519 signature (libsodium +
+ msgpackr) and returns 15-min JWT
+ - `GET /nse-auth/logs/:teamId?since=` — JWT-guarded; returns
+ log entries with Buffer serialized as `{type:'Buffer',data:[...]}`
+ - TODO: Add `NSE_JWT_SECRET` env var to `.env.local.docker` and prod
+ env
+
+ ### iOS NSE (packages/mobile/ios/QuietNotificationServiceExtension/)
+ - `NotificationService.swift` — full fetch/auth/badge flow (reads
+ teamId+qssUrl from APNs payload)
+ - `NSEAuthService.swift`, `NSENetworkClient.swift`,
+ `NSECryptoService.swift`, `NSEKeychainHelper.swift`,
+ `NSEModels.swift` — complete auth+fetch stack
+ - `NSECryptoService` signs with CryptoKit Ed25519; `ProofPayload`
+ now includes both `signature` and `publicKey`
+ - `NSEKeychainHelper.getDevicePrivateKey` Base58-decodes the stored
+ LFA key before returning raw bytes
+ - All three entitlements files now declare `group.com.quietmobile`
+ in `com.apple.security.application-groups`
+
+ ### Device credentials pipeline (new end-to-end)
+ - `@quiet/types`: `DeviceCredentialsUpdatedEvent` type +
+ `SocketEvents.DEVICE_CREDENTIALS_UPDATED`
+ - `sigchain.service.ts`: emits deviceId, teamId, signing private key
+ on iOS on every chain update
+ - Mobile state-manager: new `saveDeviceCredentials` action/saga
+ wired to the socket event
+ - `CommunicationModule.swift`:
+ `saveDeviceCredentials(_:teamId:signingPrivateKey:)` writes to
+ Keychain with `group.com.quietmobile` +
+ `kSecAttrAccessibleAfterFirstUnlock`
+ - `CommunicationBridge.m`: ObjC bridge registered
+
+ ### FCM payload fix (packages/backend/)
+ - `qps.service.ts` `sendBatchPush` now injects `teamId` and `qssUrl`
+ into the FCM data payload so the NSE guard clauses pass
+
+ ### LAN config (3rd-party/qss/app/)
+ - `docker-compose.quiet.yml`: bridge binding changed from
+ `127.0.0.1` → `0.0.0.0`
+ - `.env.local.docker`: `QSS_HOSTNAME=192.168.1.175` (update if IP
+ changes)
+ - Quiet backend needs `QSS_ENDPOINT=http://192.168.1.175:3003` in
+ its env at launch
+
+ ## Known remaining gaps
+
+ 1. **Device registration with QSS NSE auth** — the
+ `/nse-auth/challenge` endpoint currently issues a challenge to any
+ deviceId without verifying the device is actually registered.
+ There's a TODO comment in `nse-auth.service.ts` to add UCAN-level
+ trust anchor verification (check that the public key in the proof
+ belongs to a UCAN registered for that device+team).
+
+ 2. **`NSEKeychainHelper.lastSyncTimestamp` uses
+ `UserDefaults.standard`** — this is NOT shared with the main app. If
+ you want to seed an initial timestamp (to avoid fetching all
+ history on first run), the main app should write it using
+ `UserDefaults(suiteName: "group.com.quietmobile")` and the NSE
+ should read from the same suite.
+
+ 3. **`UserDefaults` for lastSyncTimestamp not in app group** —
+ `NSEKeychainHelper` uses `UserDefaults.standard` which is
+ process-local. Update both writer (if any) and reader to use
+ `UserDefaults(suiteName: "group.com.quietmobile")`.
+
+ 4. **Rebuild needed** — after the `qps.service.ts` and
+ `sigchain.service.ts` changes, rebuild the backend bundle: `npx
+ lerna run prepare --scope @quiet/backend`
+
+ 5. **QSS needs `NSE_JWT_SECRET`** — add to `.env.local.docker` for
+ stable JWT signing across restarts.
+
+ 6. **Test flow** — once rebuilt and redeployed:
+ - Confirm `quiet.device.id`, `quiet.device.privateKey.*`,
+ `quiet.team.id` appear in Keychain (check Console.app for
+ `saveDeviceCredentials: stored` logs)
+ - Send a message; check Console.app filtered to
+ `com.quietmobile.QuietNotificationServiceExtension`
+ - Look for `fetchAndUpdate: fetching entries since=`, auth logs,
+ and badge update
+
+ ## Architecture reference
+ - NSE files:
+ `packages/mobile/ios/QuietNotificationServiceExtension/`
+ - Main app bridge: `packages/mobile/ios/CommunicationModule.swift` +
+ `CommunicationBridge.m`
+ - Backend QPS: `packages/backend/src/nest/qps/qps.service.ts`
+ - QSS NSE auth: `3rd-party/qss/app/src/nest/nse-auth/`
+ - Types: `packages/types/src/keys.ts`,
+ `packages/types/src/socket.ts`
+ - Mobile sagas: `packages/mobile/src/store/keys/`
+
+ Continue from here.
+
+## Session 1
+
+### Fixed: UserDefaults → shared app group suite
+- `NSEKeychainHelper.getLastSyncTimestamp` and `saveLastSyncTimestamp` now use
+ `UserDefaults(suiteName: "group.com.quietmobile")` instead of `UserDefaults.standard`.
+- This allows the main app to seed an initial timestamp, and the NSE to read
+ the same value — both run in the same App Group.
+
+### Fixed: NSE_JWT_SECRET added to .env.local.docker
+- Added `NSE_JWT_SECRET=change-me-in-production` to
+ `3rd-party/qss/app/.env.local.docker`.
+- Without this, QSS generates a random per-process fallback secret so tokens
+ become invalid across restarts.
+
+## Known remaining gaps
+
+1. **Device registration trust** — `/nse-auth/challenge` issues to any deviceId
+ without UCAN-level verification. See TODO in `nse-auth.service.ts`.
+
+2. **Backend rebuild** — `npx lerna run prepare --scope @quiet/backend`
+ needed after `qps.service.ts` / `sigchain.service.ts` changes.
+
+3. **Test flow** — rebuild → redeploy QSS (`docker compose ... up`) → launch
+ iOS, join community → check Console.app for:
+ - `saveDeviceCredentials: stored` (main app)
+ - `fetchAndUpdate: fetching entries since=` (NSE)
+ - badge increment on next message
diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/NSEKeychainHelper.swift b/packages/mobile/ios/QuietNotificationServiceExtension/NSEKeychainHelper.swift
index 16d3dfd2e4..0dc8ade0c8 100644
--- a/packages/mobile/ios/QuietNotificationServiceExtension/NSEKeychainHelper.swift
+++ b/packages/mobile/ios/QuietNotificationServiceExtension/NSEKeychainHelper.swift
@@ -14,7 +14,13 @@ struct NSEKeychainHelper {
static func getDevicePrivateKey(deviceId: String) throws -> Data {
let account = devicePrivateKeyPrefix + deviceId
- return try readData(account: account, label: "device private key")
+ // Stored as UTF-8 Base58 string (LFA secretKey format); decode to raw bytes
+ let rawData = try readData(account: account, label: "device private key")
+ guard let base58String = String(data: rawData, encoding: .utf8),
+ let keyBytes = Base58.decode(base58String) else {
+ throw NSEAuthError.keychainError("device private key is not valid Base58")
+ }
+ return keyBytes
}
// MARK: - Device ID
@@ -40,11 +46,13 @@ struct NSEKeychainHelper {
// MARK: - Last sync timestamp (UserDefaults — not sensitive)
static func getLastSyncTimestamp() -> Int64 {
- return Int64(UserDefaults.standard.double(forKey: lastSyncKey))
+ let defaults = UserDefaults(suiteName: "group.com.quietmobile") ?? UserDefaults.standard
+ return Int64(defaults.double(forKey: lastSyncKey))
}
static func saveLastSyncTimestamp(_ ts: Int64) {
- UserDefaults.standard.set(Double(ts), forKey: lastSyncKey)
+ let defaults = UserDefaults(suiteName: "group.com.quietmobile") ?? UserDefaults.standard
+ defaults.set(Double(ts), forKey: lastSyncKey)
}
// MARK: - Private helpers
From 1e07df2cb3fe7389dad87ad63a2a3622fe6cdbdf Mon Sep 17 00:00:00 2001
From: taea
Date: Tue, 24 Mar 2026 19:15:28 -0400
Subject: [PATCH 26/92] update qss submodule: NSE_JWT_SECRET + lint fixes
Co-Authored-By: Claude Sonnet 4.6
---
3rd-party/qss | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/3rd-party/qss b/3rd-party/qss
index 2e43e197f9..f75262eef8 160000
--- a/3rd-party/qss
+++ b/3rd-party/qss
@@ -1 +1 @@
-Subproject commit 2e43e197f91ffa4731f5da2f46dbe21977f65940
+Subproject commit f75262eef84ea2be6378bd2b3b9174afdd29b21e
From 8c45568c00f627ec347bd2756c5f58fcdf9b08ee Mon Sep 17 00:00:00 2001
From: taea
Date: Tue, 24 Mar 2026 19:21:02 -0400
Subject: [PATCH 27/92] fix NSEMsgpack: map16 header + float64 timestamp to
match msgpackr
msgpackr always emits map16 (0xde 0x00 0x04) for objects and encodes
integers > 2^32 as float64. Both mismatches caused signature bytes to
differ from QSS verification, so auth always failed silently.
Co-Authored-By: Claude Sonnet 4.6
---
CLAUDE_WORKLOG.md | 25 +++++++
.../NSECryptoService.swift | 70 +++++++++++--------
2 files changed, 64 insertions(+), 31 deletions(-)
diff --git a/CLAUDE_WORKLOG.md b/CLAUDE_WORKLOG.md
index 982eb4d5fd..5fb8f0d23e 100644
--- a/CLAUDE_WORKLOG.md
+++ b/CLAUDE_WORKLOG.md
@@ -124,6 +124,31 @@ You are continuing implementation of iOS push notifications for the
- Without this, QSS generates a random per-process fallback secret so tokens
become invalid across restarts.
+## Session 2
+
+### Fixed: NSEMsgpack encoding mismatch (critical — signatures always failed)
+
+`msgpackr.pack()` uses two non-obvious encodings that the hand-rolled Swift
+encoder was getting wrong, causing signature verification to always fail:
+
+1. **Map header**: msgpackr uses `map16` (`0xde 0x00 0x04`) for ALL object
+ sizes, never `fixmap`. The Swift encoder was emitting `0x84` (fixmap).
+
+2. **Timestamp encoding**: JavaScript's `Date.now()` returns ~1.7e12, which
+ exceeds 2^32. msgpackr encodes values > 2^32 as `float64` (`0xcb` + 8-byte
+ IEEE 754), not `uint64`. The Swift encoder was emitting `uint64` (`0xcf`).
+
+Verification (msgpackr output):
+```
+de0004 a474797065 a644455649... a974696d657374616d70 cb4278e5f8c1600000
+^^^ ^^ float64, not 0xcf
+map16
+```
+
+Fix: updated `NSEMsgpack.encode()` in `NSECryptoService.swift` to emit
+`map16` header and `appendFloat64()` instead of `appendUInt64()` for the
+timestamp field.
+
## Known remaining gaps
1. **Device registration trust** — `/nse-auth/challenge` issues to any deviceId
diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/NSECryptoService.swift b/packages/mobile/ios/QuietNotificationServiceExtension/NSECryptoService.swift
index 5bfc26425c..8f1b12ff39 100644
--- a/packages/mobile/ios/QuietNotificationServiceExtension/NSECryptoService.swift
+++ b/packages/mobile/ios/QuietNotificationServiceExtension/NSECryptoService.swift
@@ -15,8 +15,17 @@ protocol DeviceCryptography {
extension DeviceCryptography {
func signChallengePayload(_ challenge: ChallengePayload, privateKeyData: Data) throws -> ProofPayload {
let payloadBytes = try NSEMsgpack.encode(challenge)
- let signatureBytes = try signBytes(payloadBytes, privateKeyData: privateKeyData)
- return ProofPayload(signature: Base58.encode(signatureBytes))
+ guard privateKeyData.count == 64 || privateKeyData.count == 32 else {
+ throw NSECryptoError.invalidKeyLength(expected: 64, got: privateKeyData.count)
+ }
+ let seed = privateKeyData.prefix(32)
+ let privateKey = try Curve25519.Signing.PrivateKey(rawRepresentation: seed)
+ let signatureBytes = try privateKey.signature(for: payloadBytes)
+ let publicKeyBytes = privateKey.publicKey.rawRepresentation
+ return ProofPayload(
+ signature: Base58.encode(signatureBytes),
+ publicKey: Base58.encode(publicKeyBytes)
+ )
}
func signBytes(_ message: Data, privateKeyData: Data) throws -> Data {
@@ -63,8 +72,13 @@ class NSECryptoService: DeviceCryptography {
}
// MARK: - Msgpack encoder
-// Encodes the ChallengePayload object in the same format as msgpackr.pack():
+// Encodes the ChallengePayload object in the same byte format as msgpackr.pack():
// { type: string, name: string, nonce: string, timestamp: number }
+//
+// msgpackr quirks that must be matched exactly:
+// 1. Objects always use map16 format (0xde + 2-byte count), never fixmap.
+// 2. Integers > 2^32 (e.g. Date.now() in ms) are encoded as float64 (0xcb).
+// 3. Strings 0–31 bytes → fixstr (0xa0|len); 32–255 bytes → str8 (0xd9, len).
// Field order must exactly match the JS object insertion order.
enum NSEMsgpack {
@@ -73,8 +87,11 @@ enum NSEMsgpack {
/// Encodes a ChallengePayload in the same byte format as msgpackr.pack().
static func encode(_ challenge: ChallengePayload) throws -> Data {
var out = Data()
- // fixmap with 4 elements: 0x84
- out.append(0x84)
+ // map16 with 4 elements: 0xde 0x00 0x04
+ // msgpackr always uses map16, never fixmap, regardless of element count.
+ out.append(0xde)
+ out.append(0x00)
+ out.append(0x04)
// key: "type" value: challenge.type
try appendString("type", to: &out)
try appendString(challenge.type, to: &out)
@@ -84,9 +101,11 @@ enum NSEMsgpack {
// key: "nonce" value: challenge.nonce
try appendString("nonce", to: &out)
try appendString(challenge.nonce, to: &out)
- // key: "timestamp" value: challenge.timestamp (Unix ms, Int64)
+ // key: "timestamp" value: challenge.timestamp
+ // Date.now() returns ms since epoch (~1.7e12), which exceeds 2^32.
+ // msgpackr encodes values > 2^32 as float64, not uint64.
try appendString("timestamp", to: &out)
- appendUInt64(UInt64(bitPattern: Int64(challenge.timestamp)), to: &out)
+ appendFloat64(Double(challenge.timestamp), to: &out)
return out
}
@@ -111,29 +130,18 @@ enum NSEMsgpack {
out.append(contentsOf: bytes)
}
- /// Encodes a positive integer as uint64 (0xcf + 8 bytes BE).
- /// msgpackr uses uint64 for integers that exceed 2^32.
- private static func appendUInt64(_ v: UInt64, to out: inout Data) {
- if v <= 0x7F {
- out.append(UInt8(v)) // positive fixint
- } else if v <= 0xFF {
- out.append(0xcc); out.append(UInt8(v)) // uint8
- } else if v <= 0xFFFF {
- out.append(0xcd)
- out.append(UInt8((v >> 8) & 0xFF))
- out.append(UInt8(v & 0xFF))
- } else if v <= 0xFFFFFFFF {
- out.append(0xce)
- out.append(UInt8((v >> 24) & 0xFF))
- out.append(UInt8((v >> 16) & 0xFF))
- out.append(UInt8((v >> 8) & 0xFF))
- out.append(UInt8(v & 0xFF))
- } else {
- // uint64: 0xcf + 8 bytes big-endian (used for Date.now() timestamps)
- out.append(0xcf)
- for shift in stride(from: 56, through: 0, by: -8) {
- out.append(UInt8((v >> shift) & 0xFF))
- }
- }
+ /// Encodes a Double as IEEE 754 float64 (0xcb + 8 bytes big-endian).
+ /// msgpackr uses float64 for JavaScript numbers that exceed 2^32.
+ private static func appendFloat64(_ v: Double, to out: inout Data) {
+ out.append(0xcb)
+ let bits = v.bitPattern // UInt64 IEEE 754 representation
+ out.append(UInt8((bits >> 56) & 0xFF))
+ out.append(UInt8((bits >> 48) & 0xFF))
+ out.append(UInt8((bits >> 40) & 0xFF))
+ out.append(UInt8((bits >> 32) & 0xFF))
+ out.append(UInt8((bits >> 24) & 0xFF))
+ out.append(UInt8((bits >> 16) & 0xFF))
+ out.append(UInt8((bits >> 8) & 0xFF))
+ out.append(UInt8(bits & 0xFF))
}
}
From 01009ae0d5657bff7407cdde197d5e4efe108620 Mon Sep 17 00:00:00 2001
From: taea
Date: Tue, 24 Mar 2026 19:24:18 -0400
Subject: [PATCH 28/92] =?UTF-8?q?fix=20NSE=20keychain=20read=20+=20qssUrl?=
=?UTF-8?q?=20scheme=20(ws=E2=86=92http)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- remove kSecAttrAccessible from SecItemCopyMatching; write-only attr
can silently prevent matches on some iOS versions
- qps.service: convert ws:// → http:// before including qssUrl in FCM
payload; URLSession rejects ws:// scheme for HTTP data tasks
Co-Authored-By: Claude Sonnet 4.6
---
CLAUDE_WORKLOG.md | 26 ++++++++++++++++---
packages/backend/src/nest/qps/qps.service.ts | 13 +++++++++-
.../NSEKeychainHelper.swift | 15 ++++++-----
3 files changed, 42 insertions(+), 12 deletions(-)
diff --git a/CLAUDE_WORKLOG.md b/CLAUDE_WORKLOG.md
index 5fb8f0d23e..a30e08865a 100644
--- a/CLAUDE_WORKLOG.md
+++ b/CLAUDE_WORKLOG.md
@@ -149,16 +149,34 @@ Fix: updated `NSEMsgpack.encode()` in `NSECryptoService.swift` to emit
`map16` header and `appendFloat64()` instead of `appendUInt64()` for the
timestamp field.
+## Session 3
+
+### Fixed: kSecAttrAccessible removed from Keychain read query
+`NSEKeychainHelper.readData` was including `kSecAttrAccessible` in
+`SecItemCopyMatching`. Apple's docs list it as a write attribute only;
+including it in a search query can silently prevent matches on some iOS
+versions. Removed from all read queries (accessibility is enforced at
+write time in `CommunicationModule.swift`).
+
+### Fixed: qssUrl scheme mismatch (ws:// → http://)
+`qps.service.ts` was sending `qssEndpoint` directly in the FCM data
+payload. `qssEndpoint` is a WebSocket URL (`ws://host:port`). The NSE
+uses this URL for HTTP REST calls via `URLSession.data(for:)`, which
+does not support the `ws://` scheme and would throw
+"unsupported URL scheme". Fix: replace `ws://` → `http://` and
+`wss://` → `https://` before putting the URL in the FCM payload.
+
## Known remaining gaps
-1. **Device registration trust** — `/nse-auth/challenge` issues to any deviceId
- without UCAN-level verification. See TODO in `nse-auth.service.ts`.
+1. **Device registration trust** — `/nse-auth/challenge` issues to any
+ deviceId without UCAN-level verification (TODO comment in
+ `nse-auth.service.ts`).
2. **Backend rebuild** — `npx lerna run prepare --scope @quiet/backend`
needed after `qps.service.ts` / `sigchain.service.ts` changes.
-3. **Test flow** — rebuild → redeploy QSS (`docker compose ... up`) → launch
- iOS, join community → check Console.app for:
+3. **Test flow** — rebuild → redeploy QSS → launch iOS, join community
+ → check Console.app for:
- `saveDeviceCredentials: stored` (main app)
- `fetchAndUpdate: fetching entries since=` (NSE)
- badge increment on next message
diff --git a/packages/backend/src/nest/qps/qps.service.ts b/packages/backend/src/nest/qps/qps.service.ts
index 071e6d15c0..fc3461d106 100644
--- a/packages/backend/src/nest/qps/qps.service.ts
+++ b/packages/backend/src/nest/qps/qps.service.ts
@@ -151,6 +151,17 @@ export class QPSService implements OnModuleInit {
batches.push(ucans.slice(i, i + PUSH_BATCH_SIZE))
}
+ // Convert ws:// → http:// (or wss:// → https://) so the NSE can use this
+ // URL for HTTP REST calls to /nse-auth/. URLSession does not support ws://
+ // scheme for regular data tasks.
+ const wsUrl = this.qssService.qssEndpoint
+ const qssUrl = wsUrl?.replace(/^wss?:\/\//, match => (match === 'wss://' ? 'https://' : 'http://'))
+ const mergedData: Record = {
+ teamId,
+ ...(qssUrl != null ? { qssUrl } : {}),
+ ...data,
+ }
+
this.logger.info(
`Triggering push notifications for team ${teamId} with ${ucans.length} UCAN(s) in ${batches.length} batch(es)`
)
@@ -161,7 +172,7 @@ export class QPSService implements OnModuleInit {
{
ts: DateTime.utc().toMillis(),
status: CommunityOperationStatus.SENDING,
- payload: { ucans: batch, title, body, data },
+ payload: { ucans: batch, title, body, data: mergedData },
},
true
)
diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/NSEKeychainHelper.swift b/packages/mobile/ios/QuietNotificationServiceExtension/NSEKeychainHelper.swift
index 0dc8ade0c8..6acc12302f 100644
--- a/packages/mobile/ios/QuietNotificationServiceExtension/NSEKeychainHelper.swift
+++ b/packages/mobile/ios/QuietNotificationServiceExtension/NSEKeychainHelper.swift
@@ -62,14 +62,15 @@ struct NSEKeychainHelper {
private static let accessGroup = "group.com.quietmobile"
private static func readData(account: String, label: String) throws -> Data {
- // Main app must write with kSecAttrAccessibleAfterFirstUnlock for NSE to read while device is locked
+ // Note: kSecAttrAccessible is intentionally omitted — it's a write attribute.
+ // Including it in a read query can cause silent failures on some iOS versions.
+ // Accessibility is enforced at write time (kSecAttrAccessibleAfterFirstUnlock).
let query: [CFString: Any] = [
- kSecClass: kSecClassGenericPassword,
- kSecAttrAccount: account,
- kSecAttrAccessGroup: accessGroup,
- kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlock,
- kSecReturnData: true,
- kSecMatchLimit: kSecMatchLimitOne,
+ kSecClass: kSecClassGenericPassword,
+ kSecAttrAccount: account,
+ kSecAttrAccessGroup: accessGroup,
+ kSecReturnData: true,
+ kSecMatchLimit: kSecMatchLimitOne,
]
var result: CFTypeRef?
From e82de2e30c6c57bc3175d47c98003a485a7dff23 Mon Sep 17 00:00:00 2001
From: taea
Date: Tue, 24 Mar 2026 19:35:55 -0400
Subject: [PATCH 29/92] fix ISO8601 fractional seconds + session 4 audit
NotificationService was using ISO8601DateFormatter() with default options;
Luxon always emits milliseconds so every receivedAt parse failed, badge
stayed wrong. Fixed with .withFractionalSeconds option.
Co-Authored-By: Claude Sonnet 4.6
---
CLAUDE_WORKLOG.md | 48 ++++++++++----
.../NotificationService.swift | 64 ++++++++++++++++---
2 files changed, 88 insertions(+), 24 deletions(-)
diff --git a/CLAUDE_WORKLOG.md b/CLAUDE_WORKLOG.md
index a30e08865a..7fc05e06d5 100644
--- a/CLAUDE_WORKLOG.md
+++ b/CLAUDE_WORKLOG.md
@@ -166,17 +166,37 @@ does not support the `ws://` scheme and would throw
"unsupported URL scheme". Fix: replace `ws://` → `http://` and
`wss://` → `https://` before putting the URL in the FCM payload.
-## Known remaining gaps
-
-1. **Device registration trust** — `/nse-auth/challenge` issues to any
- deviceId without UCAN-level verification (TODO comment in
- `nse-auth.service.ts`).
-
-2. **Backend rebuild** — `npx lerna run prepare --scope @quiet/backend`
- needed after `qps.service.ts` / `sigchain.service.ts` changes.
-
-3. **Test flow** — rebuild → redeploy QSS → launch iOS, join community
- → check Console.app for:
- - `saveDeviceCredentials: stored` (main app)
- - `fetchAndUpdate: fetching entries since=` (NSE)
- - badge increment on next message
+## Session 4
+
+### Fixed: ISO8601DateFormatter missing .withFractionalSeconds
+`NotificationService.swift` used `ISO8601DateFormatter()` with default
+options to parse `receivedAt` timestamps from QSS log entries. Luxon's
+`DateTime.toISO()` always includes milliseconds (`"...T10:00:00.000Z"`),
+which the default formatter cannot parse — `compactMap` would return `[]`
+for all entries, making `newTs` nil and triggering the "advancing sync
+pointer by 1ms" fallback. Badge would always be wrong.
+Fixed by configuring formatter with `[.withInternetDateTime, .withFractionalSeconds]`.
+
+### Verified clean: end-to-end flow audit
+Full trace reviewed — no additional blocking issues found:
+- `communityId` in QSS log entries = `sigchain.team.id` = `teamId` in FCM payload ✓
+- `LogEntriesResponse { entries: [...] }` matches Swift `LogEntriesResponse.entries` decoder ✓
+- `entry` NodeBuffer `{type:"Buffer",data:[...]}` decodes correctly to `Data` in Swift ✓
+- `kSecAttrAccessGroup: "group.com.quietmobile"` with App Group entitlement is valid
+ for cross-process Keychain sharing (NSE + main app, no keychain-access-groups needed) ✓
+- `KeychainHelper.swift` in NSE folder is unrelated dead code; NSE uses `NSEKeychainHelper` ✓
+- `process.platform === 'ios'` in nodejs-mobile confirmed by existing codebase patterns ✓
+
+## Feature status: READY FOR TESTING
+
+All known blocking code bugs fixed across 4 sessions. Remaining items
+require user action:
+
+1. **Backend rebuild** — `npx lerna run prepare --scope @quiet/backend`
+2. **QSS redeploy** — `docker compose -f docker-compose.quiet.yml up -d` in `3rd-party/qss/app/`
+3. **Test on device**:
+ - Launch app → join community → watch Console.app for `saveDeviceCredentials: stored`
+ - From another device, send a message
+ - Watch NSE Console.app for `fetchAndUpdate: teamId=`, `authenticate:`, `fetched X entries`, badge update
+4. **UCAN trust** (post-MVP) — `/nse-auth/challenge` should verify the device public key
+ is registered in the @localfirst/auth sigchain for the teamId (TODO comment in `nse-auth.service.ts`)
diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/NotificationService.swift b/packages/mobile/ios/QuietNotificationServiceExtension/NotificationService.swift
index 65a3d38369..4110776833 100644
--- a/packages/mobile/ios/QuietNotificationServiceExtension/NotificationService.swift
+++ b/packages/mobile/ios/QuietNotificationServiceExtension/NotificationService.swift
@@ -16,7 +16,14 @@ class NotificationService: UNNotificationServiceExtension {
var bestAttemptContent: UNMutableNotificationContent?
var fetchTask: Task?
- private static let iso8601 = ISO8601DateFormatter()
+ // Luxon's toISO() always includes milliseconds (e.g. "2024-03-21T10:00:00.000Z").
+ // The default ISO8601DateFormatter does not parse fractional seconds —
+ // withFractionalSeconds is required or every timestamp parse will fail.
+ private static let iso8601: ISO8601DateFormatter = {
+ let f = ISO8601DateFormatter()
+ f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ return f
+ }()
private let crypto = NSECryptoService()
private var authCache: [URL: NSEAuthService] = [:]
@@ -24,6 +31,10 @@ class NotificationService: UNNotificationServiceExtension {
_ request: UNNotificationRequest,
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
) {
+ os_log("didReceive: identifier=%{public}@", log: nseLog, type: .info, request.identifier)
+ os_log("didReceive: userInfo keys=%{public}@", log: nseLog, type: .info,
+ request.content.userInfo.keys.map { "\($0)" }.sorted().joined(separator: ", "))
+
self.contentHandler = contentHandler
bestAttemptContent = request.content.mutableCopy() as? UNMutableNotificationContent
@@ -33,6 +44,7 @@ class NotificationService: UNNotificationServiceExtension {
}
override func serviceExtensionTimeWillExpire() {
+ os_log("serviceExtensionTimeWillExpire: delivering best-attempt content", log: nseLog, type: .error)
fetchTask?.cancel()
deliver()
}
@@ -42,19 +54,37 @@ class NotificationService: UNNotificationServiceExtension {
private func fetchAndUpdate(userInfo: [AnyHashable: Any]) async {
defer { deliver() }
- guard
- let teamId = userInfo["teamId"] as? String,
- let qssUrlString = userInfo["qssUrl"] as? String,
- let qssUrl = URL(string: qssUrlString)
- else {
+ os_log("fetchAndUpdate: start", log: nseLog, type: .debug)
+
+ guard let teamId = userInfo["teamId"] as? String else {
+ os_log("fetchAndUpdate: missing 'teamId' in userInfo; payload keys=%{public}@",
+ log: nseLog, type: .error,
+ userInfo.keys.map { "\($0)" }.sorted().joined(separator: ", "))
return
}
+ guard let qssUrlString = userInfo["qssUrl"] as? String else {
+ os_log("fetchAndUpdate: missing 'qssUrl' in userInfo (teamId=%{public}@)",
+ log: nseLog, type: .error, teamId)
+ return
+ }
+
+ guard let qssUrl = URL(string: qssUrlString) else {
+ os_log("fetchAndUpdate: 'qssUrl' is not a valid URL: %{public}@",
+ log: nseLog, type: .error, qssUrlString)
+ return
+ }
+
+ os_log("fetchAndUpdate: teamId=%{public}@ qssUrl=%{public}@",
+ log: nseLog, type: .info, teamId, qssUrlString)
+
do {
let auth: NSEAuthService
if let cached = authCache[qssUrl] {
+ os_log("fetchAndUpdate: using cached NSEAuthService for %{public}@", log: nseLog, type: .debug, qssUrlString)
auth = cached
} else {
+ os_log("fetchAndUpdate: creating new NSEAuthService for %{public}@", log: nseLog, type: .debug, qssUrlString)
let client = NSENetworkClient(baseURL: qssUrl)
let newAuth = NSEAuthService(client: client, crypto: crypto)
authCache[qssUrl] = newAuth
@@ -62,16 +92,25 @@ class NotificationService: UNNotificationServiceExtension {
}
let since = NSEKeychainHelper.getLastSyncTimestamp()
+ os_log("fetchAndUpdate: fetching entries since=%{public}lld", log: nseLog, type: .info, since)
+
let entries = try await auth.fetchNewEntries(teamId: teamId, since: since)
+ os_log("fetchAndUpdate: fetched %{public}d entries", log: nseLog, type: .info, entries.count)
- guard !Task.isCancelled else { return }
+ guard !Task.isCancelled else {
+ os_log("fetchAndUpdate: task cancelled after fetch", log: nseLog, type: .info)
+ return
+ }
- if !entries.isEmpty {
+ if entries.isEmpty {
+ os_log("fetchAndUpdate: no new entries, delivering as-is", log: nseLog, type: .info)
+ } else {
let newTs = entries.lazy
.compactMap { Self.iso8601.date(from: $0.receivedAt) }
.map { Int64($0.timeIntervalSince1970 * 1000) }
.max()
if let newTs {
+ os_log("fetchAndUpdate: saving lastSyncTimestamp=%{public}lld", log: nseLog, type: .info, newTs)
NSEKeychainHelper.saveLastSyncTimestamp(newTs)
} else {
// All receivedAt failed to parse — advance by 1ms to avoid reprocessing
@@ -79,8 +118,13 @@ class NotificationService: UNNotificationServiceExtension {
NSEKeychainHelper.saveLastSyncTimestamp(NSEKeychainHelper.getLastSyncTimestamp() + 1)
}
- guard let content = bestAttemptContent else { return }
- content.badge = ((content.badge?.intValue ?? 0) + entries.count) as NSNumber
+ guard let content = bestAttemptContent else {
+ os_log("fetchAndUpdate: bestAttemptContent is nil, cannot update badge", log: nseLog, type: .error)
+ return
+ }
+ let newBadge = (content.badge?.intValue ?? 0) + entries.count
+ os_log("fetchAndUpdate: updating badge to %{public}d", log: nseLog, type: .info, newBadge)
+ content.badge = newBadge as NSNumber
}
} catch {
os_log("fetchAndUpdate failed: %{public}@", log: nseLog, type: .error, String(describing: error))
From d2b16a4d29bca06779dd4f29f2a795f5f13a4b0f Mon Sep 17 00:00:00 2001
From: taea
Date: Tue, 24 Mar 2026 19:38:00 -0400
Subject: [PATCH 30/92] session 5: loop complete, feature ready for testing
---
CLAUDE_WORKLOG.md | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/CLAUDE_WORKLOG.md b/CLAUDE_WORKLOG.md
index 7fc05e06d5..5aa70c6b1d 100644
--- a/CLAUDE_WORKLOG.md
+++ b/CLAUDE_WORKLOG.md
@@ -200,3 +200,11 @@ require user action:
- Watch NSE Console.app for `fetchAndUpdate: teamId=`, `authenticate:`, `fetched X entries`, badge update
4. **UCAN trust** (post-MVP) — `/nse-auth/challenge` should verify the device public key
is registered in the @localfirst/auth sigchain for the teamId (TODO comment in `nse-auth.service.ts`)
+
+## Session 5
+
+### Loop terminating — feature complete
+
+No remaining blocking code bugs. All 4 sessions of fixes are committed. Remaining items
+are all user-action (rebuild, redeploy, device test) or post-MVP (UCAN trust).
+Recurring cron job f1f8b51a deleted.
From 420cacf750ccc92be84a8e8f909c972c02b94d0c Mon Sep 17 00:00:00 2001
From: taea
Date: Tue, 24 Mar 2026 22:05:30 -0400
Subject: [PATCH 31/92] update auth submodule to match qss (397a62f)
---
3rd-party/auth | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/3rd-party/auth b/3rd-party/auth
index c44019ab9a..397a62f10b 160000
--- a/3rd-party/auth
+++ b/3rd-party/auth
@@ -1 +1 @@
-Subproject commit c44019ab9a9323bf53f58ed4bfb1ff44827562a1
+Subproject commit 397a62f10b1234d260f37945a10f46a7a62c00bc
From a98461fc74c70d179ab74ada8ab517d3b9dfe9ea Mon Sep 17 00:00:00 2001
From: taea
Date: Wed, 25 Mar 2026 15:45:49 -0400
Subject: [PATCH 32/92] working prototype
---
.gitignore | 1 +
.../backend/src/nest/auth/sigchain.service.ts | 30 +-
.../connections-manager.service.ts | 2 +
packages/backend/src/nest/qss/qss.service.ts | 7 +-
packages/mobile/ios/CommunicationBridge.m | 1 +
packages/mobile/ios/CommunicationModule.swift | 46 +-
packages/mobile/ios/KeychainHandler.swift | 97 ++-
packages/mobile/ios/Podfile | 5 +
packages/mobile/ios/Podfile.lock | 6 +-
packages/mobile/ios/Quiet.debug.xcconfig | 2 +
packages/mobile/ios/Quiet.release.xcconfig | 2 +
.../ios/Quiet.xcodeproj/project.pbxproj | 155 +++--
packages/mobile/ios/Quiet/Info.plist | 2 +
packages/mobile/ios/Quiet/Quiet.entitlements | 4 +-
.../mobile/ios/Quiet/QuietDebug.entitlements | 4 +-
...otificationServiceExtension.debug.xcconfig | 2 +
...ificationServiceExtension.release.xcconfig | 2 +
.../Info.plist | 2 +
.../NSEAuthService.swift | 18 +-
.../NSECryptoService.swift | 554 +++++++++++++++++-
.../NSEKeychainHelper.swift | 25 +-
.../NSEModels.swift | 1 +
.../NSENetworkClient.swift | 16 +
.../NotificationService.swift | 40 +-
...tNotificationServiceExtension.entitlements | 8 +-
.../startConnection/startConnection.saga.ts | 7 +-
.../mobile/src/store/keys/keys.master.saga.ts | 6 +-
packages/mobile/src/store/keys/keys.slice.ts | 3 +-
.../saveDeviceCredentials.saga.ts | 22 +
packages/types/src/keys.ts | 7 +
packages/types/src/socket.ts | 4 +-
31 files changed, 972 insertions(+), 109 deletions(-)
create mode 100644 packages/mobile/ios/Quiet.debug.xcconfig
create mode 100644 packages/mobile/ios/Quiet.release.xcconfig
create mode 100644 packages/mobile/ios/QuietNotificationServiceExtension.debug.xcconfig
create mode 100644 packages/mobile/ios/QuietNotificationServiceExtension.release.xcconfig
create mode 100644 packages/mobile/src/store/keys/saveDeviceCredentials/saveDeviceCredentials.saga.ts
diff --git a/.gitignore b/.gitignore
index 5d316e1825..3782f31124 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,6 +10,7 @@ packages/.DS_Store
*.log
clangd/
**/.claude/settings.local.json
+.claude/worktrees
# Yarn
diff --git a/packages/backend/src/nest/auth/sigchain.service.ts b/packages/backend/src/nest/auth/sigchain.service.ts
index cfff887d84..54cc3cfb3e 100644
--- a/packages/backend/src/nest/auth/sigchain.service.ts
+++ b/packages/backend/src/nest/auth/sigchain.service.ts
@@ -24,7 +24,7 @@ import { SERVER_IO_PROVIDER } from '../const'
import { ServerIoProviderTypes } from '../types'
import EventEmitter from 'events'
import { GetChainFilter, StoredKeyType } from './types'
-import { KeysUpdatedEvent } from '@quiet/types'
+import { DeviceCredentialsUpdatedEvent, KeysUpdatedEvent } from '@quiet/types'
@Injectable()
export class SigChainService extends EventEmitter {
@@ -144,6 +144,7 @@ export class SigChainService extends EventEmitter {
private handleChainUpdate = (teamName: string) => {
this._updateUsersOnChainUpdate(teamName)
this._updateKeysOnChainUpdate(teamName)
+ this._updateDeviceCredentials(teamName)
this.emit('updated', teamName)
this.saveChain(teamName)
this.logger.info('Chain updated, emitted updated event')
@@ -242,6 +243,33 @@ export class SigChainService extends EventEmitter {
this.serverIoProvider.io.emit(SocketEvents.KEYS_UPDATED, keyUpdateEvent)
}
+ /**
+ * Emit device credentials to iOS so the NSE can authenticate with QSS.
+ * Only runs on iOS; no-ops on other platforms.
+ */
+ private _updateDeviceCredentials(teamName: string): void {
+ if ((process.platform as string) !== 'ios') return
+ try {
+ const sigchain = this.getChain({ teamName })
+ if (sigchain?.team == null) return
+ const teamId = sigchain.team.id
+ const device = sigchain.device
+ if (!device?.deviceId || !device.keys?.signature?.secretKey) {
+ this.logger.warn('Device credentials not available, skipping NSE credential update')
+ return
+ }
+ const event: DeviceCredentialsUpdatedEvent = {
+ deviceId: device.deviceId,
+ teamId,
+ signingPrivateKey: device.keys.signature.secretKey,
+ }
+ this.serverIoProvider.io.emit(SocketEvents.DEVICE_CREDENTIALS_UPDATED, event)
+ this.logger.info('Emitted device credentials for NSE')
+ } catch (e) {
+ this.logger.error('Failed to emit device credentials', e)
+ }
+ }
+
private attachSocketListeners(chain: SigChain): void {
this.logger.info('Attaching socket listeners')
const listener = (): void => {
diff --git a/packages/backend/src/nest/connections-manager/connections-manager.service.ts b/packages/backend/src/nest/connections-manager/connections-manager.service.ts
index d1ac5c5970..32ba736d90 100644
--- a/packages/backend/src/nest/connections-manager/connections-manager.service.ts
+++ b/packages/backend/src/nest/connections-manager/connections-manager.service.ts
@@ -116,6 +116,8 @@ export class ConnectionsManagerService extends EventEmitter implements OnModuleI
})
)
+ this.logger.info('QSS_ENDPOINT', this.qssEndpoint)
+
await this.init()
}
diff --git a/packages/backend/src/nest/qss/qss.service.ts b/packages/backend/src/nest/qss/qss.service.ts
index 47a8c796eb..7e99cb4f29 100644
--- a/packages/backend/src/nest/qss/qss.service.ts
+++ b/packages/backend/src/nest/qss/qss.service.ts
@@ -609,13 +609,18 @@ export class QSSService extends EventEmitter implements OnModuleDestroy, OnModul
this.startLogPullInterval(teamId)
}
- authConnection?.on(QSSEvents.QSS_AUTH_CONNECTED, startLogPullInterval)
+ authConnection?.on(QSSEvents.QSS_AUTH_CONNECTED, () => {
+ this.socketService.serverIoProvider.io.emit(SocketEvents.QSS_CONNECTED)
+ startLogPullInterval()
+ })
authConnection?.on(QSSEvents.QSS_DISCONNECTED, () => {
this.logger.info('Disconnected event received, stopping log entry pull interval', teamId)
+ this.socketService.serverIoProvider.io.emit(SocketEvents.QSS_DISCONNECTED)
this._stopLogPullInterval(teamId)
})
if (authConnection?.active) {
+ this.socketService.serverIoProvider.io.emit(SocketEvents.QSS_CONNECTED)
startLogPullInterval()
}
}
diff --git a/packages/mobile/ios/CommunicationBridge.m b/packages/mobile/ios/CommunicationBridge.m
index 9212ba937e..50b6c804a4 100644
--- a/packages/mobile/ios/CommunicationBridge.m
+++ b/packages/mobile/ios/CommunicationBridge.m
@@ -7,4 +7,5 @@ @interface RCT_EXTERN_MODULE(CommunicationModule, RCTEventEmitter)
RCT_EXTERN_METHOD(checkNotificationPermission)
RCT_EXTERN_METHOD(saveKeysInKeychain:(NSArray *)newKeys)
RCT_EXTERN_METHOD(saveUserMetadata:(NSArray *)updatedMetadata)
+RCT_EXTERN_METHOD(saveDeviceCredentials:(NSString *)deviceId teamId:(NSString *)teamId signingPrivateKey:(NSString *)signingPrivateKey)
@end
diff --git a/packages/mobile/ios/CommunicationModule.swift b/packages/mobile/ios/CommunicationModule.swift
index cb57fd7569..5c37920ec0 100644
--- a/packages/mobile/ios/CommunicationModule.swift
+++ b/packages/mobile/ios/CommunicationModule.swift
@@ -19,7 +19,7 @@ class CommunicationModule: RCTEventEmitter {
let userMetadataHandler = UserMetadataHandler()
private var hasListeners = false
-
+
@objc
func sendDataPort(port: UInt16, socketIOSecret: String) {
self.sendEvent(withName: CommunicationModule.BACKEND_EVENT_IDENTIFIER, body: ["channelName": CommunicationModule.WEBSOCKET_CONNECTION_CHANNEL, "payload": ["dataPort": port, "socketIOSecret": socketIOSecret]])
@@ -81,6 +81,50 @@ class CommunicationModule: RCTEventEmitter {
}
}
+ @objc
+ func saveDeviceCredentials(_ deviceId: NSString, teamId: NSString, signingPrivateKey: NSString) {
+ let deviceIdStr = deviceId as String
+ let teamIdStr = teamId as String
+ let keyStr = signingPrivateKey as String
+ let accessGroup = Bundle.main.object(forInfoDictionaryKey: "QuietKeychainAccessGroup") as? String
+
+ func writeItem(account: String, value: String) {
+ guard let data = value.data(using: .utf8) else {
+ CommunicationModule.logger.error("saveDeviceCredentials: failed to encode \(account) as UTF-8")
+ return
+ }
+ // Delete any existing item first to allow updates
+ var deleteQuery: [CFString: Any] = [
+ kSecClass: kSecClassGenericPassword,
+ kSecAttrAccount: account,
+ ]
+ if let accessGroup {
+ deleteQuery[kSecAttrAccessGroup] = accessGroup
+ }
+ SecItemDelete(deleteQuery as CFDictionary)
+
+ var addQuery: [CFString: Any] = [
+ kSecClass: kSecClassGenericPassword,
+ kSecAttrAccount: account,
+ kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlock,
+ kSecValueData: data,
+ ]
+ if let accessGroup {
+ addQuery[kSecAttrAccessGroup] = accessGroup
+ }
+ let status = SecItemAdd(addQuery as CFDictionary, nil)
+ if status != errSecSuccess {
+ CommunicationModule.logger.error("saveDeviceCredentials: SecItemAdd failed for \(account): \(status)")
+ } else {
+ CommunicationModule.logger.info("saveDeviceCredentials: stored \(account)")
+ }
+ }
+
+ writeItem(account: "quiet.device.id", value: deviceIdStr)
+ writeItem(account: "quiet.team.id", value: teamIdStr)
+ writeItem(account: "quiet.device.privateKey.\(deviceIdStr)", value: keyStr)
+ }
+
@objc
func saveUserMetadata(_ updatedMetadata: NSArray) {
let decoder = JSONDecoder()
diff --git a/packages/mobile/ios/KeychainHandler.swift b/packages/mobile/ios/KeychainHandler.swift
index 355365f045..7ad76b1409 100644
--- a/packages/mobile/ios/KeychainHandler.swift
+++ b/packages/mobile/ios/KeychainHandler.swift
@@ -41,16 +41,27 @@ public struct NamedKey: Codable {
// TODO: add string to key object conversion (e.g. string to SymmetricKey)
@objc(KeychainHandler)
class KeychainHandler: NSObject {
- private let keychainGroupName: String = "com.quietmobile"
+ private let keychainService: String = "com.quietmobile"
+ private lazy var accessGroup: String? = Bundle.main.object(forInfoDictionaryKey: "QuietKeychainAccessGroup") as? String
private static let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "KeychainHandler")
public func getLfaKeyString(keyName: String) throws -> String {
do {
- let password: String = try _getKeyImpl(keyName: keyName)
+ let password: String = try _getKeyImpl(keyName: keyName, includeAccessGroup: true)
return password
} catch KeychainError.noPassword {
- throw KeychainHandlerError.noKeyFound
+ do {
+ let password = try _getKeyImpl(keyName: keyName, includeAccessGroup: false)
+ migrateLegacyKeyIfNeeded(keyName: keyName, value: password)
+ return password
+ } catch KeychainError.noPassword {
+ throw KeychainHandlerError.noKeyFound
+ } catch KeychainError.unexpectedPasswordData {
+ throw KeychainHandlerError.malformedKey
+ } catch {
+ throw KeychainHandlerError.unhandledError(reason: error)
+ }
} catch KeychainError.unexpectedPasswordData {
throw KeychainHandlerError.malformedKey
} catch ConversionError.stringToBytesError {
@@ -61,42 +72,48 @@ class KeychainHandler: NSObject {
}
public func addLfaKey(namedKey: NamedKey) throws -> KeyAddStatus {
- var existingKey: String?
- do {
- existingKey = try getLfaKeyString(keyName: namedKey.keyName)
- } catch KeychainHandlerError.noKeyFound {
- existingKey = nil
- } catch KeychainHandlerError.malformedKey {
- existingKey = nil
- } catch {
- KeychainHandler.logger.error("Error while getting existing LFA key for name \(namedKey.keyName): \(error)")
- throw error
+ if let sharedKey = try? _getKeyImpl(keyName: namedKey.keyName, includeAccessGroup: true) {
+ guard sharedKey == namedKey.key else {
+ return KeyAddStatus.duplicateScope
+ }
+ return KeyAddStatus.success
}
- guard existingKey == nil else {
- guard existingKey == namedKey.key else { return KeyAddStatus.duplicateScope }
- return KeyAddStatus.success
+ if let legacyKey = try? _getKeyImpl(keyName: namedKey.keyName, includeAccessGroup: false) {
+ guard legacyKey == namedKey.key else {
+ return KeyAddStatus.duplicateScope
+ }
}
do {
let keyData: Data = try _stringToBytes(str: namedKey.key)
- let addStatus: KeyAddStatus = try _addKeyToKeychainImpl(keyName: namedKey.keyName, keyData: keyData)
+ let addStatus: KeyAddStatus = try _addKeyToKeychainImpl(
+ keyName: namedKey.keyName,
+ keyData: keyData,
+ includeAccessGroup: true
+ )
+ if addStatus == .success {
+ try? _deleteKeyImpl(keyName: namedKey.keyName, includeAccessGroup: false)
+ }
return addStatus
} catch {
throw KeychainHandlerError.unhandledError(reason: error)
}
}
- private func _getKeyImpl(keyName: String) throws -> String {
+ private func _getKeyImpl(keyName: String, includeAccessGroup: Bool) throws -> String {
var existingKey: CFTypeRef?
- let query: [String: Any] = [
+ var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
- kSecAttrService as String: keychainGroupName,
+ kSecAttrService as String: keychainService,
kSecAttrAccount as String: keyName,
kSecMatchLimit as String: kSecMatchLimitOne,
kSecReturnAttributes as String: true,
kSecReturnData as String: true
]
+ if includeAccessGroup, let accessGroup {
+ query[kSecAttrAccessGroup as String] = accessGroup
+ }
let status: OSStatus = SecItemCopyMatching(query as CFDictionary, &existingKey)
guard status != errSecItemNotFound else { throw KeychainError.noPassword }
guard status == errSecSuccess else { throw KeychainError.unhandledError(status: status) }
@@ -109,14 +126,17 @@ class KeychainHandler: NSObject {
return password
}
- private func _addKeyToKeychainImpl(keyName: String, keyData: Data) throws -> KeyAddStatus {
- let query: [String: Any] = [
+ private func _addKeyToKeychainImpl(keyName: String, keyData: Data, includeAccessGroup: Bool) throws -> KeyAddStatus {
+ var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: keyName,
- kSecAttrService as String: keychainGroupName,
+ kSecAttrService as String: keychainService,
kSecValueData as String: keyData,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock
]
+ if includeAccessGroup, let accessGroup {
+ query[kSecAttrAccessGroup as String] = accessGroup
+ }
let status: OSStatus = SecItemAdd(query as CFDictionary, nil)
if status == errSecSuccess {
@@ -133,4 +153,35 @@ class KeychainHandler: NSObject {
guard bytes != nil else { throw ConversionError.stringToBytesError }
return bytes!
}
+
+ private func _deleteKeyImpl(keyName: String, includeAccessGroup: Bool) throws {
+ var query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: keychainService,
+ kSecAttrAccount as String: keyName,
+ ]
+ if includeAccessGroup, let accessGroup {
+ query[kSecAttrAccessGroup as String] = accessGroup
+ }
+
+ let status = SecItemDelete(query as CFDictionary)
+ if status != errSecSuccess && status != errSecItemNotFound {
+ throw KeychainError.unhandledError(status: status)
+ }
+ }
+
+ private func migrateLegacyKeyIfNeeded(keyName: String, value: String) {
+ guard let data = value.data(using: .utf8) else {
+ return
+ }
+
+ do {
+ let addStatus = try _addKeyToKeychainImpl(keyName: keyName, keyData: data, includeAccessGroup: true)
+ if addStatus == .success {
+ try? _deleteKeyImpl(keyName: keyName, includeAccessGroup: false)
+ }
+ } catch {
+ KeychainHandler.logger.error("Failed to migrate legacy key \(keyName) into shared access group: \(error.localizedDescription)")
+ }
+ }
}
diff --git a/packages/mobile/ios/Podfile b/packages/mobile/ios/Podfile
index 82cd61c7b3..ef6ce514a5 100644
--- a/packages/mobile/ios/Podfile
+++ b/packages/mobile/ios/Podfile
@@ -68,3 +68,8 @@ target 'Quiet' do
end
end
end
+
+target 'QuietNotificationServiceExtension' do
+ use_frameworks! :linkage => :static
+ pod 'Sodium'
+end
diff --git a/packages/mobile/ios/Podfile.lock b/packages/mobile/ios/Podfile.lock
index c3e895a41d..1344addc09 100644
--- a/packages/mobile/ios/Podfile.lock
+++ b/packages/mobile/ios/Podfile.lock
@@ -1725,6 +1725,7 @@ PODS:
- libwebp (~> 1.0)
- SDWebImage/Core (~> 5.17)
- SocketRocket (0.7.1)
+ - Sodium (0.9.1)
- SSZipArchive (2.6.0)
- Tor (405.9.1)
- Yoga (0.0.0)
@@ -1818,6 +1819,7 @@ DEPENDENCIES:
- RNScreens (from `../node_modules/react-native-screens`)
- RNShare (from `../node_modules/react-native-share`)
- RNSVG (from `../node_modules/react-native-svg`)
+ - Sodium
- Tor (from `https://raw.githubusercontent.com/iCepa/Tor.framework/v405.9.1/Tor.podspec`)
- Yoga (from `../node_modules/react-native/ReactCommon/yoga`)
@@ -1839,6 +1841,7 @@ SPEC REPOS:
- SDWebImageAVIFCoder
- SDWebImageWebPCoder
- SocketRocket
+ - Sodium
- SSZipArchive
EXTERNAL SOURCES:
@@ -2114,10 +2117,11 @@ SPEC CHECKSUMS:
SDWebImageAVIFCoder: afe194a084e851f70228e4be35ef651df0fc5c57
SDWebImageWebPCoder: 0e06e365080397465cc73a7a9b472d8a3bd0f377
SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748
+ Sodium: 23d11554ecd556196d313cf6130d406dfe7ac6da
SSZipArchive: 8a6ee5677c8e304bebc109e39cf0da91ccef22ea
Tor: 39dc71bf048312e202608eb499ca5c74e841b503
Yoga: 1a10502f162e8fc40ff0907c82d01cfe555d00c2
-PODFILE CHECKSUM: 1f3635b9a52a9f3267675c779818f1eab5bb5304
+PODFILE CHECKSUM: cfc64e6e7d6e469da4bad48f367062c5e329239f
COCOAPODS: 1.16.2
diff --git a/packages/mobile/ios/Quiet.debug.xcconfig b/packages/mobile/ios/Quiet.debug.xcconfig
new file mode 100644
index 0000000000..a876e8f4b5
--- /dev/null
+++ b/packages/mobile/ios/Quiet.debug.xcconfig
@@ -0,0 +1,2 @@
+#include? "Pods/Target Support Files/Pods-Quiet/Pods-Quiet.debug.xcconfig"
+#include "Quiet.xcconfig"
diff --git a/packages/mobile/ios/Quiet.release.xcconfig b/packages/mobile/ios/Quiet.release.xcconfig
new file mode 100644
index 0000000000..2ada3273b9
--- /dev/null
+++ b/packages/mobile/ios/Quiet.release.xcconfig
@@ -0,0 +1,2 @@
+#include? "Pods/Target Support Files/Pods-Quiet/Pods-Quiet.release.xcconfig"
+#include "Quiet.xcconfig"
diff --git a/packages/mobile/ios/Quiet.xcodeproj/project.pbxproj b/packages/mobile/ios/Quiet.xcodeproj/project.pbxproj
index 4628b90a75..682d61bcd2 100644
--- a/packages/mobile/ios/Quiet.xcodeproj/project.pbxproj
+++ b/packages/mobile/ios/Quiet.xcodeproj/project.pbxproj
@@ -55,11 +55,12 @@
18FD2A3E296F009E00A2B8C0 /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 18FD2A37296F009E00A2B8C0 /* AppDelegate.m */; };
18FD2A3F296F009E00A2B8C0 /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 18FD2A38296F009E00A2B8C0 /* Images.xcassets */; };
18FD2A40296F009E00A2B8C0 /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 18FD2A39296F009E00A2B8C0 /* main.m */; };
- 2BDF48C971BAFE479E950BAE /* Pods_Quiet_QuietTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FFC10A63B3F19389C478C448 /* Pods_Quiet_QuietTests.framework */; };
+ 2815B731ADE0FE0C770C78E3 /* Pods_QuietNotificationServiceExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1725031A98BBEE5E8F3F6561 /* Pods_QuietNotificationServiceExtension.framework */; };
663DC8C12F621139005D2086 /* UserMetadataHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 663DC8C02F621134005D2086 /* UserMetadataHandler.swift */; };
665587CA2F4F5ECD005D2086 /* KeychainHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 665587C92F4F5ECD005D2086 /* KeychainHandler.swift */; };
- 71C06A74FB351AA5D0449823 /* Pods_Quiet.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BD713118E20F543EF89A5669 /* Pods_Quiet.framework */; };
+ 673478D924128C1639690CFF /* Pods_Quiet_QuietTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 805522FA9F468CE465C44459 /* Pods_Quiet_QuietTests.framework */; };
955DC7582BD930B30014725B /* WebsocketSingleton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 955DC7572BD930B30014725B /* WebsocketSingleton.swift */; };
+ CE99A25A0E4E1440E0C42536 /* Pods_Quiet.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1A49D8557AFF7D73033A5FD2 /* Pods_Quiet.framework */; };
D3239FB5EFA85E780E1AD201 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 84F12DFE2A5B0E05C2C41286 /* PrivacyInfo.xcprivacy */; };
EB4DC7EC2F5FF6AB00EFD23F /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = EB4DC7EB2F5FF6AB00EFD23F /* GoogleService-Info.plist */; };
EB4DC8002F608A3300EFD23F /* QuietNotificationServiceExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = EB4DC7F92F608A3300EFD23F /* QuietNotificationServiceExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
@@ -132,8 +133,9 @@
03B674042E6103DC00A86655 /* Rubik-Regular.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = "Rubik-Regular.ttf"; path = "../src/assets/fonts/Rubik-Regular.ttf"; sourceTree = SOURCE_ROOT; };
03B674052E6103DC00A86655 /* Rubik-SemiBold.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = "Rubik-SemiBold.ttf"; path = "../src/assets/fonts/Rubik-SemiBold.ttf"; sourceTree = SOURCE_ROOT; };
03B674062E6103DC00A86655 /* Rubik-SemiBoldItalic.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = "Rubik-SemiBoldItalic.ttf"; path = "../src/assets/fonts/Rubik-SemiBoldItalic.ttf"; sourceTree = SOURCE_ROOT; };
- 116749B7D5552021F04BE739 /* Pods-Quiet.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Quiet.release.xcconfig"; path = "Target Support Files/Pods-Quiet/Pods-Quiet.release.xcconfig"; sourceTree = ""; };
+ 09783DD98C14F076599A13B4 /* Pods-Quiet.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Quiet.release.xcconfig"; path = "Target Support Files/Pods-Quiet/Pods-Quiet.release.xcconfig"; sourceTree = ""; };
13B07F961A680F5B00A75B9A /* Quiet.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Quiet.app; sourceTree = BUILT_PRODUCTS_DIR; };
+ 1725031A98BBEE5E8F3F6561 /* Pods_QuietNotificationServiceExtension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_QuietNotificationServiceExtension.framework; sourceTree = BUILT_PRODUCTS_DIR; };
180E120A2AEFB7F900804659 /* Utils.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Utils.swift; sourceTree = ""; };
1827A9E129783D6E00245FD3 /* classic-level.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; path = "classic-level.framework"; sourceTree = ""; };
183C484F296C7B6700BA2D8B /* v8-platform.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "v8-platform.h"; sourceTree = ""; };
@@ -643,22 +645,28 @@
18FD2A39296F009E00A2B8C0 /* main.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = main.m; path = Quiet/main.m; sourceTree = ""; };
18FD2A3A296F009E00A2B8C0 /* Quiet.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; name = Quiet.entitlements; path = Quiet/Quiet.entitlements; sourceTree = ""; };
18FD2A3B296F009E00A2B8C0 /* QuietDebug.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; name = QuietDebug.entitlements; path = Quiet/QuietDebug.entitlements; sourceTree = ""; };
- 2877B6088D1D81EF869D71D9 /* Pods-Quiet-QuietTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Quiet-QuietTests.release.xcconfig"; path = "Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests.release.xcconfig"; sourceTree = ""; };
+ 1A49D8557AFF7D73033A5FD2 /* Pods_Quiet.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Quiet.framework; sourceTree = BUILT_PRODUCTS_DIR; };
+ 58FBDCDCEEA5EACED0FF5D68 /* Pods-Quiet-QuietTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Quiet-QuietTests.release.xcconfig"; path = "Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests.release.xcconfig"; sourceTree = ""; };
663DC8C02F621134005D2086 /* UserMetadataHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserMetadataHandler.swift; sourceTree = ""; };
- 7F75CE6D1C7D3DA9F5BA6A76 /* Pods-Quiet-QuietTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Quiet-QuietTests.debug.xcconfig"; path = "Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests.debug.xcconfig"; sourceTree = ""; };
- 84F12DFE2A5B0E05C2C41286 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = Quiet/PrivacyInfo.xcprivacy; sourceTree = ""; };
665587C92F4F5ECD005D2086 /* KeychainHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainHandler.swift; sourceTree = ""; };
+ 72EA7AA4B77C0780A86EC300 /* Pods-Quiet-QuietTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Quiet-QuietTests.debug.xcconfig"; path = "Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests.debug.xcconfig"; sourceTree = ""; };
+ 805522FA9F468CE465C44459 /* Pods_Quiet_QuietTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Quiet_QuietTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
+ 84F12DFE2A5B0E05C2C41286 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = Quiet/PrivacyInfo.xcprivacy; sourceTree = ""; };
955DC7572BD930B30014725B /* WebsocketSingleton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebsocketSingleton.swift; sourceTree = ""; };
- BD713118E20F543EF89A5669 /* Pods_Quiet.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Quiet.framework; sourceTree = BUILT_PRODUCTS_DIR; };
- DC03A48F321B4BE2A5339F05 /* Quiet.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Quiet.xcconfig; path = Quiet.xcconfig; sourceTree = SOURCE_ROOT; };
- E727FE0446E9E46CDECEC7FE /* Pods-Quiet.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Quiet.debug.xcconfig"; path = "Target Support Files/Pods-Quiet/Pods-Quiet.debug.xcconfig"; sourceTree = ""; };
+ C5124A51F9229F2CDC5100C0 /* Pods-QuietNotificationServiceExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-QuietNotificationServiceExtension.debug.xcconfig"; path = "Target Support Files/Pods-QuietNotificationServiceExtension/Pods-QuietNotificationServiceExtension.debug.xcconfig"; sourceTree = ""; };
+ CDD5E3F75BDB63D6BC806F2B /* Pods-QuietNotificationServiceExtension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-QuietNotificationServiceExtension.release.xcconfig"; path = "Target Support Files/Pods-QuietNotificationServiceExtension/Pods-QuietNotificationServiceExtension.release.xcconfig"; sourceTree = ""; };
+ DC03A48F321B4BE2A5339F05 /* Quiet.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Quiet.xcconfig; sourceTree = SOURCE_ROOT; };
+ DE73D517F18C6E0B941FDFF2 /* Pods-Quiet.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Quiet.debug.xcconfig"; path = "Target Support Files/Pods-Quiet/Pods-Quiet.debug.xcconfig"; sourceTree = ""; };
+ E079692B46015F1D27CDBBC1 /* Quiet.debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Quiet.debug.xcconfig; sourceTree = SOURCE_ROOT; };
+ E17A7BE86B5879902AF7BA3A /* QuietNotificationServiceExtension.debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = QuietNotificationServiceExtension.debug.xcconfig; sourceTree = SOURCE_ROOT; };
+ E4A82686A95EE5D58359B484 /* QuietNotificationServiceExtension.release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = QuietNotificationServiceExtension.release.xcconfig; sourceTree = SOURCE_ROOT; };
+ E079692B46015F1D27CDBBC2 /* Quiet.release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Quiet.release.xcconfig; sourceTree = SOURCE_ROOT; };
EB4DC7EB2F5FF6AB00EFD23F /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; };
EB4DC7F92F608A3300EFD23F /* QuietNotificationServiceExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = QuietNotificationServiceExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
EB4DC8152F60912900EFD23F /* AppDelegate+Firebase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = "AppDelegate+Firebase.swift"; path = "Quiet/AppDelegate+Firebase.swift"; sourceTree = ""; };
EB5F92FA2F5FBB2100B5C60D /* FirebaseMessagingModule.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = FirebaseMessagingModule.m; path = Quiet/FirebaseMessagingModule.m; sourceTree = ""; };
EB5F92FB2F5FBB2100B5C60D /* FirebaseMessagingModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = FirebaseMessagingModule.swift; path = Quiet/FirebaseMessagingModule.swift; sourceTree = ""; };
EB5F93032F5FC50100B5C60D /* Quiet-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Quiet-Bridging-Header.h"; sourceTree = ""; };
- FFC10A63B3F19389C478C448 /* Pods_Quiet_QuietTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Quiet_QuietTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@@ -700,7 +708,7 @@
buildActionMask = 2147483647;
files = (
1827A9E229783D6E00245FD3 /* classic-level.framework in Frameworks */,
- 2BDF48C971BAFE479E950BAE /* Pods_Quiet_QuietTests.framework in Frameworks */,
+ 673478D924128C1639690CFF /* Pods_Quiet_QuietTests.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -710,7 +718,7 @@
files = (
00A416342EC2EAA900ACC877 /* NodeMobile.xcframework in Frameworks */,
1827A9E329783D7600245FD3 /* classic-level.framework in Frameworks */,
- 71C06A74FB351AA5D0449823 /* Pods_Quiet.framework in Frameworks */,
+ CE99A25A0E4E1440E0C42536 /* Pods_Quiet.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -718,6 +726,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
+ 2815B731ADE0FE0C770C78E3 /* Pods_QuietNotificationServiceExtension.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -4781,10 +4790,12 @@
1CEEDB4F07B9978C125775C5 /* Pods */ = {
isa = PBXGroup;
children = (
- E727FE0446E9E46CDECEC7FE /* Pods-Quiet.debug.xcconfig */,
- 116749B7D5552021F04BE739 /* Pods-Quiet.release.xcconfig */,
- 7F75CE6D1C7D3DA9F5BA6A76 /* Pods-Quiet-QuietTests.debug.xcconfig */,
- 2877B6088D1D81EF869D71D9 /* Pods-Quiet-QuietTests.release.xcconfig */,
+ DE73D517F18C6E0B941FDFF2 /* Pods-Quiet.debug.xcconfig */,
+ 09783DD98C14F076599A13B4 /* Pods-Quiet.release.xcconfig */,
+ 72EA7AA4B77C0780A86EC300 /* Pods-Quiet-QuietTests.debug.xcconfig */,
+ 58FBDCDCEEA5EACED0FF5D68 /* Pods-Quiet-QuietTests.release.xcconfig */,
+ C5124A51F9229F2CDC5100C0 /* Pods-QuietNotificationServiceExtension.debug.xcconfig */,
+ CDD5E3F75BDB63D6BC806F2B /* Pods-QuietNotificationServiceExtension.release.xcconfig */,
);
path = Pods;
sourceTree = "";
@@ -4794,8 +4805,9 @@
children = (
00A416332EC2EAA900ACC877 /* NodeMobile.xcframework */,
1827A9E129783D6E00245FD3 /* classic-level.framework */,
- BD713118E20F543EF89A5669 /* Pods_Quiet.framework */,
- FFC10A63B3F19389C478C448 /* Pods_Quiet_QuietTests.framework */,
+ 1A49D8557AFF7D73033A5FD2 /* Pods_Quiet.framework */,
+ 805522FA9F468CE465C44459 /* Pods_Quiet_QuietTests.framework */,
+ 1725031A98BBEE5E8F3F6561 /* Pods_QuietNotificationServiceExtension.framework */,
);
name = Frameworks;
sourceTree = "";
@@ -4841,6 +4853,10 @@
624523FCC5994B7E9869E9CF /* Resources */,
1CEEDB4F07B9978C125775C5 /* Pods */,
DC03A48F321B4BE2A5339F05 /* Quiet.xcconfig */,
+ E079692B46015F1D27CDBBC1 /* Quiet.debug.xcconfig */,
+ E17A7BE86B5879902AF7BA3A /* QuietNotificationServiceExtension.debug.xcconfig */,
+ E4A82686A95EE5D58359B484 /* QuietNotificationServiceExtension.release.xcconfig */,
+ E079692B46015F1D27CDBBC2 /* Quiet.release.xcconfig */,
);
indentWidth = 2;
sourceTree = "";
@@ -4864,12 +4880,12 @@
isa = PBXNativeTarget;
buildConfigurationList = 00E357021AD99517003FC87E /* Build configuration list for PBXNativeTarget "QuietTests" */;
buildPhases = (
- 58352AE7BD90BC0845FC78DE /* [CP] Check Pods Manifest.lock */,
+ B84DABF150D7B1B5C2DF9239 /* [CP] Check Pods Manifest.lock */,
00E356EA1AD99517003FC87E /* Sources */,
00E356EB1AD99517003FC87E /* Frameworks */,
00E356EC1AD99517003FC87E /* Resources */,
- EF4E9879B3A5060ADF995D6A /* [CP] Embed Pods Frameworks */,
- 4E18C6AEAD8D524EAD836F51 /* [CP] Copy Pods Resources */,
+ C0311D831E5FCA078E3114A7 /* [CP] Embed Pods Frameworks */,
+ D779ACFAE5667A522EFCF468 /* [CP] Copy Pods Resources */,
);
buildRules = (
);
@@ -4885,7 +4901,7 @@
isa = PBXNativeTarget;
buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "Quiet" */;
buildPhases = (
- 715B735C8E7FA602A967EAF0 /* [CP] Check Pods Manifest.lock */,
+ 669A6891CB9F7ADDDD9A98B4 /* [CP] Check Pods Manifest.lock */,
FD10A7F022414F080027D42C /* Start Packager */,
13B07F871A680F5B00A75B9A /* Sources */,
13B07F8C1A680F5B00A75B9A /* Frameworks */,
@@ -4897,9 +4913,9 @@
18FD2A32296D736300A2B8C0 /* [CUSTOM NODEJS MOBILE] Remove Python3 Binaries */,
1827A9E0297837FE00245FD3 /* [CUSTOM NODEJS MOBILE] Remove prebuilds */,
1868C095292F8FE2001D6D5E /* Embed Frameworks */,
- 7253A52F091FC8122D730B2E /* [CP] Embed Pods Frameworks */,
- A1AE2657AE5C40D18941D671 /* [CP] Copy Pods Resources */,
EBD3273E2F5FA01F00E2CD0C /* Embed Foundation Extensions */,
+ 468C008BFE8FDD32EC649A5B /* [CP] Embed Pods Frameworks */,
+ B04C9103A39CF5F564636327 /* [CP] Copy Pods Resources */,
);
buildRules = (
);
@@ -4915,6 +4931,7 @@
isa = PBXNativeTarget;
buildConfigurationList = EB4DC8022F608A3300EFD23F /* Build configuration list for PBXNativeTarget "QuietNotificationServiceExtension" */;
buildPhases = (
+ 3D09985B32EEEAFD930419CB /* [CP] Check Pods Manifest.lock */,
EB4DC7F52F608A3300EFD23F /* Sources */,
EB4DC7F62F608A3300EFD23F /* Frameworks */,
EB4DC7F72F608A3300EFD23F /* Resources */,
@@ -5140,46 +5157,46 @@
shellPath = /bin/sh;
shellScript = "find \"$CODESIGNING_FOLDER_PATH/nodejs-project/node_modules/\" -name \"python3\" | xargs rm\n";
};
- 4E18C6AEAD8D524EAD836F51 /* [CP] Copy Pods Resources */ = {
+ 3D09985B32EEEAFD930419CB /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
- "${PODS_ROOT}/Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests-resources-${CONFIGURATION}-input-files.xcfilelist",
);
- name = "[CP] Copy Pods Resources";
+ inputPaths = (
+ "${PODS_PODFILE_DIR_PATH}/Podfile.lock",
+ "${PODS_ROOT}/Manifest.lock",
+ );
+ name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
- "${PODS_ROOT}/Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests-resources-${CONFIGURATION}-output-files.xcfilelist",
+ );
+ outputPaths = (
+ "$(DERIVED_FILE_DIR)/Pods-QuietNotificationServiceExtension-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
- shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests-resources.sh\"\n";
+ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
- 58352AE7BD90BC0845FC78DE /* [CP] Check Pods Manifest.lock */ = {
+ 468C008BFE8FDD32EC649A5B /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
+ "${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
- inputPaths = (
- "${PODS_PODFILE_DIR_PATH}/Podfile.lock",
- "${PODS_ROOT}/Manifest.lock",
- );
- name = "[CP] Check Pods Manifest.lock";
+ name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
- );
- outputPaths = (
- "$(DERIVED_FILE_DIR)/Pods-Quiet-QuietTests-checkManifestLockResult.txt",
+ "${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
- shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
+ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
- 715B735C8E7FA602A967EAF0 /* [CP] Check Pods Manifest.lock */ = {
+ 669A6891CB9F7ADDDD9A98B4 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
@@ -5201,41 +5218,46 @@
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
- 7253A52F091FC8122D730B2E /* [CP] Embed Pods Frameworks */ = {
+ B04C9103A39CF5F564636327 /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
- "${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-frameworks-${CONFIGURATION}-input-files.xcfilelist",
+ "${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-resources-${CONFIGURATION}-input-files.xcfilelist",
);
- name = "[CP] Embed Pods Frameworks";
+ name = "[CP] Copy Pods Resources";
outputFileListPaths = (
- "${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-frameworks-${CONFIGURATION}-output-files.xcfilelist",
+ "${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-resources-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
- shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-frameworks.sh\"\n";
+ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-resources.sh\"\n";
showEnvVarsInLog = 0;
};
- A1AE2657AE5C40D18941D671 /* [CP] Copy Pods Resources */ = {
+ B84DABF150D7B1B5C2DF9239 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
- "${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-resources-${CONFIGURATION}-input-files.xcfilelist",
);
- name = "[CP] Copy Pods Resources";
+ inputPaths = (
+ "${PODS_PODFILE_DIR_PATH}/Podfile.lock",
+ "${PODS_ROOT}/Manifest.lock",
+ );
+ name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
- "${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-resources-${CONFIGURATION}-output-files.xcfilelist",
+ );
+ outputPaths = (
+ "$(DERIVED_FILE_DIR)/Pods-Quiet-QuietTests-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
- shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-resources.sh\"\n";
+ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
- EF4E9879B3A5060ADF995D6A /* [CP] Embed Pods Frameworks */ = {
+ C0311D831E5FCA078E3114A7 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
@@ -5252,6 +5274,23 @@
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
+ D779ACFAE5667A522EFCF468 /* [CP] Copy Pods Resources */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputFileListPaths = (
+ "${PODS_ROOT}/Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests-resources-${CONFIGURATION}-input-files.xcfilelist",
+ );
+ name = "[CP] Copy Pods Resources";
+ outputFileListPaths = (
+ "${PODS_ROOT}/Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests-resources-${CONFIGURATION}-output-files.xcfilelist",
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests-resources.sh\"\n";
+ showEnvVarsInLog = 0;
+ };
FD10A7F022414F080027D42C /* Start Packager */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
@@ -5332,7 +5371,7 @@
/* Begin XCBuildConfiguration section */
00E356F61AD99517003FC87E /* Debug */ = {
isa = XCBuildConfiguration;
- baseConfigurationReference = 7F75CE6D1C7D3DA9F5BA6A76 /* Pods-Quiet-QuietTests.debug.xcconfig */;
+ baseConfigurationReference = 72EA7AA4B77C0780A86EC300 /* Pods-Quiet-QuietTests.debug.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CLANG_ENABLE_MODULES = YES;
@@ -5367,7 +5406,7 @@
};
00E356F71AD99517003FC87E /* Release */ = {
isa = XCBuildConfiguration;
- baseConfigurationReference = 2877B6088D1D81EF869D71D9 /* Pods-Quiet-QuietTests.release.xcconfig */;
+ baseConfigurationReference = 58FBDCDCEEA5EACED0FF5D68 /* Pods-Quiet-QuietTests.release.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CLANG_ENABLE_MODULES = YES;
@@ -5398,7 +5437,7 @@
};
13B07F941A680F5B00A75B9A /* Debug */ = {
isa = XCBuildConfiguration;
- baseConfigurationReference = E727FE0446E9E46CDECEC7FE /* Pods-Quiet.debug.xcconfig */;
+ baseConfigurationReference = E079692B46015F1D27CDBBC1 /* Quiet.debug.xcconfig */;
buildSettings = {
ARCHS = "$(ARCHS_STANDARD)";
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
@@ -5498,7 +5537,7 @@
};
13B07F951A680F5B00A75B9A /* Release */ = {
isa = XCBuildConfiguration;
- baseConfigurationReference = 116749B7D5552021F04BE739 /* Pods-Quiet.release.xcconfig */;
+ baseConfigurationReference = E079692B46015F1D27CDBBC2 /* Quiet.release.xcconfig */;
buildSettings = {
ARCHS = "$(ARCHS_STANDARD)";
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
@@ -5762,12 +5801,11 @@
};
EB4DC8032F608A3300EFD23F /* Debug */ = {
isa = XCBuildConfiguration;
- baseConfigurationReference = DC03A48F321B4BE2A5339F05 /* Quiet.xcconfig */;
+ baseConfigurationReference = E17A7BE86B5879902AF7BA3A /* QuietNotificationServiceExtension.debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
- CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
@@ -5808,12 +5846,11 @@
};
EB4DC8042F608A3300EFD23F /* Release */ = {
isa = XCBuildConfiguration;
- baseConfigurationReference = DC03A48F321B4BE2A5339F05 /* Quiet.xcconfig */;
+ baseConfigurationReference = E4A82686A95EE5D58359B484 /* QuietNotificationServiceExtension.release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
- CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
diff --git a/packages/mobile/ios/Quiet/Info.plist b/packages/mobile/ios/Quiet/Info.plist
index 7fe7a0704b..cc0241871a 100644
--- a/packages/mobile/ios/Quiet/Info.plist
+++ b/packages/mobile/ios/Quiet/Info.plist
@@ -66,6 +66,8 @@
NSPhotoLibraryUsageDescription
Quiet access photos for sending images through the app.
+ QuietKeychainAccessGroup
+ $(DEVELOPMENT_TEAM).com.quietmobile
UIAppFonts
Rubik-Black.ttf
diff --git a/packages/mobile/ios/Quiet/Quiet.entitlements b/packages/mobile/ios/Quiet/Quiet.entitlements
index cdd4095f13..8dfe8e3e84 100644
--- a/packages/mobile/ios/Quiet/Quiet.entitlements
+++ b/packages/mobile/ios/Quiet/Quiet.entitlements
@@ -5,7 +5,9 @@
aps-environment
development
com.apple.security.application-groups
-
+
+ group.com.quietmobile
+
keychain-access-groups
$(AppIdentifierPrefix)com.quietmobile
diff --git a/packages/mobile/ios/Quiet/QuietDebug.entitlements b/packages/mobile/ios/Quiet/QuietDebug.entitlements
index cdd4095f13..8dfe8e3e84 100644
--- a/packages/mobile/ios/Quiet/QuietDebug.entitlements
+++ b/packages/mobile/ios/Quiet/QuietDebug.entitlements
@@ -5,7 +5,9 @@
aps-environment
development
com.apple.security.application-groups
-
+
+ group.com.quietmobile
+
keychain-access-groups
$(AppIdentifierPrefix)com.quietmobile
diff --git a/packages/mobile/ios/QuietNotificationServiceExtension.debug.xcconfig b/packages/mobile/ios/QuietNotificationServiceExtension.debug.xcconfig
new file mode 100644
index 0000000000..ae188f8dea
--- /dev/null
+++ b/packages/mobile/ios/QuietNotificationServiceExtension.debug.xcconfig
@@ -0,0 +1,2 @@
+#include? "Pods/Target Support Files/Pods-QuietNotificationServiceExtension/Pods-QuietNotificationServiceExtension.debug.xcconfig"
+#include "Quiet.xcconfig"
diff --git a/packages/mobile/ios/QuietNotificationServiceExtension.release.xcconfig b/packages/mobile/ios/QuietNotificationServiceExtension.release.xcconfig
new file mode 100644
index 0000000000..1dfbc9ee5d
--- /dev/null
+++ b/packages/mobile/ios/QuietNotificationServiceExtension.release.xcconfig
@@ -0,0 +1,2 @@
+#include? "Pods/Target Support Files/Pods-QuietNotificationServiceExtension/Pods-QuietNotificationServiceExtension.release.xcconfig"
+#include "Quiet.xcconfig"
diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/Info.plist b/packages/mobile/ios/QuietNotificationServiceExtension/Info.plist
index 7c89804a15..071d5d072d 100644
--- a/packages/mobile/ios/QuietNotificationServiceExtension/Info.plist
+++ b/packages/mobile/ios/QuietNotificationServiceExtension/Info.plist
@@ -22,6 +22,8 @@
1
FirebaseAppDelegateProxyEnabled
+ QuietKeychainAccessGroup
+ $(DEVELOPMENT_TEAM).com.quietmobile
NSExtension
NSExtensionPointIdentifier
diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/NSEAuthService.swift b/packages/mobile/ios/QuietNotificationServiceExtension/NSEAuthService.swift
index 5ab93d9b64..261c0bfce3 100644
--- a/packages/mobile/ios/QuietNotificationServiceExtension/NSEAuthService.swift
+++ b/packages/mobile/ios/QuietNotificationServiceExtension/NSEAuthService.swift
@@ -1,4 +1,7 @@
import Foundation
+import os.log
+
+private let authLog = OSLog(subsystem: "com.quietmobile.QuietNotificationServiceExtension", category: "NSEAuthService")
class NSEAuthService {
private let client: NSENetworkClient
@@ -15,20 +18,29 @@ class NSEAuthService {
func authenticate(deviceId: String, teamId: String) async throws -> String {
if let cached = tokenCache[teamId], cached.expiry > Date() {
+ os_log("authenticate: using cached token for teamId=%{public}@, expires=%{public}@",
+ log: authLog, type: .debug, teamId, "\(cached.expiry)")
return cached.token
}
+ os_log("authenticate: requesting challenge for deviceId=%{public}@ teamId=%{public}@",
+ log: authLog, type: .info, deviceId, teamId)
let challengeResp = try await client.requestChallenge(deviceId: deviceId, teamId: teamId)
+ os_log("authenticate: got challengeId=%{public}@", log: authLog, type: .debug, challengeResp.challengeId)
- // Sign the challenge using msgpack serialization (matching TypeScript identity.prove())
+ os_log("authenticate: reading device private key from keychain", log: authLog, type: .debug)
let privateKeyData = try NSEKeychainHelper.getDevicePrivateKey(deviceId: deviceId)
+ os_log("authenticate: private key read (%{public}d bytes), signing challenge", log: authLog, type: .debug, privateKeyData.count)
+
let proof = try crypto.signChallengePayload(challengeResp.challenge, privateKeyData: privateKeyData)
+ os_log("authenticate: signed challenge, requesting token", log: authLog, type: .debug)
let tokenResp = try await client.requestToken(
challengeId: challengeResp.challengeId,
deviceId: deviceId,
proof: proof
)
+ os_log("authenticate: token received, expiresIn=%{public}d", log: authLog, type: .info, tokenResp.expiresIn)
tokenCache[teamId] = (token: tokenResp.token, expiry: Date().addingTimeInterval(TimeInterval(tokenResp.expiresIn) - 30))
@@ -38,9 +50,13 @@ class NSEAuthService {
// MARK: - Fetch log entries
func fetchNewEntries(teamId: String, since: Int64) async throws -> [LogEntry] {
+ os_log("fetchNewEntries: reading deviceId from keychain", log: authLog, type: .debug)
let deviceId = try NSEKeychainHelper.getDeviceId()
+ os_log("fetchNewEntries: deviceId=%{public}@, authenticating", log: authLog, type: .info, deviceId)
let token = try await authenticate(deviceId: deviceId, teamId: teamId)
+ os_log("fetchNewEntries: authenticated, fetching log entries since=%{public}lld", log: authLog, type: .info, since)
let resp = try await client.fetchLogEntries(teamId: teamId, since: since, token: token)
+ os_log("fetchNewEntries: received %{public}d entries", log: authLog, type: .info, resp.entries.count)
return resp.entries
}
}
diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/NSECryptoService.swift b/packages/mobile/ios/QuietNotificationServiceExtension/NSECryptoService.swift
index 8f1b12ff39..e2dfbae959 100644
--- a/packages/mobile/ios/QuietNotificationServiceExtension/NSECryptoService.swift
+++ b/packages/mobile/ios/QuietNotificationServiceExtension/NSECryptoService.swift
@@ -1,5 +1,26 @@
import CryptoKit
import Foundation
+import Sodium
+
+private typealias NSEJSONObject = [String: Any]
+
+struct NSEDecryptedNotificationMessage {
+ let channelId: String
+ let userId: String
+ let body: String
+ let type: Int
+}
+
+private struct NSEEncryptionScope {
+ let type: String
+ let name: String
+ let generation: Int
+}
+
+private struct NSEEncryptedPayload {
+ let contents: Data
+ let scope: NSEEncryptionScope
+}
// MARK: - Protocol
@@ -10,6 +31,9 @@ protocol DeviceCryptography {
/// Signs a challenge payload exactly as `identity.prove()` does in TypeScript.
func signChallengePayload(_ challenge: ChallengePayload, privateKeyData: Data) throws -> ProofPayload
+
+ /// Decrypts a QSS log entry and, if it is a channel message, returns a displayable preview.
+ func decryptNotificationMessage(from logEntry: LogEntry, teamId: String) throws -> NSEDecryptedNotificationMessage?
}
extension DeviceCryptography {
@@ -43,12 +67,18 @@ extension DeviceCryptography {
enum NSECryptoError: Error, LocalizedError {
case invalidKeyLength(expected: Int, got: Int)
case invalidBase58
+ case invalidPayload(String)
+ case msgpack(String)
+ case decryptionFailed(String)
case signingFailed
var errorDescription: String? {
switch self {
case .invalidKeyLength(let e, let g): return "Invalid key length: expected \(e), got \(g)"
case .invalidBase58: return "Invalid base58 encoding"
+ case .invalidPayload(let msg): return "Invalid payload: \(msg)"
+ case .msgpack(let msg): return "MessagePack decoding failed: \(msg)"
+ case .decryptionFailed(let msg): return "Decryption failed: \(msg)"
case .signingFailed: return "Signing failed"
}
}
@@ -56,22 +86,247 @@ enum NSECryptoError: Error, LocalizedError {
// MARK: - NSECryptoService
-/// Mirrors `@localfirst/crypto` signing used in `identity.prove()`:
-/// 1. msgpack-serialize the challenge object (same encoding as msgpackr)
-/// 2. Sign with `crypto_sign_detached` (Ed25519, libsodium 64-byte key)
-/// 3. base58-encode the 64-byte signature
+/// Mirrors the JS crypto stack used in Quiet:
+/// 1. Challenge signing matches `msgpackr.pack()` + `crypto_sign_detached`
+/// 2. Log-entry decryption matches `@localfirst/crypto` symmetric.decryptBytes()
+/// 3. QSS log entries contain msgpackr-record-encoded payloads, not JSON
class NSECryptoService: DeviceCryptography {
+ private let sodium = Sodium()
+
+ // Matches @localfirst/crypto stretch.ts
+ private static let stretchSalt: [UInt8] = {
+ guard let salt = Base58.decode("H5B4DLSXw5xwNYFdz1Wr6e") else { return [] }
+ return [UInt8](salt)
+ }()
/// Signs raw bytes using the device Ed25519 private key.
/// The 64-byte libsodium secret key = 32-byte seed ++ 32-byte public key.
/// CryptoKit only needs the 32-byte seed.
func sign(message: Data) throws -> Data {
- // Requires private key externally; use signBytes(_:privateKeyData:) directly.
throw NSECryptoError.signingFailed
}
+
+ func decryptNotificationMessage(from logEntry: LogEntry, teamId: String) throws -> NSEDecryptedNotificationMessage? {
+ let outerEnvelope = try self.decodeObject(logEntry.entry)
+ guard let outerDict = outerEnvelope as? NSEJSONObject else {
+ return nil
+ }
+ let outerEncrypted = try self.parseEncryptedPayload(outerDict["encrypted"], label: "outer QSS payload")
+
+ let orbitEntry = try self.decryptPayload(outerEncrypted, teamId: teamId)
+ guard
+ let orbitEntryDict = orbitEntry as? NSEJSONObject,
+ let payload = orbitEntryDict["payload"] as? NSEJSONObject,
+ let payloadValue = payload["value"] as? NSEJSONObject
+ else {
+ return nil
+ }
+
+ guard
+ payloadValue["contents"] != nil,
+ payloadValue["channelId"] != nil
+ else {
+ return nil
+ }
+
+ let innerEncrypted = try self.parseEncryptedPayload(payloadValue["contents"], label: "inner channel message")
+ let decryptedInner = try self.decryptPayload(innerEncrypted, teamId: teamId)
+ guard let message = decryptedInner as? NSEJSONObject else {
+ return nil
+ }
+
+ guard
+ let channelId = self.stringValue(message["channelId"]),
+ let userId = self.stringValue(message["userId"]),
+ let type = self.intValue(message["type"]),
+ let body = self.notificationBody(from: message, type: type)
+ else {
+ return nil
+ }
+
+ return NSEDecryptedNotificationMessage(
+ channelId: channelId,
+ userId: userId,
+ body: body,
+ type: type
+ )
+ }
+
+ private func notificationBody(from message: NSEJSONObject, type: Int) -> String? {
+ let trimmed = (self.stringValue(message["message"]) ?? "")
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+
+ if !trimmed.isEmpty {
+ return trimmed
+ }
+
+ switch type {
+ case 2:
+ return "Sent an image"
+ case 4:
+ return "Sent a file"
+ default:
+ return nil
+ }
+ }
+
+ private func decryptPayload(_ encryptedPayload: NSEEncryptedPayload, teamId: String) throws -> Any {
+ let keyName = self.makeKeyName(teamId: teamId, scope: encryptedPayload.scope)
+ let secretKey = try NSEKeychainHelper.getLfaKeyString(keyName: keyName)
+ return try self.decryptSymmetric(cipherBytes: encryptedPayload.contents, password: secretKey)
+ }
+
+ private func decryptSymmetric(cipherBytes: Data, password: String) throws -> Any {
+ let cipher = try self.decodeObject(cipherBytes)
+ guard let cipherDict = cipher as? NSEJSONObject else {
+ throw NSECryptoError.invalidPayload("cipher bytes did not decode to an object")
+ }
+
+ guard
+ let nonce = self.dataValue(cipherDict["nonce"]),
+ let message = self.dataValue(cipherDict["message"]),
+ let tag = self.dataValue(cipherDict["tag"]),
+ let mac = self.dataValue(cipherDict["mac"])
+ else {
+ throw NSECryptoError.invalidPayload("cipher object was missing nonce/message/tag/mac")
+ }
+
+ let derivedKey = try self.stretch(password)
+ let authMessage = [UInt8](nonce + mac)
+ let tagValid = self.sodium.auth.verify(
+ message: authMessage,
+ secretKey: derivedKey,
+ tag: [UInt8](tag)
+ )
+ guard tagValid else {
+ throw NSECryptoError.decryptionFailed("cipher tag verification failed")
+ }
+
+ guard let decryptedBytes = self.sodium.secretBox.open(
+ cipherText: [UInt8](message),
+ secretKey: derivedKey,
+ nonce: [UInt8](nonce),
+ mac: [UInt8](mac)
+ ) else {
+ throw NSECryptoError.decryptionFailed("secretbox open failed")
+ }
+
+ return try self.decodeObject(Data(decryptedBytes))
+ }
+
+ private func stretch(_ password: String) throws -> [UInt8] {
+ let passwordBytes = [UInt8](password.utf8)
+ guard !Self.stretchSalt.isEmpty else {
+ throw NSECryptoError.invalidBase58
+ }
+
+ if passwordBytes.count >= 16 {
+ guard let derived = self.sodium.genericHash.hash(
+ message: passwordBytes,
+ key: Self.stretchSalt,
+ outputLength: 32
+ ) else {
+ throw NSECryptoError.decryptionFailed("generic hash stretch failed")
+ }
+ return derived
+ }
+
+ guard let derived = self.sodium.pwHash.hash(
+ outputLength: 32,
+ passwd: passwordBytes,
+ salt: Self.stretchSalt,
+ opsLimit: self.sodium.pwHash.OpsLimitInteractive,
+ memLimit: self.sodium.pwHash.MemLimitInteractive
+ ) else {
+ throw NSECryptoError.decryptionFailed("argon2 stretch failed")
+ }
+ return derived
+ }
+
+ private func parseEncryptedPayload(_ value: Any?, label: String) throws -> NSEEncryptedPayload {
+ guard let dict = value as? NSEJSONObject else {
+ throw NSECryptoError.invalidPayload("\(label) was not an object")
+ }
+
+ guard let contents = self.dataValue(dict["contents"]) else {
+ throw NSECryptoError.invalidPayload("\(label) contents were not binary")
+ }
+
+ guard
+ let scopeDict = dict["scope"] as? NSEJSONObject,
+ let scopeType = self.stringValue(scopeDict["type"]),
+ let scopeName = self.stringValue(scopeDict["name"]),
+ let generation = self.intValue(scopeDict["generation"])
+ else {
+ throw NSECryptoError.invalidPayload("\(label) scope was malformed")
+ }
+
+ return NSEEncryptedPayload(
+ contents: contents,
+ scope: NSEEncryptionScope(type: scopeType, name: scopeName, generation: generation)
+ )
+ }
+
+ private func makeKeyName(teamId: String, scope: NSEEncryptionScope) -> String {
+ return "quiet_\(teamId)_\(scope.type)_\(scope.name)_\(scope.generation)_secret"
+ }
+
+ private func decodeObject(_ data: Data) throws -> Any {
+ return try NSEMsgpack.decode(data)
+ }
+
+ private func dataValue(_ value: Any?) -> Data? {
+ if let data = value as? Data {
+ return data
+ }
+ if let bytes = value as? [UInt8] {
+ return Data(bytes)
+ }
+ return nil
+ }
+
+ private func stringValue(_ value: Any?) -> String? {
+ if let string = value as? String {
+ return string
+ }
+ return nil
+ }
+
+ private func intValue(_ value: Any?) -> Int? {
+ switch value {
+ case let int as Int:
+ return int
+ case let int8 as Int8:
+ return Int(int8)
+ case let int16 as Int16:
+ return Int(int16)
+ case let int32 as Int32:
+ return Int(int32)
+ case let int64 as Int64:
+ return Int(int64)
+ case let uint as UInt:
+ return Int(uint)
+ case let uint8 as UInt8:
+ return Int(uint8)
+ case let uint16 as UInt16:
+ return Int(uint16)
+ case let uint32 as UInt32:
+ return Int(uint32)
+ case let uint64 as UInt64:
+ return Int(uint64)
+ case let number as NSNumber:
+ return number.intValue
+ case let double as Double:
+ return Int(double)
+ case let string as String:
+ return Int(string)
+ default:
+ return nil
+ }
+ }
}
-// MARK: - Msgpack encoder
+// MARK: - Msgpack helpers
// Encodes the ChallengePayload object in the same byte format as msgpackr.pack():
// { type: string, name: string, nonce: string, timestamp: number }
//
@@ -82,7 +337,15 @@ class NSECryptoService: DeviceCryptography {
// Field order must exactly match the JS object insertion order.
enum NSEMsgpack {
- enum MsgpackError: Error { case unsupportedType, stringTooLong }
+ enum MsgpackError: Error {
+ case invalidRecordDefinition
+ case invalidString
+ case invalidMapKey
+ case stringTooLong
+ case truncated
+ case unsupportedExtension(UInt8)
+ case unsupportedType(UInt8)
+ }
/// Encodes a ChallengePayload in the same byte format as msgpackr.pack().
static func encode(_ challenge: ChallengePayload) throws -> Data {
@@ -109,8 +372,12 @@ enum NSEMsgpack {
return out
}
+ static func decode(_ data: Data) throws -> Any {
+ try Decoder(data: data).decode()
+ }
+
private static func appendString(_ s: String, to out: inout Data) throws {
- guard let bytes = s.data(using: .utf8) else { throw MsgpackError.unsupportedType }
+ guard let bytes = s.data(using: .utf8) else { throw MsgpackError.invalidString }
let len = bytes.count
if len < 32 {
// fixstr: 0xa0 | len
@@ -144,4 +411,275 @@ enum NSEMsgpack {
out.append(UInt8((bits >> 8) & 0xFF))
out.append(UInt8(bits & 0xFF))
}
+
+ private final class Decoder {
+ private let bytes: [UInt8]
+ private var index: Int = 0
+ private var records: [UInt8: [String]] = [:]
+
+ init(data: Data) {
+ self.bytes = [UInt8](data)
+ }
+
+ func decode() throws -> Any {
+ let value = try self.readValue()
+ guard self.index == self.bytes.count else {
+ throw MsgpackError.truncated
+ }
+ return value
+ }
+
+ private func readValue() throws -> Any {
+ let token = try self.readByte()
+
+ switch token {
+ case 0x00...0x3f:
+ return Int(token)
+ case 0x40...0x7f:
+ if let record = self.records[token] {
+ return try self.readRecord(record)
+ }
+ return Int(token)
+ case 0x80...0x8f:
+ return try self.readMap(count: Int(token & 0x0f))
+ case 0x90...0x9f:
+ return try self.readArray(count: Int(token & 0x0f))
+ case 0xa0...0xbf:
+ return try self.readString(length: Int(token & 0x1f))
+ case 0xc0:
+ return NSNull()
+ case 0xc1:
+ throw MsgpackError.unsupportedType(token)
+ case 0xc2:
+ return false
+ case 0xc3:
+ return true
+ case 0xc4:
+ return try self.readBinary(length: Int(try self.readByte()))
+ case 0xc5:
+ return try self.readBinary(length: Int(try self.readUInt16()))
+ case 0xc6:
+ return try self.readBinary(length: Int(try self.readUInt32()))
+ case 0xc7:
+ return try self.readExtension(length: Int(try self.readByte()))
+ case 0xc8:
+ return try self.readExtension(length: Int(try self.readUInt16()))
+ case 0xc9:
+ return try self.readExtension(length: Int(try self.readUInt32()))
+ case 0xca:
+ return try self.readFloat32()
+ case 0xcb:
+ return try self.readFloat64()
+ case 0xcc:
+ return Int(try self.readByte())
+ case 0xcd:
+ return Int(try self.readUInt16())
+ case 0xce:
+ return Int(try self.readUInt32())
+ case 0xcf:
+ let value = try self.readUInt64()
+ return value <= UInt64(Int.max) ? Int(value) : Double(value)
+ case 0xd0:
+ return Int(try self.readInt8())
+ case 0xd1:
+ return Int(try self.readInt16())
+ case 0xd2:
+ return Int(try self.readInt32())
+ case 0xd3:
+ let value = try self.readInt64()
+ return value >= Int64(Int.min) && value <= Int64(Int.max) ? Int(value) : Double(value)
+ case 0xd4:
+ return try self.readFixext(length: 1)
+ case 0xd5:
+ return try self.readFixext(length: 2)
+ case 0xd6:
+ return try self.readFixext(length: 4)
+ case 0xd7:
+ return try self.readFixext(length: 8)
+ case 0xd8:
+ return try self.readFixext(length: 16)
+ case 0xd9:
+ return try self.readString(length: Int(try self.readByte()))
+ case 0xda:
+ return try self.readString(length: Int(try self.readUInt16()))
+ case 0xdb:
+ return try self.readString(length: Int(try self.readUInt32()))
+ case 0xdc:
+ return try self.readArray(count: Int(try self.readUInt16()))
+ case 0xdd:
+ return try self.readArray(count: Int(try self.readUInt32()))
+ case 0xde:
+ return try self.readMap(count: Int(try self.readUInt16()))
+ case 0xdf:
+ return try self.readMap(count: Int(try self.readUInt32()))
+ case 0xe0...0xff:
+ return Int(Int8(bitPattern: token))
+ default:
+ throw MsgpackError.unsupportedType(token)
+ }
+ }
+
+ private func readRecord(_ keys: [String]) throws -> NSEJSONObject {
+ var object: NSEJSONObject = [:]
+ object.reserveCapacity(keys.count)
+ for key in keys {
+ object[key] = try self.readValue()
+ }
+ return object
+ }
+
+ private func readMap(count: Int) throws -> NSEJSONObject {
+ var map: NSEJSONObject = [:]
+ map.reserveCapacity(count)
+ for _ in 0.. [Any] {
+ var array: [Any] = []
+ array.reserveCapacity(count)
+ for _ in 0.. String {
+ let data = try self.readData(length: length)
+ guard let string = String(data: data, encoding: .utf8) else {
+ throw MsgpackError.invalidString
+ }
+ return string
+ }
+
+ private func readBinary(length: Int) throws -> Data {
+ return try self.readData(length: length)
+ }
+
+ private func readExtension(length: Int) throws -> Any {
+ let type = try self.readByte()
+ let payload = try self.readData(length: length)
+ if type == 0x72, length == 1 {
+ guard let recordId = payload.first else {
+ throw MsgpackError.invalidRecordDefinition
+ }
+ let keysValue = try self.readValue()
+ guard let keys = keysValue as? [Any] else {
+ throw MsgpackError.invalidRecordDefinition
+ }
+ let stringKeys = try keys.map { key -> String in
+ guard let key = key as? String else {
+ throw MsgpackError.invalidRecordDefinition
+ }
+ return key
+ }
+ self.records[recordId] = stringKeys
+ return try self.readRecord(stringKeys)
+ }
+ return payload
+ }
+
+ private func readFixext(length: Int) throws -> Any {
+ let type = try self.readByte()
+ let payload = try self.readData(length: length)
+ if type == 0x72, length == 1 {
+ guard let recordId = payload.first else {
+ throw MsgpackError.invalidRecordDefinition
+ }
+ let keysValue = try self.readValue()
+ guard let keys = keysValue as? [Any] else {
+ throw MsgpackError.invalidRecordDefinition
+ }
+ let stringKeys = try keys.map { key -> String in
+ guard let key = key as? String else {
+ throw MsgpackError.invalidRecordDefinition
+ }
+ return key
+ }
+ self.records[recordId] = stringKeys
+ return try self.readRecord(stringKeys)
+ }
+ // msgpackr uses fixext1 type 0 data 0 for undefined. Treat it as nil-like.
+ if type == 0x00, length == 1, payload.first == 0x00 {
+ return NSNull()
+ }
+ return payload
+ }
+
+ private func readData(length: Int) throws -> Data {
+ guard self.index + length <= self.bytes.count else {
+ throw MsgpackError.truncated
+ }
+ let data = Data(self.bytes[self.index..<(self.index + length)])
+ self.index += length
+ return data
+ }
+
+ private func readByte() throws -> UInt8 {
+ guard self.index < self.bytes.count else {
+ throw MsgpackError.truncated
+ }
+ let value = self.bytes[self.index]
+ self.index += 1
+ return value
+ }
+
+ private func readUInt16() throws -> UInt16 {
+ let b0 = UInt16(try self.readByte())
+ let b1 = UInt16(try self.readByte())
+ return (b0 << 8) | b1
+ }
+
+ private func readUInt32() throws -> UInt32 {
+ let b0 = UInt32(try self.readByte())
+ let b1 = UInt32(try self.readByte())
+ let b2 = UInt32(try self.readByte())
+ let b3 = UInt32(try self.readByte())
+ return (b0 << 24) | (b1 << 16) | (b2 << 8) | b3
+ }
+
+ private func readUInt64() throws -> UInt64 {
+ let b0 = UInt64(try self.readByte())
+ let b1 = UInt64(try self.readByte())
+ let b2 = UInt64(try self.readByte())
+ let b3 = UInt64(try self.readByte())
+ let b4 = UInt64(try self.readByte())
+ let b5 = UInt64(try self.readByte())
+ let b6 = UInt64(try self.readByte())
+ let b7 = UInt64(try self.readByte())
+ return (b0 << 56) | (b1 << 48) | (b2 << 40) | (b3 << 32) | (b4 << 24) | (b5 << 16) | (b6 << 8) | b7
+ }
+
+ private func readInt8() throws -> Int8 {
+ Int8(bitPattern: try self.readByte())
+ }
+
+ private func readInt16() throws -> Int16 {
+ Int16(bitPattern: try self.readUInt16())
+ }
+
+ private func readInt32() throws -> Int32 {
+ Int32(bitPattern: try self.readUInt32())
+ }
+
+ private func readInt64() throws -> Int64 {
+ Int64(bitPattern: try self.readUInt64())
+ }
+
+ private func readFloat32() throws -> Double {
+ let bits = try self.readUInt32()
+ return Double(Float(bitPattern: bits))
+ }
+
+ private func readFloat64() throws -> Double {
+ let bits = try self.readUInt64()
+ return Double(bitPattern: bits)
+ }
+ }
}
diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/NSEKeychainHelper.swift b/packages/mobile/ios/QuietNotificationServiceExtension/NSEKeychainHelper.swift
index 6acc12302f..d3e20f42c5 100644
--- a/packages/mobile/ios/QuietNotificationServiceExtension/NSEKeychainHelper.swift
+++ b/packages/mobile/ios/QuietNotificationServiceExtension/NSEKeychainHelper.swift
@@ -9,6 +9,7 @@ struct NSEKeychainHelper {
private static let deviceIdKey = "quiet.device.id"
private static let teamIdKey = "quiet.team.id"
private static let lastSyncKey = "quiet.nse.lastSyncTimestamp"
+ private static let lfaKeyService = "com.quietmobile"
// MARK: - Device private key
@@ -43,6 +44,14 @@ struct NSEKeychainHelper {
return str
}
+ static func getLfaKeyString(keyName: String) throws -> String {
+ let data = try readData(account: keyName, label: "LFA key", service: lfaKeyService)
+ guard let str = String(data: data, encoding: .utf8) else {
+ throw NSEAuthError.keychainError("LFA key '\(keyName)' is not valid UTF-8")
+ }
+ return str
+ }
+
// MARK: - Last sync timestamp (UserDefaults — not sensitive)
static func getLastSyncTimestamp() -> Int64 {
@@ -57,21 +66,25 @@ struct NSEKeychainHelper {
// MARK: - Private helpers
- // Must match the App Group entitlement in both the main app and NSE targets
- // (Signing & Capabilities → App Groups → group.com.quietmobile).
- private static let accessGroup = "group.com.quietmobile"
+ // Must match the shared keychain entitlement in both the main app and NSE targets.
+ private static let accessGroup = Bundle.main.object(forInfoDictionaryKey: "QuietKeychainAccessGroup") as? String
- private static func readData(account: String, label: String) throws -> Data {
+ private static func readData(account: String, label: String, service: String? = nil) throws -> Data {
// Note: kSecAttrAccessible is intentionally omitted — it's a write attribute.
// Including it in a read query can cause silent failures on some iOS versions.
// Accessibility is enforced at write time (kSecAttrAccessibleAfterFirstUnlock).
- let query: [CFString: Any] = [
+ var query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrAccount: account,
- kSecAttrAccessGroup: accessGroup,
kSecReturnData: true,
kSecMatchLimit: kSecMatchLimitOne,
]
+ if let accessGroup {
+ query[kSecAttrAccessGroup] = accessGroup
+ }
+ if let service {
+ query[kSecAttrService] = service
+ }
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/NSEModels.swift b/packages/mobile/ios/QuietNotificationServiceExtension/NSEModels.swift
index 5ee3af1ea4..2221257c78 100644
--- a/packages/mobile/ios/QuietNotificationServiceExtension/NSEModels.swift
+++ b/packages/mobile/ios/QuietNotificationServiceExtension/NSEModels.swift
@@ -46,6 +46,7 @@ struct ChallengeResponse: Codable {
struct ProofPayload: Codable {
let signature: String
+ let publicKey: String
}
struct TokenRequest: Codable {
diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/NSENetworkClient.swift b/packages/mobile/ios/QuietNotificationServiceExtension/NSENetworkClient.swift
index e946130e6d..f3b1445113 100644
--- a/packages/mobile/ios/QuietNotificationServiceExtension/NSENetworkClient.swift
+++ b/packages/mobile/ios/QuietNotificationServiceExtension/NSENetworkClient.swift
@@ -1,4 +1,7 @@
import Foundation
+import os.log
+
+private let netLog = OSLog(subsystem: "com.quietmobile.QuietNotificationServiceExtension", category: "NSENetworkClient")
class NSENetworkClient {
let baseURL: URL
@@ -86,18 +89,28 @@ class NSENetworkClient {
as type: T.Type,
onError: (Int) throws -> Void
) async throws -> T {
+ let method = request.httpMethod ?? "GET"
+ let urlStr = request.url?.absoluteString ?? "(nil)"
+ os_log("perform: %{public}@ %{public}@", log: netLog, type: .debug, method, urlStr)
+
let (data, response): (Data, URLResponse)
do {
(data, response) = try await session.data(for: request)
} catch {
+ os_log("perform: network error for %{public}@: %{public}@", log: netLog, type: .error, urlStr, String(describing: error))
throw NSEAuthError.networkError(error)
}
guard let http = response as? HTTPURLResponse else {
+ os_log("perform: non-HTTP response for %{public}@", log: netLog, type: .error, urlStr)
throw NSEAuthError.invalidResponse
}
+ os_log("perform: %{public}@ %{public}@ → HTTP %{public}d", log: netLog, type: .info, method, urlStr, http.statusCode)
+
guard (200..<300).contains(http.statusCode) else {
+ let body = String(data: data, encoding: .utf8) ?? "(non-UTF8 body)"
+ os_log("perform: error body: %{public}@", log: netLog, type: .error, body)
try onError(http.statusCode)
throw NSEAuthError.invalidResponse // unreachable; onError always throws
}
@@ -105,6 +118,9 @@ class NSENetworkClient {
do {
return try Self.decoder.decode(T.self, from: data)
} catch {
+ let body = String(data: data, encoding: .utf8) ?? "(non-UTF8 body)"
+ os_log("perform: decoding failed for %{public}@: %{public}@\nresponse body: %{public}@",
+ log: netLog, type: .error, String(describing: T.self), String(describing: error), body)
throw NSEAuthError.decodingFailed(error)
}
}
diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/NotificationService.swift b/packages/mobile/ios/QuietNotificationServiceExtension/NotificationService.swift
index 4110776833..80b2e05d4f 100644
--- a/packages/mobile/ios/QuietNotificationServiceExtension/NotificationService.swift
+++ b/packages/mobile/ios/QuietNotificationServiceExtension/NotificationService.swift
@@ -122,7 +122,45 @@ class NotificationService: UNNotificationServiceExtension {
os_log("fetchAndUpdate: bestAttemptContent is nil, cannot update badge", log: nseLog, type: .error)
return
}
- let newBadge = (content.badge?.intValue ?? 0) + entries.count
+
+ let decryptedMessages = entries.compactMap { entry -> NSEDecryptedNotificationMessage? in
+ do {
+ return try self.crypto.decryptNotificationMessage(from: entry, teamId: teamId)
+ } catch {
+ os_log(
+ "fetchAndUpdate: failed to decrypt entry %{public}@: %{public}@",
+ log: nseLog,
+ type: .error,
+ entry.cid,
+ String(describing: error)
+ )
+ return nil
+ }
+ }
+
+ if let latestMessage = decryptedMessages.last {
+ let title = content.title.trimmingCharacters(in: .whitespacesAndNewlines)
+ content.title = title.isEmpty ? "Quiet" : title
+ content.body = latestMessage.body
+
+ if decryptedMessages.count > 1 {
+ content.subtitle = "\(decryptedMessages.count) new messages"
+ } else {
+ content.subtitle = ""
+ }
+
+ os_log(
+ "fetchAndUpdate: updated notification body from decrypted message (count=%{public}d)",
+ log: nseLog,
+ type: .info,
+ decryptedMessages.count
+ )
+ } else {
+ os_log("fetchAndUpdate: no decryptable channel messages found", log: nseLog, type: .info)
+ }
+
+ let badgeIncrement = decryptedMessages.isEmpty ? entries.count : decryptedMessages.count
+ let newBadge = (content.badge?.intValue ?? 0) + badgeIncrement
os_log("fetchAndUpdate: updating badge to %{public}d", log: nseLog, type: .info, newBadge)
content.badge = newBadge as NSNumber
}
diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/QuietNotificationServiceExtension.entitlements b/packages/mobile/ios/QuietNotificationServiceExtension/QuietNotificationServiceExtension.entitlements
index 2eb7e333a6..8570a5737d 100644
--- a/packages/mobile/ios/QuietNotificationServiceExtension/QuietNotificationServiceExtension.entitlements
+++ b/packages/mobile/ios/QuietNotificationServiceExtension/QuietNotificationServiceExtension.entitlements
@@ -3,6 +3,12 @@
com.apple.security.application-groups
-
+
+ group.com.quietmobile
+
+ keychain-access-groups
+
+ $(AppIdentifierPrefix)com.quietmobile
+
diff --git a/packages/mobile/src/store/init/startConnection/startConnection.saga.ts b/packages/mobile/src/store/init/startConnection/startConnection.saga.ts
index 4c12d91f62..8f89626a39 100644
--- a/packages/mobile/src/store/init/startConnection/startConnection.saga.ts
+++ b/packages/mobile/src/store/init/startConnection/startConnection.saga.ts
@@ -16,7 +16,7 @@ import { PayloadAction } from '@reduxjs/toolkit'
import { socket as stateManager, Socket } from '@quiet/state-manager'
import { initActions, WebsocketConnectionPayload } from '../init.slice'
import { eventChannel } from 'redux-saga'
-import { KeysUpdatedEvent, SocketActions, SocketEvents, UserProfilesUpdatedPayload } from '@quiet/types'
+import { DeviceCredentialsUpdatedEvent, KeysUpdatedEvent, SocketActions, SocketEvents, UserProfilesUpdatedPayload } from '@quiet/types'
import { createLogger } from '../../../utils/logger'
import { initSelectors } from '../init.selectors'
import { keysActions } from '../../keys/keys.slice'
@@ -81,6 +81,7 @@ function subscribeSocketLifecycle(socket: Socket, socketIOData: WebsocketConnect
| ReturnType
| ReturnType
| ReturnType
+ | ReturnType
| ReturnType
>(emit => {
socket.on('connect', async () => {
@@ -96,6 +97,10 @@ function subscribeSocketLifecycle(socket: Socket, socketIOData: WebsocketConnect
logger.info('Keys updated, writing to keychain')
emit(keysActions.saveKeysInKeychain(payload))
})
+ socket.on(SocketEvents.DEVICE_CREDENTIALS_UPDATED, async (payload: DeviceCredentialsUpdatedEvent) => {
+ logger.info('Device credentials updated, writing to keychain')
+ emit(keysActions.saveDeviceCredentials(payload))
+ })
socket.on(SocketEvents.USER_PROFILES_UPDATED, async (payload: UserProfilesUpdatedPayload) => {
logger.info('User profiles updated, saving in ios native storage')
emit(usersMetadataActions.saveUserMetadataNatively(payload))
diff --git a/packages/mobile/src/store/keys/keys.master.saga.ts b/packages/mobile/src/store/keys/keys.master.saga.ts
index a35dff4a2a..10a0200f11 100644
--- a/packages/mobile/src/store/keys/keys.master.saga.ts
+++ b/packages/mobile/src/store/keys/keys.master.saga.ts
@@ -3,6 +3,7 @@ import { all } from 'typed-redux-saga'
import { type Socket } from '@quiet/state-manager/src/types'
import { keysActions } from './keys.slice'
import { saveKeysInKeychainSaga } from './saveKeysInKeychain/saveKeysInKeychain.saga'
+import { saveDeviceCredentialsSaga } from './saveDeviceCredentials/saveDeviceCredentials.saga'
import { createLogger } from '../../utils/logger'
const logger = createLogger('keysMasterSaga')
@@ -10,7 +11,10 @@ const logger = createLogger('keysMasterSaga')
export function* keysMasterSaga(): Generator {
logger.info('keysMasterSaga starting')
try {
- yield all([takeEvery(keysActions.saveKeysInKeychain.type, saveKeysInKeychainSaga)])
+ yield all([
+ takeEvery(keysActions.saveKeysInKeychain.type, saveKeysInKeychainSaga),
+ takeEvery(keysActions.saveDeviceCredentials.type, saveDeviceCredentialsSaga),
+ ])
} finally {
logger.info('keysMasterSaga stopping')
if (yield cancelled()) {
diff --git a/packages/mobile/src/store/keys/keys.slice.ts b/packages/mobile/src/store/keys/keys.slice.ts
index 884b147bcf..1a243c57fc 100644
--- a/packages/mobile/src/store/keys/keys.slice.ts
+++ b/packages/mobile/src/store/keys/keys.slice.ts
@@ -1,6 +1,6 @@
import { createSlice, type PayloadAction } from '@reduxjs/toolkit'
import { StoreKeys } from '../store.keys'
-import { KeysUpdatedEvent } from '@quiet/types'
+import { DeviceCredentialsUpdatedEvent, KeysUpdatedEvent } from '@quiet/types'
import { createLogger } from '../../utils/logger'
const logger = createLogger('keysSlice')
@@ -12,6 +12,7 @@ export const keysSlice = createSlice({
name: StoreKeys.Keys,
reducers: {
saveKeysInKeychain: (state, _action: PayloadAction) => state,
+ saveDeviceCredentials: (state, _action: PayloadAction) => state,
},
})
diff --git a/packages/mobile/src/store/keys/saveDeviceCredentials/saveDeviceCredentials.saga.ts b/packages/mobile/src/store/keys/saveDeviceCredentials/saveDeviceCredentials.saga.ts
new file mode 100644
index 0000000000..ec50161247
--- /dev/null
+++ b/packages/mobile/src/store/keys/saveDeviceCredentials/saveDeviceCredentials.saga.ts
@@ -0,0 +1,22 @@
+import { type PayloadAction } from '@reduxjs/toolkit'
+import { call } from 'typed-redux-saga'
+import { NativeModules } from 'react-native'
+
+import { DeviceCredentialsUpdatedEvent } from '@quiet/types'
+import { createLogger } from '../../../utils/logger'
+
+const logger = createLogger('saveDeviceCredentialsSaga')
+
+export function* saveDeviceCredentialsSaga(action: PayloadAction): Generator {
+ logger.info('Storing device credentials in iOS keychain')
+ try {
+ yield* call(
+ NativeModules.CommunicationModule.saveDeviceCredentials,
+ action.payload.deviceId,
+ action.payload.teamId,
+ action.payload.signingPrivateKey
+ )
+ } catch (e) {
+ logger.error('Error storing device credentials', e)
+ }
+}
diff --git a/packages/types/src/keys.ts b/packages/types/src/keys.ts
index 7939aac759..f6ecd314dd 100644
--- a/packages/types/src/keys.ts
+++ b/packages/types/src/keys.ts
@@ -8,3 +8,10 @@ export interface StorableKey {
export interface KeysUpdatedEvent {
keys: StorableKey[]
}
+
+export interface DeviceCredentialsUpdatedEvent {
+ deviceId: string
+ teamId: string
+ /** Base58-encoded 64-byte libsodium Ed25519 signing private key */
+ signingPrivateKey: string
+}
diff --git a/packages/types/src/socket.ts b/packages/types/src/socket.ts
index 9fb324a17a..36e6730bf2 100644
--- a/packages/types/src/socket.ts
+++ b/packages/types/src/socket.ts
@@ -40,7 +40,7 @@ import {
} from './community'
import { ErrorPayload } from './errors'
import { HCaptchaChallengeRequest, HCaptchaFormResponse, HCaptchaRequest } from './captcha'
-import { KeysUpdatedEvent } from './keys'
+import { DeviceCredentialsUpdatedEvent, KeysUpdatedEvent } from './keys'
// -----------------------------------------------------------------------------
// SocketActions: These are the actions the frontend emits to the backend
@@ -135,6 +135,7 @@ export enum SocketEvents {
USERS_REMOVED = 'usersRemoved',
USER_PROFILES_STORED = 'userProfilesStored',
KEYS_UPDATED = 'keysUpdated',
+ DEVICE_CREDENTIALS_UPDATED = 'deviceCredentialsUpdated',
USER_PROFILES_UPDATED = 'userProfilesUpdatedFwd',
// ====== Files ======
@@ -242,6 +243,7 @@ export interface SocketEventsMap {
[SocketEvents.USERS_REMOVED]: EmitEvent
[SocketEvents.USER_PROFILES_STORED]: EmitEvent
[SocketEvents.KEYS_UPDATED]: EmitEvent
+ [SocketEvents.DEVICE_CREDENTIALS_UPDATED]: EmitEvent
[SocketEvents.USER_PROFILES_UPDATED]: EmitEvent
// ====== Files ======
From a12cc5651babd4c33d51cfd733f5e898486cadcd Mon Sep 17 00:00:00 2001
From: taea
Date: Thu, 26 Mar 2026 00:33:24 -0400
Subject: [PATCH 33/92] Implement NSE sync timestamp handling and related
socket events; improve badge behavior
---
packages/backend/src/nest/qss/qss.service.ts | 36 ++++++-
.../nest/storage/orbitDb/orbitDb.service.ts | 4 +-
packages/mobile/.env.development | 2 +-
packages/mobile/ios/CommunicationBridge.m | 1 +
packages/mobile/ios/CommunicationModule.swift | 29 +++++
packages/mobile/ios/Quiet/AppDelegate.m | 13 +++
.../NSEKeychainHelper.swift | 30 +++++-
.../NotificationService.swift | 101 +++++++++++++++---
packages/mobile/src/setupTests.tsx | 1 +
.../startConnection/startConnection.saga.ts | 18 +++-
.../appConnection/connection.selectors.ts | 3 +
.../sagas/appConnection/connection.slice.ts | 7 ++
.../startConnection/startConnection.saga.ts | 10 ++
packages/types/src/keys.ts | 5 +
packages/types/src/socket.ts | 8 +-
15 files changed, 246 insertions(+), 22 deletions(-)
diff --git a/packages/backend/src/nest/qss/qss.service.ts b/packages/backend/src/nest/qss/qss.service.ts
index 7e99cb4f29..856510c124 100644
--- a/packages/backend/src/nest/qss/qss.service.ts
+++ b/packages/backend/src/nest/qss/qss.service.ts
@@ -42,7 +42,13 @@ import { DLQDecryptEntry } from '../local-db/local-db.types'
import { LogUpdate } from '../storage/orbitDb/orbitdb.types'
import { logEntryToLogUpdate } from '../storage/orbitDb/util'
import { QSS_RECONNECT_DELAY_MS } from './qss.const'
-import { CompoundError, InvitationDataV3, SocketActions, SocketEvents } from '@quiet/types'
+import {
+ CompoundError,
+ InvitationDataV3,
+ NseSyncTimestampUpdatedEvent,
+ SocketActions,
+ SocketEvents,
+} from '@quiet/types'
import { LocalDbEvents } from '../local-db/local-db.types'
import { SocketService } from '../socket/socket.service'
import { Serializer } from '../common/serializer.service'
@@ -205,7 +211,10 @@ export class QSSService extends EventEmitter implements OnModuleDestroy, OnModul
this.qssClient.on(WebsocketEvents.LOG_ENTRY_SYNC, async (message: LogEntrySyncMessage): Promise => {
this.logger.debug('Forwarding fanout log entry sync message to OrbitDB service')
- this.orbitDbService.handleFanoutMessage(message)
+ const ingested = await this.orbitDbService.handleFanoutMessage(message)
+ if (ingested) {
+ await this.updateNseLastSyncTimestamp(message.payload.teamId, message.ts)
+ }
})
this.on(QSSEvents.QSS_HANDLE_SIGN_IN, async () => {
@@ -895,7 +904,7 @@ export class QSSService extends EventEmitter implements OnModuleDestroy, OnModul
try {
await this.orbitDbService.ingestEntries(decryptedEntries)
- await this.localDbService.setLastSyncTime(teamId, newSyncTime)
+ await this.updateNseLastSyncTimestamp(teamId, newSyncTime)
} catch (e) {
this.logger.error('Failed to ingest pulled log entries from QSS into OrbitDB', e)
}
@@ -916,6 +925,27 @@ export class QSSService extends EventEmitter implements OnModuleDestroy, OnModul
return finalPullResponse
}
+ private async updateNseLastSyncTimestamp(teamId: string, timestamp: number): Promise {
+ if (!Number.isFinite(timestamp) || timestamp <= 0) {
+ this.logger.warn(`Refusing to persist invalid NSE sync timestamp for team ${teamId}: ${timestamp}`)
+ return
+ }
+
+ const existingTimestamp = await this.localDbService.getLastSyncTime(teamId)
+ const nextTimestamp = existingTimestamp == null ? timestamp : Math.max(existingTimestamp, timestamp)
+
+ if (existingTimestamp === nextTimestamp) {
+ return
+ }
+
+ await this.localDbService.setLastSyncTime(teamId, nextTimestamp)
+ const payload: NseSyncTimestampUpdatedEvent = {
+ teamId,
+ lastSyncTimestamp: nextTimestamp,
+ }
+ this.socketService.serverIoProvider.io.emit(SocketEvents.NSE_SYNC_TIMESTAMP_UPDATED, payload)
+ }
+
/**
* Process the decryption dead letter queue when sigchain updates (new keys arrive)
*/
diff --git a/packages/backend/src/nest/storage/orbitDb/orbitDb.service.ts b/packages/backend/src/nest/storage/orbitDb/orbitDb.service.ts
index e26aaff46b..33240cc466 100644
--- a/packages/backend/src/nest/storage/orbitDb/orbitDb.service.ts
+++ b/packages/backend/src/nest/storage/orbitDb/orbitDb.service.ts
@@ -239,7 +239,7 @@ export class OrbitDbService {
await Promise.all(joinAll)
}
- public async handleFanoutMessage(message: LogEntrySyncMessage): Promise {
+ public async handleFanoutMessage(message: LogEntrySyncMessage): Promise {
this.logger.debug('Ingesting fanout message, ', message.payload.hash)
try {
const logEntry: LogEntry = this.sigChainService.crypto.decryptAndVerify(
@@ -247,8 +247,10 @@ export class OrbitDbService {
message.payload.encEntry.signature
).contents
await this.ingestEntries([logEntry])
+ return true
} catch (err) {
this.logger.error(`Failed to handle fanout log entry sync message`, err)
+ return false
}
}
diff --git a/packages/mobile/.env.development b/packages/mobile/.env.development
index 40ea712d18..98cc3b88dd 100644
--- a/packages/mobile/.env.development
+++ b/packages/mobile/.env.development
@@ -3,5 +3,5 @@ NODE_ENV=development
SHOULD_RUN_BACKEND_WORKER=true
COLORIZE=false
QSS_ALLOWED=true
-QSS_ENDPOINT=ws://127.0.0.1:3003
+QSS_ENDPOINT=ws://192.168.1.175:3003
QPS_ALLOWED=true
diff --git a/packages/mobile/ios/CommunicationBridge.m b/packages/mobile/ios/CommunicationBridge.m
index 50b6c804a4..a044434144 100644
--- a/packages/mobile/ios/CommunicationBridge.m
+++ b/packages/mobile/ios/CommunicationBridge.m
@@ -8,4 +8,5 @@ @interface RCT_EXTERN_MODULE(CommunicationModule, RCTEventEmitter)
RCT_EXTERN_METHOD(saveKeysInKeychain:(NSArray *)newKeys)
RCT_EXTERN_METHOD(saveUserMetadata:(NSArray *)updatedMetadata)
RCT_EXTERN_METHOD(saveDeviceCredentials:(NSString *)deviceId teamId:(NSString *)teamId signingPrivateKey:(NSString *)signingPrivateKey)
+RCT_EXTERN_METHOD(saveNseLastSyncTimestamp:(nonnull NSNumber *)timestamp)
@end
diff --git a/packages/mobile/ios/CommunicationModule.swift b/packages/mobile/ios/CommunicationModule.swift
index 5c37920ec0..fc0795fe67 100644
--- a/packages/mobile/ios/CommunicationModule.swift
+++ b/packages/mobile/ios/CommunicationModule.swift
@@ -1,5 +1,6 @@
import UserNotifications
import OSLog
+import UIKit
@objc(CommunicationModule)
class CommunicationModule: RCTEventEmitter {
@@ -11,6 +12,10 @@ class CommunicationModule: RCTEventEmitter {
static let APP_RESUME_IDENTIFIER = "appresume"
static let NOTIFICATION_PERMISSION_RESULT = "notificationPermissionResult"
static let DEVICE_TOKEN_RECEIVED = "deviceTokenReceived"
+ static let NSE_LAST_SYNC_KEY = "quiet.nse.lastSyncTimestamp"
+ static let NSE_BADGE_COUNT_KEY = "quiet.nse.badgeCount"
+ static let APP_IS_FOREGROUND_KEY = "quiet.app.isForeground"
+ static let APP_GROUP_IDENTIFIER = "group.com.quietmobile"
static let WEBSOCKET_CONNECTION_CHANNEL = "_WEBSOCKET_CONNECTION_"
private static let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "CommunicationModule")
@@ -32,11 +37,18 @@ class CommunicationModule: RCTEventEmitter {
@objc
func appPause() {
+ let defaults = UserDefaults(suiteName: CommunicationModule.APP_GROUP_IDENTIFIER) ?? UserDefaults.standard
+ defaults.set(false, forKey: CommunicationModule.APP_IS_FOREGROUND_KEY)
self.sendEvent(withName: CommunicationModule.APP_PAUSE_IDENTIFIER, body: nil)
}
@objc
func appResume() {
+ let defaults = UserDefaults(suiteName: CommunicationModule.APP_GROUP_IDENTIFIER) ?? UserDefaults.standard
+ defaults.set(true, forKey: CommunicationModule.APP_IS_FOREGROUND_KEY)
+ defaults.set(0, forKey: CommunicationModule.NSE_BADGE_COUNT_KEY)
+ UNUserNotificationCenter.current().removeAllDeliveredNotifications()
+ UIApplication.shared.applicationIconBadgeNumber = 0
self.sendEvent(withName: CommunicationModule.APP_RESUME_IDENTIFIER, body: nil)
}
@@ -147,6 +159,23 @@ class CommunicationModule: RCTEventEmitter {
}
}
+ @objc
+ func saveNseLastSyncTimestamp(_ timestamp: NSNumber) {
+ let defaults = UserDefaults(suiteName: CommunicationModule.APP_GROUP_IDENTIFIER) ?? UserDefaults.standard
+ let newTimestamp = timestamp.doubleValue
+ let existingTimestamp = defaults.double(forKey: CommunicationModule.NSE_LAST_SYNC_KEY)
+
+ if existingTimestamp >= newTimestamp {
+ CommunicationModule.logger.debug(
+ "saveNseLastSyncTimestamp: ignoring stale timestamp \(newTimestamp, privacy: .public), existing=\(existingTimestamp, privacy: .public)"
+ )
+ return
+ }
+
+ defaults.set(newTimestamp, forKey: CommunicationModule.NSE_LAST_SYNC_KEY)
+ CommunicationModule.logger.info("saveNseLastSyncTimestamp: stored \(newTimestamp, privacy: .public)")
+ }
+
@objc
func checkNotificationPermission() {
UNUserNotificationCenter.current().getNotificationSettings { settings in
diff --git a/packages/mobile/ios/Quiet/AppDelegate.m b/packages/mobile/ios/Quiet/AppDelegate.m
index cca2289b11..0893562d27 100644
--- a/packages/mobile/ios/Quiet/AppDelegate.m
+++ b/packages/mobile/ios/Quiet/AppDelegate.m
@@ -13,6 +13,16 @@
@implementation AppDelegate
static NSString *const platform = @"mobile";
+static NSString *const QuietAppGroupIdentifier = @"group.com.quietmobile";
+static NSString *const QuietAppIsForegroundKey = @"quiet.app.isForeground";
+
+static void QuietSetAppForegroundFlag(BOOL isForeground) {
+ NSUserDefaults *defaults = [[NSUserDefaults alloc] initWithSuiteName:QuietAppGroupIdentifier];
+ if (defaults == nil) {
+ defaults = [NSUserDefaults standardUserDefaults];
+ }
+ [defaults setBool:isForeground forKey:QuietAppIsForegroundKey];
+}
- (BOOL)application:(UIApplication *)application
openURL:(NSURL *)url
@@ -31,6 +41,7 @@ - (BOOL)application:(UIApplication *)application continueUserActivity:(nonnull N
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
+ QuietSetAppForegroundFlag(YES);
self.moduleName = @"QuietMobile";
// You can add your custom initial props in the dictionary below.
// They will be passed down to the ViewController used by React Native.
@@ -185,6 +196,7 @@ - (void) stopTor {
- (void)applicationDidEnterBackground:(UIApplication *)application
{
+ QuietSetAppForegroundFlag(NO);
[self stopTor];
NSString * message = [NSString stringWithFormat:@"app:close"];
@@ -202,6 +214,7 @@ - (void)applicationDidEnterBackground:(UIApplication *)application
- (void)applicationWillEnterForeground:(UIApplication *)application
{
+ QuietSetAppForegroundFlag(YES);
// Display splash screen until services become available again
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
NSTimeInterval delayInSeconds = 0;
diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/NSEKeychainHelper.swift b/packages/mobile/ios/QuietNotificationServiceExtension/NSEKeychainHelper.swift
index d3e20f42c5..c70ac96447 100644
--- a/packages/mobile/ios/QuietNotificationServiceExtension/NSEKeychainHelper.swift
+++ b/packages/mobile/ios/QuietNotificationServiceExtension/NSEKeychainHelper.swift
@@ -1,5 +1,6 @@
import Foundation
import Security
+import OSLog
struct NSEKeychainHelper {
@@ -9,6 +10,8 @@ struct NSEKeychainHelper {
private static let deviceIdKey = "quiet.device.id"
private static let teamIdKey = "quiet.team.id"
private static let lastSyncKey = "quiet.nse.lastSyncTimestamp"
+ private static let lastSyncCidsKey = "quiet.nse.lastSyncCids"
+ private static let appIsForegroundKey = "quiet.app.isForeground"
private static let lfaKeyService = "com.quietmobile"
// MARK: - Device private key
@@ -61,7 +64,32 @@ struct NSEKeychainHelper {
static func saveLastSyncTimestamp(_ ts: Int64) {
let defaults = UserDefaults(suiteName: "group.com.quietmobile") ?? UserDefaults.standard
- defaults.set(Double(ts), forKey: lastSyncKey)
+ let current = Int64(defaults.double(forKey: lastSyncKey))
+ os_log("Saving last sync timestamp: current=%{public}lld, new=%{public}lld", current, ts)
+ defaults.set(Double(max(current, ts)), forKey: lastSyncKey)
+ }
+
+ static func getLastSyncCids() -> [String] {
+ let defaults = UserDefaults(suiteName: "group.com.quietmobile") ?? UserDefaults.standard
+ return defaults.stringArray(forKey: lastSyncCidsKey) ?? []
+ }
+
+ static func saveLastSyncState(timestamp: Int64, cids: [String]) {
+ let defaults = UserDefaults(suiteName: "group.com.quietmobile") ?? UserDefaults.standard
+ let currentTimestamp = Int64(defaults.double(forKey: lastSyncKey))
+ if timestamp < currentTimestamp {
+ os_log("Ignoring stale sync state save: current=%{public}lld, new=%{public}lld", currentTimestamp, timestamp)
+ return
+ }
+
+ defaults.set(Double(timestamp), forKey: lastSyncKey)
+ defaults.set(Array(Set(cids)).sorted(), forKey: lastSyncCidsKey)
+ os_log("Saved sync state: timestamp=%{public}lld cids=%{public}@", timestamp, cids.joined(separator: ","))
+ }
+
+ static func isMainAppForeground() -> Bool {
+ let defaults = UserDefaults(suiteName: "group.com.quietmobile") ?? UserDefaults.standard
+ return defaults.bool(forKey: appIsForegroundKey)
}
// MARK: - Private helpers
diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/NotificationService.swift b/packages/mobile/ios/QuietNotificationServiceExtension/NotificationService.swift
index 80b2e05d4f..4305432752 100644
--- a/packages/mobile/ios/QuietNotificationServiceExtension/NotificationService.swift
+++ b/packages/mobile/ios/QuietNotificationServiceExtension/NotificationService.swift
@@ -11,6 +11,13 @@ import os.log
private let nseLog = OSLog(subsystem: "com.quietmobile.QuietNotificationServiceExtension", category: "NotificationService")
class NotificationService: UNNotificationServiceExtension {
+ private static let appGroupIdentifier = "group.com.quietmobile"
+ private static let badgeCountKey = "quiet.nse.badgeCount"
+
+ private struct TimedEntry {
+ let entry: LogEntry
+ let timestamp: Int64?
+ }
var contentHandler: ((UNNotificationContent) -> Void)?
var bestAttemptContent: UNMutableNotificationContent?
@@ -54,7 +61,12 @@ class NotificationService: UNNotificationServiceExtension {
private func fetchAndUpdate(userInfo: [AnyHashable: Any]) async {
defer { deliver() }
- os_log("fetchAndUpdate: start", log: nseLog, type: .debug)
+ os_log("fetchAndUpdate: start", log: nseLog, type: .info)
+
+ if NSEKeychainHelper.isMainAppForeground() {
+ os_log("fetchAndUpdate: app is foregrounded, skipping NSE fetch/decrypt work", log: nseLog, type: .info)
+ return
+ }
guard let teamId = userInfo["teamId"] as? String else {
os_log("fetchAndUpdate: missing 'teamId' in userInfo; payload keys=%{public}@",
@@ -92,6 +104,7 @@ class NotificationService: UNNotificationServiceExtension {
}
let since = NSEKeychainHelper.getLastSyncTimestamp()
+ let lastSyncCids = Set(NSEKeychainHelper.getLastSyncCids())
os_log("fetchAndUpdate: fetching entries since=%{public}lld", log: nseLog, type: .info, since)
let entries = try await auth.fetchNewEntries(teamId: teamId, since: since)
@@ -105,17 +118,74 @@ class NotificationService: UNNotificationServiceExtension {
if entries.isEmpty {
os_log("fetchAndUpdate: no new entries, delivering as-is", log: nseLog, type: .info)
} else {
- let newTs = entries.lazy
- .compactMap { Self.iso8601.date(from: $0.receivedAt) }
- .map { Int64($0.timeIntervalSince1970 * 1000) }
- .max()
- if let newTs {
- os_log("fetchAndUpdate: saving lastSyncTimestamp=%{public}lld", log: nseLog, type: .info, newTs)
- NSEKeychainHelper.saveLastSyncTimestamp(newTs)
+ let timedEntries = entries.map { entry in
+ let parsedDate = Self.iso8601.date(from: entry.receivedAt)
+ let timestamp = parsedDate.map { Int64($0.timeIntervalSince1970 * 1000) }
+ return TimedEntry(entry: entry, timestamp: timestamp)
+ }
+
+ let unseenEntries = timedEntries.filter { timedEntry in
+ guard let timestamp = timedEntry.timestamp else {
+ return true
+ }
+ if timestamp > since {
+ return true
+ }
+ if timestamp < since {
+ return false
+ }
+ return !lastSyncCids.contains(timedEntry.entry.cid)
+ }
+
+ if unseenEntries.isEmpty {
+ os_log("fetchAndUpdate: no unseen entries after cursor filtering", log: nseLog, type: .info)
+ return
+ }
+
+ let sortedEntries = unseenEntries.sorted { lhs, rhs in
+ let leftTs = lhs.timestamp ?? Int64.min
+ let rightTs = rhs.timestamp ?? Int64.min
+ if leftTs != rightTs {
+ return leftTs < rightTs
+ }
+ return lhs.entry.cid < rhs.entry.cid
+ }
+
+ let isBootstrapSync = since == 0
+ let notificationEntries: [TimedEntry]
+ if isBootstrapSync, let newestTimestamp = sortedEntries.compactMap(\.timestamp).max() {
+ notificationEntries = sortedEntries.filter { $0.timestamp == newestTimestamp }
+ os_log(
+ "fetchAndUpdate: bootstrap sync detected, collapsing %{public}d fetched entries to %{public}d newest entries",
+ log: nseLog,
+ type: .info,
+ sortedEntries.count,
+ notificationEntries.count
+ )
+ } else {
+ notificationEntries = sortedEntries
+ }
+
+ let maxTimestamp = sortedEntries.compactMap(\.timestamp).max()
+ if let maxTimestamp {
+ let cidsAtMaxTimestamp = sortedEntries
+ .filter { $0.timestamp == maxTimestamp }
+ .map(\.entry.cid)
+ os_log(
+ "fetchAndUpdate: saving sync state timestamp=%{public}lld with %{public}d cid(s)",
+ log: nseLog,
+ type: .info,
+ maxTimestamp,
+ cidsAtMaxTimestamp.count
+ )
+ NSEKeychainHelper.saveLastSyncState(timestamp: maxTimestamp, cids: cidsAtMaxTimestamp)
} else {
// All receivedAt failed to parse — advance by 1ms to avoid reprocessing
os_log("All receivedAt timestamps failed to parse; advancing sync pointer", log: nseLog, type: .fault)
- NSEKeychainHelper.saveLastSyncTimestamp(NSEKeychainHelper.getLastSyncTimestamp() + 1)
+ NSEKeychainHelper.saveLastSyncState(
+ timestamp: NSEKeychainHelper.getLastSyncTimestamp() + 1,
+ cids: notificationEntries.map(\.entry.cid)
+ )
}
guard let content = bestAttemptContent else {
@@ -123,15 +193,15 @@ class NotificationService: UNNotificationServiceExtension {
return
}
- let decryptedMessages = entries.compactMap { entry -> NSEDecryptedNotificationMessage? in
+ let decryptedMessages = notificationEntries.compactMap { timedEntry -> NSEDecryptedNotificationMessage? in
do {
- return try self.crypto.decryptNotificationMessage(from: entry, teamId: teamId)
+ return try self.crypto.decryptNotificationMessage(from: timedEntry.entry, teamId: teamId)
} catch {
os_log(
"fetchAndUpdate: failed to decrypt entry %{public}@: %{public}@",
log: nseLog,
type: .error,
- entry.cid,
+ timedEntry.entry.cid,
String(describing: error)
)
return nil
@@ -159,9 +229,12 @@ class NotificationService: UNNotificationServiceExtension {
os_log("fetchAndUpdate: no decryptable channel messages found", log: nseLog, type: .info)
}
- let badgeIncrement = decryptedMessages.isEmpty ? entries.count : decryptedMessages.count
- let newBadge = (content.badge?.intValue ?? 0) + badgeIncrement
+ let badgeIncrement = decryptedMessages.isEmpty ? notificationEntries.count : decryptedMessages.count
+ let defaults = UserDefaults(suiteName: Self.appGroupIdentifier) ?? UserDefaults.standard
+ let storedBadgeCount = max(0, defaults.integer(forKey: Self.badgeCountKey))
+ let newBadge = storedBadgeCount + badgeIncrement
os_log("fetchAndUpdate: updating badge to %{public}d", log: nseLog, type: .info, newBadge)
+ defaults.set(newBadge, forKey: Self.badgeCountKey)
content.badge = newBadge as NSNumber
}
} catch {
diff --git a/packages/mobile/src/setupTests.tsx b/packages/mobile/src/setupTests.tsx
index c668908f41..84fd2e5065 100644
--- a/packages/mobile/src/setupTests.tsx
+++ b/packages/mobile/src/setupTests.tsx
@@ -47,6 +47,7 @@ jest.mock('react-native', () => {
requestNotificationPermission: jest.fn(),
checkNotificationPermission: jest.fn(),
handleIncomingEvents: jest.fn(),
+ saveNseLastSyncTimestamp: jest.fn(),
}
rn.NativeModules.FirebaseMessagingModule = {
getToken: jest.fn(),
diff --git a/packages/mobile/src/store/init/startConnection/startConnection.saga.ts b/packages/mobile/src/store/init/startConnection/startConnection.saga.ts
index 8f89626a39..49ef406f6e 100644
--- a/packages/mobile/src/store/init/startConnection/startConnection.saga.ts
+++ b/packages/mobile/src/store/init/startConnection/startConnection.saga.ts
@@ -1,4 +1,5 @@
import { io } from 'socket.io-client'
+import { NativeModules } from 'react-native'
import {
select,
put,
@@ -16,7 +17,14 @@ import { PayloadAction } from '@reduxjs/toolkit'
import { socket as stateManager, Socket } from '@quiet/state-manager'
import { initActions, WebsocketConnectionPayload } from '../init.slice'
import { eventChannel } from 'redux-saga'
-import { DeviceCredentialsUpdatedEvent, KeysUpdatedEvent, SocketActions, SocketEvents, UserProfilesUpdatedPayload } from '@quiet/types'
+import {
+ DeviceCredentialsUpdatedEvent,
+ KeysUpdatedEvent,
+ NseSyncTimestampUpdatedEvent,
+ SocketActions,
+ SocketEvents,
+ UserProfilesUpdatedPayload,
+} from '@quiet/types'
import { createLogger } from '../../../utils/logger'
import { initSelectors } from '../init.selectors'
import { keysActions } from '../../keys/keys.slice'
@@ -105,6 +113,14 @@ function subscribeSocketLifecycle(socket: Socket, socketIOData: WebsocketConnect
logger.info('User profiles updated, saving in ios native storage')
emit(usersMetadataActions.saveUserMetadataNatively(payload))
})
+ socket.on(SocketEvents.NSE_SYNC_TIMESTAMP_UPDATED, async (payload: NseSyncTimestampUpdatedEvent) => {
+ logger.info(`NSE sync timestamp updated for team ${payload.teamId}, saving in shared iOS storage`)
+ try {
+ await NativeModules.CommunicationModule?.saveNseLastSyncTimestamp?.(payload.lastSyncTimestamp)
+ } catch (error) {
+ logger.error('Failed to store NSE sync timestamp in iOS native storage', error)
+ }
+ })
return () => {}
})
}
diff --git a/packages/state-manager/src/sagas/appConnection/connection.selectors.ts b/packages/state-manager/src/sagas/appConnection/connection.selectors.ts
index 4b8830c546..9536cdffc9 100644
--- a/packages/state-manager/src/sagas/appConnection/connection.selectors.ts
+++ b/packages/state-manager/src/sagas/appConnection/connection.selectors.ts
@@ -21,6 +21,8 @@ export const torBootstrapProcess = createSelector(connectionSlice, reducerState
export const isTorInitialized = createSelector(connectionSlice, reducerState => reducerState.isTorInitialized)
+export const isQssConnected = createSelector(connectionSlice, reducerState => reducerState.isQssConnected)
+
export const connectionProcess = createSelector(connectionSlice, reducerState => reducerState.connectionProcess)
export const socketIOSecret = createSelector(connectionSlice, reducerState => reducerState.socketIOSecret)
@@ -141,6 +143,7 @@ export const connectionSelectors = {
torBootstrapProcess,
connectionProcess,
isTorInitialized,
+ isQssConnected,
socketIOSecret,
isJoiningCompleted,
peerStats,
diff --git a/packages/state-manager/src/sagas/appConnection/connection.slice.ts b/packages/state-manager/src/sagas/appConnection/connection.slice.ts
index ace502d093..2145398445 100644
--- a/packages/state-manager/src/sagas/appConnection/connection.slice.ts
+++ b/packages/state-manager/src/sagas/appConnection/connection.slice.ts
@@ -17,6 +17,7 @@ export class ConnectionState {
public uptime = 0
public peersStats: EntityState = peersStatsAdapter.getInitialState()
public isTorInitialized = false
+ public isQssConnected = false
public socketIOSecret: string | null = null
public torBootstrapProcess = 'Bootstrapped 0% (starting)'
public connectionProcess: { number: number; text: ConnectionProcessInfo } = {
@@ -59,6 +60,12 @@ export const connectionSlice = createSlice({
setTorInitialized: state => {
state.isTorInitialized = true
},
+ setQssConnected: state => {
+ state.isQssConnected = true
+ },
+ setQssDisconnected: state => {
+ state.isQssConnected = false
+ },
setLongLivedInvite: (state, action: PayloadAction) => {
state.longLivedInvite = action.payload
},
diff --git a/packages/state-manager/src/sagas/socket/startConnection/startConnection.saga.ts b/packages/state-manager/src/sagas/socket/startConnection/startConnection.saga.ts
index e988254ddf..ef3ebff65e 100644
--- a/packages/state-manager/src/sagas/socket/startConnection/startConnection.saga.ts
+++ b/packages/state-manager/src/sagas/socket/startConnection/startConnection.saga.ts
@@ -88,6 +88,8 @@ export function subscribe(socket: Socket) {
| ReturnType
| ReturnType
| ReturnType
+ | ReturnType
+ | ReturnType
| ReturnType
| ReturnType
| ReturnType
@@ -107,6 +109,14 @@ export function subscribe(socket: Socket) {
logger.info(`${SocketEvents.TOR_INITIALIZED}`)
emit(connectionActions.setTorInitialized())
})
+ socket.on(SocketEvents.QSS_CONNECTED, () => {
+ logger.info(`${SocketEvents.QSS_CONNECTED}`)
+ emit(connectionActions.setQssConnected())
+ })
+ socket.on(SocketEvents.QSS_DISCONNECTED, () => {
+ logger.info(`${SocketEvents.QSS_DISCONNECTED}`)
+ emit(connectionActions.setQssDisconnected())
+ })
socket.on(SocketEvents.CONNECTION_PROCESS_INFO, (payload: string) => {
logger.info(`${SocketEvents.CONNECTION_PROCESS_INFO}`, payload)
emit(connectionActions.onConnectionProcessInfo(payload))
diff --git a/packages/types/src/keys.ts b/packages/types/src/keys.ts
index f6ecd314dd..15ad7bce01 100644
--- a/packages/types/src/keys.ts
+++ b/packages/types/src/keys.ts
@@ -15,3 +15,8 @@ export interface DeviceCredentialsUpdatedEvent {
/** Base58-encoded 64-byte libsodium Ed25519 signing private key */
signingPrivateKey: string
}
+
+export interface NseSyncTimestampUpdatedEvent {
+ teamId: string
+ lastSyncTimestamp: number
+}
diff --git a/packages/types/src/socket.ts b/packages/types/src/socket.ts
index 36e6730bf2..730c029e77 100644
--- a/packages/types/src/socket.ts
+++ b/packages/types/src/socket.ts
@@ -40,7 +40,7 @@ import {
} from './community'
import { ErrorPayload } from './errors'
import { HCaptchaChallengeRequest, HCaptchaFormResponse, HCaptchaRequest } from './captcha'
-import { DeviceCredentialsUpdatedEvent, KeysUpdatedEvent } from './keys'
+import { DeviceCredentialsUpdatedEvent, KeysUpdatedEvent, NseSyncTimestampUpdatedEvent } from './keys'
// -----------------------------------------------------------------------------
// SocketActions: These are the actions the frontend emits to the backend
@@ -150,6 +150,9 @@ export enum SocketEvents {
PEER_CONNECTED = 'peerConnected',
PEER_DISCONNECTED = 'peerDisconnected',
TOR_INITIALIZED = 'torInitialized',
+ QSS_CONNECTED = 'qssConnected',
+ QSS_DISCONNECTED = 'qssDisconnected',
+ NSE_SYNC_TIMESTAMP_UPDATED = 'nseSyncTimestampUpdated',
MIGRATION_DATA_REQUIRED = 'migrationDataRequired',
PUSH_NOTIFICATION = 'pushNotification',
CONNECTION_PROCESS_INFO = 'connectionProcess',
@@ -258,6 +261,9 @@ export interface SocketEventsMap {
[SocketEvents.PEER_CONNECTED]: EmitEvent
[SocketEvents.PEER_DISCONNECTED]: EmitEvent
[SocketEvents.TOR_INITIALIZED]: EmitEvent
+ [SocketEvents.QSS_CONNECTED]: EmitEvent
+ [SocketEvents.QSS_DISCONNECTED]: EmitEvent
+ [SocketEvents.NSE_SYNC_TIMESTAMP_UPDATED]: EmitEvent
[SocketEvents.MIGRATION_DATA_REQUIRED]: EmitEvent
[SocketEvents.PUSH_NOTIFICATION]: EmitEvent
[SocketEvents.CONNECTION_PROCESS_INFO]: EmitEvent
From 134181f4bcc04b9a5b9c91cb265dcb8e5b2256cb Mon Sep 17 00:00:00 2001
From: taea
Date: Thu, 26 Mar 2026 12:49:26 -0400
Subject: [PATCH 34/92] pass qss_endpoint to nse from backend
---
.../connections-manager.service.spec.ts | 36 +++++++++++-
.../connections-manager.service.ts | 55 +++++++++++++++++++
.../backend/src/nest/qps/qps.service.spec.ts | 38 ++++++++++++-
packages/backend/src/nest/qps/qps.service.ts | 12 ++--
packages/mobile/ios/CommunicationBridge.m | 1 +
packages/mobile/ios/CommunicationModule.swift | 18 ++++++
.../NSEKeychainHelper.swift | 13 +++++
.../NotificationService.swift | 12 ++--
packages/mobile/src/setupTests.tsx | 1 +
.../startConnection/startConnection.saga.ts | 9 +++
packages/types/src/keys.ts | 5 ++
packages/types/src/socket.ts | 4 +-
12 files changed, 185 insertions(+), 19 deletions(-)
diff --git a/packages/backend/src/nest/connections-manager/connections-manager.service.spec.ts b/packages/backend/src/nest/connections-manager/connections-manager.service.spec.ts
index 971586afec..c1d936f9e1 100644
--- a/packages/backend/src/nest/connections-manager/connections-manager.service.spec.ts
+++ b/packages/backend/src/nest/connections-manager/connections-manager.service.spec.ts
@@ -2,7 +2,7 @@ import { jest } from '@jest/globals'
import { Test, TestingModule } from '@nestjs/testing'
import { getReduxStoreFactory, prepareStore, type Store } from '@quiet/state-manager'
-import { CommunityOwnership, type Community, type Identity } from '@quiet/types'
+import { CommunityOwnership, SocketEvents, type Community, type Identity } from '@quiet/types'
import { type FactoryGirl } from 'factory-girl'
import { TestModule } from '../common/test.module'
import { removeFilesFromDir } from '../common/utils'
@@ -17,6 +17,7 @@ import { createLibp2pAddress } from '@quiet/common'
import { createLogger } from '../common/logger'
import { SigChainService } from '../auth/sigchain.service'
import { StorageModule } from '../storage/storage.module'
+import { QSSService } from '../qss/qss.service'
const logger = createLogger('connections-manager.service.spec')
@@ -31,6 +32,7 @@ describe('ConnectionsManagerService', () => {
let userIdentity: Identity
let communityRootCa: string
let sigChainService: SigChainService
+ let qssService: QSSService
beforeEach(async () => {
jest.clearAllMocks()
@@ -54,6 +56,7 @@ describe('ConnectionsManagerService', () => {
connectionsManagerService = await module.resolve(ConnectionsManagerService)
localDbService = await module.resolve(LocalDbService)
sigChainService = await module.resolve(SigChainService)
+ qssService = await module.resolve(QSSService)
localDbService.open()
// initialize sigchain on local db
@@ -130,4 +133,35 @@ describe('ConnectionsManagerService', () => {
expect(launchSpy).toBeCalledTimes(1)
})
+
+ it('emits the authoritative QSS URL for the NSE on iOS', async () => {
+ const originalPlatform = process.platform
+ Object.defineProperty(process, 'platform', {
+ value: 'ios',
+ })
+
+ try {
+ await localDbService.setCommunity({
+ ...community,
+ teamId: 'team-id',
+ qssEndpoint: 'wss://community.example/ws',
+ })
+ await localDbService.setCurrentCommunityId(community.id)
+
+ qssService._qssEndpoint = 'wss://authoritative.example'
+
+ const emitSpy = jest.spyOn(connectionsManagerService.serverIoProvider.io, 'emit')
+
+ await (connectionsManagerService as any).emitNseQssUrl()
+
+ expect(emitSpy).toHaveBeenCalledWith(SocketEvents.NSE_QSS_URL_UPDATED, {
+ teamId: 'team-id',
+ qssUrl: 'https://authoritative.example',
+ })
+ } finally {
+ Object.defineProperty(process, 'platform', {
+ value: originalPlatform,
+ })
+ }
+ })
})
diff --git a/packages/backend/src/nest/connections-manager/connections-manager.service.ts b/packages/backend/src/nest/connections-manager/connections-manager.service.ts
index 32ba736d90..11631f9cae 100644
--- a/packages/backend/src/nest/connections-manager/connections-manager.service.ts
+++ b/packages/backend/src/nest/connections-manager/connections-manager.service.ts
@@ -50,6 +50,7 @@ import {
InvitationData,
SetUserProfileResponse,
UserProfilesUpdatedPayload,
+ NseQssUrlUpdatedEvent,
} from '@quiet/types'
import { CONFIG_OPTIONS, QSS_ALLOWED, QSS_ENDPOINT, SERVER_IO_PROVIDER, SOCKS_PROXY_AGENT } from '../const'
import { Libp2pService, Libp2pState } from '../libp2p/libp2p.service'
@@ -590,6 +591,7 @@ export class ConnectionsManagerService extends EventEmitter implements OnModuleI
// Unblock websocket endpoints
this.socketService.resolveReadyness()
+ void this.emitNseQssUrl()
this.serverIoProvider.io.emit(SocketEvents.COMMUNITY_LAUNCHED, {
id: community.id,
} as LaunchCommunityPayload)
@@ -675,10 +677,63 @@ export class ConnectionsManagerService extends EventEmitter implements OnModuleI
})
}
+ private getNseQssUrl(wsUrl: string | undefined): string | undefined {
+ if (wsUrl == null || wsUrl === '') {
+ return undefined
+ }
+
+ if (wsUrl.startsWith('wss://')) {
+ return `https://${wsUrl.slice('wss://'.length)}`
+ }
+
+ if (wsUrl.startsWith('ws://')) {
+ return `http://${wsUrl.slice('ws://'.length)}`
+ }
+
+ this.logger.warn('Skipping NSE QSS URL update because endpoint is not ws/wss', wsUrl)
+ return undefined
+ }
+
+ private async emitNseQssUrl(socket?: { emit: (event: string, payload: NseQssUrlUpdatedEvent) => void }): Promise {
+ if ((process.platform as string) !== 'ios') {
+ return
+ }
+
+ const community = await this.localDbService.getCurrentCommunity()
+ if (community?.teamId == null) {
+ return
+ }
+
+ const wsUrl = this.qssService.qssEndpoint ?? community.qssEndpoint ?? this.qssEndpoint
+ const qssUrl = this.getNseQssUrl(wsUrl)
+ if (qssUrl == null) {
+ return
+ }
+
+ const payload: NseQssUrlUpdatedEvent = {
+ teamId: community.teamId,
+ qssUrl,
+ }
+
+ if (socket != null) {
+ socket.emit(SocketEvents.NSE_QSS_URL_UPDATED, payload)
+ return
+ }
+
+ this.serverIoProvider.io.emit(SocketEvents.NSE_QSS_URL_UPDATED, payload)
+ }
+
/**
* Attaches listeners for events received from the state manager
*/
private attachSocketServiceListeners() {
+ this.serverIoProvider.io.on(SocketActions.CONNECTION, socket => {
+ void this.emitNseQssUrl(socket)
+ socket.on(SocketActions.START, () => {
+ void this.emitNseQssUrl(socket)
+ })
+ })
+
// Community
this.socketService.on(SocketActions.CONNECTION, () => {
this.logger.info(`socketService - ${SocketActions.CONNECTION}`)
diff --git a/packages/backend/src/nest/qps/qps.service.spec.ts b/packages/backend/src/nest/qps/qps.service.spec.ts
index 9f73d0d4eb..89730437d1 100644
--- a/packages/backend/src/nest/qps/qps.service.spec.ts
+++ b/packages/backend/src/nest/qps/qps.service.spec.ts
@@ -302,7 +302,29 @@ describe('QPSService', () => {
WebsocketEvents.SEND_BATCH_PUSH,
expect.objectContaining({
status: CommunityOperationStatus.SENDING,
- payload: { ucans: UCANS },
+ payload: expect.objectContaining({
+ ucans: UCANS,
+ data: { teamId: TEAM_ID },
+ }),
+ }),
+ true
+ )
+ })
+
+ it('strips qssUrl from push data before sending to QPS', async () => {
+ await qpsService.sendBatchPush(TEAM_ID, 'title', 'body', {
+ cid: 'cid-1',
+ qssUrl: 'https://untrusted.example',
+ })
+
+ expect(qssClient.sendMessage).toHaveBeenCalledWith(
+ WebsocketEvents.SEND_BATCH_PUSH,
+ expect.objectContaining({
+ payload: expect.objectContaining({
+ title: 'title',
+ body: 'body',
+ data: { teamId: TEAM_ID, cid: 'cid-1' },
+ }),
}),
true
)
@@ -367,13 +389,23 @@ describe('QPSService', () => {
expect(qssClient.sendMessage).toHaveBeenNthCalledWith(
1,
WebsocketEvents.SEND_BATCH_PUSH,
- expect.objectContaining({ payload: { ucans: manyUcans.slice(0, 500) } }),
+ expect.objectContaining({
+ payload: expect.objectContaining({
+ ucans: manyUcans.slice(0, 500),
+ data: { teamId: TEAM_ID },
+ }),
+ }),
true
)
expect(qssClient.sendMessage).toHaveBeenNthCalledWith(
2,
WebsocketEvents.SEND_BATCH_PUSH,
- expect.objectContaining({ payload: { ucans: manyUcans.slice(500) } }),
+ expect.objectContaining({
+ payload: expect.objectContaining({
+ ucans: manyUcans.slice(500),
+ data: { teamId: TEAM_ID },
+ }),
+ }),
true
)
})
diff --git a/packages/backend/src/nest/qps/qps.service.ts b/packages/backend/src/nest/qps/qps.service.ts
index fc3461d106..859bc1d167 100644
--- a/packages/backend/src/nest/qps/qps.service.ts
+++ b/packages/backend/src/nest/qps/qps.service.ts
@@ -151,15 +151,10 @@ export class QPSService implements OnModuleInit {
batches.push(ucans.slice(i, i + PUSH_BATCH_SIZE))
}
- // Convert ws:// → http:// (or wss:// → https://) so the NSE can use this
- // URL for HTTP REST calls to /nse-auth/. URLSession does not support ws://
- // scheme for regular data tasks.
- const wsUrl = this.qssService.qssEndpoint
- const qssUrl = wsUrl?.replace(/^wss?:\/\//, match => (match === 'wss://' ? 'https://' : 'http://'))
+ const { qssUrl: _ignoredQssUrl, ...safeData } = data ?? {}
const mergedData: Record = {
teamId,
- ...(qssUrl != null ? { qssUrl } : {}),
- ...data,
+ ...safeData,
}
this.logger.info(
@@ -204,12 +199,13 @@ export class QPSService implements OnModuleInit {
}
try {
+ const { qssUrl: _ignoredQssUrl, ...safeData } = data ?? {}
return await this.qssClient.sendMessage(
WebsocketEvents.SEND_PUSH,
{
ts: DateTime.utc().toMillis(),
status: CommunityOperationStatus.SENDING,
- payload: { ucan, title, body, data },
+ payload: { ucan, title, body, data: safeData },
},
true
)
diff --git a/packages/mobile/ios/CommunicationBridge.m b/packages/mobile/ios/CommunicationBridge.m
index a044434144..7b0f5abbba 100644
--- a/packages/mobile/ios/CommunicationBridge.m
+++ b/packages/mobile/ios/CommunicationBridge.m
@@ -8,5 +8,6 @@ @interface RCT_EXTERN_MODULE(CommunicationModule, RCTEventEmitter)
RCT_EXTERN_METHOD(saveKeysInKeychain:(NSArray *)newKeys)
RCT_EXTERN_METHOD(saveUserMetadata:(NSArray *)updatedMetadata)
RCT_EXTERN_METHOD(saveDeviceCredentials:(NSString *)deviceId teamId:(NSString *)teamId signingPrivateKey:(NSString *)signingPrivateKey)
+RCT_EXTERN_METHOD(saveNseQssUrl:(NSString *)teamId qssUrl:(NSString *)qssUrl)
RCT_EXTERN_METHOD(saveNseLastSyncTimestamp:(nonnull NSNumber *)timestamp)
@end
diff --git a/packages/mobile/ios/CommunicationModule.swift b/packages/mobile/ios/CommunicationModule.swift
index fc0795fe67..49ed5e99e8 100644
--- a/packages/mobile/ios/CommunicationModule.swift
+++ b/packages/mobile/ios/CommunicationModule.swift
@@ -13,6 +13,7 @@ class CommunicationModule: RCTEventEmitter {
static let NOTIFICATION_PERMISSION_RESULT = "notificationPermissionResult"
static let DEVICE_TOKEN_RECEIVED = "deviceTokenReceived"
static let NSE_LAST_SYNC_KEY = "quiet.nse.lastSyncTimestamp"
+ static let NSE_QSS_URLS_KEY = "quiet.nse.qssUrls"
static let NSE_BADGE_COUNT_KEY = "quiet.nse.badgeCount"
static let APP_IS_FOREGROUND_KEY = "quiet.app.isForeground"
static let APP_GROUP_IDENTIFIER = "group.com.quietmobile"
@@ -159,6 +160,23 @@ class CommunicationModule: RCTEventEmitter {
}
}
+ @objc
+ func saveNseQssUrl(_ teamId: NSString, qssUrl: NSString) {
+ let teamIdStr = teamId as String
+ let qssUrlStr = qssUrl as String
+ let defaults = UserDefaults(suiteName: CommunicationModule.APP_GROUP_IDENTIFIER) ?? UserDefaults.standard
+ var existing = defaults.dictionary(forKey: CommunicationModule.NSE_QSS_URLS_KEY) as? [String: String] ?? [:]
+
+ if existing[teamIdStr] == qssUrlStr {
+ CommunicationModule.logger.debug("saveNseQssUrl: unchanged for team \(teamIdStr, privacy: .public)")
+ return
+ }
+
+ existing[teamIdStr] = qssUrlStr
+ defaults.set(existing, forKey: CommunicationModule.NSE_QSS_URLS_KEY)
+ CommunicationModule.logger.info("saveNseQssUrl: stored for team \(teamIdStr, privacy: .public)")
+ }
+
@objc
func saveNseLastSyncTimestamp(_ timestamp: NSNumber) {
let defaults = UserDefaults(suiteName: CommunicationModule.APP_GROUP_IDENTIFIER) ?? UserDefaults.standard
diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/NSEKeychainHelper.swift b/packages/mobile/ios/QuietNotificationServiceExtension/NSEKeychainHelper.swift
index c70ac96447..b91ed2634e 100644
--- a/packages/mobile/ios/QuietNotificationServiceExtension/NSEKeychainHelper.swift
+++ b/packages/mobile/ios/QuietNotificationServiceExtension/NSEKeychainHelper.swift
@@ -11,6 +11,7 @@ struct NSEKeychainHelper {
private static let teamIdKey = "quiet.team.id"
private static let lastSyncKey = "quiet.nse.lastSyncTimestamp"
private static let lastSyncCidsKey = "quiet.nse.lastSyncCids"
+ private static let qssUrlsKey = "quiet.nse.qssUrls"
private static let appIsForegroundKey = "quiet.app.isForeground"
private static let lfaKeyService = "com.quietmobile"
@@ -87,6 +88,18 @@ struct NSEKeychainHelper {
os_log("Saved sync state: timestamp=%{public}lld cids=%{public}@", timestamp, cids.joined(separator: ","))
}
+ static func getQssUrl(teamId: String) -> URL? {
+ let defaults = UserDefaults(suiteName: "group.com.quietmobile") ?? UserDefaults.standard
+ guard
+ let qssUrls = defaults.dictionary(forKey: qssUrlsKey) as? [String: String],
+ let qssUrlString = qssUrls[teamId]
+ else {
+ return nil
+ }
+
+ return URL(string: qssUrlString)
+ }
+
static func isMainAppForeground() -> Bool {
let defaults = UserDefaults(suiteName: "group.com.quietmobile") ?? UserDefaults.standard
return defaults.bool(forKey: appIsForegroundKey)
diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/NotificationService.swift b/packages/mobile/ios/QuietNotificationServiceExtension/NotificationService.swift
index 4305432752..c397844ae8 100644
--- a/packages/mobile/ios/QuietNotificationServiceExtension/NotificationService.swift
+++ b/packages/mobile/ios/QuietNotificationServiceExtension/NotificationService.swift
@@ -75,16 +75,16 @@ class NotificationService: UNNotificationServiceExtension {
return
}
- guard let qssUrlString = userInfo["qssUrl"] as? String else {
- os_log("fetchAndUpdate: missing 'qssUrl' in userInfo (teamId=%{public}@)",
+ guard let qssUrl = NSEKeychainHelper.getQssUrl(teamId: teamId) else {
+ os_log("fetchAndUpdate: missing stored QSS URL for teamId=%{public}@",
log: nseLog, type: .error, teamId)
return
}
- guard let qssUrl = URL(string: qssUrlString) else {
- os_log("fetchAndUpdate: 'qssUrl' is not a valid URL: %{public}@",
- log: nseLog, type: .error, qssUrlString)
- return
+ let qssUrlString = qssUrl.absoluteString
+ if let payloadQssUrl = userInfo["qssUrl"] as? String, payloadQssUrl != qssUrlString {
+ os_log("fetchAndUpdate: ignoring push payload qssUrl for teamId=%{public}@; using stored value",
+ log: nseLog, type: .info, teamId)
}
os_log("fetchAndUpdate: teamId=%{public}@ qssUrl=%{public}@",
diff --git a/packages/mobile/src/setupTests.tsx b/packages/mobile/src/setupTests.tsx
index 84fd2e5065..e989a14cb9 100644
--- a/packages/mobile/src/setupTests.tsx
+++ b/packages/mobile/src/setupTests.tsx
@@ -47,6 +47,7 @@ jest.mock('react-native', () => {
requestNotificationPermission: jest.fn(),
checkNotificationPermission: jest.fn(),
handleIncomingEvents: jest.fn(),
+ saveNseQssUrl: jest.fn(),
saveNseLastSyncTimestamp: jest.fn(),
}
rn.NativeModules.FirebaseMessagingModule = {
diff --git a/packages/mobile/src/store/init/startConnection/startConnection.saga.ts b/packages/mobile/src/store/init/startConnection/startConnection.saga.ts
index 49ef406f6e..e271859e0e 100644
--- a/packages/mobile/src/store/init/startConnection/startConnection.saga.ts
+++ b/packages/mobile/src/store/init/startConnection/startConnection.saga.ts
@@ -20,6 +20,7 @@ import { eventChannel } from 'redux-saga'
import {
DeviceCredentialsUpdatedEvent,
KeysUpdatedEvent,
+ NseQssUrlUpdatedEvent,
NseSyncTimestampUpdatedEvent,
SocketActions,
SocketEvents,
@@ -113,6 +114,14 @@ function subscribeSocketLifecycle(socket: Socket, socketIOData: WebsocketConnect
logger.info('User profiles updated, saving in ios native storage')
emit(usersMetadataActions.saveUserMetadataNatively(payload))
})
+ socket.on(SocketEvents.NSE_QSS_URL_UPDATED, async (payload: NseQssUrlUpdatedEvent) => {
+ logger.info(`NSE QSS URL updated for team ${payload.teamId}, saving in shared iOS storage`)
+ try {
+ await NativeModules.CommunicationModule?.saveNseQssUrl?.(payload.teamId, payload.qssUrl)
+ } catch (error) {
+ logger.error('Failed to store NSE QSS URL in iOS native storage', error)
+ }
+ })
socket.on(SocketEvents.NSE_SYNC_TIMESTAMP_UPDATED, async (payload: NseSyncTimestampUpdatedEvent) => {
logger.info(`NSE sync timestamp updated for team ${payload.teamId}, saving in shared iOS storage`)
try {
diff --git a/packages/types/src/keys.ts b/packages/types/src/keys.ts
index 15ad7bce01..2e558f88d9 100644
--- a/packages/types/src/keys.ts
+++ b/packages/types/src/keys.ts
@@ -16,6 +16,11 @@ export interface DeviceCredentialsUpdatedEvent {
signingPrivateKey: string
}
+export interface NseQssUrlUpdatedEvent {
+ teamId: string
+ qssUrl: string
+}
+
export interface NseSyncTimestampUpdatedEvent {
teamId: string
lastSyncTimestamp: number
diff --git a/packages/types/src/socket.ts b/packages/types/src/socket.ts
index 730c029e77..1ed66a75cd 100644
--- a/packages/types/src/socket.ts
+++ b/packages/types/src/socket.ts
@@ -40,7 +40,7 @@ import {
} from './community'
import { ErrorPayload } from './errors'
import { HCaptchaChallengeRequest, HCaptchaFormResponse, HCaptchaRequest } from './captcha'
-import { DeviceCredentialsUpdatedEvent, KeysUpdatedEvent, NseSyncTimestampUpdatedEvent } from './keys'
+import { DeviceCredentialsUpdatedEvent, KeysUpdatedEvent, NseQssUrlUpdatedEvent, NseSyncTimestampUpdatedEvent } from './keys'
// -----------------------------------------------------------------------------
// SocketActions: These are the actions the frontend emits to the backend
@@ -152,6 +152,7 @@ export enum SocketEvents {
TOR_INITIALIZED = 'torInitialized',
QSS_CONNECTED = 'qssConnected',
QSS_DISCONNECTED = 'qssDisconnected',
+ NSE_QSS_URL_UPDATED = 'nseQssUrlUpdated',
NSE_SYNC_TIMESTAMP_UPDATED = 'nseSyncTimestampUpdated',
MIGRATION_DATA_REQUIRED = 'migrationDataRequired',
PUSH_NOTIFICATION = 'pushNotification',
@@ -263,6 +264,7 @@ export interface SocketEventsMap {
[SocketEvents.TOR_INITIALIZED]: EmitEvent
[SocketEvents.QSS_CONNECTED]: EmitEvent
[SocketEvents.QSS_DISCONNECTED]: EmitEvent
+ [SocketEvents.NSE_QSS_URL_UPDATED]: EmitEvent
[SocketEvents.NSE_SYNC_TIMESTAMP_UPDATED]: EmitEvent
[SocketEvents.MIGRATION_DATA_REQUIRED]: EmitEvent
[SocketEvents.PUSH_NOTIFICATION]: EmitEvent
From 1961e7ebe318962c4a266ef64c568c5ddfe90834 Mon Sep 17 00:00:00 2001
From: taea
Date: Mon, 30 Mar 2026 13:22:42 -0400
Subject: [PATCH 35/92] avoid dropping key after writing, ensure qss url is
shared at join
---
.../connections-manager.service.ts | 18 ++++++++--------
packages/mobile/ios/FindFreePort.swift | 4 ++--
packages/mobile/ios/KeychainHandler.swift | 6 ------
.../ios/Quiet.xcodeproj/project.pbxproj | 21 +++++--------------
.../NotificationService.swift | 12 +++++++++--
5 files changed, 26 insertions(+), 35 deletions(-)
diff --git a/packages/backend/src/nest/connections-manager/connections-manager.service.ts b/packages/backend/src/nest/connections-manager/connections-manager.service.ts
index 11631f9cae..3bd195c887 100644
--- a/packages/backend/src/nest/connections-manager/connections-manager.service.ts
+++ b/packages/backend/src/nest/connections-manager/connections-manager.service.ts
@@ -394,7 +394,7 @@ export class ConnectionsManagerService extends EventEmitter implements OnModuleI
peerList: [localAddress],
psk: Libp2pService.generateLibp2pPSK().psk,
ownership: CommunityOwnership.Owner,
- teamId: sigchain.team!.id,
+ teamId: sigchain.team?.id,
qssEnabled: this.qssAllowed && payload.useServer,
qssEndpoint: this.qssEndpoint,
tosAccepted: payload.tosAccepted,
@@ -509,6 +509,7 @@ export class ConnectionsManagerService extends EventEmitter implements OnModuleI
peerList: [...new Set([localAddress, ...Object.keys(bootstrapPeerStats)])], // TODO: we should deprecate this field and use db
inviteData,
psk: inviteData.psk,
+ teamId: inviteData.version === InvitationDataVersion.v3 ? inviteData.authData.teamId : undefined,
ownership: CommunityOwnership.User,
qssEnabled: inviteData?.version === InvitationDataVersion.v3 ? inviteData.qssEnabled : undefined,
qssEndpoint: inviteData?.version === InvitationDataVersion.v3 ? inviteData.qssEndpoint : undefined,
@@ -679,6 +680,7 @@ export class ConnectionsManagerService extends EventEmitter implements OnModuleI
private getNseQssUrl(wsUrl: string | undefined): string | undefined {
if (wsUrl == null || wsUrl === '') {
+ this.logger.warn('Skipping NSE QSS URL update because wsUrl is empty')
return undefined
}
@@ -694,19 +696,22 @@ export class ConnectionsManagerService extends EventEmitter implements OnModuleI
return undefined
}
- private async emitNseQssUrl(socket?: { emit: (event: string, payload: NseQssUrlUpdatedEvent) => void }): Promise {
+ private async emitNseQssUrl(): Promise {
if ((process.platform as string) !== 'ios') {
return
}
const community = await this.localDbService.getCurrentCommunity()
if (community?.teamId == null) {
+ this.logger.warn('Skipping NSE QSS URL update because no active community or team ID found')
+ this.logger.warn('Community', community)
return
}
const wsUrl = this.qssService.qssEndpoint ?? community.qssEndpoint ?? this.qssEndpoint
const qssUrl = this.getNseQssUrl(wsUrl)
if (qssUrl == null) {
+ this.logger.warn('Skipping NSE QSS URL update because no valid QSS URL could be derived')
return
}
@@ -715,11 +720,6 @@ export class ConnectionsManagerService extends EventEmitter implements OnModuleI
qssUrl,
}
- if (socket != null) {
- socket.emit(SocketEvents.NSE_QSS_URL_UPDATED, payload)
- return
- }
-
this.serverIoProvider.io.emit(SocketEvents.NSE_QSS_URL_UPDATED, payload)
}
@@ -728,9 +728,9 @@ export class ConnectionsManagerService extends EventEmitter implements OnModuleI
*/
private attachSocketServiceListeners() {
this.serverIoProvider.io.on(SocketActions.CONNECTION, socket => {
- void this.emitNseQssUrl(socket)
+ void this.emitNseQssUrl()
socket.on(SocketActions.START, () => {
- void this.emitNseQssUrl(socket)
+ void this.emitNseQssUrl()
})
})
diff --git a/packages/mobile/ios/FindFreePort.swift b/packages/mobile/ios/FindFreePort.swift
index 6730548f51..17744c67ee 100644
--- a/packages/mobile/ios/FindFreePort.swift
+++ b/packages/mobile/ios/FindFreePort.swift
@@ -5,7 +5,7 @@ class FindFreePort: NSObject {
func getFirstStartingFrom(port: in_port_t) -> UInt16 {
var returned = port;
for i in port..<65000 {
- let (result, _) = checkPort(port: port)
+ let (result, _) = checkPort(port: i)
if result == true {
returned = i;
break;
@@ -53,5 +53,5 @@ class FindFreePort: NSObject {
func descriptionOfLastError() -> String {
return String.init(cString: (UnsafePointer(strerror(errno))))
}
-
+
}
diff --git a/packages/mobile/ios/KeychainHandler.swift b/packages/mobile/ios/KeychainHandler.swift
index 7ad76b1409..789b5574ea 100644
--- a/packages/mobile/ios/KeychainHandler.swift
+++ b/packages/mobile/ios/KeychainHandler.swift
@@ -92,9 +92,6 @@ class KeychainHandler: NSObject {
keyData: keyData,
includeAccessGroup: true
)
- if addStatus == .success {
- try? _deleteKeyImpl(keyName: namedKey.keyName, includeAccessGroup: false)
- }
return addStatus
} catch {
throw KeychainHandlerError.unhandledError(reason: error)
@@ -177,9 +174,6 @@ class KeychainHandler: NSObject {
do {
let addStatus = try _addKeyToKeychainImpl(keyName: keyName, keyData: data, includeAccessGroup: true)
- if addStatus == .success {
- try? _deleteKeyImpl(keyName: keyName, includeAccessGroup: false)
- }
} catch {
KeychainHandler.logger.error("Failed to migrate legacy key \(keyName) into shared access group: \(error.localizedDescription)")
}
diff --git a/packages/mobile/ios/Quiet.xcodeproj/project.pbxproj b/packages/mobile/ios/Quiet.xcodeproj/project.pbxproj
index 682d61bcd2..359b26bb58 100644
--- a/packages/mobile/ios/Quiet.xcodeproj/project.pbxproj
+++ b/packages/mobile/ios/Quiet.xcodeproj/project.pbxproj
@@ -658,9 +658,9 @@
DC03A48F321B4BE2A5339F05 /* Quiet.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Quiet.xcconfig; sourceTree = SOURCE_ROOT; };
DE73D517F18C6E0B941FDFF2 /* Pods-Quiet.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Quiet.debug.xcconfig"; path = "Target Support Files/Pods-Quiet/Pods-Quiet.debug.xcconfig"; sourceTree = ""; };
E079692B46015F1D27CDBBC1 /* Quiet.debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Quiet.debug.xcconfig; sourceTree = SOURCE_ROOT; };
+ E079692B46015F1D27CDBBC2 /* Quiet.release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Quiet.release.xcconfig; sourceTree = SOURCE_ROOT; };
E17A7BE86B5879902AF7BA3A /* QuietNotificationServiceExtension.debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = QuietNotificationServiceExtension.debug.xcconfig; sourceTree = SOURCE_ROOT; };
E4A82686A95EE5D58359B484 /* QuietNotificationServiceExtension.release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = QuietNotificationServiceExtension.release.xcconfig; sourceTree = SOURCE_ROOT; };
- E079692B46015F1D27CDBBC2 /* Quiet.release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Quiet.release.xcconfig; sourceTree = SOURCE_ROOT; };
EB4DC7EB2F5FF6AB00EFD23F /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; };
EB4DC7F92F608A3300EFD23F /* QuietNotificationServiceExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = QuietNotificationServiceExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
EB4DC8152F60912900EFD23F /* AppDelegate+Firebase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = "AppDelegate+Firebase.swift"; path = "Quiet/AppDelegate+Firebase.swift"; sourceTree = ""; };
@@ -670,14 +670,14 @@
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
- EB611A772F60ADD1000CA1A2 /* Exceptions for "QuietNotificationServiceExtension" folder in "Quiet" target */ = {
+ EB611A772F60ADD1000CA1A2 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
KeychainHelper.swift,
);
target = 13B07F861A680F5B00A75B9A /* Quiet */;
};
- EBE628BC2F60AFB00062530D /* Exceptions for "QuietNotificationServiceExtension" folder in "QuietNotificationServiceExtension" target */ = {
+ EBE628BC2F60AFB00062530D /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
@@ -687,19 +687,7 @@
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
- EB4DC7FA2F608A3300EFD23F /* QuietNotificationServiceExtension */ = {
- isa = PBXFileSystemSynchronizedRootGroup;
- exceptions = (
- EB611A772F60ADD1000CA1A2 /* Exceptions for "QuietNotificationServiceExtension" folder in "Quiet" target */,
- EBE628BC2F60AFB00062530D /* Exceptions for "QuietNotificationServiceExtension" folder in "QuietNotificationServiceExtension" target */,
- );
- explicitFileTypes = {
- };
- explicitFolders = (
- );
- path = QuietNotificationServiceExtension;
- sourceTree = "