Files
buzz/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift
Tom Brow 3b3e536bd2 refactor(push): simplify internal notification stack
Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Codex <noreply@openai.com>
Ai-assisted: true
2026-08-24 20:25:40 -07:00

866 lines
29 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 {
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?
public let installationId: String
public let endpointGrant: String
public let endpointHash: String
public let appProfile: String
public let endpointEpoch: Int64
public let generation: Int64
public let expiresAt: Int64
public init(
relayOrigin: String,
relayPubkey: String,
relayMetadataPubkey: String? = nil,
gatewayInstallationHandle: String? = nil,
installationId: String,
endpointGrant: String,
endpointHash: String,
appProfile: String,
endpointEpoch: Int64,
generation: Int64,
expiresAt: Int64
) {
precondition(generation > 0, "Endpoint grant generation must be positive")
self.relayOrigin = relayOrigin
self.relayPubkey = relayPubkey
self.relayMetadataPubkey = relayMetadataPubkey
self.gatewayInstallationHandle = gatewayInstallationHandle
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 {
func records() throws -> [BuzzPushEndpointGrantRecord]
func save(_ record: BuzzPushEndpointGrantRecord) 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(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(clientData: Data) async throws -> String {
precondition(!clientData.isEmpty, "Delegation client data must not be empty")
try requireSupportedService()
guard let keyId = try keyIdStore.keyId(),
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 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 Self.isHTTPOrigin(gatewayBaseURL), lifetimeSeconds > 0 else {
throw BuzzDevPushEnrollmentError.invalidGatewayURL
}
self.gatewayBaseURL = gatewayBaseURL
self.store = store
self.session = session
self.appAttest = appAttest
self.now = now
self.lifetimeSeconds = lifetimeSeconds
self.installationIdBytes = installationIdBytes
}
public func endpointGrants() throws -> [BuzzPushEndpointGrantRecord] {
try store.records()
}
/// 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.relayOrigin == relayOrigin.text && $0.appProfile == Self.appProfile
}
if let current = storedForOrigin,
current.relayPubkey == relayPubkey,
current.endpointHash == endpointHash,
current.endpointEpoch == Self.endpointEpoch,
current.expiresAt > nowSeconds + 300
{
guard current.relayMetadataPubkey != relayKeys.metadataPubkey else {
return current
}
let refreshed = BuzzPushEndpointGrantRecord(
relayOrigin: current.relayOrigin,
relayPubkey: current.relayPubkey,
relayMetadataPubkey: relayKeys.metadataPubkey,
gatewayInstallationHandle: current.gatewayInstallationHandle,
installationId: current.installationId,
endpointGrant: current.endpointGrant,
endpointHash: current.endpointHash,
appProfile: current.appProfile,
endpointEpoch: current.endpointEpoch,
generation: current.generation,
expiresAt: current.expiresAt
)
try store.save(refreshed)
return refreshed
}
// One gateway delegation is scoped to an installation and relay key, not
// to a Buzz community. A second origin served by the same relay therefore
// gets a fresh unlinkable NIP-PL address while reusing the opaque grant.
if storedForOrigin == nil,
let sharedGrant = storedRecords.first(where: {
$0.relayPubkey == relayPubkey && $0.appProfile == Self.appProfile
&& $0.endpointHash == endpointHash && $0.endpointEpoch == Self.endpointEpoch
&& $0.expiresAt > nowSeconds + 300
})
{
let record = BuzzPushEndpointGrantRecord(
relayOrigin: relayOrigin.text,
relayPubkey: relayPubkey,
relayMetadataPubkey: relayKeys.metadataPubkey,
gatewayInstallationHandle: sharedGrant.gatewayInstallationHandle,
installationId: try makeInstallationId(),
endpointGrant: sharedGrant.endpointGrant,
endpointHash: endpointHash,
appProfile: Self.appProfile,
endpointEpoch: sharedGrant.endpointEpoch,
generation: sharedGrant.generation,
expiresAt: sharedGrant.expiresAt
)
try store.save(record)
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.
let reusableInstallation = storedRecords.first { record in
guard record.appProfile == Self.appProfile,
record.endpointHash == endpointHash,
record.endpointEpoch == Self.endpointEpoch,
record.expiresAt > nowSeconds + 300,
let handle = record.gatewayInstallationHandle,
let uuid = UUID(uuidString: handle)
else { return false }
return handle == uuid.uuidString.lowercased()
}
let installation: UUID
let expiresAt: Int64
if let reusableInstallation,
let handle = reusableInstallation.gatewayInstallationHandle,
let existing = UUID(uuidString: handle)
{
installation = existing
expiresAt = reusableInstallation.expiresAt
} else {
let (newExpiration, expiresOverflow) = nowSeconds.addingReportingOverflow(lifetimeSeconds)
guard !expiresOverflow else {
throw BuzzDevPushEnrollmentError.invalidGatewayURL
}
expiresAt = newExpiration
let enrollmentChallenge = try await challenge()
let preparedAttestation = try await appAttest.prepareAttestation()
let enrollmentClientData = try BuzzPushTranscript.enroll(
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")
}
installation = try await enrollInstallation(
challenge: enrollmentChallenge,
endpoint: endpoint,
expiresAt: expiresAt,
attestation: attestation
)
}
let installationHandle = installation.uuidString.lowercased()
let currentGeneration =
storedRecords
.filter {
$0.gatewayInstallationHandle == installationHandle
&& $0.relayPubkey == relayPubkey && $0.appProfile == Self.appProfile
}
.map(\.generation)
.max()
let generation: Int64
if let currentGeneration {
let (next, overflow) = currentGeneration.addingReportingOverflow(1)
guard !overflow, next > 0 else {
throw BuzzDevPushEnrollmentError.generationExhausted
}
generation = next
} else {
generation = 1
}
let delegationChallenge = try await challenge()
let delegationClientData = try BuzzPushTranscript.delegate(
challengeId: delegationChallenge.id,
challenge: delegationChallenge.value,
installationHandle: installation,
endpointEpoch: Self.endpointEpoch,
generation: generation,
relayPubkey: relayPubkey,
notBefore: nowSeconds,
expiresAt: expiresAt
)
let assertion = try await appAttest.assertion(clientData: delegationClientData)
let endpointGrant = try await delegate(
challenge: delegationChallenge,
installationHandle: installation,
relayPubkey: relayPubkey,
generation: generation,
notBefore: nowSeconds,
expiresAt: expiresAt,
assertion: assertion
)
let record = BuzzPushEndpointGrantRecord(
relayOrigin: relayOrigin.text,
relayPubkey: relayPubkey,
relayMetadataPubkey: relayKeys.metadataPubkey,
gatewayInstallationHandle: installationHandle,
installationId: try storedForOrigin?.installationId ?? makeInstallationId(),
endpointGrant: endpointGrant,
endpointHash: endpointHash,
appProfile: Self.appProfile,
endpointEpoch: Self.endpointEpoch,
generation: generation,
expiresAt: expiresAt
)
try store.save(record)
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 isHTTPOrigin(_ url: URL) -> Bool {
(url.scheme == "http" || url.scheme == "https")
&& url.host != nil
&& (url.path.isEmpty || url.path == "/")
&& url.user == nil
&& url.password == nil
&& url.query == nil
&& url.fragment == nil
}
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 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?
}