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
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
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"
}
}
|