From e3d5e1dd18b4e396f6291da9d565c8f2dba9a3c5 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Wed, 5 Aug 2026 09:36:11 +0200 Subject: [PATCH] Serve the blocklist over DoT and hand the policy to the app as an asset Two gaps stopped the unrooted path working end to end. The phone had no way to read the focus policy, and the blocklist was only reachable as a hosts file or over plain UDP/53 on the LAN -- while the rooted enforcer actively rejected every DoT connection, so a self-hosted resolver could not have worked even if one existed. focus_owner now loads a generated policy.json instead of hardcoding anything, so config.sh stays the single source of truth across Python, shell and Kotlin. The committed asset is rendered with --redact-home and a test fails if home coordinates ever appear in it. An unknown schema_version is rejected outright: silently accepting a newer schema means enforcing a policy the app has misread, and a misread allowlist is one that blocks the dialer. dns_enforcer.sh rejected ALL port 853 traffic and pinned Private DNS off, every 20 seconds. That is right when DoT is a bypass channel and exactly wrong when DoT is the enforcement channel, which is what an unrooted phone needs -- it has no hosts file to fall back to. With DNS_TRUSTED_DOT_HOST set it now ACCEPTs the trusted resolver before the blanket REJECT, and pins Private DNS to that hostname so the user cannot pick an unfiltered one. Rule order is load-bearing: iptables takes the first match, so an ACCEPT appended after the REJECT would never be reached. expected_rule_count() counts the new rules, because a mismatch there makes chain_intact() rebuild the chain every 20s -- a silent fork storm that also drops the connection each time. With the variable empty the behaviour is byte-identical, so the rooted phone is untouched until this is deployed. setup_dot_resolver.sh terminates DoT with stunnel rather than Caddy: DoT is a raw TLS stream and stock caddy:2.8 has no layer4 module, so using Caddy would have meant an xcaddy rebuild of a container serving five live sites. It reuses the certificate Caddy already renews, so nothing competes for port 80. It binds to the WireGuard address, not 0.0.0.0 -- a public DoT endpoint is an open resolver, and over the existing tunnel it needs no port-forward, no firewall rule and no rate limiting. Verified: example.com resolves, pornhub.com returns 0.0.0.0, TLS 1.3 against the LE cert, and both the LAN and public addresses refuse. The Device Owner checklist is planning only. Checking the device rather than the config settled the one open risk: Google Wallet is not installed and no NFC payment default is set, so the unconfirmable tap-to-pay question does not apply here. The same check found that Aegis, MS Authenticator, Oracle IDM and PKO IKO are absent -- they are in config.sh's whitelist, which describes the rooted phone, not this one. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AwepyL5Lxs5QKbKkMpxxxL --- docs/device-owner-wipe-checklist.md | 133 ++++++++ ...-resolver-and-policy-asset-2026-08-05.json | 25 ++ ...-resolver-and-policy-asset-2026-08-05.json | 80 +++++ focus_owner/assets/policy.json | 208 +++++++++++++ focus_owner/lib/main.dart | 89 +++++- focus_owner/lib/policy.dart | 201 ++++++++++++ focus_owner/pubspec.yaml | 11 +- focus_owner/test/policy_test.dart | 189 ++++++++++++ focus_owner/test/widget_test.dart | 47 ++- .../single_use/features/setup_dot_resolver.sh | 290 ++++++++++++++++++ phone_focus_mode/config.sh | 16 + phone_focus_mode/dns_enforcer.sh | 84 ++++- python_pkg/focus_policy/__main__.py | 80 +++++ python_pkg/focus_policy/tests/test_main.py | 110 +++++++ 14 files changed, 1547 insertions(+), 16 deletions(-) create mode 100644 docs/device-owner-wipe-checklist.md create mode 100644 docs/superpowers/contracts/dot-resolver-and-policy-asset-2026-08-05.json create mode 100644 docs/superpowers/evidence/dot-resolver-and-policy-asset-2026-08-05.json create mode 100644 focus_owner/assets/policy.json create mode 100644 focus_owner/lib/policy.dart create mode 100644 focus_owner/test/policy_test.dart create mode 100755 linux_configuration/scripts/single_use/features/setup_dot_resolver.sh create mode 100644 python_pkg/focus_policy/__main__.py create mode 100644 python_pkg/focus_policy/tests/test_main.py diff --git a/docs/device-owner-wipe-checklist.md b/docs/device-owner-wipe-checklist.md new file mode 100644 index 00000000..20902724 --- /dev/null +++ b/docs/device-owner-wipe-checklist.md @@ -0,0 +1,133 @@ +# Device Owner provisioning: pre-wipe checklist (Pixel 6a, 23181JEGR08034) + +Status: **planning only. No wipe has been performed and none is scheduled.** + +Device Owner is the only unrooted tier that meets the "cannot be shut down from +the phone" requirement. `dpm set-device-owner` refuses to run while any account +exists on the device, and this phone has six, so provisioning costs a factory +reset. This is what that would take. + +## The Google Wallet blocker does not apply to this device + +The one genuinely unresolved risk was whether tap-to-pay survives +self-provisioned Device Owner. Research found no mechanism by which DO alone +disables Wallet — the documented failure causes are root, custom ROM, unlocked +bootloader and uncertified devices, none of which DO touches — but also no +first-hand report from anyone who self-provisioned bare DO on a recent Pixel +and then confirmed a successful contactless payment. + +**Checked on the device instead of arguing about it:** + +``` +pm list packages -f com.google.android.apps.walletnfcrel -> not installed +settings get secure nfc_payment_default_component -> null +``` + +Google Wallet is **not installed** and **no NFC payment default is set**. There +is no tap-to-pay to lose. The blocker is moot here; it would only return if you +later start using Wallet, and by then the DO app's release path gives you a +no-wipe exit to test with. + +## What is actually on this phone + +The earlier checklist was drafted from `phone_focus_mode/config.sh`, which +describes the **rooted** phone. Verified against the Pixel by `pm list packages`, +these are absent: **Aegis, Microsoft Authenticator, Oracle IDM Authenticator, +PKO IKO, Google Wallet.** + +Present and relevant: + +| App | Package | Survives Google backup? | +|---|---|---| +| KeePassDX | `com.kunzisoft.keepass.libre` | File-based — you own the `.kdbx` | +| Signal | `org.thoughtcrime.securesms` | **No** — needs its own backup | +| mBank | `pl.mbank` | No — device re-pairing | +| Revolut | `com.revolut.revolut` | No — re-login + possible re-verification | +| mObywatel | `pl.nask.mobywatel` | No — full re-activation | + +**No dedicated TOTP app is installed**, so the usual "authenticator seeds are +destroyed by the wipe" trap does not apply — unless your TOTP secrets live +inside the KeePassDX database, which is likely. If so, the `.kdbx` file *is* +your seed backup, and step 2 covers it. + +## Phase A — break the circular dependency first + +Re-verifying a bank typically needs an SMS **and** a second factor; if the +second factor died with the wipe, the loop closes on you. Do these before +touching anything. + +1. **Print Google backup codes for both accounts** (`321krzychu@gmail.com`, + `krzysztofrudnicki0@gmail.com`) — myaccount.google.com → Security → 2-Step + Verification → Backup codes. If your only second factor is an on-device + prompt or passkey, the wipe locks you out of the accounts that gate + everything else. +2. **Copy the KeePassDX database off the device, and its keyfile if you use + one.** This is the highest-value artifact on the phone. + ```bash + adb -s 23181JEGR08034 shell 'find /sdcard -iname "*.kdbx" 2>/dev/null' + adb -s 23181JEGR08034 pull ~/phone-backup/ + ``` +3. **Write down on paper**: mBank customer ID + password, Revolut passcode, + Signal PIN, KeePassDX master password, both Google passwords. +4. **Confirm the SIM and number will keep working** — nearly every + re-activation below sends an SMS. + +## Phase B — export + +5. **Signal**: Settings → Backups → On-device backups. **Record the 30-digit + passphrase on paper** — it is not your Signal PIN. + ```bash + adb -s 23181JEGR08034 pull /sdcard/Signal/Backups/ ~/phone-backup/signal/ + ``` + Registration Lock: forgetting the PIN locks you out for up to 7 days. +6. **Photos and downloads**: + ```bash + adb -s 23181JEGR08034 pull /sdcard/DCIM/ ~/phone-backup/DCIM/ + adb -s 23181JEGR08034 pull /sdcard/Download/ ~/phone-backup/Download/ + ``` +7. **mBank**: in the transactional service, note how to remove a paired device. + The old phone stays listed as active after a reset and must be detached. +8. **VERIFY EVERY EXPORT BEFORE WIPING.** Open the `.kdbx` on the PC. An + untested export is not a backup — this is the step people skip and regret. + +## Phase C — wipe and provision, in this order + +9. **Factory reset.** +10. **Provision DO immediately, before signing into anything** — the account + check is what blocks it: + ```bash + adb install -r focus_owner/build/app/outputs/flutter-apk/app-debug.apk + adb shell dpm set-device-owner com.kuhy.focus_owner/.FocusDeviceAdminReceiver + ``` +11. **Test the escape hatch before adding a single account.** Open Focus Owner, + confirm it reports `Device owner: yes`, press **Release device owner**, and + confirm it flips back to `no`. Then re-provision. If release does not work, + stop — a DO app that cannot let go is a soft-brick waiting to happen, and at + this moment you have nothing to lose by wiping again. +12. **Add one Google account.** Verify banking apps install and log in. +13. **Restore KeePassDX**, then Signal (the restore prompt appears at install + time only — do not skip past it). +14. **Banks.** mBank: pair via app or transactional service, SMS code, then app + PIN and mobile authorisation. Revolut: re-login, SMS/email, possible selfie + re-verification. +15. **mObywatel last.** Re-activation needs Profil Zaufany or a **bank login** + plus SMS — which is why the banks must work first. Activating on the new + device automatically deactivates the old one, so there is no rollback. + Verify current steps at before relying on + this. +16. **Only then** apply enforcement, one restriction at a time, re-testing the + release path after each. + +## Do not set these until the rest is proven + +`DISALLOW_FACTORY_RESET` and `DISALLOW_DEBUGGING_FEATURES` close your own repair +routes as well as your bypass routes. Combined with an always-on VPN lockdown +they are the specific combination most likely to leave an unrecoverable device. +Add them last, if at all. + +## Rollback + +`DevicePolicyBridge.releaseDeviceOwner()` (`clearDeviceOwnerApp`) removes DO with +no wipe, but only while the app is installed and working. `dpm +remove-active-admin` does **not** work on a device owner. If the app is +uninstalled without releasing first, the only exit is another factory reset. diff --git a/docs/superpowers/contracts/dot-resolver-and-policy-asset-2026-08-05.json b/docs/superpowers/contracts/dot-resolver-and-policy-asset-2026-08-05.json new file mode 100644 index 00000000..ac45315c --- /dev/null +++ b/docs/superpowers/contracts/dot-resolver-and-policy-asset-2026-08-05.json @@ -0,0 +1,25 @@ +{ + "title": "Deliver the blocklist over DoT and hand the policy to the Kotlin backend", + "objective": "Close the two gaps that stopped the unrooted path from working end to end. First, the phone had no way to consume the focus policy: the app now loads a generated JSON asset instead of the app and the shell config drifting apart. Second, the blocklist was only reachable as a hosts file or over plain UDP/53 on the LAN, and the rooted enforcer actively rejected every DoT connection, so a self-hosted DoT resolver could not have worked even if one existed. Success is a resolver that answers filtered queries over TLS, reachable only over WireGuard, with the rooted enforcer permitting exactly that one endpoint and nothing else.", + "acceptance_criteria": [ + "python3 -m python_pkg.focus_policy renders the policy as JSON and refuses to run against the placeholder coordinates", + "The committed focus_owner/assets/policy.json carries no home coordinates, and a test fails if it ever does", + "focus_owner parses the asset and displays the real counts on-device, matching config.sh", + "An unknown schema_version is rejected rather than partially interpreted", + "dns_enforcer.sh inserts an ACCEPT for the trusted resolver BEFORE the blanket 853 REJECT - order is load-bearing, since iptables takes the first match", + "expected_rule_count() counts those ACCEPTs, so chain_intact() does not treat the chain as tampered and rebuild it every 20s", + "With DNS_TRUSTED_DOT_HOST empty the behaviour is byte-identical to before, so the live rooted phone is unaffected until deployed", + "With it set, Private DNS is pinned to that hostname instead of forced off, and user changes are reverted within DNS_CHECK_INTERVAL", + "stunnel serves DoT on 853 backed by the Caddy-managed Let's Encrypt certificate, renewed without a second ACME client", + "The resolver is bound to the WireGuard address only, and is verifiably unreachable from both LAN and WAN", + "A blocked domain returns 0.0.0.0 over DoT while a normal domain resolves" + ], + "out_of_scope": [ + "Device Owner provisioning and the factory reset it requires - the checklist is planning only", + "Any enforcement in focus_owner: it reads the policy but applies nothing", + "Public exposure of the resolver - deliberately WireGuard-only, no port-forward, no nftables rule", + "Deploying the dns_enforcer change to the rooted phone - the code is in, the deploy is not", + "Fixing generate_hosts_file.sh, which still comments out unblocks rather than deleting them" + ], + "verifier": "python3 -m python_pkg.focus_policy (both success and placeholder-rejection paths); cd focus_owner && flutter analyze && flutter test (27 tests, incl. the committed-asset redaction guard); adb install + am start + screenshot showing the parsed policy; shellcheck + shfmt on dns_enforcer.sh, config.sh and setup_dot_resolver.sh; a stubbed-iptables harness asserting rule order, rule counts and the private-DNS pin in both modes; setup_dot_resolver.sh --check; kdig +tls @10.8.0.1 -p 853 for a normal and a blocked domain; ss -lnt plus kdig against the LAN and WAN addresses to prove non-exposure" +} diff --git a/docs/superpowers/evidence/dot-resolver-and-policy-asset-2026-08-05.json b/docs/superpowers/evidence/dot-resolver-and-policy-asset-2026-08-05.json new file mode 100644 index 00000000..97aa88dd --- /dev/null +++ b/docs/superpowers/evidence/dot-resolver-and-policy-asset-2026-08-05.json @@ -0,0 +1,80 @@ +{ + "intent": "Make the unrooted path work end to end: the phone can now consume the focus policy as a generated asset, and the blocklist is served over DNS-over-TLS from a resolver the rooted enforcer permits instead of blocking.", + "scope": [ + "python_pkg/focus_policy/__main__.py - new CLI that renders the policy as JSON", + "focus_owner/lib/policy.dart, assets/policy.json, test/policy_test.dart - new: parse and display the policy", + "phone_focus_mode/dns_enforcer.sh - allow one trusted DoT endpoint instead of rejecting all 853; pin Private DNS to it", + "phone_focus_mode/config.sh - new DNS_TRUSTED_DOT_HOST / DNS_TRUSTED_DOT_IPS", + "linux_configuration/scripts/single_use/features/setup_dot_resolver.sh - new: stunnel DoT front end", + "docs/device-owner-wipe-checklist.md - planning only, no wipe performed", + "Non-goal: no Device Owner provisioning, no factory reset, no enforcement in focus_owner", + "Non-goal: the dns_enforcer change is NOT deployed to the rooted phone" + ], + "changes": [ + "focus_owner loads a generated policy.json rather than hardcoding anything, so config.sh remains the single source of truth across Python, shell and Kotlin. The committed asset is rendered with --redact-home, and a test fails if home coordinates ever appear in it. An unknown schema_version is rejected outright, because silently accepting a newer schema means enforcing a policy the app has misread - and a misread allowlist is one that blocks the dialer.", + "dns_enforcer.sh previously rejected ALL port 853 traffic and pinned Private DNS off, re-asserted every 20s. That is correct when DoT is a bypass channel, but it is exactly wrong when DoT is the enforcement channel, which is what an unrooted phone needs. With DNS_TRUSTED_DOT_HOST set it now ACCEPTs the trusted resolver's addresses BEFORE the blanket REJECT and pins Private DNS to that hostname. With the variable empty the old behaviour is byte-identical.", + "expected_rule_count() gained a family argument and counts the new ACCEPTs. Without that, chain_intact() would see a size mismatch and rebuild the chain every DNS_CHECK_INTERVAL - a silent fork storm that also drops the DoT connection on each rebuild.", + "setup_dot_resolver.sh terminates DoT with stunnel rather than Caddy: DoT is a raw TLS stream and stock caddy:2.8 lacks the layer4 module, so using Caddy would have meant an xcaddy rebuild of a container serving five live sites. It reuses the certificate Caddy already renews, so there is no second ACME client competing for port 80.", + "The resolver binds to the WireGuard address, not 0.0.0.0. A public DoT endpoint is an open resolver; over the existing tunnel it needs no port-forward, no firewall rule and no rate limiting, because only configured peers can route to it." + ], + "verification": [ + { + "command": "python3 -m python_pkg.focus_policy --config phone_focus_mode/config.sh --redact-home --output focus_owner/assets/policy.json", + "result": "pass", + "evidence": "Renders 84 allowed / 39 night / 44 never-disable / 15 workout domains, curfew 23:00-05:00, latitude and longitude null. Running it against the real config_secrets.sh exits 1 with the placeholder message rather than emitting a policy." + }, + { + "command": "cd focus_owner && flutter analyze && flutter test", + "result": "pass", + "evidence": "'No issues found!' and 27 tests passing, including a guard that loads the actually-committed assets/policy.json and asserts hasCoordinates is false." + }, + { + "command": "adb install -r && am start && screencap", + "result": "pass", + "evidence": "On-device screenshot shows 'Allowed apps 84 (39 at night)', 'Curfew 23:00-05:00', 'Home radius 150 m (coords redacted)', 'Workout domains 15' - matching config.sh, read through the generated asset. Device owner still 'no', restrictions still 'none'." + }, + { + "command": "bash scratchpad/test_dot.sh (stubbed iptables/settings harness)", + "result": "pass", + "evidence": "14/14 checks. Confirms: with no trusted host there are zero ACCEPT rules and Private DNS is forced off (old behaviour preserved); with one set, the v4 and v6 ACCEPTs exist, the rule counts match expected_rule_count exactly, the ACCEPT precedes the REJECT, Private DNS is pinned to hostname, and a user changing it in Settings is reverted." + }, + { + "command": "shellcheck + shfmt on dns_enforcer.sh, config.sh, setup_dot_resolver.sh", + "result": "pass", + "evidence": "Clean. CI fails on any shellcheck finding, so an SC2155 on the new script was fixed by splitting declaration from assignment rather than suppressed." + }, + { + "command": "setup_dot_resolver.sh && --check && kdig +tls @10.8.0.1 -p 853", + "result": "pass", + "evidence": "'OK - DoT resolver healthy'. example.com resolves (104.20.23.154); pornhub.com returns 0.0.0.0, i.e. the blocklist is applied; TLS 1.3 negotiates against the Let's Encrypt cert. Survives systemctl restart; service and cert-sync timer both enabled at boot." + }, + { + "command": "ss -lnt | grep 853; kdig +tls @192.168.1.43 and @79.184.36.76", + "result": "pass", + "evidence": "Listening on 10.8.0.1:853 only. Both the LAN address and the public IP fail to answer, confirming the resolver is not exposed." + }, + { + "command": "caddy validate + reload, then curl each existing site", + "result": "pass", + "evidence": "Config validated BEFORE reload because a bad site block previously broke ACME for every domain. After reload the cert for dns.kuhy.duckdns.org was issued and kuhy.duckdns.org, gitea.kuhy.duckdns.org and kuhy-sync.duckdns.org all still return 200." + }, + { + "command": "adb shell pm list packages (Wallet and 2FA apps)", + "result": "pass", + "evidence": "Google Wallet is NOT installed and nfc_payment_default_component is null, so the one unresolved Device Owner risk does not apply to this device. Also found that Aegis, MS Authenticator, Oracle IDM and PKO IKO are absent - they were in config.sh's whitelist, which describes the ROOTED phone, not this one. The checklist was corrected against the device rather than the config." + } + ], + "risks": [ + "config.sh now ships DNS_TRUSTED_DOT_HOST populated. Deploying it to the rooted phone flips DNS enforcement from 'no DoT at all' to 'only this DoT', and the phone must be on the WireGuard tunnel or DNS stops resolving. This has NOT been deployed; do it attended.", + "The DoT endpoint is reachable only over WireGuard, and phone.conf uses AllowedIPs = 10.8.0.0/24 (split tunnel). Filtering therefore applies only while the tunnel is up - it is not an always-on guarantee.", + "stunnel reads the certificate from disk; the daily timer copies it out of the Caddy container. If Caddy renews and the timer fails silently, stunnel serves an expired certificate and Android rejects the connection, which presents as total DNS failure on the phone.", + "A dns.caddy site block now exists purely to make Caddy issue the certificate. It responds 200 on a public hostname. Harmless, but it is a live endpoint that did not exist before.", + "The wipe checklist is drafted, not rehearsed. mObywatel re-activation in particular is documented from secondary Polish sources and should be re-checked at gov.pl before anyone relies on it." + ], + "rollback": [ + "DoT resolver: sudo systemctl disable --now stunnel-dot.service dot-cert-sync.timer; rm /etc/stunnel/dot-resolver.conf /etc/systemd/system/stunnel-dot.service; rm /home/kuhy/gitea/sites/dns.caddy and reload Caddy.", + "dns_enforcer: set DNS_TRUSTED_DOT_HOST=\"\" in config.sh. That alone restores the previous behaviour exactly - no code revert needed, which is why the flag defaults to empty.", + "focus_owner: git revert. The app reads the policy but enforces nothing, so nothing on the phone changes.", + "After rollback validate: setup_dot_resolver.sh --check fails as expected, the five existing Caddy sites still return 200, and the stubbed enforcer harness still passes section A." + ] +} diff --git a/focus_owner/assets/policy.json b/focus_owner/assets/policy.json new file mode 100644 index 00000000..38bdd294 --- /dev/null +++ b/focus_owner/assets/policy.json @@ -0,0 +1,208 @@ +{ + "allowed_packages": [ + "ac.mdiq.podcini.X", + "ch.protonmail.android", + "com.Splitwise.SplitwiseMobile", + "com.anthropic.claude", + "com.aurora.store", + "com.azure.authenticator", + "com.beemdevelopment.aegis", + "com.blackview.launcher", + "com.blackview.launcher.overlay.framework", + "com.bnyro.clock", + "com.discord", + "com.facebook.katana", + "com.facebook.orca", + "com.fitatu.tracker", + "com.fsck.k9", + "com.github.android", + "com.google.android.apps.maps", + "com.google.android.calendar", + "com.google.android.contactkeys", + "com.google.android.gm", + "com.google.android.safetycore", + "com.ichi2.anki", + "com.jksalcedo.librefind", + "com.jwang123.splitbills", + "com.kolejeslaskie.mss", + "com.kuhy.diet_guard_app", + "com.kuhy.dufs_client", + "com.kuhy.epopeja_karta", + "com.kuhy.focusstatus", + "com.kuhy.habit_stack", + "com.kuhy.home_inventory", + "com.kuhy.horatio", + "com.kuhy.pomodoro_app", + "com.kuhy.vaultkitbypass", + "com.kuhy.wake_alarm_sync", + "com.kuhy.workout_app", + "com.kunzisoft.keepass.libre", + "com.machiav3lli.fdroid", + "com.maksimowiczm.foodyou", + "com.metrolist.music", + "com.microsoft.office.outlook", + "com.microsoft.teams", + "com.qqlabs.minimalistlauncher", + "com.rama.teyin", + "com.revolut.revolut", + "com.schwegelbin.openbible", + "com.shazam.android", + "com.sosauce.cutecalc", + "com.stronglifts.app", + "com.topjohnwu.magisk", + "com.tplink.iot", + "com.vayunmathur.camera", + "com.waist.line", + "com.xiaomi.smarthome", + "com.yoshi.rain", + "com.zaneschepke.wireguardautotunnel", + "de.kicktipp.mbookmark", + "de.schildbach.oeffi", + "de.thomaskuenneth.benice", + "dev.kuhy.todo", + "eu.kanade.tachiyomi.sy", + "is.xyz.mpv", + "me.phh.superuser", + "moe.shizuku.privileged.api", + "net.bible.android.activity", + "net.cozic.joplin", + "net.osmand", + "net.thunderbird.android.beta", + "oracle.idm.mobile.authenticator", + "org.connectbot", + "org.fdroid.fdroid", + "org.fossify.contacts", + "org.fossify.messages", + "org.fossify.phone", + "org.mozilla.fenix", + "org.runnerup", + "org.thoughtcrime.securesms", + "pkp.ic.eicmobile", + "pl.mbank", + "pl.nask.mobywatel", + "pl.orange.mojeorange", + "pl.pkobp.iko", + "pl.plksa.portalpasazera", + "ws.xsoh.etar" + ], + "browser_packages": [ + "com.android.chrome", + "org.mozilla.fenix" + ], + "curfew": { + "end": "05:00", + "start": "23:00" + }, + "home": { + "hysteresis_m": 30.0, + "latitude": null, + "longitude": null, + "radius_m": 150.0 + }, + "launcher_package": "com.qqlabs.minimalistlauncher", + "never_disable_prefixes": [ + "android", + "com.android.bluetooth", + "com.android.camera", + "com.android.camera2", + "com.android.captiveportallogin", + "com.android.certinstaller", + "com.android.contacts", + "com.android.dialer", + "com.android.documentsui", + "com.android.emergency", + "com.android.externalstorage", + "com.android.inputmethod", + "com.android.keychain", + "com.android.launcher", + "com.android.launcher3", + "com.android.location", + "com.android.messaging", + "com.android.mms", + "com.android.networkstack", + "com.android.nfc", + "com.android.permissioncontroller", + "com.android.phone", + "com.android.providers", + "com.android.se", + "com.android.settings", + "com.android.shell", + "com.android.systemui", + "com.android.vpndialogs", + "com.android.wifi", + "com.google.android.apps.messaging", + "com.google.android.contacts", + "com.google.android.deskclock", + "com.google.android.dialer", + "com.google.android.ext.services", + "com.google.android.ext.shared", + "com.google.android.gms", + "com.google.android.gsf", + "com.google.android.inputmethod.latin", + "com.google.android.permissioncontroller", + "com.google.android.setupwizard", + "com.google.android.trichromelibrary", + "com.google.android.webview", + "com.mediatek", + "com.qualcomm" + ], + "night_allowed_packages": [ + "com.azure.authenticator", + "com.beemdevelopment.aegis", + "com.blackview.launcher", + "com.blackview.launcher.overlay.framework", + "com.bnyro.clock", + "com.fitatu.tracker", + "com.google.android.apps.maps", + "com.google.android.calendar", + "com.google.android.contactkeys", + "com.google.android.safetycore", + "com.kuhy.diet_guard_app", + "com.kuhy.focusstatus", + "com.kuhy.habit_stack", + "com.kuhy.vaultkitbypass", + "com.kuhy.wake_alarm_sync", + "com.kuhy.workout_app", + "com.kunzisoft.keepass.libre", + "com.maksimowiczm.foodyou", + "com.metrolist.music", + "com.qqlabs.minimalistlauncher", + "com.revolut.revolut", + "com.stronglifts.app", + "com.topjohnwu.magisk", + "com.tplink.iot", + "com.waist.line", + "com.xiaomi.smarthome", + "de.thomaskuenneth.benice", + "dev.kuhy.todo", + "me.phh.superuser", + "moe.shizuku.privileged.api", + "oracle.idm.mobile.authenticator", + "org.fossify.contacts", + "org.fossify.messages", + "org.fossify.phone", + "org.runnerup", + "pl.mbank", + "pl.nask.mobywatel", + "pl.pkobp.iko", + "ws.xsoh.etar" + ], + "schema_version": 1, + "workout_unblock_domains": [ + "googlevideo.com", + "i.ytimg.com", + "i9.ytimg.com", + "m.youtube.com", + "s.ytimg.com", + "www.youtube-nocookie.com", + "www.youtube.com", + "youtu.be", + "youtube-nocookie.com", + "youtube.com", + "youtube.googleapis.com", + "youtubei.googleapis.com", + "yt3.ggpht.com", + "yt3.googleusercontent.com", + "ytimg.com" + ] +} diff --git a/focus_owner/lib/main.dart b/focus_owner/lib/main.dart index 303f971f..362003b9 100644 --- a/focus_owner/lib/main.dart +++ b/focus_owner/lib/main.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:focus_owner/device_policy.dart'; +import 'package:focus_owner/policy.dart'; void main() => runApp(const FocusOwnerApp()); @@ -43,16 +44,25 @@ class FocusOwnerApp extends StatelessWidget { /// Shows provisioning state and offers the one action this build supports: /// giving up device ownership. class StatusPage extends StatefulWidget { - const StatusPage({super.key, this.policy = const DevicePolicy()}); + const StatusPage({ + super.key, + this.policy = const DevicePolicy(), + this.loadFocusPolicy = FocusPolicy.load, + }); final DevicePolicy policy; + /// Injectable so tests can supply a policy without a rootBundle asset. + final Future Function() loadFocusPolicy; + @override State createState() => _StatusPageState(); } class _StatusPageState extends State { DevicePolicyStatus? _status; + FocusPolicy? _focusPolicy; + String? _policyError; String? _error; bool _busy = false; @@ -64,6 +74,11 @@ class _StatusPageState extends State { Future _refresh() async { setState(() => _busy = true); + await Future.wait([_loadDeviceStatus(), _loadPolicy()]); + if (mounted) setState(() => _busy = false); + } + + Future _loadDeviceStatus() async { try { final status = await widget.policy.status(); if (!mounted) return; @@ -74,8 +89,25 @@ class _StatusPageState extends State { } on PlatformException catch (e) { if (!mounted) return; setState(() => _error = e.message ?? 'Could not read device policy state'); - } finally { - if (mounted) setState(() => _busy = false); + } + } + + Future _loadPolicy() async { + try { + final policy = await widget.loadFocusPolicy(); + if (!mounted) return; + setState(() { + _focusPolicy = policy; + _policyError = null; + }); + } on PolicyFormatException catch (e) { + // A policy that cannot be parsed must be reported, never silently + // treated as an empty allowlist - that would block everything. + if (!mounted) return; + setState(() => _policyError = e.message); + } on Exception catch (e) { + if (!mounted) return; + setState(() => _policyError = '$e'); } } @@ -126,7 +158,13 @@ class _StatusPageState extends State { if (error != null) { body = const _Message(text: 'Could not read state', color: _kDanger); } else if (status != null) { - body = _StatusBody(status: status, busy: _busy, onRelease: _release); + body = _StatusBody( + status: status, + focusPolicy: _focusPolicy, + policyError: _policyError, + busy: _busy, + onRelease: _release, + ); } else { body = const Center(child: CircularProgressIndicator()); } @@ -152,16 +190,21 @@ class _StatusPageState extends State { class _StatusBody extends StatelessWidget { const _StatusBody({ required this.status, + required this.focusPolicy, + required this.policyError, required this.busy, required this.onRelease, }); final DevicePolicyStatus status; + final FocusPolicy? focusPolicy; + final String? policyError; final bool busy; final Future Function() onRelease; @override Widget build(BuildContext context) { + final policy = focusPolicy; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -173,6 +216,37 @@ class _StatusBody extends StatelessWidget { label: 'Restrictions', value: status.restrictionsApplied ? 'applied' : 'none (inert build)', ), + const SizedBox(height: _kGap), + const Divider(color: _kMuted), + const SizedBox(height: _kGap / 2), + if (policyError != null) + _Message(text: 'Policy: $policyError', color: _kDanger) + else if (policy == null) + const _Message(text: 'Policy: loading...', color: _kMuted) + else ...[ + _Row( + label: 'Allowed apps', + value: '${policy.allowedPackages.length}' + ' (${policy.nightAllowedPackages.length} at night)', + ), + _Row( + label: 'Curfew', + value: policy.curfew == null + ? 'disabled' + : '${_hhmm(policy.curfew!.startMinutes)}' + '-${_hhmm(policy.curfew!.endMinutes)}', + ), + _Row( + label: 'Home radius', + value: policy.home.hasCoordinates + ? '${policy.home.radiusM.round()} m' + : '${policy.home.radiusM.round()} m (coords redacted)', + ), + _Row( + label: 'Workout domains', + value: '${policy.workoutUnblockDomains.length}', + ), + ], const SizedBox(height: _kGap * 2), if (status.isDeviceOwner) FilledButton( @@ -190,6 +264,13 @@ class _StatusBody extends StatelessWidget { } } +/// Formats minutes-since-midnight as HH:MM. +String _hhmm(int minutes) { + final h = (minutes ~/ 60).toString().padLeft(2, '0'); + final m = (minutes % 60).toString().padLeft(2, '0'); + return '$h:$m'; +} + class _Row extends StatelessWidget { const _Row({required this.label, required this.value}); diff --git a/focus_owner/lib/policy.dart b/focus_owner/lib/policy.dart new file mode 100644 index 00000000..29e60e53 --- /dev/null +++ b/focus_owner/lib/policy.dart @@ -0,0 +1,201 @@ +import 'dart:convert'; + +import 'package:flutter/services.dart' show AssetBundle, rootBundle; + +/// Schema version this build understands. +/// +/// The asset is generated by `python3 -m python_pkg.focus_policy`, which is the +/// single source of truth. Refusing an unknown version is deliberate: silently +/// accepting a newer schema would mean enforcing a policy this code has +/// misread, and a misread allowlist is an allowlist that blocks the dialer. +const int kSupportedSchemaVersion = 1; + +/// Raised when the bundled policy cannot be trusted. +class PolicyFormatException implements Exception { + const PolicyFormatException(this.message); + + final String message; + + @override + String toString() => 'PolicyFormatException: $message'; +} + +/// A nightly window that may wrap midnight (23:00 -> 05:00). +class CurfewWindow { + const CurfewWindow({required this.startMinutes, required this.endMinutes}); + + /// Parses an `{"start": "23:00", "end": "05:00"}` object. + factory CurfewWindow.fromJson(Map json) { + return CurfewWindow( + startMinutes: _parseHhMm(json['start'], 'curfew.start'), + endMinutes: _parseHhMm(json['end'], 'curfew.end'), + ); + } + + /// Minutes since midnight. + final int startMinutes; + final int endMinutes; + + /// Whether [minutesSinceMidnight] falls inside the window. + /// + /// Wrapping windows are the normal case here, so "inside" means at or after + /// the start *or* before the end, not both. + bool contains(int minutesSinceMidnight) { + if (startMinutes <= endMinutes) { + return minutesSinceMidnight >= startMinutes && + minutesSinceMidnight < endMinutes; + } + return minutesSinceMidnight >= startMinutes || + minutesSinceMidnight < endMinutes; + } +} + +/// The home anchor. Coordinates are null in a redacted policy. +class HomeLocation { + const HomeLocation({ + required this.latitude, + required this.longitude, + required this.radiusM, + required this.hysteresisM, + }); + + /// Parses the `home` object. + factory HomeLocation.fromJson(Map json) { + return HomeLocation( + latitude: (json['latitude'] as num?)?.toDouble(), + longitude: (json['longitude'] as num?)?.toDouble(), + radiusM: _requireNum(json['radius_m'], 'home.radius_m'), + hysteresisM: _requireNum(json['hysteresis_m'], 'home.hysteresis_m'), + ); + } + + final double? latitude; + final double? longitude; + final double radiusM; + final double hysteresisM; + + /// Whether this policy carries real coordinates. + /// + /// The committed asset is redacted, so the location gate cannot run against + /// it. Enforcement must check this rather than treating a missing coordinate + /// as (0, 0), which would put "home" in the Atlantic. + bool get hasCoordinates => latitude != null && longitude != null; +} + +/// The device-independent focus policy, as generated from `config.sh`. +class FocusPolicy { + const FocusPolicy({ + required this.home, + required this.allowedPackages, + required this.nightAllowedPackages, + required this.neverDisablePrefixes, + required this.workoutUnblockDomains, + required this.curfew, + required this.launcherPackage, + }); + + /// Parses a rendered policy document. + factory FocusPolicy.fromJson(Map json) { + final version = json['schema_version']; + if (version != kSupportedSchemaVersion) { + throw PolicyFormatException( + 'unsupported schema_version $version ' + '(this build understands $kSupportedSchemaVersion)', + ); + } + final home = json['home']; + if (home is! Map) { + throw const PolicyFormatException('missing "home" object'); + } + final curfew = json['curfew']; + return FocusPolicy( + home: HomeLocation.fromJson(home), + allowedPackages: _stringSet(json['allowed_packages'], 'allowed_packages'), + nightAllowedPackages: + _stringSet(json['night_allowed_packages'], 'night_allowed_packages'), + neverDisablePrefixes: _stringSet( + json['never_disable_prefixes'], + 'never_disable_prefixes', + ), + workoutUnblockDomains: _stringSet( + json['workout_unblock_domains'], + 'workout_unblock_domains', + ), + curfew: curfew == null + ? null + : CurfewWindow.fromJson(curfew as Map), + launcherPackage: json['launcher_package'] as String?, + ); + } + + /// Loads the policy bundled with the app. + static Future load({ + AssetBundle? bundle, + String assetKey = 'assets/policy.json', + }) async { + final raw = await (bundle ?? rootBundle).loadString(assetKey); + final decoded = jsonDecode(raw); + if (decoded is! Map) { + throw const PolicyFormatException('policy asset is not a JSON object'); + } + return FocusPolicy.fromJson(decoded); + } + + final HomeLocation home; + final Set allowedPackages; + final Set nightAllowedPackages; + final Set neverDisablePrefixes; + final Set workoutUnblockDomains; + final CurfewWindow? curfew; + final String? launcherPackage; + + /// Whether a package must never be disabled. + /// + /// Prefix-matched on whole labels, so `com.android.providers` covers + /// `com.android.providers.telephony` but not `com.android.providersomething`. + bool isProtected(String package) => neverDisablePrefixes.any( + (prefix) => package == prefix || package.startsWith('$prefix.'), + ); + + /// Whether a package may run under the given conditions. + bool isAllowed(String package, {bool duringCurfew = false}) { + if (isProtected(package)) return true; + return duringCurfew + ? nightAllowedPackages.contains(package) + : allowedPackages.contains(package); + } + + /// Whether the curfew is in force at [minutesSinceMidnight]. + bool isCurfewActive(int minutesSinceMidnight) => + curfew?.contains(minutesSinceMidnight) ?? false; +} + +Set _stringSet(Object? value, String field) { + if (value is! List) { + throw PolicyFormatException('"$field" must be a list'); + } + return value.map((e) => e! as String).toSet(); +} + +double _requireNum(Object? value, String field) { + if (value is! num) { + throw PolicyFormatException('"$field" must be a number'); + } + return value.toDouble(); +} + +int _parseHhMm(Object? value, String field) { + if (value is! String) { + throw PolicyFormatException('"$field" must be a "HH:MM" string'); + } + final parts = value.split(':'); + if (parts.length != 2) { + throw PolicyFormatException('"$field" must be "HH:MM", got "$value"'); + } + final hour = int.tryParse(parts[0]); + final minute = int.tryParse(parts[1]); + if (hour == null || minute == null || hour > 23 || minute > 59) { + throw PolicyFormatException('"$field" is not a valid time: "$value"'); + } + return hour * 60 + minute; +} diff --git a/focus_owner/pubspec.yaml b/focus_owner/pubspec.yaml index 45d57c3a..ba499e10 100644 --- a/focus_owner/pubspec.yaml +++ b/focus_owner/pubspec.yaml @@ -57,10 +57,13 @@ flutter: # the material Icons class. uses-material-design: true - # To add assets to your application, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg + # Generated from phone_focus_mode/config.sh, which stays the source of truth: + # python3 -m python_pkg.focus_policy \ + # --config phone_focus_mode/config.sh --redact-home \ + # --output focus_owner/assets/policy.json + # The committed copy is redacted, so it carries no home coordinates. + assets: + - assets/policy.json # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/to/resolution-aware-images diff --git a/focus_owner/test/policy_test.dart b/focus_owner/test/policy_test.dart new file mode 100644 index 00000000..10c06907 --- /dev/null +++ b/focus_owner/test/policy_test.dart @@ -0,0 +1,189 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:focus_owner/policy.dart'; + +Map _doc({ + Object? schemaVersion = kSupportedSchemaVersion, + Object? curfew = const {'start': '23:00', 'end': '05:00'}, + double? latitude, + double? longitude, +}) => + { + 'schema_version': schemaVersion, + 'home': { + 'latitude': latitude, + 'longitude': longitude, + 'radius_m': 150.0, + 'hysteresis_m': 30.0, + }, + 'curfew': curfew, + 'launcher_package': 'com.qqlabs.minimalistlauncher', + 'allowed_packages': ['com.good', 'pl.mbank'], + 'night_allowed_packages': ['pl.mbank'], + 'never_disable_prefixes': ['com.android.providers'], + 'workout_unblock_domains': ['youtube.com'], + 'browser_packages': [], + }; + +void main() { + group('FocusPolicy.fromJson', () { + test('parses a rendered policy', () { + final policy = FocusPolicy.fromJson(_doc()); + expect(policy.allowedPackages, {'com.good', 'pl.mbank'}); + expect(policy.nightAllowedPackages, {'pl.mbank'}); + expect(policy.launcherPackage, 'com.qqlabs.minimalistlauncher'); + expect(policy.home.radiusM, 150.0); + }); + + test('rejects an unknown schema version', () { + // Accepting a newer schema would mean enforcing a misread policy. + expect( + () => FocusPolicy.fromJson(_doc(schemaVersion: 999)), + throwsA(isA()), + ); + }); + + test('rejects a document with no home object', () { + final doc = _doc()..remove('home'); + expect( + () => FocusPolicy.fromJson(doc), + throwsA(isA()), + ); + }); + + test('rejects a malformed curfew time', () { + expect( + () => FocusPolicy.fromJson(_doc(curfew: {'start': '25:00', 'end': 'x'})), + throwsA(isA()), + ); + }); + + test('accepts an absent curfew', () { + expect(FocusPolicy.fromJson(_doc(curfew: null)).curfew, isNull); + }); + }); + + group('HomeLocation', () { + test('redacted coordinates are reported as absent', () { + // The committed asset is redacted; enforcement must not treat a missing + // coordinate as (0, 0), which would put "home" in the Atlantic. + final policy = FocusPolicy.fromJson(_doc()); + expect(policy.home.hasCoordinates, isFalse); + expect(policy.home.radiusM, 150.0); + }); + + test('real coordinates are reported as present', () { + final policy = + FocusPolicy.fromJson(_doc(latitude: 52.2297, longitude: 21.0122)); + expect(policy.home.hasCoordinates, isTrue); + }); + }); + + group('CurfewWindow', () { + const window = CurfewWindow(startMinutes: 23 * 60, endMinutes: 5 * 60); + + test('wraps midnight', () { + expect(window.contains(22 * 60 + 59), isFalse); + expect(window.contains(23 * 60), isTrue); + expect(window.contains(0), isTrue); + expect(window.contains(4 * 60 + 59), isTrue); + expect(window.contains(5 * 60), isFalse); + expect(window.contains(12 * 60), isFalse); + }); + + test('handles a same-day window', () { + const day = CurfewWindow(startMinutes: 9 * 60, endMinutes: 17 * 60); + expect(day.contains(8 * 60), isFalse); + expect(day.contains(9 * 60), isTrue); + expect(day.contains(17 * 60), isFalse); + }); + }); + + group('allow decisions', () { + final policy = FocusPolicy.fromJson(_doc()); + + test('protects system prefixes on whole labels only', () { + expect(policy.isProtected('com.android.providers'), isTrue); + expect(policy.isProtected('com.android.providers.telephony'), isTrue); + expect(policy.isProtected('com.android.providersomething'), isFalse); + }); + + test('protected packages are allowed even during curfew', () { + expect( + policy.isAllowed('com.android.providers', duringCurfew: true), + isTrue, + ); + }); + + test('curfew narrows the allowlist', () { + expect(policy.isAllowed('com.good'), isTrue); + expect(policy.isAllowed('com.good', duringCurfew: true), isFalse); + expect(policy.isAllowed('pl.mbank', duringCurfew: true), isTrue); + }); + + test('unknown packages are blocked', () { + expect(policy.isAllowed('com.evil'), isFalse); + }); + + test('isCurfewActive follows the window', () { + expect(policy.isCurfewActive(23 * 60 + 30), isTrue); + expect(policy.isCurfewActive(12 * 60), isFalse); + }); + }); + + group('FocusPolicy.load', () { + test('reads and parses the bundled asset', () async { + final bundle = _StubBundle(jsonEncode(_doc())); + final policy = await FocusPolicy.load(bundle: bundle); + expect(policy.allowedPackages, contains('pl.mbank')); + }); + + test('rejects an asset that is not a JSON object', () async { + final bundle = _StubBundle('[]'); + await expectLater( + FocusPolicy.load(bundle: bundle), + throwsA(isA()), + ); + }); + }); + + test('the real committed asset parses and is redacted', () async { + // Guards the generator: a policy.json that this app cannot read, or that + // leaks the home coordinates into git, is a build error. + final policy = await FocusPolicy.load(bundle: _RealAssetBundle()); + expect(policy.allowedPackages.length, greaterThan(50)); + expect(policy.home.hasCoordinates, isFalse, + reason: 'committed policy.json must not carry home coordinates'); + expect(policy.curfew, isNotNull); + }); +} + +class _StubBundle extends CachingAssetBundle { + _StubBundle(this.contents); + + final String contents; + + @override + Future load(String key) async => + ByteData.sublistView(utf8.encode(contents)); + + @override + Future loadString(String key, {bool cache = true}) async => contents; +} + +/// Reads the asset straight off disk, bypassing the (empty) test rootBundle. +class _RealAssetBundle extends CachingAssetBundle { + @override + Future load(String key) async { + throw UnimplementedError(key); + } + + @override + Future loadString(String key, {bool cache = true}) async { + final file = File('assets/policy.json'); + return file.readAsString(); + } +} diff --git a/focus_owner/test/widget_test.dart b/focus_owner/test/widget_test.dart index f65fcbda..3d8638af 100644 --- a/focus_owner/test/widget_test.dart +++ b/focus_owner/test/widget_test.dart @@ -3,6 +3,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:focus_owner/device_policy.dart'; import 'package:focus_owner/main.dart'; +import 'package:focus_owner/policy.dart'; /// Builds a channel whose handler answers from [responses], recording calls. ({MethodChannel channel, List calls}) _fakeChannel( @@ -29,6 +30,24 @@ Map _statusMap({bool isDeviceOwner = false}) => { 'restrictionsApplied': false, }; +/// Minimal policy so widget tests never touch the (empty) test rootBundle. +Future _stubPolicy() async => FocusPolicy.fromJson({ + 'schema_version': kSupportedSchemaVersion, + 'home': { + 'latitude': null, + 'longitude': null, + 'radius_m': 150.0, + 'hysteresis_m': 30.0, + }, + 'curfew': {'start': '23:00', 'end': '05:00'}, + 'launcher_package': 'com.launcher', + 'allowed_packages': ['com.good'], + 'night_allowed_packages': [], + 'never_disable_prefixes': [], + 'workout_unblock_domains': ['youtube.com'], + 'browser_packages': [], + }); + void main() { group('DevicePolicyStatus', () { test('parses the native status map', () { @@ -80,7 +99,12 @@ void main() { (tester) async { final fake = _fakeChannel({'status': _statusMap()}); await tester.pumpWidget( - MaterialApp(home: StatusPage(policy: DevicePolicy(fake.channel))), + MaterialApp( + home: StatusPage( + policy: DevicePolicy(fake.channel), + loadFocusPolicy: _stubPolicy, + ), + ), ); await tester.pumpAndSettle(); @@ -91,7 +115,12 @@ void main() { testWidgets('shows the release button when device owner', (tester) async { final fake = _fakeChannel({'status': _statusMap(isDeviceOwner: true)}); await tester.pumpWidget( - MaterialApp(home: StatusPage(policy: DevicePolicy(fake.channel))), + MaterialApp( + home: StatusPage( + policy: DevicePolicy(fake.channel), + loadFocusPolicy: _stubPolicy, + ), + ), ); await tester.pumpAndSettle(); @@ -105,7 +134,12 @@ void main() { 'releaseDeviceOwner': true, }); await tester.pumpWidget( - MaterialApp(home: StatusPage(policy: DevicePolicy(fake.channel))), + MaterialApp( + home: StatusPage( + policy: DevicePolicy(fake.channel), + loadFocusPolicy: _stubPolicy, + ), + ), ); await tester.pumpAndSettle(); @@ -128,7 +162,12 @@ void main() { 'releaseDeviceOwner': true, }); await tester.pumpWidget( - MaterialApp(home: StatusPage(policy: DevicePolicy(fake.channel))), + MaterialApp( + home: StatusPage( + policy: DevicePolicy(fake.channel), + loadFocusPolicy: _stubPolicy, + ), + ), ); await tester.pumpAndSettle(); diff --git a/linux_configuration/scripts/single_use/features/setup_dot_resolver.sh b/linux_configuration/scripts/single_use/features/setup_dot_resolver.sh new file mode 100755 index 00000000..4083fa28 --- /dev/null +++ b/linux_configuration/scripts/single_use/features/setup_dot_resolver.sh @@ -0,0 +1,290 @@ +#!/bin/bash + +# ============================================================================ +# Expose the existing dnsmasq blocklist over DNS-over-TLS (port 853). +# +# Why this exists: +# setup_dns_blocker.sh serves the blocklist over plain UDP/53, which only +# works on the LAN -- and Android's default "Automatic" Private DNS uses DoH +# and bypasses it entirely. A phone off the LAN gets no filtering at all. +# Terminating DoT here lets the phone pin Private DNS to a resolver that +# applies the blocklist, anywhere, with no root and no VPN slot consumed. +# +# Why stunnel rather than Caddy: +# DoT is a raw TLS stream, not HTTPS. Terminating it in Caddy needs the +# `layer4` module, which the stock `caddy:2.8` image does not ship -- that +# would mean an xcaddy rebuild of a container currently serving five live +# sites. stunnel does exactly this one job, and it can reuse the Let's +# Encrypt certificate Caddy already renews, so there is no second ACME +# client competing for port 80. +# +# Usage: +# ./setup_dot_resolver.sh [--domain ] [--check] +# ============================================================================ + +set -euo pipefail + +SCRIPT_NAME="$(basename "$0")" +readonly SCRIPT_NAME +readonly DEFAULT_DOMAIN="dns.kuhy.duckdns.org" +readonly CADDY_CONTAINER="gitea-caddy" +readonly CERT_DIR="/etc/stunnel/dot" +readonly STUNNEL_CONF="/etc/stunnel/dot-resolver.conf" +readonly SYNC_SCRIPT="/usr/local/bin/sync-dot-cert.sh" +readonly DOT_PORT=853 + +readonly DEFAULT_BIND="10.8.0.1" + +DOMAIN="$DEFAULT_DOMAIN" +# Bind to the WireGuard interface, not 0.0.0.0. An internet-facing DoT +# endpoint is an open resolver: TCP-only so not usable for classic UDP +# amplification, but still someone else's free DNS and a standing attack +# surface. Reaching it over the existing tunnel needs no port-forward and no +# rate limiting, because only configured peers can route to it at all. +BIND_ADDR="$DEFAULT_BIND" +CHECK_ONLY=0 + +log() { printf '[dot-resolver] %s\n' "$1"; } +die() { + printf '[dot-resolver] ERROR: %s\n' "$1" >&2 + exit 1 +} + +usage() { + cat < DoT hostname (default: $DEFAULT_DOMAIN) + --bind Address to listen on (default: $DEFAULT_BIND, the + WireGuard interface). Use 0.0.0.0 only if you intend + to expose an open resolver to the internet. + --check Verify an existing install, change nothing + -h, --help Show this help +EOF + exit 0 +} + +require_tools() { + # Per repo policy a script installs what it needs rather than telling the + # user to. pacman is non-interactive here; if that fails we stop rather + # than silently skipping the dependency. + local missing=() + command -v stunnel >/dev/null 2>&1 || missing+=(stunnel) + command -v openssl >/dev/null 2>&1 || missing+=(openssl) + if [[ ${#missing[@]} -gt 0 ]]; then + log "Installing: ${missing[*]}" + sudo pacman -S --needed --noconfirm "${missing[@]}" || + die "could not install ${missing[*]}; run: sudo pacman -S ${missing[*]}" + fi +} + +cert_source_dir() { + # Caddy stores certs per-domain under its data volume. + printf '/data/caddy/certificates/acme-v02.api.letsencrypt.org-directory/%s' \ + "$DOMAIN" +} + +verify_prerequisites() { + # A DoT endpoint in front of a resolver that is not running would answer + # every query with a connection error, which looks exactly like a network + # fault on the phone. Check first. + ss -lnu 2>/dev/null | grep -qE '127\.0\.0\.1:53' || + die "dnsmasq is not listening on 127.0.0.1:53 - run setup_dns_blocker.sh first" + + docker ps --format '{{.Names}}' 2>/dev/null | grep -qx "$CADDY_CONTAINER" || + die "container '$CADDY_CONTAINER' is not running; it owns the certificates" + + local resolved + resolved="$(getent hosts "$DOMAIN" | awk '{print $1; exit}')" || + die "$DOMAIN does not resolve - DuckDNS wildcard missing?" + log "$DOMAIN resolves to $resolved" + + # Binding to an address that does not exist yet makes stunnel fail at + # start with an opaque error, so check before installing anything. + if [[ "$BIND_ADDR" != "0.0.0.0" ]]; then + ip -brief addr show 2>/dev/null | grep -q "$BIND_ADDR" || + die "$BIND_ADDR is not assigned to any interface - is wg0 up?" + log "Binding to $BIND_ADDR (reachable over WireGuard only)" + else + log "WARNING: binding to 0.0.0.0 exposes an open resolver publicly" + fi +} + +install_cert_sync() { + # Caddy renews on its own schedule; stunnel reads files from disk and must + # be told. A timer copies the current cert out of the container and + # reloads stunnel only when the content actually changed. + sudo install -d -m 700 "$CERT_DIR" + sudo tee "$SYNC_SCRIPT" >/dev/null <"\$tmp/combined.pem" +if ! cmp -s "\$tmp/combined.pem" "$CERT_DIR/combined.pem"; then + install -m 600 "\$tmp/combined.pem" "$CERT_DIR/combined.pem" + systemctl reload-or-restart stunnel-dot.service + echo "certificate updated for $DOMAIN" +fi +EOF + sudo chmod 755 "$SYNC_SCRIPT" +} + +install_stunnel_conf() { + sudo tee "$STUNNEL_CONF" >/dev/null </dev/null </dev/null </dev/null </dev/null 2>&1 && sudo nft list table inet filter >/dev/null 2>&1; then + if ! sudo nft list table inet filter | grep -q "dport $DOT_PORT"; then + sudo nft add rule inet filter input tcp dport "$DOT_PORT" accept || + log "WARNING: could not add nftables rule for $DOT_PORT" + fi + fi + log "Ensure your router forwards TCP $DOT_PORT to this host." +} + +run_check() { + local ok=0 + systemctl is-active --quiet stunnel-dot.service || + { + log "FAIL stunnel-dot.service not active" + ok=1 + } + ss -lnt 2>/dev/null | grep -qE ":$DOT_PORT\b" || + { + log "FAIL nothing listening on $DOT_PORT" + ok=1 + } + # $CERT_DIR is mode 700 and owned by root because it holds a private key, + # so an unprivileged [[ -s ]] reports "missing" for a file that is + # present. Test as root, or --check reports a false failure on a healthy + # install. + sudo test -s "$CERT_DIR/combined.pem" || + { + log "FAIL certificate missing at $CERT_DIR/combined.pem" + ok=1 + } + if command -v kdig >/dev/null 2>&1; then + kdig +tls "@$BIND_ADDR" -p "$DOT_PORT" +short example.com >/dev/null 2>&1 || + { + log "FAIL DoT query did not answer" + ok=1 + } + fi + [[ $ok -eq 0 ]] && log "OK - DoT resolver healthy on $DOMAIN:$DOT_PORT" + return "$ok" +} + +main() { + if [[ $CHECK_ONLY -eq 1 ]]; then + run_check + exit $? + fi + + require_tools + verify_prerequisites + install_cert_sync + install_stunnel_conf + install_units + + log "Syncing certificate..." + sudo "$SYNC_SCRIPT" || die "certificate sync failed - has Caddy issued $DOMAIN yet?" + + sudo systemctl enable --now stunnel-dot.service + sudo systemctl enable --now dot-cert-sync.timer + open_firewall + + log "Done. Point the phone at:" + log " Private DNS hostname: $DOMAIN" + log "And set in phone_focus_mode/config.sh:" + log " DNS_TRUSTED_DOT_HOST=\"$DOMAIN\"" + log " DNS_TRUSTED_DOT_IPS=\"\$(getent hosts $DOMAIN | awk '{print \$1}')\"" + run_check || true +} + +while [[ $# -gt 0 ]]; do + case $1 in + --domain) + DOMAIN="$2" + shift 2 + ;; + --bind) + BIND_ADDR="$2" + shift 2 + ;; + --check) + CHECK_ONLY=1 + shift + ;; + -h | --help) usage ;; + *) die "Unknown option: $1" ;; + esac +done + +main "$@" diff --git a/phone_focus_mode/config.sh b/phone_focus_mode/config.sh index b2b0fb03..68115708 100755 --- a/phone_focus_mode/config.sh +++ b/phone_focus_mode/config.sh @@ -231,6 +231,22 @@ i9.ytimg.com # endpoints so lookups fall back to the system resolver -> hosts file. export DNS_CHECK_INTERVAL=20 export DNS_LOG="$STATE_DIR/dns_enforcer.log" +# --- Trusted DoT resolver (opt-in, empty = old behaviour) --- +# Set this to the hostname of a DoT resolver you control, and the enforcer +# switches from "no DoT at all" to "only YOUR DoT". Two things change: +# 1. an ACCEPT for that resolver's IPs is inserted BEFORE the blanket +# 853 REJECT, so the connection survives; +# 2. private_dns_mode is pinned to "hostname" with this specifier, instead +# of being forced off -- so the phone cannot silently fall back to a +# resolver that does not filter. +# Leave EMPTY to keep the original behaviour (Private DNS forced off, all +# 853 rejected). Do not point this at a public resolver: the whole point is +# that the resolver applies your blocklist. See python_pkg/focus_policy. +export DNS_TRUSTED_DOT_HOST="dns.kuhy.duckdns.org" +# Where to resolve DNS_TRUSTED_DOT_HOST when Private DNS is pinned to it. +# Chicken-and-egg: the resolver's own name must resolve without using it. +# Space-separated IPv4/IPv6 literals; leave empty to resolve at runtime. +export DNS_TRUSTED_DOT_IPS="10.8.0.1" # iptables chain used exclusively by us; we flush+refill it every check. export DNS_IPT_CHAIN="FOCUS_DNS_BLOCK" # DoH/DoT endpoints to DROP. Well-known public resolvers used by browsers diff --git a/phone_focus_mode/dns_enforcer.sh b/phone_focus_mode/dns_enforcer.sh index e53e1dc6..7b1beb6d 100755 --- a/phone_focus_mode/dns_enforcer.sh +++ b/phone_focus_mode/dns_enforcer.sh @@ -71,6 +71,21 @@ acquire_lock() { # ---- Private DNS ---- ensure_private_dns_off() { + # Two modes, selected by DNS_TRUSTED_DOT_HOST: + # + # empty - the original behaviour. DoT is a bypass channel, so Private + # DNS is forced off and every 853 connection is rejected. + # set - DoT is the *enforcement* channel. Private DNS is pinned to + # our own filtering resolver, and pinning it is what stops the + # user picking an unfiltered one from Settings. + # + # The pinned case is what an unrooted phone needs, since it has no hosts + # file to fall back to. + if [ -n "${DNS_TRUSTED_DOT_HOST:-}" ]; then + ensure_private_dns_pinned + return + fi + local mode mode="$(settings get global private_dns_mode 2>/dev/null)" # Possible values: "off", "opportunistic", "hostname", null (default=opportunistic) @@ -88,6 +103,25 @@ ensure_private_dns_off() { fi } +ensure_private_dns_pinned() { + # Hold Private DNS on our own resolver. Re-asserted every check, so a + # change made in Settings is reverted within DNS_CHECK_INTERVAL. + local mode spec + spec="$(settings get global private_dns_specifier 2>/dev/null)" + if [ "$spec" != "$DNS_TRUSTED_DOT_HOST" ]; then + settings put global private_dns_specifier "$DNS_TRUSTED_DOT_HOST" 2>/dev/null + log "private_dns_specifier was '$spec' - pinned to '$DNS_TRUSTED_DOT_HOST'" + fi + # Set the specifier before the mode: switching to "hostname" while the + # specifier still names someone else would briefly route lookups through + # that other resolver. + mode="$(settings get global private_dns_mode 2>/dev/null)" + if [ "$mode" != "hostname" ]; then + settings put global private_dns_mode hostname 2>/dev/null + log "Private DNS was '$mode' - forced to 'hostname'" + fi +} + # ---- iptables chain management ---- ensure_chain() { @@ -117,10 +151,35 @@ ensure_chain() { fi } +trusted_dot_ips() { + # Echo the trusted resolver's addresses of the given family, one per line. + # $1 is "4" or "6". Literals come from DNS_TRUSTED_DOT_IPS; anything else + # is ignored, because resolving the resolver's own name here would be + # circular once Private DNS is pinned to it. + local want="$1" ip + [ -z "${DNS_TRUSTED_DOT_HOST:-}" ] && return 0 + for ip in ${DNS_TRUSTED_DOT_IPS:-}; do + [ -z "$ip" ] && continue + [ "${ip#\#}" != "$ip" ] && continue + case "$ip" in + *:*) [ "$want" = "6" ] && printf '%s\n' "$ip" ;; + *) [ "$want" = "4" ] && printf '%s\n' "$ip" ;; + esac + done +} + fill_chain_v4() { # Flush and refill so we always converge to the intended rule set. iptables -F "$DNS_IPT_CHAIN" 2>/dev/null || return 1 - # Drop DoT everywhere. This is a narrow port rule - there's no legit + # Allow the one DoT resolver we trust, BEFORE the blanket reject below. + # Order matters: iptables takes the first match, so an ACCEPT appended + # after the REJECT would never be reached. + local trusted + for trusted in $(trusted_dot_ips 4); do + iptables -A "$DNS_IPT_CHAIN" -d "$trusted" -p tcp --dport 853 \ + -j ACCEPT 2>/dev/null || true + done + # Drop DoT everywhere else. This is a narrow port rule - there's no legit # reason for arbitrary apps to talk 853/tcp on Android. iptables -A "$DNS_IPT_CHAIN" -p tcp --dport 853 -j REJECT \ --reject-with tcp-reset 2>/dev/null || true @@ -147,6 +206,11 @@ fill_chain_v4() { fill_chain_v6() { ip6tables -F "$DNS_IPT_CHAIN" 2>/dev/null || return 1 + local trusted + for trusted in $(trusted_dot_ips 6); do + ip6tables -A "$DNS_IPT_CHAIN" -d "$trusted" -p tcp --dport 853 \ + -j ACCEPT 2>/dev/null || true + done ip6tables -A "$DNS_IPT_CHAIN" -p tcp --dport 853 -j REJECT \ --reject-with tcp-reset 2>/dev/null || true ip6tables -A "$DNS_IPT_CHAIN" -p udp --dport 853 -j REJECT \ @@ -179,13 +243,25 @@ fill_chain_v6() { # redundant identical rebuilds are skipped. expected_rule_count() { - # 2 fixed DoT rules (tcp+udp/853) + 4 rules per configured DoH/DNS IP. + # 2 fixed DoT rules (tcp+udp/853) + 4 rules per configured DoH/DNS IP, + # plus 1 ACCEPT per trusted-resolver address of this family. $1 is the + # family ("4"/"6"); the remaining arguments are the DoH/DNS IPs. + # + # This has to match fill_chain_* exactly. chain_intact() compares the + # live rule count against this number and rebuilds on any mismatch, so + # an undercount here means a rebuild every DNS_CHECK_INTERVAL seconds -- + # a silent fork storm that also drops the connection each time. + local family="$1" + shift local n=2 ip for ip in "$@"; do [ -z "$ip" ] && continue [ "${ip#\#}" != "$ip" ] && continue n=$((n + 4)) done + for ip in $(trusted_dot_ips "$family"); do + n=$((n + 1)) + done echo "$n" } @@ -202,7 +278,7 @@ enforce_iptables() { # configured IP list. This runs under /system/bin/sh on the phone, where # arrays do not exist, so an unquoted expansion is the POSIX way to do it. # shellcheck disable=SC2086 - if chain_intact iptables "$(expected_rule_count $DNS_DOH_IPV4)"; then + if chain_intact iptables "$(expected_rule_count 4 $DNS_DOH_IPV4)"; then : elif ensure_chain iptables && fill_chain_v4; then log "iptables (v4) DNS chain rebuilt (was missing/tampered)" @@ -211,7 +287,7 @@ enforce_iptables() { if command -v ip6tables >/dev/null 2>&1; then # Intentional word split, as above. # shellcheck disable=SC2086 - if chain_intact ip6tables "$(expected_rule_count $DNS_DOH_IPV6)"; then + if chain_intact ip6tables "$(expected_rule_count 6 $DNS_DOH_IPV6)"; then : elif ensure_chain ip6tables && fill_chain_v6; then log "ip6tables (v6) DNS chain rebuilt (was missing/tampered)" diff --git a/python_pkg/focus_policy/__main__.py b/python_pkg/focus_policy/__main__.py new file mode 100644 index 00000000..b01581d4 --- /dev/null +++ b/python_pkg/focus_policy/__main__.py @@ -0,0 +1,80 @@ +r"""Render the focus policy as JSON for a non-Python enforcement backend. + +The Device Owner app is Kotlin and cannot read ``config.sh`` or import this +package, so the policy is handed over as a generated asset: + + python3 -m python_pkg.focus_policy \\ + --config phone_focus_mode/config.sh \\ + --output focus_owner/assets/policy.json + +``--redact-home`` blanks the coordinates, for a policy that will be committed +or attached to a report. +""" + +from __future__ import annotations + +import argparse +from pathlib import Path +import sys + +from python_pkg.focus_policy.export import policy_to_json +from python_pkg.focus_policy.loader import load_policy +from python_pkg.focus_policy.model import PolicyError + + +def build_parser() -> argparse.ArgumentParser: + """Return the argument parser for the policy exporter.""" + parser = argparse.ArgumentParser( + prog="python3 -m python_pkg.focus_policy", + description="Render phone_focus_mode's policy as backend-neutral JSON.", + ) + parser.add_argument( + "--config", + type=Path, + required=True, + help="path to phone_focus_mode/config.sh", + ) + parser.add_argument( + "--secrets", + type=Path, + default=None, + help="path to config_secrets.sh (defaults to alongside --config)", + ) + parser.add_argument( + "--output", + type=Path, + default=None, + help="file to write (defaults to stdout)", + ) + parser.add_argument( + "--redact-home", + action="store_true", + help="blank the home coordinates, keeping radius and hysteresis", + ) + return parser + + +def main(argv: list[str] | None = None) -> int: + """Render the policy, returning a process exit code.""" + args = build_parser().parse_args(argv) + try: + policy = load_policy(args.config, args.secrets) + except (PolicyError, OSError) as error: + # Written to stderr rather than raised: the caller is a shell script or + # a build step, and a bare traceback buries the one line that says the + # coordinates were never filled in. + sys.stderr.write(f"error: {error}\n") + return 1 + + rendered = policy_to_json(policy, redact_home=args.redact_home) + "\n" + if args.output is None: + sys.stdout.write(rendered) + else: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(rendered, encoding="utf-8") + sys.stderr.write(f"wrote {args.output}\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/python_pkg/focus_policy/tests/test_main.py b/python_pkg/focus_policy/tests/test_main.py new file mode 100644 index 00000000..dad113f4 --- /dev/null +++ b/python_pkg/focus_policy/tests/test_main.py @@ -0,0 +1,110 @@ +"""Tests for the policy-export command line entry point.""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING + +import pytest + +from python_pkg.focus_policy.__main__ import build_parser, main + +if TYPE_CHECKING: + from pathlib import Path + +MINIMAL_CONFIG = """ +export RADIUS=150 +export NIGHT_CURFEW_ENABLED=1 +export NIGHT_CURFEW_START="2300" +export NIGHT_CURFEW_END="0500" +export LAUNCHER_PACKAGE="com.launcher" +export WHITELIST=" +com.launcher +pl.mbank +" +export NIGHT_WHITELIST=" +pl.mbank +" +export SYSTEM_NEVER_DISABLE=" +com.android.settings +" +""" + +SECRETS = "export HOME_LAT=52.2297\nexport HOME_LON=21.0122\n" + + +@pytest.fixture +def config(tmp_path: Path) -> Path: + """Write a minimal config plus secrets, returning the config path.""" + (tmp_path / "config.sh").write_text(MINIMAL_CONFIG, encoding="utf-8") + (tmp_path / "config_secrets.sh").write_text(SECRETS, encoding="utf-8") + return tmp_path / "config.sh" + + +def test_parser_requires_config() -> None: + """--config is mandatory; without it argparse exits.""" + with pytest.raises(SystemExit): + build_parser().parse_args([]) + + +def test_writes_policy_to_stdout( + config: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """With no --output the rendered policy goes to stdout.""" + assert main(["--config", str(config)]) == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["schema_version"] == 1 + assert payload["home"]["latitude"] == pytest.approx(52.2297) + + +def test_writes_policy_to_file(config: Path, tmp_path: Path) -> None: + """--output writes the document, creating parent directories.""" + out = tmp_path / "nested" / "policy.json" + assert main(["--config", str(config), "--output", str(out)]) == 0 + assert json.loads(out.read_text(encoding="utf-8"))["schema_version"] == 1 + + +def test_redact_home_removes_coordinates( + config: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """--redact-home blanks the coordinates but keeps the radius. + + This is the flag the committed asset is generated with, so that the + repository never records where the user lives. + """ + assert main(["--config", str(config), "--redact-home"]) == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["home"]["latitude"] is None + assert payload["home"]["longitude"] is None + assert payload["home"]["radius_m"] == pytest.approx(150.0) + + +def test_explicit_secrets_path(config: Path, tmp_path: Path) -> None: + """--secrets overrides the default sibling lookup.""" + other = tmp_path / "other.sh" + other.write_text("export HOME_LAT=1.0\nexport HOME_LON=2.0\n", encoding="utf-8") + assert main(["--config", str(config), "--secrets", str(other)]) == 0 + + +def test_placeholder_coordinates_exit_nonzero( + config: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """The shipped placeholder is a hard error, not a silent default.""" + config.with_name("config_secrets.sh").write_text( + "export HOME_LAT=REDACTED_LAT\nexport HOME_LON=REDACTED_LON\n", + encoding="utf-8", + ) + assert main(["--config", str(config)]) == 1 + assert "placeholder" in capsys.readouterr().err + + +def test_missing_config_exits_nonzero( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """An unreadable config reports the OS error rather than traceback.""" + assert main(["--config", str(tmp_path / "absent.sh")]) == 1 + assert "error:" in capsys.readouterr().err