diff options
Diffstat (limited to 'packages/meshbay-android/app/src/main')
10 files changed, 963 insertions, 3 deletions
diff --git a/packages/meshbay-android/app/src/main/assets/bridge/meshbay-bridge.js b/packages/meshbay-android/app/src/main/assets/bridge/meshbay-bridge.js index 350e087..c6e1f53 100644 --- a/packages/meshbay-android/app/src/main/assets/bridge/meshbay-bridge.js +++ b/packages/meshbay-android/app/src/main/assets/bridge/meshbay-bridge.js @@ -62,6 +62,46 @@ fetch: (url, init) => call('hub:fetch', url, init), resolveStun: (urls) => call('ice:resolve-stun', urls), + + // The device's hub key: generated, held and used natively. The page asks + // for a signature and never sees a key — it parses hostile input. + device: { + ensure: () => call('device:ensure'), + publicKey: () => call('device:public'), + sign: (username) => call('device:sign', username), + forget: () => call('device:forget'), + }, + + // The bundle key and the identity on every node, held natively: the page + // is told public keys and handed signatures and agreements. A signature is + // asked for by kind and fields, never by bytes. + keys: { + available: () => call('keys:available'), + deriveSession: (o) => call('keys:derive-session', o), + commitPending: (u) => call('keys:commit-pending', u), + dropPending: (u) => call('keys:drop-pending', u), + hasSession: (u) => call('keys:has-session', u), + forgetSession: (u) => call('keys:forget-session', u), + identity: (u, n) => call('keys:identity', u, n), + openBundle: (u, n, o) => call('keys:open-bundle', u, n, o), + mint: (u, n) => call('keys:mint', u, n), + sealBundle: (u, n, o) => call('keys:seal-bundle', u, n, o), + sealRecovery: (u, n, m, name) => call('keys:seal-recovery', u, n, m, name), + markSealed: (u, n, fp) => call('keys:mark-sealed', u, n, fp), + fingerprint: (u) => call('keys:fingerprint', u), + sign: (u, n, kind, fields) => call('keys:sign', u, n, kind, fields), + shared: (u, n, peer) => call('keys:shared', u, n, peer), + playlistKey: (u) => call('keys:playlist-key', u), + browserAccess: (u) => call('keys:browser-access', u), + setBrowserAccess: (u, on) => call('keys:set-browser-access', u, on), + createdHere: (u) => call('keys:created-here', u), + }, + + // Whether the OS protects what is stored. The store itself is not + // reachable from here. + secrets: { + backend: () => call('secrets:backend'), + }, }; const freeze = (o) => { diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt index f6f9740..9892cae 100644 --- a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt @@ -25,9 +25,13 @@ import androidx.webkit.WebViewFeature import org.json.JSONObject import org.meshbay.client.bridge.Bridge import org.meshbay.client.bridge.Channels +import org.meshbay.client.bridge.KeyChannels import org.meshbay.client.hub.HubClient +import org.meshbay.client.keys.SecretStore import org.meshbay.client.shell.EngineCheck +import org.meshbay.client.shell.NativeText import org.meshbay.client.shell.UiAssets +import java.util.concurrent.CountDownLatch /** * The shell: one WebView showing the packaged interface, and the bridge. @@ -40,6 +44,10 @@ class MainActivity : Activity() { private lateinit var root: FrameLayout private lateinit var web: WebView private lateinit var hub: HubClient + private lateinit var channels: Channels + private val text = NativeText { code -> + try { assets.open("ui/locales/$code.js").bufferedReader().use { it.readText() } } catch (e: java.io.IOException) { null } + } private var shim: ScriptHandler? = null private var fullscreen: View? = null private var fullscreenCallback: WebChromeClient.CustomViewCallback? = null @@ -59,8 +67,10 @@ class MainActivity : Activity() { root.addView(web, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)) configure(web) - val channels = Channels(hub, onHubChanged = { runOnUiThread { reloadForHub() } }, - hasCatalogue = { code -> hasAsset("ui/locales/$code.js") }) + val keys = KeyChannels(SecretStore(this), confirm = ::confirmNatively, + declined = { text.get("native.declined", channels.locale) }) + channels = Channels(hub, onHubChanged = { runOnUiThread { reloadForHub() } }, + hasCatalogue = { code -> hasAsset("ui/locales/$code.js") }, keys = keys) WebViewCompat.addWebMessageListener(web, Bridge.PORT, setOf(UiAssets.ORIGIN), Bridge(channels)) installShim() web.loadUrl(UiAssets.START) @@ -151,6 +161,27 @@ class MainActivity : Activity() { web.loadUrl(UiAssets.START) } + /** + * A confirmation this process draws (main.js confirmNatively). Called from + * a bridge worker, never the UI thread, which it waits on. The keyboard is + * handed back to the page afterwards: after a dialog the document can stay + * unfocused and every keystroke go nowhere (CLAUDE.md, ask.js). + */ + private fun confirmNatively(key: String): Boolean { + val done = CountDownLatch(1) + var accepted = false + runOnUiThread { + android.app.AlertDialog.Builder(this) + .setMessage(text.get(key, channels.locale)) + .setPositiveButton(text.get("dialog.ok", channels.locale)) { _, _ -> accepted = true } + .setNegativeButton(text.get("dialog.cancel", channels.locale), null) + .setOnDismissListener { web.requestFocus(); done.countDown() } + .show() + } + done.await() + return accepted + } + private fun hasAsset(path: String) = try { assets.open(path).close(); true } catch (e: java.io.IOException) { false } private fun refused() = WebResourceResponse("text/plain", "utf-8", 403, "Forbidden", emptyMap(), "".byteInputStream()) diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Channels.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Channels.kt index 81be25e..736e434 100644 --- a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Channels.kt +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Channels.kt @@ -19,6 +19,7 @@ class Channels( private val hub: HubClient, private val onHubChanged: () -> Unit, private val hasCatalogue: (String) -> Boolean, + private val keys: KeyChannels? = null, ) { @Volatile var locale = "en" private set @@ -28,7 +29,8 @@ class Channels( "hub:fetch" -> hub.fetch(args.optString(0, ""), args.optJSONObject(1)) "ice:resolve-stun" -> resolveStun(args.optJSONArray(0) ?: JSONArray()) "ui:locale" -> setLocale(args.optString(0, "")) - else -> throw Refused("Refused: no such channel") + else -> if (keys != null && keys.handles(channel)) keys.call(channel, args) + else throw Refused("Refused: no such channel") } private fun setLocale(code: String): String { diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/KeyChannels.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/KeyChannels.kt new file mode 100644 index 0000000..f11b98c --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/KeyChannels.kt @@ -0,0 +1,117 @@ +package org.meshbay.client.bridge + +import org.json.JSONArray +import org.json.JSONObject +import org.meshbay.client.keys.DeviceKey +import org.meshbay.client.keys.Keyring +import org.meshbay.client.keys.SecretStore +import org.meshbay.client.keys.Secrets + +/** + * The device key, the account's bundle key and its identity on every node — + * held here, never in the page (§8.2, §14.1 #20). The page is answered with + * public keys, signatures and agreements; it names what it signs by kind and + * fields, never by bytes (Transcripts). Arguments are checked as main.js does: + * ids are ids, keys are keys. + * + * `confirm` is a dialog this process draws, worded from the interface's own + * catalogues: what widens what leaves this device is never answered by the + * page. + */ +class KeyChannels( + private val secrets: Secrets, + private val confirm: (String) -> Boolean, + private val declined: () -> String, +) { + private val device = DeviceKey(secrets) + val keyring = Keyring( + load = { + val raw = secrets.read().optString(SecretStore.KEYRING_SLOT, "") + if (raw.isEmpty()) null else try { JSONObject(raw) } catch (e: Exception) { null } + }, + save = { state -> secrets.update { it.put(SecretStore.KEYRING_SLOT, state.toString()) } }, + ) + + // Only where the OS protects what is stored: an identity kept here and lost + // at the next start would leave a node pinning a key nobody holds, so + // without key storage the page keeps its keys the way a browser does. + private fun available() = secrets.backend() != "unavailable" + private fun needKeys() { if (!available()) throw Refused("No OS key storage") } + + fun handles(channel: String) = channel.startsWith("keys:") || channel.startsWith("device:") || + channel == "secrets:backend" + + fun call(channel: String, a: JSONArray): Any? = when (channel) { + "secrets:backend" -> secrets.backend() + + "device:ensure" -> device.ensure() + "device:public" -> device.publicKey() + "device:sign" -> device.sign(a.optString(0, "")) + "device:forget" -> device.forget() + + "keys:available" -> available() + "keys:derive-session" -> { + needKeys() + val o = a.optJSONObject(0) ?: JSONObject() + keyring.deriveSession( + password = o.optString("password", ""), username = o.optString("username", ""), + userId = uid(o.opt("userId")), pepperB64 = o.optString("pepperB64", ""), + pepperVersion = o.optInt("pepperVersion", 1).takeIf { it != 0 } ?: 1, + pendingChange = o.optBoolean("pending", false)) + } + "keys:commit-pending" -> keyring.commitPending(uid(a.opt(0))) + "keys:drop-pending" -> keyring.dropPending(uid(a.opt(0))) + "keys:has-session" -> available() && keyring.hasSession(uid(a.opt(0))) + "keys:forget-session" -> keyring.forgetSession(uid(a.opt(0))) + "keys:identity" -> keyring.identity(uid(a.opt(0)), npk(a.opt(1)))?.let { + JSONObject().put("pkEdB64", it.pkEdB64).put("pkXB64", it.pkXB64).put("sealedWith", it.sealedWith ?: JSONObject.NULL) + } + "keys:open-bundle" -> pub(keyring.openBundle(uid(a.opt(0)), npk(a.opt(1)), + bundleEnc = a.optJSONObject(2)?.optString("bundleEnc", "") ?: "")) + "keys:mint" -> { needKeys(); pub(keyring.mint(uid(a.opt(0)), npk(a.opt(1)))) } + "keys:seal-bundle" -> keyring.sealBundle(uid(a.opt(0)), npk(a.opt(1)), + usePending = a.optJSONObject(2)?.optBoolean("pending", false) ?: false).let { + JSONObject().put("bundle", it.bundle).put("fingerprint", it.fingerprint) + } + "keys:seal-recovery" -> keyring.sealRecovery(uid(a.opt(0)), npk(a.opt(1)), a.optString(2, ""), a.optString(3, "")) + "keys:mark-sealed" -> keyring.markSealed(uid(a.opt(0)), npk(a.opt(1)), a.optString(2, "")) + "keys:fingerprint" -> keyring.currentFingerprint(uid(a.opt(0))) + // By kind and fields: the page never names the bytes. + "keys:sign" -> keyring.signAs(uid(a.opt(0)), npk(a.opt(1)), a.optString(2, ""), a.optJSONObject(3) ?: JSONObject()) + "keys:shared" -> keyring.shared(uid(a.opt(0)), npk(a.opt(1)), a.optString(2, "")) + "keys:playlist-key" -> keyring.playlistKey(uid(a.opt(0))) + "keys:browser-access" -> keyring.browserAccess(uid(a.opt(0))) + // Turning it on leaves this account's identities on every node, sealed + // for a browser: the person decides that here, in a dialog the page + // cannot answer. Turning it off only narrows. + "keys:set-browser-access" -> { + val id = uid(a.opt(0)) + val on = a.optBoolean(1, false) + if (on && !keyring.browserAccess(id) && !confirm("native.browser_access_confirm")) throw Refused(declined()) + keyring.setBrowserAccess(id, on) + } + // An account created on this device starts without browser access. + // Only ever narrows, so the page may say it. + "keys:created-here" -> keyring.setBrowserAccess(uid(a.opt(0)), false) + else -> throw Refused("Refused: no such channel") + } + + private fun pub(p: Keyring.Pub) = JSONObject().put("pkEdB64", p.pkEdB64).put("pkXB64", p.pkXB64) + + companion object { + private val UID = Regex("^[0-9a-f-]{36}$", RegexOption.IGNORE_CASE) + private val NPK = Regex("^[A-Za-z0-9+/=]{1,100}$") + + fun uid(v: Any?): String { + val s = if (v == null || v == JSONObject.NULL) "" else v.toString() + if (!UID.matches(s)) throw Refused("Refused: not an account id") + return s + } + + fun npk(v: Any?): String { + val s = if (v == null || v == JSONObject.NULL) "" else v.toString() + if (!NPK.matches(s)) throw Refused("Refused: not a node's key") + return s + } + } +} diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/DeviceKey.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/DeviceKey.kt new file mode 100644 index 0000000..4cd840c --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/DeviceKey.kt @@ -0,0 +1,47 @@ +package org.meshbay.client.keys + +import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters +import org.bouncycastle.crypto.signers.Ed25519Signer +import org.json.JSONObject +import java.security.SecureRandom + +/** + * The device's key for signing in to the hub (E3): generated, held and used + * here, never handed to the page, which asks for a signature over + * `meshbay:user_auth:<username>:<ts>` — the bytes `POST /v1/users/auth` + * verifies. Not a per-node identity: nothing here correlates a person across + * operators (main.js `device:*`). + */ +class DeviceKey(private val store: Secrets, private val now: () -> Long = { System.currentTimeMillis() / 1000 }) { + + private fun current(): Ed25519PrivateKeyParameters? { + val stored = store.read().optString(SecretStore.DEVICE_KEY, "") + return if (stored.isEmpty()) null else Kdf.edFromPkcs8(Kdf.unb64(stored)) + } + + private fun publicOf(k: Ed25519PrivateKeyParameters) = Kdf.b64(k.generatePublicKey().encoded) + + fun ensure(): String { + current()?.let { return publicOf(it) } + val k = Ed25519PrivateKeyParameters(SecureRandom()) + store.update { it.put(SecretStore.DEVICE_KEY, Kdf.b64(Kdf.edToPkcs8(k))) } + return publicOf(k) + } + + fun publicKey(): String? = current()?.let { publicOf(it) } + + fun sign(username: String): JSONObject? { + val k = current() ?: return null + val ts = now() + // The username is inside the signature, so one collected for another + // account is not usable. + val message = "meshbay:user_auth:$username:$ts".toByteArray(Charsets.UTF_8) + val s = Ed25519Signer().apply { init(true, k); update(message, 0, message.size) } + return JSONObject().put("timestamp", ts).put("signature", Kdf.b64(s.generateSignature())) + } + + fun forget(): Boolean { + store.update { it.remove(SecretStore.DEVICE_KEY) } + return true + } +} diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/Kdf.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/Kdf.kt new file mode 100644 index 0000000..30f094e --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/Kdf.kt @@ -0,0 +1,106 @@ +package org.meshbay.client.keys + +import org.bouncycastle.crypto.digests.SHA256Digest +import org.bouncycastle.crypto.generators.Argon2BytesGenerator +import org.bouncycastle.crypto.generators.HKDFBytesGenerator +import org.bouncycastle.crypto.params.Argon2Parameters +import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters +import org.bouncycastle.crypto.params.HKDFParameters +import org.bouncycastle.crypto.params.X25519PrivateKeyParameters +import java.util.Base64 +import javax.crypto.Cipher +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec + +/** + * The primitives keyring.js takes from node:crypto and the vendored Argon2, + * with the same numbers. keyderive.js (the page), keyring.js (desktop) and this + * are one format: a mismatch looks like an account nobody can open, not like + * an error. meshbay-hub/tests/vectors/keyring.json holds them together. + */ +object Kdf { + // keyderive.js: the same numbers, or no bundle opens across the clients. + const val ARGON2_MEMORY_KIB = 131072 + const val ARGON2_PASSES = 3 + const val ARGON2_PARALLELISM = 1 + const val ARGON2_TAG = 32 + + private val ED_PKCS8_PREFIX = hex("302e020100300506032b657004220420") + private val X_PKCS8_PREFIX = hex("302e020100300506032b656e04220420") + + // One derivation at a time: 128 MiB each, on a phone. (Two concurrent + // lanes=4 derivations deadlock inside OpenSSL on the hub — CLAUDE.md; not + // this library, but there is no reason to find out.) + @Synchronized + fun argon2id(password: String, salt: ByteArray): ByteArray { + val params = Argon2Parameters.Builder(Argon2Parameters.ARGON2_id) + .withVersion(Argon2Parameters.ARGON2_VERSION_13) + .withIterations(ARGON2_PASSES) + .withMemoryAsKB(ARGON2_MEMORY_KIB) + .withParallelism(ARGON2_PARALLELISM) + .withSalt(salt) + .build() + val gen = Argon2BytesGenerator() + gen.init(params) + val out = ByteArray(ARGON2_TAG) + gen.generateBytes(password.toByteArray(Charsets.UTF_8), out) + return out + } + + /** node:crypto hkdfSync('sha256', ikm, <empty salt>, info, 32). */ + fun hkdf(ikm: ByteArray, info: String): ByteArray { + val gen = HKDFBytesGenerator(SHA256Digest()) + gen.init(HKDFParameters(ikm, null, info.toByteArray(Charsets.UTF_8))) + val out = ByteArray(32) + gen.generateBytes(out, 0, 32) + return out + } + + fun sha256(data: ByteArray): ByteArray { + val d = SHA256Digest() + d.update(data, 0, data.size) + val out = ByteArray(32) + d.doFinal(out, 0) + return out + } + + /** AES-256-GCM, 16-byte tag appended — the layout node:crypto's getAuthTag gives. */ + fun gcmSeal(key: ByteArray, nonce: ByteArray, plain: ByteArray, aad: ByteArray?): ByteArray { + val c = Cipher.getInstance("AES/GCM/NoPadding") + c.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, nonce)) + if (aad != null) c.updateAAD(aad) + return c.doFinal(plain) + } + + fun gcmOpen(key: ByteArray, nonce: ByteArray, ctAndTag: ByteArray, aad: ByteArray?): ByteArray { + val c = Cipher.getInstance("AES/GCM/NoPadding") + c.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, nonce)) + if (aad != null) c.updateAAD(aad) + return c.doFinal(ctAndTag) + } + + // Keys are stored as Node exports them: PKCS#8 DER, RFC 8410, 48 bytes, no + // public key attached. Written out by hand because a library's own PKCS#8 + // encoder may add the optional public key, and the stored format is one. + fun edToPkcs8(k: Ed25519PrivateKeyParameters) = ED_PKCS8_PREFIX + k.encoded + fun xToPkcs8(k: X25519PrivateKeyParameters) = X_PKCS8_PREFIX + k.encoded + + fun edFromPkcs8(der: ByteArray): Ed25519PrivateKeyParameters { + require(der.size == 48 && der.copyOfRange(0, 16).contentEquals(ED_PKCS8_PREFIX)) { + "not an Ed25519 PKCS#8 key" + } + return Ed25519PrivateKeyParameters(der, 16) + } + + fun xFromPkcs8(der: ByteArray): X25519PrivateKeyParameters { + require(der.size == 48 && der.copyOfRange(0, 16).contentEquals(X_PKCS8_PREFIX)) { + "not an X25519 PKCS#8 key" + } + return X25519PrivateKeyParameters(der, 16) + } + + fun b64(b: ByteArray): String = Base64.getEncoder().encodeToString(b) + fun unb64(s: String?): ByteArray = Base64.getDecoder().decode(s ?: "") + fun hex(s: String): ByteArray = ByteArray(s.length / 2) { s.substring(2 * it, 2 * it + 2).toInt(16).toByte() } + fun toHex(b: ByteArray): String = b.joinToString("") { "%02x".format(it) } +} diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/Keyring.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/Keyring.kt new file mode 100644 index 0000000..fa8ff71 --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/Keyring.kt @@ -0,0 +1,266 @@ +package org.meshbay.client.keys + +import org.bouncycastle.crypto.agreement.X25519Agreement +import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters +import org.bouncycastle.crypto.params.X25519PrivateKeyParameters +import org.bouncycastle.crypto.params.X25519PublicKeyParameters +import org.bouncycastle.crypto.signers.Ed25519Signer +import org.json.JSONObject +import java.security.SecureRandom +import org.meshbay.client.keys.Kdf.b64 +import org.meshbay.client.keys.Kdf.hkdf +import org.meshbay.client.keys.Kdf.unb64 + +/** + * The account's keys on Android: the bundle master key `M` and the identity on + * every node, held here and never handed to the page. A port of + * meshbay-client/src/keyring.js — same state shape (masters, identities, + * access), same formats, same refusals — so the two read side by side. + * + * Storage is injected (load/save of one JSON object), as on desktop; the app + * keeps it in SecretStore (Keystore-wrapped). `random` is injected so the + * vectors can pin a nonce. + */ +class Keyring( + private val load: () -> JSONObject?, + private val save: (JSONObject) -> Unit, + private val transcripts: Transcripts = Transcripts(), + private val random: (Int) -> ByteArray = { n -> ByteArray(n).also { SecureRandom().nextBytes(it) } }, +) { + class Pub(val pkEdB64: String, val pkXB64: String, val sealedWith: String? = null) + class Sealed(val bundle: String, val fingerprint: String) + class FormatRetired : IllegalStateException("bundle_format_retired") + + private class Master(val m: ByteArray, val v: Int) + private class Identity(val ed: String, val x: String) + + // In memory: the key of a passphrase change not yet accepted by the hub. + private val pending = HashMap<String, Master>() + + private fun state(): JSONObject { + val s = load() ?: JSONObject() + for (k in listOf("masters", "identities", "access")) if (!s.has(k)) s.put(k, JSONObject()) + return s + } + + private fun master(userId: String, usePending: Boolean = false): Master { + if (usePending) pending[userId]?.let { return it } + val m = state().getJSONObject("masters").optJSONObject(userId) + ?: throw IllegalStateException("no bundle key in this session") + return Master(unb64(m.getString("m")), m.getInt("v")) + } + + private fun fingerprint(m: ByteArray) = Kdf.toHex(Kdf.sha256(m)).substring(0, 16) + + private fun stored(userId: String, nodePk: String): Identity { + val id = state().getJSONObject("identities").optJSONObject(userId)?.optJSONObject(nodePk) + ?: throw IllegalStateException("no identity for this node") + return Identity(id.getString("ed"), id.getString("x")) + } + + private fun publicOf(id: Identity) = Pub( + b64(Kdf.edFromPkcs8(unb64(id.ed)).generatePublicKey().encoded), + b64(Kdf.xFromPkcs8(unb64(id.x)).generatePublicKey().encoded), + ) + + private fun accessOn(userId: String) = state().getJSONObject("access").opt(userId) != false + private fun needAccess(userId: String) { + if (!accessOn(userId)) throw IllegalStateException("Refused: browser access is off for this account") + } + + private fun keep(userId: String, nodePk: String, put: (JSONObject) -> Unit) { + val s = state() + val ids = s.getJSONObject("identities") + val forUser = ids.optJSONObject(userId) ?: JSONObject().also { ids.put(userId, it) } + val entry = forUser.optJSONObject(nodePk) ?: JSONObject().also { forUser.put(nodePk, it) } + put(entry) + save(s) + } + + private fun aad(userId: String, nodePk: String) = + "meshbay:bundle:v3|$userId|$nodePk".toByteArray(Charsets.UTF_8) + + // Exactly JSON.stringify({ skEd, skX }): base64 needs no escaping, and the + // sealed bytes are then comparable with the desktop's in tests. + private fun plaintext(id: Identity) = "{\"skEd\":\"${id.ed}\",\"skX\":\"${id.x}\"}" + + private fun seal(id: Identity, key: ByteArray, userId: String, nodePk: String, pepperVersion: Int): String { + val nonce = random(12) + val ct = Kdf.gcmSeal(key, nonce, plaintext(id).toByteArray(Charsets.UTF_8), aad(userId, nodePk)) + return b64(MAGIC + byteArrayOf((pepperVersion and 0xff).toByte()) + nonce + ct) + } + + private fun parse(plain: ByteArray): Identity { + val o = JSONObject(String(plain, Charsets.UTF_8)) + return Identity(o.getString("skEd"), o.getString("skX")) + } + + private fun open(bundleB64: String, key: ByteArray, userId: String, nodePk: String): Identity { + val raw = unb64(bundleB64) + if (raw.size < 4 || !raw.copyOfRange(0, 4).contentEquals(MAGIC)) throw FormatRetired() + return parse(Kdf.gcmOpen(key, raw.copyOfRange(5, 17), raw.copyOfRange(17, raw.size), aad(userId, nodePk))) + } + + /** TRANSITIONAL — MBK2: "MBK2" ‖ nonce ‖ AES-GCM under the Argon2 key, no AAD. */ + private fun openLegacy(bundleB64: String, key: ByteArray): Identity { + val raw = unb64(bundleB64) + return parse(Kdf.gcmOpen(key, raw.copyOfRange(4, 16), raw.copyOfRange(16, raw.size), null)) + } + + private fun fromMnemonic(mnemonic: String): ByteArray { + val clean = mnemonic.replace(Regex("[^A-Za-z2-7]"), "").uppercase() + var bits = 0 + var value = 0 + val out = ArrayList<Byte>() + for (ch in clean) { + value = (value shl 5) or B32.indexOf(ch) + bits += 5 + if (bits >= 8) { out.add(((value ushr (bits - 8)) and 0xff).toByte()); bits -= 8 } + } + if (out.size < 32) throw IllegalArgumentException("recovery key too short") + return out.subList(0, 32).toByteArray() + } + + // ── The API, in keyring.js order ──────────────────────────────────────── + + fun hasSession(userId: String) = state().getJSONObject("masters").has(userId) + + /** `M` from the passphrase and the pepper — one Argon2 run, as in the page. */ + fun deriveSession(password: String, username: String, userId: String, pepperB64: String?, + pepperVersion: Int?, pendingChange: Boolean = false): Boolean { + if (userId.isEmpty() || pepperB64.isNullOrEmpty()) { + throw IllegalStateException("the hub did not provide the bundle pepper") + } + val salt = Kdf.sha256("meshbay:bundle:v2:$username".toByteArray(Charsets.UTF_8)).copyOfRange(0, 16) + val a = Kdf.argon2id(password, salt) + val m = hkdf(a + unb64(pepperB64), "meshbay:bundle-master:v3|$userId") + val v = if (pepperVersion == null || pepperVersion == 0) 1 else pepperVersion + if (pendingChange) { pending[userId] = Master(m, v); return true } + val s = state() + // `legacy` (TRANSITIONAL): the Argon2 key MBK2 bundles were sealed under. + s.getJSONObject("masters").put(userId, JSONObject().put("m", b64(m)).put("v", v).put("legacy", b64(a))) + save(s) + return true + } + + fun commitPending(userId: String): Boolean { + val p = pending[userId] ?: return false + val s = state() + val legacy = s.getJSONObject("masters").optJSONObject(userId)?.optString("legacy", "") + val entry = JSONObject().put("m", b64(p.m)).put("v", p.v) + if (!legacy.isNullOrEmpty()) entry.put("legacy", legacy) + s.getJSONObject("masters").put(userId, entry) + save(s) + pending.remove(userId) + return true + } + + fun dropPending(userId: String) = pending.remove(userId) != null + + /** Sign-out: `M` goes. The identities stay — they are this device's. */ + fun forgetSession(userId: String): Boolean { + val s = state() + s.getJSONObject("masters").remove(userId) + save(s) + pending.remove(userId) + return true + } + + fun identity(userId: String, nodePk: String): Pub? { + val id = state().getJSONObject("identities").optJSONObject(userId)?.optJSONObject(nodePk) ?: return null + val pub = publicOf(Identity(id.getString("ed"), id.getString("x"))) + val sw = id.opt("sealedWith") + return Pub(pub.pkEdB64, pub.pkXB64, if (sw is String) sw else null) + } + + fun openBundle(userId: String, nodePk: String, bundleEnc: String, recoveryEnc: String? = null, + recoveryMnemonic: String? = null, username: String? = null): Pub { + val raw = unb64(bundleEnc) + if (raw.size >= 4 && raw.copyOfRange(0, 4).contentEquals(LEGACY_MAGIC)) { + val legacy = state().getJSONObject("masters").optJSONObject(userId)?.optString("legacy", "") + if (legacy.isNullOrEmpty()) throw IllegalStateException("no_legacy_key") + val id = openLegacy(bundleEnc, unb64(legacy)) + keepIdentity(userId, nodePk, id) + return publicOf(id) + } + val m = master(userId).m + val id = try { + open(bundleEnc, hkdf(m, "meshbay:bundle:v3|node|$nodePk"), userId, nodePk) + } catch (e: Exception) { + if (e is FormatRetired || recoveryEnc.isNullOrEmpty() || recoveryMnemonic.isNullOrEmpty()) throw e + val rk = hkdf(fromMnemonic(recoveryMnemonic), "meshbay:recovery:v1:${username ?: ""}") + open(recoveryEnc, rk, userId, nodePk) + } + keepIdentity(userId, nodePk, id) + return publicOf(id) + } + + private fun keepIdentity(userId: String, nodePk: String, id: Identity) = + keep(userId, nodePk) { it.put("ed", id.ed).put("x", id.x).put("sealedWith", JSONObject.NULL) } + + fun mint(userId: String, nodePk: String): Pub { + val rnd = SecureRandom() + val id = Identity(b64(Kdf.edToPkcs8(Ed25519PrivateKeyParameters(rnd))), + b64(Kdf.xToPkcs8(X25519PrivateKeyParameters(rnd)))) + keepIdentity(userId, nodePk, id) + return publicOf(id) + } + + /** The identity sealed for its node, under `M` (or the pending one). */ + fun sealBundle(userId: String, nodePk: String, usePending: Boolean = false): Sealed { + needAccess(userId) + val m = master(userId, usePending) + val bundle = seal(stored(userId, nodePk), hkdf(m.m, "meshbay:bundle:v3|node|$nodePk"), userId, nodePk, m.v) + return Sealed(bundle, fingerprint(m.m)) + } + + /** The recovery copy: sealed under the recovery key, owing nothing to `M`. */ + fun sealRecovery(userId: String, nodePk: String, mnemonic: String, username: String): String { + needAccess(userId) + val rk = hkdf(fromMnemonic(mnemonic), "meshbay:recovery:v1:$username") + return seal(stored(userId, nodePk), rk, userId, nodePk, 0) + } + + fun markSealed(userId: String, nodePk: String, fp: String?): Boolean { + keep(userId, nodePk) { it.put("sealedWith", if (fp.isNullOrEmpty()) JSONObject.NULL else fp) } + return true + } + + fun currentFingerprint(userId: String) = fingerprint(master(userId).m) + + /** Sign what `kind` names, built from `fields` (Transcripts). */ + fun signAs(userId: String, nodePk: String, kind: String, fields: JSONObject?): String { + val id = stored(userId, nodePk) + val pub = publicOf(id) + val transcript = transcripts.forKind(kind, fields, Transcripts.Ctx(userId, nodePk, pub.pkEdB64, pub.pkXB64)) + val signer = Ed25519Signer() + signer.init(true, Kdf.edFromPkcs8(unb64(id.ed))) + signer.update(transcript, 0, transcript.size) + return b64(signer.generateSignature()) + } + + fun shared(userId: String, nodePk: String, peerPkB64: String): String { + val agreement = X25519Agreement() + agreement.init(Kdf.xFromPkcs8(unb64(stored(userId, nodePk).x))) + val out = ByteArray(32) + agreement.calculateAgreement(X25519PublicKeyParameters(unb64(peerPkB64), 0), out, 0) + return b64(out) + } + + fun playlistKey(userId: String) = b64(hkdf(master(userId).m, "meshbay:playlists:v2")) + + fun browserAccess(userId: String) = accessOn(userId) + fun setBrowserAccess(userId: String, on: Boolean): Boolean { + val s = state() + s.getJSONObject("access").put(userId, on) + save(s) + return on + } + + companion object { + private val MAGIC = "MBK3".toByteArray() + // TRANSITIONAL — the format before MBK3, read once to be replaced. + private val LEGACY_MAGIC = "MBK2".toByteArray() + private const val B32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567" + } +} diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/SecretStore.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/SecretStore.kt new file mode 100644 index 0000000..5a2c1bb --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/SecretStore.kt @@ -0,0 +1,120 @@ +package org.meshbay.client.keys + +import android.content.Context +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.security.keystore.StrongBoxUnavailableException +import android.util.Log +import org.json.JSONObject +import java.io.File +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +/** + * The application's secrets, at rest: the desktop's safeStorage blob, here + * wrapped by an AES-256-GCM key that lives in Android Keystore and never leaves + * it (in StrongBox where the device has one). + * + * One file, `files/secrets.bin` = nonce ‖ ciphertext ‖ tag over the JSON of + * every slot (`device_key`, `keyring` — main.js's slots), replaced atomically. + * Nothing in it is reachable from the page: it holds the device's hub key. + * + * Honest without protection, as on desktop: if the Keystore cannot be used, + * `backend()` says `unavailable` and nothing is stored — the page then keeps + * its keys the way a browser does, rather than this downgrading silently. + */ +/** What the keys need of their storage; SecretStore on a device, a map in tests. */ +interface Secrets { + fun backend(): String + fun read(): JSONObject + fun update(fn: (JSONObject) -> Unit) +} + +class SecretStore(private val context: Context) : Secrets { + private val file get() = File(context.filesDir, "secrets.bin") + @Volatile private var strongBox = false + + private fun key(): SecretKey? = try { + val ks = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + (ks.getKey(ALIAS, null) as SecretKey?) ?: generate() + } catch (e: Exception) { + Log.w(TAG, "Keystore unusable", e) + null + } + + private fun generate(): SecretKey { + fun spec(strong: Boolean) = KeyGenParameterSpec.Builder(ALIAS, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256) + // Usable without a prompt: the app answers the hub on its own, as + // the desktop keychain does once the session is unlocked. + .setUserAuthenticationRequired(false) + .setRandomizedEncryptionRequired(true) + .apply { if (strong) setIsStrongBoxBacked(true) } + .build() + val gen = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore") + return try { + gen.init(spec(true)); gen.generateKey().also { strongBox = true } + } catch (e: StrongBoxUnavailableException) { + gen.init(spec(false)); gen.generateKey() + } + } + + override fun backend(): String { + if (key() == null) return "unavailable" + return if (strongBox || isStrongBox()) "android_strongbox" else "android_keystore" + } + + private fun isStrongBox(): Boolean = try { + val k = key() ?: return false + val info = javax.crypto.SecretKeyFactory.getInstance(k.algorithm, "AndroidKeyStore") + .getKeySpec(k, android.security.keystore.KeyInfo::class.java) as android.security.keystore.KeyInfo + if (android.os.Build.VERSION.SDK_INT >= 31) info.securityLevel == KeyProperties.SECURITY_LEVEL_STRONGBOX else false + } catch (e: Exception) { false } + + @Synchronized + override fun read(): JSONObject { + val k = key() ?: return JSONObject() + val raw = try { file.readBytes() } catch (e: java.io.FileNotFoundException) { return JSONObject() } + return try { + val c = Cipher.getInstance("AES/GCM/NoPadding") + c.init(Cipher.DECRYPT_MODE, k, GCMParameterSpec(128, raw, 0, 12)) + JSONObject(String(c.doFinal(raw, 12, raw.size - 12), Charsets.UTF_8)) + } catch (e: Exception) { + // A store that does not open is reported, never overwritten: what + // is in it is a device key and identities nodes have pinned. + throw IllegalStateException("the key store does not open", e) + } + } + + @Synchronized + fun write(all: JSONObject) { + val k = key() ?: throw IllegalStateException("No OS key storage") + val c = Cipher.getInstance("AES/GCM/NoPadding") + c.init(Cipher.ENCRYPT_MODE, k) + val sealed = c.iv + c.doFinal(all.toString().toByteArray(Charsets.UTF_8)) + val tmp = File(context.filesDir, "secrets.bin.tmp") + tmp.writeBytes(sealed) + if (!tmp.renameTo(file)) throw IllegalStateException("could not replace the key store") + } + + /** One slot, read and replaced under the same lock. */ + @Synchronized + override fun update(fn: (JSONObject) -> Unit) { + val all = read() + fn(all) + write(all) + } + + companion object { + private const val TAG = "MeshBay" + private const val ALIAS = "meshbay.secrets.v1" + const val DEVICE_KEY = "device_key" + const val KEYRING_SLOT = "keyring" + } +} diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/Transcripts.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/Transcripts.kt new file mode 100644 index 0000000..4d183f7 --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/Transcripts.kt @@ -0,0 +1,183 @@ +package org.meshbay.client.keys + +import org.json.JSONObject +import java.io.ByteArrayOutputStream +import java.util.Base64 +import kotlin.math.abs + +/** + * What a node identity signs, built here from named fields — never bytes the + * page chose. A port of meshbay-client/src/transcripts.js, byte for byte; the + * shapes it checks and the refusals it gives are the same, and + * meshbay-hub/tests/vectors/keyring.json is what holds them (and + * meshbay_common) together. + * + * `now` is injected so the vectors can pin the clock; production passes the + * system clock. + */ +class Transcripts(private val now: () -> Double = { System.currentTimeMillis() / 1000.0 }) { + + class Refused(what: String) : IllegalArgumentException("Refused: $what") + + data class Ctx(val userId: String, val nodePk: String, val pkEdB64: String, val pkXB64: String) + + private fun refuse(what: String): Nothing = throw Refused(what) + + private fun enc(s: String) = s.toByteArray(Charsets.UTF_8) + + private fun lenPrefixed(prefix: String, parts: List<ByteArray>): ByteArray { + val out = ByteArrayOutputStream() + out.write(prefix.toByteArray(Charsets.UTF_8)) + for (p in parts) { + out.write(byteArrayOf((p.size ushr 24).toByte(), (p.size ushr 16).toByte(), + (p.size ushr 8).toByte(), p.size.toByte())) + out.write(p) + } + return out.toByteArray() + } + + // JavaScript's String(v ?? '') over a value that came through JSON. + private fun jsString(v: Any?): String = when (v) { + null, JSONObject.NULL -> "" + is String -> v + is Boolean -> v.toString() + is Int, is Long -> v.toString() + is Number -> { + val d = v.toDouble() + if (d == Math.floor(d) && !d.isInfinite() && abs(d) < 1e21) d.toLong().toString() else d.toString() + } + else -> v.toString() + } + + // JavaScript's Number(v), for the values a timestamp or an epoch can arrive as. + private fun jsNumber(v: Any?): Double = when (v) { + null, JSONObject.NULL -> if (v == null) Double.NaN else 0.0 + is Number -> v.toDouble() + is Boolean -> if (v) 1.0 else 0.0 + is String -> v.trim().let { if (it.isEmpty()) 0.0 else it.toDoubleOrNull() ?: Double.NaN } + else -> Double.NaN + } + + private fun isInteger(d: Double) = !d.isNaN() && !d.isInfinite() && d == Math.floor(d) + + private val base64Shape = Regex("^[A-Za-z0-9+/]*={0,2}$") + + private fun bytes(v: Any?, what: String, min: Int = 1, max: Int = 64): ByteArray { + val s = jsString(v) + if (!base64Shape.matches(s)) refuse("$what is not base64") + // Node's decoder is lenient where Java's throws (a lone trailing + // character). Both outcomes are a refusal: Node's yields a short + // buffer that the length check below refuses. + val b = try { Base64.getDecoder().decode(s) } catch (e: IllegalArgumentException) { + refuse("$what has the wrong length") + } + if (b.size < min || b.size > max) refuse("$what has the wrong length") + return b + } + + private fun key32(v: Any?, what: String): String { + bytes(v, what, 32, 32) + return jsString(v) + } + + private fun text(v: Any?, what: String, max: Int = 256): String { + val s = jsString(v) + if (s.length > max) refuse("$what is too long") // UTF-16 units, as JS counts + return s + } + + private val groupShape = Regex("^[A-Za-z0-9_-]{1,64}$") + private fun groupId(v: Any?): String { + val s = jsString(v) + if (s.isNotEmpty() && !groupShape.matches(s)) refuse("not a group id") + return s + } + + private fun timestamp(v: Any?): String { + val n = jsNumber(v) + if (!isInteger(n) || abs(n - now()) > TS_SLACK_S) refuse("the timestamp is not now") + return jsString(n.toLong()) + } + + fun forKind(kind: String, f: JSONObject?, ctx: Ctx): ByteArray { + val fields = f ?: JSONObject() + fun field(name: String): Any? = if (fields.has(name)) fields.get(name) else null + fun sameNode(): String { + if (jsString(field("nodePk")) != ctx.nodePk) refuse("another node") + return ctx.nodePk + } + fun sameUser(): String { + if (jsString(field("userId")) != ctx.userId) refuse("another account") + return ctx.userId + } + fun nonceNode() = bytes(field("nonceNode"), "the node nonce", 16, 64) + + return when (kind) { + "join" -> lenPrefixed(PREFIX_JOIN, listOf( + enc(sameNode()), enc(groupId(field("groupId"))), enc(sameUser()), + enc(ctx.pkEdB64), enc(ctx.pkXB64), nonceNode(), enc(timestamp(field("ts"))))) + "device_hello" -> lenPrefixed(PREFIX_DEVICE_HELLO, listOf( + enc(sameNode()), enc(groupId(field("groupId"))), enc(sameUser()), + enc(ctx.pkEdB64), nonceNode(), enc(timestamp(field("ts"))))) + "device_request" -> { + val codeHash = jsString(field("codeHash")) + if (!Regex("^[0-9a-f]{64}$").matches(codeHash)) refuse("not a request hash") + lenPrefixed(PREFIX_DEVICE_REQUEST, listOf( + enc(sameNode()), enc(sameUser()), enc(ctx.pkEdB64), enc(ctx.pkXB64), + enc(codeHash), nonceNode(), enc(timestamp(field("ts"))))) + } + "device_add" -> lenPrefixed(PREFIX_DEVICE_ADD, listOf( + enc(sameNode()), enc(sameUser()), enc(key32(field("pkEd"), "the device key")), + enc(key32(field("pkX"), "the device key")), nonceNode(), enc(timestamp(field("ts"))))) + "device_revoke" -> lenPrefixed(PREFIX_DEVICE_REVOKE, listOf( + enc(sameNode()), enc(sameUser()), enc(key32(field("pkEd"), "the device key")), + nonceNode(), enc(timestamp(field("ts"))))) + "chat" -> { + val epoch = jsNumber(field("epoch")) + if (!isInteger(epoch) || epoch < 0) refuse("not an epoch") + lenPrefixed(PREFIX_CHAT, listOf( + enc(groupId(field("groupId"))), enc(jsString(epoch.toLong())), + Base64.getDecoder().decode(ctx.pkEdB64), + bytes(field("nonce"), "the message nonce", 12, 24), + bytes(field("ct"), "the message", 1, 8 * 1024 * 1024))) + } + "admin" -> { + val op = jsString(field("op")) + if (op !in ADMIN_OPS) refuse("not an operation") + lenPrefixed(PREFIX_ADMIN, listOf( + enc(op), enc(sameNode()), enc(groupId(field("groupId"))), + enc(text(field("subject"), "the subject", 16384)), + bytes(field("nonce"), "the challenge nonce", 16, 64), + enc(timestamp(field("ts"))))) + } + else -> refuse("nothing is signed as \"${kind.take(32)}\"") + } + } + + companion object { + // The node's clock and ours: a signature for a moment far from now is one to keep for later. + const val TS_SLACK_S = 600 + + // The signed operations this application asks a node to perform + // (meshbay_common/adminop.py) — transcripts.js's list, held equal by + // test_android_keys.py. A list, not a pattern: what widens a node's + // sharing (root_add, root_update, group_attach — gone from MNP 6.0) is + // never signed here, so a script in the page cannot drive an older node + // into it either. + val ADMIN_OPS = setOf( + "file_delete", "dir_delete", "invite_create", "invite_link_create", + "invite_cancel", "member_revoke", "apps_enabled", "set_scan_settings", + "tmdb_config", "tmdb_enabled", "tmdb_override", "tmdb_rematch", + "musicbrainz_enabled", "root_remove", "root_eject", "root_plug", + "app_directories", "chat_directory", "chat_link_preview", "search_listed", + "chat_epoch", + ) + const val PREFIX_JOIN = "meshbay:join:v1" + const val PREFIX_DEVICE_REQUEST = "meshbay:device_req:v1" + const val PREFIX_DEVICE_ADD = "meshbay:device_add:v1" + const val PREFIX_DEVICE_REVOKE = "meshbay:device_revoke:v1" + const val PREFIX_DEVICE_HELLO = "meshbay:device_hello:v1" + const val PREFIX_CHAT = "meshbay:chat:v1" + const val PREFIX_ADMIN = "meshbay:admin:v1" + } +} diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/NativeText.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/NativeText.kt new file mode 100644 index 0000000..ae23e46 --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/NativeText.kt @@ -0,0 +1,48 @@ +package org.meshbay.client.shell + +/** + * A sentence from the interface's own catalogues, for the few things the + * application draws itself (main.js `nativeText`). The page chooses the + * language and nothing else: a confirmation it worded would be one it could + * answer for itself. + * + * The catalogues are `export default { 'key': '…', … }` with single-quoted + * strings; a plural entry is an object, of which `other` is taken. + */ +class NativeText(private val readCatalogue: (String) -> String?) { + + fun get(key: String, locale: String, params: Map<String, String> = emptyMap()): String { + var text = pick(readCatalogue(locale), key) ?: pick(readCatalogue("en"), key) ?: key + // split/join, as i18n.js: a folder name may contain `$&`. + for ((k, v) in params) text = text.split("{$k}").joinToString(v) + return text + } + + companion object { + fun pick(source: String?, key: String): String? { + source ?: return null + val k = Regex.escape(key) + Regex("""(?m)^\s*'$k'\s*:\s*'((?:[^'\\]|\\.)*)'""").find(source)?.let { return unescape(it.groupValues[1]) } + Regex("""(?ms)^\s*'$k'\s*:\s*\{.*?\bother\s*:\s*'((?:[^'\\]|\\.)*)'""").find(source)?.let { return unescape(it.groupValues[1]) } + return null + } + + fun unescape(s: String): String { + val out = StringBuilder() + var i = 0 + while (i < s.length) { + val c = s[i] + if (c == '\\' && i + 1 < s.length) { + val n = s[i + 1] + when (n) { + 'n' -> out.append('\n'); 't' -> out.append('\t') + 'u' -> if (i + 5 < s.length) { out.append(s.substring(i + 2, i + 6).toInt(16).toChar()); i += 4 } + else -> out.append(n) + } + i += 2 + } else { out.append(c); i++ } + } + return out.toString() + } + } +} |