import UserNotifications import Security /// Notification Service Extension for Nextcloud push-v2. /// /// Architecture: Nextcloud pushes an E2E-encrypted notification to the /// MarianumConnect proxy, which forwards it via FCM to this device as an /// `alert` push with `mutable-content: 1`. That flag makes iOS spin up this /// extension *before* showing the notification, giving us ~30 s (wall clock, /// shared with the CPU budget) to decrypt the payload and replace the /// placeholder alert with the real content. /// /// The FCM proxy places the two relevant fields at the top level of the APNs /// payload (mirrored into `request.content.userInfo`): /// - `subject` base64( RSA-encrypted subject JSON ) — encrypted with THIS /// device's public key, so we decrypt with our private key. /// - `signature` base64( SHA-512-with-RSA over the *encrypted* subject bytes ) /// — signed by the per-user server key we stored at registration. /// /// Key material lives in the shared (App Group) keychain, written by the Dart /// side via flutter_secure_storage with /// `IOSOptions(groupId: kPushKeychainGroup, accessibility: first_unlock)`: /// - `push_device_private_key_pem` crypton PKCS#8-in-"RSA PRIVATE KEY" PEM /// - `push_server_public_key_pem` SPKI "PUBLIC KEY" PEM (from NC) /// /// Robustness contract: on ANY failure (missing keys, bad signature, decrypt /// failure, malformed JSON, timeout) we deliver the untouched placeholder /// content. We never crash and never deliver empty content. class NotificationService: UNNotificationServiceExtension { /// Must exactly match `kPushKeychainGroup` in lib/push/push_secure_storage.dart /// and the `keychain-access-groups` entitlement of BOTH the Runner and this /// extension. Wrong value here => keychain reads return nil => placeholder. private static let keychainAccessGroup = "group.eu.mhsl.marianum.mobile.client.widget" private static let devicePrivateKeyAccount = "push_device_private_key_pem" private static let serverPublicKeyAccount = "push_server_public_key_pem" private static let talkCategoryId = "TALK_MESSAGE" private var contentHandler: ((UNNotificationContent) -> Void)? private var bestAttemptContent: UNMutableNotificationContent? override func didReceive( _ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void ) { self.contentHandler = contentHandler self.bestAttemptContent = request.content.mutableCopy() as? UNMutableNotificationContent guard let content = bestAttemptContent else { contentHandler(request.content) return } // Any early return delivers the placeholder unchanged — that is the // intended fallback, never an error path that hides the notification. let userInfo = content.userInfo guard let subjectB64 = userInfo["subject"] as? String, let signatureB64 = userInfo["signature"] as? String, let encrypted = Data(base64Encoded: subjectB64), let signature = Data(base64Encoded: signatureB64) else { deliver(content) return } guard let privateKey = loadPrivateKey() else { NSLog("[NSE] no device private key in keychain, delivering placeholder") deliver(content) return } // Signature verification is defence-in-depth (the proxy already verified // it). Skip only if we somehow have no server key; never deliver on a // *failed* verification. if let serverKey = loadServerPublicKey() { if !verifySignature(signature, over: encrypted, with: serverKey) { NSLog("[NSE] signature verification failed, delivering placeholder") deliver(content) return } } guard let plaintext = decrypt(encrypted, with: privateKey), let subject = try? JSONSerialization.jsonObject(with: plaintext) as? [String: Any] else { NSLog("[NSE] could not decrypt/parse subject, delivering placeholder") deliver(content) return } apply(subject: subject, to: content) deliver(content) } override func serviceExtensionTimeWillExpire() { // Ran out of time — hand back whatever we have (at worst the placeholder). if let handler = contentHandler, let content = bestAttemptContent { handler(content) } } private func deliver(_ content: UNNotificationContent) { contentHandler?(content) } // MARK: - Content shaping private func apply(subject: [String: Any], to content: UNMutableNotificationContent) { // Delete pushes normally arrive as silent `background` pushes that never // invoke this extension. If one reaches us anyway, an NSE cannot suppress // the banner (iOS always shows *something*), so we leave the placeholder. // Actual delete cleanup is done by the Flutter app on next open. See // ios/PUSH_NSE_SETUP.md "Delete-Handling". let isDelete = (subject["delete"] as? Bool == true) || (subject["delete-multiple"] as? Bool == true) || (subject["delete-all"] as? Bool == true) if isDelete { NSLog("[NSE] delete push reached NSE — cannot suppress, leaving placeholder") return } let app = subject["app"] as? String let text = subject["subject"] as? String ?? "Neue Benachrichtigung" let objectId = stringValue(subject["id"]) let nid = subject["nid"] if app == "spreed" { // Talk: "Sender: message" -> title = Sender, body = message. let (sender, message) = splitSender(text) content.title = sender content.body = message content.categoryIdentifier = NotificationService.talkCategoryId if let token = objectId, !token.isEmpty { content.threadIdentifier = "talk_\(token)" var info = content.userInfo info["chatToken"] = token if let nid = nid { info["nid"] = nid } content.userInfo = info } } else { content.title = text content.body = "" } // Badge intentionally left untouched (the app recomputes it from the // delivered notifications). } /// Splits `"Sender: message"` into its parts; falls back to a generic sender. private func splitSender(_ subject: String) -> (String, String) { if let range = subject.range(of: ": ") { let sender = String(subject[.. String? { if let s = value as? String { return s } if let n = value as? NSNumber { return n.stringValue } return nil } // MARK: - Crypto private func verifySignature(_ signature: Data, over message: Data, with key: SecKey) -> Bool { var error: Unmanaged? let ok = SecKeyVerifySignature( key, .rsaSignatureMessagePKCS1v15SHA512, message as CFData, signature as CFData, &error ) return ok } private func decrypt(_ data: Data, with key: SecKey) -> Data? { // NC 32 defaults to OAEP(SHA-1); older instances use PKCS#1 v1.5. Try // OAEP first, fall back to PKCS#1 — mirrors the Dart PushDecryptor. for algorithm in [SecKeyAlgorithm.rsaEncryptionOAEPSHA1, SecKeyAlgorithm.rsaEncryptionPKCS1] { var error: Unmanaged? if let plain = SecKeyCreateDecryptedData(key, algorithm, data as CFData, &error) as Data? { return plain } } return nil } // MARK: - Key loading private func loadPrivateKey() -> SecKey? { guard let pem = keychainString(NotificationService.devicePrivateKeyAccount), let der = PEM.der(fromPem: pem), // crypton labels it "RSA PRIVATE KEY" but the body is PKCS#8. let pkcs1 = PEM.pkcs1PrivateKey(fromPkcs8: der) else { return nil } return PEM.rsaKey(pkcs1: pkcs1, isPrivate: true) } private func loadServerPublicKey() -> SecKey? { guard let pem = keychainString(NotificationService.serverPublicKeyAccount), let der = PEM.der(fromPem: pem), let pkcs1 = PEM.pkcs1PublicKey(fromSpki: der) else { return nil } return PEM.rsaKey(pkcs1: pkcs1, isPrivate: false) } // MARK: - Keychain /// Reads a flutter_secure_storage entry from the shared keychain. Must match /// exactly how flutter_secure_storage_darwin 0.3.2 stores items: /// kSecClass = kSecClassGenericPassword /// kSecAttrAccount = the Dart key, verbatim /// kSecAttrService = (unset — the Dart IOSOptions set no accountName) /// kSecAttrAccessGroup = the App Group id /// value = raw UTF-8 bytes of the string private func keychainString(_ account: String) -> String? { let query: [CFString: Any] = [ kSecClass: kSecClassGenericPassword, kSecAttrAccount: account, kSecAttrAccessGroup: NotificationService.keychainAccessGroup, kSecReturnData: true, kSecMatchLimit: kSecMatchLimitOne, ] var result: AnyObject? let status = SecItemCopyMatching(query as CFDictionary, &result) guard status == errSecSuccess, let data = result as? Data else { if status != errSecItemNotFound { NSLog("[NSE] keychain read '\(account)' failed: \(status)") } return nil } return String(data: data, encoding: .utf8) } }