245 lines
10 KiB
Swift
245 lines
10 KiB
Swift
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)
|
|
}
|
|
}
|