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:
+93
@@ -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",
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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") + "\""
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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}")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user