mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
feat(napplet): Android sandbox host — isolated process, broker IPC, consent
Implements the Android side of the napplet/nsite trust boundary on top of the commons core. The applet runs in a separate OS process holding no keys; every dangerous operation is brokered to the main process and gated by user consent. :napplet process (no secrets): - NappletHostActivity: hardened WebView (no file/content access, no DOM storage, mixed-content blocked, SafeBrowsing on), applet served into an opaque-origin sandboxed iframe, manifest blobs served already-verified via shouldInterceptRequest with a default-deny CSP (connect-src 'none' = no direct network), and a window.napplet.* shim bridged over an origin-restricted WebMessageListener. Main process (holds the signer): - NappletBrokerService: bound Messenger service running the commons NappletBroker against the live account; exported=false + UID check. Builds the broker per request so account switches are honored; relay publish via the account's computed broadcast relays. - NappletConsentActivity + NappletConsentCoordinator: capability-consent dialog with a suspend bridge; fails closed on dismissal. - DataStoreNappletPermissionStore: persistent grant store. Shared edge: NappletProtocolJson (JSON codec), NappletIpc (Messenger contract), NappletLauncher (packs a verified manifest into the host Intent), shell.html. Adds androidx.webkit (Apache-2.0) for the origin-restricted message bridge — a plain @JavascriptInterface leaks into every frame and would break the boundary. Manifest declares the :napplet activity, the consent activity, and the broker service. :amethyst:compileFdroidDebugKotlin passes; on-device verification and a UI entry point are the remaining steps (see the plan doc). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
This commit is contained in:
@@ -337,6 +337,9 @@ dependencies {
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
|
||||
// Hardened WebView host for sandboxed napplet/nsite rendering (origin-restricted message bridge).
|
||||
implementation(libs.androidx.webkit)
|
||||
|
||||
implementation(libs.androidx.ui)
|
||||
implementation(libs.androidx.ui.graphics)
|
||||
implementation(libs.androidx.ui.tooling.preview)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Napplet / nsite sandbox host — design
|
||||
|
||||
**Date:** 2026-06-19
|
||||
**Status:** Draft — core (commons) implemented + tested; Android host specified, not yet built
|
||||
**Status:** Core (commons) implemented + tested; Android host (`:napplet` process, WebView, broker IPC, consent, DataStore) implemented and compiling — needs on-device verification
|
||||
**Companion:** `quartz/plans/2026-06-19-napplet-nip5a-resolver.md` (the bottom half — manifest parsing + verified Blossom resolution — already landed in `quartz`).
|
||||
|
||||
## Goal
|
||||
@@ -190,9 +190,36 @@ Deferred to v2; v1 nails the single-applet boundary first.
|
||||
|
||||
## Phasing (within the "full napplet" milestone)
|
||||
|
||||
1. **Core (commons, tested)** — protocol + capability model + ledger + broker. ✅ verifiable now.
|
||||
2. **Android host** — `:napplet` process + WebView + asset loader rendering a
|
||||
static, no-capability applet (proves sandbox + verified blob serving).
|
||||
3. **Broker IPC + `identity`** — getPublicKey/signEvent + consent UI.
|
||||
4. **`relay` + `value` + `storage`** capabilities.
|
||||
5. **Inter-applet** (v2).
|
||||
1. **Core (commons, tested)** — protocol + capability model + ledger + broker. ✅ done.
|
||||
2. **Android host** — `:napplet` process + WebView + verified-blob serving via
|
||||
`shouldInterceptRequest` + CSP. ✅ implemented (`NappletHostActivity`).
|
||||
3. **Broker IPC + `identity` + `relay`** — Messenger broker
|
||||
(`NappletBrokerService`), `window.napplet.*` shim, consent UI
|
||||
(`NappletConsentActivity`), DataStore ledger. ✅ implemented.
|
||||
4. **`value` + `storage` + `net`** capabilities — protocol-defined; broker
|
||||
currently answers `Unsupported`. ⏳ next.
|
||||
5. **Inter-applet** (v2). ⏳
|
||||
|
||||
### Implemented Android components (amethyst `…/napplet/`)
|
||||
|
||||
| File | Process | Role |
|
||||
|---|---|---|
|
||||
| `NappletHostActivity` | `:napplet` | WebView host: hardened settings, opaque-origin iframe, verified-blob `shouldInterceptRequest`, CSP, `window.napplet` shim, Messenger client |
|
||||
| `NappletBrokerService` | main | Bound Messenger service; runs the commons `NappletBroker` against the live account; `exported=false` + UID check |
|
||||
| `NappletConsentActivity` / `NappletConsentCoordinator` | main | Capability-consent dialog + suspend bridge to the broker |
|
||||
| `DataStoreNappletPermissionStore` | main | Persistent grant store (`NappletPermissionStore` actual) |
|
||||
| `NappletProtocolJson` / `NappletIpc` | both | JSON codec + Messenger wire contract |
|
||||
| `NappletLauncher` | caller | Packs a verified manifest into the host Intent |
|
||||
| `assets/napplet/shell.html` | `:napplet` | Trusted shell page that sandboxes the applet iframe and relays messages |
|
||||
|
||||
### Remaining before user-facing ship
|
||||
|
||||
- **On-device verification (needs emulator/device):** opaque-origin iframe really
|
||||
excludes the bridge; CSP `connect-src 'none'` blocks fetch/XHR/WebSocket; a real
|
||||
napplet renders and round-trips a `getPublicKey` / `signEvent` through consent.
|
||||
- **UI entry point:** wire `NappletLauncher.launch(...)` into navigation (a napplet
|
||||
list/detail screen). Today the host is reachable only programmatically.
|
||||
- **Privacy:** the host's `OkHttpClient` for blob fetches ignores the user's
|
||||
Tor/proxy settings — route it through the app's configured client.
|
||||
- **Consent UX:** reuse `commons/.../ui/signing` styling; show the manifest title
|
||||
and a per-capability rationale; batch-grant on first run.
|
||||
|
||||
@@ -402,6 +402,29 @@
|
||||
android:name=".service.call.CallNotificationReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<!-- Sandboxed napplet/nsite host. Runs in an isolated process that holds no keys. -->
|
||||
<activity
|
||||
android:name=".napplet.NappletHostActivity"
|
||||
android:process=":napplet"
|
||||
android:exported="false"
|
||||
android:autoRemoveFromRecents="true"
|
||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden|keyboard|uiMode|navigation|fontScale|density"
|
||||
android:launchMode="singleTask"
|
||||
android:theme="@style/Theme.Amethyst" />
|
||||
|
||||
<!-- Capability-consent dialog. Runs in the main process (the only side trusted to grant). -->
|
||||
<activity
|
||||
android:name=".napplet.NappletConsentActivity"
|
||||
android:exported="false"
|
||||
android:excludeFromRecents="true"
|
||||
android:launchMode="singleTop"
|
||||
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
|
||||
|
||||
<!-- Main-process broker: holds the signer and brokers capabilities for the sandbox. -->
|
||||
<service
|
||||
android:name=".napplet.NappletBrokerService"
|
||||
android:exported="false" />
|
||||
|
||||
</application>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<!doctype html>
|
||||
<!--
|
||||
Trusted napplet shell page (served from app assets at https://napplet.local/__shell__).
|
||||
It hosts the untrusted applet in an opaque-origin sandboxed iframe and relays capability
|
||||
messages between the applet (window.postMessage) and the native bridge (__nappletBridge),
|
||||
which is origin-restricted to this page only. The applet iframe can never reach the bridge.
|
||||
-->
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
|
||||
<title>Napplet</title>
|
||||
<style>
|
||||
html, body, iframe { margin: 0; padding: 0; border: 0; width: 100%; height: 100%; background: #fff; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<iframe id="app" sandbox="allow-scripts" referrerpolicy="no-referrer"></iframe>
|
||||
<script>
|
||||
(function () {
|
||||
var iframe = document.getElementById('app');
|
||||
var bridge = window.__nappletBridge;
|
||||
|
||||
// applet -> native: only forward messages that came from our applet iframe.
|
||||
window.addEventListener('message', function (e) {
|
||||
if (e.source !== iframe.contentWindow) return;
|
||||
if (typeof e.data !== 'string') return;
|
||||
if (bridge) bridge.postMessage(e.data);
|
||||
});
|
||||
|
||||
// native -> applet: hand the broker's reply down into the iframe.
|
||||
if (bridge) {
|
||||
bridge.onmessage = function (e) {
|
||||
iframe.contentWindow.postMessage(e.data, '*');
|
||||
};
|
||||
}
|
||||
|
||||
iframe.src = 'https://napplet.local/app/';
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
BIN
Binary file not shown.
@@ -0,0 +1,179 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.napplet
|
||||
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.os.Binder
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import android.os.Message
|
||||
import android.os.Messenger
|
||||
import android.os.Process
|
||||
import android.os.RemoteException
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletBroker
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletConsentPrompt
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletRelayGateway
|
||||
import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger
|
||||
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest
|
||||
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* The trust boundary's main-process endpoint. The untrusted `:napplet` process binds this
|
||||
* service and sends [NappletRequest]s as JSON over a [Messenger]; this is the only side that
|
||||
* holds the signer, the relays, and the permission ledger. It runs each request through the
|
||||
* shared [NappletBroker], which gates everything on consent and never returns key material.
|
||||
*
|
||||
* `exported=false` in the manifest restricts binding to this app's own UID, so no other
|
||||
* installed app can reach the broker. A renderer escape that reaches the `:napplet` process
|
||||
* could still bind here — but it gains only what the user explicitly consents to, per
|
||||
* capability, and never the private key (the broker's contract).
|
||||
*/
|
||||
class NappletBrokerService : Service() {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
// One ledger for the whole service lifetime: persistent grants on disk, session grants in RAM.
|
||||
private val ledger by lazy { NappletPermissionLedger(DataStoreNappletPermissionStore(applicationContext)) }
|
||||
|
||||
private val incoming by lazy { Messenger(Handler(Looper.getMainLooper(), ::handleMessage)) }
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? {
|
||||
// Defense in depth on top of exported=false: only our own UID may bind.
|
||||
if (Binder.getCallingUid() != Process.myUid()) return null
|
||||
return incoming.binder
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
scope.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun handleMessage(msg: Message): Boolean {
|
||||
if (msg.what != NappletIpc.MSG_REQUEST) return false
|
||||
|
||||
val data = msg.data ?: return true
|
||||
val replyTo = msg.replyTo ?: return true
|
||||
val requestId = data.getString(NappletIpc.KEY_REQUEST_ID) ?: return true
|
||||
val payload = data.getString(NappletIpc.KEY_PAYLOAD) ?: return true
|
||||
|
||||
val identity =
|
||||
NappletIdentity(
|
||||
authorPubKey = data.getString(NappletIpc.KEY_AUTHOR).orEmpty(),
|
||||
identifier = data.getString(NappletIpc.KEY_IDENTIFIER).orEmpty(),
|
||||
aggregateHash = data.getString(NappletIpc.KEY_AGGREGATE_HASH),
|
||||
)
|
||||
|
||||
scope.launch {
|
||||
val response = process(identity, payload)
|
||||
reply(replyTo, requestId, NappletProtocolJson.encodeResponse(response))
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private suspend fun process(
|
||||
identity: NappletIdentity,
|
||||
payload: String,
|
||||
): NappletResponse {
|
||||
val request =
|
||||
runCatching { NappletProtocolJson.decodeRequest(payload) }.getOrNull()
|
||||
?: return NappletResponse.Failed("Malformed or unsupported request.")
|
||||
|
||||
val broker = buildBroker() ?: return NappletResponse.Failed("No account is signed in.")
|
||||
return broker.handle(identity, request)
|
||||
}
|
||||
|
||||
/** Builds a broker bound to the *currently* signed-in account, so account switches are honored. */
|
||||
private fun buildBroker(): NappletBroker? {
|
||||
val account = Amethyst.instance.sessionManager.loggedInAccount() ?: return null
|
||||
|
||||
val relay =
|
||||
NappletRelayGateway { event: Event ->
|
||||
val relays = account.computeRelayListToBroadcast(event)
|
||||
account.client.publish(event, relays)
|
||||
relays.map { it.url }
|
||||
}
|
||||
|
||||
val consent =
|
||||
NappletConsentPrompt { id, capability, request ->
|
||||
NappletConsentCoordinator.requestConsent(
|
||||
context = applicationContext,
|
||||
info = consentInfo(id, capability, request),
|
||||
)
|
||||
}
|
||||
|
||||
return NappletBroker(account.signer, ledger, consent, relay)
|
||||
}
|
||||
|
||||
private fun consentInfo(
|
||||
identity: NappletIdentity,
|
||||
capability: NappletCapability,
|
||||
request: NappletRequest,
|
||||
): NappletConsentInfo {
|
||||
val title = identity.identifier.ifBlank { "Napplet ${identity.authorPubKey.take(8)}…" }
|
||||
return NappletConsentInfo(
|
||||
appletTitle = title,
|
||||
coordinate = identity.coordinate,
|
||||
capabilityLabel = capability.name.lowercase(),
|
||||
operationSummary = summaryFor(request),
|
||||
)
|
||||
}
|
||||
|
||||
private fun summaryFor(request: NappletRequest): String =
|
||||
when (request) {
|
||||
is NappletRequest.GetPublicKey -> "This napplet wants to read your public key."
|
||||
is NappletRequest.SignEvent -> "This napplet wants to sign an event (kind ${request.kind}) as you."
|
||||
is NappletRequest.Nip04Encrypt, is NappletRequest.Nip44Encrypt -> "This napplet wants to encrypt a message as you."
|
||||
is NappletRequest.Nip04Decrypt, is NappletRequest.Nip44Decrypt -> "This napplet wants to decrypt a message addressed to you."
|
||||
is NappletRequest.Publish -> "This napplet wants to publish an event to your relays."
|
||||
}
|
||||
|
||||
private fun reply(
|
||||
replyTo: Messenger,
|
||||
requestId: String,
|
||||
payload: String,
|
||||
) {
|
||||
val response =
|
||||
Message.obtain(null, NappletIpc.MSG_RESPONSE).apply {
|
||||
data =
|
||||
Bundle().apply {
|
||||
putString(NappletIpc.KEY_REQUEST_ID, requestId)
|
||||
putString(NappletIpc.KEY_PAYLOAD, payload)
|
||||
}
|
||||
}
|
||||
try {
|
||||
replyTo.send(response)
|
||||
} catch (e: RemoteException) {
|
||||
Log.w("NappletBrokerService", "Applet host went away before reply could be delivered", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.napplet
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.napplet.permissions.GrantState
|
||||
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
|
||||
|
||||
/**
|
||||
* The capability-consent dialog, shown in the **main** process (the only place trusted to
|
||||
* make a grant). It renders the [NappletConsentInfo] for a pending request and reports the
|
||||
* user's [GrantState] back through [NappletConsentCoordinator]. The untrusted applet never
|
||||
* sees or drives this UI.
|
||||
*/
|
||||
class NappletConsentActivity : ComponentActivity() {
|
||||
private var token: String? = null
|
||||
private var decided = false
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val token = intent.getStringExtra(NappletConsentCoordinator.EXTRA_TOKEN)
|
||||
this.token = token
|
||||
val info = token?.let { NappletConsentCoordinator.infoFor(it) }
|
||||
|
||||
if (token == null || info == null) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
setContent {
|
||||
AmethystTheme {
|
||||
NappletConsentDialog(
|
||||
info = info,
|
||||
onDecision = { grant ->
|
||||
decided = true
|
||||
NappletConsentCoordinator.complete(token, grant)
|
||||
finish()
|
||||
},
|
||||
onDismiss = {
|
||||
decided = true
|
||||
NappletConsentCoordinator.cancel(token)
|
||||
finish()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun finish() {
|
||||
// If the system tears us down before the user chose, fail closed.
|
||||
if (!decided) token?.let { NappletConsentCoordinator.cancel(it) }
|
||||
super.finish()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NappletConsentDialog(
|
||||
info: NappletConsentInfo,
|
||||
onDecision: (GrantState) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(info.appletTitle) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(info.operationSummary)
|
||||
Text(
|
||||
"Capability: ${info.capabilityLabel}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
info.coordinate,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
TextButton(
|
||||
onClick = { onDecision(GrantState.ALLOW_ALWAYS) },
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
|
||||
) { Text("Always allow") }
|
||||
TextButton(
|
||||
onClick = { onDecision(GrantState.ALLOW_ONCE) },
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
|
||||
) { Text("Allow once") }
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
TextButton(
|
||||
onClick = { onDecision(GrantState.DENY) },
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
|
||||
) { Text("Never allow") }
|
||||
TextButton(
|
||||
onClick = { onDecision(GrantState.ASK) },
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
|
||||
) { Text("Not now") }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.napplet
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import com.vitorpamplona.amethyst.commons.napplet.permissions.GrantState
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/** Everything the consent dialog needs to describe what an applet is asking permission to do. */
|
||||
data class NappletConsentInfo(
|
||||
val appletTitle: String,
|
||||
val coordinate: String,
|
||||
val capabilityLabel: String,
|
||||
val operationSummary: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Bridges the main-process broker to the consent UI. The broker suspends in
|
||||
* [requestConsent]; this launches [NappletConsentActivity], holds the pending decision keyed
|
||||
* by a one-time token, and resolves it when the activity reports the user's choice.
|
||||
*
|
||||
* A dismissed dialog resolves to [GrantState.ASK] — i.e. "not authorized, ask again next
|
||||
* time" — so closing the prompt never silently grants nor permanently blocks.
|
||||
*/
|
||||
object NappletConsentCoordinator {
|
||||
private class Pending(
|
||||
val info: NappletConsentInfo,
|
||||
val deferred: CompletableDeferred<GrantState>,
|
||||
)
|
||||
|
||||
private val pending = ConcurrentHashMap<String, Pending>()
|
||||
|
||||
suspend fun requestConsent(
|
||||
context: Context,
|
||||
info: NappletConsentInfo,
|
||||
): GrantState {
|
||||
val token = UUID.randomUUID().toString()
|
||||
val deferred = CompletableDeferred<GrantState>()
|
||||
pending[token] = Pending(info, deferred)
|
||||
|
||||
val intent =
|
||||
Intent(context, NappletConsentActivity::class.java)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
.putExtra(EXTRA_TOKEN, token)
|
||||
context.startActivity(intent)
|
||||
|
||||
return try {
|
||||
deferred.await()
|
||||
} finally {
|
||||
pending.remove(token)
|
||||
}
|
||||
}
|
||||
|
||||
/** Called by [NappletConsentActivity] to render the prompt. */
|
||||
fun infoFor(token: String): NappletConsentInfo? = pending[token]?.info
|
||||
|
||||
/** Called by [NappletConsentActivity] with the user's decision. */
|
||||
fun complete(
|
||||
token: String,
|
||||
grant: GrantState,
|
||||
) {
|
||||
pending[token]?.deferred?.complete(grant)
|
||||
}
|
||||
|
||||
/** Called when the dialog is dismissed without a choice — fails closed (no grant). */
|
||||
fun cancel(token: String) {
|
||||
pending[token]?.deferred?.complete(GrantState.ASK)
|
||||
}
|
||||
|
||||
const val EXTRA_TOKEN = "napplet_consent_token"
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.napplet
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import android.os.Message
|
||||
import android.os.Messenger
|
||||
import android.util.Log
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebResourceResponse
|
||||
import android.webkit.WebSettings
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.webkit.JavaScriptReplyProxy
|
||||
import androidx.webkit.WebMessageCompat
|
||||
import androidx.webkit.WebViewCompat
|
||||
import androidx.webkit.WebViewFeature
|
||||
import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.BlobFetcher
|
||||
import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.StaticSiteResolution
|
||||
import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.StaticSiteResolver
|
||||
import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.json.JSONObject
|
||||
import java.io.ByteArrayInputStream
|
||||
|
||||
/**
|
||||
* Hosts a napplet/nsite WebView in the isolated `:napplet` process — a process that holds **no**
|
||||
* account state, signer, or keys. It:
|
||||
*
|
||||
* 1. serves the trusted shell page and the manifest's **already-verified** blobs through
|
||||
* [WebViewClient.shouldInterceptRequest] (no `file://`/`content://`, default-deny CSP with
|
||||
* `connect-src 'none'` so the applet has no direct network), and
|
||||
* 2. relays the applet's `window.napplet.*` capability calls — applet → shell (postMessage) →
|
||||
* native (origin-restricted [WebViewCompat.addWebMessageListener]) → main-process broker
|
||||
* (Messenger) → back — without ever interpreting them itself.
|
||||
*
|
||||
* Even a full WebView/renderer escape into this process yields no secret: the keys live only in
|
||||
* the main process, and every brokered operation is still gated by user consent there.
|
||||
*/
|
||||
class NappletHostActivity : ComponentActivity() {
|
||||
private lateinit var webView: WebView
|
||||
|
||||
private val paths = mutableListOf<PathTag>()
|
||||
private val servers = mutableListOf<String>()
|
||||
private var author: String = ""
|
||||
private var identifier: String = ""
|
||||
private var aggregateHash: String? = null
|
||||
|
||||
private val http = OkHttpClient()
|
||||
private val fetch: BlobFetcher = { url ->
|
||||
try {
|
||||
http
|
||||
.newCall(
|
||||
Request
|
||||
.Builder()
|
||||
.url(url)
|
||||
.get()
|
||||
.build(),
|
||||
).execute()
|
||||
.use { r ->
|
||||
if (r.isSuccessful) r.body.bytes() else null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Blob fetch failed for $url", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// Messenger to the main-process broker, bound lazily; requests queue until connected.
|
||||
private var brokerMessenger: Messenger? = null
|
||||
private val replyMessenger = Messenger(Handler(Looper.getMainLooper(), ::onBrokerReply))
|
||||
private val pendingRequests = mutableListOf<Message>()
|
||||
private var bridgeReplyProxy: JavaScriptReplyProxy? = null
|
||||
|
||||
private val brokerConnection =
|
||||
object : ServiceConnection {
|
||||
override fun onServiceConnected(
|
||||
name: ComponentName?,
|
||||
service: IBinder?,
|
||||
) {
|
||||
brokerMessenger = Messenger(service)
|
||||
pendingRequests.forEach { sendToBroker(it) }
|
||||
pendingRequests.clear()
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName?) {
|
||||
brokerMessenger = null
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
if (!readManifestExtras()) {
|
||||
Toast.makeText(this, "Invalid napplet.", Toast.LENGTH_SHORT).show()
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
if (!WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) {
|
||||
Toast.makeText(this, "This device's WebView is too old to run napplets safely.", Toast.LENGTH_LONG).show()
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
webView = WebView(this)
|
||||
setContentView(webView)
|
||||
hardenWebView(webView)
|
||||
|
||||
// Origin-restricted bridge: only the trusted shell page (main frame) can reach native.
|
||||
WebViewCompat.addWebMessageListener(
|
||||
webView,
|
||||
BRIDGE_NAME,
|
||||
setOf(ORIGIN),
|
||||
::onShellMessage,
|
||||
)
|
||||
|
||||
bindService(Intent(this, NappletBrokerService::class.java), brokerConnection, BIND_AUTO_CREATE)
|
||||
|
||||
webView.loadUrl(SHELL_URL)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
runCatching { unbindService(brokerConnection) }
|
||||
if (this::webView.isInitialized) {
|
||||
webView.destroy()
|
||||
}
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun readManifestExtras(): Boolean {
|
||||
val pathList = intent.getStringArrayListExtra(NappletLauncher.EXTRA_PATHS) ?: return false
|
||||
val hashList = intent.getStringArrayListExtra(NappletLauncher.EXTRA_HASHES) ?: return false
|
||||
if (pathList.size != hashList.size || pathList.isEmpty()) return false
|
||||
|
||||
for (i in pathList.indices) paths.add(PathTag(pathList[i], hashList[i]))
|
||||
servers.addAll(intent.getStringArrayListExtra(NappletLauncher.EXTRA_SERVERS) ?: emptyList())
|
||||
author = intent.getStringExtra(NappletLauncher.EXTRA_AUTHOR).orEmpty()
|
||||
identifier = intent.getStringExtra(NappletLauncher.EXTRA_IDENTIFIER).orEmpty()
|
||||
aggregateHash = intent.getStringExtra(NappletLauncher.EXTRA_AGGREGATE_HASH)
|
||||
title = intent.getStringExtra(NappletLauncher.EXTRA_TITLE).orEmpty()
|
||||
return author.isNotEmpty()
|
||||
}
|
||||
|
||||
private var title: String = ""
|
||||
|
||||
@Suppress("SetJavaScriptEnabled")
|
||||
private fun hardenWebView(webView: WebView) {
|
||||
webView.settings.apply {
|
||||
javaScriptEnabled = true // the applet needs JS; isolation comes from process + CSP + sandbox
|
||||
domStorageEnabled = false
|
||||
databaseEnabled = false
|
||||
allowFileAccess = false
|
||||
allowContentAccess = false
|
||||
@Suppress("DEPRECATION")
|
||||
allowFileAccessFromFileURLs = false
|
||||
@Suppress("DEPRECATION")
|
||||
allowUniversalAccessFromFileURLs = false
|
||||
javaScriptCanOpenWindowsAutomatically = false
|
||||
setSupportMultipleWindows(false)
|
||||
setGeolocationEnabled(false)
|
||||
mediaPlaybackRequiresUserGesture = true
|
||||
cacheMode = WebSettings.LOAD_NO_CACHE
|
||||
mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW
|
||||
if (WebViewFeature.isFeatureSupported(WebViewFeature.SAFE_BROWSING_ENABLE)) {
|
||||
safeBrowsingEnabled = true
|
||||
}
|
||||
}
|
||||
WebView.setWebContentsDebuggingEnabled(false)
|
||||
webView.webViewClient = NappletWebViewClient()
|
||||
}
|
||||
|
||||
/** Serves only the trusted shell and the manifest's verified blobs; everything else 404s. */
|
||||
private inner class NappletWebViewClient : WebViewClient() {
|
||||
override fun shouldInterceptRequest(
|
||||
view: WebView,
|
||||
request: WebResourceRequest,
|
||||
): WebResourceResponse? {
|
||||
val url = request.url.toString()
|
||||
if (!request.method.equals("GET", ignoreCase = true)) return null
|
||||
if (!url.startsWith(ORIGIN)) return notFound()
|
||||
|
||||
if (url == SHELL_URL) return serveShell()
|
||||
if (url == APP_BASE || url.startsWith(APP_BASE)) return serveAppResource(url)
|
||||
return notFound()
|
||||
}
|
||||
|
||||
override fun shouldOverrideUrlLoading(
|
||||
view: WebView,
|
||||
request: WebResourceRequest,
|
||||
): Boolean {
|
||||
// Block any navigation away from our internal origin (e.g. applet link clicks).
|
||||
return request.url.host != HOST
|
||||
}
|
||||
}
|
||||
|
||||
private fun serveShell(): WebResourceResponse {
|
||||
val bytes = assets.open("napplet/shell.html").use { it.readBytes() }
|
||||
return WebResourceResponse(
|
||||
"text/html",
|
||||
"utf-8",
|
||||
200,
|
||||
"OK",
|
||||
mapOf("Content-Security-Policy" to SHELL_CSP),
|
||||
ByteArrayInputStream(bytes),
|
||||
)
|
||||
}
|
||||
|
||||
private fun serveAppResource(url: String): WebResourceResponse {
|
||||
val requestPath =
|
||||
url
|
||||
.removePrefix(APP_BASE)
|
||||
.substringBefore('?')
|
||||
.substringBefore('#')
|
||||
.let { if (it.isEmpty()) "/" else "/$it" }
|
||||
|
||||
val resolution = runBlocking { StaticSiteResolver.resolve(requestPath, paths, servers, fetch) }
|
||||
if (resolution !is StaticSiteResolution.Resolved) return notFound()
|
||||
|
||||
val (mime, charset) = splitContentType(resolution.contentType)
|
||||
val isHtml = mime.equals("text/html", ignoreCase = true)
|
||||
val bytes = if (isHtml) injectShim(resolution.bytes) else resolution.bytes
|
||||
|
||||
return WebResourceResponse(
|
||||
mime,
|
||||
charset,
|
||||
200,
|
||||
"OK",
|
||||
mapOf("Content-Security-Policy" to APP_CSP),
|
||||
ByteArrayInputStream(bytes),
|
||||
)
|
||||
}
|
||||
|
||||
/** Inserts the `window.napplet` client shim into the applet's HTML document. */
|
||||
private fun injectShim(html: ByteArray): ByteArray {
|
||||
val text = html.decodeToString()
|
||||
val script = "<script>$SHIM_JS</script>"
|
||||
val headIdx = text.indexOf("<head", ignoreCase = true)
|
||||
val injected =
|
||||
when {
|
||||
headIdx >= 0 -> {
|
||||
val close = text.indexOf('>', headIdx)
|
||||
if (close >= 0) text.substring(0, close + 1) + script + text.substring(close + 1) else script + text
|
||||
}
|
||||
else -> script + text
|
||||
}
|
||||
return injected.encodeToByteArray()
|
||||
}
|
||||
|
||||
// ---- bridge: shell <-> native ----
|
||||
|
||||
private fun onShellMessage(
|
||||
view: WebView,
|
||||
message: WebMessageCompat,
|
||||
sourceOrigin: Uri,
|
||||
isMainFrame: Boolean,
|
||||
replyProxy: JavaScriptReplyProxy,
|
||||
) {
|
||||
if (!isMainFrame) return // only the trusted shell, never a sub-frame
|
||||
bridgeReplyProxy = replyProxy
|
||||
|
||||
val raw = message.data ?: return
|
||||
val envelope = runCatching { JSONObject(raw) }.getOrNull() ?: return
|
||||
val id = envelope.optString("id").ifEmpty { return }
|
||||
val payload = envelope.optString("payload").ifEmpty { return }
|
||||
|
||||
val msg =
|
||||
Message.obtain(null, NappletIpc.MSG_REQUEST).apply {
|
||||
replyTo = replyMessenger
|
||||
data =
|
||||
Bundle().apply {
|
||||
putString(NappletIpc.KEY_REQUEST_ID, id)
|
||||
putString(NappletIpc.KEY_PAYLOAD, payload)
|
||||
putString(NappletIpc.KEY_AUTHOR, author)
|
||||
putString(NappletIpc.KEY_IDENTIFIER, identifier)
|
||||
putString(NappletIpc.KEY_AGGREGATE_HASH, aggregateHash)
|
||||
}
|
||||
}
|
||||
|
||||
val messenger = brokerMessenger
|
||||
if (messenger == null) {
|
||||
pendingRequests.add(msg)
|
||||
} else {
|
||||
sendToBroker(msg)
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendToBroker(msg: Message) {
|
||||
try {
|
||||
brokerMessenger?.send(msg)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to deliver request to broker", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onBrokerReply(msg: Message): Boolean {
|
||||
if (msg.what != NappletIpc.MSG_RESPONSE) return false
|
||||
val data = msg.data ?: return true
|
||||
val id = data.getString(NappletIpc.KEY_REQUEST_ID) ?: return true
|
||||
val payload = data.getString(NappletIpc.KEY_PAYLOAD) ?: return true
|
||||
|
||||
val envelope =
|
||||
JSONObject().apply {
|
||||
put("id", id)
|
||||
put("response", payload)
|
||||
}
|
||||
bridgeReplyProxy?.postMessage(envelope.toString())
|
||||
return true
|
||||
}
|
||||
|
||||
private fun notFound(): WebResourceResponse = WebResourceResponse("text/plain", "utf-8", 404, "Not Found", emptyMap(), ByteArrayInputStream(ByteArray(0)))
|
||||
|
||||
private fun splitContentType(contentType: String): Pair<String, String> {
|
||||
val mime = contentType.substringBefore(';').trim().ifEmpty { "application/octet-stream" }
|
||||
val charset =
|
||||
contentType.substringAfter("charset=", "").trim().ifEmpty { null }
|
||||
?: if (mime.startsWith("text/") || mime.endsWith("javascript") || mime.endsWith("json")) "utf-8" else ""
|
||||
return mime to charset
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "NappletHostActivity"
|
||||
private const val HOST = "napplet.local"
|
||||
private const val ORIGIN = "https://napplet.local"
|
||||
private const val SHELL_URL = "$ORIGIN/__shell__"
|
||||
private const val APP_BASE = "$ORIGIN/app/"
|
||||
private const val BRIDGE_NAME = "__nappletBridge"
|
||||
|
||||
private const val SHELL_CSP =
|
||||
"default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; " +
|
||||
"frame-src https://napplet.local; base-uri 'none'; form-action 'none'"
|
||||
|
||||
// 'self' does not match an opaque (sandboxed) origin, so the host is listed explicitly.
|
||||
// connect-src 'none' is the key lever: the applet gets no direct network.
|
||||
private const val APP_CSP =
|
||||
"default-src 'self' https://napplet.local; " +
|
||||
"script-src 'self' https://napplet.local 'unsafe-inline'; " +
|
||||
"style-src 'self' https://napplet.local 'unsafe-inline'; " +
|
||||
"img-src 'self' https://napplet.local data: blob:; " +
|
||||
"font-src 'self' https://napplet.local data:; " +
|
||||
"media-src 'self' https://napplet.local blob: data:; " +
|
||||
"connect-src 'none'; frame-src 'none'; object-src 'none'; base-uri 'self'; form-action 'none'"
|
||||
|
||||
private const val SHIM_JS = """
|
||||
(function(){
|
||||
if (window.__nappletShimInstalled) return; window.__nappletShimInstalled = true;
|
||||
var seq = 0, pending = {};
|
||||
function call(payload){
|
||||
return new Promise(function(resolve){
|
||||
var id = 'r' + (seq++);
|
||||
pending[id] = resolve;
|
||||
parent.postMessage(JSON.stringify({ id: id, payload: JSON.stringify(payload) }), '*');
|
||||
});
|
||||
}
|
||||
window.addEventListener('message', function(e){
|
||||
if (e.source !== parent) return;
|
||||
if (typeof e.data !== 'string') return;
|
||||
var msg; try { msg = JSON.parse(e.data); } catch (_) { return; }
|
||||
if (!msg || !msg.id) return;
|
||||
var cb = pending[msg.id]; if (!cb) return; delete pending[msg.id];
|
||||
var resp; try { resp = JSON.parse(msg.response); } catch (_) { resp = { type: 'failed', reason: 'bad response' }; }
|
||||
cb(resp);
|
||||
});
|
||||
function fail(r){ var e = new Error((r && r.reason) || (r && r.type) || 'napplet error'); e.napplet = r; throw e; }
|
||||
function pubkey(r){ if (r.type === 'publicKey') return r.pubkey; fail(r); }
|
||||
function evt(r){ if (r.type === 'signedEvent') return r.event; fail(r); }
|
||||
function text(r){ if (r.type === 'text') return r.value; fail(r); }
|
||||
function published(r){ if (r.type === 'published') return r.relays; fail(r); }
|
||||
window.napplet = Object.freeze({
|
||||
getPublicKey: function(){ return call({ op: 'getPublicKey' }).then(pubkey); },
|
||||
signEvent: function(t){ return call({ op: 'signEvent', kind: t.kind, tags: t.tags || [], content: t.content || '' }).then(evt); },
|
||||
nip04Encrypt: function(peer, plaintext){ return call({ op: 'nip04Encrypt', peer: peer, plaintext: plaintext }).then(text); },
|
||||
nip04Decrypt: function(peer, ciphertext){ return call({ op: 'nip04Decrypt', peer: peer, ciphertext: ciphertext }).then(text); },
|
||||
nip44Encrypt: function(peer, plaintext){ return call({ op: 'nip44Encrypt', peer: peer, plaintext: plaintext }).then(text); },
|
||||
nip44Decrypt: function(peer, ciphertext){ return call({ op: 'nip44Decrypt', peer: peer, ciphertext: ciphertext }).then(text); },
|
||||
publish: function(ev){ return call({ op: 'publish', event: ev }).then(published); }
|
||||
});
|
||||
})();
|
||||
"""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.napplet
|
||||
|
||||
/**
|
||||
* The Messenger wire contract between the untrusted `:napplet` process (the WebView host) and
|
||||
* the main-process [NappletBrokerService]. Kept tiny and string-only on purpose: nothing the
|
||||
* applet controls is ever interpreted as a Binder object, and the only payloads are JSON
|
||||
* strings ([NappletProtocolJson]) plus the applet's identity coordinate.
|
||||
*/
|
||||
object NappletIpc {
|
||||
/** Host → broker: a capability request. Carries [KEY_REQUEST_ID], the identity keys, and [KEY_PAYLOAD]. */
|
||||
const val MSG_REQUEST = 1
|
||||
|
||||
/** Broker → host: the matching reply. Carries [KEY_REQUEST_ID] and [KEY_PAYLOAD]. */
|
||||
const val MSG_RESPONSE = 2
|
||||
|
||||
const val KEY_REQUEST_ID = "requestId"
|
||||
const val KEY_PAYLOAD = "payload"
|
||||
|
||||
// Applet identity (the ledger coordinate) travels with every request — the host cannot be
|
||||
// trusted to have applied any policy, so the broker re-derives everything from these.
|
||||
const val KEY_AUTHOR = "author"
|
||||
const val KEY_IDENTIFIER = "identifier"
|
||||
const val KEY_AGGREGATE_HASH = "aggregateHash"
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.napplet
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip5dNapplets.NappletManifest
|
||||
|
||||
/**
|
||||
* Opens a napplet/nsite in the sandboxed [NappletHostActivity] (the `:napplet` process). Only
|
||||
* the verified manifest data the host needs to render and broker for the applet is passed —
|
||||
* the declared `path → hash` map, the Blossom servers, the applet's identity coordinate, and a
|
||||
* display title. No account state crosses into the sandbox process.
|
||||
*/
|
||||
object NappletLauncher {
|
||||
const val EXTRA_PATHS = "napplet_paths"
|
||||
const val EXTRA_HASHES = "napplet_hashes"
|
||||
const val EXTRA_SERVERS = "napplet_servers"
|
||||
const val EXTRA_AUTHOR = "napplet_author"
|
||||
const val EXTRA_IDENTIFIER = "napplet_identifier"
|
||||
const val EXTRA_AGGREGATE_HASH = "napplet_aggregate_hash"
|
||||
const val EXTRA_TITLE = "napplet_title"
|
||||
|
||||
fun launch(
|
||||
context: Context,
|
||||
manifest: NappletManifest,
|
||||
authorPubKey: HexKey,
|
||||
identifier: String,
|
||||
) {
|
||||
val pathTags = manifest.paths()
|
||||
val intent =
|
||||
Intent(context, NappletHostActivity::class.java).apply {
|
||||
putExtra(EXTRA_PATHS, ArrayList(pathTags.map { it.path }))
|
||||
putExtra(EXTRA_HASHES, ArrayList(pathTags.map { it.hash }))
|
||||
putExtra(EXTRA_SERVERS, ArrayList(manifest.servers()))
|
||||
putExtra(EXTRA_AUTHOR, authorPubKey)
|
||||
putExtra(EXTRA_IDENTIFIER, identifier)
|
||||
putExtra(EXTRA_AGGREGATE_HASH, manifest.declaredAggregateHash() ?: manifest.computeAggregateHash())
|
||||
putExtra(EXTRA_TITLE, manifest.title() ?: identifier.ifBlank { "Napplet" })
|
||||
if (context !is android.app.Activity) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.napplet
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest
|
||||
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Marshals the KMP-pure [NappletRequest] / [NappletResponse] types to and from the JSON the
|
||||
* applet exchanges over `window.napplet.*`. This is the only place the boundary parses applet
|
||||
* input, so it is deliberately strict: unknown ops decode to `null` (the broker rejects them)
|
||||
* and a malformed body throws rather than guessing.
|
||||
*
|
||||
* The host process only ever *shuttles* these strings; decoding/encoding happens in the
|
||||
* main-process broker so a compromised host cannot fabricate a typed request that skips a field.
|
||||
*/
|
||||
object NappletProtocolJson {
|
||||
/** Parses an applet request. Returns `null` for an unrecognized `op` so the broker can deny it. */
|
||||
fun decodeRequest(json: String): NappletRequest? {
|
||||
val o = JSONObject(json)
|
||||
return when (o.getString("op")) {
|
||||
"getPublicKey" -> NappletRequest.GetPublicKey
|
||||
"signEvent" ->
|
||||
NappletRequest.SignEvent(
|
||||
kind = o.getInt("kind"),
|
||||
tags = decodeTags(o.optJSONArray("tags")),
|
||||
content = o.optString("content", ""),
|
||||
)
|
||||
"nip04Encrypt" -> NappletRequest.Nip04Encrypt(o.getString("peer"), o.getString("plaintext"))
|
||||
"nip04Decrypt" -> NappletRequest.Nip04Decrypt(o.getString("peer"), o.getString("ciphertext"))
|
||||
"nip44Encrypt" -> NappletRequest.Nip44Encrypt(o.getString("peer"), o.getString("plaintext"))
|
||||
"nip44Decrypt" -> NappletRequest.Nip44Decrypt(o.getString("peer"), o.getString("ciphertext"))
|
||||
"publish" -> NappletRequest.Publish(Event.fromJson(o.getJSONObject("event").toString()))
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
fun encodeResponse(response: NappletResponse): String {
|
||||
val o = JSONObject()
|
||||
when (response) {
|
||||
is NappletResponse.PublicKey -> {
|
||||
o.put("type", "publicKey")
|
||||
o.put("pubkey", response.pubkey)
|
||||
}
|
||||
is NappletResponse.SignedEvent -> {
|
||||
o.put("type", "signedEvent")
|
||||
o.put("event", JSONObject(response.event.toJson()))
|
||||
}
|
||||
is NappletResponse.Text -> {
|
||||
o.put("type", "text")
|
||||
o.put("value", response.value)
|
||||
}
|
||||
is NappletResponse.Published -> {
|
||||
o.put("type", "published")
|
||||
o.put("relays", JSONArray(response.relays))
|
||||
}
|
||||
is NappletResponse.Denied -> {
|
||||
o.put("type", "denied")
|
||||
o.put("capability", response.capability.name)
|
||||
o.put("reason", response.reason)
|
||||
}
|
||||
is NappletResponse.Unsupported -> {
|
||||
o.put("type", "unsupported")
|
||||
o.put("operation", response.operation)
|
||||
}
|
||||
is NappletResponse.Failed -> {
|
||||
o.put("type", "failed")
|
||||
o.put("reason", response.reason)
|
||||
}
|
||||
}
|
||||
return o.toString()
|
||||
}
|
||||
|
||||
private fun decodeTags(array: JSONArray?): Array<Array<String>> {
|
||||
if (array == null) return emptyArray()
|
||||
return Array(array.length()) { i ->
|
||||
val inner = array.getJSONArray(i)
|
||||
Array(inner.length()) { j -> inner.getString(j) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,7 @@ commonsImaging = "1.0.0-alpha6"
|
||||
thumbnailator = "0.4.21"
|
||||
zxing = "3.5.4"
|
||||
zxingAndroidEmbedded = "4.3.0"
|
||||
webkit = "1.12.1"
|
||||
windowCoreAndroid = "1.5.1"
|
||||
workRuntime = "2.11.2"
|
||||
androidxCamera = "1.6.1"
|
||||
@@ -207,6 +208,7 @@ stream-webrtc-android = { group = "io.getstream", name = "stream-webrtc-android"
|
||||
unifiedpush = { group = "com.github.UnifiedPush", name = "android-connector", version.ref = "unifiedpush" }
|
||||
play-services-cast-framework = { group = "com.google.android.gms", name = "play-services-cast-framework", version.ref = "playServicesCast" }
|
||||
vico-charts-compose = { group = "com.patrykandpatrick.vico", name = "compose", version.ref = "vico-charts-compose" }
|
||||
androidx-webkit = { group = "androidx.webkit", name = "webkit", version.ref = "webkit" }
|
||||
vico-charts-m3 = { group = "com.patrykandpatrick.vico", name = "compose-m3", version.ref = "vico-charts-compose" }
|
||||
zelory-image-compressor = { group = "id.zelory", name = "compressor", version.ref = "zelory" }
|
||||
zoomable = { group = "net.engawapg.lib", name = "zoomable", version.ref = "zoomable" }
|
||||
|
||||
Reference in New Issue
Block a user