diff --git a/app.config.js b/app.config.js index 4911cf31..2e8dbadf 100644 --- a/app.config.js +++ b/app.config.js @@ -22,6 +22,13 @@ COMMIT = COMMIT.slice(0, 7); // fall back to 1 (never uploaded). const IOS_BUILD_NUMBER = process.env.IOS_BUILD_NUMBER || '1'; +// Apple team: prefixes the keychain groups the app shares with its +// notification service extension (plugins/ios-push). Not a secret. +const APPLE_TEAM_ID = process.env.APPLE_TEAM_ID || '8ZANF9LQ39'; +// Which APNs host the device tokens belong to: TestFlight and App Store builds +// use production, simulators and development builds the sandbox. +const APS_ENVIRONMENT = process.env.APS_ENVIRONMENT === 'development' ? 'development' : 'production'; + module.exports = { expo: { name: 'Bulwark Mobile', @@ -96,12 +103,17 @@ module.exports = { microphonePermission: false, }, ], + ['./plugins/ios-push', { apsEnvironment: APS_ENVIRONMENT, teamId: APPLE_TEAM_ID }], ], extra: { commit: COMMIT, // Settings → About → Source code. A modified build sets SOURCE_CODE_URL // to where its own source is (AGPL §13); the default is upstream. sourceCodeUrl: process.env.SOURCE_CODE_URL || 'https://github.com/bulwarkmail/native', + // src/lib/ios-push-shared.ts + iosSharedKeychainGroup: `${APPLE_TEAM_ID}.org.bulwarkmail.shared`, + iosAppKeychainGroup: `${APPLE_TEAM_ID}.org.bulwarkmail.mobile`, + apnsEnvironment: APS_ENVIRONMENT === 'development' ? 'sandbox' : 'production', }, }, }; diff --git a/plugins/ios-push/Jmap.swift b/plugins/ios-push/Jmap.swift new file mode 100644 index 00000000..6f1ed71e --- /dev/null +++ b/plugins/ios-push/Jmap.swift @@ -0,0 +1,161 @@ +import Foundation + +/// The little JMAP the extension needs: who sent the new messages and what +/// they are about, plus the inbox's unread count for the badge. +struct PushedEmail { + let id: String + let threadId: String + let sender: String + let subject: String + let preview: String +} + +struct JmapFetchResult { + let emails: [PushedEmail] + let inboxUnread: Int? +} + +enum JmapError: Error { + case noCredentials + case http(Int) + case badResponse +} + +/// Credentials as the app stores them (src/api/jmap-client.ts StoredCredentials). +struct StoredCredentials { + var json: [String: Any] + + var username: String { json["username"] as? String ?? "" } + var password: String { json["password"] as? String ?? "" } + var accessToken: String? { json["accessToken"] as? String } + var refreshToken: String? { json["refreshToken"] as? String } + var tokenEndpoint: String? { json["tokenEndpoint"] as? String } + var clientId: String? { json["clientId"] as? String } + /// Milliseconds since 1970, like Date.now() in the app. + var expiresAt: Double? { (json["expiresAt"] as? NSNumber)?.doubleValue } +} + +enum Jmap { + static let session: URLSession = { + let config = URLSessionConfiguration.ephemeral + config.timeoutIntervalForRequest = 12 + config.timeoutIntervalForResource = 20 + return URLSession(configuration: config) + }() + + /// The Authorization header for these credentials, refreshing an OAuth + /// access token that is about to expire. A refreshed bundle is written back + /// to the shared keychain item so the app picks it up (and doesn't refresh + /// again with a refresh token the server has already replaced). + static func authorization(credentialsKey: String) async throws -> String { + guard let json = SharedKeychain.readJSON(credentialsKey) else { throw JmapError.noCredentials } + var creds = StoredCredentials(json: json) + if let token = creds.accessToken { + let nowMs = Date().timeIntervalSince1970 * 1000 + if let expiresAt = creds.expiresAt, expiresAt - nowMs < 60_000, + let refresh = creds.refreshToken, let endpoint = creds.tokenEndpoint, let clientId = creds.clientId, + let url = URL(string: endpoint) { + if let fresh = try? await refreshToken(url: url, refreshToken: refresh, clientId: clientId) { + creds.json["accessToken"] = fresh.accessToken + if let newRefresh = fresh.refreshToken { creds.json["refreshToken"] = newRefresh } + if let expiresIn = fresh.expiresIn { creds.json["expiresAt"] = nowMs + expiresIn * 1000 } + SharedKeychain.update(credentialsKey, json: creds.json) + return "Bearer \(fresh.accessToken)" + } + } + return "Bearer \(token)" + } + let basic = Data("\(creds.username):\(creds.password)".utf8).base64EncodedString() + return "Basic \(basic)" + } + + struct FreshTokens { + let accessToken: String + let refreshToken: String? + let expiresIn: Double? + } + + static func refreshToken(url: URL, refreshToken: String, clientId: String) async throws -> FreshTokens { + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + var form = URLComponents() + form.queryItems = [ + URLQueryItem(name: "grant_type", value: "refresh_token"), + URLQueryItem(name: "refresh_token", value: refreshToken), + URLQueryItem(name: "client_id", value: clientId), + ] + request.httpBody = form.percentEncodedQuery?.data(using: .utf8) + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse else { throw JmapError.badResponse } + guard http.statusCode == 200 else { throw JmapError.http(http.statusCode) } + guard let body = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any], + let access = body["access_token"] as? String else { throw JmapError.badResponse } + return FreshTokens( + accessToken: access, + refreshToken: body["refresh_token"] as? String, + expiresIn: (body["expires_in"] as? NSNumber)?.doubleValue + ) + } + + static func fetch(apiUrl: URL, accountId: String, emailIds: [String], authorization: String) async throws -> JmapFetchResult { + var request = URLRequest(url: apiUrl) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue(authorization, forHTTPHeaderField: "Authorization") + let body: [String: Any] = [ + "using": ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"], + "methodCalls": [ + ["Email/get", [ + "accountId": accountId, + "ids": Array(emailIds.prefix(5)), + "properties": ["threadId", "from", "subject", "preview"], + ], "0"], + ["Mailbox/query", ["accountId": accountId, "filter": ["role": "inbox"]], "1"], + ["Mailbox/get", [ + "accountId": accountId, + "#ids": ["resultOf": "1", "name": "Mailbox/query", "path": "/ids"], + "properties": ["unreadEmails"], + ], "2"], + ], + ] + request.httpBody = try JSONSerialization.data(withJSONObject: body) + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse else { throw JmapError.badResponse } + guard http.statusCode == 200 else { throw JmapError.http(http.statusCode) } + guard let root = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any], + let responses = root["methodResponses"] as? [[Any]] else { throw JmapError.badResponse } + + var emails: [PushedEmail] = [] + var inboxUnread: Int? + for response in responses where response.count >= 2 { + guard let name = response[0] as? String, let args = response[1] as? [String: Any] else { continue } + if name == "Email/get", let list = args["list"] as? [[String: Any]] { + // Keep the order the push gave (newest first from the server's filter). + // Stalwart can list an id twice; keep the first. + let byId = Dictionary(list.compactMap { item -> (String, [String: Any])? in + guard let id = item["id"] as? String else { return nil } + return (id, item) + }, uniquingKeysWith: { first, _ in first }) + for id in emailIds { + guard let item = byId[id] else { continue } + let from = (item["from"] as? [[String: Any]])?.first + let name = (from?["name"] as? String).flatMap { $0.isEmpty ? nil : $0 } + let sender = name ?? (from?["email"] as? String) ?? "" + emails.append(PushedEmail( + id: id, + threadId: item["threadId"] as? String ?? "", + sender: sender, + subject: item["subject"] as? String ?? "", + preview: item["preview"] as? String ?? "" + )) + } + } else if name == "Mailbox/get", let list = args["list"] as? [[String: Any]] { + inboxUnread = (list.first?["unreadEmails"] as? NSNumber)?.intValue + } + } + return JmapFetchResult(emails: emails, inboxUnread: inboxUnread) + } +} diff --git a/plugins/ios-push/NotificationService.swift b/plugins/ios-push/NotificationService.swift new file mode 100644 index 00000000..1b93d075 --- /dev/null +++ b/plugins/ios-push/NotificationService.swift @@ -0,0 +1,100 @@ +import Foundation +import UserNotifications + +/// Turns the relay's content-blind push ("New message" plus ids) into a real +/// notification: sender, subject and preview, fetched over JMAP on this +/// device with the account's own credentials. Nothing about the message ever +/// passes through the relay or Apple. +/// +/// What the app shares through the keychain group (src/lib/ios-push-shared.ts): +/// bulwark_push_account__ {credentialsKey, apiUrl} +/// bulwark_push_settings {badge, activeAccountId, strings{...}} +/// the account's stored credentials +/// If anything is missing or the server can't be reached, the relay's +/// placeholder text is shown, so the user still learns there is new mail. +final class NotificationService: UNNotificationServiceExtension { + private var contentHandler: ((UNNotificationContent) -> Void)? + private var placeholder: UNMutableNotificationContent? + private var work: Task? + + override func didReceive( + _ request: UNNotificationRequest, + withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void + ) { + self.contentHandler = contentHandler + guard let content = request.content.mutableCopy() as? UNMutableNotificationContent else { + contentHandler(request.content) + return + } + placeholder = content + let push = request.content.userInfo["body"] as? [String: Any] + work = Task { [weak self] in + let enriched = await NotificationService.enrich(content, push: push) + self?.finish(enriched) + } + } + + override func serviceExtensionTimeWillExpire() { + work?.cancel() + finish(placeholder) + } + + private func finish(_ content: UNNotificationContent?) { + guard let handler = contentHandler, let content else { return } + contentHandler = nil + handler(content) + } + + static func sanitize(_ id: String) -> String { + String(id.map { ch in (ch.isASCII && (ch.isLetter || ch.isNumber)) || ch == "." || ch == "-" || ch == "_" ? ch : "_" }) + } + + static func enrich(_ content: UNMutableNotificationContent, push: [String: Any]?) async -> UNMutableNotificationContent { + guard let push, + let accountId = push["accountId"] as? String, !accountId.isEmpty, + let account = SharedKeychain.readJSON("bulwark_push_account__\(sanitize(accountId))"), + let credentialsKey = account["credentialsKey"] as? String, + let apiString = account["apiUrl"] as? String, + let apiUrl = URL(string: apiString) else { + return content + } + let emailIds = push["emailIds"] as? [String] ?? [] + let settings = SharedKeychain.readJSON("bulwark_push_settings") ?? [:] + let strings = settings["strings"] as? [String: String] ?? [:] + + content.threadIdentifier = accountId + // The app reads userInfo.body when the notification is tapped. + var body = push + + do { + let authorization = try await Jmap.authorization(credentialsKey: credentialsKey) + let result = try await Jmap.fetch(apiUrl: apiUrl, accountId: accountId, emailIds: emailIds, authorization: authorization) + + if let first = result.emails.first { + content.title = first.sender + content.subtitle = first.subject.isEmpty ? (strings["noSubject"] ?? "(no subject)") : first.subject + content.body = first.preview + if result.emails.count > 1 { + let more = (strings["more"] ?? "and {count} more").replacingOccurrences(of: "{count}", with: String(result.emails.count - 1)) + content.body = first.preview.isEmpty ? more : "\(first.preview)\n\(more)" + } + body["emailId"] = first.id + body["threadId"] = first.threadId + } + + // Badge: like the app, the active account's inbox unread count. A push + // for another account leaves the badge as it is. + if settings["badge"] as? Bool == true, + settings["activeAccountId"] as? String == accountId, + let unread = result.inboxUnread { + content.badge = NSNumber(value: unread) + } + } catch { + // Keep the placeholder. + } + var userInfo = content.userInfo + userInfo["body"] = body + content.userInfo = userInfo + return content + } +} diff --git a/plugins/ios-push/SharedKeychain.swift b/plugins/ios-push/SharedKeychain.swift new file mode 100644 index 00000000..6eeaeb31 --- /dev/null +++ b/plugins/ios-push/SharedKeychain.swift @@ -0,0 +1,50 @@ +import Foundation +import Security + +/// Items the app shares with this extension through a keychain access group +/// (BulwarkKeychainGroup in Info.plist). The app writes them with +/// expo-secure-store: generic passwords whose account and generic attributes +/// are the UTF-8 key and whose service is "app:no-auth". +enum SharedKeychain { + static let service = "app:no-auth" + + static var group: String? { + Bundle.main.object(forInfoDictionaryKey: "BulwarkKeychainGroup") as? String + } + + static func read(_ key: String) -> Data? { + guard let group else { return nil } + let account = Data(key.utf8) + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: account, + kSecAttrAccessGroup as String: group, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecReturnData as String: true, + ] + var item: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess else { return nil } + return item as? Data + } + + static func readJSON(_ key: String) -> [String: Any]? { + guard let data = read(key) else { return nil } + return (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] + } + + /// Replaces the value of an existing item (refreshed OAuth tokens). Never + /// creates one: the app owns which items exist. + @discardableResult + static func update(_ key: String, json: [String: Any]) -> Bool { + guard let group, let data = try? JSONSerialization.data(withJSONObject: json) else { return false } + let account = Data(key.utf8) + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecAttrAccessGroup as String: group, + ] + let attributes: [String: Any] = [kSecValueData as String: data] + return SecItemUpdate(query as CFDictionary, attributes as CFDictionary) == errSecSuccess + } +} diff --git a/plugins/ios-push/index.js b/plugins/ios-push/index.js new file mode 100644 index 00000000..2ce065de --- /dev/null +++ b/plugins/ios-push/index.js @@ -0,0 +1,195 @@ +// Config plugin: push notifications on iOS. +// +// - The app's entitlements: aps-environment, and a keychain group shared with +// the notification service extension (the app's own group stays first, so +// items saved before keep their group). +// - A Notification Service Extension target ("NotificationService", +// org.bulwarkmail.mobile.NotificationService). The relay's push carries +// only ids; the extension fetches sender and subject over JMAP on the device +// before iOS shows the notification. Its sources live next to this file and +// are copied into ios/ at prebuild, like everything else in ios/. +// +// Options (app.config.js): +// apsEnvironment: 'production' | 'development' (APS_ENVIRONMENT env wins) +// teamId: Apple team id, for the shared keychain group +// extensionProfile / appProfile: provisioning profile specifiers for the +// Release configuration of the extension and the app (IOS_NSE_PROFILE and +// IOS_APP_PROFILE env win). Set per target because xcodebuild's command +// line applies a specifier to every target. Empty = leave unset. + +const fs = require('node:fs'); +const path = require('node:path'); +const { + withEntitlementsPlist, + withXcodeProject, + withDangerousMod, + createRunOncePlugin, +} = require('@expo/config-plugins'); + +const TARGET = 'NotificationService'; +const DEPLOYMENT_TARGET = '15.1'; +const SOURCE_FILES = ['NotificationService.swift', 'SharedKeychain.swift', 'Jmap.swift']; + +function sharedGroupSuffix() { + return 'org.bulwarkmail.shared'; +} + +function withAppEntitlements(config, { apsEnvironment }) { + return withEntitlementsPlist(config, (cfg) => { + const bundleId = cfg.ios?.bundleIdentifier; + cfg.modResults['aps-environment'] = apsEnvironment; + const groups = new Set(cfg.modResults['keychain-access-groups'] ?? []); + groups.add(`$(AppIdentifierPrefix)${bundleId}`); + groups.add(`$(AppIdentifierPrefix)${sharedGroupSuffix()}`); + cfg.modResults['keychain-access-groups'] = [...groups]; + return cfg; + }); +} + +function infoPlist({ version, buildNumber }) { + return ` + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Bulwark Mail notifications + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + ${version} + CFBundleVersion + ${buildNumber} + BulwarkKeychainGroup + $(DEVELOPMENT_TEAM).${sharedGroupSuffix()} + NSExtension + + NSExtensionPointIdentifier + com.apple.usernotifications.service + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).NotificationService + + + +`; +} + +function entitlementsPlist() { + return ` + + + + keychain-access-groups + + $(AppIdentifierPrefix)${sharedGroupSuffix()} + + + +`; +} + +function withExtensionFiles(config) { + return withDangerousMod(config, [ + 'ios', + async (cfg) => { + const dir = path.join(cfg.modRequest.platformProjectRoot, TARGET); + fs.mkdirSync(dir, { recursive: true }); + for (const file of SOURCE_FILES) { + fs.copyFileSync(path.join(__dirname, file), path.join(dir, file)); + } + fs.writeFileSync( + path.join(dir, `${TARGET}-Info.plist`), + infoPlist({ version: cfg.version, buildNumber: cfg.ios?.buildNumber ?? '1' }), + ); + fs.writeFileSync(path.join(dir, `${TARGET}.entitlements`), entitlementsPlist()); + return cfg; + }, + ]); +} + +function setReleaseProfiles(cfg, { appProfile, extensionProfile }) { + if (!appProfile && !extensionProfile) return; + const project = cfg.modResults; + const appName = `"${cfg.modRequest.projectName}"`; + const configs = project.pbxXCBuildConfigurationSection(); + for (const key of Object.keys(configs)) { + const entry = configs[key]; + if (!entry.buildSettings || entry.name !== 'Release') continue; + const name = entry.buildSettings.PRODUCT_NAME; + const profile = name === `"${TARGET}"` ? extensionProfile + : (name === appName || name === '"$(TARGET_NAME)"') && entry.buildSettings.PRODUCT_BUNDLE_IDENTIFIER ? appProfile + : ''; + if (profile) entry.buildSettings.PROVISIONING_PROFILE_SPECIFIER = `"${profile}"`; + } +} + +// One mod for creating the target and naming the profiles: separate +// withXcodeProject mods don't run in the order they are registered. +function withExtensionTarget(config, { teamId, appProfile, extensionProfile }) { + return withXcodeProject(config, (cfg) => { + addExtensionTarget(cfg, { teamId }); + setReleaseProfiles(cfg, { appProfile, extensionProfile }); + return cfg; + }); +} + +function addExtensionTarget(cfg, { teamId }) { + const project = cfg.modResults; + if (project.pbxTargetByName(TARGET)) return; + + // addTarget expects these sections; a fresh Expo project may not have them. + const objects = project.hash.project.objects; + objects.PBXTargetDependency = objects.PBXTargetDependency || {}; + objects.PBXContainerItemProxy = objects.PBXContainerItemProxy || {}; + + const group = project.addPbxGroup( + [...SOURCE_FILES, `${TARGET}-Info.plist`, `${TARGET}.entitlements`], + TARGET, + TARGET, + ); + // Hang the group off the project's main group. + const mainGroupId = project.getFirstProject().firstProject.mainGroup; + project.addToPbxGroup(group.uuid, mainGroupId); + + const bundleId = `${cfg.ios.bundleIdentifier}.${TARGET}`; + const target = project.addTarget(TARGET, 'app_extension', TARGET, bundleId); + project.addBuildPhase(SOURCE_FILES, 'PBXSourcesBuildPhase', 'Sources', target.uuid); + project.addBuildPhase([], 'PBXResourcesBuildPhase', 'Resources', target.uuid); + project.addBuildPhase([], 'PBXFrameworksBuildPhase', 'Frameworks', target.uuid); + + const configs = project.pbxXCBuildConfigurationSection(); + for (const key of Object.keys(configs)) { + const entry = configs[key]; + if (!entry.buildSettings || entry.buildSettings.PRODUCT_NAME !== `"${TARGET}"`) continue; + const s = entry.buildSettings; + s.CODE_SIGN_ENTITLEMENTS = `${TARGET}/${TARGET}.entitlements`; + s.IPHONEOS_DEPLOYMENT_TARGET = DEPLOYMENT_TARGET; + s.TARGETED_DEVICE_FAMILY = '"1,2"'; + s.SWIFT_VERSION = '5.0'; + s.CLANG_ENABLE_MODULES = 'YES'; + s.GENERATE_INFOPLIST_FILE = 'NO'; + if (teamId) s.DEVELOPMENT_TEAM = teamId; + } +} + +function withIosPush(config, options = {}) { + const apsEnvironment = process.env.APS_ENVIRONMENT || options.apsEnvironment || 'production'; + const teamId = process.env.APPLE_TEAM_ID || options.teamId || ''; + const extensionProfile = process.env.IOS_NSE_PROFILE || options.extensionProfile || ''; + const appProfile = process.env.IOS_APP_PROFILE || options.appProfile || ''; + config = withAppEntitlements(config, { apsEnvironment }); + config = withExtensionFiles(config); + config = withExtensionTarget(config, { teamId, appProfile, extensionProfile }); + return config; +} + +module.exports = createRunOncePlugin(withIosPush, 'bulwark-ios-push', '1.0.0');