diff --git a/apps/rebreak-native/app.config.ts b/apps/rebreak-native/app.config.ts index de145a5..cd35117 100644 --- a/apps/rebreak-native/app.config.ts +++ b/apps/rebreak-native/app.config.ts @@ -107,19 +107,19 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ projectId: "a4f2186e-8ca5-4d38-921d-82ae96c9c086", // EAS muss VOR dem Build wissen, dass es eine App-Extension gibt — sonst // generiert es nur Credentials für die Haupt-App und der Xcode-Build kippt - // mit "No profiles for 'org.rebreak.app.RebreakURLFilter' were found". + // mit "No profiles for 'org.rebreak.app.URLFilterExtension' were found". // Bundle-ID + Entitlements müssen exakt zu plugins/with-rebreak-protection-ios.js - // und modules/rebreak-protection/ios/RebreakURLFilter/RebreakURLFilter.entitlements passen. + // und modules/rebreak-protection/ios/RebreakURLFilterExtension/ passen. build: { experimental: { ios: { appExtensions: [ { - targetName: "RebreakURLFilter", - bundleIdentifier: "org.rebreak.app.RebreakURLFilter", + targetName: "RebreakURLFilterExtension", + bundleIdentifier: "org.rebreak.app.URLFilterExtension", entitlements: { "com.apple.developer.networking.networkextension": [ - "content-filter-provider", + "url-filter-provider", ], "com.apple.security.application-groups": [ "group.org.rebreak.app", @@ -144,6 +144,11 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ // Sobald Apple das Distribution-Entitlement freigibt: Flag in eas.json // preview/production env setzen — dann zieht beides automatisch. familyControlsEnabled: process.env.REBREAK_ENABLE_FAMILY_CONTROLS === "1", + // iOS-NEURLFilter / PIR-Server. Der Auth-Token kommt aus der Build-Umgebung + // (Infisical staging/PIR_AUTH_TOKEN) — NIEMALS committen. Lokaler Dev-Build: + // `PIR_AUTH_TOKEN=… npx expo run:ios --device`. + pirServerURL: process.env.PIR_SERVER_URL || "https://pir.staging.rebreak.org", + pirAuthToken: process.env.PIR_AUTH_TOKEN || "", apiUrl: process.env.EXPO_PUBLIC_API_URL || process.env.API_URL || diff --git a/apps/rebreak-native/lib/protection.ts b/apps/rebreak-native/lib/protection.ts index e9ac699..0ef4bc5 100644 --- a/apps/rebreak-native/lib/protection.ts +++ b/apps/rebreak-native/lib/protection.ts @@ -113,7 +113,26 @@ export const protection = { const enabled = !r.missingLayers.includes("vpn"); res = enabled ? { enabled: true } : { enabled: false, error: r.errors?.[0] }; } else { - res = await RebreakProtection.activateUrlFilter(); + // iOS: NEURLFilter braucht die PIR-Server-Config. Token kommt aus + // app.config.ts extra (Build-Env PIR_AUTH_TOKEN) — nie im Repo. + const pirServerURL = (Constants.expoConfig?.extra?.pirServerURL as string) ?? ""; + const pirAuthToken = (Constants.expoConfig?.extra?.pirAuthToken as string) ?? ""; + // Diagnose: zeigt in der Metro-Konsole ob der Token im Manifest ankommt. + console.log( + `[protection] PIR-Config — serverURL=${pirServerURL} tokenLen=${pirAuthToken.length}`, + ); + res = await RebreakProtection.activateUrlFilter({ pirServerURL, pirAuthToken }); + } + // Diagnose: Fehler-String + nativer Log-Tail (inkl. der [EXT ...]-Zeilen + // der Control-Provider-Extension) zeilenweise in Metro. + { + const resAny = res as Record; + const nativeLog = resAny.log; + delete resAny.log; + console.log(`[protection] activateUrlFilter → ${JSON.stringify(res)}`); + if (Array.isArray(nativeLog)) { + for (const l of nativeLog) console.log(` [native] ${l}`); + } } // Bei erfolgreicher Reaktivierung: Backend-Flag clearen (sonst bleibt // protectionShouldBeActive=false und Bypass-Detection feuert nicht mehr). diff --git a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakProtectionModule.swift b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakProtectionModule.swift index 8909503..c9f34ee 100644 --- a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakProtectionModule.swift +++ b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakProtectionModule.swift @@ -32,6 +32,13 @@ fileprivate enum SharedLogStore { if logs.count > maxEntries { logs.removeFirst(logs.count - maxEntries) } defaults.set(logs, forKey: logKey) } + + /// Letzte `n` Einträge — für Inline-Diagnose im activateUrlFilter-Ergebnis + /// (enthält auch die `[EXT ...]`-Zeilen der Control-Provider-Extension). + static func tail(_ n: Int) -> [String] { + guard let defaults = UserDefaults(suiteName: APP_GROUP) else { return [] } + return Array((defaults.stringArray(forKey: logKey) ?? []).suffix(n)) + } } // ─── Module ─────────────────────────────────────────────────────────────────── @@ -61,31 +68,106 @@ public class RebreakProtectionModule: Module { // ───────── activateUrlFilter: NUR NEFilter ───────── - AsyncFunction("activateUrlFilter") { () async -> [String: Any] in + AsyncFunction("activateUrlFilter") { (opts: [String: String]) async -> [String: Any] in var error: String? = nil var enabled = false - do { - let manager = NEFilterManager.shared() - SharedLogStore.append("📥 [activateUrlFilter] loadFromPreferences...") - try await manager.loadFromPreferences() + var statusName = "n/a" + var disconnectName: String? = nil + if #available(iOS 26.0, *) { + let pirAuthToken = opts["pirAuthToken"] ?? "" + guard let pirServerURL = URL(string: opts["pirServerURL"] ?? ""), !pirAuthToken.isEmpty else { + SharedLogStore.append("❌ [activateUrlFilter] fehlende pirServerURL/pirAuthToken") + return ["enabled": false, "error": "missing_pir_config"] + } + func name(_ s: NEURLFilterManager.Status) -> String { + switch s { + case .invalid: return "invalid" + case .stopped: return "stopped" + case .starting: return "starting" + case .running: return "running" + case .stopping: return "stopping" + @unknown default: return "unknown(\(s.rawValue))" + } + } + var phase = "loadFromPreferences" + do { + let manager = NEURLFilterManager.shared + SharedLogStore.append("📥 [activateUrlFilter] loadFromPreferences...") + try await manager.loadFromPreferences() - let config = NEFilterProviderConfiguration() - config.filterBrowsers = true - config.filterSockets = false - manager.providerConfiguration = config - manager.localizedDescription = "Rebreak URL Filter" - manager.isEnabled = true + phase = "setConfiguration" + SharedLogStore.append( + "⚙️ [activateUrlFilter] setConfiguration (server=\(pirServerURL.absoluteString) tokenLen=\(pirAuthToken.count))..." + ) + // pirPrivacyPassIssuerURL == pirServerURL — PIRService bedient beides. + try manager.setConfiguration( + pirServerURL: pirServerURL, + pirPrivacyPassIssuerURL: pirServerURL, + pirAuthenticationToken: pirAuthToken, + controlProviderBundleIdentifier: "org.rebreak.app.URLFilterExtension" + ) + // WWDC2025-NEURLFilter-Sample setzt zusätzlich `localizedDescription` + + // `prefilterFetchInterval` — die hatten wir bisher NICHT. Eine fehlende + // `localizedDescription` ist ein Kandidat für das `configurationInvalid` + // bei `saveToPreferences()`. + manager.localizedDescription = "ReBreak URL-Filter" + manager.prefilterFetchInterval = 86400 // 1 Tag (wie WWDC-Sample) + manager.isEnabled = true + manager.shouldFailClosed = true - SharedLogStore.append("💾 [activateUrlFilter] saveToPreferences (System-Dialog)...") - try await manager.saveToPreferences() - enabled = manager.isEnabled - SharedLogStore.append("✅ NEFilter enabled (isEnabled=\(enabled))") - } catch let e as NSError { - error = "\(e.domain):\(e.code) \(e.localizedDescription)" - SharedLogStore.append("❌ NEFilter enable failed: \(error!)") + phase = "saveToPreferences" + SharedLogStore.append("💾 [activateUrlFilter] saveToPreferences...") + try await manager.saveToPreferences() + + phase = "status" + // saveToPreferences wirft NICHT, wenn iOS die Config zwar speichert, + // aber als ungültig ablehnt ("Ungültig" in Settings). Der echte + // Status ist erst nach kurzer Zeit stabil — direkt nach dem Save + // steht er auf .starting. Auf stabilen Status warten (max ~3s). + var status = await manager.status + var waited = 0 + while status == .starting && waited < 8 { + try? await Task.sleep(nanoseconds: 400_000_000) + status = await manager.status + waited += 1 + } + statusName = name(status) + if let d = await manager.lastDisconnectError { + disconnectName = "\(d)" + SharedLogStore.append("⚠️ NEURLFilter lastDisconnectError: \(d) (rawValue=\(d.rawValue))") + } + // Wahrheit ist der Status, NICHT isEnabled (das bleibt true auch bei + // einer abgelehnten Config). + enabled = (status == .running) + SharedLogStore.append( + "ℹ️ NEURLFilter post-save: status=\(statusName) isEnabled=\(manager.isEnabled) disconnectError=\(disconnectName ?? "nil")" + ) + if !enabled { + error = "config_invalid status=\(statusName) disconnectError=\(disconnectName ?? "none")" + SharedLogStore.append("❌ NEURLFilter nicht aktiv: \(error!)") + } else { + SharedLogStore.append("✅ NEURLFilter running") + } + } catch let e as NSError { + // lastDisconnectError auch im Throw-Fall lesen — gibt oft den echten + // Grund hinter einem generischen configurationInvalid. + var disc = "n/a" + if let d = await NEURLFilterManager.shared.lastDisconnectError { + disc = "\(d)" + } + error = "[\(phase)] \(e.domain):\(e.code) \(e.localizedDescription) | lastDisconnectError=\(disc)" + SharedLogStore.append("❌ NEURLFilter enable failed: \(error!)") + } + } else { + error = "iOS 26+ erforderlich für NEURLFilter" + SharedLogStore.append("❌ \(error!)") } - var result: [String: Any] = ["enabled": enabled] + var result: [String: Any] = ["enabled": enabled, "status": statusName] + if let d = disconnectName { result["disconnectError"] = d } if let error = error { result["error"] = error } + // Tail des geteilten Log-Stores mitgeben — enthält die [EXT ...]-Zeilen + // der Control-Provider-Extension (separater Prozess, sonst unsichtbar). + result["log"] = SharedLogStore.tail(30) return result } @@ -301,22 +383,16 @@ public class RebreakProtectionModule: Module { // Provider beendet sein sollte. Erst isEnabled=false + save bringt das // System dazu, den Filter-Daemon sauber zu beenden bevor wir die Config // löschen. Pattern aus Apple-Developer-Forums + eigene Empirie. - do { - let manager = NEFilterManager.shared() - try await manager.loadFromPreferences() - if manager.isEnabled { + if #available(iOS 26.0, *) { + do { + let manager = NEURLFilterManager.shared + try await manager.loadFromPreferences() manager.isEnabled = false - do { - try await manager.saveToPreferences() - SharedLogStore.append("⏸ NEFilter isEnabled=false saved (daemon stop)") - } catch { - SharedLogStore.append("⚠️ saveToPreferences(disabled) failed: \(error.localizedDescription)") - } + try await manager.removeFromPreferences() + SharedLogStore.append("✅ NEURLFilter disabled + removed from preferences") + } catch { + SharedLogStore.append("⚠️ NEURLFilter disable: \(error.localizedDescription)") } - try await manager.removeFromPreferences() - SharedLogStore.append("✅ NEFilter disabled + removed from preferences") - } catch { - SharedLogStore.append("⚠️ NEFilter disable: \(error.localizedDescription)") } // ManagedSettings (löst denyAppRemoval) @@ -335,14 +411,18 @@ public class RebreakProtectionModule: Module { // ───────── getDeviceState: aktueller Status aller Layer ───────── AsyncFunction("getDeviceState") { () async -> [String: Any] in - // NEFilter + // NEURLFilter var urlFilter = false - do { - let manager = NEFilterManager.shared() - try await manager.loadFromPreferences() - urlFilter = manager.isEnabled - } catch { - // ignore + if #available(iOS 26.0, *) { + do { + let manager = NEURLFilterManager.shared + try await manager.loadFromPreferences() + // Wahrheit ist der Runtime-Status: nur .running heißt „filtert + // wirklich". isEnabled bleibt true auch bei einer „Ungültig"-Config. + urlFilter = (await manager.status == .running) + } catch { + // ignore + } } // FamilyControls diff --git a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakURLFilter/FilterControlProvider.swift b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakURLFilter/FilterControlProvider.swift deleted file mode 100644 index 2b8a12b..0000000 --- a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakURLFilter/FilterControlProvider.swift +++ /dev/null @@ -1,228 +0,0 @@ -// -// FilterDataProvider.swift -// RebreakURLFilter — NEFilterDataProvider mit memory-mapped Hash-Liste. -// -// Architektur: -// - Container-App lädt `blocklist.bin` vom Server runter (sortierte 64-bit Hashes) -// - File liegt in App-Group: group.org.rebreak.app/blocklist.bin -// - Diese Extension memory-mapped die Datei und macht Binary-Search pro Flow -// - Memory-Footprint: <1 MB (mmap working-set) -// -// Privacy: -// - Keine Klartext-Domains auf Disk (nur SHA-256/64-bit Hashes) -// - User-Browsing-URL verlässt das Gerät nie -// - -import NetworkExtension -import Foundation -import CryptoKit - -/// Shared Log-Store via App-Group UserDefaults (für Container-App-Debug-Page). -enum SharedLogStore { - static let appGroup = "group.org.rebreak.app" - static let logKey = "url_filter_logs" - static let maxEntries = 200 - - static func append(_ message: String) { - NSLog("REBREAK_URL_FILTER %@", message) - guard let defaults = UserDefaults(suiteName: appGroup) else { return } - let timestamp = ISO8601DateFormatter().string(from: Date()) - let entry = "[\(timestamp)] \(message)" - var logs = defaults.stringArray(forKey: logKey) ?? [] - logs.append(entry) - if logs.count > maxEntries { logs.removeFirst(logs.count - maxEntries) } - defaults.set(logs, forKey: logKey) - } -} - -/// Domain-Hashing — IDENTISCH zu `server/utils/domainHash.ts`. -/// Server schickt: SHA-256(salt:domain).first(8) als big-endian UInt64. -enum DomainHasher { - static func normalize(_ host: String) -> String { - var h = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - if h.hasPrefix("https://") { h = String(h.dropFirst(8)) } - else if h.hasPrefix("http://") { h = String(h.dropFirst(7)) } - if let slash = h.firstIndex(of: "/") { h = String(h[.. UInt64 { - let normalized = normalize(host) - let input = salt.isEmpty ? normalized : "\(salt):\(normalized)" - guard let data = input.data(using: .utf8) else { return 0 } - let digest = SHA256.hash(data: data) - // First 8 bytes as big-endian UInt64 (matches Node's readBigUInt64BE) - var result: UInt64 = 0 - for (i, byte) in digest.prefix(8).enumerated() { - result |= UInt64(byte) << UInt64((7 - i) * 8) - } - return result - } -} - -/// Memory-mapped Binary-Hash-Liste. Lädt die Datei lazy beim ersten Zugriff, -/// reloaded bei DarwinNotification "rebreak.blocklist.updated". -final class HashListMmap { - static let shared = HashListMmap() - - private static let appGroup = "group.org.rebreak.app" - private static let filename = "blocklist.bin" - - private var data: Data? - private var hashCount: Int = 0 - private var loadedMtime: Date? - private var lastMtimeCheck: Date = .distantPast - private let queue = DispatchQueue(label: "rebreak.hashlist.reload") - - private init() { - load() - observeUpdates() - } - - private static var fileURL: URL? { - FileManager.default - .containerURL(forSecurityApplicationGroupIdentifier: appGroup)? - .appendingPathComponent(filename) - } - - private static func currentMtime() -> Date? { - guard let url = fileURL, - let attrs = try? FileManager.default.attributesOfItem(atPath: url.path), - let mtime = attrs[.modificationDate] as? Date - else { return nil } - return mtime - } - - /// Polled die mtime von blocklist.bin (max. 1×/sec) und reloaded den mmap - /// wenn sich was geändert hat. Macht uns unabhängig von DarwinNotifications, - /// die verloren gehen können wenn die Extension idle ist. - private func refreshIfChanged() { - let needsReload: Bool = queue.sync { - let now = Date() - if now.timeIntervalSince(lastMtimeCheck) < 1.0 { return false } - lastMtimeCheck = now - return loadedMtime != Self.currentMtime() - } - if needsReload { load() } - } - - private func load() { - queue.sync { - guard let url = Self.fileURL, - FileManager.default.fileExists(atPath: url.path), - let mmapped = try? Data(contentsOf: url, options: .alwaysMapped) - else { - self.data = nil - self.hashCount = 0 - self.loadedMtime = nil - SharedLogStore.append("ℹ️ blocklist.bin not present — block-set ist leer") - return - } - self.data = mmapped - self.hashCount = mmapped.count / 8 - self.loadedMtime = Self.currentMtime() - SharedLogStore.append("📂 blocklist.bin loaded: \(self.hashCount) hashes (\(mmapped.count) bytes)") - } - } - - private func observeUpdates() { - // Container-App feuert DarwinNotification nach Sync. Apple's - // CFNotificationCenterGetDarwinNotifyCenter erlaubt cross-process events - // ohne shared state. - let name = "rebreak.blocklist.updated" as CFString - let center = CFNotificationCenterGetDarwinNotifyCenter() - let observer = Unmanaged.passUnretained(self).toOpaque() - CFNotificationCenterAddObserver( - center, - observer, - { _, observer, _, _, _ in - guard let observer = observer else { return } - let me = Unmanaged.fromOpaque(observer).takeUnretainedValue() - me.load() - }, - name, - nil, - .deliverImmediately - ) - } - - /// Binary-search auf sortierten 64-bit Hashes. O(log n). - func contains(_ hash: UInt64) -> Bool { - // Cheap mtime-check (rate-limited 1×/sec) — fängt verlorene - // DarwinNotifications und stale-mmap nach atomic-replace. - refreshIfChanged() - return queue.sync { - guard let data = self.data, self.hashCount > 0 else { return false } - var lo = 0 - var hi = self.hashCount - 1 - while lo <= hi { - let mid = (lo + hi) / 2 - let offset = mid * 8 - // Read big-endian UInt64 at offset - var value: UInt64 = 0 - data.withUnsafeBytes { ptr in - let base = ptr.baseAddress!.advanced(by: offset) - for i in 0..<8 { - value = (value << 8) | UInt64(base.load(fromByteOffset: i, as: UInt8.self)) - } - } - if value == hash { return true } - if value < hash { lo = mid + 1 } - else { hi = mid - 1 } - } - return false - } - } -} - -class FilterDataProvider: NEFilterDataProvider { - - override func startFilter(completionHandler: @escaping (Error?) -> Void) { - SharedLogStore.append("🚀 startFilter() called") - // Trigger initial load - _ = HashListMmap.shared - completionHandler(nil) - } - - override func stopFilter(with reason: NEProviderStopReason, completionHandler: @escaping () -> Void) { - SharedLogStore.append("🛑 stopFilter() reason=\(reason.rawValue)") - completionHandler() - } - - override func handleNewFlow(_ flow: NEFilterFlow) -> NEFilterNewFlowVerdict { - guard let browserFlow = flow as? NEFilterBrowserFlow, - let url = browserFlow.url, - let host = url.host - else { - return .allow() - } - - let normalizedHost = DomainHasher.normalize(host) - let hashList = HashListMmap.shared - - // Subdomain-Match: für `evil.shop.bet365.com` testen wir - // - evil.shop.bet365.com - // - shop.bet365.com - // - bet365.com - // - com (wird in der Praxis nie matchen — TLD steht nicht in der Liste) - // Max 5 Iterationen (Cap zur Sicherheit). - var current = normalizedHost - var iter = 0 - while iter < 5 { - let h = DomainHasher.hash(current) - if hashList.contains(h) { - SharedLogStore.append("🚫 BLOCKED: \(normalizedHost) (matched suffix: \(current))") - return .drop() - } - // Strippen bis zum nächsten Punkt - guard let dot = current.firstIndex(of: ".") else { break } - current = String(current[current.index(after: dot)...]) - // Stoppen wenn kein Punkt mehr übrig (= TLD) - if !current.contains(".") { break } - iter += 1 - } - - return .allow() - } -} diff --git a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakURLFilter/RebreakURLFilter.entitlements b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakURLFilter/RebreakURLFilter.entitlements deleted file mode 100644 index 5583680..0000000 --- a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakURLFilter/RebreakURLFilter.entitlements +++ /dev/null @@ -1,14 +0,0 @@ - - - - - com.apple.developer.networking.networkextension - - content-filter-provider - - com.apple.security.application-groups - - group.org.rebreak.app - - - diff --git a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakURLFilterExtension/BloomFilter.swift b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakURLFilterExtension/BloomFilter.swift new file mode 100644 index 0000000..3ba321e --- /dev/null +++ b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakURLFilterExtension/BloomFilter.swift @@ -0,0 +1,140 @@ +/* +Vendored from Apple's `SimpleURLFilter` / `FilteringTrafficByURL` sample +(SwiftBloomFilter package) — verbatim. See Apple's LICENSE.txt for licensing. + +Verbatim übernommen, damit der Bloom-Filter-Generator und diese Extension +exakt dasselbe Hashing verwenden wie der iOS-NEURLFilter erwartet. NICHT +verändern — jede Abweichung bricht den Prefilter-Abgleich. + +Abstract: A Swift implementation of a Bloom filter. +*/ + +import Foundation + +public enum BloomFilterError: Error { + case invalidParameters(message: String?) + case encodingIssue(message: String?) +} + +public struct BloomFilter { + + public let itemCount: Int + public let falsePositiveTolerance: Double + public let murmurSeed: UInt32 + public let bitCount: UInt32 + public let byteCount: Int + public let hashCount: UInt32 + public var data: Data? { + Data(bits) + } + private var bits: Data + + public init(items: [String], falsePositiveTolerance: Double = 0.001) throws { + try self.init(items: items, falsePositiveTolerance: falsePositiveTolerance, murmurSeed: arc4random()) + } + + internal init(items: [String], falsePositiveTolerance: Double = 0.001, murmurSeed: UInt32) throws { + let itemCount = items.count + guard itemCount > 0 else { + throw BloomFilterError.invalidParameters(message: "items must not be empty") + } + guard falsePositiveTolerance > 0.0 && falsePositiveTolerance < 1.0 else { + throw BloomFilterError.invalidParameters(message: "falsePositiveTolerance must be greater than zero and less than one") + } + + self.itemCount = itemCount + self.falsePositiveTolerance = falsePositiveTolerance + self.murmurSeed = murmurSeed + + bitCount = Self.calculateBitCount(itemCount: itemCount, falsePositiveTolerance: falsePositiveTolerance) + hashCount = Self.calculateHashCount(itemCount: itemCount, bitCount: bitCount) + + // Create the bit field of an appropriate size. + byteCount = Self.calculateByteCount(bitCount: bitCount) + bits = Data(count: byteCount) + + // Create the filter by inserting the given items. + for item in items { + try insert(value: item) + } + } + + internal static func calculateBitCount(itemCount: Int, falsePositiveTolerance: Double) -> UInt32 { + let itemCountD = Double(itemCount) + return UInt32((ceil(-(itemCountD * log(falsePositiveTolerance) / pow(M_LN2, 2.0))))) + } + + internal static func calculateHashCount(itemCount: Int, bitCount: UInt32) -> UInt32 { + let itemCountD = Double(itemCount) + let bitCountD = Double(bitCount) + return UInt32(ceil((bitCountD / itemCountD) * M_LN2)) + } + + internal static func calculateByteCount(bitCount: UInt32) -> Int { + return Int((bitCount + 7) / 8) + } + + internal mutating func insert(value: String) throws { + guard let data = value.data(using: .utf8) else { + throw BloomFilterError.encodingIssue(message: "Unable to encode string '\(value)' to UTF8") + } + + for count in 0.." + } +} + +extension Data { + + public mutating func setBit(at index: Int, to value: Bool) { + let byteIndex = index / 8 + guard byteIndex >= self.startIndex && byteIndex < self.endIndex else { + return + } + + let bitPosition = index % 8 + + if value { + self[byteIndex] |= (1 << bitPosition) // Set the bit to 1 + } else { + self[byteIndex] &= ~(1 << bitPosition) // Set the bit to 0 + } + } + + func bit(at index: Int) -> Bool { + guard index >= 0 && index < count * 8 else { + return false + } + + let byteIndex = index / 8 + let bitIndex = index % 8 + let mask = 1 << bitIndex + + return (self[byteIndex] & UInt8(mask)) != 0 + } + +} diff --git a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakURLFilterExtension/FNV1aHash.swift b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakURLFilterExtension/FNV1aHash.swift new file mode 100644 index 0000000..2713eb2 --- /dev/null +++ b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakURLFilterExtension/FNV1aHash.swift @@ -0,0 +1,26 @@ +/* +Vendored from Apple's `SimpleURLFilter` / `FilteringTrafficByURL` sample +(SwiftBloomFilter package) — verbatim. See Apple's LICENSE.txt for licensing. +NICHT verändern — siehe BloomFilter.swift. + +Abstract: A Swift implementation of the FNV1a hashing algorithm. +*/ + +// See https://www.ietf.org/archive/id/draft-eastlake-fnv-22.html for more information on this algorithm. + +import Foundation + +extension Data { + + public func fnvHash() -> UInt32 { + var fnvHash: UInt32 = 0x811c9dc5 // Initialize with the 32-bit offset basis. + let fnvPrime: UInt32 = 0x01000193 // 2**24 + 2**8 + 0x93 + + for byte in self { + fnvHash = fnvPrime &* (fnvHash ^ UInt32(byte)) + } + + return fnvHash + } + +} diff --git a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakURLFilter/Info.plist b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakURLFilterExtension/Info.plist similarity index 72% rename from apps/rebreak-native/modules/rebreak-protection/ios/RebreakURLFilter/Info.plist rename to apps/rebreak-native/modules/rebreak-protection/ios/RebreakURLFilterExtension/Info.plist index 0054c1b..e9d62e9 100644 --- a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakURLFilter/Info.plist +++ b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakURLFilterExtension/Info.plist @@ -5,7 +5,7 @@ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName - RebreakURLFilter + ReBreak URL Filter CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier @@ -15,17 +15,15 @@ CFBundleName $(PRODUCT_NAME) CFBundlePackageType - XPC! + $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString 1.0 CFBundleVersion 1 - NSExtension + EXAppExtensionAttributes - NSExtensionPointIdentifier - com.apple.networkextension.filter-data - NSExtensionPrincipalClass - $(PRODUCT_MODULE_NAME).FilterDataProvider + EXExtensionPointIdentifier + com.apple.networkextension.url-filter-control diff --git a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakURLFilterExtension/Murmur3Hash.swift b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakURLFilterExtension/Murmur3Hash.swift new file mode 100644 index 0000000..600c9fa --- /dev/null +++ b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakURLFilterExtension/Murmur3Hash.swift @@ -0,0 +1,95 @@ +/* +Vendored from Apple's `SimpleURLFilter` / `FilteringTrafficByURL` sample +(SwiftBloomFilter package) — verbatim. See Apple's LICENSE.txt for licensing. +NICHT verändern — siehe BloomFilter.swift. + +Abstract: A Swift implementation of the Murmur3A 32-bit hashing algorithm. +*/ + +// For more information, see: +// * https://en.wikipedia.org/wiki/MurmurHash +// * https://github.com/aappleby/smhasher/blob/master/src/MurmurHash3.cpp + +import Foundation + +extension Data { + + public func murmurHash3(seed: UInt32) -> UInt32 { + let length = self.count + var hash1 = seed + let const1: UInt32 = 0xcc9e2d51 + let const2: UInt32 = 0x1b873593 + + let nblocks = length / 4 + + // Handle body. + for index in 0..(at index: Int) -> T { + var block: T = 0 + + for byteIndex in 0...size { + block |= T(self[index * MemoryLayout.size + byteIndex]) << (byteIndex * 8) + } + + return block + } +} + +extension UInt32 { + + func rotateLeft(_ bitCount: UInt32) -> UInt32 { + guard bitCount <= 32 else { + return self + } + return (self << bitCount) | (self >> (32 - bitCount)) + } + + func fmix() -> UInt32 { + var hash = UInt32(self) + hash ^= hash >> 16 + hash &*= 0x85ebca6b + hash ^= hash >> 13 + hash &*= 0xc2b2ae35 + hash ^= hash >> 16 + return hash + } +} diff --git a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakURLFilterExtension/RebreakURLFilterControlProvider.swift b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakURLFilterExtension/RebreakURLFilterControlProvider.swift new file mode 100644 index 0000000..84dca64 --- /dev/null +++ b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakURLFilterExtension/RebreakURLFilterControlProvider.swift @@ -0,0 +1,135 @@ +/* +RebreakURLFilterControlProvider — die NEURLFilter-Control-Provider-Extension. + +Adaptiert aus Apples `SimpleURLFilter`-Sample (`URLFilterControlProvider.swift`). +Unterschied: `SwiftBloomFilter` ist nicht als Package importiert, sondern als +`BloomFilter.swift`/`Murmur3Hash.swift`/`FNV1aHash.swift` direkt in dieses +Extension-Target vendored — daher kein `import`. + +Aufgabe: dem System via `fetchPrefilter()` den lokalen Bloom-Vorfilter liefern. +Das System prüft jede URL zuerst gegen diesen Bloom-Filter; nur bei einem +Treffer folgt ein PIR-Lookup gegen den Server. +*/ + +import Foundation +import NetworkExtension +import OSLog + +enum ProviderFilterError: Error { + case loadError(message: String?) +} + +/// Schreibt in den geteilten App-Group-Log-Store (`url_filter_logs`) — den die +/// Haupt-App via `getProtectionLogs()` / `activateUrlFilter` ausliest. Die +/// Extension läuft als eigener Prozess; das ist unser einziges Fenster rein, +/// um zu sehen ob sie überhaupt geladen wird und was `fetchPrefilter` tut. +enum ExtLog { + static func write(_ msg: String) { + NSLog("REBREAK_EXT %@", msg) + guard let d = UserDefaults(suiteName: "group.org.rebreak.app") else { return } + let line = "[EXT \(ISO8601DateFormatter().string(from: Date()))] \(msg)" + var logs = d.stringArray(forKey: "url_filter_logs") ?? [] + logs.append(line) + if logs.count > 200 { logs.removeFirst(logs.count - 200) } + d.set(logs, forKey: "url_filter_logs") + } +} + +@main +class RebreakURLFilterControlProvider { + + /// Name der Bloom-Filter-Bundle-Resource (`bloom_filter.plist`). + let filterPlistFileName = "bloom_filter" + var filter: BloomFilter? + + private let log = Logger(subsystem: "org.rebreak.app.urlfilter", category: "controlprovider") + + required init() { + ExtLog.write("controlProvider init — Extension-Prozess gestartet") + } + + func loadBloomFilter() throws { + guard let filterFile = Bundle.main.url(forResource: filterPlistFileName, withExtension: "plist") else { + throw ProviderFilterError.loadError( + message: "Bloom-Filter-Plist '\(filterPlistFileName).plist' nicht im Bundle gefunden.") + } + let data = try Data(contentsOf: filterFile) + filter = try PropertyListDecoder().decode(BloomFilter.self, from: data) + } + + /// Tag = Identität des aktuellen Filters. Ändert er sich nicht, kann ein + /// erneuter (teurer) Fetch übersprungen werden. + func filterTag() -> String? { + guard let filter else { return nil } + return filter.hashValue.description + } +} + +extension RebreakURLFilterControlProvider: NEURLFilterControlProvider { + + func start() async throws { + ExtLog.write("start()") + log.log("RebreakURLFilterControlProvider — start") + } + + func stop(reason: NEProviderStopReason) async throws { + ExtLog.write("stop() reason=\(reason.rawValue)") + log.log("RebreakURLFilterControlProvider — stop (reason \(reason.rawValue))") + } + + /// Vom System aufgerufen, um den Bloom-Vorfilter zu holen. Bei unverändertem + /// `existingPrefilterTag` → `nil` (spart den Fetch). Ein geworfener Fehler + /// scheitert die Filter-Installation. + func fetchPrefilter(existingPrefilterTag: String?) async throws -> NEURLFilterPrefilter? { + ExtLog.write("fetchPrefilter ENTER (existingTag=\(existingPrefilterTag ?? "nil"))") + log.log("fetchPrefilter — existingTag: \(existingPrefilterTag ?? "(nil)")") + + // Unverändert seit letztem Fetch → nichts zu tun. + guard existingPrefilterTag == nil || existingPrefilterTag != filterTag() else { + ExtLog.write("fetchPrefilter → nil (Prefilter unverändert)") + log.debug("Prefilter unverändert (tag: '\(existingPrefilterTag ?? "")').") + return nil + } + + if filter == nil { + do { + try loadBloomFilter() + ExtLog.write("bloom_filter.plist geladen + dekodiert") + } catch { + ExtLog.write("❌ loadBloomFilter FEHLER: \(error) → fetchPrefilter → nil") + log.error("Bloom-Filter konnte nicht geladen werden: \(error)") + return nil + } + } + + guard let filter, let filterData = filter.data, let tag = filterTag() else { + ExtLog.write("❌ filter/filterData/tag unerwartet nil → fetchPrefilter → nil") + log.error("Bloom-Filter unerwartet nil.") + return nil + } + + log.debug("Bloom-Filter: \(filter.description)") + + // Daten in eine temporäre Datei schreiben statt in-memory zu übergeben + // (Apple-Empfehlung — vermeidet Memory-Druck bei großen Datensätzen). + let fileURL = FileManager.default.temporaryDirectory.appendingPathComponent("bloomfilterdata") + do { + try filterData.write(to: fileURL) + } catch { + ExtLog.write("❌ filterData.write FEHLER: \(error) → fetchPrefilter → nil") + log.error("Bloom-Bit-Vektor konnte nicht in Temp-Datei geschrieben werden: \(error)") + return nil + } + + let prefilter = NEURLFilterPrefilter( + data: .temporaryFilepath(fileURL), + tag: tag, + bitCount: Int(filter.bitCount), + hashCount: Int(filter.hashCount), + murmurSeed: filter.murmurSeed + ) + ExtLog.write("✅ fetchPrefilter → Prefilter (tag=\(tag) bitCount=\(filter.bitCount) hashCount=\(filter.hashCount) bytes=\(filterData.count))") + log.debug("Prefilter geliefert (tag '\(tag)').") + return prefilter + } +} diff --git a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakURLFilterExtension/RebreakURLFilterExtension.entitlements b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakURLFilterExtension/RebreakURLFilterExtension.entitlements new file mode 100644 index 0000000..61b7678 --- /dev/null +++ b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakURLFilterExtension/RebreakURLFilterExtension.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.developer.networking.networkextension + + url-filter-provider + + com.apple.security.application-groups + + group.org.rebreak.app + + + diff --git a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakURLFilterExtension/bloom_filter.plist b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakURLFilterExtension/bloom_filter.plist new file mode 100644 index 0000000..faeb285 --- /dev/null +++ b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakURLFilterExtension/bloom_filter.plist @@ -0,0 +1,22 @@ + + + + + bitCount + 403 + byteCount + 51 + data + + VGeOsqWinDZqry0DSi23iBLkLf9C3RgD3vY3ytOK1Z3SShzbt6ci94N4iDmbkRfrBV0H + + falsePositiveTolerance + 0.001 + hashCount + 10 + itemCount + 28 + murmurSeed + 489355545 + + diff --git a/apps/rebreak-native/modules/rebreak-protection/src/RebreakProtectionModule.ts b/apps/rebreak-native/modules/rebreak-protection/src/RebreakProtectionModule.ts index 8533dd8..be96940 100644 --- a/apps/rebreak-native/modules/rebreak-protection/src/RebreakProtectionModule.ts +++ b/apps/rebreak-native/modules/rebreak-protection/src/RebreakProtectionModule.ts @@ -14,10 +14,14 @@ import type { declare class RebreakProtectionModule extends NativeModule { /** - * iOS: aktiviert NUR den NEFilter (URL-Filter Layer). - * Triggert iOS-Dialog "Filter-Konfiguration zulassen". + * iOS: aktiviert den NEURLFilter (URL-Filter Layer) via `NEURLFilterManager` + * mit dem PIR-Server. Braucht `pirServerURL` + `pirAuthToken` (aus + * `app.config.ts` extra → Build-Env). iOS 26+. */ - activateUrlFilter(): Promise<{ enabled: boolean; error?: string }>; + activateUrlFilter(opts: { + pirServerURL: string; + pirAuthToken: string; + }): Promise<{ enabled: boolean; error?: string }>; /** * iOS: nach "Nicht erlauben" beim NEFilter-Permission-Dialog hat iOS den diff --git a/apps/rebreak-native/modules/rebreak-protection/tools/GenerateBloomFilter.swift b/apps/rebreak-native/modules/rebreak-protection/tools/GenerateBloomFilter.swift new file mode 100644 index 0000000..b13940b --- /dev/null +++ b/apps/rebreak-native/modules/rebreak-protection/tools/GenerateBloomFilter.swift @@ -0,0 +1,56 @@ +/* +GenerateBloomFilter — erzeugt `bloom_filter.plist` für die NEURLFilter-Extension. + +Nutzt die vendorten Apple-Bloom-Dateien (`BloomFilter.swift`, `Murmur3Hash.swift`, +`FNV1aHash.swift` aus `../ios/RebreakURLFilterExtension/`) — dadurch ist die +Erzeugung byte-identisch zu dem, was die Extension dekodiert und der iOS- +NEURLFilter erwartet. + +Kompilieren + ausführen: + xcrun swiftc \ + ../ios/RebreakURLFilterExtension/BloomFilter.swift \ + ../ios/RebreakURLFilterExtension/Murmur3Hash.swift \ + ../ios/RebreakURLFilterExtension/FNV1aHash.swift \ + GenerateBloomFilter.swift -o /tmp/genbloom + /tmp/genbloom [falsePositiveTolerance] + +``: eine Domain/URL pro Zeile (ASCII-Hostnamen; Leerzeilen werden +übersprungen). Der Output ist ein XML-Property-List der `BloomFilter`-Struct. +*/ + +import Foundation + +@main +struct GenerateBloomFilter { + static func main() throws { + let args = CommandLine.arguments + guard args.count >= 3 else { + FileHandle.standardError.write(Data( + "usage: genbloom [falsePositiveTolerance=0.001]\n".utf8)) + exit(2) + } + let inPath = args[1] + let outPath = args[2] + let fpr = args.count >= 4 ? (Double(args[3]) ?? 0.001) : 0.001 + + let raw = try String(contentsOfFile: inPath, encoding: .utf8) + let domains = raw + .split(whereSeparator: \.isNewline) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + guard !domains.isEmpty else { + FileHandle.standardError.write(Data("error: keine Domains in \(inPath)\n".utf8)) + exit(1) + } + + let filter = try BloomFilter(items: domains, falsePositiveTolerance: fpr) + + let encoder = PropertyListEncoder() + encoder.outputFormat = .xml + let data = try encoder.encode(filter) + try data.write(to: URL(fileURLWithPath: outPath), options: [.atomic]) + + FileHandle.standardError.write(Data("\(filter)\n".utf8)) + print("bloom_filter.plist geschrieben: \(outPath) — \(data.count) Bytes, \(domains.count) Domains, FPR \(fpr)") + } +} diff --git a/apps/rebreak-native/modules/rebreak-protection/tools/spike-domains.txt b/apps/rebreak-native/modules/rebreak-protection/tools/spike-domains.txt new file mode 100644 index 0000000..624f372 --- /dev/null +++ b/apps/rebreak-native/modules/rebreak-protection/tools/spike-domains.txt @@ -0,0 +1,28 @@ +bet365.com +betway.com +pokerstars.com +williamhill.com +bwin.com +tipico.com +888casino.com +unibet.com +ladbrokes.com +betfair.com +casino.com +leovegas.com +mrgreen.com +draftkings.com +fanduel.com +stake.com +roobet.com +betano.com +sportingbet.com +interwetten.com +32red.com +partypoker.com +jackpotcity.com +bet-at-home.com +gametwist.com +pokerstars.eu +n1casino.com +wildz.com diff --git a/apps/rebreak-native/plugins/with-rebreak-protection-ios.js b/apps/rebreak-native/plugins/with-rebreak-protection-ios.js index fbde45c..eb425c5 100644 --- a/apps/rebreak-native/plugins/with-rebreak-protection-ios.js +++ b/apps/rebreak-native/plugins/with-rebreak-protection-ios.js @@ -1,22 +1,21 @@ /* eslint-disable @typescript-eslint/no-var-requires */ /** - * Expo Config-Plugin — wires the NEFilter Extension target into the iOS - * project at prebuild time. + * Expo Config-Plugin — bindet das NEURLFilter-ExtensionKit-Target + * (`RebreakURLFilterExtension`) beim Prebuild ins iOS-Projekt ein. + * + * Ersetzt das frühere NEFilter-Plugin. Erkenntnis aus Apples `SimpleURLFilter`- + * Sample: das Target ist KEIN exotischer Produkttyp — es bleibt das klassische + * `app_extension`. Die ExtensionKit-Natur kommt aus: + * 1. der Info.plist (`EXAppExtensionAttributes` / `EXExtensionPointIdentifier`) + * 2. der Embed-Phase „Embed Foundation Extensions" mit `dstSubfolderSpec = 16` + * (das klassische „Embed App Extensions" wäre 13). * * Was es macht: - * 1) Setzt die Entitlements der Haupt-App (family-controls, network- - * extension, app-groups). - * 2) Kopiert `modules/rebreak-protection/ios/RebreakURLFilter/` nach - * `ios/RebreakURLFilter/` (idempotent). - * 3) Fügt einen neuen Xcode-Target `RebreakURLFilter` (Bundle-ID - * `org.rebreak.app.RebreakURLFilter`) zum Projekt hinzu, mit: - * - Source-File: FilterControlProvider.swift - * - NetworkExtension.framework - * - Embed-App-Extensions Build-Phase im Haupt-Target - * - Entitlements via `RebreakURLFilter.entitlements` + * 1) Haupt-App-Entitlements (url-filter-provider, family-controls, app-groups) + * 2) kopiert `modules/rebreak-protection/ios/RebreakURLFilterExtension/` → `ios/` + * 3) fügt das Xcode-Target `RebreakURLFilterExtension` hinzu * - * Wird aus app.config.ts via `plugins: ['./plugins/with-rebreak-protection-ios']` - * registriert. Idempotent — kann beliebig oft via `expo prebuild` laufen. + * Idempotent — kann beliebig oft via `expo prebuild` laufen. */ const fs = require('fs'); @@ -29,8 +28,9 @@ const { } = require('@expo/config-plugins'); const APP_GROUP = 'group.org.rebreak.app'; -const TARGET_NAME = 'RebreakURLFilter'; -const EXT_BUNDLE_SUFFIX = 'RebreakURLFilter'; +const TARGET_NAME = 'RebreakURLFilterExtension'; +const EXT_BUNDLE_SUFFIX = 'URLFilterExtension'; // → org.rebreak.app.URLFilterExtension +const DEVELOPMENT_TEAM = '84BQ7MTFYK'; const MODULE_DIR = path.join( __dirname, '..', @@ -40,20 +40,28 @@ const MODULE_DIR = path.join( TARGET_NAME, ); -// ─── 1) Haupt-App Entitlements ────────────────────────────────────────────── +// Swift-Quellen (Apple-Bloom vendored + unser Control-Provider). +const SWIFT_SOURCES = [ + 'BloomFilter.swift', + 'Murmur3Hash.swift', + 'FNV1aHash.swift', + 'RebreakURLFilterControlProvider.swift', +]; +// Bundle-Resources. +const RESOURCES = ['bloom_filter.plist']; + +// ─── 1) Haupt-App-Entitlements ────────────────────────────────────────────── function withMainAppEntitlements(config) { return withEntitlementsPlist(config, (cfg) => { + // NEURLFilter-Entitlement (ersetzt das alte content-filter-provider). + // Dev-Builds laufen ohne Apple-Freigabe; TestFlight/Store brauchen die + // genehmigte Capability (Developer-Portal → Capability Requests). cfg.modResults['com.apple.developer.networking.networkextension'] = [ - 'content-filter-provider', + 'url-filter-provider', ]; - // Family Controls: das DISTRIBUTION-Entitlement liegt noch bei Apple zur Freigabe - // → AppStore/TestFlight-Builds (Profile preview/production) dürfen es NICHT claimen, - // sonst kippt EAS' Provisioning ("doesn't support the Family Controls capability"). - // Für development-Builds (Dev-Entitlement, internal distribution) schalten wir's per - // Env-Flag wieder ein (eas.json → development.env.REBREAK_ENABLE_FAMILY_CONTROLS="1"), - // damit denyAppRemoval / ManagedSettings im Dev-Client testbar ist. Sobald Apple das - // Distribution-Entitlement freigibt: Flag auch in preview/production env setzen + buildNumber bump. + // Family Controls: Distribution-Entitlement — nur wenn das Env-Flag gesetzt + // ist (development-Builds bzw. nach Apple-Freigabe auch preview/production). if (process.env.REBREAK_ENABLE_FAMILY_CONTROLS === '1') { cfg.modResults['com.apple.developer.family-controls'] = true; } @@ -71,8 +79,7 @@ function withCopyExtensionSources(config) { return withDangerousMod(config, [ 'ios', async (cfg) => { - const platformProjectRoot = cfg.modRequest.platformProjectRoot; - const dest = path.join(platformProjectRoot, TARGET_NAME); + const dest = path.join(cfg.modRequest.platformProjectRoot, TARGET_NAME); if (!fs.existsSync(MODULE_DIR)) { throw new Error( `[with-rebreak-protection-ios] Extension source dir missing: ${MODULE_DIR}`, @@ -80,9 +87,7 @@ function withCopyExtensionSources(config) { } if (!fs.existsSync(dest)) fs.mkdirSync(dest, { recursive: true }); for (const file of fs.readdirSync(MODULE_DIR)) { - const srcFile = path.join(MODULE_DIR, file); - const destFile = path.join(dest, file); - fs.copyFileSync(srcFile, destFile); + fs.copyFileSync(path.join(MODULE_DIR, file), path.join(dest, file)); } return cfg; }, @@ -95,7 +100,7 @@ function withExtensionTarget(config) { return withXcodeProject(config, async (cfg) => { const proj = cfg.modResults; - // Idempotenz: skip wenn Target schon angelegt + // Idempotenz: skip wenn Target schon angelegt. if (proj.pbxTargetByName(TARGET_NAME)) { return cfg; } @@ -106,31 +111,25 @@ function withExtensionTarget(config) { } const extBundleId = `${mainBundleId}.${EXT_BUNDLE_SUFFIX}`; - // ── Target anlegen (Type: app_extension) ── + // ── Target anlegen (Produkttyp app_extension — wie Apples SimpleURLFilter) ── const target = proj.addTarget(TARGET_NAME, 'app_extension', TARGET_NAME, extBundleId); - // ── Build-Phasen: Sources + Frameworks + Resources ── - proj.addBuildPhase( - ['FilterControlProvider.swift'], - 'PBXSourcesBuildPhase', - 'Sources', - target.uuid, - ); + // ── Build-Phasen ── + proj.addBuildPhase(SWIFT_SOURCES, 'PBXSourcesBuildPhase', 'Sources', target.uuid); + proj.addBuildPhase(RESOURCES, 'PBXResourcesBuildPhase', 'Resources', target.uuid); proj.addBuildPhase( ['NetworkExtension.framework'], 'PBXFrameworksBuildPhase', 'Frameworks', target.uuid, ); - // Info.plist gehört NICHT als Resource — wird via INFOPLIST_FILE referenziert. - // ── PBXGroup für die Sources ── + // ── PBXGroup für die Extension-Dateien ── const pbxGroup = proj.addPbxGroup( - ['FilterControlProvider.swift', 'Info.plist', 'RebreakURLFilter.entitlements'], + [...SWIFT_SOURCES, ...RESOURCES, 'Info.plist', `${TARGET_NAME}.entitlements`], TARGET_NAME, TARGET_NAME, ); - // Group ans CustomTemplate-Group hängen damit sie im Project Navigator erscheint const groups = proj.hash.project.objects.PBXGroup; Object.keys(groups).forEach((key) => { if ( @@ -141,7 +140,7 @@ function withExtensionTarget(config) { } }); - // ── Build-Settings auf der Target-Configuration anpassen ── + // ── Build-Settings auf der Target-Configuration ── const configurations = proj.pbxXCBuildConfigurationSection(); Object.keys(configurations) .filter((k) => typeof configurations[k] === 'object') @@ -154,41 +153,51 @@ function withExtensionTarget(config) { ) { buildSettingsObj.INFOPLIST_FILE = `"${TARGET_NAME}/Info.plist"`; buildSettingsObj.CODE_SIGN_ENTITLEMENTS = `"${TARGET_NAME}/${TARGET_NAME}.entitlements"`; - buildSettingsObj.IPHONEOS_DEPLOYMENT_TARGET = '15.1'; - buildSettingsObj.SWIFT_VERSION = '5.9'; + // NEURLFilter ist iOS 26+. Die Extension lädt nur dort — höheres + // Deployment-Target als die Haupt-App (iOS 16+) ist korrekt. + buildSettingsObj.IPHONEOS_DEPLOYMENT_TARGET = '26.0'; + buildSettingsObj.SWIFT_VERSION = '5.0'; buildSettingsObj.TARGETED_DEVICE_FAMILY = '"1,2"'; buildSettingsObj.CODE_SIGN_STYLE = 'Automatic'; - // EAS managed credentials setzen DEVELOPMENT_TEAM nur auf der Main-App. - // Die Extension ist ein eigenes Target → muss expliziten Team-Wert haben, - // sonst: "Signing for 'RebreakURLFilter' requires a development team". - buildSettingsObj.DEVELOPMENT_TEAM = '84BQ7MTFYK'; + // EAS managed credentials setzen DEVELOPMENT_TEAM nur auf der Main-App + // → Extension braucht expliziten Team-Wert. + buildSettingsObj.DEVELOPMENT_TEAM = DEVELOPMENT_TEAM; } }); - // ── Embed App Extensions Build-Phase im Haupt-Target ── - // Suche nach existierender CopyFilesBuildPhase mit Comment "Embed App Extensions" + // ── Embed-Phase auf ExtensionKit umstellen ── + // proj.addTarget() hat bereits eine „Copy Files"-PBXCopyFilesBuildPhase + // (dstSubfolderSpec 13 = PlugIns/) im Haupt-Target angelegt, die unsere + // .appex einbettet. iOS' Installer (MIPluginKitBundle) behandelt aber + // ALLES in PlugIns/ als klassische PluginKit-Extension und verlangt ein + // NSExtension-Dict im Info.plist — das ExtensionKit-Extensions NICHT haben + // ("AppexBundleMissingNSExtensionDict", MIInstallerError 39). ExtensionKit- + // Extensions MÜSSEN nach Extensions/. Also die bestehende Phase umbiegen: + // dstSubfolderSpec 16 + dstPath $(EXTENSIONS_FOLDER_PATH) — exakt wie + // Apples SimpleURLFilter.xcodeproj. KEINE zweite Phase anlegen (sonst wird + // die .appex doppelt eingebettet). const mainTargetUuid = proj.getFirstTarget().uuid; - const buildPhases = proj.hash.project.objects.PBXNativeTarget[mainTargetUuid].buildPhases; const copyFilesPhases = proj.hash.project.objects.PBXCopyFilesBuildPhase || {}; - const hasEmbedPhase = Object.keys(copyFilesPhases).some((key) => { + let embedFixed = false; + Object.keys(copyFilesPhases).forEach((key) => { const phase = copyFilesPhases[key]; - return ( - typeof phase === 'object' && - phase.dstSubfolderSpec === 13 && // 13 = PluginsAndFrameworks (App Extensions) - buildPhases.some((bp) => bp.value === key) + if (typeof phase !== 'object') return; + const embedsOurAppex = (phase.files || []).some( + (f) => typeof f?.comment === 'string' && f.comment.includes(`${TARGET_NAME}.appex`), ); + if (!embedsOurAppex) return; + phase.name = '"Embed Foundation Extensions"'; + phase.dstSubfolderSpec = 16; + phase.dstPath = '"$(EXTENSIONS_FOLDER_PATH)"'; + embedFixed = true; }); - if (!hasEmbedPhase) { - proj.addBuildPhase( - [`${TARGET_NAME}.appex`], - 'PBXCopyFilesBuildPhase', - 'Embed App Extensions', - mainTargetUuid, - 'app_extension', // dstSubfolderSpec=13 + if (!embedFixed) { + throw new Error( + '[with-rebreak-protection-ios] Embed-CopyFiles-Phase für die .appex nicht gefunden', ); } - // ── Target-Dependency: Haupt-App muss Extension vor sich bauen ── + // ── Target-Dependency: Haupt-App baut die Extension vorher ── proj.addTargetDependency(mainTargetUuid, [target.uuid]); return cfg; diff --git a/backend/scripts/generate-pir-input.ts b/backend/scripts/generate-pir-input.ts new file mode 100644 index 0000000..7d7c231 --- /dev/null +++ b/backend/scripts/generate-pir-input.ts @@ -0,0 +1,193 @@ +#!/usr/bin/env npx tsx +/** + * generate-pir-input.ts + * + * Erzeugt input.txtpb für den iOS-NEURLFilter-PIR-Server. + * + * Format (gegen Apples pir_database.proto verifiziert): + * rows: { keyword: "bet365.com" value: "1" } + * rows: { keyword: "poker.de" value: "1" } + * ... + * Eine Zeile pro Domain. KEIN Top-Level-KeywordDatabase-Wrapper. + * + * Modi: + * Default (--source db): liest BlocklistDomain aus PostgreSQL via Prisma. + * DATABASE_URL muss gesetzt sein (via `infisical run`). + * --source hagezi: Fallback — fetcht HaGeZi gambling-blocklist direkt + * ohne DB-Zugriff. + * + * Optionen: + * --source Default: db + * --output Default: ./input.txtpb (relativ zum CWD) + * --dry-run Nur zählen, nicht schreiben + * + * Aufruf: + * infisical run -- npx tsx scripts/generate-pir-input.ts + * infisical run -- npx tsx scripts/generate-pir-input.ts --output /srv/pir-server/data/input.txtpb + * npx tsx scripts/generate-pir-input.ts --source hagezi --output /tmp/test.txtpb + */ + +import { writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { PrismaClient } from "../server/generated/prisma/client.js"; +import { PrismaPg } from "@prisma/adapter-pg"; + +// ─── Normalisierung (identisch zu server/utils/domainHash.ts) ──────────────── + +function normalizeDomain(input: string): string { + return input + .trim() + .toLowerCase() + .replace(/^https?:\/\//, "") + .replace(/\/.*$/, "") + .replace(/^www\./, ""); +} + +// ─── CLI-Argument-Parsing ───────────────────────────────────────────────────── + +const args = process.argv.slice(2); + +function getFlag(name: string): string | undefined { + const idx = args.indexOf(name); + if (idx === -1) return undefined; + return args[idx + 1]; +} + +const source = (getFlag("--source") ?? "db") as "db" | "hagezi"; +const outputArg = getFlag("--output") ?? "input.txtpb"; +const outputPath = resolve(process.cwd(), outputArg); +const dryRun = args.includes("--dry-run"); + +if (source !== "db" && source !== "hagezi") { + console.error(`Unbekannte Source "${source}". Erlaubt: db, hagezi`); + process.exit(1); +} + +// ─── Domain-Fetch ───────────────────────────────────────────────────────────── + +async function fetchFromDb(): Promise { + const dbUrl = process.env.DATABASE_URL; + if (!dbUrl) { + console.error( + "DATABASE_URL nicht gesetzt. Script via `infisical run --` aufrufen.", + ); + process.exit(1); + } + + console.log("Verbinde zu PostgreSQL..."); + const adapter = new PrismaPg({ connectionString: dbUrl }); + const prisma = new PrismaClient({ adapter, log: ["error"] }); + + try { + const count = await prisma.blocklistDomain.count({ + where: { isActive: true }, + }); + console.log(`Lade ${count.toLocaleString("de-DE")} aktive Domains aus DB...`); + + const rows = await prisma.blocklistDomain.findMany({ + where: { isActive: true }, + select: { domain: true }, + }); + + return rows.map((r) => r.domain); + } finally { + await prisma.$disconnect(); + } +} + +const HAGEZI_URL = + "https://raw.githubusercontent.com/hagezi/dns-blocklists/main/adblock/gambling.txt"; + +async function fetchFromHagezi(): Promise { + console.log(`Fetche HaGeZi gambling-blocklist von ${HAGEZI_URL}...`); + const res = await fetch(HAGEZI_URL); + if (!res.ok) { + throw new Error(`HaGeZi-Fetch fehlgeschlagen: ${res.status} ${res.statusText}`); + } + const raw = await res.text(); + + return raw + .split("\n") + .map((l) => l.trim()) + .filter((l) => l.startsWith("||") && l.endsWith("^")) + .map((l) => l.slice(2, -1).toLowerCase()) + .filter((d) => d.length > 0 && !d.includes("/") && d.includes(".")); +} + +// ─── Normalisieren + Dedupe + Sort ──────────────────────────────────────────── + +function processdomains(raw: string[]): string[] { + const seen = new Set(); + for (const d of raw) { + const n = normalizeDomain(d); + if (n.length > 0 && n.includes(".")) { + seen.add(n); + } + } + return Array.from(seen).sort(); +} + +// ─── .txtpb-Formatierung ────────────────────────────────────────────────────── + +function formatTxtpb(domains: string[]): string { + // Jede Domain → eine Zeile: rows: { keyword: "domain.com" value: "1" } + // kein Wrapper, kein BOM, UTF-8 + const lines = domains.map((d) => `rows: { keyword: "${d}" value: "1" }`); + return lines.join("\n") + "\n"; +} + +// ─── Main ───────────────────────────────────────────────────────────────────── + +async function main() { + console.log(`PIR input.txtpb Generator`); + console.log(`Source : ${source}`); + console.log(`Output : ${outputPath}`); + if (dryRun) console.log(`Mode : dry-run (kein Schreibvorgang)`); + console.log(""); + + // 1. Domains laden + const raw = + source === "db" ? await fetchFromDb() : await fetchFromHagezi(); + + console.log(`Rohe Domains geladen: ${raw.length.toLocaleString("de-DE")}`); + + // 2. Normalisieren + Dedupe + Sort + const domains = processdomains(raw); + console.log( + `Nach Normalisierung + Dedupe: ${domains.length.toLocaleString("de-DE")} Domains`, + ); + + // 3. Vorschau + console.log("\nBeispiel (erste 5 Zeilen):"); + domains.slice(0, 5).forEach((d) => + console.log(` rows: { keyword: "${d}" value: "1" }`), + ); + console.log(" ..."); + console.log("\nBeispiel (letzte 3 Zeilen):"); + domains.slice(-3).forEach((d) => + console.log(` rows: { keyword: "${d}" value: "1" }`), + ); + + if (dryRun) { + console.log("\ndry-run: Datei wird NICHT geschrieben."); + return; + } + + // 4. Schreiben + const content = formatTxtpb(domains); + const bytes = Buffer.byteLength(content, "utf8"); + writeFileSync(outputPath, content, { encoding: "utf8" }); + + console.log( + `\nGeschrieben: ${outputPath}`, + ); + console.log( + `Groesse : ${(bytes / 1024 / 1024).toFixed(2)} MB (${bytes.toLocaleString("de-DE")} Bytes)`, + ); + console.log(`Domains : ${domains.length.toLocaleString("de-DE")}`); +} + +main().catch((err) => { + console.error("Fehler:", err); + process.exit(1); +}); diff --git a/ops/pir-server/Dockerfile b/ops/pir-server/Dockerfile new file mode 100644 index 0000000..5ad435f --- /dev/null +++ b/ops/pir-server/Dockerfile @@ -0,0 +1,47 @@ +# PIR Service — Multi-Stage Docker Build +# +# Stage 1 (builder): Swift 6.2-noble — kompiliert PIRProcessDatabase + PIRService +# Stage 2 (runtime): swift:6.2-noble-slim — minimales Runtime-Image mit Swift-Libs +# +# Build-Kontext: /srv/pir-build/ (enthält pir-service-example/ + swift-homomorphic-encryption/) +# Anmerkung: swift:6.2-noble-slim enthält alle Swift-Runtime-Libs (~300 MB), +# ist aber OHNE Swift-Toolchain (kein swiftc, kein spm). + +# ── Stage 1: Builder ──────────────────────────────────────────────────────── +FROM swift:6.2-noble AS builder + +WORKDIR /build + +# swift-homomorphic-encryption → PIRProcessDatabase bauen +COPY swift-homomorphic-encryption/ ./swift-homomorphic-encryption/ +RUN cd swift-homomorphic-encryption && \ + swift build -c release --product PIRProcessDatabase 2>&1 && \ + cp .build/release/PIRProcessDatabase /usr/local/bin/PIRProcessDatabase + +# pir-service-example → PIRService + ConstructDatabase bauen +COPY pir-service-example/ ./pir-service-example/ +RUN cd pir-service-example && \ + swift build -c release --product PIRService 2>&1 && \ + swift build -c release --product ConstructDatabase 2>&1 && \ + cp .build/release/PIRService /usr/local/bin/PIRService && \ + cp .build/release/ConstructDatabase /usr/local/bin/ConstructDatabase + +# ── Stage 2: Runtime ───────────────────────────────────────────────────────── +# swift:6.2-noble enthält alle Swift-Runtime-Libs — kein manueller Lib-Transfer nötig +FROM swift:6.2-noble AS runtime + +# Nur die Binaries aus dem Builder-Stage kopieren +COPY --from=builder /usr/local/bin/PIRService /usr/local/bin/PIRService +COPY --from=builder /usr/local/bin/PIRProcessDatabase /usr/local/bin/PIRProcessDatabase +COPY --from=builder /usr/local/bin/ConstructDatabase /usr/local/bin/ConstructDatabase + +# Verzeichnisse: /data = DB-Artifacts, /config = service-config.json +RUN mkdir -p /data /config + +WORKDIR /data + +EXPOSE 8090 + +# service-config.json wird via Volume gemountet (/config/service-config.json) +# Daten-Artifacts werden via Volume gemountet (/data/) +CMD ["PIRService", "--hostname", "0.0.0.0", "--port", "8090", "/config/service-config.json"] diff --git a/ops/pir-server/README.md b/ops/pir-server/README.md new file mode 100644 index 0000000..cd198cb --- /dev/null +++ b/ops/pir-server/README.md @@ -0,0 +1,167 @@ +# PIR-Server — iOS NEURLFilter Backend + +**Status: LIVE (Staging)** — `https://pir.staging.rebreak.org` · Stand 2026-05-21 + +Dieses Dokument beschreibt den PIR-Server vollständig, damit eine andere Session +ihn verstehen, betreiben und weiterentwickeln kann. + +--- + +## 1. Was & Warum + +Rebreaks iOS-Gambling-Blocker nutzt Apples **`NEURLFilter`** (iOS 26). NEURLFilter +filtert URLs **systemweit** (Safari + alle WebKit-Browser + `URLSession`) und +verlangt dafür **zwingend** einen vom Vendor gehosteten **PIR-Server** (Private +Information Retrieval) — `NEURLFilterManager.setConfiguration()` akzeptiert keine +Konfiguration ohne `pirServerURL`. Dieser Server ist dieser PIR-Server. + +**Ablauf zur Laufzeit:** +1. Das Gerät prüft die URL gegen einen lokalen **Bloom-Filter** (Prefilter, in der + iOS-App-Extension `NEURLFilterControlProvider`). +2. Bei einem Bloom-Treffer → **PIR-Lookup** gegen diesen Server, geroutet über + Apples Oblivious-HTTP-Relay. +3. Per Homomorphic Encryption sieht der Server die geprüfte URL **nie im Klartext**. + +**DSGVO/DiGA-Vorteil:** Kein Beteiligter sieht URL + Identität zusammen — Rebreak +erfährt das Surfverhalten der User nie. Datenminimierung per Krypto-Konstruktion. + +--- + +## 2. Architektur-Bausteine + +Basiert auf Apples Open-Source-Vorlagen: +- `apple/pir-service-example` → `PIRService` (Swift/Hummingbird-HTTP-Server, bedient + PIR-Queries + eingebauten PrivacyPass-Issuer) + `ConstructDatabase`. +- `apple/swift-homomorphic-encryption` → `PIRProcessDatabase` (wandelt `input.txtpb` + in PIR-optimierte Shards). + +--- + +## 3. Deployment-Stand (Hetzner `rebreak-server`, 49.13.55.22) + +| Komponente | Ort / Wert | +|---|---| +| Docker-Container | `pir-service-staging`, `restart=unless-stopped`, `127.0.0.1:8090->8090` | +| Docker-Image | `pir-service-staging:latest` (Multi-Stage Swift-Build, ~5 GB) | +| Build-Repos | `/srv/pir-build/{pir-service-example,swift-homomorphic-encryption}` + `Dockerfile` | +| Deploy-Verzeichnis | `/srv/pir-server/` (`build-and-deploy.sh`, `gen-test-input.sh`, `config/`, `data/`) | +| PIR-DB-Artifacts | `/srv/pir-server/data/` — `input.txtpb` + `url-0..3.bin` + `url-0..3.params.txtpb` (4 Shards) | +| Service-Config | `/srv/pir-server/config/service-config.json` (Token inline — NICHT im Repo) | +| nginx | `/etc/nginx/sites-{available,enabled}/pir-staging.rebreak.org`, `server_name pir.staging.rebreak.org` | +| TLS | Let's Encrypt für `pir.staging.rebreak.org`, läuft 2026-08-18 ab, Auto-Renew aktiv | +| Public-URL | `https://pir.staging.rebreak.org` | + +--- + +## 4. Konfiguration + +**`service-config.json`** (auf dem Server; Token nicht im Repo → siehe `service-config.template.json`): +- usecase-Name: **`org.rebreak.app.url.filtering`** — muss EXAKT mit der iOS-`setConfiguration` matchen, sonst HTTP 404. +- `shardCount`: **4** (215k Domains / 50k pro Shard). +- `users[].tokens`: enthält den `PIR_AUTH_TOKEN`. + +**`PIR_AUTH_TOKEN`** — liegt in **Infisical**, Env `staging`, Key `PIR_AUTH_TOKEN`, +Project-ID `14b11b35-ef59-4b8a-a16b-398f0cc3ad93`. App-weit halten (NICHT pro-User — +ein per-User-Token würde die PIR-Nicht-Verkettbarkeit schwächen). + +**`process-config.json`** — Parameter für `PIRProcessDatabase`: +`entryCountPerShard 50000`, `rlweParameters n_4096_logq_27_28_28_logt_5`, +`databaseType keyword`. + +**`PIR_ISSUER_REQUEST_URI`** — Env-Var am Container (`docker run -e ...`), gesetzt von +`build-and-deploy.sh` auf `https://pir.staging.rebreak.org/issue`. **Zwingend absolut** +(RFC 9578 §6): das Apple-`pir-service-example` advertised von Haus aus nur ein +**relatives** `/issue` im Issuer-Directory (`PrivacyPassController.swift`, hardcodiert) — +der NEURLFilter-Client kann damit keinen Privacy-Pass-Token holen und scheitert mit +`serverSetupIncomplete`. Behoben durch `patches/0001-absolute-issuer-request-uri.patch`, +das die URL aus dieser Env-Var liest. + +--- + +## 5. iOS-Integrationswerte (`NEURLFilterManager.setConfiguration`) + +| Parameter | Wert | +|---|---| +| `pirServerURL` | `https://pir.staging.rebreak.org` | +| `pirPrivacyPassIssuerURL` | `https://pir.staging.rebreak.org` (PIRService bedient beides) | +| `pirAuthenticationToken` | Infisical `staging/PIR_AUTH_TOKEN` | +| usecase-Name | `org.rebreak.app.url.filtering` | +| `controlProviderBundleIdentifier` | `org.rebreak.app.URLFilterExtension` (geplant — beim iOS-Scaffolding final festlegen) | + +--- + +## 6. Daten-Pipeline (Blocklist → PIR-DB) + +1. `backend/scripts/generate-pir-input.ts` erzeugt `input.txtpb` aus ~215k Gambling-Domains. + - **DB-Modus** (Default): liest die `BlocklistDomain`-Tabelle — braucht `infisical run` für `DATABASE_URL`. + - **HaGeZi-Modus** (`--source hagezi`): direkt von HaGeZis `gambling.txt`, kein DB/Infisical + nötig. **Aktuell verwendet**, weil `infisical` nicht auf dem Dev-Mac liegt. Äquivalent — + die `BlocklistDomain`-Tabelle wird ohnehin aus genau dieser HaGeZi-Liste gespeist. + - Output-Format: pro Domain eine Zeile `rows: { keyword: "" value: "1" }`. +2. `input.txtpb` → per `scp` nach `/srv/pir-server/data/input.txtpb`. +3. `PIRProcessDatabase /data/process-config.json` (im Container) → `url-N.bin` + `url-N.params.txtpb`. + +--- + +## 7. Betrieb / Runbook + +**Health-Check:** +```bash +curl -s -o /dev/null -w '%{http_code}\n' \ + https://pir.staging.rebreak.org/.well-known/private-token-issuer-directory # → 200 +ssh rebreak-server 'docker ps | grep pir-service-staging' +``` + +**Blocklist aktualisieren:** +1. Neue `input.txtpb` generieren (`backend/scripts/generate-pir-input.ts`) + nach + `/srv/pir-server/data/input.txtpb` scp'en. +2. Alte Shards löschen: `ssh rebreak-server 'rm /srv/pir-server/data/url-*.bin /srv/pir-server/data/url-*.params.txtpb'`. +3. `PIRProcessDatabase` neu laufen lassen (siehe `build-and-deploy.sh` Step 6) — erzeugt neue Shards. +4. `shardCount` in `service-config.json` prüfen + Container neu starten. + +**Komplett neu bauen/deployen:** +```bash +ssh rebreak-server 'bash /srv/pir-server/build-and-deploy.sh' +``` +Swift-Build ~20–30 Min, RAM-intensiv (~2–3 GB → Swap nötig, off-peak bauen). + +--- + +## 8. Bekannte Issues / TODOs + +- ✅ **`build-and-deploy.sh` shardCount-Bug (BEHOBEN 2026-05-21):** Step 3 leitet den + `shardCount` jetzt aus den vorhandenen `url-*.bin`-Artifacts ab statt ihn fix auf `1` + zu setzen — kein Regress mehr von 4 Shards auf 1 bei Re-Runs. +- ✅ **Relatives `issuer-request-uri` (BEHOBEN 2026-05-21):** siehe §4 `PIR_ISSUER_REQUEST_URI`. + War die Ursache für `serverSetupIncomplete` auf der iOS-Seite. Fix via + `patches/0001-absolute-issuer-request-uri.patch`, von `build-and-deploy.sh` Step 3b + automatisch auf den `pir-service-example`-Clone angewandt. +- Deploy-Skripte liegen sowohl auf dem Server (`/srv/pir-server/`, `/srv/pir-build/`) + als auch hier im Repo. Bei Änderungen synchron halten — oder das Repo als + Single-Source-of-Truth etablieren (empfohlen). +- **App-Store-Distribution:** braucht zusätzlich Apples OHTTP-Onboarding-Formular, + ein OHTTP-Gateway und einen DNS-TXT-Record `apple-url-filter=org.rebreak.app`. + Für TestFlight/Dev NICHT nötig. → Phase 2. +- **Entitlement** `com.apple.developer.networking.networkextension.url-filter-provider`: + Dev-Builds ausgenommen; vor TestFlight/Store über den „Capability Requests"-Tab im + Apple-Developer-Portal (Certificates, Identifiers & Profiles → Identifiers) beantragen. +- Apple zu `pir-service-example`: „example service, not for production" — + Produktionshärtung (persistente PrivacyPass-Keys statt ephemerer, Monitoring, + Ressourcen-Tuning) ist ein eigener späterer Schritt. + +--- + +## 9. Dateien in diesem Verzeichnis + +| Datei | Zweck | +|---|---| +| `README.md` | dieses Dokument | +| `Dockerfile` | Multi-Stage Swift-Build (Kopie von `/srv/pir-build/Dockerfile`) | +| `build-and-deploy.sh` | Deploy-Script (Kopie von `/srv/pir-server/`) | +| `gen-test-input.sh` | Test-DB-Generator (10 Domains, Fallback) | +| `process-config.json` | `PIRProcessDatabase`-Parameter | +| `service-config.template.json` | Service-Config-Vorlage (Token-Platzhalter) | +| `nginx-pir.staging.rebreak.org.conf` | nginx-Reverse-Proxy-Config (Referenz) | +| `patches/*.patch` | Quell-Patches für `pir-service-example`, von `build-and-deploy.sh` Step 3b angewandt | + +Der `input.txtpb`-Generator selbst liegt bei `backend/scripts/generate-pir-input.ts`. diff --git a/ops/pir-server/apple-dts-neurlfilter-report.md b/ops/pir-server/apple-dts-neurlfilter-report.md new file mode 100644 index 0000000..08fc483 --- /dev/null +++ b/ops/pir-server/apple-dts-neurlfilter-report.md @@ -0,0 +1,153 @@ +# Apple DTS / Feedback — NEURLFilter `serverSetupIncomplete` on a development-signed build + +**Zweck:** Vorlage für einen Apple Developer Technical Support Incident (TSI) bzw. einen +Beitrag im Developer-Forum-Thread 791352 ("Getting a basic URL Filter to work"). +Stand: 2026-05-21. Einfach Inhalt anpassen/kopieren und einreichen. + +--- + +## Summary + +`NEURLFilterManager.saveToPreferences()` fails on a **development-signed** build +installed directly via Xcode (`expo run:ios --device`). `NEURLFilterManager.status` +never reaches `.running`; `lastDisconnectError` is `.serverSetupIncomplete`. + +The device performs the PIR use-case registration successfully, but then issues a +`POST /config` to our PIR server **without a Privacy Pass token** and treats the +resulting `401` as fatal. The device **never** performs Privacy Pass token issuance +(no request to the issuer directory, `/token-key-for-user-token`, or `/issue` ever +reaches our server). The underlying CipherML error is logged redacted +(`com.apple.CipherML Code=1800 "Error details were logged and redacted."`). + +We would like to know **why the device does not acquire a Privacy Pass token** before +calling `/config`, and what "server setup" the framework considers incomplete. + +## Environment + +- Device: iPhone, iOS 26.5 +- Build: development-signed, installed directly from Xcode toolchain (`expo run:ios --device`) + - Verified: `get-task-allow = true`, `aps-environment = development` + - Provisioning profile: "iOS Team Provisioning Profile" (Development, ProvisionedDevices present) + - Entitlements: `com.apple.developer.networking.networkextension` includes `url-filter-provider`; + `com.apple.developer.family-controls = true` +- App bundle id: `org.rebreak.app`; control-provider extension: `org.rebreak.app.URLFilterExtension` +- PIR server: self-hosted, `apple/pir-service-example` (`PIRService`), public HTTPS endpoint + +## Configuration used + +```swift +try manager.setConfiguration( + pirServerURL: URL(string: "https://")!, // no trailing slash + pirPrivacyPassIssuerURL: URL(string: "https://")!, // same host serves both + pirAuthenticationToken: "", + controlProviderBundleIdentifier: "org.rebreak.app.URLFilterExtension") +manager.isEnabled = true +manager.shouldFailClosed = true +try await manager.saveToPreferences() +``` + +The configuration the system actually received (from `neagent` logs) is correct: + +``` +urlFilter = { + Enabled = YES + FailClosed = YES + AppBundleIdentifier = org.rebreak.app + ControlProviderBundleIdentifier = org.rebreak.app.URLFilterExtension + pirServerURL = https:// + pirPrivacyPassIssuerURL = https:// + AuthenticationToken = + pirPrivacyProxyFailOpen = NO + pirSkipRegistration = NO +} +``` + +## What works (verified) + +- The control-provider extension launches; `fetchPrefilter()` returns a valid + `NEURLFilterPrefilter` (bloom filter loads). +- `url-filter-provider` entitlement is validated OK by `neagent`. +- PIR use-case **registration succeeds**: `NEPIRChecker … "completed registration + with PIR for Group org.rebreak.app usecase org.rebreak.app.url.filtering"`. +- Server side independently verified healthy: the issuer directory is served, and + `GET /token-key-for-user-token` with `Authorization: Bearer ` returns + HTTP 200 with a valid SPKI public key. + +## The failure (device logs, `com.apple.cipherml` subsystem un-redacted via NE debug profile) + +``` +ciphermld Request to fetchConfigs has started for useCases ['org.rebreak.app.url.filtering'] +ciphermld error Failed to fetch configs. URL: https:///config Status Code: 401 +ciphermld error queryStatus(for:options:) threw an error: + CipherML.CipherMLError.serverError("{\"error\":{\"message\":\"No private token\"}}") +neagent requestStatusForClientConfig… XPC complete, error: com.apple.CipherML Code=1800 + "Error details were logged and redacted." +neagent NEPIRChecker … PIR status returned error: com.apple.CipherML Code=1100 + "Unable to query status due to errors…" (NSUnderlyingError Code=1800) +neagent NEAgentURLFilterExtension startURLFilter … Failed to startFilter: + NEMembershipCheckerErrorDomain Code=3 +→ NEURLFilterManager.lastDisconnectError = .serverSetupIncomplete +``` + +Our server's access log confirms: the device only ever sends `POST /config` +(User-Agent `com.apple.ciphermld/1.0 iOS/26.5`), always returning `401 "No private +token"`. Over many hours of retries we observe **zero** requests to +`/.well-known/private-token-issuer-directory`, `/token-key-for-user-token`, or +`/issue` from the device. The device never attempts Privacy Pass token issuance. + +The whole `saveToPreferences()` → failure happens in well under a second. + +## What we have ruled out + +- **Server**: registration succeeds; `/token-key-for-user-token` returns 200 for the + configured user token; issuer directory served correctly. +- **App configuration**: the `setConfiguration` values arrive intact at `neagent`. +- **Signing**: confirmed a genuine Development build (`get-task-allow = true`, + Development provisioning profile) installed directly via Xcode — per the + documentation, such builds should not require the Oblivious HTTP private relay. +- **URL formatting**: `pirServerURL` / `pirPrivacyPassIssuerURL` have no trailing + slash, no custom port, HTTPS, host-only (no path). + +## Additional diagnostic tests (2026-05-21) + +- **`shouldFailClosed = false`**: `lastDisconnectError` changes from + `.serverSetupIncomplete` to `.unknown`, and the device then makes **zero** PIR + network requests at all — `saveToPreferences()` rejects the configuration as + `.configurationInvalid` before any network activity. With `shouldFailClosed = true` + the device at least sends the tokenless `POST /config`. +- **Spec-compliant user token**: replacing the user token with a 40-character + base64-alphabet string (no hyphens, decodes cleanly) — server accepts it + (`/token-key-for-user-token` → 200) — changed nothing on the device side. + +- **WWDC2025-sample parity**: additionally setting `manager.localizedDescription` + and `manager.prefilterFetchInterval` (so the configuration matches Apple's + WWDC2025 sample code exactly) — no change. `saveToPreferences()` still fails with + `.configurationInvalid`; `lastDisconnectError` is now `nil` (the rejection is now + synchronous, before any server connection attempt). + +Server, app configuration, and code signing are therefore all confirmed not to be +the cause. Every public `NEURLFilterManager` parameter is set and matches Apple's +own WWDC2025 sample, yet `saveToPreferences()` rejects the configuration as +`.configurationInvalid`. + +## Questions for Apple + +1. Why does the device call `POST /config` **without** first performing Privacy Pass + token issuance (issuer directory → `/token-key-for-user-token` → `/issue`)? +2. What does `serverSetupIncomplete` / `CipherML Code 1800` concretely indicate here, + given registration succeeded and `/token-key-for-user-token` works? The detailed + error is redacted even with the Network Extension logging profile installed — + which profile un-redacts `com.apple.CipherML` errors? +3. For a **development build installed via Xcode**, is a provisioned privacy proxy / + OHTTP onboarding nonetheless required for Privacy Pass token issuance? The + `pirPrivacyProxyFailOpen = NO` flag in the resolved config suggests the privacy + proxy path is mandatory — is that expected for dev builds? +4. Is there a token-pool warm-up delay (background token fetch) that must elapse + before `saveToPreferences()` can succeed, and if so how should the app handle it? + +## Reproduction + +Standard `apple/pir-service-example` `PIRService` as the PIR + Privacy Pass issuer, +a development-signed app with an `NEURLFilterControlProvider` extension, and the +`setConfiguration` call shown above. Happy to provide a sysdiagnose and full +`neagent`/`ciphermld` logs. diff --git a/ops/pir-server/build-and-deploy.sh b/ops/pir-server/build-and-deploy.sh new file mode 100644 index 0000000..cb922f0 --- /dev/null +++ b/ops/pir-server/build-and-deploy.sh @@ -0,0 +1,221 @@ +#!/usr/bin/env bash +# build-and-deploy.sh — PIR-Server auf Hetzner bauen und deployen +# +# Voraussetzungen (auf dem Server): +# - /srv/pir-build/pir-service-example/ → geklont +# - /srv/pir-build/swift-homomorphic-encryption/ → geklont +# - /srv/pir-server/data/ → existiert +# - /srv/pir-server/config/ → existiert +# - /etc/environment enthält INFISICAL_CLIENT_ID / INFISICAL_CLIENT_SECRET +# +# Was dieses Script macht: +# 1. Infisical-Token holen (Universal Auth) +# 2. PIR_AUTH_TOKEN aus Infisical staging lesen +# 3. service-config.json mit realem Token nach /srv/pir-server/config/ schreiben +# 3b. Quell-Patches auf pir-service-example anwenden (patches/*.patch) +# 4. Docker-Image bauen (Multi-Stage, ~20-30 Min wegen Swift-Compile) +# 5. Test-input.txtpb generieren falls keine echte DB vorhanden +# 6. PIRProcessDatabase ausführen (innerhalb Container) +# 7. Container starten / neu starten (inkl. PIR_ISSUER_REQUEST_URI env) + +set -euo pipefail + +BUILD_DIR="/srv/pir-build" +DATA_DIR="/srv/pir-server/data" +CONFIG_DIR="/srv/pir-server/config" +IMAGE_NAME="pir-service-staging" +CONTAINER_NAME="pir-service-staging" +PORT="8090" +INFISICAL_PROJECT_ID="14b11b35-ef59-4b8a-a16b-398f0cc3ad93" +PROCESS_CONFIG_SRC="$(dirname "$0")/process-config.json" +PATCH_DIR="$(dirname "$0")/patches" +PIR_SERVICE_SRC="${BUILD_DIR}/pir-service-example" +# Public-URL des PIR-Servers. issuer-request-uri MUSS absolut sein (RFC 9578 §6), +# sonst kann der NEURLFilter-Client keinen Privacy-Pass-Token holen. +PIR_PUBLIC_URL="https://pir.staging.rebreak.org" +PIR_ISSUER_REQUEST_URI="${PIR_PUBLIC_URL}/issue" + +log() { echo "[pir-deploy] $(date '+%H:%M:%S') $*"; } +log_err() { echo "[pir-deploy:err] $(date '+%H:%M:%S') $*" >&2; } + +log "=== PIR-Server Deploy gestartet ===" + +# 0. Environment laden +if [[ -f /etc/environment ]]; then + export $(grep -v "^#" /etc/environment | grep -v "^$" | xargs) +fi + +# 1. Infisical-Token holen +log "Step 1: Infisical Universal-Auth Login..." +INFISICAL_TOKEN=$(infisical login \ + --method=universal-auth \ + --client-id="${INFISICAL_CLIENT_ID}" \ + --client-secret="${INFISICAL_CLIENT_SECRET}" \ + --silent --plain 2>/dev/null) + +if [[ -z "$INFISICAL_TOKEN" ]]; then + log_err "Infisical login fehlgeschlagen" + exit 1 +fi +log "Infisical Login ok" + +# 2. PIR_AUTH_TOKEN holen +log "Step 2: PIR_AUTH_TOKEN aus Infisical (staging) holen..." +PIR_AUTH_TOKEN=$(infisical secrets get PIR_AUTH_TOKEN \ + --env=staging \ + --projectId="${INFISICAL_PROJECT_ID}" \ + --token="${INFISICAL_TOKEN}" \ + --plain 2>/dev/null) + +if [[ -z "$PIR_AUTH_TOKEN" ]]; then + log_err "PIR_AUTH_TOKEN nicht in Infisical gefunden (env=staging, project=${INFISICAL_PROJECT_ID})" + log_err "Bitte via Infisical-Dashboard anlegen: Key=PIR_AUTH_TOKEN, Env=staging" + exit 1 +fi +log "PIR_AUTH_TOKEN geladen (${#PIR_AUTH_TOKEN} Zeichen)" + +# 3. service-config.json schreiben (Token inline, nicht in Datei committed) +log "Step 3: service-config.json nach ${CONFIG_DIR}/ schreiben..." + +# Shard-Count aus vorhandenen url-N.bin-Artifacts ableiten. +# Bugfix: vorher fix `SHARD_COUNT=1` — bei Re-Runs ohne PIRProcessDatabase (Step 6 +# wird übersprungen wenn Artifacts existieren) regressierte das einen 4-Shard-Server +# fälschlich auf shardCount=1. +SHARD_COUNT=$(ls "${DATA_DIR}"/url-*.bin 2>/dev/null | wc -l | tr -d ' ') +[[ "$SHARD_COUNT" -lt 1 ]] && SHARD_COUNT=1 +log "Shard-Count aus ${DATA_DIR}: ${SHARD_COUNT}" + +cat > "${CONFIG_DIR}/service-config.json" << EOF +{ + "users": [ + { + "tier": "tier1", + "tokens": ["${PIR_AUTH_TOKEN}"] + } + ], + "usecases": [ + { + "name": "org.rebreak.app.url.filtering", + "fileStem": "/data/url", + "shardCount": ${SHARD_COUNT} + } + ] +} +EOF +log "service-config.json geschrieben" + +# 3b. Quell-Patches auf pir-service-example anwenden (idempotent) +log "Step 3b: Quell-Patches anwenden..." +if [[ -d "$PATCH_DIR" ]] && compgen -G "${PATCH_DIR}/*.patch" > /dev/null; then + # Clone auf sauberen Stand bringen, dann alle Patches anwenden + git -C "$PIR_SERVICE_SRC" checkout -- . 2>/dev/null || true + for p in "${PATCH_DIR}"/*.patch; do + log " apply $(basename "$p")" + git -C "$PIR_SERVICE_SRC" apply "$p" + done +else + log " keine Patches gefunden — übersprungen" +fi + +# 4. Docker-Image bauen +log "Step 4: Docker-Image bauen (Kontext: ${BUILD_DIR})..." +log "WARNUNG: Swift-Build dauert ~20-30 Min, RAM-Nutzung hoch (~2-3 GB). Swap wird benötigt." + +docker build \ + -t "${IMAGE_NAME}:latest" \ + -f "$(dirname "$0")/Dockerfile" \ + "${BUILD_DIR}" + +log "Docker-Image gebaut: ${IMAGE_NAME}:latest" + +# 5. Test-input.txtpb generieren falls keine echte DB +log "Step 5: Input-DB prüfen..." +bash "$(dirname "$0")/gen-test-input.sh" "${DATA_DIR}/input.txtpb" + +# 6. PIRProcessDatabase ausführen (im Container, mounted /data) +log "Step 6: PIRProcessDatabase ausführen..." + +# Prüfe ob DB-Artifacts schon vorhanden (von vorherigem Run) +if [[ -f "${DATA_DIR}/url-0.bin" ]] && [[ -f "${DATA_DIR}/url-0.params.txtpb" ]]; then + log "DB-Artifacts bereits vorhanden (url-0.bin + url-0.params.txtpb) — überspringe PIRProcessDatabase" + log "Für Rebuild: rm ${DATA_DIR}/url-0.bin ${DATA_DIR}/url-0.params.txtpb && re-run" +else + # process-config.json in data-dir kopieren (PIRProcessDatabase braucht absolute Pfade) + cp "${PROCESS_CONFIG_SRC}" "${DATA_DIR}/process-config.json" + + docker run --rm \ + -v "${DATA_DIR}:/data" \ + "${IMAGE_NAME}:latest" \ + PIRProcessDatabase /data/process-config.json + + log "PIRProcessDatabase abgeschlossen" + + # Shard-Count aus Output ermitteln + SHARD_COUNT=$(ls "${DATA_DIR}"/url-*.bin 2>/dev/null | wc -l) + log "Erkannte Shards: ${SHARD_COUNT}" + + # service-config.json mit korrektem shardCount neu schreiben + if [[ "$SHARD_COUNT" -gt 1 ]]; then + log "Mehr als 1 Shard — service-config.json updaten..." + cat > "${CONFIG_DIR}/service-config.json" << EOF +{ + "users": [ + { + "tier": "tier1", + "tokens": ["${PIR_AUTH_TOKEN}"] + } + ], + "usecases": [ + { + "name": "org.rebreak.app.url.filtering", + "fileStem": "/data/url", + "shardCount": ${SHARD_COUNT} + } + ] +} +EOF + log "service-config.json mit shardCount=${SHARD_COUNT} aktualisiert" + fi +fi + +# 7. Container starten / neu starten +log "Step 7: Container starten..." + +# Alten Container stoppen falls vorhanden +if docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then + log "Stoppe alten Container ${CONTAINER_NAME}..." + docker stop "${CONTAINER_NAME}" 2>/dev/null || true + docker rm "${CONTAINER_NAME}" 2>/dev/null || true +fi + +docker run -d \ + --name "${CONTAINER_NAME}" \ + --restart unless-stopped \ + -p "127.0.0.1:${PORT}:8090" \ + -v "${DATA_DIR}:/data:ro" \ + -v "${CONFIG_DIR}:/config:ro" \ + -e "PIR_ISSUER_REQUEST_URI=${PIR_ISSUER_REQUEST_URI}" \ + "${IMAGE_NAME}:latest" + +log "Container ${CONTAINER_NAME} gestartet auf 127.0.0.1:${PORT}" + +# 8. Health-Check +log "Step 8: Health-Check (warte 5s auf Start)..." +sleep 5 + +# Issuer-Directory muss erreichbar sein UND eine ABSOLUTE issuer-request-uri liefern. +# (PrivateToken-Auth lässt sich ohne echten Privacy-Pass-Token nicht testen — der +# PIR_AUTH_TOKEN ist nur der User-Token für /issue, nicht der /config-Token.) +DIRECTORY=$(curl -s "http://127.0.0.1:${PORT}/.well-known/private-token-issuer-directory" 2>/dev/null || echo "") + +if echo "$DIRECTORY" | grep -q '"issuer-request-uri":"https://'; then + log "Health-Check OK — Issuer-Directory liefert absolute issuer-request-uri" + log "=== Deploy erfolgreich abgeschlossen ===" +else + log_err "Health-Check fehlgeschlagen — Issuer-Directory unerwartet:" + log_err "$DIRECTORY" + log_err "Container-Logs:" + docker logs "${CONTAINER_NAME}" --tail 30 + log_err "Deploy FEHLGESCHLAGEN — manueller Check erforderlich" + exit 1 +fi diff --git a/ops/pir-server/gen-test-input.sh b/ops/pir-server/gen-test-input.sh new file mode 100644 index 0000000..1cdb042 --- /dev/null +++ b/ops/pir-server/gen-test-input.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# gen-test-input.sh — generiert input.txtpb mit Test-Domains falls keine echte DB vorhanden +# Ablage: /srv/pir-server/data/input.txtpb +# +# Wird aufgerufen wenn /srv/pir-server/data/input.txtpb NICHT existiert. +# Reale Daten kommen via rebreak-backend → /srv/pir-server/data/input.txtpb + +set -euo pipefail + +OUTPUT="${1:-/srv/pir-server/data/input.txtpb}" + +if [[ -f "$OUTPUT" ]]; then + echo "[gen-test-input] $OUTPUT bereits vorhanden — überspringe Test-DB-Generierung" + exit 0 +fi + +echo "[gen-test-input] Erstelle Test-input.txtpb → $OUTPUT" + +cat > "$OUTPUT" << 'TXTPB' +rows: [{ + keyword: "www.apple.com/url-filter-test", + value: "1" + }, + { + keyword: "casino.example.com", + value: "1" + }, + { + keyword: "slots.example.com", + value: "1" + }, + { + keyword: "gambling.example.org", + value: "1" + }, + { + keyword: "poker.example.net", + value: "1" + }, + { + keyword: "bet365.example.com", + value: "1" + }, + { + keyword: "jackpot.example.com", + value: "1" + }, + { + keyword: "sportbet.example.com", + value: "1" + }, + { + keyword: "onlinecasino.example.de", + value: "1" + }, + { + keyword: "freespins.example.com", + value: "1" + }] +TXTPB + +echo "[gen-test-input] Test-DB mit 10 Domains erstellt: $OUTPUT" +echo "[gen-test-input] ACHTUNG: Das ist eine Test-DB — echte Daten via rebreak-backend bereitstellen!" diff --git a/ops/pir-server/nginx-pir.staging.rebreak.org.conf b/ops/pir-server/nginx-pir.staging.rebreak.org.conf new file mode 100644 index 0000000..0c1f787 --- /dev/null +++ b/ops/pir-server/nginx-pir.staging.rebreak.org.conf @@ -0,0 +1,50 @@ +# nginx Reverse-Proxy für pir.staging.rebreak.org → 127.0.0.1:8090 (PIR-Service) +# +# Referenz-Config. Auf dem Server liegt sie unter +# /etc/nginx/sites-available/pir-staging.rebreak.org +# verlinkt nach sites-enabled/. Die `listen 443 ssl` + ssl_certificate-Zeilen +# werden von certbot automatisch eingefügt (`certbot --nginx -d pir.staging.rebreak.org`). +# +# PIR-Requests können groß sein (EvaluationKey bis ~10 MiB) → client_max_body_size 12m. + +server { + server_name pir.staging.rebreak.org; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + proxy_pass http://127.0.0.1:8090; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + client_max_body_size 12m; + proxy_read_timeout 60s; + proxy_send_timeout 60s; + } + + # ── von certbot verwaltet ── + listen 443 ssl; + listen [::]:443 ssl; + ssl_certificate /etc/letsencrypt/live/pir.staging.rebreak.org/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/pir.staging.rebreak.org/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; +} + +# HTTP → HTTPS Redirect (von certbot verwaltet) +server { + listen 80; + listen [::]:80; + server_name pir.staging.rebreak.org; + + if ($host = pir.staging.rebreak.org) { + return 301 https://$host$request_uri; + } + + return 404; +} diff --git a/ops/pir-server/patches/0001-absolute-issuer-request-uri.patch b/ops/pir-server/patches/0001-absolute-issuer-request-uri.patch new file mode 100644 index 0000000..bbdf859 --- /dev/null +++ b/ops/pir-server/patches/0001-absolute-issuer-request-uri.patch @@ -0,0 +1,16 @@ +diff --git a/Sources/PIRService/Controllers/PrivacyPassController.swift b/Sources/PIRService/Controllers/PrivacyPassController.swift +index 816a6bb..3f71176 100644 +--- a/Sources/PIRService/Controllers/PrivacyPassController.swift ++++ b/Sources/PIRService/Controllers/PrivacyPassController.swift +@@ -43,8 +43,10 @@ struct PrivacyPassController { + tokenKeyBase64Url: spki.base64URLEncodedString(), + notBefore: nil) + } ++ // RFC 9578 §6: issuer-request-uri MUST be absolute — NEURLFilter rejects relative URIs. ++ // Configurable via PIR_ISSUER_REQUEST_URI env var (set in docker run). + // swiftlint:disable:next force_unwrapping +- let issuerRequestUri = URL(string: "/issue")! ++ let issuerRequestUri = URL(string: ProcessInfo.processInfo.environment["PIR_ISSUER_REQUEST_URI"] ?? "/issue")! + return TokenIssuerDirectory(issuerRequestUri: issuerRequestUri, tokenKeys: tokenKeys) + } + diff --git a/ops/pir-server/process-config.json b/ops/pir-server/process-config.json new file mode 100644 index 0000000..6b82961 --- /dev/null +++ b/ops/pir-server/process-config.json @@ -0,0 +1,11 @@ +{ + "inputDatabase": "/data/input.txtpb", + "outputDatabase": "/data/url-SHARD_ID.bin", + "outputPirParameters": "/data/url-SHARD_ID.params.txtpb", + "rlweParameters": "n_4096_logq_27_28_28_logt_5", + "sharding": { + "entryCountPerShard": 50000 + }, + "trialsPerShard": 1, + "databaseType": "keyword" +} diff --git a/ops/pir-server/service-config.template.json b/ops/pir-server/service-config.template.json new file mode 100644 index 0000000..8651d17 --- /dev/null +++ b/ops/pir-server/service-config.template.json @@ -0,0 +1,17 @@ +{ + "_comment": "VORLAGE — der echte PIR_AUTH_TOKEN kommt aus Infisical (staging/PIR_AUTH_TOKEN) und wird von build-and-deploy.sh zur Laufzeit inline geschrieben. NIEMALS den echten Token committen. usecases[].name MUSS exakt mit der iOS-NEURLFilterManager.setConfiguration matchen. shardCount entspricht der Anzahl url-N.bin-Dateien (aktuell 4).", + "issuerRequestUri": null, + "users": [ + { + "tier": "tier1", + "tokens": ["${PIR_AUTH_TOKEN}"] + } + ], + "usecases": [ + { + "name": "org.rebreak.app.url.filtering", + "fileStem": "/data/url", + "shardCount": 4 + } + ] +}