commit ae9e830f7fef7fa6c9d87aeb7e079c561c47bf28 Author: oxmc7769 Date: Sat Jul 25 00:06:51 2026 -0700 Initial commit: PawletCache, device-side LAN content-cache resolver Bound service BgUpd consults before downloading component assets: discovers a trusted cache server (mDNS and/or central lookup, policy- configurable), verifies its signed CacheToken against a pinned root key, and hands back a rewritten URL + TLS pin. Three-tier discovery policy (runtime MDM override -> vendor-baked default -> compiled default). Pairs with pawletcache-server (the LAN daemon) and BgUpd (the caller). diff --git a/Android.bp b/Android.bp new file mode 100644 index 0000000..49c1dc3 --- /dev/null +++ b/Android.bp @@ -0,0 +1,37 @@ +// +// Copyright (C) 2026 oxmc / PawletOS +// +// SPDX-License-Identifier: Apache-2.0 +// + +// Shared with BgUpd (and later the OTA/media resolvers) so callers can bind +// IPawletCacheService without duplicating the AIDL sources. +aidl_interface { + name: "pawletos.cache-aidl", + unstable: true, + local_include_dir: "aidl", + srcs: ["aidl/pawletos/cache/*.aidl"], +} + +android_app { + name: "PawletCache", + + srcs: ["src/**/*.kt"], + resource_dirs: ["res"], + manifest: "AndroidManifest.xml", + + // No hidden-API/privileged-permission surface needed — just platform-signed + // so BgUpd (also platform-signed) can hold the signature-level BIND_RESOLVER + // permission this app defines. + certificate: "platform", + system_ext_specific: true, + + static_libs: [ + "androidx.core_core-ktx", + "pawletos.cache-aidl-java", + ], + + optimize: { + enabled: false, + }, +} diff --git a/AndroidManifest.xml b/AndroidManifest.xml new file mode 100644 index 0000000..c378f0b --- /dev/null +++ b/AndroidManifest.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/aidl/pawletos/cache/IPawletCacheService.aidl b/aidl/pawletos/cache/IPawletCacheService.aidl new file mode 100644 index 0000000..d3f9be1 --- /dev/null +++ b/aidl/pawletos/cache/IPawletCacheService.aidl @@ -0,0 +1,24 @@ +/* + * SPDX-FileCopyrightText: oxmc / PawletOS + * SPDX-License-Identifier: Apache-2.0 + */ +package pawletos.cache; + +interface IPawletCacheService { + // Rewrites originUrl to a local cache URL if a trusted cache server is + // currently active and its allowlist covers this origin; otherwise + // returns originUrl unchanged. Never blocks on network — resolution + // reads state refreshed in the background (see PawletCacheService), + // so this is safe to call before every download. + String resolveAssetUrl(String originUrl); + + // Base64 SHA-256 of the active cache's TLS SubjectPublicKeyInfo, for the + // caller to pin its HTTPS connection against — the cache's cert is + // self-signed, this fingerprint (verified against the signed CacheToken) + // is the actual trust anchor, not a CA chain. Null if no cache is active. + String getActiveCacheTlsSpkiSha256(); + + // Diagnostics for a future settings UI. + boolean isCacheActive(); + String getActiveCacheHost(); +} diff --git a/res/values/strings.xml b/res/values/strings.xml new file mode 100644 index 0000000..1691c50 --- /dev/null +++ b/res/values/strings.xml @@ -0,0 +1,8 @@ + + + + PawletOS Content Cache + diff --git a/sepolicy/file_contexts b/sepolicy/file_contexts new file mode 100644 index 0000000..eb69f10 --- /dev/null +++ b/sepolicy/file_contexts @@ -0,0 +1,4 @@ +/data/misc/pawletcache(/.*)? u:object_r:pawletcache_policy_file:s0 + +# OEM/vendor-baked default policy — see vendor-config/README.md. +/vendor/etc/pawletcache(/.*)? u:object_r:pawletcache_vendor_config_file:s0 diff --git a/sepolicy/pawlet_cache.te b/sepolicy/pawlet_cache.te new file mode 100644 index 0000000..1be8757 --- /dev/null +++ b/sepolicy/pawlet_cache.te @@ -0,0 +1,41 @@ +# pawlet-cache (os.pawlet.cache) — platform-signed app, LAN content-cache resolver. +# Bound by bg_upd (and later OTA/media resolvers) via IPawletCacheService. +# +# Seeded in permissive mode, same caveat as bg_upd.te: shaped from the actual +# code paths (NsdManager discovery, HTTPS central lookup, policy override file +# read) but not yet validated against real avc denials on-device. + +type pawlet_cache, domain, coredomain; +app_domain(pawlet_cache) +permissive pawlet_cache; + +type pawlet_cache_data_file, file_type, data_file_type, app_data_file_type; + +# Central lookup (HTTPS) + cache server asset fetches happen in bg_upd, not +# here — pawlet_cache itself only needs network for central lookup requests +# made during discovery and for NSD's underlying mDNS multicast traffic. +net_domain(pawlet_cache) + +allow pawlet_cache pawlet_cache_data_file:dir create_dir_perms; +allow pawlet_cache pawlet_cache_data_file:file create_file_perms; + +# NsdManager discovery/resolve calls route through system_server to mdnsd. +binder_call(pawlet_cache, system_server) +allow pawlet_cache servicediscovery_service:service_manager find; + +# Callers bind IPawletCacheService — see bg_upd.te's binder_call(bg_upd, pawlet_cache). + +# Enterprise policy override, written by PawletOS's own fork of vesperprofiled +# (git.oxmc.me/PawletOS/profiled — see PolicyOverride.kt). Its +# ContentCacheHandler doesn't exist in vesperprofiled's default VesperOS +# build; write-side sepolicy rule lives in that fork's vesperprofiled.te. +type pawletcache_policy_file, file_type, data_file_type; +allow pawlet_cache pawletcache_policy_file:file { read open getattr }; +allow pawlet_cache pawletcache_policy_file:dir { read open getattr search }; + +# OEM/vendor-baked default policy (read-only, build-time) — see +# vendor-config/README.md. vendor_file_type is required for a type to live +# under /vendor per Treble's vendor/system sepolicy split. +type pawletcache_vendor_config_file, file_type, vendor_file_type; +allow pawlet_cache pawletcache_vendor_config_file:file { read open getattr }; +allow pawlet_cache pawletcache_vendor_config_file:dir { read open getattr search }; diff --git a/sepolicy/seapp_contexts b/sepolicy/seapp_contexts new file mode 100644 index 0000000..d246c8b --- /dev/null +++ b/sepolicy/seapp_contexts @@ -0,0 +1 @@ +user=_app seinfo=platform name=os.pawlet.cache domain=pawlet_cache type=pawlet_cache_data_file levelFrom=all diff --git a/src/os/pawlet/cache/CacheOrchestrator.kt b/src/os/pawlet/cache/CacheOrchestrator.kt new file mode 100644 index 0000000..6eb5135 --- /dev/null +++ b/src/os/pawlet/cache/CacheOrchestrator.kt @@ -0,0 +1,40 @@ +/* + * SPDX-FileCopyrightText: oxmc / PawletOS + * SPDX-License-Identifier: Apache-2.0 + */ +package os.pawlet.cache + +import android.content.Context +import android.util.Log +import os.pawlet.cache.discovery.CentralLookupClient +import os.pawlet.cache.discovery.DiscoveredCache +import os.pawlet.cache.discovery.MdnsDiscovery + +/** + * Runs one discovery pass per the active policy (enterprise override, or the + * central-preferred/mDNS-fallback default) and returns the cache server to + * trust, if any. Blocking — callers run this off the main thread on a timer, + * not per-request (see PawletCacheService). + */ +class CacheOrchestrator(private val context: Context) { + + fun resolve(): DiscoveredCache? { + val policy = PolicyOverride.read() + if (policy.mode == Constants.DiscoveryMode.DISABLED) return null + policy.pinnedServer?.let { return it } + + if (policy.mode == Constants.DiscoveryMode.CENTRAL || policy.mode == Constants.DiscoveryMode.BOTH) { + CentralLookupClient().lookup()?.let { + Log.d(Constants.TAG, "Active cache via central lookup: ${it.host}:${it.port}") + return it + } + } + if (policy.mode == Constants.DiscoveryMode.LAN || policy.mode == Constants.DiscoveryMode.BOTH) { + MdnsDiscovery(context).discover().firstOrNull()?.let { + Log.d(Constants.TAG, "Active cache via mDNS: ${it.host}:${it.port}") + return it + } + } + return null + } +} diff --git a/src/os/pawlet/cache/Constants.kt b/src/os/pawlet/cache/Constants.kt new file mode 100644 index 0000000..25d5fe3 --- /dev/null +++ b/src/os/pawlet/cache/Constants.kt @@ -0,0 +1,50 @@ +/* + * SPDX-FileCopyrightText: oxmc / PawletOS + * SPDX-License-Identifier: Apache-2.0 + */ +package os.pawlet.cache + +object Constants { + const val TAG = "PawletCache" + + const val CENTRAL_LOOKUP_URL = "https://oxmc.me/apis/aosp/cache/lookup" + + const val MDNS_SERVICE_TYPE = "_pawletcache._tcp" + + // Central authority's Ed25519 public key (SPKI, base64), pinned in the OS + // image. Every CacheToken is verified against this before a cache server + // is trusted, offline and independent of which discovery path found it. + // + // PLACEHOLDER — replace with the real oxmc.me signing key before shipping. + // Generate the real pair with: `openssl genpkey -algorithm ed25519` / + // `openssl pkey -pubout`, keep the private half only on oxmc.me. + const val CACHE_TRUST_ROOT_PUBKEY_BASE64 = + "REPLACE_WITH_REAL_OXMC_ME_ED25519_PUBLIC_KEY_SPKI_BASE64" + + // Enterprise override written at runtime by PawletOS's own fork of + // vesperprofiled's "content-cache" payload handler (MDM-pushed, mutable) + // — see vesperprofiled-config-schema and PolicyOverride.kt. Doesn't + // exist in vesperprofiled's default VesperOS build; PawletOS-fork-only. + // Wins over VENDOR_POLICY_PATH when both are present. + const val POLICY_OVERRIDE_PATH = "/data/misc/pawletcache/policy.json" + + // OEM/vendor-baked default policy, shipped with the system image on the + // vendor partition (read-only, set at build time — see + // android_packages_apps_PawletCache/vendor-config/README.md). Same JSON + // shape as POLICY_OVERRIDE_PATH. Used when no runtime override exists — + // this is how a device builder ships "our devices default to LAN-only" + // or "pin our own cache server" without needing an MDM profile pushed + // after first boot. + const val VENDOR_POLICY_PATH = "/vendor/etc/pawletcache/policy.json" + + // How often the background resolver re-runs discovery + re-validates the + // active cache's token expiry. + const val REFRESH_INTERVAL_MS = 5 * 60 * 1000L + + object DiscoveryMode { + const val CENTRAL = "central" + const val LAN = "lan" + const val BOTH = "both" + const val DISABLED = "disabled" + } +} diff --git a/src/os/pawlet/cache/PawletCacheService.kt b/src/os/pawlet/cache/PawletCacheService.kt new file mode 100644 index 0000000..fe2f649 --- /dev/null +++ b/src/os/pawlet/cache/PawletCacheService.kt @@ -0,0 +1,74 @@ +/* + * SPDX-FileCopyrightText: oxmc / PawletOS + * SPDX-License-Identifier: Apache-2.0 + */ +package os.pawlet.cache + +import android.app.Service +import android.content.Intent +import android.net.Uri +import android.os.IBinder +import android.util.Log +import os.pawlet.cache.discovery.DiscoveredCache +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference + +/** + * Bound entry point other platform-signed apps (BgUpd today; the OTA/media + * resolvers later) call before downloading anything, to find out whether a + * trusted LAN cache server should serve the request instead of origin. + * + * State is refreshed on a timer, not per-call — resolveAssetUrl() only ever + * reads [activeCache], so it never blocks a caller on network I/O. + */ +class PawletCacheService : Service() { + + private val workers = Executors.newSingleThreadScheduledExecutor() + private val activeCache = AtomicReference(null) + private lateinit var orchestrator: CacheOrchestrator + + override fun onCreate() { + super.onCreate() + orchestrator = CacheOrchestrator(this) + workers.scheduleWithFixedDelay(::refresh, 0, Constants.REFRESH_INTERVAL_MS, TimeUnit.MILLISECONDS) + } + + override fun onDestroy() { + workers.shutdown() + super.onDestroy() + } + + private fun refresh() { + try { + val current = activeCache.get() + if (current != null && !current.token.isExpired) { + // Still valid — no need to re-run discovery every tick, just + // let it ride until it's close to expiry or missing. + return + } + activeCache.set(orchestrator.resolve()) + } catch (e: Exception) { + Log.e(Constants.TAG, "Cache discovery refresh failed", e) + } + } + + private val binder = object : IPawletCacheService.Stub() { + override fun resolveAssetUrl(originUrl: String): String { + val cache = activeCache.get() ?: return originUrl + return Uri.parse("https://${cache.host}:${cache.port}/asset") + .buildUpon() + .appendQueryParameter("url", originUrl) + .build() + .toString() + } + + override fun getActiveCacheTlsSpkiSha256(): String? = activeCache.get()?.token?.tlsSpkiSha256Base64 + + override fun isCacheActive(): Boolean = activeCache.get() != null + + override fun getActiveCacheHost(): String? = activeCache.get()?.host + } + + override fun onBind(intent: Intent?): IBinder = binder +} diff --git a/src/os/pawlet/cache/PolicyOverride.kt b/src/os/pawlet/cache/PolicyOverride.kt new file mode 100644 index 0000000..cf1567e --- /dev/null +++ b/src/os/pawlet/cache/PolicyOverride.kt @@ -0,0 +1,81 @@ +/* + * SPDX-FileCopyrightText: oxmc / PawletOS + * SPDX-License-Identifier: Apache-2.0 + */ +package os.pawlet.cache + +import android.util.Log +import org.json.JSONObject +import os.pawlet.cache.discovery.DiscoveredCache +import os.pawlet.cache.trust.CacheTokenVerifier +import java.io.File + +/** + * Discovery policy, resolved from three tiers, most specific wins: + * + * 1. [Constants.POLICY_OVERRIDE_PATH] — runtime override, written by + * PawletOS's own fork of vesperprofiled's "content-cache" MDM payload + * handler (see vesperprofiled-config-schema/schema/profile.schema.yml). + * Doesn't exist in vesperprofiled's default VesperOS build — this is + * PawletOS fork-specific code, added once that fork exists. Mutable, + * can arrive any time after first boot. + * 2. [Constants.VENDOR_POLICY_PATH] — OEM/vendor-baked default, shipped on + * the read-only vendor partition at build time (see + * android_packages_apps_PawletCache/vendor-config/README.md). This is + * how a device builder ships a standing default (e.g. "our fleet + * defaults to LAN-only cache discovery") without needing an MDM profile + * pushed after the fact, and doesn't depend on the fork above existing. + * 3. Compiled-in default — [Constants.DiscoveryMode.BOTH] — if neither + * file exists. + * + * Both files share the same JSON contract (world-readable by design — it's + * policy, not a secret): + * ``` + * { + * "mode": "lan" | "central" | "both" | "disabled", + * "pinnedServer": { "host": "...", "port": 8443, "token": "" } + * } + * ``` + * `pinnedServer`, if present, skips discovery entirely — whoever wrote the + * file already knows which server to use. Its token is still + * signature-verified either way; a config file can pin a *hostname*, not + * bypass the trust chain. + */ +object PolicyOverride { + + data class Policy( + val mode: String, + val pinnedServer: DiscoveredCache?, + ) + + fun read(): Policy { + return readFrom(Constants.POLICY_OVERRIDE_PATH) + ?: readFrom(Constants.VENDOR_POLICY_PATH) + ?: Policy(Constants.DiscoveryMode.BOTH, null) + } + + /** @return null if the file doesn't exist — distinct from a present-but-empty file. */ + private fun readFrom(path: String): Policy? { + val file = File(path) + if (!file.exists()) return null + + return try { + val json = JSONObject(file.readText()) + val mode = json.optString("mode", Constants.DiscoveryMode.BOTH) + val pinned = json.optJSONObject("pinnedServer")?.let { p -> + val token = CacheTokenVerifier.verify(p.getString("token")) ?: run { + Log.w(Constants.TAG, "$path's pinnedServer token failed verification, ignoring pin") + return@let null + } + DiscoveredCache(p.getString("host"), p.getInt("port"), token, source = "policy") + } + Policy(mode, pinned) + } catch (e: Exception) { + Log.w(Constants.TAG, "Malformed policy override at $path", e) + // A malformed file at this tier should not silently fall through + // to a lower-priority tier — it's a real config error the + // OEM/admin should fix, not a "file doesn't exist" case. + Policy(Constants.DiscoveryMode.BOTH, null) + } + } +} diff --git a/src/os/pawlet/cache/discovery/CentralLookupClient.kt b/src/os/pawlet/cache/discovery/CentralLookupClient.kt new file mode 100644 index 0000000..ad0fec3 --- /dev/null +++ b/src/os/pawlet/cache/discovery/CentralLookupClient.kt @@ -0,0 +1,54 @@ +/* + * SPDX-FileCopyrightText: oxmc / PawletOS + * SPDX-License-Identifier: Apache-2.0 + */ +package os.pawlet.cache.discovery + +import android.util.Log +import org.json.JSONObject +import os.pawlet.cache.Constants +import os.pawlet.cache.trust.CacheTokenVerifier +import java.net.HttpURLConnection +import java.net.URL + +/** + * Apple-content-caching-style lookup: ask the central authority whether a + * cache server is registered behind *our* public IP. The request's own + * source IP is the lookup key — nothing client-supplied to spoof. + */ +class CentralLookupClient { + + fun lookup(): DiscoveredCache? { + val connection = URL(Constants.CENTRAL_LOOKUP_URL).openConnection() as HttpURLConnection + try { + connection.connectTimeout = 10_000 + connection.readTimeout = 10_000 + connection.requestMethod = "GET" + if (connection.responseCode != HttpURLConnection.HTTP_OK) { + Log.d(Constants.TAG, "Central lookup: HTTP ${connection.responseCode}") + return null + } + val body = connection.inputStream.bufferedReader().use { it.readText() } + val json = JSONObject(body) + if (!json.optBoolean("available", false)) return null + + val token = CacheTokenVerifier.verify(json.getString("token")) ?: run { + Log.w(Constants.TAG, "Central lookup returned an unverifiable token") + return null + } + // host/port come from inside the verified token (lanHost/port), + // not unsigned JSON siblings — see CacheToken.kt's doc comment. + return DiscoveredCache( + host = token.lanHost, + port = token.port, + token = token, + source = "central", + ) + } catch (e: Exception) { + Log.d(Constants.TAG, "Central lookup failed: ${e.message}") + return null + } finally { + connection.disconnect() + } + } +} diff --git a/src/os/pawlet/cache/discovery/DiscoveredCache.kt b/src/os/pawlet/cache/discovery/DiscoveredCache.kt new file mode 100644 index 0000000..ba23a61 --- /dev/null +++ b/src/os/pawlet/cache/discovery/DiscoveredCache.kt @@ -0,0 +1,15 @@ +/* + * SPDX-FileCopyrightText: oxmc / PawletOS + * SPDX-License-Identifier: Apache-2.0 + */ +package os.pawlet.cache.discovery + +import os.pawlet.cache.trust.CacheToken + +/** A cache server candidate whose CacheToken has already passed signature verification. */ +data class DiscoveredCache( + val host: String, + val port: Int, + val token: CacheToken, + val source: String, // "mdns" or "central" — diagnostics only +) diff --git a/src/os/pawlet/cache/discovery/MdnsDiscovery.kt b/src/os/pawlet/cache/discovery/MdnsDiscovery.kt new file mode 100644 index 0000000..0557908 --- /dev/null +++ b/src/os/pawlet/cache/discovery/MdnsDiscovery.kt @@ -0,0 +1,93 @@ +/* + * SPDX-FileCopyrightText: oxmc / PawletOS + * SPDX-License-Identifier: Apache-2.0 + */ +package os.pawlet.cache.discovery + +import android.content.Context +import android.net.nsd.NsdManager +import android.net.nsd.NsdServiceInfo +import android.util.Log +import os.pawlet.cache.Constants +import os.pawlet.cache.trust.CacheTokenVerifier +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +/** + * One-shot LAN browse for `_pawletcache._tcp` via NsdManager. Blocking — + * callers already run this off the main thread (see PawletCacheService's + * refresh loop) — so a simple browse-for-N-seconds-then-stop model is + * simpler than threading NsdManager's callback API through coroutines here. + */ +class MdnsDiscovery(private val context: Context) { + + fun discover(timeoutMs: Long = 4_000): List { + val nsdManager = context.getSystemService(Context.NSD_SERVICE) as? NsdManager ?: return emptyList() + val found = CopyOnWriteArrayList() + val pendingResolves = CopyOnWriteArrayList() + val discoveryDone = CountDownLatch(1) + + val discoveryListener = object : NsdManager.DiscoveryListener { + override fun onDiscoveryStarted(serviceType: String) {} + override fun onServiceFound(serviceInfo: NsdServiceInfo) { + val resolveDone = CountDownLatch(1) + val resolveListener = object : NsdManager.ResolveListener { + override fun onResolveFailed(info: NsdServiceInfo, errorCode: Int) { + Log.w(Constants.TAG, "mDNS resolve failed for ${info.serviceName}: $errorCode") + resolveDone.countDown() + } + override fun onServiceResolved(info: NsdServiceInfo) { + toDiscoveredCache(info)?.let { found.add(it) } + resolveDone.countDown() + } + } + try { + nsdManager.resolveService(serviceInfo, resolveListener) + } catch (e: Exception) { + Log.w(Constants.TAG, "resolveService threw", e) + resolveDone.countDown() + } + // Track so we can bound total discover() time below. + pendingResolves.add(Thread { resolveDone.await(3, TimeUnit.SECONDS) }.apply { start() }) + } + override fun onServiceLost(serviceInfo: NsdServiceInfo) {} + override fun onDiscoveryStopped(serviceType: String) {} + override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) { + Log.w(Constants.TAG, "mDNS discovery start failed: $errorCode") + discoveryDone.countDown() + } + override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {} + } + + try { + nsdManager.discoverServices(Constants.MDNS_SERVICE_TYPE, NsdManager.PROTOCOL_DNS_SD, discoveryListener) + } catch (e: Exception) { + Log.w(Constants.TAG, "discoverServices threw", e) + return emptyList() + } + + discoveryDone.await(timeoutMs, TimeUnit.MILLISECONDS) + try { + nsdManager.stopServiceDiscovery(discoveryListener) + } catch (e: Exception) { + // Already stopped/never started — fine. + } + pendingResolves.forEach { it.join(500) } + + return found + } + + private fun toDiscoveredCache(info: NsdServiceInfo): DiscoveredCache? { + val chunkCount = info.attributes["chunks"]?.let { String(it, Charsets.UTF_8).toIntOrNull() } ?: return null + val token = buildString { + for (i in 0 until chunkCount) { + val chunk = info.attributes["token$i"] ?: return null + append(String(chunk, Charsets.UTF_8)) + } + } + val verified = CacheTokenVerifier.verify(token) ?: return null + val host = info.host?.hostAddress ?: return null + return DiscoveredCache(host, info.port, verified, source = "mdns") + } +} diff --git a/src/os/pawlet/cache/trust/CacheToken.kt b/src/os/pawlet/cache/trust/CacheToken.kt new file mode 100644 index 0000000..82af7ce --- /dev/null +++ b/src/os/pawlet/cache/trust/CacheToken.kt @@ -0,0 +1,129 @@ +/* + * SPDX-FileCopyrightText: oxmc / PawletOS + * SPDX-License-Identifier: Apache-2.0 + */ +package os.pawlet.cache.trust + +import android.util.Base64 +import android.util.Log +import org.json.JSONObject +import os.pawlet.cache.Constants +import java.security.KeyFactory +import java.security.NoSuchAlgorithmException +import java.security.Signature +import java.security.spec.X509EncodedKeySpec +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.TimeZone + +/** + * A verified, parsed CacheToken — the central authority's attestation that a + * given TLS public key belongs to a legitimately registered cache server. + * See pawletcache-server/README.md for the wire format and trust chain. + */ +data class CacheToken( + val serverId: String, + val pubkeyEd25519Base64: String, + val tlsSpkiSha256Base64: String, + val hostname: String, + // The actual connect target — inside the signed payload deliberately + // (see pawletcache-server/README.md's registration API), not a sibling + // of the token in the lookup/mDNS response, so nothing on the path + // between device and central registry can redirect a device to a + // different host/port without invalidating the signature. + val lanHost: String, + val port: Int, + val issuedAt: String, + val expiresAt: String, +) { + val isExpired: Boolean + get() = parseIso8601(expiresAt)?.let { it <= System.currentTimeMillis() } ?: true +} + +object CacheTokenVerifier { + + /** + * @param wireToken base64( JSON { payload: {...CacheToken fields}, signature: base64 } ) + * @return the parsed token if the signature checks out against the pinned root key + * and it isn't already expired; null otherwise (logged, never throws). + */ + fun verify(wireToken: String): CacheToken? { + return try { + val envelope = JSONObject(String(Base64.decode(wireToken, Base64.DEFAULT), Charsets.UTF_8)) + val payload = envelope.getJSONObject("payload") + val signatureBytes = Base64.decode(envelope.getString("signature"), Base64.DEFAULT) + + if (!verifySignature(canonicalize(payload), signatureBytes)) { + Log.w(Constants.TAG, "CacheToken signature verification failed") + return null + } + + val token = CacheToken( + serverId = payload.getString("serverId"), + pubkeyEd25519Base64 = payload.getString("pubkeyEd25519"), + tlsSpkiSha256Base64 = payload.getString("tlsSpkiSha256"), + hostname = payload.getString("hostname"), + lanHost = payload.getString("lanHost"), + // Carried as a JSON string, not a number — every CacheToken + // payload field is a string so canonicalize() below can treat + // them uniformly; must match how the registry serializes it. + port = payload.getString("port").toInt(), + issuedAt = payload.getString("issuedAt"), + expiresAt = payload.getString("expiresAt"), + ) + if (token.isExpired) { + Log.w(Constants.TAG, "CacheToken for ${token.hostname} is expired") + return null + } + token + } catch (e: Exception) { + Log.w(Constants.TAG, "Malformed CacheToken", e) + null + } + } + + // KeyFactory/Signature "Ed25519" needs API 33+ (Conscrypt). PawletOS targets + // recent AOSP bases only, so no fallback path is implemented. + private fun verifySignature(canonicalPayload: String, signature: ByteArray): Boolean { + return try { + val keyBytes = Base64.decode(Constants.CACHE_TRUST_ROOT_PUBKEY_BASE64, Base64.DEFAULT) + val publicKey = KeyFactory.getInstance("Ed25519") + .generatePublic(X509EncodedKeySpec(keyBytes)) + val verifier = Signature.getInstance("Ed25519") + verifier.initVerify(publicKey) + verifier.update(canonicalPayload.toByteArray(Charsets.UTF_8)) + verifier.verify(signature) + } catch (e: NoSuchAlgorithmException) { + Log.e(Constants.TAG, "Ed25519 not available on this platform", e) + false + } catch (e: Exception) { + Log.w(Constants.TAG, "Signature verification error", e) + false + } + } + + /** + * Must byte-for-byte match registration.js's canonicalize(): JSON.stringify + * with keys sorted lexicographically, no extra whitespace. + */ + private fun canonicalize(obj: JSONObject): String { + val keys = obj.keys().asSequence().sorted().toList() + val sb = StringBuilder("{") + keys.forEachIndexed { i, key -> + if (i > 0) sb.append(',') + sb.append(JSONObject.quote(key)).append(':').append(JSONObject.quote(obj.getString(key))) + } + return sb.append('}').toString() + } + + private fun parseIso8601(value: String): Long? = try { + SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + }.parse(value)?.time + ?: SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + }.parse(value)?.time + } catch (e: Exception) { + null + } +} diff --git a/vendor-config/README.md b/vendor-config/README.md new file mode 100644 index 0000000..200afda --- /dev/null +++ b/vendor-config/README.md @@ -0,0 +1,68 @@ +# Vendor/OEM content-cache policy + +A device builder shipping a PawletOS build can ship a standing default +discovery policy on the read-only vendor partition — no MDM enrollment or +post-boot profile push required. `PawletCacheService` reads it automatically; +see +[`PolicyOverride.kt`](../src/os/pawlet/cache/PolicyOverride.kt) for the full +three-tier resolution (`/data/misc/pawletcache/policy.json` runtime override +→ this vendor file → compiled-in default). + +This directory is **not itself installed anywhere** — `PawletCache`'s own +build doesn't ship an opinionated default. It's a template for your device +tree. + +--- + +## File contract + +Same shape as the runtime/MDM override — see `policy.json.example`: + +```json +{ + "mode": "both", + "pinnedServer": { "host": "cache.local.lan", "port": 8443, "token": "" } +} +``` + +`mode` — one of `central`, `lan`, `both` (default if the file is malformed +or missing this key), `disabled`. + +`pinnedServer` — optional. If present, `PawletCacheService` skips discovery +entirely and uses this server directly (its token is still +signature-verified against the pinned root key — see +[`Constants.kt`](../src/os/pawlet/cache/Constants.kt)'s +`CACHE_TRUST_ROOT_PUBKEY_BASE64` — a vendor config can point at a server, +not bypass the trust chain). + +--- + +## Installing it from a device tree + +Drop your own `policy.json` somewhere in your device tree, then either of: + +**Soong (`Android.bp`):** +``` +prebuilt_etc { + name: "pawletcache_vendor_policy", + src: "policy.json", // your file, relative to this Android.bp + sub_dir: "pawletcache", // -> /vendor/etc/pawletcache/policy.json + vendor: true, +} +``` +Then add `pawletcache_vendor_policy` to your device's `PRODUCT_PACKAGES`. + +**Make (`device.mk`), if your tree still uses it:** +``` +PRODUCT_COPY_FILES += \ + device/yourvendor/yourdevice/pawletcache_policy.json:$(TARGET_COPY_OUT_VENDOR)/etc/pawletcache/policy.json +``` + +--- + +## sepolicy + +`android_packages_apps_PawletCache/sepolicy/file_contexts` already labels +`/vendor/etc/pawletcache(/.*)?` and `pawlet_cache.te` already grants the app +read access — nothing extra needed in your device tree's own policy unless +you've customized `pawlet_cache`'s domain. diff --git a/vendor-config/policy.json.example b/vendor-config/policy.json.example new file mode 100644 index 0000000..98f97e4 --- /dev/null +++ b/vendor-config/policy.json.example @@ -0,0 +1,3 @@ +{ + "mode": "both" +}