mirror of
https://github.com/block/buzz.git
synced 2026-10-28 21:11:48 +00:00
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>
164 lines
5.4 KiB
Swift
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
|
|
}
|
|
}
|