Files
buzz/mobile/ios/Runner/PushEndpointGrantStore.swift
Tom Brow 76f7e5ef0c feat(push): make gateway origin configurable (#7261)
Make the push gateway origin configurable across the relay, gateway,
mobile client, and deployment chart.

This is a fresh deployment. The gateway has only run on personal
development infrastructure, and push has no existing enabled users.
Legacy migration, configured-to-unconfigured mobile transitions, and
rolling upgrades from pre-launch gateway binaries are not supported.

Populated pre-launch databases are rejected with an informative error
before migrations run; their data is untouched. Mobile builds may omit
the gateway URL, leaving push unavailable. Once push is enabled,
subsequent builds must retain that configuration.

### Validation

- All applicable CI checks pass at
`5f546cfaf403f0386f146d213adfb4392f4ec29c`, including Rust,
mobile/Swift, desktop/integration, security, image builds, and Helm
checks. An unrelated agent cancellation test passed on retry.
- PostgreSQL CI passed 384 tests, including the production-entry-point
regression for refusing populated pre-launch databases before
migrations, fresh initialization, and subsequent initialized
deployments.
- Focused local validation passed all 10 gateway PostgreSQL tests under
the CI discovery filter and all 3 unconfigured-mobile tests.
Repository-wide PostgreSQL discovery validation and the complete
pre-push hooks passed.
- Physical-device APNs/App Attest behavior and deployment to a live
cluster were not verified in this work.

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Codex <noreply@openai.com>
2026-09-15 14:56:45 -07:00

164 lines
5.4 KiB
Swift

import BuzzPushKit
import Foundation
import Security
/// Keychain-backed endpoint grant storage. The opaque grant is never written to
/// UserDefaults or logs. Dart can read the closed record through the push bridge.
final class BuzzPushEndpointGrantKeychainStore: BuzzPushEndpointGrantStore {
private static let service = "buzz.push.endpoint-grants"
private static let recordsAccount = "v2"
private static let pendingAccount = "pending-v2"
private let accessGroup: String?
init(accessGroup: String?) {
self.accessGroup = accessGroup
}
func records() throws -> [BuzzPushEndpointGrantRecord] {
var query = baseQuery(account: Self.recordsAccount)
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecItemNotFound { return [] }
guard status == errSecSuccess, let data = result as? Data else {
throw keychainError(status, operation: "read")
}
do {
return try JSONDecoder().decode([BuzzPushEndpointGrantRecord].self, from: data)
} catch {
throw NSError(
domain: "BuzzPushEndpointGrantStore",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Stored endpoint grants are invalid: \(error)"]
)
}
}
func save(_ record: BuzzPushEndpointGrantRecord) throws {
var all = try records()
all.removeAll {
$0.gatewayOrigin == record.gatewayOrigin && $0.relayOrigin == record.relayOrigin
&& $0.appProfile == record.appProfile
}
all.append(record)
try replaceValue(all, account: Self.recordsAccount)
}
func pendingEnrollment(
gatewayOrigin: String,
relayOrigin: String,
appProfile: String
) throws -> BuzzPushPendingEnrollmentRecord? {
try pendingEnrollments().first {
$0.gatewayOrigin == gatewayOrigin && $0.relayOrigin == relayOrigin
&& $0.appProfile == appProfile
}
}
func savePendingEnrollment(_ record: BuzzPushPendingEnrollmentRecord) throws {
var all = try pendingEnrollments()
all.removeAll {
$0.gatewayOrigin == record.gatewayOrigin && $0.relayOrigin == record.relayOrigin
&& $0.appProfile == record.appProfile
}
all.append(record)
try replaceValue(all, account: Self.pendingAccount)
}
func removePendingEnrollment(
gatewayOrigin: String,
relayOrigin: String,
appProfile: String
) throws {
var all = try pendingEnrollments()
all.removeAll {
$0.gatewayOrigin == gatewayOrigin && $0.relayOrigin == relayOrigin
&& $0.appProfile == appProfile
}
try replaceValue(all, account: Self.pendingAccount)
}
private func pendingEnrollments() throws -> [BuzzPushPendingEnrollmentRecord] {
var query = baseQuery(account: Self.pendingAccount)
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecItemNotFound { return [] }
guard status == errSecSuccess, let data = result as? Data else {
throw keychainError(status, operation: "read pending enrollment")
}
do {
return try JSONDecoder().decode([BuzzPushPendingEnrollmentRecord].self, from: data)
} catch {
throw NSError(
domain: "BuzzPushEndpointGrantStore",
code: 2,
userInfo: [NSLocalizedDescriptionKey: "Stored pending enrollments are invalid: \(error)"]
)
}
}
private func replaceValue<T: Encodable>(_ value: T, account: String) throws {
let data = try JSONEncoder().encode(value)
let updateStatus = SecItemUpdate(
baseQuery(account: account) as CFDictionary,
[kSecValueData as String: data] as CFDictionary
)
if updateStatus == errSecSuccess { return }
guard updateStatus == errSecItemNotFound else {
throw keychainError(updateStatus, operation: "update")
}
var add = baseQuery(account: account)
add[kSecValueData as String] = data
add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
let addStatus = SecItemAdd(add as CFDictionary, nil)
guard addStatus == errSecSuccess else {
throw keychainError(addStatus, operation: "add")
}
}
private func baseQuery(account: String) -> [String: Any] {
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: Self.service,
kSecAttrAccount as String: account,
]
if let accessGroup, !accessGroup.isEmpty {
query[kSecAttrAccessGroup as String] = accessGroup
}
return query
}
private func keychainError(_ status: OSStatus, operation: String) -> Error {
NSError(
domain: NSOSStatusErrorDomain,
code: Int(status),
userInfo: [
NSLocalizedDescriptionKey:
"Endpoint grant Keychain \(operation) failed: \(SecCopyErrorMessageString(status, nil) ?? "unknown" as CFString)"
]
)
}
}
extension BuzzPushEndpointGrantRecord {
var flutterArguments: [String: Any] {
let arguments: [String: Any] = [
"relayOrigin": relayOrigin,
"relayPubkey": relayPubkey,
"installationId": installationId,
"endpointGrant": endpointGrant,
"endpointHash": endpointHash,
"appProfile": appProfile,
"endpointEpoch": endpointEpoch,
"generation": generation,
"expiresAt": expiresAt,
]
return arguments
}
}