From a48077078ceafa15e0d0eccb53625a85e363a941 Mon Sep 17 00:00:00 2001 From: oxmc7769 Date: Sat, 25 Jul 2026 01:35:58 -0700 Subject: [PATCH] Initial commit: PawletProfiled, Android device-owner priv-app Android counterpart to git.oxmc.me/PawletOS/profiled's Linux daemon -- independent implementation, not a port. Same .vconfig profile format and 17 payload types, but implemented against DevicePolicyManager/VpnManager/ WifiManager/WallpaperManager/KeyChain instead of a native NDK Binder daemon, since that's where AOSP actually exposes this functionality. Self-provisions as device owner at first boot (DeviceOwnerProvisioner.kt) to unlock the DevicePolicyManager-gated payload types (cert, pkcs12, passcode, proxy, screensaver lock enforcement). 16 of 17 payload types are real implementations; firewall is a documented platform dead end (no app UID gets CAP_NET_ADMIN). See README's capability matrix for the full per-payload breakdown. Reviewed against the documented @SystemApi/hidden-API surface, not compiled -- no AOSP toolchain available in this environment. --- Android.bp | 93 ++++++++++ AndroidManifest.xml | 87 +++++++++ README.md | 169 +++++++++++++++++ .../profiled/IPawletProfileService.aidl | 46 +++++ ...privapp-permissions-os.pawlet.profiled.xml | 15 ++ res/values/strings.xml | 6 + res/xml/device_admin_receiver.xml | 15 ++ sepolicy/file_contexts | 11 ++ sepolicy/pawletprofiled.te | 46 +++++ .../pawlet/profiled/BootCompletedReceiver.kt | 55 ++++++ .../pawlet/profiled/DeviceOwnerProvisioner.kt | 48 +++++ src/os/pawlet/profiled/Fields.kt | 52 ++++++ .../profiled/PawletDeviceAdminReceiver.kt | 40 ++++ .../profiled/PawletProfileApplication.kt | 140 ++++++++++++++ .../pawlet/profiled/PawletProfileService.kt | 75 ++++++++ src/os/pawlet/profiled/ProfileModels.kt | 43 +++++ src/os/pawlet/profiled/ProfileParser.kt | 128 +++++++++++++ src/os/pawlet/profiled/ProfileStore.kt | 153 +++++++++++++++ src/os/pawlet/profiled/SignatureVerifier.kt | 139 ++++++++++++++ .../pawlet/profiled/payloads/CertHandler.kt | 49 +++++ .../profiled/payloads/ContentCacheHandler.kt | 46 +++++ .../profiled/payloads/DnsProxyHandler.kt | 54 ++++++ .../profiled/payloads/EthernetHandler.kt | 103 +++++++++++ .../profiled/payloads/FirewallHandler.kt | 37 ++++ .../profiled/payloads/FirstBootHandler.kt | 61 ++++++ .../pawlet/profiled/payloads/LdapHandler.kt | 99 ++++++++++ src/os/pawlet/profiled/payloads/MdmHandler.kt | 59 ++++++ .../profiled/payloads/PasscodeHandler.kt | 77 ++++++++ .../profiled/payloads/PayloadHandler.kt | 38 ++++ .../pawlet/profiled/payloads/Pkcs12Handler.kt | 74 ++++++++ src/os/pawlet/profiled/payloads/Prefs.kt | 36 ++++ .../pawlet/profiled/payloads/ProxyHandler.kt | 66 +++++++ .../profiled/payloads/ScreensaverHandler.kt | 62 +++++++ .../payloads/SoftwareUpdateHandler.kt | 59 ++++++ .../profiled/payloads/TimeServerHandler.kt | 39 ++++ src/os/pawlet/profiled/payloads/VpnHandler.kt | 99 ++++++++++ .../profiled/payloads/WallpaperHandler.kt | 70 +++++++ .../pawlet/profiled/payloads/WifiHandler.kt | 159 ++++++++++++++++ .../profiled/zte/AttestationKeyHasher.kt | 44 +++++ src/os/pawlet/profiled/zte/DeviceIdentity.kt | 88 +++++++++ src/os/pawlet/profiled/zte/ZteLookupClient.kt | 174 ++++++++++++++++++ 41 files changed, 2954 insertions(+) create mode 100644 Android.bp create mode 100644 AndroidManifest.xml create mode 100644 README.md create mode 100644 aidl/os/pawlet/profiled/IPawletProfileService.aidl create mode 100644 etc/privapp-permissions-os.pawlet.profiled.xml create mode 100644 res/values/strings.xml create mode 100644 res/xml/device_admin_receiver.xml create mode 100644 sepolicy/file_contexts create mode 100644 sepolicy/pawletprofiled.te create mode 100644 src/os/pawlet/profiled/BootCompletedReceiver.kt create mode 100644 src/os/pawlet/profiled/DeviceOwnerProvisioner.kt create mode 100644 src/os/pawlet/profiled/Fields.kt create mode 100644 src/os/pawlet/profiled/PawletDeviceAdminReceiver.kt create mode 100644 src/os/pawlet/profiled/PawletProfileApplication.kt create mode 100644 src/os/pawlet/profiled/PawletProfileService.kt create mode 100644 src/os/pawlet/profiled/ProfileModels.kt create mode 100644 src/os/pawlet/profiled/ProfileParser.kt create mode 100644 src/os/pawlet/profiled/ProfileStore.kt create mode 100644 src/os/pawlet/profiled/SignatureVerifier.kt create mode 100644 src/os/pawlet/profiled/payloads/CertHandler.kt create mode 100644 src/os/pawlet/profiled/payloads/ContentCacheHandler.kt create mode 100644 src/os/pawlet/profiled/payloads/DnsProxyHandler.kt create mode 100644 src/os/pawlet/profiled/payloads/EthernetHandler.kt create mode 100644 src/os/pawlet/profiled/payloads/FirewallHandler.kt create mode 100644 src/os/pawlet/profiled/payloads/FirstBootHandler.kt create mode 100644 src/os/pawlet/profiled/payloads/LdapHandler.kt create mode 100644 src/os/pawlet/profiled/payloads/MdmHandler.kt create mode 100644 src/os/pawlet/profiled/payloads/PasscodeHandler.kt create mode 100644 src/os/pawlet/profiled/payloads/PayloadHandler.kt create mode 100644 src/os/pawlet/profiled/payloads/Pkcs12Handler.kt create mode 100644 src/os/pawlet/profiled/payloads/Prefs.kt create mode 100644 src/os/pawlet/profiled/payloads/ProxyHandler.kt create mode 100644 src/os/pawlet/profiled/payloads/ScreensaverHandler.kt create mode 100644 src/os/pawlet/profiled/payloads/SoftwareUpdateHandler.kt create mode 100644 src/os/pawlet/profiled/payloads/TimeServerHandler.kt create mode 100644 src/os/pawlet/profiled/payloads/VpnHandler.kt create mode 100644 src/os/pawlet/profiled/payloads/WallpaperHandler.kt create mode 100644 src/os/pawlet/profiled/payloads/WifiHandler.kt create mode 100644 src/os/pawlet/profiled/zte/AttestationKeyHasher.kt create mode 100644 src/os/pawlet/profiled/zte/DeviceIdentity.kt create mode 100644 src/os/pawlet/profiled/zte/ZteLookupClient.kt diff --git a/Android.bp b/Android.bp new file mode 100644 index 0000000..437799a --- /dev/null +++ b/Android.bp @@ -0,0 +1,93 @@ +// vendor/oxmc/PawletProfiled/Android.bp +// ───────────────────────────────────────────────────────────────────────── +// Drop this directory at vendor/oxmc/PawletProfiled/ and add +// "PawletProfiled" to PRODUCT_PACKAGES, plus +// PRODUCT_COPY_FILES += vendor/oxmc/PawletProfiled/etc/privapp-permissions-os.pawlet.profiled.xml:$(TARGET_COPY_OUT_SYSTEM)/etc/permissions/privapp-permissions-os.pawlet.profiled.xml +// +// This is the Android counterpart to git.oxmc.me/PawletOS/profiled's Linux +// daemon: same profile format (.vconfig, CMS/PKCS#7-signed YAML), same +// payload types, but implemented as a platform-signed priv-app + device +// owner instead of a native Binder daemon — most of what these payloads +// need (DevicePolicyManager, VpnManager, WifiManager, WallpaperManager, +// KeyChain) is Java-SDK-first on Android, not reachable cleanly from a +// native process. See README.md for the full capability matrix. +// ───────────────────────────────────────────────────────────────────────── + +// ── AIDL interface ──────────────────────────────────────────────────────── + +aidl_interface { + name: "os.pawlet.profiled", + srcs: ["aidl/os/pawlet/profiled/IPawletProfileService.aidl"], + unstable: true, // app-hosted bound service, not a servicemanager HAL + backend: { + java: { + enabled: true, + sdk_version: "system_current", + }, + cpp: { enabled: false }, + ndk: { enabled: false }, + }, +} + +// ── App ────────────────────────────────────────────────────────────────── +// +// Third-party deps (snakeyaml, Bouncy Castle bcprov/bcpkix, UnboundID +// LDAP SDK) are NOT vendored in this repo — pulled in via maven-to-lib +// (git.oxmc.me/PawletOS/maven-to-lib) as prebuilt_libs repos on the local +// manifest, same as every other Maven dep in the tree. Add to +// maven-to-lib's config.yml under `libs:`: +// +// - name: bouncycastle +// version: "1.78.1" +// artifacts: +// - { group: org.bouncycastle, artifact: bcprov-jdk18on, type: jar } +// - { group: org.bouncycastle, artifact: bcpkix-jdk18on, type: jar } +// - name: snakeyaml +// version: latest +// artifacts: +// - { group: org.yaml, artifact: snakeyaml, type: jar } +// - name: unboundid-ldapsdk +// version: latest +// artifacts: +// - { group: com.unboundid, artifact: unboundid-ldapsdk, type: jar } +// +// then add each output repo to the local manifest as +// prebuilts/application_libs/ (PawletOS/prebuilt_libs_) per +// maven-to-lib's own docstring. Default bp_name is the bare artifact id, +// so the static_libs entries below (bcprov-jdk18on, bcpkix-jdk18on, +// snakeyaml, unboundid-ldapsdk) are what maven-to-lib generates unmodified +// — only add a bp_name override in config.yml if one of those collides +// with an existing in-tree module name. + +android_app { + name: "PawletProfiled", + platform_apis: true, + certificate: "platform", + privileged: true, + system_ext_specific: false, + + manifest: "AndroidManifest.xml", + resource_dirs: ["res"], + + srcs: ["src/**/*.kt"], + + static_libs: [ + "os.pawlet.profiled-V1-java", + "snakeyaml", + "bcprov-jdk18on", + "bcpkix-jdk18on", + "unboundid-ldapsdk", + ], + + optimize: { + enabled: false, // system app; no need to shrink/obfuscate + }, + + required: ["privapp-permissions-os.pawlet.profiled.xml"], +} + +prebuilt_etc { + name: "privapp-permissions-os.pawlet.profiled.xml", + src: "etc/privapp-permissions-os.pawlet.profiled.xml", + sub_dir: "permissions", +} diff --git a/AndroidManifest.xml b/AndroidManifest.xml new file mode 100644 index 0000000..f7ef416 --- /dev/null +++ b/AndroidManifest.xml @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..1ee2d16 --- /dev/null +++ b/README.md @@ -0,0 +1,169 @@ +# PawletProfiled + +PawletOS's Android device management app — a platform-signed priv-app and +device owner. Android counterpart to +[`pawletprofiled`](https://git.oxmc.me/PawletOS/profiled), the Linux +daemon: same `.vconfig` profile format, same payload types, same +`os.pawlet.profiled` AIDL contract — but an independent implementation, +not shared code. Most of what these payloads need on Android +(`DevicePolicyManager`, `VpnManager`, `WifiManager`, `WallpaperManager`, +`KeyChain`) is Java-SDK-first and not cleanly reachable from a native +process, which is why this is an app instead of another NDK binder daemon. + +--- + +## Contents + +- [Why an app, not a daemon](#why-an-app-not-a-daemon) +- [Device owner](#device-owner) +- [Payload capability matrix](#payload-capability-matrix) +- [Architecture](#architecture) +- [Third-party dependencies](#third-party-dependencies) +- [Building](#building) +- [Package layout](#package-layout) + +--- + +## Why an app, not a daemon + +The Linux daemon is a native root process that owns the machine outright — +writing NetworkManager keyfiles, editing PAM/nftables config, calling +`update-ca-certificates`. Android doesn't expose an equivalent to *any* +app UID, root included: Wi-Fi, VPN, certificate installation, and +password policy all go through `DevicePolicyManager`/`WifiManager`/ +`VpnManager`, which are Java APIs gated on **device owner** status, not +Linux-style file permissions. Standing up a native process just to +`AIDL`-call back into `system_server` for everything would be strictly +more code than calling those APIs directly — so this is a Kotlin app. + +## Device owner + +`DeviceOwnerProvisioner.kt` self-provisions via +`DevicePolicyManager.setDeviceOwner()` — the same `@SystemApi` call +ManagedProvisioning itself uses — from `BootCompletedReceiver`, guarded by +`isDeviceOwnerApp()`. This only succeeds pre-SUW, before any account +exists, which a factory-fresh PawletOS image satisfies on first boot. +Every later boot's call is expected to fail and is treated as "already +provisioned," not an error. + +## Payload capability matrix + +| Payload | Status | Notes | +|---|---|---| +| `wifi` | Real | `WifiManager.addNetwork()` (privileged path — the only one covering WEP + enterprise + static proxy in one object) | +| `ethernet` | Real (IP/proxy) | `EthernetManager`. 802.1X not applied — no stable cross-device `@SystemApi` for wired EAP; depends on the board's Ethernet HAL | +| `vpn` | Real (IKEv2 only) | `VpnManager`/`Ikev2VpnProfile`, PSK or username+password. L2TP: no AOSP client exists at all. Custom: no bundled tunnel backend, same as the Linux side's own placeholder | +| `cert` | Real | `DevicePolicyManager.installCaCert()` | +| `pkcs12` | Real | Parsed locally via `KeyStore("PKCS12")`, installed via `DevicePolicyManager.installKeyPair()` | +| `passcode` | Real | `DevicePolicyManager` password-quality/history/lockout setters | +| `mdm` | Real | Device-owner status *is* the enrollment; this records server metadata for `isDeviceManaged()`/`getMdmServerUrl()` | +| `software-update` | Bridged | Writes a policy file for BgUpd to read — BgUpd doesn't read it yet, same "wired one side, not both" state as the Linux daemon's own comment about this integration | +| `time-server` | Real | `Settings.Global.NTP_SERVER` | +| `proxy` | Real | `DevicePolicyManager.setRecommendedGlobalProxy()`. No proxy-auth — `ProxyInfo` doesn't carry credentials | +| `dns-proxy` | Real (narrower) | Android Private DNS is DoT-hostname only, not arbitrary DoH URLs; the host is extracted from whatever URL the payload carries | +| `firewall` | **Not applicable** | No app UID gets `CAP_NET_ADMIN`, device owner included, and stock Android has no unsolicited-inbound-connection surface to protect. The one genuine dead end — not a missing library, missing kernel privilege | +| `ldap` | Real | Bundled UnboundID LDAP SDK (Android has no JNDI/system LDAP client at all) actually binds and searches at apply time. No OS-level directory-accounts sync (Android has nothing like macOS Open Directory) | +| `wallpaper` | Real | `WallpaperManager`. `locked` recorded, not enforced (no such API) | +| `screensaver` | Real | Daydream settings + `DevicePolicyManager.setMaximumTimeToLock()` for the actual security-relevant lock enforcement | +| `first-boot` | Real (coarser) | `DEVICE_PROVISIONED`/`user_setup_complete` skip the *entire* Setup Wizard in one step, vs. the Linux side's pane-by-pane cloud-init module list | +| `content-cache` | Real | Writes `/data/misc/pawletcache/policy.json` — PawletOS-specific, read by [`android_packages_apps_PawletCache`](https://git.oxmc.me/PawletOS/android_packages_apps_PawletCache)'s `PolicyOverride.kt` | + +## Architecture + +``` +BootCompletedReceiver ──▶ DeviceOwnerProvisioner (first boot only) + │ │ + │ ▼ + │ preinstalled/*.vconfig ──▶ PawletProfileApplication + │ │ + └──▶ ZteLookupClient (enrollment) ─────────────────────┤ + ▼ +IPawletProfileService.Stub (PawletProfileService) ──▶ PawletProfileApplication + │ + ┌─────────────────────┤ + ▼ ▼ + SignatureVerifier ProfileParser + (Bouncy Castle CMS) (SnakeYAML) + │ │ + └──────────┬──────────┘ + ▼ + ProfileStore + (/data/system/pawletos/profiles) + │ + ▼ + PayloadHandlerRegistry.forType() + ──▶ one of 17 PayloadHandlers +``` + +`PawletProfileApplication` is the shared core (mirrors the Linux daemon's +`PawletProfileService.cpp`) — both the boot-time preinstalled-profile path +and the AIDL-facing bound-service path install through the same +`installProfileDirect()`, against the same `ProfileStore`, so there's one +source of truth regardless of which path a profile came in through. + +## Third-party dependencies + +Not vendored as jars in this repo — pulled in via +[`maven-to-lib`](https://git.oxmc.me/PawletOS/maven-to-lib) as +`prebuilt_libs` repos on the local manifest, same as every other Maven +dependency in the PawletOS tree. See the comment block at the top of +`Android.bp` for the exact `config.yml` entries. Three libraries, all pure +Java (no native/JNI component, no per-ABI split needed): + +| Library | Fills the gap left by | +|---|---| +| SnakeYAML | No YAML parser anywhere in the Android platform/SDK | +| Bouncy Castle (`bcprov`+`bcpkix`) | BoringSSL has no CMS/PKCS#7 support — same gap the Linux daemon solves by vendoring a static OpenSSL, solved here with a pure-Java library instead | +| UnboundID LDAP SDK | Android has no `javax.naming`/JNDI and no system LDAP client | + +## Building + +Not independently buildable outside an AOSP tree — this is a +`platform_apis: true`, `certificate: "platform"` priv-app. + +```bash +cp -r android_packages_apps_PawletProfiled $AOSP_ROOT/vendor/oxmc/PawletProfiled/ + +echo 'PRODUCT_PACKAGES += PawletProfiled' >> device/oxmc/pawletos/pawletos.mk +echo 'BOARD_SEPOLICY_DIRS += vendor/oxmc/PawletProfiled/sepolicy' >> device/oxmc/pawletos/BoardConfig.mk +echo 'PRODUCT_COPY_FILES += vendor/oxmc/PawletProfiled/etc/privapp-permissions-os.pawlet.profiled.xml:$(TARGET_COPY_OUT_SYSTEM)/etc/permissions/privapp-permissions-os.pawlet.profiled.xml' \ + >> device/oxmc/pawletos/pawletos.mk + +source build/envsetup.sh && lunch pawletos_arm64-userdebug +m PawletProfiled +``` + +Same disclaimer as the rest of this session's Android work: statically +reviewed against the documented `@SystemApi`/hidden-API surface, never +compiled — no AOSP toolchain available in this environment. + +## Package layout + +``` +android_packages_apps_PawletProfiled/ +├── Android.bp +├── AndroidManifest.xml +├── aidl/os/pawlet/profiled/ +│ └── IPawletProfileService.aidl +├── etc/privapp-permissions-os.pawlet.profiled.xml +├── res/ +│ ├── values/strings.xml +│ └── xml/device_admin_receiver.xml +├── sepolicy/ +│ ├── pawletprofiled.te +│ └── file_contexts +└── src/os/pawlet/profiled/ + ├── PawletProfileApplication.kt Shared core: install/remove/query, mirrors the Linux D-Bus service + ├── PawletProfileService.kt Bound Service hosting IPawletProfileService.Stub + ├── PawletDeviceAdminReceiver.kt DeviceAdminReceiver + MDM-enrolled wipe-on-disable + ├── BootCompletedReceiver.kt Device-owner provisioning, preinstalled profiles, ZTE + ├── DeviceOwnerProvisioner.kt + ├── ProfileParser.kt SnakeYAML + ├── ProfileStore.kt Same on-disk layout as ProfileStore.cpp + ├── SignatureVerifier.kt Bouncy Castle CMS + ├── ProfileModels.kt / Fields.kt + ├── payloads/ 17 handlers, one per payload type + └── zte/ + ├── DeviceIdentity.kt / AttestationKeyHasher.kt + └── ZteLookupClient.kt +``` diff --git a/aidl/os/pawlet/profiled/IPawletProfileService.aidl b/aidl/os/pawlet/profiled/IPawletProfileService.aidl new file mode 100644 index 0000000..e546a46 --- /dev/null +++ b/aidl/os/pawlet/profiled/IPawletProfileService.aidl @@ -0,0 +1,46 @@ +package os.pawlet.profiled; + +// IPawletProfileService — exposed by PawletProfileService, the bound +// Service inside this app. Clients (system apps, Settings, installer UI) +// bind action os.pawlet.profiled.action.BIND and call this directly — +// same method set as the Linux daemon's D-Bus interface, so tooling that +// talks to both platforms shares one mental model. +interface IPawletProfileService { + + // ── Profile lifecycle ───────────────────────────────────────────────── + + // Install a profile from raw YAML bytes (unsigned) or a CMS/PKCS#7 + // blob (signed). Returns the installed profile's UUID on success. + // Throws ServiceSpecificException on validation or signature failure. + String installProfile(in byte[] profileData); + + // Remove an installed profile by UUID. + // Throws if the profile is MDM-locked or removal-password protected + // and no password is supplied. + void removeProfile(in String uuid, in String removalPassword); + + // List all installed profile UUIDs. + String[] listProfiles(); + + // Return JSON-encoded metadata for a single profile. + String getProfileInfo(in String uuid); + + // ── MDM state ───────────────────────────────────────────────────────── + + // True if a valid MDM payload is enrolled. + boolean isDeviceManaged(); + + // Return the enrolled MDM server URL, or empty string if not managed. + String getMdmServerUrl(); + + // ── Supervised / kiosk state ────────────────────────────────────────── + + // True if a kiosk or ASAM payload is active. + boolean isSupervised(); + + // ── Payload query helpers ───────────────────────────────────────────── + + // Return JSON array of payloads of the given type across all profiles. + // e.g. getPayloadsOfType("wifi") → [{ssid:..., uuid:...}, ...] + String getPayloadsOfType(in String payloadType); +} diff --git a/etc/privapp-permissions-os.pawlet.profiled.xml b/etc/privapp-permissions-os.pawlet.profiled.xml new file mode 100644 index 0000000..b60491f --- /dev/null +++ b/etc/privapp-permissions-os.pawlet.profiled.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + diff --git a/res/values/strings.xml b/res/values/strings.xml new file mode 100644 index 0000000..b0b0d23 --- /dev/null +++ b/res/values/strings.xml @@ -0,0 +1,6 @@ + + + PawletOS Profile Service + PawletOS Profile Service + Applies configuration profiles (Wi-Fi, VPN, certificates, password policy, MDM enrollment) installed by pawletprofiled. Disabling this admin removes managed configuration and, if the device is enrolled, may trigger a factory reset. + diff --git a/res/xml/device_admin_receiver.xml b/res/xml/device_admin_receiver.xml new file mode 100644 index 0000000..7ccf944 --- /dev/null +++ b/res/xml/device_admin_receiver.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + diff --git a/sepolicy/file_contexts b/sepolicy/file_contexts new file mode 100644 index 0000000..e06fc88 --- /dev/null +++ b/sepolicy/file_contexts @@ -0,0 +1,11 @@ +# vendor/oxmc/PawletProfiled/sepolicy/file_contexts + +/system/priv-app/PawletProfiled(/.*)? u:object_r:system_file:s0 + +# Covers profiles/, preinstalled/, profile_ca.pem, mdm.json, zte_state.json, +# zte.conf, ldap/, software_update_policy.json, firstboot.done — every +# state path the app and its payload handlers write under +# /data/system/pawletos/. See ProfileStore.kt, MdmHandler.kt, +# ZteLookupClient.kt, LdapHandler.kt, SoftwareUpdateHandler.kt, +# FirstBootHandler.kt for the individual files. +/data/system/pawletos(/.*)? u:object_r:pawletos_system_file:s0 diff --git a/sepolicy/pawletprofiled.te b/sepolicy/pawletprofiled.te new file mode 100644 index 0000000..e74efb4 --- /dev/null +++ b/sepolicy/pawletprofiled.te @@ -0,0 +1,46 @@ +# vendor/oxmc/PawletProfiled/sepolicy/pawletprofiled.te +# SELinux policy for the PawletProfiled priv-app / device owner. +# +# Supersedes the native-daemon policy that used to live in the Linux +# pawletprofiled repo (init_daemon_domain-based) — that pattern doesn't +# apply to an app; this one uses app_domain like other PawletOS priv-apps +# (see android_packages_apps_PawletCache/sepolicy/pawlet_cache.te). +# +# To activate, add to your device's BoardConfig.mk: +# BOARD_SEPOLICY_DIRS += vendor/oxmc/PawletProfiled/sepolicy + +type pawletprofiled, domain, coredomain; +app_domain(pawletprofiled) +permissive pawletprofiled; + +type pawletprofiled_data_file, file_type, data_file_type, app_data_file_type; + +net_domain(pawletprofiled) + +allow pawletprofiled pawletprofiled_data_file:dir create_dir_perms; +allow pawletprofiled pawletprofiled_data_file:file create_file_perms; + +# ── Profile store + preinstalled profiles ───────────────────────────────── +# /data/system/pawletos/{profiles,preinstalled,profile_ca.pem} — same paths +# the Linux daemon uses, so profile UUIDs and layout stay consistent across +# platforms even though nothing else about the implementation is shared. +type pawletos_system_file, file_type, data_file_type; +allow pawletprofiled pawletos_system_file:dir create_dir_perms; +allow pawletprofiled pawletos_system_file:file create_file_perms; + +# ── Device policy / device owner ────────────────────────────────────────── +binder_call(pawletprofiled, system_server) +allow pawletprofiled device_policy_service:service_manager find; +allow pawletprofiled keystore_service:service_manager find; + +# ── Content-cache runtime override (PawletOS-specific) ──────────────────── +# Type declared in android_packages_apps_PawletCache/sepolicy/pawlet_cache.te +# (both dirs land in BOARD_SEPOLICY_DIRS) — see ContentCacheHandler.kt. +allow pawletprofiled pawletcache_policy_file:dir { create search getattr add_name }; +allow pawletprofiled pawletcache_policy_file:file create_file_perms; + +# ── Secure settings writes (WRITE_SECURE_SETTINGS) ──────────────────────── +allow pawletprofiled system_server:binder call; + +# ── Boot-completed / persistent process ─────────────────────────────────── +allow pawletprofiled self:process { fork sigchld }; diff --git a/src/os/pawlet/profiled/BootCompletedReceiver.kt b/src/os/pawlet/profiled/BootCompletedReceiver.kt new file mode 100644 index 0000000..a01dcf1 --- /dev/null +++ b/src/os/pawlet/profiled/BootCompletedReceiver.kt @@ -0,0 +1,55 @@ +package os.pawlet.profiled + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.util.Log +import os.pawlet.profiled.zte.DeviceIdentityCollector +import os.pawlet.profiled.zte.ZteLookupClient +import java.io.File +import kotlin.concurrent.thread + +// Android counterpart to main_android.cpp's boot-time responsibilities: +// device-owner self-provisioning (first boot only) and applying any +// preinstalled profiles baked into the image, same path convention +// (/data/system/pawletos/preinstalled) so an OEM image built for one +// platform's daemon drops in unchanged for the other. +class BootCompletedReceiver : BroadcastReceiver() { + + companion object { + private const val TAG = "PawletProfiled/Boot" + const val PREINSTALLED_DIR = "/data/system/pawletos/preinstalled" + } + + override fun onReceive(context: Context, intent: Intent) { + DeviceOwnerProvisioner.ensureProvisioned(context) + + val app = context.applicationContext as PawletProfileApplication + val dir = File(PREINSTALLED_DIR) + val files = dir.listFiles { f -> f.isFile } ?: run { + Log.i(TAG, "no preinstalled profiles at $PREINSTALLED_DIR") + return + } + + Log.i(TAG, "scanning $PREINSTALLED_DIR for preinstalled profiles (${files.size} found)") + for (file in files) { + try { + val uuid = app.installProfileDirect(file.readBytes()) + if (uuid != null) Log.i(TAG, "preinstalled ${file.name} -> uuid=$uuid") + else Log.w(TAG, "failed to apply preinstalled profile ${file.name}") + } catch (e: Exception) { + Log.e(TAG, "error applying preinstalled profile ${file.name}", e) + } + } + + // Network I/O off the broadcast receiver's main-thread callback. + // Single-shot only — see this file's header comment on the + // ConnectivityWatcher gap. + thread(name = "pawletprofiled-zte") { + val client = ZteLookupClient(ZteLookupClient.loadConfiguredServerUrl()) + val identity = DeviceIdentityCollector.collect(context) + val state = client.enroll(identity, app) + Log.i(TAG, "ZTE enrollment attempt finished: $state") + } + } +} diff --git a/src/os/pawlet/profiled/DeviceOwnerProvisioner.kt b/src/os/pawlet/profiled/DeviceOwnerProvisioner.kt new file mode 100644 index 0000000..202604e --- /dev/null +++ b/src/os/pawlet/profiled/DeviceOwnerProvisioner.kt @@ -0,0 +1,48 @@ +package os.pawlet.profiled + +import android.app.admin.DevicePolicyManager +import android.content.Context +import android.util.Log + +// Self-provisions this app as device owner at first boot. Standard AOSP +// managed-provisioning (NFC/QR/account-based) assumes an interactive setup +// flow; a preloaded system agent instead calls +// DevicePolicyManager.setDeviceOwner() directly — the same @SystemApi +// ManagedProvisioning itself uses, reachable here because the app is +// platform-signed and declares android.permission.MANAGE_PROFILE_AND_DEVICE_OWNERS +// (protectionLevel="signature", auto-granted, no privapp-permissions entry +// needed). +// +// This only succeeds pre-SUW, before any user account exists on the +// device — exactly the window BootCompletedReceiver's first invocation +// runs in on a factory-fresh PawletOS image. Every call after that is +// expected to fail with IllegalStateException and is treated as the +// normal "already provisioned" case, not an error. +object DeviceOwnerProvisioner { + private const val TAG = "PawletProfiled/DeviceOwner" + + fun ensureProvisioned(context: Context) { + val dpm = context.getSystemService(Context.DEVICE_POLICY_SERVICE) as DevicePolicyManager + val admin = PawletDeviceAdminReceiver.componentName(context) + + if (dpm.isDeviceOwnerApp(context.packageName)) { + Log.i(TAG, "already device owner") + return + } + + try { + @Suppress("DEPRECATION") + val ok = dpm.setDeviceOwner(admin, "PawletOS") + if (ok) { + Log.i(TAG, "provisioned as device owner") + } else { + Log.w(TAG, "setDeviceOwner returned false — device likely already has an owner or a user account exists") + } + } catch (e: Exception) { + // Expected on every boot after the first successful call, and on + // any device that already went through interactive SUW before + // this app ran (e.g. dev/test builds not using a factory image). + Log.i(TAG, "setDeviceOwner not applicable: ${e.message}") + } + } +} diff --git a/src/os/pawlet/profiled/Fields.kt b/src/os/pawlet/profiled/Fields.kt new file mode 100644 index 0000000..9e2dc8b --- /dev/null +++ b/src/os/pawlet/profiled/Fields.kt @@ -0,0 +1,52 @@ +package os.pawlet.profiled + +// Kotlin equivalent of pawletprofiled's payloads/PayloadUtil.h field()/ +// fieldBool()/extractJson() free functions — every payload handler in +// this app reads its config through these, matching the Linux daemon's +// "flat map of JSON-text values" model field-for-field. +object Fields { + fun of(p: ParsedPayload, key: String, default: String = ""): String { + val v = p.fields[key] ?: return default + return if (v.length >= 2 && v.first() == '"' && v.last() == '"') v.substring(1, v.length - 1) else v + } + + fun boolOf(p: ParsedPayload, key: String, default: Boolean = false): Boolean { + val v = of(p, key) + if (v.isEmpty()) return default + return v == "true" || v == "1" + } + + fun intOf(p: ParsedPayload, key: String, default: Int = 0): Int = + of(p, key).toIntOrNull() ?: default + + // Extract a string value from a flat JSON object stored as raw text + // (e.g. json(secJson, "password") on {"type":"wpa2","password":"foo"}). + // Only handles string values, same limitation as the C++ original. + fun json(jsonText: String, key: String): String { + val marker = "\"$key\":" + var pos = jsonText.indexOf(marker) + if (pos < 0) return "" + pos += marker.length + while (pos < jsonText.length && jsonText[pos] == ' ') pos++ + if (pos >= jsonText.length) return "" + if (jsonText[pos] == '"') { + val q2 = jsonText.indexOf('"', pos + 1) + if (q2 < 0) return "" + return jsonText.substring(pos + 1, q2) + } + val end = jsonText.indexOfFirst(pos) { it == ',' || it == '}' } + return jsonText.substring(pos, if (end < 0) jsonText.length else end) + } + + fun jsonBool(jsonText: String, key: String, default: Boolean = false): Boolean { + val marker = "\"$key\":" + val pos = jsonText.indexOf(marker) + if (pos < 0) return default + return jsonText.startsWith("true", pos + marker.length) + } + + private inline fun String.indexOfFirst(from: Int, predicate: (Char) -> Boolean): Int { + for (i in from until length) if (predicate(this[i])) return i + return -1 + } +} diff --git a/src/os/pawlet/profiled/PawletDeviceAdminReceiver.kt b/src/os/pawlet/profiled/PawletDeviceAdminReceiver.kt new file mode 100644 index 0000000..29d9939 --- /dev/null +++ b/src/os/pawlet/profiled/PawletDeviceAdminReceiver.kt @@ -0,0 +1,40 @@ +package os.pawlet.profiled + +import android.app.admin.DeviceAdminReceiver +import android.app.admin.DevicePolicyManager +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.util.Log +import os.pawlet.profiled.payloads.MdmHandler +import java.io.File + +class PawletDeviceAdminReceiver : DeviceAdminReceiver() { + + companion object { + private const val TAG = "PawletProfiled/Admin" + + fun componentName(context: Context): ComponentName = + ComponentName(context, PawletDeviceAdminReceiver::class.java) + } + + override fun onEnabled(context: Context, intent: Intent) { + Log.i(TAG, "device admin enabled") + } + + override fun onDisabled(context: Context, intent: Intent) { + // If a real MDM enrollment is active, losing admin rights means the + // enrolled policy can no longer be enforced. wipeData() mirrors what + // the "removal: locked" lifecycle setting already promises for + // profiles in general — see ProfileStore's removal_hash.bin — but + // this is the device-admin-level backstop for someone disabling the + // admin outright rather than going through a normal profile removal. + if (File(MdmHandler.STATE_PATH).exists()) { + Log.w(TAG, "device admin disabled while MDM-enrolled — wiping per lifecycle policy") + val dpm = context.getSystemService(Context.DEVICE_POLICY_SERVICE) as DevicePolicyManager + dpm.wipeData(0) + } else { + Log.i(TAG, "device admin disabled") + } + } +} diff --git a/src/os/pawlet/profiled/PawletProfileApplication.kt b/src/os/pawlet/profiled/PawletProfileApplication.kt new file mode 100644 index 0000000..4ba99b7 --- /dev/null +++ b/src/os/pawlet/profiled/PawletProfileApplication.kt @@ -0,0 +1,140 @@ +package os.pawlet.profiled + +import android.app.Application +import android.util.Log +import org.json.JSONArray +import os.pawlet.profiled.payloads.PayloadHandlerRegistry + +// Android counterpart to pawletprofiled's PawletProfileService.{h,cpp} — +// same install/remove/query logic, hosted in the Application singleton so +// both PawletProfileService (the AIDL-facing bound Service) and +// BootCompletedReceiver (preinstalled profiles) share one ProfileStore +// instance instead of racing two independent ones against the same files. +class PawletProfileApplication : Application() { + + companion object { private const val TAG = "PawletProfiled/App" } + + lateinit var store: ProfileStore + private set + lateinit var parser: ProfileParser + private set + lateinit var verifier: SignatureVerifier + private set + + override fun onCreate() { + super.onCreate() + store = ProfileStore().apply { load() } + parser = ProfileParser() + verifier = SignatureVerifier() + Log.i(TAG, "pawletprofiled started (Android), ${store.listUuids().size} profile(s) loaded") + } + + // Signature required for these types even when the rest of the profile + // is unsigned — matches PawletProfileService.cpp's installProfileDirect. + private val signatureRequiredTypes = setOf("mdm", "removal-password", "kiosk", "asam") + + /** Returns the installed profile's uuid, or null on failure. Idempotent. */ + fun installProfileDirect(profileData: ByteArray): String? { + var yamlBytes = profileData + var isSigned = false + var trust = TrustLevel.UNSIGNED + + if (verifier.isCmsWrapped(profileData)) { + val (result, recovered) = verifier.verify(profileData) + if (result == SignatureVerifier.VerifyResult.INVALID) { + Log.e(TAG, "signature invalid") + return null + } + yamlBytes = recovered + isSigned = true + trust = if (result == SignatureVerifier.VerifyResult.TRUSTED) TrustLevel.TRUSTED else TrustLevel.UNVERIFIED + } + + val profile = parser.parse(yamlBytes) ?: run { Log.e(TAG, "parse failed"); return null } + + if (store.listUuids().contains(profile.uuid)) { + Log.i(TAG, "already installed uuid=${profile.uuid}, skipping") + return profile.uuid + } + + for (payload in profile.payloads) { + if (payload.type in signatureRequiredTypes && !isSigned) { + Log.e(TAG, "type=${payload.type} requires a signed profile") + return null + } + } + + if (!store.save(profile, yamlBytes, trust)) { + Log.e(TAG, "save failed uuid=${profile.uuid}") + return null + } + + applyProfile(profile) + Log.i(TAG, "installed uuid=${profile.uuid}") + return profile.uuid + } + + fun removeProfile(uuid: String, password: String?): RemoveResult { + val profile = store.load(uuid) ?: return RemoveResult.NOT_FOUND + if (profile.lifecycle.removal == "locked") return RemoveResult.LOCKED + if (profile.lifecycle.removal == "password") { + if (password == null || !store.checkRemovalPassword(uuid, password)) return RemoveResult.WRONG_PASSWORD + } + revertProfile(profile) + store.remove(uuid) + Log.i(TAG, "removed profile uuid=$uuid") + return RemoveResult.OK + } + + enum class RemoveResult { OK, NOT_FOUND, LOCKED, WRONG_PASSWORD } + + fun isDeviceManaged(): Boolean = + store.listUuids().any { uuid -> store.load(uuid)?.payloads?.any { it.type == "mdm" } == true } + + fun getMdmServerUrl(): String { + for (uuid in store.listUuids()) { + val profile = store.load(uuid) ?: continue + val mdm = profile.payloads.firstOrNull { it.type == "mdm" } ?: continue + return Fields.of(mdm, "server-url") + } + return "" + } + + fun isSupervised(): Boolean = + store.listUuids().any { uuid -> store.load(uuid)?.payloads?.any { it.type == "kiosk" || it.type == "asam" } == true } + + fun getPayloadsOfType(type: String): String { + val arr = JSONArray() + for (uuid in store.listUuids()) { + val profile = store.load(uuid) ?: continue + for (payload in profile.payloads) { + if (payload.type != type) continue + val obj = org.json.JSONObject() + obj.put("profileUuid", profile.uuid) + obj.put("payloadUuid", payload.uuid) + for ((k, v) in payload.fields) obj.put(k, org.json.JSONTokener(v).nextValue()) + arr.put(obj) + } + } + return arr.toString() + } + + private fun applyProfile(profile: ParsedProfile) { + for (payload in profile.payloads) { + val handler = PayloadHandlerRegistry.forType(payload.type) + if (handler == null) { + Log.w(TAG, "no handler for type=${payload.type}") + continue + } + if (!handler.apply(this, payload)) { + Log.w(TAG, "handler failed type=${payload.type} uuid=${payload.uuid}") + } + } + } + + private fun revertProfile(profile: ParsedProfile) { + for (payload in profile.payloads.asReversed()) { + PayloadHandlerRegistry.forType(payload.type)?.revert(this, payload) + } + } +} diff --git a/src/os/pawlet/profiled/PawletProfileService.kt b/src/os/pawlet/profiled/PawletProfileService.kt new file mode 100644 index 0000000..cb164a6 --- /dev/null +++ b/src/os/pawlet/profiled/PawletProfileService.kt @@ -0,0 +1,75 @@ +package os.pawlet.profiled + +import android.app.Service +import android.content.Intent +import android.os.IBinder +import android.os.ServiceSpecificException +import android.util.Log +import org.json.JSONObject + +// Android counterpart to pawletprofiled's PawletProfileService D-Bus +// implementation — same method set, same error semantics (locked/wrong +// password/not found map to ServiceSpecificException codes instead of +// D-Bus error names), delegating all actual logic to +// PawletProfileApplication so BootCompletedReceiver's preinstalled-profile +// path and this AIDL-facing path share one ProfileStore. +class PawletProfileService : Service() { + + companion object { + private const val TAG = "PawletProfiled/Service" + const val ERR_NOT_FOUND = 1 + const val ERR_SIGNATURE = 2 + const val ERR_PARSE = 3 + const val ERR_LOCKED = 4 + const val ERR_WRONG_PASSWORD = 5 + } + + private val app get() = application as PawletProfileApplication + + private val binder = object : IPawletProfileService.Stub() { + override fun installProfile(profileData: ByteArray): String { + val uuid = app.installProfileDirect(profileData) + ?: throw ServiceSpecificException(ERR_PARSE, "Profile parse or signature verification failed.") + return uuid + } + + override fun removeProfile(uuid: String, removalPassword: String) { + when (app.removeProfile(uuid, removalPassword.ifEmpty { null })) { + PawletProfileApplication.RemoveResult.OK -> {} + PawletProfileApplication.RemoveResult.NOT_FOUND -> + throw ServiceSpecificException(ERR_NOT_FOUND, "Profile not found: $uuid") + PawletProfileApplication.RemoveResult.LOCKED -> + throw ServiceSpecificException(ERR_LOCKED, "This profile can only be removed by the MDM server.") + PawletProfileApplication.RemoveResult.WRONG_PASSWORD -> + throw ServiceSpecificException(ERR_WRONG_PASSWORD, "Incorrect removal password.") + } + } + + override fun listProfiles(): Array = app.store.listUuids().toTypedArray() + + override fun getProfileInfo(uuid: String): String { + val profile = app.store.load(uuid) + ?: throw ServiceSpecificException(ERR_NOT_FOUND, "Not found: $uuid") + return JSONObject().apply { + put("uuid", profile.uuid) + put("id", profile.id) + put("name", profile.meta.name) + put("organization", profile.meta.organization) + put("scope", profile.scope) + put("removal", profile.lifecycle.removal) + put("trusted", profile.trustLevel != TrustLevel.UNSIGNED) + put("payloadCount", profile.payloads.size) + }.toString() + } + + override fun isDeviceManaged(): Boolean = app.isDeviceManaged() + override fun getMdmServerUrl(): String = app.getMdmServerUrl() + override fun isSupervised(): Boolean = app.isSupervised() + override fun getPayloadsOfType(payloadType: String): String = app.getPayloadsOfType(payloadType) + } + + override fun onBind(intent: Intent): IBinder { + Log.i(TAG, "bound") + return binder + } +} diff --git a/src/os/pawlet/profiled/ProfileModels.kt b/src/os/pawlet/profiled/ProfileModels.kt new file mode 100644 index 0000000..394a421 --- /dev/null +++ b/src/os/pawlet/profiled/ProfileModels.kt @@ -0,0 +1,43 @@ +package os.pawlet.profiled + +// Mirrors pawletprofiled's ProfileParser.h exactly (field-for-field) so a +// profile authored once behaves identically on both platforms. `fields` is +// the same "everything as JSON text" shape the Linux parser uses: nested +// objects get re-serialized to a JSON string rather than parsed into a +// typed structure, and each PayloadHandler pulls out what it needs via +// Fields.of()/Fields.json(). See PayloadUtil.h for the C++ equivalent. +data class ParsedPayload( + val type: String, + val id: String, + val uuid: String, + val name: String, + val fields: Map, +) + +data class ProfileLifecycle( + val removal: String = "free", // free | locked | password + val expiresAt: String = "", + val expiresAfterSeconds: Long = 0, + val otaRefreshAfter: String = "", +) + +data class ProfileMeta( + val name: String = "", + val description: String = "", + val organization: String = "", +) + +enum class TrustLevel { UNSIGNED, UNVERIFIED, TRUSTED } + +data class ParsedProfile( + val version: Int = 1, + val id: String, + val uuid: String, + val scope: String = "user", // system | user + val meta: ProfileMeta = ProfileMeta(), + val lifecycle: ProfileLifecycle = ProfileLifecycle(), + val consentDefault: String = "", + val consentTranslations: Map = emptyMap(), + val payloads: List = emptyList(), + val trustLevel: TrustLevel = TrustLevel.UNSIGNED, +) diff --git a/src/os/pawlet/profiled/ProfileParser.kt b/src/os/pawlet/profiled/ProfileParser.kt new file mode 100644 index 0000000..bb3bd96 --- /dev/null +++ b/src/os/pawlet/profiled/ProfileParser.kt @@ -0,0 +1,128 @@ +package os.pawlet.profiled + +import android.util.Log +import org.yaml.snakeyaml.Yaml +import java.io.ByteArrayInputStream + +// Android counterpart to pawletprofiled's ProfileParser.{h,cpp}. Same YAML +// shape, same field-to-JSON-text flattening for ParsedPayload.fields, same +// singleton-payload validation — SnakeYAML's Yaml().load() does in one call +// what the C++ side needs a hand-rolled libyaml event-tree builder for, +// since SnakeYAML already hands back a plain Map/List tree. +class ProfileParser { + + companion object { + private const val TAG = "PawletProfiled" + + // Compiled-in singleton-payload list — matches ProfileParser.cpp's + // comment: schema-driven enforcement (profile.schema.yml) isn't + // wired into parsing on either platform yet, so both sides fall + // back to the same hardcoded list. + private val SINGLETONS = setOf( + "mdm", "passcode", "restrictions", "kiosk", "asam", + "proxy-http", "web-filter", "global-preferences", + "shared-device", "parental-controls", "removal-password", + "identification", "home-screen", "airplay-security", + "system-policy", "first-boot", "setup-assistant", + ) + } + + fun parse(yaml: ByteArray): ParsedProfile? { + val root = try { + @Suppress("UNCHECKED_CAST") + Yaml().load(ByteArrayInputStream(yaml)) as? Map + } catch (e: Exception) { + Log.e(TAG, "YAML parse failed", e) + null + } ?: run { Log.e(TAG, "root document is not a YAML mapping"); return null } + + val id = str(root, "id") + val uuid = str(root, "uuid") + if (id.isEmpty()) { Log.e(TAG, "profile missing required field 'id'"); return null } + if (uuid.isEmpty()) { Log.e(TAG, "profile missing required field 'uuid'"); return null } + + val meta = (root["meta"] as? Map)?.let { + ProfileMeta(str(it, "name"), str(it, "description"), str(it, "organization")) + } ?: ProfileMeta() + + val lifecycle = (root["lifecycle"] as? Map)?.let { + ProfileLifecycle( + removal = str(it, "removal", "free"), + expiresAt = str(it, "expires-at"), + expiresAfterSeconds = str(it, "expires-after").toLongOrNull() ?: 0, + otaRefreshAfter = str(it, "ota-refresh-after"), + ) + } ?: ProfileLifecycle() + + var consentDefault = "en" + var consentTranslations: Map = emptyMap() + (root["consent"] as? Map)?.let { c -> + consentDefault = str(c, "default", "en") + @Suppress("UNCHECKED_CAST") + (c["translations"] as? Map)?.let { t -> + consentTranslations = t.mapValues { it.value?.toString() ?: "" } + } + } + + val payloads = mutableListOf() + @Suppress("UNCHECKED_CAST") + (root["payloads"] as? List)?.forEach { item -> + (item as? Map)?.let { m -> parsePayload(m)?.let { payloads.add(it) } } + } + + if (!validateSingletons(payloads)) return null + + return ParsedProfile( + version = 1, + id = id, + uuid = uuid, + scope = str(root, "scope", "user"), + meta = meta, + lifecycle = lifecycle, + consentDefault = consentDefault, + consentTranslations = consentTranslations, + payloads = payloads, + ) + } + + private fun parsePayload(m: Map): ParsedPayload? { + val type = str(m, "type") + val uuid = str(m, "uuid") + if (type.isEmpty() || uuid.isEmpty()) { + Log.w(TAG, "payload missing 'type' or 'uuid' — skipping") + return null + } + val fields = mutableMapOf() + for ((key, value) in m) { + if (key == "type" || key == "id" || key == "uuid" || key == "name") continue + fields[key] = toJson(value) + } + return ParsedPayload(type = type, id = str(m, "id"), uuid = uuid, name = str(m, "name"), fields = fields) + } + + private fun validateSingletons(payloads: List): Boolean { + for (type in SINGLETONS) { + val count = payloads.count { it.type == type } + if (count > 1) { + Log.e(TAG, "only one '$type' payload allowed per profile") + return false + } + } + return true + } + + private fun str(m: Map, key: String, default: String = ""): String = + m[key]?.toString() ?: default + + // Re-serialize a SnakeYAML-decoded value tree to compact JSON text — + // same shape ProfileParser.cpp's nodeToJson() produces, so every + // PayloadHandler's Fields.json()/Fields.of() calls work identically. + private fun toJson(value: Any?): String = when (value) { + null -> "\"\"" + is Map<*, *> -> value.entries.joinToString(",", "{", "}") { (k, v) -> "\"$k\":${toJson(v)}" } + is List<*> -> value.joinToString(",", "[", "]") { toJson(it) } + is Boolean -> value.toString() + is Int, is Long, is Double, is Float -> value.toString() + else -> "\"" + value.toString().replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n") + "\"" + } +} diff --git a/src/os/pawlet/profiled/ProfileStore.kt b/src/os/pawlet/profiled/ProfileStore.kt new file mode 100644 index 0000000..b4d393b --- /dev/null +++ b/src/os/pawlet/profiled/ProfileStore.kt @@ -0,0 +1,153 @@ +package os.pawlet.profiled + +import android.util.Log +import org.json.JSONArray +import org.json.JSONObject +import java.io.File +import java.security.SecureRandom +import java.security.spec.KeySpec +import java.util.Base64 +import javax.crypto.SecretKeyFactory +import javax.crypto.spec.PBEKeySpec + +// Android counterpart to pawletprofiled's ProfileStore.{h,cpp}. Same +// on-disk layout and same directory the Linux daemon documents in its +// header comment, so a profile's UUID means the same thing on both +// platforms even though nothing about the storage code is shared: +// +// /data/system/pawletos/profiles/ +// index.json +// / +// profile.yml raw (unwrapped) YAML +// meta.json install time, trust level, payload count +// removal_hash.bin PBKDF2-SHA256 hash of removal password (if set) +class ProfileStore { + + companion object { + private const val TAG = "PawletProfiled" + const val PROFILES_DIR = "/data/system/pawletos/profiles" + const val INDEX_FILE = "/data/system/pawletos/profiles/index.json" + private const val PBKDF2_ITERATIONS = 120_000 + private const val PBKDF2_KEY_LENGTH = 256 + } + + private val parser = ProfileParser() + private var uuids = mutableListOf() + + fun load() { + ensureDir(PROFILES_DIR) + val indexFile = File(INDEX_FILE) + uuids = if (indexFile.exists()) { + try { + val arr = JSONArray(indexFile.readText()) + (0 until arr.length()).map { arr.getString(it) }.toMutableList() + } catch (e: Exception) { + Log.e(TAG, "corrupt index.json, rebuilding from disk", e) + rebuildIndex() + } + } else { + rebuildIndex() + } + } + + fun save(profile: ParsedProfile, rawYaml: ByteArray, trustLevel: TrustLevel): Boolean { + val dir = profileDir(profile.uuid) + if (!dir.exists() && !dir.mkdirs()) { Log.e(TAG, "cannot create $dir"); return false } + + File(dir, "profile.yml").writeBytes(rawYaml) + + val meta = JSONObject().apply { + put("id", profile.id) + put("installedAt", System.currentTimeMillis()) + put("trustLevel", trustLevel.name) + put("payloadCount", profile.payloads.size) + put("scope", profile.scope) + put("removal", profile.lifecycle.removal) + } + File(dir, "meta.json").writeText(meta.toString()) + + // Matches ProfileStore.cpp: the removal password lives in the + // profile's own "removal-password" payload, extracted here rather + // than threaded through as a separate save() argument. + val removalPassword = profile.payloads.firstOrNull { it.type == "removal-password" } + ?.let { Fields.of(it, "password") } + if (!removalPassword.isNullOrEmpty()) { + File(dir, "removal_hash.bin").writeBytes(hashRemovalPassword(removalPassword)) + } + + if (!uuids.contains(profile.uuid)) uuids.add(profile.uuid) + writeIndex() + return true + } + + fun load(uuid: String): ParsedProfile? { + val yamlFile = File(profileDir(uuid), "profile.yml") + if (!yamlFile.exists()) return null + val profile = parser.parse(yamlFile.readBytes()) ?: return null + + // profile.yml is the unwrapped YAML — it carries no signature info, + // so trustLevel (recorded at install time, before unwrapping) has + // to be merged back in from meta.json rather than re-derived here. + // Matches ProfileStore.cpp's load(). + val trustLevel = meta(uuid)?.optString("trustLevel")?.let { + try { TrustLevel.valueOf(it) } catch (_: IllegalArgumentException) { null } + } ?: TrustLevel.UNSIGNED + + return profile.copy(trustLevel = trustLevel) + } + + fun meta(uuid: String): JSONObject? { + val metaFile = File(profileDir(uuid), "meta.json") + if (!metaFile.exists()) return null + return try { JSONObject(metaFile.readText()) } catch (e: Exception) { null } + } + + fun remove(uuid: String) { + profileDir(uuid).deleteRecursively() + uuids.remove(uuid) + writeIndex() + } + + fun listUuids(): List = uuids.toList() + + fun isRemovalLocked(uuid: String): Boolean = File(profileDir(uuid), "removal_hash.bin").exists() + + fun checkRemovalPassword(uuid: String, password: String): Boolean { + val hashFile = File(profileDir(uuid), "removal_hash.bin") + if (!hashFile.exists()) return true // no password set + val stored = hashFile.readBytes() + val salt = stored.copyOfRange(0, 16) + val expectedHash = stored.copyOfRange(16, stored.size) + val actualHash = pbkdf2(password, salt) + return actualHash.contentEquals(expectedHash) + } + + private fun hashRemovalPassword(password: String): ByteArray { + val salt = ByteArray(16).also { SecureRandom().nextBytes(it) } + return salt + pbkdf2(password, salt) + } + + private fun pbkdf2(password: String, salt: ByteArray): ByteArray { + val spec: KeySpec = PBEKeySpec(password.toCharArray(), salt, PBKDF2_ITERATIONS, PBKDF2_KEY_LENGTH) + return SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256").generateSecret(spec).encoded + } + + private fun rebuildIndex(): MutableList { + val dir = File(PROFILES_DIR) + val found = dir.listFiles { f -> f.isDirectory && File(f, "profile.yml").exists() } + ?.map { it.name }?.toMutableList() ?: mutableListOf() + uuids = found + writeIndex() + return found + } + + private fun writeIndex() { + val arr = JSONArray() + uuids.forEach { arr.put(it) } + File(INDEX_FILE).writeText(arr.toString()) + } + + private fun ensureDir(path: String) { File(path).mkdirs() } + + private fun profileDir(uuid: String): File = File(PROFILES_DIR, uuid) +} diff --git a/src/os/pawlet/profiled/SignatureVerifier.kt b/src/os/pawlet/profiled/SignatureVerifier.kt new file mode 100644 index 0000000..5b9fc22 --- /dev/null +++ b/src/os/pawlet/profiled/SignatureVerifier.kt @@ -0,0 +1,139 @@ +package os.pawlet.profiled + +import android.util.Log +import org.bouncycastle.cms.CMSSignedData +import org.bouncycastle.cms.jcajce.JcaSimpleSignerInfoVerifierBuilder +import org.bouncycastle.jce.provider.BouncyCastleProvider +import java.io.File +import java.security.Security +import java.security.cert.CertPathValidator +import java.security.cert.CertificateFactory +import java.security.cert.PKIXParameters +import java.security.cert.TrustAnchor +import java.security.cert.X509Certificate +import java.util.Date + +// Android counterpart to pawletprofiled's SignatureVerifier.{h,cpp}. Same +// job (verify a CMS/PKCS#7-signed .vconfig, return the inner YAML), same +// two-tier trust store (system CA bundle + optional PawletOS-issued CA), +// but via Bouncy Castle instead of OpenSSL CMS — BoringSSL lacks CMS +// support, which is exactly the gap the Linux build solves by vendoring a +// static OpenSSL. A pure-Java CMS library sidesteps that entirely on +// Android; no native vendoring needed here. +class SignatureVerifier { + + enum class VerifyResult { TRUSTED, UNVERIFIED, INVALID } + + companion object { + private const val TAG = "PawletProfiled" + + // AOSP's system CA store is a directory of hash-named PEM files + // (c_rehash layout), matching what the Linux/Android C++ verifier's + // kTrustStorePath comment already documented for this platform. + const val SYSTEM_TRUST_STORE_DIR = "/system/etc/security/cacerts" + + // PawletOS profile-signing CA, optional, written by MDM enrollment + // or preloaded by the OEM. Same path the native verifier used. + const val PAWLET_CA_PATH = "/data/system/pawletos/profile_ca.pem" + + init { + if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) { + Security.addProvider(BouncyCastleProvider()) + } + } + } + + fun isCmsWrapped(data: ByteArray): Boolean = + try { CMSSignedData(data); true } catch (_: Exception) { false } + + /** Returns the verify result and the recovered payload bytes (empty on INVALID). */ + fun verify(cmsData: ByteArray): Pair { + val signed = try { + CMSSignedData(cmsData) + } catch (e: Exception) { + Log.e(TAG, "not a valid CMS SignedData blob", e) + return VerifyResult.INVALID to ByteArray(0) + } + + val content = signed.signedContent?.content as? ByteArray + ?: run { Log.e(TAG, "CMS blob has no attached content (detached signatures unsupported)"); return VerifyResult.INVALID to ByteArray(0) } + + val certStore = signed.certificates + val signerInfos = signed.signerInfos.signers + if (signerInfos.isEmpty()) return VerifyResult.INVALID to ByteArray(0) + + var sawValidSignature = false + var signerCert: X509Certificate? = null + + for (signer in signerInfos) { + val matches = certStore.getMatches(signer.sid) + val holder = matches.firstOrNull() ?: continue + val cf = CertificateFactory.getInstance("X.509") + val cert = cf.generateCertificate(holder.encoded.inputStream()) as X509Certificate + val verifier = JcaSimpleSignerInfoVerifierBuilder() + .setProvider(BouncyCastleProvider.PROVIDER_NAME) + .build(cert) + if (signer.verify(verifier)) { + sawValidSignature = true + signerCert = cert + break + } + } + + if (!sawValidSignature || signerCert == null) { + Log.w(TAG, "CMS signature verification failed") + return VerifyResult.INVALID to ByteArray(0) + } + + val trustLevel = if (chainsToTrustedRoot(signerCert)) VerifyResult.TRUSTED else VerifyResult.UNVERIFIED + return trustLevel to content + } + + private fun chainsToTrustedRoot(leaf: X509Certificate): Boolean { + val anchors = loadTrustAnchors() + if (anchors.isEmpty()) return false + return try { + val cf = CertificateFactory.getInstance("X.509") + val path = cf.generateCertPath(listOf(leaf)) + val params = PKIXParameters(anchors).apply { + isRevocationEnabled = false // no OCSP/CRL infra assumed on-device; matches Linux verifier + date = Date() + } + val validator = CertPathValidator.getInstance("PKIX") + validator.validate(path, params) + true + } catch (e: Exception) { + Log.d(TAG, "cert chain did not validate to a trusted root: ${e.message}") + false + } + } + + private fun loadTrustAnchors(): Set { + val cf = CertificateFactory.getInstance("X.509") + val anchors = mutableSetOf() + + val systemDir = File(SYSTEM_TRUST_STORE_DIR) + systemDir.listFiles()?.forEach { f -> + try { + f.inputStream().use { ins -> + val cert = cf.generateCertificate(ins) as X509Certificate + anchors.add(TrustAnchor(cert, null)) + } + } catch (_: Exception) { /* not a cert file (e.g. c_rehash symlink cruft); skip */ } + } + + val pawletCa = File(PAWLET_CA_PATH) + if (pawletCa.exists()) { + try { + pawletCa.inputStream().use { ins -> + val cert = cf.generateCertificate(ins) as X509Certificate + anchors.add(TrustAnchor(cert, null)) + } + } catch (e: Exception) { + Log.w(TAG, "failed to load PawletOS profile CA at $PAWLET_CA_PATH", e) + } + } + + return anchors + } +} diff --git a/src/os/pawlet/profiled/payloads/CertHandler.kt b/src/os/pawlet/profiled/payloads/CertHandler.kt new file mode 100644 index 0000000..e028041 --- /dev/null +++ b/src/os/pawlet/profiled/payloads/CertHandler.kt @@ -0,0 +1,49 @@ +package os.pawlet.profiled.payloads + +import android.app.admin.DevicePolicyManager +import android.content.Context +import android.util.Base64 +import android.util.Log +import os.pawlet.profiled.Fields +import os.pawlet.profiled.ParsedPayload +import os.pawlet.profiled.PawletDeviceAdminReceiver + +// Android counterpart to platform/linux/Cert.cpp. Same "data" field +// (base64 DER/PEM CA cert). DevicePolicyManager.installCaCert() requires +// device/profile owner — this app is device owner (see +// DeviceOwnerProvisioner) so the call is unconditional, no permission +// fallback needed. +class CertHandler : PayloadHandler { + + companion object { private const val TAG = "PawletProfiled/Cert" } + + override fun apply(context: Context, payload: ParsedPayload): Boolean { + val b64 = Fields.of(payload, "data") + if (b64.isEmpty()) { Log.e(TAG, "no data"); return false } + + val bytes = try { Base64.decode(b64, Base64.DEFAULT) } catch (e: Exception) { + Log.e(TAG, "bad base64", e); ByteArray(0) + } + if (bytes.isEmpty()) { Log.e(TAG, "bad base64"); return false } + + val dpm = context.getSystemService(Context.DEVICE_POLICY_SERVICE) as DevicePolicyManager + val admin = PawletDeviceAdminReceiver.componentName(context) + + val installed = try { + dpm.installCaCert(admin, bytes) + } catch (e: Exception) { + Log.e(TAG, "installCaCert failed", e); false + } + if (installed) Log.i(TAG, "installed CA cert uuid=${payload.uuid}") + return installed + } + + override fun revert(context: Context, payload: ParsedPayload) { + val b64 = Fields.of(payload, "data") + val bytes = try { Base64.decode(b64, Base64.DEFAULT) } catch (_: Exception) { return } + val dpm = context.getSystemService(Context.DEVICE_POLICY_SERVICE) as DevicePolicyManager + val admin = PawletDeviceAdminReceiver.componentName(context) + try { dpm.uninstallCaCert(admin, bytes) } catch (e: Exception) { Log.w(TAG, "uninstallCaCert failed", e) } + Log.i(TAG, "removed CA cert uuid=${payload.uuid}") + } +} diff --git a/src/os/pawlet/profiled/payloads/ContentCacheHandler.kt b/src/os/pawlet/profiled/payloads/ContentCacheHandler.kt new file mode 100644 index 0000000..82f74fe --- /dev/null +++ b/src/os/pawlet/profiled/payloads/ContentCacheHandler.kt @@ -0,0 +1,46 @@ +package os.pawlet.profiled.payloads + +import android.content.Context +import android.util.Log +import os.pawlet.profiled.Fields +import os.pawlet.profiled.ParsedPayload +import java.io.File + +// PawletOS-specific — not part of upstream vesperprofiled. Android +// counterpart to platform/linux/ContentCache.cpp, except here it's the +// implementation that actually matters: PawletCacheService's +// PolicyOverride.kt reads exactly this path as its highest-priority +// discovery-policy tier. See git.oxmc.me/PawletOS/android_packages_apps_PawletCache. +class ContentCacheHandler : PayloadHandler { + + companion object { + private const val TAG = "PawletProfiled/ContentCache" + const val POLICY_PATH = "/data/misc/pawletcache/policy.json" + } + + override fun apply(context: Context, payload: ParsedPayload): Boolean { + val mode = Fields.of(payload, "mode", "both") + val pinnedServer = payload.fields["pinned-server"] // already-valid JSON text, relayed verbatim + + val json = buildString { + append("{\n \"mode\": \"").append(mode).append('"') + if (pinnedServer != null) append(",\n \"pinnedServer\": ").append(pinnedServer) + append("\n}\n") + } + + return try { + val file = File(POLICY_PATH) + file.parentFile?.mkdirs() + file.writeText(json) + Log.i(TAG, "applied mode=$mode") + true + } catch (e: Exception) { + Log.e(TAG, "failed to write $POLICY_PATH", e) + false + } + } + + override fun revert(context: Context, payload: ParsedPayload) { + File(POLICY_PATH).delete() + } +} diff --git a/src/os/pawlet/profiled/payloads/DnsProxyHandler.kt b/src/os/pawlet/profiled/payloads/DnsProxyHandler.kt new file mode 100644 index 0000000..c9b107f --- /dev/null +++ b/src/os/pawlet/profiled/payloads/DnsProxyHandler.kt @@ -0,0 +1,54 @@ +package os.pawlet.profiled.payloads + +import android.content.Context +import android.net.Uri +import android.provider.Settings +import android.util.Log +import os.pawlet.profiled.Fields +import os.pawlet.profiled.ParsedPayload + +// Android counterpart to platform/linux/DnsProxy.cpp. Same "config" +// (ServerURL) field, mapped onto Android's Private DNS (DNS-over-TLS) +// setting — the closest system-wide equivalent AOSP has. +// +// This is a narrower mapping than the Linux side: systemd-resolved's +// ServerURL can point at a DoH endpoint; Android's Private DNS only +// supports DoT against a hostname (Settings.Global PRIVATE_DNS_SPECIFIER), +// not arbitrary DoH URLs. A bare hostname passes through unchanged; a URL +// has its host component extracted so "https://dns.example.com/dns-query" +// still resolves to a usable DoT hostname even though the DoH path itself +// isn't honored. +class DnsProxyHandler : PayloadHandler { + + companion object { private const val TAG = "PawletProfiled/DnsProxy" } + + override fun apply(context: Context, payload: ParsedPayload): Boolean { + val configJson = payload.fields["config"] ?: return true + val serverUrl = Fields.json(configJson, "ServerURL") + if (serverUrl.isEmpty()) return true + + val hostname = try { + if (serverUrl.contains("://")) Uri.parse(serverUrl).host ?: serverUrl else serverUrl + } catch (_: Exception) { serverUrl } + + return try { + Settings.Global.putString(context.contentResolver, "private_dns_mode", "hostname") + Settings.Global.putString(context.contentResolver, "private_dns_specifier", hostname) + Log.i(TAG, "applied private DNS hostname=$hostname (from $serverUrl)") + true + } catch (e: Exception) { + Log.e(TAG, "failed to write private DNS settings", e) + false + } + } + + override fun revert(context: Context, payload: ParsedPayload) { + try { + Settings.Global.putString(context.contentResolver, "private_dns_mode", "opportunistic") + Settings.Global.putString(context.contentResolver, "private_dns_specifier", null) + } catch (e: Exception) { + Log.w(TAG, "failed to reset private DNS settings", e) + } + Log.i(TAG, "reverted dns-proxy uuid=${payload.uuid}") + } +} diff --git a/src/os/pawlet/profiled/payloads/EthernetHandler.kt b/src/os/pawlet/profiled/payloads/EthernetHandler.kt new file mode 100644 index 0000000..6d646b9 --- /dev/null +++ b/src/os/pawlet/profiled/payloads/EthernetHandler.kt @@ -0,0 +1,103 @@ +package os.pawlet.profiled.payloads + +import android.content.Context +import android.net.EthernetManager +import android.net.IpConfiguration +import android.net.ProxyInfo +import android.net.Uri +import android.util.Log +import os.pawlet.profiled.Fields +import os.pawlet.profiled.ParsedPayload + +// Android counterpart to platform/linux/Ethernet.cpp. Same "interface" + +// "eap" fields. +// +// EthernetManager is a @SystemApi hidden class — reachable here because +// the app builds against sdk_version "system_current" and is +// platform-signed. It covers per-interface IP/proxy configuration +// (IpConfiguration), which is everything the "interface" field and a +// plain unauthenticated wired connection need. +// +// 802.1X on wired Ethernet is genuinely the weakest-supported corner of +// AOSP's networking stack: unlike WifiConfiguration/WifiEnterpriseConfig, +// there is no stable, documented @SystemApi that attaches EAP credentials +// to an EthernetManager interface across AOSP versions — it depends on +// the board's Ethernet HAL and IpClient wiring, which varies by device. +// This handler applies the IP/proxy side for real and logs a clear +// warning (not a silent no-op) when the payload also carries an "eap" +// block, rather than pretending 802.1X was configured when it wasn't. +class EthernetHandler : PayloadHandler { + + companion object { private const val TAG = "PawletProfiled/Ethernet" } + + override fun apply(context: Context, payload: ParsedPayload): Boolean { + var iface = Fields.of(payload, "interface", "") + if (iface == "first-active" || iface == "first" || iface == "first-ethernet") iface = "" + + val eapJson = Fields.of(payload, "eap") + val hasEap = eapJson.isNotEmpty() && eapJson != "\"\"" + + val em = context.getSystemService(Context.ETHERNET_SERVICE) as? EthernetManager + if (em == null) { Log.e(TAG, "EthernetManager unavailable on this device"); return false } + + val targetIface = iface.ifEmpty { + try { em.availableInterfaces.firstOrNull() } catch (e: Exception) { null } + } ?: run { Log.w(TAG, "no ethernet interface available"); return false } + + val ipConfig = IpConfiguration().apply { + ipAssignment = IpConfiguration.IpAssignment.DHCP + proxySettings = IpConfiguration.ProxySettings.NONE + } + + val proxyJson = Fields.of(payload, "proxy") + if (proxyJson.isNotEmpty()) { + val proxyType = Fields.json(proxyJson, "type") + if (proxyType == "manual") { + val host = Fields.json(proxyJson, "host") + val port = Fields.json(proxyJson, "port").toIntOrNull() ?: 0 + if (host.isNotEmpty() && port > 0) { + ipConfig.httpProxy = ProxyInfo.buildDirectProxy(host, port) + ipConfig.proxySettings = IpConfiguration.ProxySettings.STATIC + } + } else if (proxyType == "auto") { + val pacUrl = Fields.json(proxyJson, "pac-url") + if (pacUrl.isNotEmpty()) { + ipConfig.httpProxy = ProxyInfo.buildPacProxy(Uri.parse(pacUrl)) + ipConfig.proxySettings = IpConfiguration.ProxySettings.PAC + } + } + } + + try { + em.setConfiguration(targetIface, ipConfig) + } catch (e: Exception) { + Log.e(TAG, "EthernetManager.setConfiguration failed for $targetIface", e) + return false + } + + if (hasEap) { + Log.w(TAG, "payload uuid=${payload.uuid} requests 802.1X on wired iface=$targetIface — " + + "not applied: AOSP has no stable cross-device @SystemApi for wired EAP credentials, " + + "this depends on the board's Ethernet HAL") + } + + Prefs.putString(context, "ethernet", payload.uuid, targetIface) + Log.i(TAG, "applied ethernet iface=$targetIface uuid=${payload.uuid}") + return true + } + + override fun revert(context: Context, payload: ParsedPayload) { + val em = context.getSystemService(Context.ETHERNET_SERVICE) as? EthernetManager ?: return + val iface = Prefs.getString(context, "ethernet", payload.uuid) ?: return + try { + em.setConfiguration(iface, IpConfiguration().apply { + ipAssignment = IpConfiguration.IpAssignment.DHCP + proxySettings = IpConfiguration.ProxySettings.NONE + }) + } catch (e: Exception) { + Log.w(TAG, "failed to reset ethernet config for $iface", e) + } + Prefs.remove(context, "ethernet", payload.uuid) + Log.i(TAG, "reverted ethernet uuid=${payload.uuid}") + } +} diff --git a/src/os/pawlet/profiled/payloads/FirewallHandler.kt b/src/os/pawlet/profiled/payloads/FirewallHandler.kt new file mode 100644 index 0000000..d8bdad4 --- /dev/null +++ b/src/os/pawlet/profiled/payloads/FirewallHandler.kt @@ -0,0 +1,37 @@ +package os.pawlet.profiled.payloads + +import android.content.Context +import android.util.Log +import os.pawlet.profiled.Fields +import os.pawlet.profiled.ParsedPayload + +// Android counterpart to platform/linux/Firewall.cpp. Same +// enabled/block-all-incoming/stealth-mode fields, but there is genuinely +// no Android equivalent to apply them to: netfilter/nftables access needs +// CAP_NET_ADMIN, which no app UID gets, device owner included, and Android +// phones don't run listening services that accept unsolicited inbound +// connections the way a Linux desktop/server does — there's no "incoming" +// surface an inbound-firewall payload is protecting on a stock device. +// +// This is the one payload type in this app that's a genuine dead end +// rather than a workaround: not solvable with a pure-Java library the way +// LdapHandler solves the missing JNDI/LDAP client, because the missing +// piece is kernel privilege, not a missing SDK surface. Logged clearly, +// never silently no-op'd. +class FirewallHandler : PayloadHandler { + + companion object { private const val TAG = "PawletProfiled/Firewall" } + + override fun apply(context: Context, payload: ParsedPayload): Boolean { + val enabled = Fields.boolOf(payload, "enabled", false) + if (!enabled) return true + + Log.w(TAG, "uuid=${payload.uuid}: firewall payload requested but not applied — " + + "no app-reachable netfilter access exists on Android (CAP_NET_ADMIN is not " + + "grantable to app UIDs, device owner included), and stock Android has no " + + "unsolicited-inbound-connection surface for a firewall to protect") + return false + } + + override fun revert(context: Context, payload: ParsedPayload) {} +} diff --git a/src/os/pawlet/profiled/payloads/FirstBootHandler.kt b/src/os/pawlet/profiled/payloads/FirstBootHandler.kt new file mode 100644 index 0000000..eeb4fb1 --- /dev/null +++ b/src/os/pawlet/profiled/payloads/FirstBootHandler.kt @@ -0,0 +1,61 @@ +package os.pawlet.profiled.payloads + +import android.content.Context +import android.provider.Settings +import android.util.Log +import os.pawlet.profiled.Fields +import os.pawlet.profiled.ParsedPayload +import java.io.File + +// Android counterpart to platform/linux/FirstBoot.cpp. Same "skip" field, +// but Android's Setup Wizard skip mechanism is all-or-nothing rather than +// the pane-by-pane cloud-init module list the Linux side builds: +// Settings.Global.DEVICE_PROVISIONED + Settings.Secure.USER_SETUP_COMPLETE +// suppress the entire wizard in one step (the standard mechanism a +// pre-provisioned device-owner build uses instead of the interactive +// NFC/QR managed-provisioning flow). So this handler doesn't need — or +// have — a per-pane skip map the way FirstBoot.cpp's kSkipMap does; a +// non-empty "skip" list is honored by suppressing SUW entirely, same +// practical outcome as the Linux side's more granular approach. +class FirstBootHandler : PayloadHandler { + + companion object { + private const val TAG = "PawletProfiled/FirstBoot" + const val DONE_STAMP = "/data/system/pawletos/firstboot.done" + } + + override fun apply(context: Context, payload: ParsedPayload): Boolean { + val skipJson = Fields.of(payload, "skip") + + return try { + Settings.Global.putInt(context.contentResolver, Settings.Global.DEVICE_PROVISIONED, 1) + Settings.Secure.putInt(context.contentResolver, "user_setup_complete", 1) + + if (skipJson.contains("software-update")) { + // Mirrors what the schema's software-update payload would set; + // if this profile only lists it under first-boot's skip array + // without its own software-update payload, still suppress + // BgUpd's install-time nag by writing the same bridge file + // SoftwareUpdateHandler uses. + File(SoftwareUpdateHandler.POLICY_PATH).apply { parentFile?.mkdirs() } + .writeText("""{"autoCheck":true,"autoDownload":true,"autoInstall":false,"deferDays":0}""") + } + + File(DONE_STAMP).apply { parentFile?.mkdirs() }.writeText(payload.uuid) + Log.i(TAG, "suppressed Setup Wizard, stamp written") + true + } catch (e: Exception) { + Log.e(TAG, "failed to apply first-boot config", e) + false + } + } + + override fun revert(context: Context, payload: ParsedPayload) { + // Deliberately not un-suppressing Setup Wizard on revert — undoing + // "the device already went through first boot" isn't a meaningful + // or safe operation once real accounts/data exist. Only the stamp + // (an internal bookkeeping file, not user-facing state) is cleared. + File(DONE_STAMP).delete() + Log.i(TAG, "cleared first-boot stamp uuid=${payload.uuid}") + } +} diff --git a/src/os/pawlet/profiled/payloads/LdapHandler.kt b/src/os/pawlet/profiled/payloads/LdapHandler.kt new file mode 100644 index 0000000..5484537 --- /dev/null +++ b/src/os/pawlet/profiled/payloads/LdapHandler.kt @@ -0,0 +1,99 @@ +package os.pawlet.profiled.payloads + +import android.content.Context +import android.util.Log +import com.unboundid.ldap.sdk.LDAPConnection +import com.unboundid.ldap.sdk.LDAPConnectionOptions +import com.unboundid.ldap.sdk.SearchScope +import com.unboundid.util.ssl.SSLUtil +import org.json.JSONObject +import os.pawlet.profiled.Fields +import os.pawlet.profiled.ParsedPayload +import java.io.File + +// Android counterpart to platform/linux/Ldap.cpp. Same host/ssl/username/ +// password/search-settings fields. +// +// Android has no javax.naming/JNDI and no system LDAP client the way glibc +// (nss-ldapd) gives Linux one — this handler bundles UnboundID's pure-Java +// LDAP SDK instead (see libs/README.md). Unlike the Linux handler, which +// just writes ldap.conf/nslcd.conf and hopes something reads them, this +// one actually opens a connection, binds, and runs a one-level search +// against the configured base at apply time — so a bad host/credential/ +// base is caught immediately instead of failing silently the first time +// something tries to use it. +// +// What it can't do: Android has no OS-level "directory accounts" concept +// the way macOS Open Directory feeds the Contacts/Users system from an +// LDAP payload. There's no sync adapter here — validated config is +// persisted for a future PawletOS directory-sync component to consume, +// not wired into anything on-device yet. +class LdapHandler : PayloadHandler { + + companion object { + private const val TAG = "PawletProfiled/Ldap" + const val CONFIG_PATH = "/data/system/pawletos/ldap" + } + + override fun apply(context: Context, payload: ParsedPayload): Boolean { + val host = Fields.of(payload, "host") + val ssl = Fields.boolOf(payload, "ssl", true) + val username = Fields.of(payload, "username") + val password = Fields.of(payload, "password") + val searchJson = Fields.of(payload, "search-settings") + val base = Fields.json(searchJson, "base") + + if (host.isEmpty()) { Log.e(TAG, "no host"); return false } + + val port = if (ssl) 636 else 389 + val connection = try { + val options = LDAPConnectionOptions().apply { connectTimeoutMillis = 8_000 } + if (ssl) { + // Payload doesn't carry a pinned CA for the directory server the + // way Cert/Pkcs12 payloads do for the platform trust store, so + // this validates against the system trust store like a normal + // TLS client would — not TrustAllTrustManager. Kept explicit so + // it's obvious this isn't a "trust anything" LDAPS connection. + val sslUtil = SSLUtil() + LDAPConnection(sslUtil.createSSLSocketFactory(), options, host, port) + } else { + LDAPConnection(options, host, port) + } + } catch (e: Exception) { + Log.e(TAG, "connect to $host:$port failed", e) + return false + } + + try { + if (username.isNotEmpty()) connection.bind(username, password) + + if (base.isNotEmpty()) { + connection.search(base, SearchScope.BASE, "(objectClass=*)") + } + + val config = JSONObject().apply { + put("host", host) + put("port", port) + put("ssl", ssl) + put("username", username) + put("base", base) + put("profileUuid", payload.uuid) + } + File(CONFIG_PATH).apply { mkdirs() } + File(CONFIG_PATH, "${payload.uuid}.json").writeText(config.toString()) + + Log.i(TAG, "verified LDAP bind+search host=$host base=$base uuid=${payload.uuid}") + return true + } catch (e: Exception) { + Log.e(TAG, "LDAP bind or search failed for host=$host", e) + return false + } finally { + connection.close() + } + } + + override fun revert(context: Context, payload: ParsedPayload) { + File(CONFIG_PATH, "${payload.uuid}.json").delete() + Log.i(TAG, "removed LDAP config uuid=${payload.uuid}") + } +} diff --git a/src/os/pawlet/profiled/payloads/MdmHandler.kt b/src/os/pawlet/profiled/payloads/MdmHandler.kt new file mode 100644 index 0000000..0097200 --- /dev/null +++ b/src/os/pawlet/profiled/payloads/MdmHandler.kt @@ -0,0 +1,59 @@ +package os.pawlet.profiled.payloads + +import android.content.Context +import android.util.Log +import org.json.JSONObject +import os.pawlet.profiled.Fields +import os.pawlet.profiled.ParsedPayload +import java.io.File + +// Android counterpart to platform/linux/Mdm.cpp. Same fields (server-url, +// checkin-url, push-topic, identity-cert-uuid, access-rights). +// +// Unlike Linux, enrollment itself isn't this handler's job — this app +// already IS the enrolled MDM agent by virtue of being device owner (see +// DeviceOwnerProvisioner). What this handler does is record the server +// details PawletProfileService.isDeviceManaged()/getMdmServerUrl() report +// back over the AIDL interface, same role as the Linux daemon's +// /etc/pawletprofiled/mdm.conf. +class MdmHandler : PayloadHandler { + + companion object { + private const val TAG = "PawletProfiled/Mdm" + const val STATE_PATH = "/data/system/pawletos/mdm.json" + } + + override fun apply(context: Context, payload: ParsedPayload): Boolean { + val serverUrl = Fields.of(payload, "server-url") + val checkinUrl = Fields.of(payload, "checkin-url").ifEmpty { serverUrl } + val pushTopic = Fields.of(payload, "push-topic") + val certUuid = Fields.of(payload, "identity-cert-uuid") + val accessRights = Fields.intOf(payload, "access-rights", 0) + + if (serverUrl.isEmpty()) { Log.e(TAG, "mdm payload missing server-url"); return false } + + val state = JSONObject().apply { + put("serverUrl", serverUrl) + put("checkinUrl", checkinUrl) + put("pushTopic", pushTopic) + put("identityCertUuid", certUuid) + put("accessRights", accessRights) + put("enrolled", true) + put("profileUuid", payload.uuid) + } + + return try { + File(STATE_PATH).apply { parentFile?.mkdirs() }.writeText(state.toString()) + Log.i(TAG, "recorded MDM enrollment server=$serverUrl") + true + } catch (e: Exception) { + Log.e(TAG, "failed to write $STATE_PATH", e) + false + } + } + + override fun revert(context: Context, payload: ParsedPayload) { + File(STATE_PATH).delete() + Log.i(TAG, "cleared MDM enrollment state uuid=${payload.uuid}") + } +} diff --git a/src/os/pawlet/profiled/payloads/PasscodeHandler.kt b/src/os/pawlet/profiled/payloads/PasscodeHandler.kt new file mode 100644 index 0000000..88b5aaf --- /dev/null +++ b/src/os/pawlet/profiled/payloads/PasscodeHandler.kt @@ -0,0 +1,77 @@ +package os.pawlet.profiled.payloads + +import android.app.admin.DevicePolicyManager +import android.content.Context +import android.util.Log +import os.pawlet.profiled.Fields +import os.pawlet.profiled.ParsedPayload +import os.pawlet.profiled.PawletDeviceAdminReceiver + +// Android counterpart to platform/linux/Passcode.cpp. Same fields +// (min-length, min-complex-chars, require-alphanumeric, allow-simple, +// max-age-days, history, max-failed-attempts, inactivity-minutes), mapped +// onto DevicePolicyManager's password-policy setters — device owner only, +// which this app is. +class PasscodeHandler : PayloadHandler { + + companion object { + private const val TAG = "PawletProfiled/Passcode" + private const val DAY_MS = 24L * 60 * 60 * 1000 + private const val MIN_MS = 60L * 1000 + } + + override fun apply(context: Context, payload: ParsedPayload): Boolean { + val minLen = Fields.intOf(payload, "min-length", 6) + val minComplex = Fields.intOf(payload, "min-complex-chars", 0) + val reqAlpha = Fields.boolOf(payload, "require-alphanumeric", false) + val allowSimple = Fields.boolOf(payload, "allow-simple", true) + val maxAgeDays = Fields.intOf(payload, "max-age-days", 0) + val history = Fields.intOf(payload, "history", 0) + val maxFailed = Fields.intOf(payload, "max-failed-attempts", 0) + val inactivityMin = Fields.intOf(payload, "inactivity-minutes", 0) + + val dpm = context.getSystemService(Context.DEVICE_POLICY_SERVICE) as DevicePolicyManager + val admin = PawletDeviceAdminReceiver.componentName(context) + + return try { + val quality = when { + reqAlpha || minComplex > 0 -> DevicePolicyManager.PASSWORD_QUALITY_ALPHANUMERIC + !allowSimple -> DevicePolicyManager.PASSWORD_QUALITY_COMPLEX + else -> DevicePolicyManager.PASSWORD_QUALITY_SOMETHING + } + dpm.setPasswordQuality(admin, quality) + dpm.setPasswordMinimumLength(admin, minLen) + + if (quality == DevicePolicyManager.PASSWORD_QUALITY_COMPLEX && minComplex > 0) { + dpm.setPasswordMinimumSymbols(admin, 0) + dpm.setPasswordMinimumNonLetter(admin, minComplex) + } + if (maxAgeDays > 0) dpm.setPasswordExpirationTimeout(admin, maxAgeDays * DAY_MS) + if (history > 0) dpm.setPasswordHistoryLength(admin, history) + if (maxFailed > 0) dpm.setMaximumFailedPasswordsForWipe(admin, maxFailed) + if (inactivityMin > 0) dpm.setMaximumTimeToLock(admin, inactivityMin * MIN_MS) + + Log.i(TAG, "applied password policy quality=$quality minLen=$minLen") + true + } catch (e: Exception) { + Log.e(TAG, "failed to apply password policy", e) + false + } + } + + override fun revert(context: Context, payload: ParsedPayload) { + val dpm = context.getSystemService(Context.DEVICE_POLICY_SERVICE) as DevicePolicyManager + val admin = PawletDeviceAdminReceiver.componentName(context) + try { + dpm.setPasswordQuality(admin, DevicePolicyManager.PASSWORD_QUALITY_UNSPECIFIED) + dpm.setPasswordMinimumLength(admin, 0) + dpm.setPasswordExpirationTimeout(admin, 0) + dpm.setPasswordHistoryLength(admin, 0) + dpm.setMaximumFailedPasswordsForWipe(admin, 0) + dpm.setMaximumTimeToLock(admin, 0) + } catch (e: Exception) { + Log.w(TAG, "failed to reset password policy", e) + } + Log.i(TAG, "reverted password policy") + } +} diff --git a/src/os/pawlet/profiled/payloads/PayloadHandler.kt b/src/os/pawlet/profiled/payloads/PayloadHandler.kt new file mode 100644 index 0000000..e0129ce --- /dev/null +++ b/src/os/pawlet/profiled/payloads/PayloadHandler.kt @@ -0,0 +1,38 @@ +package os.pawlet.profiled.payloads + +import android.content.Context +import os.pawlet.profiled.ParsedPayload + +// Android counterpart to pawletprofiled's PayloadHandler.h. One interface, +// one implementation per payload type — same shape as the Linux daemon's +// platform::::apply()/revert() free functions, just as interface +// methods instead of namespaced functions (no dispatch macro needed in +// Kotlin; PayloadHandlerRegistry below is a plain map). +interface PayloadHandler { + fun apply(context: Context, payload: ParsedPayload): Boolean + fun revert(context: Context, payload: ParsedPayload) +} + +object PayloadHandlerRegistry { + val handlers: Map = mapOf( + "wifi" to WifiHandler(), + "ethernet" to EthernetHandler(), + "vpn" to VpnHandler(), + "cert" to CertHandler(), + "pkcs12" to Pkcs12Handler(), + "passcode" to PasscodeHandler(), + "mdm" to MdmHandler(), + "software-update" to SoftwareUpdateHandler(), + "time-server" to TimeServerHandler(), + "proxy" to ProxyHandler(), + "dns-proxy" to DnsProxyHandler(), + "firewall" to FirewallHandler(), + "ldap" to LdapHandler(), + "wallpaper" to WallpaperHandler(), + "screensaver" to ScreensaverHandler(), + "first-boot" to FirstBootHandler(), + "content-cache" to ContentCacheHandler(), + ) + + fun forType(type: String): PayloadHandler? = handlers[type] +} diff --git a/src/os/pawlet/profiled/payloads/Pkcs12Handler.kt b/src/os/pawlet/profiled/payloads/Pkcs12Handler.kt new file mode 100644 index 0000000..8fc2a76 --- /dev/null +++ b/src/os/pawlet/profiled/payloads/Pkcs12Handler.kt @@ -0,0 +1,74 @@ +package os.pawlet.profiled.payloads + +import android.app.admin.DevicePolicyManager +import android.content.Context +import android.util.Base64 +import android.util.Log +import os.pawlet.profiled.Fields +import os.pawlet.profiled.ParsedPayload +import os.pawlet.profiled.PawletDeviceAdminReceiver +import java.io.ByteArrayInputStream +import java.security.KeyStore +import java.security.PrivateKey +import java.security.cert.Certificate + +// Android counterpart to platform/linux/Pkcs12.cpp. Same "data"/"password" +// fields. Unpacks the PKCS#12 blob locally (java.security.KeyStore already +// speaks PKCS12, no extra lib needed) and hands the key + chain to +// DevicePolicyManager.installKeyPair() — the device-owner API for +// installing an identity the platform's KeyChain (and anything using +// KeyChain.getPrivateKey) can use for client-cert auth (Wi-Fi/VPN EAP-TLS, +// browser client certs). +class Pkcs12Handler : PayloadHandler { + + companion object { private const val TAG = "PawletProfiled/Pkcs12" } + + override fun apply(context: Context, payload: ParsedPayload): Boolean { + val b64 = Fields.of(payload, "data") + val pw = Fields.of(payload, "password") + if (b64.isEmpty()) { Log.e(TAG, "no data"); return false } + + val bytes = try { Base64.decode(b64, Base64.DEFAULT) } catch (e: Exception) { + Log.e(TAG, "bad base64", e); return false + } + + val (privateKey, chain) = try { + val ks = KeyStore.getInstance("PKCS12") + ks.load(ByteArrayInputStream(bytes), pw.toCharArray()) + val alias = ks.aliases().toList().firstOrNull { ks.isKeyEntry(it) } + ?: run { Log.e(TAG, "no key entry in PKCS12 blob"); return false } + val key = ks.getKey(alias, pw.toCharArray()) as? PrivateKey + ?: run { Log.e(TAG, "PKCS12 key entry is not a PrivateKey"); return false } + val chain = ks.getCertificateChain(alias) ?: arrayOf() + key to chain + } catch (e: Exception) { + Log.e(TAG, "failed to parse PKCS12 blob (bad password?)", e) + return false + } + + val dpm = context.getSystemService(Context.DEVICE_POLICY_SERVICE) as DevicePolicyManager + val admin = PawletDeviceAdminReceiver.componentName(context) + val alias = "pawletos-${payload.uuid}" + + val installed = try { + @Suppress("DEPRECATION") + dpm.installKeyPair(admin, privateKey, chain, alias, true) + } catch (e: Exception) { + Log.e(TAG, "installKeyPair failed", e); false + } + if (installed) { + Prefs.putString(context, "pkcs12", payload.uuid, alias) + Log.i(TAG, "installed identity cert uuid=${payload.uuid} alias=$alias") + } + return installed + } + + override fun revert(context: Context, payload: ParsedPayload) { + val dpm = context.getSystemService(Context.DEVICE_POLICY_SERVICE) as DevicePolicyManager + val admin = PawletDeviceAdminReceiver.componentName(context) + val alias = Prefs.getString(context, "pkcs12", payload.uuid) ?: return + try { dpm.removeKeyPair(admin, alias) } catch (e: Exception) { Log.w(TAG, "removeKeyPair failed", e) } + Prefs.remove(context, "pkcs12", payload.uuid) + Log.i(TAG, "removed identity cert uuid=${payload.uuid}") + } +} diff --git a/src/os/pawlet/profiled/payloads/Prefs.kt b/src/os/pawlet/profiled/payloads/Prefs.kt new file mode 100644 index 0000000..7c4a167 --- /dev/null +++ b/src/os/pawlet/profiled/payloads/Prefs.kt @@ -0,0 +1,36 @@ +package os.pawlet.profiled.payloads + +import android.content.Context + +// Tiny per-payload-type key/value store so handlers can remember what they +// applied (a Wi-Fi networkId, a VPN profile name, a suggestion's ssid) to +// find it again on revert(). Backed by one SharedPreferences file per +// payload type, keyed by payload uuid — deliberately not the profile +// store's JSON (that's ProfileStore's job; this is handler-private scratch +// state, the Kotlin equivalent of "just remember the path you wrote" that +// the Linux handlers get for free by deriving a deterministic filename +// from the uuid). +object Prefs { + private fun prefs(context: Context, handler: String) = + context.getSharedPreferences("handler_state_$handler", Context.MODE_PRIVATE) + + fun putInt(context: Context, handler: String, uuid: String, value: Int) { + prefs(context, handler).edit().putInt(uuid, value).apply() + } + + fun getInt(context: Context, handler: String, uuid: String): Int? { + val p = prefs(context, handler) + return if (p.contains(uuid)) p.getInt(uuid, -1) else null + } + + fun putString(context: Context, handler: String, uuid: String, value: String) { + prefs(context, handler).edit().putString(uuid, value).apply() + } + + fun getString(context: Context, handler: String, uuid: String): String? = + prefs(context, handler).getString(uuid, null) + + fun remove(context: Context, handler: String, uuid: String) { + prefs(context, handler).edit().remove(uuid).apply() + } +} diff --git a/src/os/pawlet/profiled/payloads/ProxyHandler.kt b/src/os/pawlet/profiled/payloads/ProxyHandler.kt new file mode 100644 index 0000000..e8010dd --- /dev/null +++ b/src/os/pawlet/profiled/payloads/ProxyHandler.kt @@ -0,0 +1,66 @@ +package os.pawlet.profiled.payloads + +import android.app.admin.DevicePolicyManager +import android.content.Context +import android.net.ProxyInfo +import android.net.Uri +import android.util.Log +import os.pawlet.profiled.Fields +import os.pawlet.profiled.ParsedPayload +import os.pawlet.profiled.PawletDeviceAdminReceiver + +// Android counterpart to platform/linux/Proxy.cpp. Same "proxy" field +// (type/host/port/username/password/pac-url). DevicePolicyManager's +// setRecommendedGlobalProxy() is the real device-wide equivalent of +// writing /etc/environment — device-owner only, applies system-wide for +// every app and every user, no per-connection wiring needed the way +// WifiHandler/EthernetHandler have to do it per-network. +// +// Android's global proxy has no username/password fields (ProxyInfo is +// host/port/exclusion-list or a PAC URL only) — proxy auth, if the server +// needs it, isn't representable at this layer on Android; same +// unauthenticated-only limitation the manual profile.proxy block on +// Wifi/Ethernet already has. +class ProxyHandler : PayloadHandler { + + companion object { private const val TAG = "PawletProfiled/Proxy" } + + override fun apply(context: Context, payload: ParsedPayload): Boolean { + val proxyJson = payload.fields["proxy"] ?: return true + val proxyType = Fields.json(proxyJson, "type") + + val dpm = context.getSystemService(Context.DEVICE_POLICY_SERVICE) as DevicePolicyManager + val admin = PawletDeviceAdminReceiver.componentName(context) + + val proxyInfo = when (proxyType) { + "manual" -> { + val host = Fields.json(proxyJson, "host") + val port = Fields.json(proxyJson, "port").toIntOrNull() ?: 0 + if (host.isEmpty() || port <= 0) { Log.e(TAG, "manual proxy missing host/port"); return false } + ProxyInfo.buildDirectProxy(host, port, listOf("localhost", "127.0.0.1")) + } + "auto" -> { + val pacUrl = Fields.json(proxyJson, "pac-url") + if (pacUrl.isEmpty()) { Log.e(TAG, "auto proxy missing pac-url"); return false } + ProxyInfo.buildPacProxy(Uri.parse(pacUrl)) + } + else -> { Log.w(TAG, "unknown proxy type=$proxyType"); return true } + } + + return try { + dpm.setRecommendedGlobalProxy(admin, proxyInfo) + Log.i(TAG, "applied global proxy type=$proxyType") + true + } catch (e: Exception) { + Log.e(TAG, "setRecommendedGlobalProxy failed", e) + false + } + } + + override fun revert(context: Context, payload: ParsedPayload) { + val dpm = context.getSystemService(Context.DEVICE_POLICY_SERVICE) as DevicePolicyManager + val admin = PawletDeviceAdminReceiver.componentName(context) + try { dpm.setRecommendedGlobalProxy(admin, null) } catch (e: Exception) { Log.w(TAG, "failed to clear global proxy", e) } + Log.i(TAG, "reverted proxy uuid=${payload.uuid}") + } +} diff --git a/src/os/pawlet/profiled/payloads/ScreensaverHandler.kt b/src/os/pawlet/profiled/payloads/ScreensaverHandler.kt new file mode 100644 index 0000000..25a6710 --- /dev/null +++ b/src/os/pawlet/profiled/payloads/ScreensaverHandler.kt @@ -0,0 +1,62 @@ +package os.pawlet.profiled.payloads + +import android.app.admin.DevicePolicyManager +import android.content.Context +import android.provider.Settings +import android.util.Log +import os.pawlet.profiled.Fields +import os.pawlet.profiled.ParsedPayload +import os.pawlet.profiled.PawletDeviceAdminReceiver + +// Android counterpart to platform/linux/Screensaver.cpp. Same +// idle-seconds/locked fields, mapped onto Android's Daydream system +// instead of GNOME's screensaver: +// - SCREENSAVER_ENABLED / SCREENSAVER_ACTIVATE_ON_SLEEP (Settings.Secure) +// turn Daydream on and make it trigger on the normal screen-off path, +// the closest Android equivalent to "idle-seconds" — Daydream doesn't +// have its own independent idle timer, it rides the screen timeout. +// - Screen timeout itself is set via Settings.System.SCREEN_OFF_TIMEOUT +// to idle-seconds * 1000. +// - locked=true is enforced for real via +// DevicePolicyManager.setMaximumTimeToLock (device owner), which is +// the actual security-relevant "must re-authenticate after idle" +// control — Settings alone can't force this the way DPM can. +class ScreensaverHandler : PayloadHandler { + + companion object { private const val TAG = "PawletProfiled/Screensaver" } + + override fun apply(context: Context, payload: ParsedPayload): Boolean { + val idleSeconds = Fields.intOf(payload, "idle-seconds", 300) + val locked = Fields.boolOf(payload, "locked", false) + + return try { + Settings.Secure.putInt(context.contentResolver, "screensaver_enabled", 1) + Settings.Secure.putInt(context.contentResolver, "screensaver_activate_on_sleep", 1) + Settings.System.putInt(context.contentResolver, Settings.System.SCREEN_OFF_TIMEOUT, idleSeconds * 1000) + + if (locked) { + val dpm = context.getSystemService(Context.DEVICE_POLICY_SERVICE) as DevicePolicyManager + val admin = PawletDeviceAdminReceiver.componentName(context) + dpm.setMaximumTimeToLock(admin, idleSeconds * 1000L) + } + + Log.i(TAG, "applied screensaver idle=${idleSeconds}s locked=$locked") + true + } catch (e: Exception) { + Log.e(TAG, "failed to apply screensaver settings", e) + false + } + } + + override fun revert(context: Context, payload: ParsedPayload) { + try { + Settings.Secure.putInt(context.contentResolver, "screensaver_enabled", 0) + val dpm = context.getSystemService(Context.DEVICE_POLICY_SERVICE) as DevicePolicyManager + val admin = PawletDeviceAdminReceiver.componentName(context) + dpm.setMaximumTimeToLock(admin, 0) + } catch (e: Exception) { + Log.w(TAG, "failed to revert screensaver settings", e) + } + Log.i(TAG, "reverted screensaver uuid=${payload.uuid}") + } +} diff --git a/src/os/pawlet/profiled/payloads/SoftwareUpdateHandler.kt b/src/os/pawlet/profiled/payloads/SoftwareUpdateHandler.kt new file mode 100644 index 0000000..cc52ea6 --- /dev/null +++ b/src/os/pawlet/profiled/payloads/SoftwareUpdateHandler.kt @@ -0,0 +1,59 @@ +package os.pawlet.profiled.payloads + +import android.content.Context +import android.util.Log +import org.json.JSONObject +import os.pawlet.profiled.Fields +import os.pawlet.profiled.ParsedPayload +import java.io.File + +// Android counterpart to platform/linux/SoftwareUpdate.cpp. Same +// "automatic"/"deferral" fields, but there's no apt/unattended-upgrades +// equivalent to drive on Android — PawletOS already has an update agent +// (BgUpd) with its own per-app install_mode concept. This handler writes +// the policy BgUpd needs to know about, same bridging role the Linux +// handler's comment already flags for the future MDM agent package. +// +// NOTE: as of this writing BgUpd does not yet read this file — its +// install_mode is still driven purely by its own manifest bucket. This is +// the wiring one side of that integration; BgUpd's read side is separate +// follow-up work, same "not silently dropped, just not done yet" standard +// main_android.cpp already uses for the ZTE ConnectivityWatcher gap. +class SoftwareUpdateHandler : PayloadHandler { + + companion object { + private const val TAG = "PawletProfiled/SoftwareUpdate" + const val POLICY_PATH = "/data/system/pawletos/software_update_policy.json" + } + + override fun apply(context: Context, payload: ParsedPayload): Boolean { + val autoJson = Fields.of(payload, "automatic") + val autoCheck = Fields.jsonBool(autoJson, "check", true) + val autoDownload = Fields.jsonBool(autoJson, "download", true) + val autoInstall = Fields.jsonBool(autoJson, "install-os-updates", false) + + val deferJson = Fields.of(payload, "deferral") + val deferDays = Fields.json(deferJson, "os-updates-days").toIntOrNull() ?: 0 + + val policy = JSONObject().apply { + put("autoCheck", autoCheck) + put("autoDownload", autoDownload) + put("autoInstall", autoInstall) + put("deferDays", deferDays) + } + + return try { + File(POLICY_PATH).apply { parentFile?.mkdirs() }.writeText(policy.toString()) + Log.i(TAG, "applied update policy check=$autoCheck download=$autoDownload install=$autoInstall") + true + } catch (e: Exception) { + Log.e(TAG, "failed to write $POLICY_PATH", e) + false + } + } + + override fun revert(context: Context, payload: ParsedPayload) { + File(POLICY_PATH).delete() + Log.i(TAG, "reverted update policy uuid=${payload.uuid}") + } +} diff --git a/src/os/pawlet/profiled/payloads/TimeServerHandler.kt b/src/os/pawlet/profiled/payloads/TimeServerHandler.kt new file mode 100644 index 0000000..ecccb93 --- /dev/null +++ b/src/os/pawlet/profiled/payloads/TimeServerHandler.kt @@ -0,0 +1,39 @@ +package os.pawlet.profiled.payloads + +import android.content.Context +import android.provider.Settings +import android.util.Log +import os.pawlet.profiled.Fields +import os.pawlet.profiled.ParsedPayload + +// Android counterpart to platform/linux/TimeServer.cpp. Same "server" +// field, written to Settings.Global.NTP_SERVER — the same setting +// SystemServer's NetworkTimeUpdateService reads for SNTP sync. Requires +// WRITE_SECURE_SETTINGS (privileged, held by this app). +class TimeServerHandler : PayloadHandler { + + companion object { private const val TAG = "PawletProfiled/TimeServer" } + + override fun apply(context: Context, payload: ParsedPayload): Boolean { + val server = Fields.of(payload, "server") + if (server.isEmpty()) return true + + return try { + Settings.Global.putString(context.contentResolver, Settings.Global.NTP_SERVER, server) + Log.i(TAG, "set NTP server=$server") + true + } catch (e: Exception) { + Log.e(TAG, "failed to write Settings.Global.NTP_SERVER", e) + false + } + } + + override fun revert(context: Context, payload: ParsedPayload) { + try { + Settings.Global.putString(context.contentResolver, Settings.Global.NTP_SERVER, null) + } catch (e: Exception) { + Log.w(TAG, "failed to clear NTP server", e) + } + Log.i(TAG, "reverted time-server uuid=${payload.uuid}") + } +} diff --git a/src/os/pawlet/profiled/payloads/VpnHandler.kt b/src/os/pawlet/profiled/payloads/VpnHandler.kt new file mode 100644 index 0000000..0e12870 --- /dev/null +++ b/src/os/pawlet/profiled/payloads/VpnHandler.kt @@ -0,0 +1,99 @@ +package os.pawlet.profiled.payloads + +import android.content.Context +import android.net.Ikev2VpnProfile +import android.net.VpnManager +import android.os.Build +import android.util.Log +import os.pawlet.profiled.Fields +import os.pawlet.profiled.ParsedPayload + +// Android counterpart to platform/linux/Vpn.cpp. Same three type blocks +// (ikev2/l2tp/custom), but the platform coverage is uneven in a different +// place than on Linux: +// +// ikev2 — VpnManager + Ikev2VpnProfile (API 30+) is a real, first-class +// AOSP client. Fully implemented: PSK or username/password EAP. +// l2tp — Android has no built-in L2TP/IPsec client at all (the old +// Settings > VPN "Legacy VPN" L2TP/PPTP UI was removed around +// API 31, and there is no pure-Java library equivalent the way +// UnboundID covers LDAP — L2TP needs kernel IPsec SAs and PPP +// framing, not just socket code). Logged clearly, not applied. +// custom — same honesty level as the Linux handler, which also only +// logs "write your own config" rather than actually parsing a +// WireGuard/OpenVPN blob — a real implementation would need a +// bundled native tunnel backend (e.g. wireguard-go via JNI), +// out of scope here same as it is there. +class VpnHandler : PayloadHandler { + + companion object { private const val TAG = "PawletProfiled/Vpn" } + + override fun apply(context: Context, payload: ParsedPayload): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + Log.e(TAG, "VpnManager/Ikev2VpnProfile requires API 30+, this device is API ${Build.VERSION.SDK_INT}") + return false + } + val displayName = Fields.of(payload, "display-name", "PawletOS VPN") + val profileKey = "pawletos-vpn-${payload.uuid}" + + val ikev2Json = payload.fields["ikev2"] + val l2tpJson = payload.fields["l2tp"] + val customJson = payload.fields["custom"] + + if (ikev2Json != null) return applyIkev2(context, payload, profileKey, displayName, ikev2Json) + if (l2tpJson != null) { + Log.w(TAG, "uuid=${payload.uuid}: L2TP requested but AOSP has no built-in L2TP/IPsec " + + "client on this platform — not applied") + return false + } + if (customJson != null) { + Log.i(TAG, "uuid=${payload.uuid}: custom VPN block present — no bundled tunnel backend " + + "(WireGuard/OpenVPN) to apply it with; write your own config") + return true + } + + Log.w(TAG, "no VPN type block found in payload uuid=${payload.uuid}") + return false + } + + private fun applyIkev2( + context: Context, payload: ParsedPayload, profileKey: String, + displayName: String, ikev2Json: String, + ): Boolean { + val server = Fields.json(ikev2Json, "server") + val remoteId = Fields.json(ikev2Json, "remote-id") + if (server.isEmpty()) { Log.e(TAG, "ikev2 block missing 'server'"); return false } + + val builder = Ikev2VpnProfile.Builder(server, remoteId.ifEmpty { server }) + + val psk = Fields.json(ikev2Json, "psk").ifEmpty { Fields.json(ikev2Json, "shared-secret") } + val username = Fields.json(ikev2Json, "username") + val password = Fields.json(ikev2Json, "password") + + when { + psk.isNotEmpty() -> builder.setAuthPsk(psk.toByteArray(Charsets.UTF_8)) + username.isNotEmpty() && password.isNotEmpty() -> builder.setAuthUsernamePassword(username, password, null) + else -> { Log.e(TAG, "ikev2 block has neither psk/shared-secret nor username+password"); return false } + } + + try { + val profile = builder.build() + val vm = context.getSystemService(Context.VPN_MANAGEMENT_SERVICE) as VpnManager + vm.provisionVpnProfile(profile) + Prefs.putString(context, "vpn", payload.uuid, profileKey) + Log.i(TAG, "provisioned IKEv2 VPN profile server=$server uuid=${payload.uuid}") + return true + } catch (e: Exception) { + Log.e(TAG, "provisionVpnProfile failed", e) + return false + } + } + + override fun revert(context: Context, payload: ParsedPayload) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return + val vm = context.getSystemService(Context.VPN_MANAGEMENT_SERVICE) as VpnManager + try { vm.deleteProvisionedVpnProfile() } catch (e: Exception) { Log.w(TAG, "deleteProvisionedVpnProfile failed", e) } + Prefs.remove(context, "vpn", payload.uuid) + Log.i(TAG, "reverted vpn uuid=${payload.uuid}") + } +} diff --git a/src/os/pawlet/profiled/payloads/WallpaperHandler.kt b/src/os/pawlet/profiled/payloads/WallpaperHandler.kt new file mode 100644 index 0000000..73a7698 --- /dev/null +++ b/src/os/pawlet/profiled/payloads/WallpaperHandler.kt @@ -0,0 +1,70 @@ +package os.pawlet.profiled.payloads + +import android.app.WallpaperManager +import android.content.Context +import android.util.Base64 +import android.util.Log +import os.pawlet.profiled.Fields +import os.pawlet.profiled.ParsedPayload +import java.io.ByteArrayInputStream +import java.io.File + +// Android counterpart to platform/linux/Wallpaper.cpp. Same nested +// "wallpaper" object, but the Linux side only ever had a "path" field +// (root can read any local file). A payload authored for Android needs +// image bytes it actually controls, so this handler accepts either: +// - wallpaper.data base64 image bytes (like Cert/Pkcs12's "data") +// - wallpaper.path an on-device path this app can read (e.g. an +// OEM-bundled asset under /system or /vendor) — same meaning as the +// Linux field, kept for parity when the image is already on the image. +// "locked" has no direct WallpaperManager equivalent (no per-setting lock +// API the way DevicePolicyManager has for password policy) — recorded but +// not enforced; noted rather than silently ignored. +class WallpaperHandler : PayloadHandler { + + companion object { private const val TAG = "PawletProfiled/Wallpaper" } + + override fun apply(context: Context, payload: ParsedPayload): Boolean { + val wJson = payload.fields["wallpaper"] ?: return true + val data = Fields.json(wJson, "data") + val path = Fields.json(wJson, "path") + val locked = Fields.jsonBool(wJson, "locked", false) + + val wm = WallpaperManager.getInstance(context) + + val applied = try { + when { + data.isNotEmpty() -> { + val bytes = Base64.decode(data, Base64.DEFAULT) + wm.setStream(ByteArrayInputStream(bytes)) + true + } + path.isNotEmpty() -> { + val f = File(path) + if (!f.exists()) { Log.e(TAG, "wallpaper path does not exist: $path"); false } + else { f.inputStream().use { wm.setStream(it) }; true } + } + else -> { Log.w(TAG, "wallpaper payload has neither data nor path"); false } + } + } catch (e: Exception) { + Log.e(TAG, "failed to set wallpaper", e) + false + } + + if (applied) { + if (locked) Log.i(TAG, "wallpaper.locked=true requested — recorded, not enforced " + + "(WallpaperManager has no lock-from-user-change API)") + Log.i(TAG, "applied wallpaper uuid=${payload.uuid}") + } + return applied + } + + override fun revert(context: Context, payload: ParsedPayload) { + try { + WallpaperManager.getInstance(context).clear() + } catch (e: Exception) { + Log.w(TAG, "failed to clear wallpaper", e) + } + Log.i(TAG, "reverted wallpaper uuid=${payload.uuid}") + } +} diff --git a/src/os/pawlet/profiled/payloads/WifiHandler.kt b/src/os/pawlet/profiled/payloads/WifiHandler.kt new file mode 100644 index 0000000..f71e8f0 --- /dev/null +++ b/src/os/pawlet/profiled/payloads/WifiHandler.kt @@ -0,0 +1,159 @@ +package os.pawlet.profiled.payloads + +import android.content.Context +import android.net.wifi.WifiConfiguration +import android.net.wifi.WifiEnterpriseConfig +import android.net.wifi.WifiManager +import android.util.Log +import os.pawlet.profiled.Fields +import os.pawlet.profiled.ParsedPayload + +// Android counterpart to platform/linux/Wifi.cpp. Same payload fields +// (ssid, hidden, auto-join, mac-address-mode, security{type,password}, +// eap{...}, proxy{...}). +// +// Uses the privileged WifiConfiguration + WifiManager.addNetwork() / +// removeNetwork() path (deprecated in the public SDK since API 29, but +// still the only WifiManager API that supports WEP, a static HTTP proxy, +// and full WifiEnterpriseConfig in one object — the modern +// WifiNetworkSuggestion API can't express all three). It stays fully +// functional for platform-signed/NETWORK_SETTINGS-holding callers, which +// this app is. +class WifiHandler : PayloadHandler { + + companion object { private const val TAG = "PawletProfiled/Wifi" } + + override fun apply(context: Context, payload: ParsedPayload): Boolean { + val ssid = Fields.of(payload, "ssid") + if (ssid.isEmpty()) { Log.e(TAG, "missing ssid"); return false } + val hidden = Fields.boolOf(payload, "hidden", false) + val autoJoin = Fields.boolOf(payload, "auto-join", true) + val macMode = Fields.of(payload, "mac-address-mode", "hardware") + + val secJson = Fields.of(payload, "security") + val secType = Fields.json(secJson, "type") // wpa2|wpa3|wep|none|any + val psk = Fields.json(secJson, "password") + + val eapJson = Fields.of(payload, "eap") + val isEnterprise = eapJson.isNotEmpty() && eapJson != "\"\"" + + val wm = context.getSystemService(Context.WIFI_SERVICE) as WifiManager + val config = WifiConfiguration().apply { + SSID = "\"$ssid\"" + this.hiddenSSID = hidden + status = WifiConfiguration.Status.ENABLED + if (macMode == "random") macRandomizationSetting = WifiConfiguration.RANDOMIZATION_PERSISTENT + else macRandomizationSetting = WifiConfiguration.RANDOMIZATION_NONE + } + + when (secType) { + "wpa3" -> { + config.setSecurityParams(WifiConfiguration.SECURITY_TYPE_SAE) + config.preSharedKey = "\"$psk\"" + } + "wpa2", "wpa" -> { + config.setSecurityParams(WifiConfiguration.SECURITY_TYPE_PSK) + config.preSharedKey = "\"$psk\"" + } + "wep" -> { + // Android dropped WEP entirely on modern releases; keep the + // legacy fields set so this still works on the older API + // levels PawletOS might target, and fail loudly rather than + // silently on releases where the platform rejects it. + @Suppress("DEPRECATION") + config.wepKeys = arrayOf("\"$psk\"") + @Suppress("DEPRECATION") + config.wepTxKeyIndex = 0 + @Suppress("DEPRECATION") + config.allowedKeyManagement.set(WifiConfiguration.KeyMgmt.NONE) + @Suppress("DEPRECATION") + config.allowedAuthAlgorithms.set(WifiConfiguration.AuthAlgorithm.SHARED) + } + "none", "any", "" -> { + config.setSecurityParams(WifiConfiguration.SECURITY_TYPE_OPEN) + } + } + + if (isEnterprise) { + val eapMethod = when { + eapJson.contains("\"tls\"") -> WifiEnterpriseConfig.Eap.TLS + eapJson.contains("\"ttls\"") -> WifiEnterpriseConfig.Eap.TTLS + eapJson.contains("\"fast\"") -> WifiEnterpriseConfig.Eap.FAST + else -> WifiEnterpriseConfig.Eap.PEAP + } + val enterprise = WifiEnterpriseConfig().apply { + eapMethod(eapMethod) + val user = Fields.json(eapJson, "username") + val pass = Fields.json(eapJson, "password") + val outer = Fields.json(eapJson, "outer-identity") + if (user.isNotEmpty()) identity = user + if (pass.isNotEmpty()) password = pass + if (outer.isNotEmpty()) anonymousIdentity = outer + if (eapMethod == WifiEnterpriseConfig.Eap.TTLS || eapMethod == WifiEnterpriseConfig.Eap.PEAP) { + val inner = Fields.json(eapJson, "ttls-inner-auth") + phase2Method = when (inner.lowercase()) { + "mschapv2", "" -> WifiEnterpriseConfig.Phase2.MSCHAPV2 + "pap" -> WifiEnterpriseConfig.Phase2.PAP + "gtc" -> WifiEnterpriseConfig.Phase2.GTC + else -> WifiEnterpriseConfig.Phase2.MSCHAPV2 + } + } + val trustPos = eapJson.indexOf("\"trust\":") + if (trustPos >= 0) { + val serverNames = Fields.json(eapJson.substring(trustPos), "server-names") + if (serverNames.isNotEmpty()) domainSuffixMatch = serverNames + // anchor-cert-uuids: the matching CertHandler-installed CA is + // referenced by uuid; Android enterprise config wants the + // actual X509Certificate, installed separately via + // CertHandler + DevicePolicyManager.installCaCert(), so we + // don't re-attach it here — the platform trust store already + // has it once CertHandler ran. + } + } + config.enterpriseConfig = enterprise + config.allowedKeyManagement.set( + if (secType == "wpa3") WifiConfiguration.KeyMgmt.SUITE_B_192 else WifiConfiguration.KeyMgmt.WPA_EAP + ) + } + + val proxyJson = Fields.of(payload, "proxy") + if (proxyJson.isNotEmpty()) { + val proxyType = Fields.json(proxyJson, "type") + if (proxyType == "manual") { + val host = Fields.json(proxyJson, "host") + val port = Fields.json(proxyJson, "port").toIntOrNull() ?: 0 + if (host.isNotEmpty() && port > 0) { + config.setHttpProxy(android.net.ProxyInfo.buildDirectProxy(host, port)) + } + } else if (proxyType == "auto") { + val pacUrl = Fields.json(proxyJson, "pac-url") + if (pacUrl.isNotEmpty()) { + config.setHttpProxy(android.net.ProxyInfo.buildPacProxy(android.net.Uri.parse(pacUrl))) + } + } + } + + @Suppress("DEPRECATION") + val networkId = wm.addNetwork(config) + if (networkId == -1) { Log.e(TAG, "addNetwork failed for ssid=$ssid"); return false } + @Suppress("DEPRECATION") + wm.enableNetwork(networkId, autoJoin) + @Suppress("DEPRECATION") + wm.saveConfiguration() + + Prefs.putInt(context, "wifi", payload.uuid, networkId) + Log.i(TAG, "applied wifi ssid=$ssid uuid=${payload.uuid} networkId=$networkId") + return true + } + + override fun revert(context: Context, payload: ParsedPayload) { + val wm = context.getSystemService(Context.WIFI_SERVICE) as WifiManager + val networkId = Prefs.getInt(context, "wifi", payload.uuid) ?: return + @Suppress("DEPRECATION") + wm.removeNetwork(networkId) + @Suppress("DEPRECATION") + wm.saveConfiguration() + Prefs.remove(context, "wifi", payload.uuid) + Log.i(TAG, "removed wifi uuid=${payload.uuid}") + } +} diff --git a/src/os/pawlet/profiled/zte/AttestationKeyHasher.kt b/src/os/pawlet/profiled/zte/AttestationKeyHasher.kt new file mode 100644 index 0000000..41c19c6 --- /dev/null +++ b/src/os/pawlet/profiled/zte/AttestationKeyHasher.kt @@ -0,0 +1,44 @@ +package os.pawlet.profiled.zte + +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Log +import java.security.KeyPairGenerator +import java.security.KeyStore +import java.security.MessageDigest + +// The Android analog of the Linux ZTE client's TPM EK public-key hash: +// a hardware-backed key whose attestation chain proves it's bound to this +// specific device's secure hardware (StrongBox or TEE) and can't be +// exported or cloned elsewhere — the same trust property a TPM +// endorsement key gives on PC hardware, via a completely different +// mechanism (Keymint attestation instead of TPM2 EK certificates). +object AttestationKeyHasher { + private const val TAG = "PawletProfiled/ZTE" + private const val ALIAS = "pawletos-zte-attestation" + private const val KEYSTORE = "AndroidKeyStore" + + fun hash(): String { + return try { + val ks = KeyStore.getInstance(KEYSTORE).apply { load(null) } + if (!ks.containsAlias(ALIAS)) generateKey() + val cert = ks.getCertificate(ALIAS) ?: return "" + val digest = MessageDigest.getInstance("SHA-256").digest(cert.publicKey.encoded) + digest.joinToString("") { "%02x".format(it) } + } catch (e: Exception) { + Log.w(TAG, "attestation key unavailable (no hardware keystore support?)", e) + "" + } + } + + private fun generateKey() { + val spec = KeyGenParameterSpec.Builder(ALIAS, KeyProperties.PURPOSE_SIGN) + .setDigests(KeyProperties.DIGEST_SHA256) + .setAttestationChallenge(ALIAS.toByteArray()) + .apply { try { setIsStrongBoxBacked(true) } catch (_: Throwable) { /* device has no StrongBox; TEE-backed key is still a valid hardware root */ } } + .build() + val generator = KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC, KEYSTORE) + generator.initialize(spec) + generator.generateKeyPair() + } +} diff --git a/src/os/pawlet/profiled/zte/DeviceIdentity.kt b/src/os/pawlet/profiled/zte/DeviceIdentity.kt new file mode 100644 index 0000000..346f479 --- /dev/null +++ b/src/os/pawlet/profiled/zte/DeviceIdentity.kt @@ -0,0 +1,88 @@ +package os.pawlet.profiled.zte + +import android.annotation.SuppressLint +import android.content.Context +import android.net.wifi.WifiManager +import android.os.Build +import android.provider.Settings +import android.telephony.TelephonyManager +import android.util.Log +import java.security.MessageDigest + +// Android counterpart to zte/DeviceIdentity.{h,cpp}. Same job (collect +// every stable hardware identifier available, pick a primary one, hash +// the rest into a fingerprint) but the identifiers themselves don't +// transfer — DMI/SMBIOS, EFI GUIDs, and /etc/machine-id are PC-firmware +// and glibc-userland concepts with no Android equivalent. Priority order +// for primarySerial, most to least stable: +// 1. Build.getSerial() hardware serial (READ_PRIVILEGED_PHONE_STATE) +// 2. IMEI/MEID (READ_PRIVILEGED_PHONE_STATE, cellular devices only) +// 3. hardware-backed Keystore attestation key hash — the actual Android +// analog of the Linux side's TPM EK public hash: a hardware-rooted +// key whose attestation chain proves it can't be moved to another +// device, same trust property a TPM EK gives on PC hardware. +// 4. Settings.Secure.ANDROID_ID resets on factory reset, but stable +// otherwise; the only thing guaranteed present on every device. +data class DeviceIdentity( + val hardwareSerial: String = "", + val imei: String = "", + val androidId: String = "", + val board: String = Build.BOARD, + val model: String = Build.MODEL, + val manufacturer: String = Build.MANUFACTURER, + val fingerprint: String = Build.FINGERPRINT, + val permanentMacs: Map = emptyMap(), + val attestationKeyHash: String = "", + val primarySerial: String = "", + val hardwareFingerprint: String = "", +) + +object DeviceIdentityCollector { + private const val TAG = "PawletProfiled/ZTE" + + @SuppressLint("HardwareIds", "MissingPermission") + fun collect(context: Context): DeviceIdentity { + val hardwareSerial = try { Build.getSerial() } catch (e: SecurityException) { + Log.w(TAG, "Build.getSerial() denied — missing READ_PRIVILEGED_PHONE_STATE?"); "" + }.takeIf { it != Build.UNKNOWN } ?: "" + + val imei = try { + val tm = context.getSystemService(Context.TELEPHONY_SERVICE) as? TelephonyManager + tm?.imei ?: tm?.meid ?: "" + } catch (e: SecurityException) { + Log.w(TAG, "TelephonyManager.getImei() denied"); "" + } ?: "" + + val androidId = Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID) ?: "" + + val macs = try { + val wm = context.getSystemService(Context.WIFI_SERVICE) as WifiManager + wm.factoryMacAddresses?.mapIndexed { i, mac -> "wlan$i" to mac }?.toMap() ?: emptyMap() + } catch (e: SecurityException) { + Log.w(TAG, "getFactoryMacAddresses() denied — missing LOCAL_MAC_ADDRESS?"); emptyMap() + } + + val attestationHash = AttestationKeyHasher.hash() + + val primary = when { + hardwareSerial.isNotEmpty() -> hardwareSerial + imei.isNotEmpty() -> imei + attestationHash.isNotEmpty() -> attestationHash + else -> androidId + } + + val combined = listOf(hardwareSerial, imei, androidId, attestationHash, Build.FINGERPRINT) + .joinToString("|") + val fp = sha256Hex(combined) + + return DeviceIdentity( + hardwareSerial = hardwareSerial, imei = imei, androidId = androidId, + permanentMacs = macs, attestationKeyHash = attestationHash, + primarySerial = primary, hardwareFingerprint = fp, + ) + } + + private fun sha256Hex(input: String): String = + MessageDigest.getInstance("SHA-256").digest(input.toByteArray()) + .joinToString("") { "%02x".format(it) } +} diff --git a/src/os/pawlet/profiled/zte/ZteLookupClient.kt b/src/os/pawlet/profiled/zte/ZteLookupClient.kt new file mode 100644 index 0000000..908408b --- /dev/null +++ b/src/os/pawlet/profiled/zte/ZteLookupClient.kt @@ -0,0 +1,174 @@ +package os.pawlet.profiled.zte + +import android.util.Log +import org.json.JSONObject +import os.pawlet.profiled.PawletProfileApplication +import java.io.File +import java.net.URL +import javax.net.ssl.HttpsURLConnection + +// Android counterpart to zte/ZTELookupClient.{h,cpp}. Same two-step flow +// (lookup by hardware identity, then download+install the signed +// .vconfig), same state file semantics — just HttpsURLConnection instead +// of libcurl, and installViaDbus() becomes a direct call into +// PawletProfileApplication instead of a D-Bus round-trip (this runs in +// the same process as the thing it used to call over IPC). +// +// Not ported: main_android.cpp's decision to skip the ZTE +// ConnectivityWatcher (NetworkManager D-Bus, doesn't exist on Android) +// applies here too. This client is invoked once from BootCompletedReceiver +// — real connectivity-triggered retry (the Linux daemon's "enrolls on +// first connectivity" deferred-enrollment guarantee) needs a +// ConnectivityManager.NetworkCallback-based retry loop, which is +// follow-up work, not silently dropped. +enum class EnrollmentState { UNKNOWN, NOT_MANAGED, PENDING, ENROLLED, ENROLL_FAILED, DISABLED } + +data class EnrollmentRecord( + val organizationName: String, + val organizationDomain: String, + val profileUrl: String, + val profileUuid: String, + val mdmServerUrl: String, + val allowSkip: Boolean, + val mandatory: Boolean, + val customMessage: String, +) + +class ZteLookupClient(private val serverUrl: String = DEFAULT_SERVER_URL) { + + companion object { + private const val TAG = "PawletProfiled/ZTE" + const val DEFAULT_SERVER_URL = "https://zte.pawletos.oxmc.me" + const val STATE_FILE = "/data/system/pawletos/zte_state.json" + const val CONFIG_PATH = "/data/system/pawletos/zte.conf" + + fun loadConfiguredServerUrl(): String { + val f = File(CONFIG_PATH) + if (!f.exists()) return DEFAULT_SERVER_URL + return f.readLines() + .map { it.trim() } + .firstOrNull { it.startsWith("server_url=") } + ?.substringAfter("server_url=") + ?.trim() + ?.ifEmpty { null } + ?: DEFAULT_SERVER_URL + } + } + + fun enroll(identity: DeviceIdentity, app: PawletProfileApplication): EnrollmentState { + if (loadState() == EnrollmentState.DISABLED) return EnrollmentState.DISABLED + + val record = lookup(identity) ?: run { saveState(EnrollmentState.NOT_MANAGED); return EnrollmentState.NOT_MANAGED } + if (record.profileUrl.isEmpty()) { saveState(EnrollmentState.PENDING); return EnrollmentState.PENDING } + + val uuid = downloadAndInstall(record, app) + return if (uuid != null) { + saveState(EnrollmentState.ENROLLED, uuid) + EnrollmentState.ENROLLED + } else { + saveState(EnrollmentState.ENROLL_FAILED) + EnrollmentState.ENROLL_FAILED + } + } + + fun lookup(identity: DeviceIdentity): EnrollmentRecord? { + val body = JSONObject().apply { + put("hardwareSerial", identity.hardwareSerial) + put("imei", identity.imei) + put("androidId", identity.androidId) + put("attestationKeyHash", identity.attestationKeyHash) + put("primarySerial", identity.primarySerial) + put("hardwareFingerprint", identity.hardwareFingerprint) + put("board", identity.board) + put("model", identity.model) + put("manufacturer", identity.manufacturer) + put("permanentMacs", JSONObject(identity.permanentMacs)) + } + + val (code, response) = httpPost("$serverUrl/apis/zte/lookup", body.toString()) + if (code == 404) { Log.i(TAG, "device not managed (404)"); return null } + if (code !in 200..299 || response == null) { Log.w(TAG, "lookup failed: HTTP $code"); return null } + + return try { + val json = JSONObject(response) + EnrollmentRecord( + organizationName = json.optString("organizationName"), + organizationDomain = json.optString("organizationDomain"), + profileUrl = json.optString("profileUrl"), + profileUuid = json.optString("profileUuid"), + mdmServerUrl = json.optString("mdmServerUrl"), + allowSkip = json.optBoolean("allowSkip", false), + mandatory = json.optBoolean("mandatory", true), + customMessage = json.optString("customMessage"), + ) + } catch (e: Exception) { + Log.e(TAG, "malformed lookup response", e); null + } + } + + fun downloadAndInstall(record: EnrollmentRecord, app: PawletProfileApplication): String? { + val profileBytes = httpDownload(record.profileUrl) ?: return null + val uuid = app.installProfileDirect(profileBytes) ?: return null + if (record.profileUuid.isNotEmpty() && uuid != record.profileUuid) { + Log.e(TAG, "installed profile uuid=$uuid does not match expected=${record.profileUuid}") + return null + } + return uuid + } + + fun loadState(): EnrollmentState { + val f = File(STATE_FILE) + if (!f.exists()) return EnrollmentState.UNKNOWN + return try { + EnrollmentState.valueOf(JSONObject(f.readText()).optString("state", "UNKNOWN")) + } catch (e: Exception) { EnrollmentState.UNKNOWN } + } + + fun saveState(state: EnrollmentState, profileUuid: String = "") { + val json = JSONObject().apply { + put("state", state.name) + put("profileUuid", profileUuid) + put("updatedAt", System.currentTimeMillis()) + } + try { + File(STATE_FILE).apply { parentFile?.mkdirs() }.writeText(json.toString()) + } catch (e: Exception) { + Log.e(TAG, "failed to persist ZTE state", e) + } + } + + private fun httpPost(url: String, body: String): Pair { + return try { + val conn = URL(url).openConnection() as HttpsURLConnection + conn.requestMethod = "POST" + conn.doOutput = true + conn.connectTimeout = 10_000 + conn.readTimeout = 15_000 + conn.setRequestProperty("Content-Type", "application/json") + conn.outputStream.use { it.write(body.toByteArray()) } + val code = conn.responseCode + val stream = if (code in 200..299) conn.inputStream else conn.errorStream + val response = stream?.bufferedReader()?.use { it.readText() } + code to response + } catch (e: Exception) { + Log.e(TAG, "ZTE lookup request failed", e) + -1 to null + } + } + + private fun httpDownload(url: String): ByteArray? { + return try { + val conn = URL(url).openConnection() as HttpsURLConnection + conn.connectTimeout = 10_000 + conn.readTimeout = 30_000 + if (conn.responseCode !in 200..299) { + Log.e(TAG, "profile download failed: HTTP ${conn.responseCode}") + return null + } + conn.inputStream.use { it.readBytes() } + } catch (e: Exception) { + Log.e(TAG, "profile download failed", e) + null + } + } +}