Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

End-to-end encrypted notifications #1679

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
45 changes: 39 additions & 6 deletions Sources/Extensions/NotificationService/NotificationService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,59 @@ import Shared
import UserNotifications

final class NotificationService: UNNotificationServiceExtension {
class Pending {
internal init(content: UNNotificationContent, handler: @escaping (UNNotificationContent) -> Void) {
self.content = content
self.handler = handler
}

var content: UNNotificationContent
var handler: (UNNotificationContent) -> Void
}

private var pending: Pending?

override func didReceive(
_ request: UNNotificationRequest,
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
) {
Current.Log.info("didReceive \(request), user info \(request.content.userInfo)")

Current.api.then(on: nil) { api in
Current.notificationAttachmentManager.content(from: request.content, api: api)
let pending = Pending(content: request.content, handler: contentHandler)
self.pending = pending

firstly {
Current.notificationAttachmentManager.decryptContent(fromUserInfo: request.content.userInfo)
}.recover { error in
Current.Log.error("failed to get content, giving default: \(error)")
Current.Log.error("failed to decrypt content, giving default: \(error)")
return .value(request.content)
}.done {
contentHandler($0)
}.get {
pending.content = $0
}.then { withoutAttachment in
Current.api.then(on: nil) { api in
Current.notificationAttachmentManager.content(from: withoutAttachment, api: api)
}.recover { error in
Current.Log.error("failed to get content, giving default: \(error)")
return .value(withoutAttachment)
}
}.done { [weak self] content in
Current.Log.info("providing body \(content.body)")
contentHandler(content)
self?.pending = nil
}
}

override func serviceExtensionTimeWillExpire() {
// Called just before the extension will be terminated by the system.
// Use this as an opportunity to deliver your "best attempt" at modified content,
// otherwise the original push payload will be used.
Current.Log.warning("serviceExtensionTimeWillExpire")
if let pending = pending {
Current.Log.info("sending content")
pending.handler(pending.content)
} else {
Current.Log.error("missing content at expiration time")
}

pending = nil
}
}
9 changes: 9 additions & 0 deletions Sources/Shared/API/HAAPI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,14 @@ public class HomeAssistantAPI {
"push_url": "https://mobile-apps.home-assistant.io/api/sendPushNotification",
"push_token": pushID,
]
$0.PushConfig = [
[
"platform": Current.device.systemName(),
"push_token": pushID,
"push_url": "http://localhost:5000/home-assistant-mobile-apps/us-central1/encryptedV1",
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

todo

"supports_encryption": true,
],
]
}

$0.AppIdentifier = Constants.BundleID
Expand All @@ -456,6 +464,7 @@ public class HomeAssistantAPI {

let ident = with(MobileAppUpdateRegistrationRequest()) {
$0.AppData = registerRequest.AppData
$0.PushConfig = registerRequest.PushConfig
$0.AppVersion = registerRequest.AppVersion
$0.DeviceName = registerRequest.DeviceName
$0.Manufacturer = registerRequest.Manufacturer
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import ObjectMapper

class MobileAppRegistrationRequest: Mappable {
var AppData: [String: Any]?
var PushConfig: [[String: Any]]?
var AppIdentifier: String?
var AppName: String?
var AppVersion: String?
Expand All @@ -21,6 +22,7 @@ class MobileAppRegistrationRequest: Mappable {

func mapping(map: Map) {
AppData <- map["app_data"]
PushConfig <- map["push_config"]
AppIdentifier <- map["app_id"]
AppName <- map["app_name"]
AppVersion <- map["app_version"]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import ObjectMapper

class MobileAppUpdateRegistrationRequest: Mappable {
var AppData: [String: Any]?
var PushConfig: [[String: Any]]?
var AppVersion: String?
var DeviceName: String?
var Manufacturer: String?
Expand All @@ -20,5 +21,6 @@ class MobileAppUpdateRegistrationRequest: Mappable {
Manufacturer <- map["manufacturer"]
Model <- map["model"]
OSVersion <- map["os_version"]
PushConfig <- map["push_config"]
}
}
26 changes: 24 additions & 2 deletions Sources/Shared/API/Webhook/Networking/Promise+WebhookJson.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ enum WebhookJsonParseError: Error, Equatable {
case base64
case missingKey
case decrypt
case notEncrypted
}

extension Promise where T == Data? {
Expand Down Expand Up @@ -81,10 +82,31 @@ extension Promise where T == Data {
} else {
return try JSONSerialization.jsonObject(with: data, options: options)
}
}.map { object in
}.decrypted(
on: queue,
sodium: sodium,
secretGetter: secretGetter,
options: options
)
}
}

extension Promise {
func decrypted(
on queue: DispatchQueue? = nil,
requireEncryption: Bool = false,
sodium: Sodium = Sodium(),
secretGetter: @escaping () -> String? = { Current.settingsStore.connectionInfo?.webhookSecret },
options: JSONSerialization.ReadingOptions = [.allowFragments]
) -> Promise<Any> {
map(on: queue) { object throws -> Any in
guard let dictionary = object as? [String: Any],
let encoded = dictionary["encrypted_data"] as? String else {
return object
if requireEncryption {
throw WebhookJsonParseError.notEncrypted
} else {
return object
}
}

guard let secret = secretGetter() else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ public enum NotificationAttachmentManagerServiceError: Error {
}

public protocol NotificationAttachmentManager {
func decryptContent(
fromUserInfo payload: [AnyHashable: Any]
) -> Promise<UNNotificationContent>
func content(
from originalContent: UNNotificationContent,
api: HomeAssistantAPI
Expand All @@ -34,6 +37,14 @@ class NotificationAttachmentManagerImpl: NotificationAttachmentManager {
self.parsers = parsers
}

func decryptContent(fromUserInfo payload: [AnyHashable: Any]) -> Promise<UNNotificationContent> {
Promise.value(payload)
.decrypted(on: DispatchQueue.global(qos: .utility), requireEncryption: true)
.compactMap { $0 as? [String: Any] }
.map { try LocalPushEvent(data: .dictionary($0)) }
.map { $0.content }
}

public func content(
from originalContent: UNNotificationContent,
api: HomeAssistantAPI
Expand Down