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,31 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>NotificationServiceExtension</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(FLUTTER_BUILD_NAME)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.usernotifications.service</string>
|
||||
<key>NSExtensionPrincipalClass</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).NotificationService</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.eu.mhsl.marianum.mobile.client.widget</string>
|
||||
</array>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>group.eu.mhsl.marianum.mobile.client.widget</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,105 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
/// Minimal PEM/DER helpers to turn the crypton-produced PEM strings into
|
||||
/// `SecKey`s. iOS's `SecKeyCreateWithData` only accepts *raw PKCS#1* DER for
|
||||
/// RSA, so we must strip the two wrapper formats crypton/Nextcloud use:
|
||||
///
|
||||
/// - Private key: crypton's `toPEM()` emits the `RSA PRIVATE KEY` label but the
|
||||
/// DER body is actually **PKCS#8** `PrivateKeyInfo`
|
||||
/// (SEQUENCE { INTEGER version, SEQUENCE algId, OCTET STRING pkcs1 }).
|
||||
/// We extract the OCTET STRING content = the PKCS#1 `RSAPrivateKey`.
|
||||
///
|
||||
/// - Public key: `PUBLIC KEY` label, **SPKI** `SubjectPublicKeyInfo`
|
||||
/// (SEQUENCE { SEQUENCE algId, BIT STRING spki }). We extract the BIT STRING
|
||||
/// content and drop its leading 0x00 "unused bits" byte = the PKCS#1
|
||||
/// `RSAPublicKey`.
|
||||
///
|
||||
/// Only DER lengths up to 4 bytes are handled — more than enough for RSA-2048.
|
||||
enum PEM {
|
||||
|
||||
/// Strips the PEM armor and returns the base64-decoded DER.
|
||||
static func der(fromPem pem: String) -> Data? {
|
||||
let body = pem
|
||||
.split(whereSeparator: { $0 == "\n" || $0 == "\r" })
|
||||
.filter { !$0.hasPrefix("-----") }
|
||||
.joined()
|
||||
return Data(base64Encoded: body)
|
||||
}
|
||||
|
||||
/// PKCS#8 `PrivateKeyInfo` -> inner PKCS#1 `RSAPrivateKey`.
|
||||
static func pkcs1PrivateKey(fromPkcs8 der: Data) -> Data? {
|
||||
var reader = DERReader(der)
|
||||
guard let outer = reader.readTLV(), outer.tag == 0x30 else { return nil }
|
||||
var inner = DERReader(Data(outer.value))
|
||||
guard let version = inner.readTLV(), version.tag == 0x02 else { return nil } // INTEGER version
|
||||
guard let algId = inner.readTLV(), algId.tag == 0x30 else { return nil } // SEQUENCE algId
|
||||
guard let octet = inner.readTLV(), octet.tag == 0x04 else { return nil } // OCTET STRING
|
||||
return Data(octet.value)
|
||||
}
|
||||
|
||||
/// SPKI `SubjectPublicKeyInfo` -> inner PKCS#1 `RSAPublicKey`.
|
||||
static func pkcs1PublicKey(fromSpki der: Data) -> Data? {
|
||||
var reader = DERReader(der)
|
||||
guard let outer = reader.readTLV(), outer.tag == 0x30 else { return nil }
|
||||
var inner = DERReader(Data(outer.value))
|
||||
guard let algId = inner.readTLV(), algId.tag == 0x30 else { return nil } // SEQUENCE algId
|
||||
guard let bitString = inner.readTLV(), bitString.tag == 0x03 else { return nil } // BIT STRING
|
||||
var bytes = Array(bitString.value)
|
||||
guard let first = bytes.first, first == 0x00 else { return nil } // unused-bits count
|
||||
bytes.removeFirst()
|
||||
return Data(bytes)
|
||||
}
|
||||
|
||||
/// Builds an RSA `SecKey` from raw PKCS#1 DER.
|
||||
static func rsaKey(pkcs1 der: Data, isPrivate: Bool) -> SecKey? {
|
||||
let attributes: [CFString: Any] = [
|
||||
kSecAttrKeyType: kSecAttrKeyTypeRSA,
|
||||
kSecAttrKeyClass: isPrivate ? kSecAttrKeyClassPrivate : kSecAttrKeyClassPublic,
|
||||
kSecAttrKeySizeInBits: 2048,
|
||||
]
|
||||
var error: Unmanaged<CFError>?
|
||||
let key = SecKeyCreateWithData(der as CFData, attributes as CFDictionary, &error)
|
||||
if key == nil {
|
||||
NSLog("[NSE] SecKeyCreateWithData failed: \(String(describing: error?.takeRetainedValue()))")
|
||||
}
|
||||
return key
|
||||
}
|
||||
}
|
||||
|
||||
/// Tiny non-recursive DER TLV reader over a byte buffer.
|
||||
private struct DERReader {
|
||||
private let bytes: [UInt8]
|
||||
private var pos = 0
|
||||
|
||||
init(_ data: Data) { bytes = [UInt8](data) }
|
||||
|
||||
/// Reads one tag-length-value triple, advancing past the value. Returns the
|
||||
/// tag byte and the value bytes, or nil on a malformed/truncated buffer.
|
||||
mutating func readTLV() -> (tag: UInt8, value: ArraySlice<UInt8>)? {
|
||||
guard pos < bytes.count else { return nil }
|
||||
let tag = bytes[pos]
|
||||
pos += 1
|
||||
|
||||
guard pos < bytes.count else { return nil }
|
||||
var length = Int(bytes[pos])
|
||||
pos += 1
|
||||
|
||||
if length & 0x80 != 0 {
|
||||
let numLengthBytes = length & 0x7F
|
||||
guard numLengthBytes > 0, numLengthBytes <= 4, pos + numLengthBytes <= bytes.count else {
|
||||
return nil
|
||||
}
|
||||
length = 0
|
||||
for _ in 0..<numLengthBytes {
|
||||
length = (length << 8) | Int(bytes[pos])
|
||||
pos += 1
|
||||
}
|
||||
}
|
||||
|
||||
guard pos + length <= bytes.count else { return nil }
|
||||
let value = bytes[pos..<pos + length]
|
||||
pos += length
|
||||
return (tag, value)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user