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).
This commit is contained in:
2026-07-25 00:06:51 -07:00
commit ae9e830f7f
17 changed files with 760 additions and 0 deletions
+37
View File
@@ -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,
},
}
+38
View File
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
SPDX-FileCopyrightText: oxmc / PawletOS
SPDX-License-Identifier: Apache-2.0
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="os.pawlet.cache"
android:versionCode="1">
<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_MULTICAST_STATE" />
<!-- Signature-level permission so only platform-signed callers (BgUpd,
and later the OTA/media resolvers) can bind IPawletCacheService. -->
<permission
android:name="pawletos.cache.permission.BIND_RESOLVER"
android:protectionLevel="signature" />
<application
android:allowBackup="false"
android:label="@string/app_name"
android:persistent="false"
android:supportsRtl="true">
<service
android:name=".PawletCacheService"
android:exported="true"
android:permission="pawletos.cache.permission.BIND_RESOLVER">
<intent-filter>
<action android:name="pawletos.cache.action.BIND" />
</intent-filter>
</service>
</application>
</manifest>
+24
View File
@@ -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();
}
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
SPDX-FileCopyrightText: oxmc / PawletOS
SPDX-License-Identifier: Apache-2.0
-->
<resources>
<string name="app_name">PawletOS Content Cache</string>
</resources>
+4
View File
@@ -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
+41
View File
@@ -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 };
+1
View File
@@ -0,0 +1 @@
user=_app seinfo=platform name=os.pawlet.cache domain=pawlet_cache type=pawlet_cache_data_file levelFrom=all
+40
View File
@@ -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
}
}
+50
View File
@@ -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"
}
}
+74
View File
@@ -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<DiscoveredCache?>(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
}
+81
View File
@@ -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": "<base64 CacheToken>" }
* }
* ```
* `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)
}
}
}
+54
View File
@@ -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()
}
}
}
+15
View File
@@ -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
)
+93
View File
@@ -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<DiscoveredCache> {
val nsdManager = context.getSystemService(Context.NSD_SERVICE) as? NsdManager ?: return emptyList()
val found = CopyOnWriteArrayList<DiscoveredCache>()
val pendingResolves = CopyOnWriteArrayList<Thread>()
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")
}
}
+129
View File
@@ -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
}
}
+68
View File
@@ -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": "<base64 CacheToken>" }
}
```
`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.
+3
View File
@@ -0,0 +1,3 @@
{
"mode": "both"
}