aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/KeyChannels.kt
blob: f11b98ca30088846e7e226ab435ae02f4f871e82 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
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
        }
    }
}