mirror of
https://github.com/block/buzz.git
synced 2026-10-28 21:11:48 +00:00
Buzz push currently selects an application profile, such as `dogfood`, during client registration. We want each gateway to serve one Apple app configured by the operator instead. This removes profile selection from the client, relay, and gateway, while allowing internal and public gateways to use their own Apple identities. This PR implements that model in the Buzz source and upstream deployment chart. Discovery, enrollment, grants, and leases no longer carry an application-profile selector. Each gateway has one configured Apple application and APNs transport. The protocol changes are incompatible with the old registration format, so relays advertise `buzz-push-v1` instead of `nip-pl`. Old clients do not discover the new push capability. Updated clients use separate Keychain records and register again. The [push notes](https://github.com/block/buzz/blob/push-capability-v1/docs/buzz-push.md) describe the experimental contract. Landing this PR is the first step. Publish the matching gateway image and upstream chart, then land the [shared chart update](https://github.com/squareup/cash-helm-charts/pull/5740) and [gateway deployment update](https://github.com/squareup/builderbot-platform-core-infrastructure/pull/1085). Before cutover, stop old gateway instances and check legacy registration state. Migrations refuse to proceed if old gateway installations or active relay leases remain; they do not delete that state. Physical-device verification remains necessary before production activation. ### Validation Gateway and focused Flutter push tests pass, as do the Swift package, simulator build, and native notification/enrichment tests. A local relay advertises `buzz-push-v1` without `nip-pl` or `app_profiles`. The PostgreSQL suite passed 868 tests; its two migration-test failures were corrected and pass a focused rerun together with the gateway cutover guard. Chart rendering and release-contract checks pass. Desktop JavaScript tests, desktop and web builds, native desktop compilation, mobile analysis/tests, and desktop native tests pass. The full Rust unit lane and prescribed `just test` command pass, including workspace integration tests against task-isolated local services. The production overlay now requires an explicitly supplied APNs topic, with omission and rendered-value regression coverage. The corrected Swift checksum assertion passes the complete package suite: 78 XCTest tests and 42 Swift Testing tests. No physical-device enrollment or production cutover is claimed. Local merge validation: the embedded migration test and formatting pass after reserving profile migration 0060 behind rollback migration 0059. The local pre-push Rust lane failed ACP timing/steering tests (`idle_resets_on_stdout_activity`, `keepalive_resets_idle_past_deadline`, and the failed/missing-outcome steering tests). ACP and its dependency manifests are unchanged by this branch; all four pass in an isolated single-thread rerun. The documented local-check exception skips only the Rust pre-push lane for publication; required CI remains mandatory. Mobile and desktop pre-push lanes passed. Combined migration validation: after rollback 0059 landed, the main merge preserves profile migration 0060 and both embedded migration assertions. The focused migration test and formatting pass. The next local push again failed only the unchanged ACP idle/keepalive timing tests (1053 passed, 2 failed, 3 ignored); other lanes passed. Publication uses the same documented local Rust-lane exception; required CI remains mandatory. Follow-up review fix: removed the legacy profile column from the copied-lease fixture. All four event-follow-up contract tests pass on isolated PostgreSQL databases, covering desired and migrated schemas in dual and app-only modes. Formatting passes. Publication retains the prior passing local-lane evidence and uses the authorized pre-push exception for the known unrelated ACP timing failures; required CI remains mandatory. --------- Signed-off-by: Tom Brow <tomb@block.xyz>
1065 lines
37 KiB
Swift
1065 lines
37 KiB
Swift
import CryptoKit
|
|
import DeviceCheck
|
|
import Foundation
|
|
|
|
#if canImport(Security)
|
|
import Security
|
|
#endif
|
|
|
|
#if canImport(FoundationNetworking)
|
|
import FoundationNetworking
|
|
#endif
|
|
|
|
/// The opaque gateway capability and binding metadata needed by a later lease publisher.
|
|
public struct BuzzPushEndpointGrantRecord: Codable, Equatable, Sendable {
|
|
/// Gateway authority that issued this opaque capability.
|
|
public let gatewayOrigin: String
|
|
/// Canonical WebSocket relay origin that owns the client lease.
|
|
public let relayOrigin: String
|
|
/// NIP-PL delegation key selected from the relay push descriptor.
|
|
public let relayPubkey: String
|
|
/// Optional NIP-11 `self` key that verifies relay-authored NIP-29 metadata.
|
|
public let relayMetadataPubkey: String?
|
|
/// Gateway installation authority. This is distinct from [installationId],
|
|
/// which is the unlinkable per-relay-origin NIP-PL lease address.
|
|
public let gatewayInstallationHandle: String?
|
|
/// App Attest key that authenticates mutations for the gateway installation.
|
|
public let appAttestKeyId: String
|
|
/// Unlinkable per-relay lease address, distinct from the gateway handle.
|
|
public let installationId: String
|
|
/// Opaque capability issued by the gateway for relay delivery.
|
|
public let endpointGrant: String
|
|
/// Lowercase SHA-256 digest of the binary APNs device token.
|
|
public let endpointHash: String
|
|
/// Monotonic endpoint version bound into the gateway grant.
|
|
public let endpointEpoch: Int64
|
|
/// Monotonic delegation version bound into the gateway grant.
|
|
public let generation: Int64
|
|
/// Unix expiration time of the installation or delegation authority.
|
|
public let expiresAt: Int64
|
|
|
|
/// Creates a gateway-scoped durable record from authenticated enrollment state.
|
|
public init(
|
|
gatewayOrigin: String,
|
|
relayOrigin: String,
|
|
relayPubkey: String,
|
|
relayMetadataPubkey: String? = nil,
|
|
gatewayInstallationHandle: String? = nil,
|
|
appAttestKeyId: String,
|
|
installationId: String,
|
|
endpointGrant: String,
|
|
endpointHash: String,
|
|
endpointEpoch: Int64,
|
|
generation: Int64,
|
|
expiresAt: Int64
|
|
) {
|
|
precondition(generation > 0, "Endpoint grant generation must be positive")
|
|
self.gatewayOrigin = gatewayOrigin
|
|
self.relayOrigin = relayOrigin
|
|
self.relayPubkey = relayPubkey
|
|
self.relayMetadataPubkey = relayMetadataPubkey
|
|
self.gatewayInstallationHandle = gatewayInstallationHandle
|
|
self.appAttestKeyId = appAttestKeyId
|
|
self.installationId = installationId
|
|
self.endpointGrant = endpointGrant
|
|
self.endpointHash = endpointHash
|
|
self.endpointEpoch = endpointEpoch
|
|
self.generation = generation
|
|
self.expiresAt = expiresAt
|
|
}
|
|
}
|
|
|
|
/// Persistence boundary for endpoint grants. The Runner implementation stores
|
|
/// records in its Keychain access group and exposes them over the Flutter bridge.
|
|
public protocol BuzzPushEndpointGrantStore {
|
|
/// Returns durable grants across configured origins without migrating them.
|
|
func records() throws -> [BuzzPushEndpointGrantRecord]
|
|
/// Atomically replaces the grant for its gateway and relay origin.
|
|
func save(_ record: BuzzPushEndpointGrantRecord) throws
|
|
/// Loads the exact retry journal for a gateway and relay origin.
|
|
func pendingEnrollment(
|
|
gatewayOrigin: String,
|
|
relayOrigin: String
|
|
) throws -> BuzzPushPendingEnrollmentRecord?
|
|
/// Persists the retry journal before any enrollment or delegation mutation.
|
|
func savePendingEnrollment(_ record: BuzzPushPendingEnrollmentRecord) throws
|
|
/// Removes a journal only after success or authoritative terminal failure.
|
|
func removePendingEnrollment(
|
|
gatewayOrigin: String,
|
|
relayOrigin: String
|
|
) throws
|
|
}
|
|
|
|
public enum BuzzDevPushEnrollmentError: Error, LocalizedError, Equatable {
|
|
case invalidGatewayURL
|
|
case invalidRelayURL
|
|
case invalidRelayDescriptor
|
|
case invalidResponse(route: String)
|
|
case unexpectedStatus(route: String, expected: Int, actual: Int, body: String)
|
|
case randomGenerationFailed(Int32)
|
|
case appAttestUnsupported
|
|
case invalidAppAttestKeyId
|
|
case generationExhausted
|
|
|
|
public var errorDescription: String? {
|
|
switch self {
|
|
case .invalidGatewayURL:
|
|
return "The development push gateway URL must be an HTTP or HTTPS origin."
|
|
case .invalidRelayURL:
|
|
return "The relay URL must be a ws or wss origin."
|
|
case .invalidRelayDescriptor:
|
|
return "NIP-11 must contain exactly one valid current push key."
|
|
case .invalidResponse(let route):
|
|
return "The response from \(route) did not match the closed push protocol."
|
|
case .unexpectedStatus(let route, let expected, let actual, let body):
|
|
return "The response from \(route) was HTTP \(actual), expected \(expected): \(body)"
|
|
case .randomGenerationFailed(let status):
|
|
return "Secure random generation failed with status \(status)."
|
|
case .appAttestUnsupported:
|
|
return "App Attest is unavailable on this device."
|
|
case .invalidAppAttestKeyId:
|
|
return "The App Attest key identifier is missing or invalid."
|
|
case .generationExhausted:
|
|
return "The development push grant generation cannot advance further."
|
|
}
|
|
}
|
|
}
|
|
|
|
protocol BuzzDevAppAttesting {
|
|
func prepareAttestation() async throws -> BuzzDevAttestation
|
|
func attestation(_ prepared: BuzzDevAttestation, clientData: Data) async throws
|
|
-> BuzzDevAttestation
|
|
func assertion(keyId: String, clientData: Data) async throws -> String
|
|
}
|
|
|
|
struct BuzzDevAttestation: Equatable {
|
|
let keyId: String
|
|
let attestation: String
|
|
}
|
|
|
|
private enum BuzzSecureRandom {
|
|
static func bytes(count: Int) throws -> Data {
|
|
var bytes = [UInt8](repeating: 0, count: count)
|
|
let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
|
|
guard status == errSecSuccess else {
|
|
throw BuzzDevPushEnrollmentError.randomGenerationFailed(status)
|
|
}
|
|
return Data(bytes)
|
|
}
|
|
}
|
|
|
|
private enum BuzzAppAttestKeyId {
|
|
static func isValid(_ keyId: String) -> Bool {
|
|
guard !keyId.isEmpty,
|
|
keyId.unicodeScalars.allSatisfy(\.isASCII),
|
|
let bytes = Data(base64Encoded: keyId)
|
|
else { return false }
|
|
return bytes.count == 32 && bytes.base64EncodedString() == keyId
|
|
}
|
|
}
|
|
|
|
protocol BuzzAppAttestKeyIdStoring {
|
|
func keyId() throws -> String?
|
|
func saveKeyId(_ keyId: String) throws
|
|
}
|
|
|
|
struct BuzzAppAttestKeyIdKeychainStore: BuzzAppAttestKeyIdStoring {
|
|
private static let service = "buzz.push.app-attest"
|
|
private static let account = "key-id-v1"
|
|
|
|
private let accessGroup: String?
|
|
private let copyMatching: (CFDictionary, UnsafeMutablePointer<CFTypeRef?>?) -> OSStatus
|
|
private let update: (CFDictionary, CFDictionary) -> OSStatus
|
|
private let add: (CFDictionary, UnsafeMutablePointer<CFTypeRef?>?) -> OSStatus
|
|
|
|
init(
|
|
accessGroup: String?,
|
|
copyMatching: @escaping (CFDictionary, UnsafeMutablePointer<CFTypeRef?>?) -> OSStatus =
|
|
SecItemCopyMatching,
|
|
update: @escaping (CFDictionary, CFDictionary) -> OSStatus = SecItemUpdate,
|
|
add: @escaping (CFDictionary, UnsafeMutablePointer<CFTypeRef?>?) -> OSStatus = SecItemAdd
|
|
) {
|
|
self.accessGroup = accessGroup
|
|
self.copyMatching = copyMatching
|
|
self.update = update
|
|
self.add = add
|
|
}
|
|
|
|
func keyId() throws -> String? {
|
|
var query = baseQuery()
|
|
query[kSecReturnData as String] = true
|
|
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
|
var result: CFTypeRef?
|
|
let status = copyMatching(query as CFDictionary, &result)
|
|
if status == errSecItemNotFound { return nil }
|
|
guard status == errSecSuccess else {
|
|
throw keychainError(status, operation: "read")
|
|
}
|
|
guard let data = result as? Data,
|
|
let keyId = String(data: data, encoding: .utf8),
|
|
BuzzAppAttestKeyId.isValid(keyId)
|
|
else {
|
|
throw BuzzDevPushEnrollmentError.invalidAppAttestKeyId
|
|
}
|
|
return keyId
|
|
}
|
|
|
|
func saveKeyId(_ keyId: String) throws {
|
|
guard BuzzAppAttestKeyId.isValid(keyId) else {
|
|
throw BuzzDevPushEnrollmentError.invalidAppAttestKeyId
|
|
}
|
|
let data = Data(keyId.utf8)
|
|
let updateStatus = update(
|
|
baseQuery() as CFDictionary,
|
|
[kSecValueData as String: data] as CFDictionary
|
|
)
|
|
if updateStatus == errSecSuccess { return }
|
|
guard updateStatus == errSecItemNotFound else {
|
|
throw keychainError(updateStatus, operation: "update")
|
|
}
|
|
|
|
var item = baseQuery()
|
|
item[kSecValueData as String] = data
|
|
item[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
|
let addStatus = add(item as CFDictionary, nil)
|
|
guard addStatus == errSecSuccess else {
|
|
throw keychainError(addStatus, operation: "add")
|
|
}
|
|
}
|
|
|
|
private func baseQuery() -> [String: Any] {
|
|
var query: [String: Any] = [
|
|
kSecClass as String: kSecClassGenericPassword,
|
|
kSecAttrService as String: Self.service,
|
|
kSecAttrAccount as String: Self.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:
|
|
"App Attest key identifier Keychain \(operation) failed: \(SecCopyErrorMessageString(status, nil) ?? "unknown" as CFString)"
|
|
]
|
|
)
|
|
}
|
|
}
|
|
|
|
protocol BuzzDCAppAttestServicing {
|
|
var isSupported: Bool { get }
|
|
func generateKey() async throws -> String
|
|
func attestKey(_ keyId: String, clientDataHash: Data) async throws -> Data
|
|
func generateAssertion(_ keyId: String, clientDataHash: Data) async throws -> Data
|
|
}
|
|
|
|
extension DCAppAttestService: BuzzDCAppAttestServicing {}
|
|
|
|
struct BuzzDCAppAttestProvider: BuzzDevAppAttesting {
|
|
private let service: BuzzDCAppAttestServicing
|
|
private let keyIdStore: BuzzAppAttestKeyIdStoring
|
|
|
|
init(
|
|
service: BuzzDCAppAttestServicing = DCAppAttestService.shared,
|
|
keyIdStore: BuzzAppAttestKeyIdStoring
|
|
) {
|
|
self.service = service
|
|
self.keyIdStore = keyIdStore
|
|
}
|
|
|
|
func prepareAttestation() async throws -> BuzzDevAttestation {
|
|
try requireSupportedService()
|
|
let keyId = try await service.generateKey()
|
|
guard BuzzAppAttestKeyId.isValid(keyId) else {
|
|
throw BuzzDevPushEnrollmentError.invalidAppAttestKeyId
|
|
}
|
|
try keyIdStore.saveKeyId(keyId)
|
|
return BuzzDevAttestation(keyId: keyId, attestation: "")
|
|
}
|
|
|
|
func attestation(
|
|
_ prepared: BuzzDevAttestation,
|
|
clientData: Data
|
|
) async throws -> BuzzDevAttestation {
|
|
precondition(!clientData.isEmpty, "Enrollment client data must not be empty")
|
|
try requireSupportedService()
|
|
guard BuzzAppAttestKeyId.isValid(prepared.keyId),
|
|
try keyIdStore.keyId() == prepared.keyId
|
|
else {
|
|
throw BuzzDevPushEnrollmentError.invalidAppAttestKeyId
|
|
}
|
|
let object = try await service.attestKey(
|
|
prepared.keyId,
|
|
clientDataHash: Data(SHA256.hash(data: clientData))
|
|
)
|
|
return BuzzDevAttestation(
|
|
keyId: prepared.keyId,
|
|
attestation: object.base64EncodedString()
|
|
)
|
|
}
|
|
|
|
func assertion(keyId: String, clientData: Data) async throws -> String {
|
|
precondition(!clientData.isEmpty, "Delegation client data must not be empty")
|
|
try requireSupportedService()
|
|
guard BuzzAppAttestKeyId.isValid(keyId) else {
|
|
throw BuzzDevPushEnrollmentError.invalidAppAttestKeyId
|
|
}
|
|
let object = try await service.generateAssertion(
|
|
keyId,
|
|
clientDataHash: Data(SHA256.hash(data: clientData))
|
|
)
|
|
return object.base64EncodedString()
|
|
}
|
|
|
|
private func requireSupportedService() throws {
|
|
guard service.isSupported else {
|
|
throw BuzzDevPushEnrollmentError.appAttestUnsupported
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Enrollment and delegation driver for real App Attest and the gated debug bypass.
|
|
public final class BuzzDevPushEnrollmentDriver {
|
|
public static let endpointEpoch: Int64 = 1
|
|
|
|
private let gatewayBaseURL: URL
|
|
private let gatewayOrigin: String
|
|
private let store: BuzzPushEndpointGrantStore
|
|
private let session: URLSession
|
|
private let appAttest: BuzzDevAppAttesting
|
|
private let now: () -> Date
|
|
private let lifetimeSeconds: Int64
|
|
private let installationIdBytes: () throws -> Data
|
|
|
|
/// Creates a driver backed by Apple's App Attest service and persists the
|
|
/// generated App Attest key identifier in the requested Keychain access group.
|
|
public convenience init(
|
|
gatewayBaseURL: URL,
|
|
store: BuzzPushEndpointGrantStore,
|
|
appAttestKeychainAccessGroup: String?,
|
|
session: URLSession = .shared
|
|
) throws {
|
|
try self.init(
|
|
gatewayBaseURL: gatewayBaseURL,
|
|
store: store,
|
|
session: session,
|
|
appAttest: BuzzDCAppAttestProvider(
|
|
keyIdStore: BuzzAppAttestKeyIdKeychainStore(
|
|
accessGroup: appAttestKeychainAccessGroup
|
|
)
|
|
),
|
|
now: Date.init,
|
|
lifetimeSeconds: 2_592_000,
|
|
installationIdBytes: { try BuzzSecureRandom.bytes(count: 16) }
|
|
)
|
|
}
|
|
|
|
init(
|
|
gatewayBaseURL: URL,
|
|
store: BuzzPushEndpointGrantStore,
|
|
session: URLSession,
|
|
appAttest: BuzzDevAppAttesting,
|
|
now: @escaping () -> Date,
|
|
lifetimeSeconds: Int64,
|
|
installationIdBytes: @escaping () throws -> Data = {
|
|
try BuzzSecureRandom.bytes(count: 16)
|
|
}
|
|
) throws {
|
|
guard lifetimeSeconds > 0 else {
|
|
throw BuzzDevPushEnrollmentError.invalidGatewayURL
|
|
}
|
|
let canonical: (url: URL, text: String)
|
|
do {
|
|
canonical = try BuzzPushTranscript.canonicalGatewayOrigin(gatewayBaseURL)
|
|
} catch {
|
|
throw BuzzDevPushEnrollmentError.invalidGatewayURL
|
|
}
|
|
self.gatewayBaseURL = canonical.url
|
|
self.gatewayOrigin = canonical.text
|
|
self.store = store
|
|
self.session = session
|
|
self.appAttest = appAttest
|
|
self.now = now
|
|
self.lifetimeSeconds = lifetimeSeconds
|
|
self.installationIdBytes = installationIdBytes
|
|
}
|
|
|
|
/// Returns the persisted opaque grants available to the lease publisher.
|
|
public func endpointGrants() throws -> [BuzzPushEndpointGrantRecord] {
|
|
try store.records().filter { $0.gatewayOrigin == gatewayOrigin }
|
|
}
|
|
|
|
/// Fetches the relay's current NIP-11 push key, enrolls the APNs endpoint,
|
|
/// delegates to that key, and durably saves the resulting opaque grant.
|
|
public func enroll(
|
|
deviceToken: Data,
|
|
relayURL: URL
|
|
) async throws -> BuzzPushEndpointGrantRecord {
|
|
precondition(!deviceToken.isEmpty, "The APNs device token must not be empty")
|
|
let relayOrigin = try Self.relayOrigin(relayURL)
|
|
let relayKeys = try await fetchCurrentRelayKeys(from: relayOrigin.url)
|
|
let relayPubkey = relayKeys.pushPubkey
|
|
let endpoint = Self.lowercaseHex(deviceToken)
|
|
let endpointHash = Self.lowercaseHex(Data(SHA256.hash(data: deviceToken)))
|
|
let nowSeconds = Int64(now().timeIntervalSince1970)
|
|
|
|
let storedRecords = try store.records()
|
|
let storedForOrigin = storedRecords.first {
|
|
$0.gatewayOrigin == gatewayOrigin && $0.relayOrigin == relayOrigin.text
|
|
}
|
|
let pendingEnrollment = try store.pendingEnrollment(
|
|
gatewayOrigin: gatewayOrigin,
|
|
relayOrigin: relayOrigin.text
|
|
)
|
|
if let pending = pendingEnrollment,
|
|
pending.relayPubkey != relayPubkey || pending.endpointHash != endpointHash
|
|
|| pending.expiresAt <= nowSeconds
|
|
{
|
|
// Finish an unexpired request using its exact journaled endpoint before
|
|
// replacing it. A token/key change does not make a response-lost request
|
|
// safe to discard, nor does it require revoking sibling installations.
|
|
if pending.expiresAt > nowSeconds {
|
|
_ = try await resumeEnrollment(
|
|
pending,
|
|
storedRecords: storedRecords,
|
|
relayMetadataPubkey: nil
|
|
)
|
|
} else {
|
|
try store.removePendingEnrollment(
|
|
gatewayOrigin: gatewayOrigin,
|
|
relayOrigin: relayOrigin.text
|
|
)
|
|
}
|
|
return try await enroll(deviceToken: deviceToken, relayURL: relayURL)
|
|
}
|
|
let newestSharedGrant = storedRecords.filter {
|
|
$0.gatewayOrigin == gatewayOrigin && $0.relayPubkey == relayPubkey
|
|
&& $0.endpointHash == endpointHash && $0.endpointEpoch == Self.endpointEpoch
|
|
&& $0.expiresAt > nowSeconds + 300
|
|
}.max { $0.generation < $1.generation }
|
|
if pendingEnrollment == nil, let reusableGrant = newestSharedGrant {
|
|
// Reuse the newest delegation without invalidating sibling communities.
|
|
let record = BuzzPushEndpointGrantRecord(
|
|
gatewayOrigin: gatewayOrigin,
|
|
relayOrigin: relayOrigin.text,
|
|
relayPubkey: relayPubkey,
|
|
relayMetadataPubkey: relayKeys.metadataPubkey,
|
|
gatewayInstallationHandle: reusableGrant.gatewayInstallationHandle,
|
|
appAttestKeyId: reusableGrant.appAttestKeyId,
|
|
installationId: try storedForOrigin?.installationId ?? makeInstallationId(),
|
|
endpointGrant: reusableGrant.endpointGrant,
|
|
endpointHash: endpointHash,
|
|
endpointEpoch: reusableGrant.endpointEpoch,
|
|
generation: reusableGrant.generation,
|
|
expiresAt: reusableGrant.expiresAt
|
|
)
|
|
try store.save(record)
|
|
try store.removePendingEnrollment(
|
|
gatewayOrigin: gatewayOrigin,
|
|
relayOrigin: relayOrigin.text
|
|
)
|
|
return record
|
|
}
|
|
|
|
// A previously attested installation can delegate independently to a new
|
|
// relay key, or issue a higher-generation grant for the same relay,
|
|
// without attempting duplicate APNs-token enrollment. An installation in
|
|
// its final five minutes is renewed by the authenticated delegation.
|
|
let reusableInstallation = storedRecords.first { record in
|
|
guard record.gatewayOrigin == gatewayOrigin,
|
|
record.endpointHash == endpointHash,
|
|
record.endpointEpoch == Self.endpointEpoch,
|
|
record.expiresAt > nowSeconds,
|
|
let handle = record.gatewayInstallationHandle,
|
|
let uuid = UUID(uuidString: handle)
|
|
else { return false }
|
|
return handle == uuid.uuidString.lowercased()
|
|
}
|
|
|
|
let (renewedExpiration, expiresOverflow) = nowSeconds.addingReportingOverflow(lifetimeSeconds)
|
|
guard !expiresOverflow else {
|
|
throw BuzzDevPushEnrollmentError.invalidGatewayURL
|
|
}
|
|
|
|
var pending: BuzzPushPendingEnrollmentRecord
|
|
if let existingPending = pendingEnrollment {
|
|
pending = existingPending
|
|
} else if let reusableInstallation,
|
|
let handle = reusableInstallation.gatewayInstallationHandle,
|
|
let existing = UUID(uuidString: handle)
|
|
{
|
|
let expiresAt =
|
|
reusableInstallation.expiresAt > nowSeconds + 300
|
|
? reusableInstallation.expiresAt
|
|
: renewedExpiration
|
|
pending = BuzzPushPendingEnrollmentRecord(
|
|
gatewayOrigin: gatewayOrigin,
|
|
relayOrigin: relayOrigin.text,
|
|
relayPubkey: relayPubkey,
|
|
endpoint: endpoint,
|
|
endpointHash: endpointHash,
|
|
expiresAt: expiresAt,
|
|
installationId: try storedForOrigin?.installationId ?? makeInstallationId(),
|
|
gatewayInstallationHandle: existing.uuidString.lowercased(),
|
|
keyId: reusableInstallation.appAttestKeyId
|
|
)
|
|
try store.savePendingEnrollment(pending)
|
|
} else {
|
|
let expiresAt = renewedExpiration
|
|
let enrollmentChallenge = try await challenge()
|
|
let preparedAttestation = try await appAttest.prepareAttestation()
|
|
let enrollmentClientData = try BuzzPushTranscript.enroll(
|
|
gatewayOrigin: gatewayBaseURL,
|
|
challengeId: enrollmentChallenge.id,
|
|
challenge: enrollmentChallenge.value,
|
|
keyId: preparedAttestation.keyId,
|
|
endpoint: endpoint,
|
|
endpointEpoch: Self.endpointEpoch,
|
|
expiresAt: expiresAt
|
|
)
|
|
let attestation = try await appAttest.attestation(
|
|
preparedAttestation,
|
|
clientData: enrollmentClientData
|
|
)
|
|
guard attestation.keyId == preparedAttestation.keyId else {
|
|
throw BuzzDevPushEnrollmentError.invalidResponse(route: "development attestation")
|
|
}
|
|
pending = BuzzPushPendingEnrollmentRecord(
|
|
gatewayOrigin: gatewayOrigin,
|
|
relayOrigin: relayOrigin.text,
|
|
relayPubkey: relayPubkey,
|
|
endpoint: endpoint,
|
|
endpointHash: endpointHash,
|
|
expiresAt: expiresAt,
|
|
installationId: try storedForOrigin?.installationId ?? makeInstallationId(),
|
|
challengeId: enrollmentChallenge.id.uuidString.lowercased(),
|
|
challenge: enrollmentChallenge.value,
|
|
keyId: attestation.keyId,
|
|
attestation: attestation.attestation
|
|
)
|
|
// The exact signed request is durable before the first network attempt.
|
|
try store.savePendingEnrollment(pending)
|
|
}
|
|
|
|
if pendingEnrollment != nil {
|
|
if let record = try await resumeEnrollment(
|
|
pending,
|
|
storedRecords: storedRecords,
|
|
relayMetadataPubkey: relayKeys.metadataPubkey
|
|
) {
|
|
return record
|
|
}
|
|
return try await enroll(deviceToken: deviceToken, relayURL: relayURL)
|
|
}
|
|
return try await completeEnrollment(
|
|
pending,
|
|
storedRecords: storedRecords,
|
|
relayMetadataPubkey: relayKeys.metadataPubkey
|
|
)
|
|
}
|
|
|
|
private func resumeEnrollment(
|
|
_ pending: BuzzPushPendingEnrollmentRecord,
|
|
storedRecords: [BuzzPushEndpointGrantRecord],
|
|
relayMetadataPubkey: String?
|
|
) async throws -> BuzzPushEndpointGrantRecord? {
|
|
do {
|
|
return try await completeEnrollment(
|
|
pending,
|
|
storedRecords: storedRecords,
|
|
relayMetadataPubkey: relayMetadataPubkey
|
|
)
|
|
} catch BuzzDevPushEnrollmentError.unexpectedStatus(
|
|
route: "v1/installations", _, actual: 404, _
|
|
) where pending.gatewayInstallationHandle == nil {
|
|
// Exact replay found no committed installation and its challenge expired.
|
|
// Only this terminal response permits replacing the old journal. A fresh
|
|
// request's failure propagates instead of recursively retrying forever.
|
|
try store.removePendingEnrollment(
|
|
gatewayOrigin: gatewayOrigin,
|
|
relayOrigin: pending.relayOrigin
|
|
)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private func completeEnrollment(
|
|
_ journal: BuzzPushPendingEnrollmentRecord,
|
|
storedRecords: [BuzzPushEndpointGrantRecord],
|
|
relayMetadataPubkey: String?
|
|
) async throws -> BuzzPushEndpointGrantRecord {
|
|
var pending = journal
|
|
let endpoint = pending.endpoint
|
|
guard Self.endpointHash(endpoint) == pending.endpointHash
|
|
else {
|
|
throw BuzzDevPushEnrollmentError.invalidResponse(route: "pending enrollment endpoint")
|
|
}
|
|
let nowSeconds = Int64(now().timeIntervalSince1970)
|
|
let relayPubkey = pending.relayPubkey
|
|
let installation: UUID
|
|
if let handle = pending.gatewayInstallationHandle,
|
|
let existing = UUID(uuidString: handle),
|
|
handle == existing.uuidString.lowercased()
|
|
{
|
|
installation = existing
|
|
} else {
|
|
guard let challengeId = pending.challengeId,
|
|
let challengeUUID = UUID(uuidString: challengeId),
|
|
challengeId == challengeUUID.uuidString.lowercased(),
|
|
let challengeValue = pending.challenge,
|
|
let keyId = pending.keyId,
|
|
let attestation = pending.attestation
|
|
else {
|
|
throw BuzzDevPushEnrollmentError.invalidResponse(
|
|
route: "pending development enrollment"
|
|
)
|
|
}
|
|
installation = try await enrollInstallation(
|
|
challenge: Challenge(id: challengeUUID, value: challengeValue),
|
|
endpoint: endpoint,
|
|
expiresAt: pending.expiresAt,
|
|
attestation: BuzzDevAttestation(keyId: keyId, attestation: attestation)
|
|
)
|
|
pending = BuzzPushPendingEnrollmentRecord(
|
|
gatewayOrigin: gatewayOrigin,
|
|
relayOrigin: pending.relayOrigin,
|
|
relayPubkey: pending.relayPubkey,
|
|
endpoint: pending.endpoint,
|
|
endpointHash: pending.endpointHash,
|
|
expiresAt: pending.expiresAt,
|
|
installationId: pending.installationId,
|
|
gatewayInstallationHandle: installation.uuidString.lowercased(),
|
|
challengeId: pending.challengeId,
|
|
challenge: pending.challenge,
|
|
keyId: pending.keyId,
|
|
attestation: pending.attestation,
|
|
delegationGeneration: pending.delegationGeneration
|
|
)
|
|
try store.savePendingEnrollment(pending)
|
|
}
|
|
|
|
let installationHandle = installation.uuidString.lowercased()
|
|
let currentGeneration =
|
|
storedRecords
|
|
.filter {
|
|
$0.gatewayOrigin == gatewayOrigin && $0.gatewayInstallationHandle == installationHandle
|
|
&& $0.relayPubkey == relayPubkey
|
|
}
|
|
.map(\.generation)
|
|
.max()
|
|
let generationBase = max(currentGeneration ?? 0, pending.delegationGeneration)
|
|
let generation: Int64
|
|
if generationBase > 0 {
|
|
let (next, overflow) = generationBase.addingReportingOverflow(1)
|
|
guard !overflow, next > 0 else {
|
|
throw BuzzDevPushEnrollmentError.generationExhausted
|
|
}
|
|
generation = next
|
|
} else {
|
|
generation = 1
|
|
}
|
|
pending = BuzzPushPendingEnrollmentRecord(
|
|
gatewayOrigin: gatewayOrigin,
|
|
relayOrigin: pending.relayOrigin,
|
|
relayPubkey: pending.relayPubkey,
|
|
endpoint: pending.endpoint,
|
|
endpointHash: pending.endpointHash,
|
|
expiresAt: pending.expiresAt,
|
|
installationId: pending.installationId,
|
|
gatewayInstallationHandle: installationHandle,
|
|
challengeId: pending.challengeId,
|
|
challenge: pending.challenge,
|
|
keyId: pending.keyId,
|
|
attestation: pending.attestation,
|
|
delegationGeneration: generation
|
|
)
|
|
// Reserve before delegation so a committed delegation followed by a local
|
|
// save failure is retried at a strictly higher generation.
|
|
try store.savePendingEnrollment(pending)
|
|
|
|
let delegationChallenge = try await challenge()
|
|
let delegationClientData = try BuzzPushTranscript.delegate(
|
|
gatewayOrigin: gatewayBaseURL,
|
|
challengeId: delegationChallenge.id,
|
|
challenge: delegationChallenge.value,
|
|
installationHandle: installation,
|
|
endpointEpoch: Self.endpointEpoch,
|
|
generation: generation,
|
|
relayPubkey: relayPubkey,
|
|
notBefore: nowSeconds,
|
|
expiresAt: pending.expiresAt
|
|
)
|
|
guard let appAttestKeyId = pending.keyId else {
|
|
throw BuzzDevPushEnrollmentError.invalidAppAttestKeyId
|
|
}
|
|
let assertion = try await appAttest.assertion(
|
|
keyId: appAttestKeyId,
|
|
clientData: delegationClientData
|
|
)
|
|
let endpointGrant = try await delegate(
|
|
challenge: delegationChallenge,
|
|
installationHandle: installation,
|
|
relayPubkey: relayPubkey,
|
|
generation: generation,
|
|
notBefore: nowSeconds,
|
|
expiresAt: pending.expiresAt,
|
|
assertion: assertion
|
|
)
|
|
|
|
let record = BuzzPushEndpointGrantRecord(
|
|
gatewayOrigin: gatewayOrigin,
|
|
relayOrigin: pending.relayOrigin,
|
|
relayPubkey: relayPubkey,
|
|
relayMetadataPubkey: relayMetadataPubkey,
|
|
gatewayInstallationHandle: installationHandle,
|
|
appAttestKeyId: appAttestKeyId,
|
|
installationId: pending.installationId,
|
|
endpointGrant: endpointGrant,
|
|
endpointHash: pending.endpointHash,
|
|
endpointEpoch: Self.endpointEpoch,
|
|
generation: generation,
|
|
expiresAt: pending.expiresAt
|
|
)
|
|
try store.save(record)
|
|
try store.removePendingEnrollment(
|
|
gatewayOrigin: gatewayOrigin,
|
|
relayOrigin: pending.relayOrigin
|
|
)
|
|
return record
|
|
}
|
|
|
|
private func makeInstallationId() throws -> String {
|
|
let bytes = try installationIdBytes()
|
|
precondition(
|
|
bytes.count == 16,
|
|
"NIP-PL installation identity entropy must be exactly 16 bytes"
|
|
)
|
|
// This value is per relay origin and never leaves the relay-facing lease.
|
|
return Self.lowercaseHex(bytes)
|
|
}
|
|
|
|
private func challenge() async throws -> Challenge {
|
|
let response: ChallengeResponse = try await post(
|
|
route: "v1/installations/challenges",
|
|
expectedStatus: 200,
|
|
body: VersionRequest(v: 1)
|
|
)
|
|
guard let id = UUID(uuidString: response.challengeId),
|
|
response.challengeId == id.uuidString.lowercased(),
|
|
Self.isBase64URLChallenge(response.challenge),
|
|
response.expiresAt > Int64(now().timeIntervalSince1970)
|
|
else {
|
|
throw BuzzDevPushEnrollmentError.invalidResponse(route: "v1/installations/challenges")
|
|
}
|
|
return Challenge(id: id, value: response.challenge)
|
|
}
|
|
|
|
private func enrollInstallation(
|
|
challenge: Challenge,
|
|
endpoint: String,
|
|
expiresAt: Int64,
|
|
attestation: BuzzDevAttestation
|
|
) async throws -> UUID {
|
|
let response: InstallationResponse = try await post(
|
|
route: "v1/installations",
|
|
expectedStatus: 201,
|
|
body: InstallationRequest(
|
|
v: 1,
|
|
challengeId: challenge.id.uuidString.lowercased(),
|
|
challenge: challenge.value,
|
|
keyId: attestation.keyId,
|
|
attestation: attestation.attestation,
|
|
endpoint: endpoint,
|
|
endpointEpoch: Self.endpointEpoch,
|
|
expiresAt: expiresAt
|
|
)
|
|
)
|
|
guard let installation = UUID(uuidString: response.installationHandle),
|
|
response.installationHandle == installation.uuidString.lowercased(),
|
|
response.endpointEpoch == Self.endpointEpoch,
|
|
response.expiresAt == expiresAt
|
|
else {
|
|
throw BuzzDevPushEnrollmentError.invalidResponse(route: "v1/installations")
|
|
}
|
|
return installation
|
|
}
|
|
|
|
private func delegate(
|
|
challenge: Challenge,
|
|
installationHandle: UUID,
|
|
relayPubkey: String,
|
|
generation: Int64,
|
|
notBefore: Int64,
|
|
expiresAt: Int64,
|
|
assertion: String
|
|
) async throws -> String {
|
|
let response: DelegationResponse = try await post(
|
|
route: "v1/delegations",
|
|
expectedStatus: 201,
|
|
body: DelegationRequest(
|
|
v: 1,
|
|
challengeId: challenge.id.uuidString.lowercased(),
|
|
challenge: challenge.value,
|
|
installationHandle: installationHandle.uuidString.lowercased(),
|
|
endpointEpoch: Self.endpointEpoch,
|
|
generation: generation,
|
|
relayPubkey: relayPubkey,
|
|
notBefore: notBefore,
|
|
expiresAt: expiresAt,
|
|
assertion: assertion
|
|
)
|
|
)
|
|
guard !response.endpointGrant.isEmpty, response.endpointGrant.utf8.count <= 4_096 else {
|
|
throw BuzzDevPushEnrollmentError.invalidResponse(route: "v1/delegations")
|
|
}
|
|
return response.endpointGrant
|
|
}
|
|
|
|
private func fetchCurrentRelayKeys(from relayOrigin: URL) async throws -> RelayKeys {
|
|
var request = URLRequest(url: relayOrigin)
|
|
request.httpMethod = "GET"
|
|
request.setValue("application/nostr+json", forHTTPHeaderField: "Accept")
|
|
let (data, response) = try await session.data(for: request)
|
|
try Self.expectStatus(response, data: data, route: "NIP-11", expected: 200)
|
|
let document: RelayInformation
|
|
do {
|
|
document = try JSONDecoder().decode(RelayInformation.self, from: data)
|
|
} catch {
|
|
throw BuzzDevPushEnrollmentError.invalidRelayDescriptor
|
|
}
|
|
let current = document.push.keys.filter(\.current)
|
|
guard current.count == 1,
|
|
Self.isLowercaseHexPubkey(current[0].pubkey)
|
|
else {
|
|
throw BuzzDevPushEnrollmentError.invalidRelayDescriptor
|
|
}
|
|
let metadataPubkey = document.relaySelf.flatMap {
|
|
Self.isLowercaseHexPubkey($0) ? $0 : nil
|
|
}
|
|
return RelayKeys(
|
|
pushPubkey: current[0].pubkey,
|
|
metadataPubkey: metadataPubkey
|
|
)
|
|
}
|
|
|
|
private func post<Request: Encodable, Response: Decodable>(
|
|
route: String,
|
|
expectedStatus: Int,
|
|
body: Request
|
|
) async throws -> Response {
|
|
let url = route.split(separator: "/").reduce(gatewayBaseURL) {
|
|
$0.appendingPathComponent(String($1))
|
|
}
|
|
var request = URLRequest(url: url)
|
|
request.httpMethod = "POST"
|
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
request.httpBody = try JSONEncoder().encode(body)
|
|
let (data, response) = try await session.data(for: request)
|
|
try Self.expectStatus(response, data: data, route: route, expected: expectedStatus)
|
|
do {
|
|
return try JSONDecoder().decode(Response.self, from: data)
|
|
} catch {
|
|
throw BuzzDevPushEnrollmentError.invalidResponse(route: route)
|
|
}
|
|
}
|
|
|
|
private static func expectStatus(
|
|
_ response: URLResponse,
|
|
data: Data,
|
|
route: String,
|
|
expected: Int
|
|
) throws {
|
|
guard let http = response as? HTTPURLResponse else {
|
|
throw BuzzDevPushEnrollmentError.invalidResponse(route: route)
|
|
}
|
|
guard http.statusCode == expected else {
|
|
let body = String(decoding: data.prefix(512), as: UTF8.self)
|
|
throw BuzzDevPushEnrollmentError.unexpectedStatus(
|
|
route: route, expected: expected, actual: http.statusCode, body: body
|
|
)
|
|
}
|
|
}
|
|
|
|
private static func relayOrigin(_ url: URL) throws -> (url: URL, text: String) {
|
|
guard url.scheme == "ws" || url.scheme == "wss",
|
|
url.host != nil,
|
|
url.path.isEmpty || url.path == "/",
|
|
url.user == nil,
|
|
url.password == nil,
|
|
url.query == nil,
|
|
url.fragment == nil
|
|
else {
|
|
throw BuzzDevPushEnrollmentError.invalidRelayURL
|
|
}
|
|
var components = URLComponents()
|
|
components.scheme = url.scheme == "wss" ? "https" : "http"
|
|
components.host = url.host
|
|
components.port = url.port
|
|
components.path = "/"
|
|
guard let httpURL = components.url else {
|
|
throw BuzzDevPushEnrollmentError.invalidRelayURL
|
|
}
|
|
var relayComponents = components
|
|
relayComponents.scheme = url.scheme
|
|
relayComponents.path = ""
|
|
guard let relayText = relayComponents.string else {
|
|
throw BuzzDevPushEnrollmentError.invalidRelayURL
|
|
}
|
|
return (httpURL, relayText)
|
|
}
|
|
|
|
private static func isLowercaseHexPubkey(_ value: String) -> Bool {
|
|
value.utf8.count == 64
|
|
&& value.utf8.allSatisfy {
|
|
(48...57).contains($0) || (97...102).contains($0)
|
|
}
|
|
}
|
|
|
|
private static func isBase64URLChallenge(_ value: String) -> Bool {
|
|
guard value.utf8.count == 43,
|
|
value.utf8.allSatisfy({
|
|
(48...57).contains($0) || (65...90).contains($0)
|
|
|| (97...122).contains($0) || $0 == 45 || $0 == 95
|
|
})
|
|
else { return false }
|
|
var padded = value.replacingOccurrences(of: "-", with: "+")
|
|
.replacingOccurrences(of: "_", with: "/")
|
|
padded += String(repeating: "=", count: (4 - padded.count % 4) % 4)
|
|
return Data(base64Encoded: padded)?.count == 32
|
|
}
|
|
|
|
private static func lowercaseHex(_ data: Data) -> String {
|
|
data.map { String(format: "%02x", $0) }.joined()
|
|
}
|
|
|
|
private static func endpointHash(_ endpoint: String) -> String? {
|
|
let utf8 = Array(endpoint.utf8)
|
|
guard !utf8.isEmpty, utf8.count <= 512, utf8.count.isMultiple(of: 2) else {
|
|
return nil
|
|
}
|
|
var bytes = [UInt8]()
|
|
bytes.reserveCapacity(utf8.count / 2)
|
|
for index in stride(from: 0, to: utf8.count, by: 2) {
|
|
guard let high = hexNibble(utf8[index]), let low = hexNibble(utf8[index + 1]) else {
|
|
return nil
|
|
}
|
|
bytes.append(high << 4 | low)
|
|
}
|
|
return lowercaseHex(Data(SHA256.hash(data: Data(bytes))))
|
|
}
|
|
|
|
private static func hexNibble(_ byte: UInt8) -> UInt8? {
|
|
switch byte {
|
|
case 48...57: byte - 48
|
|
case 97...102: byte - 87
|
|
default: nil
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct VersionRequest: Encodable { let v: Int }
|
|
private struct Challenge {
|
|
let id: UUID
|
|
let value: String
|
|
}
|
|
private struct ChallengeResponse: Decodable {
|
|
let challengeId: String
|
|
let challenge: String
|
|
let expiresAt: Int64
|
|
enum CodingKeys: String, CodingKey {
|
|
case challengeId = "challenge_id"
|
|
case challenge
|
|
case expiresAt = "expires_at"
|
|
}
|
|
}
|
|
private struct InstallationRequest: Encodable {
|
|
let v: Int
|
|
let challengeId: String
|
|
let challenge: String
|
|
let keyId: String
|
|
let attestation: String
|
|
let endpoint: String
|
|
let endpointEpoch: Int64
|
|
let expiresAt: Int64
|
|
enum CodingKeys: String, CodingKey {
|
|
case v
|
|
case challengeId = "challenge_id"
|
|
case challenge
|
|
case keyId = "key_id"
|
|
case attestation
|
|
case endpoint
|
|
case endpointEpoch = "endpoint_epoch"
|
|
case expiresAt = "expires_at"
|
|
}
|
|
}
|
|
private struct InstallationResponse: Decodable {
|
|
let installationHandle: String
|
|
let endpointEpoch: Int64
|
|
let expiresAt: Int64
|
|
enum CodingKeys: String, CodingKey {
|
|
case installationHandle = "installation_handle"
|
|
case endpointEpoch = "endpoint_epoch"
|
|
case expiresAt = "expires_at"
|
|
}
|
|
}
|
|
private struct DelegationRequest: Encodable {
|
|
let v: Int
|
|
let challengeId: String
|
|
let challenge: String
|
|
let installationHandle: String
|
|
let endpointEpoch: Int64
|
|
let generation: Int64
|
|
let relayPubkey: String
|
|
let notBefore: Int64
|
|
let expiresAt: Int64
|
|
let assertion: String
|
|
enum CodingKeys: String, CodingKey {
|
|
case v
|
|
case challengeId = "challenge_id"
|
|
case challenge
|
|
case installationHandle = "installation_handle"
|
|
case endpointEpoch = "endpoint_epoch"
|
|
case generation
|
|
case relayPubkey = "relay_pubkey"
|
|
case notBefore = "not_before"
|
|
case expiresAt = "expires_at"
|
|
case assertion
|
|
}
|
|
}
|
|
private struct DelegationResponse: Decodable {
|
|
let endpointGrant: String
|
|
enum CodingKeys: String, CodingKey { case endpointGrant = "endpoint_grant" }
|
|
}
|
|
private struct RelayInformation: Decodable {
|
|
struct Push: Decodable {
|
|
struct Key: Decodable {
|
|
let pubkey: String
|
|
let current: Bool
|
|
}
|
|
let keys: [Key]
|
|
}
|
|
let relaySelf: String?
|
|
let push: Push
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case relaySelf = "self"
|
|
case push
|
|
}
|
|
|
|
init(from decoder: Decoder) throws {
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
push = try container.decode(Push.self, forKey: .push)
|
|
relaySelf = try? container.decode(String.self, forKey: .relaySelf)
|
|
}
|
|
}
|
|
|
|
private struct RelayKeys {
|
|
let pushPubkey: String
|
|
let metadataPubkey: String?
|
|
}
|