// Copyright © 2020 Metabolist. All rights reserved. import CryptoKit import Keychain import Kingfisher import Mastodon import Secrets import ServiceLayer import UserNotifications final class NotificationService: UNNotificationServiceExtension { private let environment = AppEnvironment.live( userNotificationCenter: .current(), reduceMotion: { false }) override init() { super.init() } 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) guard let bestAttemptContent = bestAttemptContent else { return } let pushNotification: PushNotification let decryptedJSON: Data let identityId: Identity.Id do { (decryptedJSON, identityId) = try Self.extractAndDecrypt(userInfo: request.content.userInfo) pushNotification = try MastodonDecoder().decode(PushNotification.self, from: decryptedJSON) } catch { contentHandler(bestAttemptContent) return } bestAttemptContent.title = pushNotification.title bestAttemptContent.body = XMLUnescaper(string: pushNotification.body).unescape() let appPreferences = AppPreferences(environment: environment) if appPreferences.notificationSounds.contains(pushNotification.notificationType) { bestAttemptContent.sound = .default } if appPreferences.notificationAccountName, let accountName = try? AllIdentitiesService(environment: environment).identity(id: identityId)?.handle { bestAttemptContent.subtitle = accountName } if appPreferences.notificationPictures { Self.addImage(pushNotification: pushNotification, bestAttemptContent: bestAttemptContent, contentHandler: contentHandler) } else { contentHandler(bestAttemptContent) } } override func serviceExtensionTimeWillExpire() { if let contentHandler = contentHandler, let bestAttemptContent = bestAttemptContent { contentHandler(bestAttemptContent) } } } enum NotificationServiceError: Error { case userInfoDataAbsent case keychainDataAbsent } private extension NotificationService { static let identityIdUserInfoKey = "i" static let encryptedMessageUserInfoKey = "m" static let saltUserInfoKey = "s" static let serverPublicKeyUserInfoKey = "k" static let keyLength = 16 static let nonceLength = 12 static let pseudoRandomKeyLength = 32 static let paddedByteCount = 2 static let curve = "P-256" enum HKDFInfo: String { case auth, aesgcm, nonce var bytes: [UInt8] { Array("Content-Encoding: \(self)\0".utf8) } } static func extractAndDecrypt(userInfo: [AnyHashable: Any]) throws -> (Data, Identity.Id) { guard let identityIdString = userInfo[identityIdUserInfoKey] as? String, let identityId = Identity.Id(uuidString: identityIdString), let encryptedMessageBase64 = (userInfo[encryptedMessageUserInfoKey] as? String)?.URLSafeBase64ToBase64(), let encryptedMessage = Data(base64Encoded: encryptedMessageBase64), let saltBase64 = (userInfo[saltUserInfoKey] as? String)?.URLSafeBase64ToBase64(), let salt = Data(base64Encoded: saltBase64), let serverPublicKeyBase64 = (userInfo[serverPublicKeyUserInfoKey] as? String)?.URLSafeBase64ToBase64(), let serverPublicKeyData = Data(base64Encoded: serverPublicKeyBase64) else { throw NotificationServiceError.userInfoDataAbsent } let secretsService = Secrets(identityId: identityId, keychain: LiveKeychain.self) guard let auth = try secretsService.getPushAuth(), let pushKey = try secretsService.getPushKey() else { throw NotificationServiceError.keychainDataAbsent } return (try decrypt(encryptedMessage: encryptedMessage, privateKeyData: pushKey, serverPublicKeyData: serverPublicKeyData, auth: auth, salt: salt), identityId) } static func decrypt(encryptedMessage: Data, privateKeyData: Data, serverPublicKeyData: Data, auth: Data, salt: Data) throws -> Data { let privateKey = try P256.KeyAgreement.PrivateKey(x963Representation: privateKeyData) let serverPublicKey = try P256.KeyAgreement.PublicKey(x963Representation: serverPublicKeyData) let sharedSecret = try privateKey.sharedSecretFromKeyAgreement(with: serverPublicKey) var keyInfo = HKDFInfo.aesgcm.bytes var nonceInfo = HKDFInfo.nonce.bytes var context = Array(curve.utf8) let publicKeyData = privateKey.publicKey.x963Representation context.append(0) context.append(0) context.append(UInt8(publicKeyData.count)) context += Array(publicKeyData) context.append(0) context.append(UInt8(serverPublicKeyData.count)) context += Array(serverPublicKeyData) keyInfo += context nonceInfo += context let pseudoRandomKey = sharedSecret.hkdfDerivedSymmetricKey( using: SHA256.self, salt: auth, sharedInfo: HKDFInfo.auth.bytes, outputByteCount: pseudoRandomKeyLength) let key = HKDF.deriveKey( inputKeyMaterial: pseudoRandomKey, salt: salt, info: keyInfo, outputByteCount: keyLength) let nonce = HKDF.deriveKey( inputKeyMaterial: pseudoRandomKey, salt: salt, info: nonceInfo, outputByteCount: nonceLength) let sealedBox = try AES.GCM.SealedBox(combined: nonce.withUnsafeBytes(Array.init) + encryptedMessage) let decrypted = try AES.GCM.open(sealedBox, using: key) let unpadded = decrypted.suffix(from: paddedByteCount) return Data(unpadded) } static func addImage(pushNotification: PushNotification, bestAttemptContent: UNMutableNotificationContent, contentHandler: @escaping (UNNotificationContent) -> Void) { let fileName = pushNotification.icon.lastPathComponent let fileURL = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) .appendingPathComponent(fileName) KingfisherManager.shared.retrieveImage(with: pushNotification.icon) { switch $0 { case let .success(result): let format: ImageFormat switch fileURL.pathExtension.lowercased() { case "jpg", "jpeg": format = .JPEG case "gif": format = .GIF case "png": format = .PNG default: format = .unknown } do { try result.image.kf.data(format: format)?.write(to: fileURL) bestAttemptContent.attachments = [try UNNotificationAttachment(identifier: fileName, url: fileURL)] contentHandler(bestAttemptContent) } catch { contentHandler(bestAttemptContent) } case .failure: contentHandler(bestAttemptContent) } } } } extension String { func URLSafeBase64ToBase64() -> String { var base64 = replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") let countMod4 = count % 4 if countMod4 != 0 { base64.append(String(repeating: "=", count: 4 - countMod4)) } return base64 } }