implemented an E2E-encrypted Nextcloud push-v2 notification system with support for RSA decryption and signature verification; introduced an iOS Notification Service Extension and native AppDelegate handlers for Talk actions (inline reply and mark-as-read); replaced the legacy notification registration with a new lifecycle managing app passwords and secure keypair storage; added background message handling with tray synchronization and a test notification utility in the settings.
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
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[..<range.lowerBound])
|
||||
let message = String(subject[range.upperBound...])
|
||||
if !sender.isEmpty && !message.isEmpty {
|
||||
return (sender, message)
|
||||
}
|
||||
}
|
||||
return ("Talk", subject)
|
||||
}
|
||||
|
||||
private func stringValue(_ value: Any?) -> 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<CFError>?
|
||||
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<CFError>?
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user