field_ios

In-the-field app for Radroots on iOS
git clone https://radroots.dev/git/field_ios.git
Log | Files | Refs | README | LICENSE

TeraConfigurationStore.swift (28904B)


      1 import CryptoKit
      2 import Darwin
      3 import Foundation
      4 import RadrootsKit
      5 import Security
      6 
      7 enum TeraAppNetworkProfile: String, Codable, Sendable, Equatable {
      8     case publicNetwork = "public"
      9     case simulator
     10     case device
     11 
     12     var runtimeValue: TeraRuntimeNetworkProfile {
     13         switch self {
     14         case .publicNetwork: .publicNetwork
     15         case .simulator: .simulator
     16         case .device: .device
     17         }
     18     }
     19 }
     20 
     21 struct TeraAppConfiguration: Sendable, Equatable {
     22     let profile: TeraAppNetworkProfile
     23     let writableRelays: [String]
     24     let blossom: TeraBlossomEndpointConfiguration?
     25     let keychainServicePrefix: String
     26     let bundleIdentifier: String
     27     let appMetadata: TeraRuntimeAppMetadata
     28     let generation: UInt64
     29     let activationState: TeraConfigurationActivationState
     30     let previousBlossomConfigFingerprint: String?
     31 }
     32 
     33 enum TeraConfigurationActivationState: String, Codable, Sendable, Equatable {
     34     case current
     35     case reconfigurationRequired = "reconfiguration_required"
     36 }
     37 
     38 struct TeraConfigurationBootstrap: Sendable, Equatable {
     39     let runtimeMode: String
     40     let relayURLs: [String]
     41     let blossomOrigins: [String]
     42     let keychainServicePrefix: String
     43     let bundleIdentifier: String
     44     let appMetadata: TeraRuntimeAppMetadata
     45 }
     46 
     47 struct TeraSourceGeneration: Codable, Sendable, Equatable {
     48     let schemaVersion: UInt16
     49     let generationHex: String
     50     let createdAtUnixMilliseconds: UInt64
     51 }
     52 
     53 enum TeraConfigurationError: Error, Sendable, Equatable {
     54     case missing(String)
     55     case invalid(String)
     56     case corruptStoredConfiguration
     57     case corruptSourceGeneration
     58     case persistenceFailed
     59 }
     60 
     61 extension TeraConfigurationError: LocalizedError {
     62     var errorDescription: String? {
     63         switch self {
     64         case .missing:
     65             "Required Tera configuration is missing."
     66         case .invalid:
     67             "Tera network configuration is invalid."
     68         case .corruptStoredConfiguration:
     69             "Stored Tera settings are corrupt and require recovery."
     70         case .corruptSourceGeneration:
     71             "Stored Tera local-state identity is corrupt and requires recovery."
     72         case .persistenceFailed:
     73             "Tera could not persist its local configuration."
     74         }
     75     }
     76 }
     77 
     78 actor TeraConfigurationStore {
     79     static let maximumStoredConfigurationBytes = 64 * 1024
     80 
     81     private struct StoredConfigurationV3: Codable, Equatable {
     82         static let format = "radroots_ios_configuration_v3"
     83 
     84         let format: String
     85         let profile: TeraAppNetworkProfile
     86         let writableRelays: [String]
     87         let blossom: TeraBlossomEndpointConfiguration?
     88         let generation: UInt64?
     89         let activationState: TeraConfigurationActivationState?
     90         let bootstrapFingerprint: String?
     91         let canonicalBlossomConfigFingerprint: String?
     92         let previousBlossomConfigFingerprint: String?
     93     }
     94 
     95     private struct BootstrapNetworkIdentity: Codable {
     96         let profile: TeraAppNetworkProfile
     97         let relayURLs: [String]
     98         let blossomOrigins: [String]
     99     }
    100 
    101     private struct StoredConfigurationV2: Codable {
    102         static let format = "radroots_ios_configuration_v2"
    103 
    104         let format: String
    105         let profile: TeraAppNetworkProfile
    106         let writableRelays: [String]
    107         let blossomOrigins: [String]
    108     }
    109 
    110     private struct LegacyRelaySettings: Codable {
    111         static let format = "radroots_field_ios_relay_settings_v1"
    112 
    113         let format: String
    114         let relays: [String]
    115     }
    116 
    117     private static let configurationFile = RadrootsFileReference(
    118       scope: .data,
    119       relativePath: "settings/radroots_configuration_v3.json"
    120     )
    121     private static let v2ConfigurationFile = RadrootsFileReference(
    122       scope: .data,
    123       relativePath: "settings/radroots_configuration_v2.json"
    124     )
    125     private static let legacyRelayFile = RadrootsFileReference(
    126       scope: .data,
    127       relativePath: "settings/relay_settings.json"
    128     )
    129     private static let sourceGenerationFile = RadrootsFileReference(
    130       scope: .data,
    131       relativePath: "state/source_generation_v1.json"
    132     )
    133 
    134     private let bootstrap: TeraConfigurationBootstrap
    135     private let fileAccess: RadrootsAppleFileAccess
    136     private let clock: TeraClock
    137 
    138     init(
    139       bootstrap: TeraConfigurationBootstrap,
    140       roots: RadrootsAppleFileRoots,
    141       clock: TeraClock = .system
    142     ) {
    143         self.bootstrap = bootstrap
    144         fileAccess = RadrootsAppleFileAccess(roots: roots)
    145         self.clock = clock
    146     }
    147 
    148     func load() throws -> TeraAppConfiguration {
    149         let profile = try Self.profile(for: bootstrap.runtimeMode)
    150         let bootstrapFingerprint = try Self.bootstrapFingerprint(bootstrap, profile: profile)
    151         let selected: StoredConfigurationV3
    152         if let stored = try readStoredConfiguration() {
    153             guard stored.format == StoredConfigurationV3.format else {
    154                 throw TeraConfigurationError.corruptStoredConfiguration
    155             }
    156             let upgraded = try Self.upgradeStoredConfiguration(
    157               stored,
    158               bootstrapFingerprint: bootstrapFingerprint
    159             )
    160             if upgraded.profile == profile,
    161                Self.blossomMatchesProfile(upgraded.blossom, profile: profile),
    162                upgraded.bootstrapFingerprint == bootstrapFingerprint
    163             {
    164                 selected = upgraded
    165                 if selected != stored {
    166                     try persist(selected)
    167                 }
    168             } else {
    169                 selected = try Self.bootstrapConfiguration(
    170                   bootstrap,
    171                   profile: profile,
    172                   generation: Self.nextGeneration(after: upgraded.generation ?? 0),
    173                   activationState: .reconfigurationRequired,
    174                   bootstrapFingerprint: bootstrapFingerprint,
    175                   previousBlossomConfigFingerprint: upgraded.canonicalBlossomConfigFingerprint
    176                         ?? upgraded.previousBlossomConfigFingerprint
    177                 )
    178                 try persist(selected)
    179             }
    180         } else if let stored = try readV2Configuration() {
    181             guard stored.format == StoredConfigurationV2.format else {
    182                 throw TeraConfigurationError.corruptStoredConfiguration
    183             }
    184             selected = if stored.profile == profile {
    185                 StoredConfigurationV3(
    186                   format: StoredConfigurationV3.format,
    187                   profile: profile,
    188                   writableRelays: stored.writableRelays,
    189                   blossom: Self.blossomConfiguration(
    190                     origins: stored.blossomOrigins,
    191                     profile: profile
    192                   ),
    193                   generation: 1,
    194                   activationState: .current,
    195                   bootstrapFingerprint: bootstrapFingerprint,
    196                   canonicalBlossomConfigFingerprint: nil,
    197                   previousBlossomConfigFingerprint: nil
    198                 )
    199             } else {
    200                 Self.bootstrapConfiguration(
    201                   bootstrap,
    202                   profile: profile,
    203                   generation: 1,
    204                   activationState: .reconfigurationRequired,
    205                   bootstrapFingerprint: bootstrapFingerprint
    206                 )
    207             }
    208             try persist(selected)
    209         } else if let legacy = try readLegacyConfiguration() {
    210             guard legacy.format == LegacyRelaySettings.format else {
    211                 throw TeraConfigurationError.corruptStoredConfiguration
    212             }
    213             selected = StoredConfigurationV3(
    214               format: StoredConfigurationV3.format,
    215               profile: profile,
    216               writableRelays: legacy.relays,
    217               blossom: Self.blossomConfiguration(
    218                 origins: bootstrap.blossomOrigins,
    219                 profile: profile
    220               ),
    221               generation: 1,
    222               activationState: .current,
    223               bootstrapFingerprint: bootstrapFingerprint,
    224               canonicalBlossomConfigFingerprint: nil,
    225               previousBlossomConfigFingerprint: nil
    226             )
    227             try persist(selected)
    228         } else {
    229             selected = Self.bootstrapConfiguration(
    230               bootstrap,
    231               profile: profile,
    232               generation: 1,
    233               activationState: .current,
    234               bootstrapFingerprint: bootstrapFingerprint
    235             )
    236             try persist(selected)
    237         }
    238 
    239         let relays = try TeraNetworkValidator.relays(
    240           selected.writableRelays,
    241           profile: profile
    242         )
    243         guard !bootstrap.keychainServicePrefix.isEmpty,
    244               !bootstrap.bundleIdentifier.isEmpty
    245         else {
    246             throw TeraConfigurationError.missing("identity")
    247         }
    248         return TeraAppConfiguration(
    249           profile: profile,
    250           writableRelays: relays,
    251           blossom: selected.blossom,
    252           keychainServicePrefix: bootstrap.keychainServicePrefix,
    253           bundleIdentifier: bootstrap.bundleIdentifier,
    254           appMetadata: bootstrap.appMetadata,
    255           generation: selected.generation ?? 1,
    256           activationState: selected.activationState ?? .current,
    257           previousBlossomConfigFingerprint: selected.previousBlossomConfigFingerprint
    258         )
    259     }
    260 
    261     func confirmCanonicalBlossomConfiguration(
    262       _ canonical: TeraBlossomConfigurationStatus?,
    263       expectedGeneration: UInt64
    264     ) throws {
    265         guard let stored = try readStoredConfiguration(),
    266               stored.format == StoredConfigurationV3.format,
    267               stored.generation == expectedGeneration,
    268               let bootstrapFingerprint = stored.bootstrapFingerprint
    269         else {
    270             throw TeraConfigurationError.persistenceFailed
    271         }
    272         let canonicalBlossom: TeraBlossomEndpointConfiguration?
    273         let canonicalFingerprint: String?
    274         switch (stored.blossom, canonical) {
    275         case (nil, nil):
    276             canonicalBlossom = nil
    277             canonicalFingerprint = nil
    278         case let (.some(selected), .some(canonical)):
    279             guard let hostKind = TeraBlossomHostKind(rawValue: canonical.hostKind),
    280                   let endpointAuthority = TeraBlossomEndpointAuthority(
    281                       runtimeValue: canonical.endpointAuthority
    282                   ),
    283                   hostKind == selected.hostKind,
    284                   endpointAuthority == selected.endpointAuthority,
    285                   Self.isFingerprint(canonical.configFingerprint)
    286             else {
    287                 throw TeraConfigurationError.invalid("canonical_blossom_configuration")
    288             }
    289             canonicalBlossom = TeraBlossomEndpointConfiguration(
    290               hostKind: hostKind,
    291               endpointAuthority: endpointAuthority,
    292               primaryOrigin: canonical.primaryOrigin,
    293               fallbackOrigins: canonical.fallbackOrigins
    294             )
    295             canonicalFingerprint = canonical.configFingerprint
    296         default:
    297             throw TeraConfigurationError.invalid("canonical_blossom_configuration")
    298         }
    299         try persist(
    300             StoredConfigurationV3(
    301               format: StoredConfigurationV3.format,
    302               profile: stored.profile,
    303               writableRelays: stored.writableRelays,
    304               blossom: canonicalBlossom,
    305               generation: expectedGeneration,
    306               activationState: .current,
    307               bootstrapFingerprint: bootstrapFingerprint,
    308               canonicalBlossomConfigFingerprint: canonicalFingerprint,
    309               previousBlossomConfigFingerprint: nil
    310             )
    311         )
    312     }
    313 
    314     func confirmBootstrapActivation(expectedGeneration: UInt64) throws {
    315         guard let stored = try readStoredConfiguration(),
    316               stored.format == StoredConfigurationV3.format,
    317               stored.generation == expectedGeneration,
    318               let bootstrapFingerprint = stored.bootstrapFingerprint
    319         else {
    320             throw TeraConfigurationError.persistenceFailed
    321         }
    322         try persist(
    323             StoredConfigurationV3(
    324               format: stored.format,
    325               profile: stored.profile,
    326               writableRelays: stored.writableRelays,
    327               blossom: stored.blossom,
    328               generation: expectedGeneration,
    329               activationState: .current,
    330               bootstrapFingerprint: bootstrapFingerprint,
    331               canonicalBlossomConfigFingerprint: nil,
    332               previousBlossomConfigFingerprint: nil
    333             )
    334         )
    335     }
    336 
    337     func sourceGeneration() throws -> TeraSourceGeneration {
    338         if let data = try read(Self.sourceGenerationFile) {
    339             guard let value = try? JSONDecoder().decode(TeraSourceGeneration.self, from: data),
    340                   value.schemaVersion == 1,
    341                   value.generationHex.count == 64,
    342                   value.generationHex.allSatisfy(\.isHexDigit),
    343                   value.generationHex == value.generationHex.lowercased(),
    344                   value.createdAtUnixMilliseconds > 0
    345             else {
    346                 throw TeraConfigurationError.corruptSourceGeneration
    347             }
    348             return value
    349         }
    350         var bytes = [UInt8](repeating: 0, count: 32)
    351         guard SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) == errSecSuccess else {
    352             throw TeraConfigurationError.persistenceFailed
    353         }
    354         let createdAtUnixMilliseconds: UInt64
    355         do {
    356             createdAtUnixMilliseconds = try clock.unixMilliseconds(requirePositive: true)
    357         } catch {
    358             throw TeraConfigurationError.persistenceFailed
    359         }
    360         let value = TeraSourceGeneration(
    361           schemaVersion: 1,
    362           generationHex: bytes.map { String(format: "%02x", $0) }.joined(),
    363           createdAtUnixMilliseconds: createdAtUnixMilliseconds
    364         )
    365         do {
    366             let data = try JSONEncoder.tera.encode(value)
    367             try fileAccess.write(.inline(data), to: Self.sourceGenerationFile)
    368             return value
    369         } catch let error as TeraConfigurationError {
    370             throw error
    371         } catch {
    372             throw TeraConfigurationError.persistenceFailed
    373         }
    374     }
    375 
    376     private func readStoredConfiguration() throws -> StoredConfigurationV3? {
    377         guard let data = try read(Self.configurationFile) else { return nil }
    378         guard let stored = try? JSONDecoder().decode(StoredConfigurationV3.self, from: data) else {
    379             throw TeraConfigurationError.corruptStoredConfiguration
    380         }
    381         return stored
    382     }
    383 
    384     private func readV2Configuration() throws -> StoredConfigurationV2? {
    385         guard let data = try read(Self.v2ConfigurationFile) else { return nil }
    386         guard let stored = try? JSONDecoder().decode(StoredConfigurationV2.self, from: data) else {
    387             throw TeraConfigurationError.corruptStoredConfiguration
    388         }
    389         return stored
    390     }
    391 
    392     private func readLegacyConfiguration() throws -> LegacyRelaySettings? {
    393         guard let data = try read(Self.legacyRelayFile) else { return nil }
    394         guard let legacy = try? JSONDecoder().decode(LegacyRelaySettings.self, from: data) else {
    395             throw TeraConfigurationError.corruptStoredConfiguration
    396         }
    397         return legacy
    398     }
    399 
    400     private func read(_ file: RadrootsFileReference) throws -> Data? {
    401         do {
    402             guard case let .inline(data) = try fileAccess.read(
    403               file,
    404               mode: .inline(maxBytes: Self.maximumStoredConfigurationBytes)
    405             ) else {
    406                 throw TeraConfigurationError.persistenceFailed
    407             }
    408             return data
    409         } catch RadrootsAppleFileError.notFound {
    410             return nil
    411         } catch let error as TeraConfigurationError {
    412             throw error
    413         } catch {
    414             throw TeraConfigurationError.persistenceFailed
    415         }
    416     }
    417 
    418     private func persist(_ configuration: StoredConfigurationV3) throws {
    419         do {
    420             let data = try JSONEncoder.tera.encode(configuration)
    421             try fileAccess.write(.inline(data), to: Self.configurationFile)
    422         } catch {
    423             throw TeraConfigurationError.persistenceFailed
    424         }
    425     }
    426 
    427     private static func nextGeneration(after generation: UInt64) throws -> UInt64 {
    428         do {
    429             return try TeraCheckedStateTransition.nextGeneration(after: generation)
    430         } catch {
    431             throw TeraConfigurationError.corruptStoredConfiguration
    432         }
    433     }
    434 
    435     private static func profile(for runtimeMode: String) throws -> TeraAppNetworkProfile {
    436         switch runtimeMode.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
    437         case "production": .publicNetwork
    438         case "localhost-dev", "simulator": .simulator
    439         case "device-development", "device": .device
    440         default: throw TeraConfigurationError.invalid("runtime_mode")
    441         }
    442     }
    443 
    444     private static func bootstrapConfiguration(
    445       _ bootstrap: TeraConfigurationBootstrap,
    446       profile: TeraAppNetworkProfile,
    447       generation: UInt64,
    448       activationState: TeraConfigurationActivationState,
    449       bootstrapFingerprint: String,
    450       previousBlossomConfigFingerprint: String? = nil
    451     ) -> StoredConfigurationV3 {
    452         StoredConfigurationV3(
    453           format: StoredConfigurationV3.format,
    454           profile: profile,
    455           writableRelays: bootstrap.relayURLs,
    456           blossom: blossomConfiguration(origins: bootstrap.blossomOrigins, profile: profile),
    457           generation: generation,
    458           activationState: activationState,
    459           bootstrapFingerprint: bootstrapFingerprint,
    460           canonicalBlossomConfigFingerprint: nil,
    461           previousBlossomConfigFingerprint: previousBlossomConfigFingerprint
    462         )
    463     }
    464 
    465     private static func upgradeStoredConfiguration(
    466       _ stored: StoredConfigurationV3,
    467       bootstrapFingerprint: String
    468     ) throws -> StoredConfigurationV3 {
    469         if let generation = stored.generation,
    470            let activationState = stored.activationState,
    471            let persistedBootstrapFingerprint = stored.bootstrapFingerprint
    472         {
    473             guard generation > 0,
    474                   isFingerprint(persistedBootstrapFingerprint),
    475                   stored.canonicalBlossomConfigFingerprint.map(isFingerprint) ?? true,
    476                   stored.previousBlossomConfigFingerprint.map(isFingerprint) ?? true
    477             else {
    478                 throw TeraConfigurationError.corruptStoredConfiguration
    479             }
    480             return StoredConfigurationV3(
    481               format: stored.format,
    482               profile: stored.profile,
    483               writableRelays: stored.writableRelays,
    484               blossom: stored.blossom,
    485               generation: generation,
    486               activationState: activationState,
    487               bootstrapFingerprint: persistedBootstrapFingerprint,
    488               canonicalBlossomConfigFingerprint: stored.canonicalBlossomConfigFingerprint,
    489               previousBlossomConfigFingerprint: stored.previousBlossomConfigFingerprint
    490             )
    491         }
    492         guard stored.generation == nil,
    493               stored.activationState == nil,
    494               stored.bootstrapFingerprint == nil,
    495               stored.canonicalBlossomConfigFingerprint == nil,
    496               stored.previousBlossomConfigFingerprint == nil
    497         else {
    498             throw TeraConfigurationError.corruptStoredConfiguration
    499         }
    500         return StoredConfigurationV3(
    501           format: stored.format,
    502           profile: stored.profile,
    503           writableRelays: stored.writableRelays,
    504           blossom: stored.blossom,
    505           generation: 1,
    506           activationState: .current,
    507           bootstrapFingerprint: bootstrapFingerprint,
    508           canonicalBlossomConfigFingerprint: nil,
    509           previousBlossomConfigFingerprint: nil
    510         )
    511     }
    512 
    513     private static func bootstrapFingerprint(
    514       _ bootstrap: TeraConfigurationBootstrap,
    515       profile: TeraAppNetworkProfile
    516     ) throws -> String {
    517         do {
    518             let data = try JSONEncoder.tera.encode(
    519                 BootstrapNetworkIdentity(
    520                   profile: profile,
    521                   relayURLs: bootstrap.relayURLs,
    522                   blossomOrigins: bootstrap.blossomOrigins
    523                 )
    524             )
    525             return SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
    526         } catch {
    527             throw TeraConfigurationError.invalid("bootstrap_network_identity")
    528         }
    529     }
    530 
    531     private static func isFingerprint(_ value: String) -> Bool {
    532         value.count == 64
    533             && value == value.lowercased()
    534             && value.allSatisfy(\.isHexDigit)
    535     }
    536 
    537     private static func blossomConfiguration(
    538       origins: [String],
    539       profile: TeraAppNetworkProfile
    540     ) -> TeraBlossomEndpointConfiguration? {
    541         guard let primary = origins.first else { return nil }
    542         let identity = blossomIdentity(profile: profile)
    543         return TeraBlossomEndpointConfiguration(
    544           hostKind: identity.hostKind,
    545           endpointAuthority: identity.endpointAuthority,
    546           primaryOrigin: primary,
    547           fallbackOrigins: Array(origins.dropFirst())
    548         )
    549     }
    550 
    551     private static func blossomMatchesProfile(
    552       _ configuration: TeraBlossomEndpointConfiguration?,
    553       profile: TeraAppNetworkProfile
    554     ) -> Bool {
    555         guard let configuration else { return true }
    556         let identity = blossomIdentity(profile: profile)
    557         return configuration.hostKind == identity.hostKind
    558             && configuration.endpointAuthority == identity.endpointAuthority
    559     }
    560 
    561     private static func blossomIdentity(
    562         profile: TeraAppNetworkProfile
    563     ) -> (
    564       hostKind: TeraBlossomHostKind,
    565       endpointAuthority: TeraBlossomEndpointAuthority
    566     ) {
    567         switch profile {
    568         case .publicNetwork:
    569             (.physicalDevice, .publicWebPKI)
    570         case .simulator:
    571             (.simulator, .loopbackDevelopment)
    572         case .device:
    573             (.physicalDevice, .privateNetworkDevelopment)
    574         }
    575     }
    576 }
    577 
    578 enum TeraNetworkValidator {
    579     static let canonicalRelay = "wss://radroots.org"
    580 
    581     static func relays(
    582       _ values: [String],
    583       profile: TeraAppNetworkProfile
    584     ) throws -> [String] {
    585         var output: [String] = []
    586         var seen = Set<String>()
    587         for raw in values {
    588             let canonical = try relay(raw, profile: profile)
    589             if profile != .simulator, canonical == canonicalRelay {
    590                 continue
    591             }
    592             guard seen.insert(canonical).inserted else {
    593                 throw TeraConfigurationError.invalid("duplicate_relay")
    594             }
    595             output.append(canonical)
    596         }
    597         if profile != .publicNetwork, output.isEmpty {
    598             throw TeraConfigurationError.invalid("empty_relay_set")
    599         }
    600         let totalCount = output.count + (profile == .simulator ? 0 : 1)
    601         guard totalCount <= 64 else {
    602             throw TeraConfigurationError.invalid("too_many_relays")
    603         }
    604         return output
    605     }
    606 
    607     private static func relay(
    608       _ raw: String,
    609       profile: TeraAppNetworkProfile
    610     ) throws -> String {
    611         guard raw == raw.trimmingCharacters(in: .whitespacesAndNewlines),
    612               raw.utf8.count <= 2048,
    613               !raw.contains(where: { $0.isASCII && ($0.isWhitespace || $0.asciiValue ?? 32 < 32) }),
    614               !raw.contains("?"),
    615               !raw.contains("#"),
    616               !raw.contains("\\"),
    617               let components = URLComponents(string: raw),
    618               components.user == nil,
    619               components.password == nil,
    620               let scheme = components.scheme?.lowercased(),
    621               let parsedHost = components.host?.lowercased(),
    622               components.port != 0,
    623               components.path.isEmpty || components.path == "/"
    624         else {
    625             throw TeraConfigurationError.invalid("relay_url")
    626         }
    627         let host = parsedHost.hasPrefix("[") && parsedHost.hasSuffix("]")
    628             ? String(parsedHost.dropFirst().dropLast())
    629             : parsedHost
    630         let schemeAllowed = scheme == "wss"
    631             || (scheme == "ws" && (profile == .simulator || profile == .device))
    632         guard schemeAllowed, hostAllowed(host, profile: profile) else {
    633             throw TeraConfigurationError.invalid("relay_policy")
    634         }
    635         var canonical = "\(scheme)://\(hostForURL(host))"
    636         if let port = components.port,
    637            !((scheme == "wss" && port == 443) || (scheme == "ws" && port == 80))
    638         {
    639             canonical += ":\(port)"
    640         }
    641         return canonical
    642     }
    643 
    644     private static func hostForURL(_ host: String) -> String {
    645         host.contains(":") ? "[\(host)]" : host
    646     }
    647 
    648     private static func hostAllowed(
    649       _ host: String,
    650       profile: TeraAppNetworkProfile
    651     ) -> Bool {
    652         if profile == .simulator {
    653             return host == "localhost" || host == "127.0.0.1" || host == "::1"
    654         }
    655         if host == "localhost" || host.hasSuffix(".localhost") {
    656             return false
    657         }
    658         if let ipv4 = IPv4Address(host) {
    659             return profile == .publicNetwork ? ipv4.isPublic
    660                 : profile == .device ? ipv4.isPrivateLAN : ipv4.isTrustedDevice
    661         }
    662         if let ipv6 = IPv6Address(host) {
    663             return profile == .publicNetwork ? ipv6.isPublic
    664                 : profile == .device ? ipv6.isPrivateLAN : ipv6.isTrustedDevice
    665         }
    666         if profile == .device {
    667             return false
    668         }
    669         let normalized = host.trimmingCharacters(in: CharacterSet(charactersIn: "."))
    670         return normalized.contains(".")
    671             && !normalized.hasSuffix(".local")
    672             && !normalized.hasSuffix(".home.arpa")
    673     }
    674 }
    675 
    676 private struct IPv4Address {
    677     private var address = in_addr()
    678 
    679     init?(_ value: String) {
    680         guard inet_pton(AF_INET, value, &address) == 1 else { return nil }
    681     }
    682 
    683     var isTrustedDevice: Bool {
    684         var address = address
    685         let octets = withUnsafeBytes(of: &address.s_addr) { Array($0) }
    686         return octets[0] != 0 && octets[0] != 127 && !(224 ... 239).contains(octets[0])
    687             && octets != [255, 255, 255, 255]
    688     }
    689 
    690     var isPublic: Bool {
    691         var address = address
    692         let octets = withUnsafeBytes(of: &address.s_addr) { Array($0) }
    693         guard isTrustedDevice else { return false }
    694         return !(octets[0] == 10
    695             || octets[0] == 169 && octets[1] == 254
    696             || octets[0] == 172 && (16 ... 31).contains(octets[1])
    697             || octets[0] == 192 && octets[1] == 168
    698             || octets[0] == 100 && (64 ... 127).contains(octets[1])
    699             || octets[0] == 192 && octets[1] == 0 && octets[2] == 0
    700             || octets[0] == 192 && octets[1] == 0 && octets[2] == 2
    701             || octets[0] == 192 && octets[1] == 88 && octets[2] == 99
    702             || octets[0] == 198 && (octets[1] == 18 || octets[1] == 19)
    703             || octets[0] == 198 && octets[1] == 51 && octets[2] == 100
    704             || octets[0] == 203 && octets[1] == 0 && octets[2] == 113
    705             || octets[0] >= 240)
    706     }
    707 
    708     var isPrivateLAN: Bool {
    709         var address = address
    710         let octets = withUnsafeBytes(of: &address.s_addr) { Array($0) }
    711         return octets[0] == 10
    712             || octets[0] == 172 && (16 ... 31).contains(octets[1])
    713             || octets[0] == 192 && octets[1] == 168
    714     }
    715 }
    716 
    717 private struct IPv6Address {
    718     private var address = in6_addr()
    719 
    720     init?(_ value: String) {
    721         guard inet_pton(AF_INET6, value, &address) == 1 else { return nil }
    722     }
    723 
    724     var isTrustedDevice: Bool {
    725         var address = address
    726         let bytes = withUnsafeBytes(of: &address) { Array($0) }
    727         let allZero = bytes.allSatisfy { $0 == 0 }
    728         let loopback = bytes.dropLast().allSatisfy { $0 == 0 } && bytes.last == 1
    729         let multicast = bytes.first == 0xFF
    730         return !allZero && !loopback && !multicast
    731     }
    732 
    733     var isPublic: Bool {
    734         var address = address
    735         let bytes = withUnsafeBytes(of: &address) { Array($0) }
    736         guard isTrustedDevice, bytes.count == 16 else { return false }
    737         let segment0 = UInt16(bytes[0]) << 8 | UInt16(bytes[1])
    738         let segment1 = UInt16(bytes[2]) << 8 | UInt16(bytes[3])
    739         return segment0 & 0xE000 == 0x2000
    740             && !(segment0 == 0x2001 && segment1 <= 0x01FF)
    741             && !(segment0 == 0x2001 && segment1 == 0x0DB8)
    742             && segment0 != 0x2002
    743             && !(segment0 == 0x3FFF && segment1 & 0xF000 == 0)
    744     }
    745 
    746     var isPrivateLAN: Bool {
    747         var address = address
    748         let bytes = withUnsafeBytes(of: &address) { Array($0) }
    749         return bytes.count == 16 && bytes[0] & 0xFE == 0xFC
    750     }
    751 }
    752 
    753 private extension JSONEncoder {
    754     static var tera: JSONEncoder {
    755         let encoder = JSONEncoder()
    756         encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
    757         return encoder
    758     }
    759 }