mirror of
https://github.com/block/buzz.git
synced 2026-11-04 16:31:38 +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>
1090 lines
38 KiB
Swift
1090 lines
38 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
|
|
/// Server-owned application identity used for enrollment.
|
|
public let appProfile: 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,
|
|
appProfile: 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.appProfile = appProfile
|
|
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, relay origin, and app profile.
|
|
func save(_ record: BuzzPushEndpointGrantRecord) throws
|
|
/// Loads the exact retry journal for a gateway, relay origin, and app profile.
|
|
func pendingEnrollment(
|
|
gatewayOrigin: String,
|
|
relayOrigin: String,
|
|
appProfile: 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,
|
|
appProfile: 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 appProfile = "buzz-ios-dogfood"
|
|
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
|
|
&& $0.appProfile == Self.appProfile
|
|
}
|
|
let pendingEnrollment = try store.pendingEnrollment(
|
|
gatewayOrigin: gatewayOrigin,
|
|
relayOrigin: relayOrigin.text,
|
|
appProfile: Self.appProfile
|
|
)
|
|
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,
|
|
appProfile: Self.appProfile
|
|
)
|
|
}
|
|
return try await enroll(deviceToken: deviceToken, relayURL: relayURL)
|
|
}
|
|
let newestSharedGrant = storedRecords.filter {
|
|
$0.gatewayOrigin == gatewayOrigin && $0.relayPubkey == relayPubkey
|
|
&& $0.appProfile == Self.appProfile
|
|
&& $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,
|
|
appProfile: Self.appProfile,
|
|
endpointEpoch: reusableGrant.endpointEpoch,
|
|
generation: reusableGrant.generation,
|
|
expiresAt: reusableGrant.expiresAt
|
|
)
|
|
try store.save(record)
|
|
try store.removePendingEnrollment(
|
|
gatewayOrigin: gatewayOrigin,
|
|
relayOrigin: relayOrigin.text,
|
|
appProfile: Self.appProfile
|
|
)
|
|
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.appProfile == Self.appProfile,
|
|
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,
|
|
appProfile: Self.appProfile,
|
|
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,
|
|
appProfile: Self.appProfile,
|
|
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,
|
|
appProfile: Self.appProfile,
|
|
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,
|
|
appProfile: pending.appProfile
|
|
)
|
|
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,
|
|
appProfile: pending.appProfile,
|
|
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 && $0.appProfile == Self.appProfile
|
|
}
|
|
.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,
|
|
appProfile: pending.appProfile,
|
|
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,
|
|
appProfile: Self.appProfile,
|
|
endpointEpoch: Self.endpointEpoch,
|
|
generation: generation,
|
|
expiresAt: pending.expiresAt
|
|
)
|
|
try store.save(record)
|
|
try store.removePendingEnrollment(
|
|
gatewayOrigin: gatewayOrigin,
|
|
relayOrigin: pending.relayOrigin,
|
|
appProfile: Self.appProfile
|
|
)
|
|
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,
|
|
appProfile: Self.appProfile,
|
|
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 appProfile: 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 appProfile = "app_profile"
|
|
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?
|
|
}
|