feat(protection): iOS NEURLFilter-Spike + PIR-Server-Ops

NEURLFilter-Stack (iOS 26): Extension RebreakURLFilter -> URLFilterExtension
umbenannt, url-filter-provider-Entitlement, Bloom-Prefilter-Extension,
PIR-Client-Config (pirServerURL/pirAuthToken via Build-Env).
PIR-Server-Ops unter ops/pir-server/ (Dockerfile, build-and-deploy, Patches,
DTS-Report). backend/scripts/generate-pir-input.ts erzeugt die PIR-Datenbank.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-05-21 18:09:42 +02:00
parent c6604f02df
commit 29bbf23405
26 changed files with 1691 additions and 364 deletions

View File

@ -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 ||

View File

@ -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<string, unknown>;
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).

View File

@ -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

View File

@ -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[..<slash]) }
if h.hasPrefix("www.") { h = String(h.dropFirst(4)) }
return h
}
static func hash(_ host: String, salt: String = "") -> 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<HashListMmap>.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()
}
}

View File

@ -1,14 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.networking.networkextension</key>
<array>
<string>content-filter-provider</string>
</array>
<key>com.apple.security.application-groups</key>
<array>
<string>group.org.rebreak.app</string>
</array>
</dict>
</plist>

View File

@ -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..<hashCount {
let fnv = data.fnvHash()
let murmur = data.murmurHash3(seed: murmurSeed)
let index = Int((fnv &+ count &* murmur) % bitCount)
bits.setBit(at: index, to: true)
}
}
}
extension BloomFilter: Codable, Hashable {
enum CodingKeys: String, CodingKey {
case itemCount
case falsePositiveTolerance
case murmurSeed
case bitCount
case byteCount
case hashCount
case bits = "data"
}
}
extension BloomFilter: CustomStringConvertible {
public var description: String {
return "<BloomFilter itemCount: \(itemCount), falsePositiveTolerance: \(falsePositiveTolerance), murmurSeed: \(murmurSeed), bitCount: \(bitCount), byteCount: \(byteCount), hashCount: \(hashCount) data bytes: \(bits.count) >"
}
}
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
}
}

View File

@ -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
}
}

View File

@ -5,7 +5,7 @@
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>RebreakURLFilter</string>
<string>ReBreak URL Filter</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
@ -15,17 +15,15 @@
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>XPC!</string>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>NSExtension</key>
<key>EXAppExtensionAttributes</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.networkextension.filter-data</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).FilterDataProvider</string>
<key>EXExtensionPointIdentifier</key>
<string>com.apple.networkextension.url-filter-control</string>
</dict>
</dict>
</plist>

View File

@ -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..<nblocks {
var block: UInt32 = self.block(at: index)
// Hash the block.
block &*= const1
block = block.rotateLeft(15)
block &*= const2
hash1 ^= block
hash1 = hash1.rotateLeft(13)
hash1 = hash1 &* 5 &+ 0xe6546b64
}
// Handle remaining bytes.
var block: UInt32 = 0
let blockedLength = length / 4 * 4
let remainingLength = length - blockedLength
switch remainingLength {
case 3:
block ^= UInt32(self[blockedLength + 2]) << 16
fallthrough
case 2:
block ^= UInt32(self[blockedLength + 1]) << 8
fallthrough
case 1:
block ^= UInt32(self[blockedLength])
block &*= const1
block = block.rotateLeft(15)
block &*= const2
hash1 ^= block
default:
break
}
// Perform finalization.
hash1 ^= UInt32(length)
hash1 = hash1.fmix()
return hash1
}
func block<T: FixedWidthInteger>(at index: Int) -> T {
var block: T = 0
for byteIndex in 0..<MemoryLayout<T>.size {
block |= T(self[index * MemoryLayout<T>.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
}
}

View File

@ -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
}
}

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.networking.networkextension</key>
<array>
<string>url-filter-provider</string>
</array>
<key>com.apple.security.application-groups</key>
<array>
<string>group.org.rebreak.app</string>
</array>
</dict>
</plist>

View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>bitCount</key>
<integer>403</integer>
<key>byteCount</key>
<integer>51</integer>
<key>data</key>
<data>
VGeOsqWinDZqry0DSi23iBLkLf9C3RgD3vY3ytOK1Z3SShzbt6ci94N4iDmbkRfrBV0H
</data>
<key>falsePositiveTolerance</key>
<real>0.001</real>
<key>hashCount</key>
<integer>10</integer>
<key>itemCount</key>
<integer>28</integer>
<key>murmurSeed</key>
<integer>489355545</integer>
</dict>
</plist>

View File

@ -14,10 +14,14 @@ import type {
declare class RebreakProtectionModule extends NativeModule<RebreakProtectionEvents> {
/**
* 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

View File

@ -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 <domains.txt> <out.plist> [falsePositiveTolerance]
`<domains.txt>`: 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 <domains.txt> <out.plist> [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)")
}
}

View File

@ -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

View File

@ -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;

View File

@ -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 <db|hagezi> Default: db
* --output <pfad> 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<string[]> {
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<string[]> {
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<string>();
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);
});

47
ops/pir-server/Dockerfile Normal file
View File

@ -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"]

167
ops/pir-server/README.md Normal file
View File

@ -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: "<domain>" 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 ~2030 Min, RAM-intensiv (~23 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`.

View File

@ -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://<our-host>")!, // no trailing slash
pirPrivacyPassIssuerURL: URL(string: "https://<our-host>")!, // same host serves both
pirAuthenticationToken: "<user token>",
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://<our-host>
pirPrivacyPassIssuerURL = https://<our-host>
AuthenticationToken = <user token>
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 <user token>` 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://<our-host>/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.

View File

@ -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

View File

@ -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!"

View File

@ -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;
}

View File

@ -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<UserAuthenticator: UserTokenAuthenticator> {
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)
}

View File

@ -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"
}

View File

@ -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
}
]
}