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:
+37
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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 };
|
||||
@@ -0,0 +1 @@
|
||||
user=_app seinfo=platform name=os.pawlet.cache domain=pawlet_cache type=pawlet_cache_data_file levelFrom=all
|
||||
+40
@@ -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
|
||||
}
|
||||
}
|
||||
Vendored
+50
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"mode": "both"
|
||||
}
|
||||
Reference in New Issue
Block a user