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.
This commit is contained in:
2026-07-25 01:35:58 -07:00
commit a48077078c
41 changed files with 2954 additions and 0 deletions
+93
View File
@@ -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/<name> (PawletOS/prebuilt_libs_<name>) 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",
}
+87
View File
@@ -0,0 +1,87 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="os.pawlet.profiled">
<!-- Installed as a priv-app under /system/priv-app, platform-signed.
Privileged permissions below require a matching entry in
etc/privapp-permissions-os.pawlet.profiled.xml on top of this
declaration — AOSP ignores privileged perms without both. -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.SET_WALLPAPER" />
<uses-permission android:name="android.permission.SET_WALLPAPER_HINTS" />
<!-- Signature-protected, auto-granted to platform-signed apps, no
privapp-permissions allowlist entry required. Lets this app
provision itself as device owner at first boot (see
DeviceOwnerProvisioner.kt) and manage other admins afterward. -->
<uses-permission android:name="android.permission.MANAGE_PROFILE_AND_DEVICE_OWNERS" />
<uses-permission android:name="android.permission.MANAGE_DEVICE_ADMINS" />
<!-- Privileged (signature|privileged) — require the allowlist entry.
WRITE_SECURE_SETTINGS: private DNS, screensaver, device-provisioned
flags, NTP server.
NETWORK_SETTINGS: global HTTP proxy, privileged Wi-Fi/Ethernet
config that WifiNetworkSuggestion can't express (EAP anchor
pinning, static config without user approval UI).
INTERACT_ACROSS_USERS: apply profiles before any secondary user
exists / from the system user context during first boot. -->
<uses-permission android:name="android.permission.WRITE_SECURE_SETTINGS" />
<uses-permission android:name="android.permission.NETWORK_SETTINGS" />
<uses-permission android:name="android.permission.NETWORK_SETUP_WIZARD" />
<uses-permission android:name="android.permission.INTERACT_ACROSS_USERS" />
<!-- ZTE hardware-identity collection (DeviceIdentityCollector.kt) -->
<uses-permission android:name="android.permission.READ_PRIVILEGED_PHONE_STATE" />
<uses-permission android:name="android.permission.LOCAL_MAC_ADDRESS" />
<application
android:name=".PawletProfileApplication"
android:label="@string/app_name"
android:persistent="true"
android:directBootAware="true"
android:allowBackup="false">
<service
android:name=".PawletProfileService"
android:exported="true"
android:directBootAware="true"
android:permission="os.pawlet.profiled.permission.BIND_SERVICE">
<intent-filter>
<action android:name="os.pawlet.profiled.action.BIND" />
</intent-filter>
</service>
<receiver
android:name=".PawletDeviceAdminReceiver"
android:permission="android.permission.BIND_DEVICE_ADMIN"
android:exported="true">
<meta-data
android:name="android.app.device_admin"
android:resource="@xml/device_admin_receiver" />
<intent-filter>
<action android:name="android.app.action.DEVICE_ADMIN_ENABLED" />
</intent-filter>
</receiver>
<receiver
android:name=".BootCompletedReceiver"
android:exported="true"
android:directBootAware="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.LOCKED_BOOT_COMPLETED" />
</intent-filter>
</receiver>
</application>
<permission
android:name="os.pawlet.profiled.permission.BIND_SERVICE"
android:protectionLevel="signature" />
</manifest>
+169
View File
@@ -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
```
@@ -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);
}
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Installed to /system/etc/permissions/. Required alongside the
<uses-permission> declarations in AndroidManifest.xml for any
signature|privileged permission — AOSP silently denies privileged
permissions to priv-app packages that aren't also allowlisted here. -->
<permissions>
<privapp-permissions package="os.pawlet.profiled">
<permission name="android.permission.WRITE_SECURE_SETTINGS" />
<permission name="android.permission.NETWORK_SETTINGS" />
<permission name="android.permission.NETWORK_SETUP_WIZARD" />
<permission name="android.permission.INTERACT_ACROSS_USERS" />
<permission name="android.permission.READ_PRIVILEGED_PHONE_STATE" />
<permission name="android.permission.LOCAL_MAC_ADDRESS" />
</privapp-permissions>
</permissions>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">PawletOS Profile Service</string>
<string name="device_admin_label">PawletOS Profile Service</string>
<string name="device_admin_description">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.</string>
</resources>
+15
View File
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<device-admin xmlns:android="http://schemas.android.com/apk/res/android"
android:visible="true">
<uses-policies>
<limit-password />
<watch-login />
<reset-password />
<force-lock />
<wipe-data />
<expire-password />
<encrypted-storage />
<disable-camera />
<disable-keyguard-features />
</uses-policies>
</device-admin>
+11
View File
@@ -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
+46
View File
@@ -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 };
@@ -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")
}
}
}
@@ -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}")
}
}
}
+52
View File
@@ -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
}
}
@@ -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")
}
}
}
@@ -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)
}
}
}
@@ -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<String> = 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
}
}
+43
View File
@@ -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<String, String>,
)
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<String, String> = emptyMap(),
val payloads: List<ParsedPayload> = emptyList(),
val trustLevel: TrustLevel = TrustLevel.UNSIGNED,
)
+128
View File
@@ -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<String,Any>/List<Any> 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<String, Any?>
} 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<String, Any?>)?.let {
ProfileMeta(str(it, "name"), str(it, "description"), str(it, "organization"))
} ?: ProfileMeta()
val lifecycle = (root["lifecycle"] as? Map<String, Any?>)?.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<String, String> = emptyMap()
(root["consent"] as? Map<String, Any?>)?.let { c ->
consentDefault = str(c, "default", "en")
@Suppress("UNCHECKED_CAST")
(c["translations"] as? Map<String, Any?>)?.let { t ->
consentTranslations = t.mapValues { it.value?.toString() ?: "" }
}
}
val payloads = mutableListOf<ParsedPayload>()
@Suppress("UNCHECKED_CAST")
(root["payloads"] as? List<Any?>)?.forEach { item ->
(item as? Map<String, Any?>)?.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<String, Any?>): 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<String, String>()
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<ParsedPayload>): 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<String, Any?>, 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") + "\""
}
}
+153
View File
@@ -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
// <uuid>/
// 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<String>()
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<String> = 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<String> {
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)
}
+139
View File
@@ -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<VerifyResult, ByteArray> {
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<TrustAnchor> {
val cf = CertificateFactory.getInstance("X.509")
val anchors = mutableSetOf<TrustAnchor>()
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
}
}
@@ -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}")
}
}
@@ -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()
}
}
@@ -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}")
}
}
@@ -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}")
}
}
@@ -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) {}
}
@@ -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}")
}
}
@@ -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}")
}
}
@@ -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}")
}
}
@@ -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")
}
}
@@ -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::<name>::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<String, PayloadHandler> = 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]
}
@@ -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<Certificate>()
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}")
}
}
+36
View File
@@ -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()
}
}
@@ -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}")
}
}
@@ -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}")
}
}
@@ -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}")
}
}
@@ -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}")
}
}
@@ -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}")
}
}
@@ -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}")
}
}
@@ -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}")
}
}
@@ -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()
}
}
@@ -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<String, String> = 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) }
}
@@ -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<Int, String?> {
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
}
}
}