Skip to content

Commit 768c3e2

Browse files
authored
Feat/secure keybase (#216)
# Secure keybase via hardware-backed encryption (iOS Secure Enclave / Android Keystore) ## Summary This PR secures the Gno keybase by encrypting keys using a hardware-backed private key that lives in the platform keystore: * **iOS:** Keychain protected by the **Secure Enclave** (when available). * **Android:** **Android Keystore System** (StrongBox/TEE when available). To enable this, the `gnonative` framework now implements the [`gno` DB interface](https://github.com/gnolang/gno/blob/master/tm2/pkg/db/types.go#L4-L54). Only the **primitive DB operations** are bridged to native code (Swift/Kotlin) via a `NativeDBManager`, which talks to the Keychain/Keystore for `get/set/delete...` of raw byte keys and values. Higher-level logic stays in Go. ## Design * **DB interface split:** * Native (Swift/Kotlin): minimal KV primitives only (opaque `[]byte` in/out). * Go: implements iterators and any non-primitive behavior on top of the native primitives. * **Iterator protocol:** Because the Go↔native bridge can only exchange primitive values, iteration is done in **chunks**. Go calls `scanChunk(...)` repeatedly; native returns a single **blob** that aggregates multiple key/value pairs. `scanChunk` native functions return this **blob** format: ``` // Blob layout (all integers are big-endian): // // +---------+-------------------+---------------------------------------+--------------------------+------------------------+ // | Offset | Field | Description | Type/Size | Notes | // +---------+-------------------+---------------------------------------+--------------------------+------------------------+ // | 0 | flags | bit0 = hasMore (1 => more pages) | uint8 (1 byte) | other bits reserved | // | 1 | count | number of K/V pairs that follow | uint32 (4 bytes, BE) | N | // | 5 | pairs[0..N-1] | repeated K/V frames: | | | // | | - klen | key length | uint32 (4 bytes, BE) | | // | | - key | key bytes | klen bytes | | // | | - vlen | value length | uint32 (4 bytes, BE) | | // | | - value | value bytes | vlen bytes | | // | ... | nextSeekLen | length of the nextSeek key | uint32 (4 bytes, BE) | 0 if empty | // | ... | nextSeek | nextSeek key bytes | nextSeekLen bytes | | // +---------+-------------------+---------------------------------------+--------------------------+------------------------+ // // Semantics: // - The iterator uses 'hasMore' to know if additional pages exist. // - 'nextSeek' is typically the last key of this page; pass it back as 'seekKey' (exclusive) // on the next ScanChunk call to continue from the next item. // - Keys/values are raw bytes; ordering and range checks are done on the raw key bytes. ``` --------- Signed-off-by: D4ryl00 <[email protected]>
1 parent e21e348 commit 768c3e2

File tree

8 files changed

+932
-5
lines changed

8 files changed

+932
-5
lines changed

expo/android/src/main/java/land/gno/gnonative/GnonativeModule.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ class GnonativeModule : Module() {
1717
private var rootDir: File? = null
1818
private var socketPort = 0
1919
private var bridgeGnoNative: Bridge? = null
20+
private var nativeDBManager: NativeDBManager? = null
2021

2122
// Each module class must implement the definition function. The definition consists of components
2223
// that describes the module's functionality and behavior.
@@ -33,6 +34,7 @@ class GnonativeModule : Module() {
3334
OnCreate {
3435
context = appContext.reactContext
3536
rootDir = context!!.filesDir
37+
nativeDBManager = NativeDBManager(context!!)
3638
}
3739

3840
OnDestroy {
@@ -53,8 +55,8 @@ class GnonativeModule : Module() {
5355
try {
5456
val config: BridgeConfig = Gnonative.newBridgeConfig() ?: throw Exception("")
5557
config.rootDir = rootDir!!.absolutePath
58+
config.nativeDB = nativeDBManager
5659
bridgeGnoNative = Gnonative.newBridge(config)
57-
5860
promise.resolve(true)
5961
} catch (err: CodedException) {
6062
promise.reject(err)
Lines changed: 324 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,324 @@
1+
package land.gno.gnonative
2+
3+
import android.content.Context
4+
import android.content.SharedPreferences
5+
import android.os.Build
6+
import android.security.keystore.KeyGenParameterSpec
7+
import android.security.keystore.KeyProperties
8+
import android.util.Base64
9+
import gnolang.gno.gnonative.NativeDB
10+
import java.io.ByteArrayOutputStream
11+
import java.nio.ByteBuffer
12+
import java.security.KeyStore
13+
import javax.crypto.Cipher
14+
import javax.crypto.KeyGenerator
15+
import javax.crypto.SecretKey
16+
import javax.crypto.spec.GCMParameterSpec
17+
import kotlin.math.min
18+
import androidx.core.content.edit
19+
20+
class NativeDBManager(
21+
context: Context,
22+
private val prefsName: String = "gnonative_secure_db",
23+
private val keyAlias: String = "gnonative_aes_key"
24+
) : NativeDB {
25+
26+
// -------- storage / index --------
27+
private val prefs: SharedPreferences =
28+
context.getSharedPreferences(prefsName, Context.MODE_PRIVATE)
29+
private val entryPrefix = "kv:" // entryPrefix + hexKey -> Base64(encrypted blob)
30+
private val idxKey = "__idx__" // CSV of hex keys in ascending order
31+
32+
// -------- crypto --------
33+
private val ks: KeyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
34+
35+
private val lock = Any()
36+
37+
init {
38+
ensureAesKey()
39+
if (!prefs.contains(idxKey)) prefs.edit { putString(idxKey, "") }
40+
}
41+
42+
// ========== NativeDB implementation ==========
43+
44+
override fun delete(p0: ByteArray?) {
45+
val key = requireKey(p0)
46+
val hex = hex(key)
47+
synchronized(lock) {
48+
val idx = loadIndexAsc().toMutableList()
49+
val pos = lowerBound(idx, hex)
50+
if (pos < idx.size && idx[pos] == hex) {
51+
idx.removeAt(pos)
52+
saveIndexAsc(idx)
53+
}
54+
prefs.edit { remove("$entryPrefix$hex") }
55+
}
56+
}
57+
58+
override fun deleteSync(p0: ByteArray?) {
59+
delete(p0)
60+
}
61+
62+
override fun get(p0: ByteArray?): ByteArray {
63+
val key = requireKey(p0)
64+
val hex = hex(key)
65+
val b64 = synchronized(lock) { prefs.getString("$entryPrefix$hex", null) }
66+
?: return ByteArray(0) // gomobile generated non-null return -> use empty on miss
67+
val blob = Base64.decode(b64, Base64.NO_WRAP)
68+
return decrypt(blob) ?: ByteArray(0)
69+
}
70+
71+
override fun has(p0: ByteArray?): Boolean {
72+
val key = requireKey(p0)
73+
val hex = hex(key)
74+
return synchronized(lock) { prefs.contains("$entryPrefix$hex") }
75+
}
76+
77+
override fun scanChunk(
78+
p0: ByteArray?, // start
79+
p1: ByteArray?, // end
80+
p2: ByteArray?, // seekKey
81+
p3: Long, // limit
82+
p4: Boolean // reverse
83+
): ByteArray {
84+
val limit = if (p3 < 0) 0 else min(p3, Int.MAX_VALUE.toLong()).toInt()
85+
return synchronized(lock) {
86+
val asc = loadIndexAsc() // ascending hex keys
87+
val startHex = p0?.let { hex(it) }
88+
val endHex = p1?.let { hex(it) }
89+
val seekHex = p2?.let { hex(it) }
90+
91+
val loBase = startHex?.let { lowerBound(asc, it) } ?: 0
92+
val hiBase = endHex?.let { lowerBound(asc, it) } ?: asc.size
93+
var slice: List<String> = if (hiBase <= loBase) emptyList() else asc.subList(loBase, hiBase)
94+
95+
// seek positioning & direction
96+
slice = if (!p4) {
97+
val from = seekHex?.let { upperBound(slice, it) } ?: 0
98+
if (from >= slice.size) emptyList() else slice.subList(from, slice.size)
99+
} else {
100+
val positioned = if (seekHex != null) {
101+
val idx = upperBound(slice, seekHex) - 1
102+
if (idx < 0) emptyList() else slice.subList(0, idx + 1)
103+
} else slice
104+
positioned.asReversed()
105+
}
106+
107+
val page = if (limit == 0) emptyList() else slice.take(limit)
108+
val hasMore = page.isNotEmpty() && page.size < slice.size
109+
val nextSeekHex = if (hasMore) page.last() else null
110+
111+
// materialize kv pairs in traversal order
112+
val pairs = ArrayList<Pair<ByteArray, ByteArray>>(page.size)
113+
for (h in page) {
114+
val b64 = prefs.getString("$entryPrefix$h", null) ?: continue
115+
val v = decrypt(Base64.decode(b64, Base64.NO_WRAP)) ?: continue
116+
pairs += (unhex(h) to v)
117+
}
118+
119+
// flags(1) | count(u32 BE) | [kLen k vLen v]* | nextSeekLen(u32 BE) | nextSeek
120+
encodeChunkBlobBE(pairs, nextSeekHex?.let { unhex(it) }, hasMore)
121+
}
122+
}
123+
124+
override fun set(p0: ByteArray?, p1: ByteArray?) {
125+
val key = requireKey(p0)
126+
val value = requireValue(p1)
127+
val hex = hex(key)
128+
val enc = encrypt(value)
129+
val b64 = Base64.encodeToString(enc, Base64.NO_WRAP)
130+
synchronized(lock) {
131+
val idx = loadIndexAsc().toMutableList()
132+
val pos = lowerBound(idx, hex)
133+
if (pos == idx.size || idx[pos] != hex) {
134+
idx.add(pos, hex)
135+
saveIndexAsc(idx)
136+
}
137+
prefs.edit { putString("$entryPrefix$hex", b64) }
138+
}
139+
}
140+
141+
override fun setSync(p0: ByteArray?, p1: ByteArray?) {
142+
set(p0, p1)
143+
}
144+
145+
// ========== helpers ==========
146+
147+
private fun requireKey(b: ByteArray?): ByteArray {
148+
require(!(b == null || b.isEmpty())) { "key must not be null/empty" }
149+
return b
150+
}
151+
private fun requireValue(b: ByteArray?): ByteArray {
152+
require(b != null) { "value must not be null" }
153+
return b
154+
}
155+
156+
// ----- index (csv of hex keys, ascending) -----
157+
private fun loadIndexAsc(): List<String> {
158+
val csv = prefs.getString(idxKey, "") ?: ""
159+
return if (csv.isEmpty()) emptyList() else csv.split(',').filter { it.isNotEmpty() }
160+
}
161+
private fun saveIndexAsc(keys: List<String>) {
162+
prefs.edit { putString(idxKey, if (keys.isEmpty()) "" else keys.joinToString(",")) }
163+
}
164+
165+
private fun lowerBound(list: List<String>, key: String): Int {
166+
var lo = 0; var hi = list.size
167+
while (lo < hi) {
168+
val mid = (lo + hi) ushr 1
169+
if (list[mid] < key) lo = mid + 1 else hi = mid
170+
}
171+
return lo
172+
}
173+
private fun upperBound(list: List<String>, key: String): Int {
174+
var lo = 0; var hi = list.size
175+
while (lo < hi) {
176+
val mid = (lo + hi) ushr 1
177+
if (list[mid] <= key) lo = mid + 1 else hi = mid
178+
}
179+
return lo
180+
}
181+
182+
// crypto AES/GCM, StrongBox preferred
183+
private fun ensureAesKey() {
184+
if (getAesKey() != null) return
185+
186+
val kg = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE)
187+
val base = KeyGenParameterSpec.Builder(
188+
keyAlias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
189+
)
190+
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
191+
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
192+
.setKeySize(256)
193+
.setRandomizedEncryptionRequired(true)
194+
195+
try {
196+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
197+
base.setIsStrongBoxBacked(true)
198+
}
199+
kg.init(base.build())
200+
kg.generateKey()
201+
return
202+
} catch (_: Throwable) {
203+
// fall back below without StrongBox
204+
}
205+
206+
kg.init(
207+
KeyGenParameterSpec.Builder(
208+
keyAlias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
209+
)
210+
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
211+
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
212+
.setKeySize(256)
213+
.setRandomizedEncryptionRequired(true)
214+
.build()
215+
)
216+
kg.generateKey()
217+
}
218+
219+
private fun getAesKey(): SecretKey? {
220+
val e = ks.getEntry(keyAlias, null) as? KeyStore.SecretKeyEntry
221+
return e?.secretKey
222+
}
223+
224+
private fun encrypt(plain: ByteArray): ByteArray {
225+
val key = getAesKey() ?: error("AES key missing")
226+
227+
val c = Cipher.getInstance(AES_GCM)
228+
c.init(Cipher.ENCRYPT_MODE, key)
229+
230+
val iv = c.iv
231+
val ct = c.doFinal(plain)
232+
233+
// payload: [version=1][ivLen][iv][ct]
234+
val out = ByteArray(1 + 1 + iv.size + ct.size)
235+
var i = 0
236+
out[i++] = 1
237+
out[i++] = iv.size.toByte()
238+
System.arraycopy(iv, 0, out, i, iv.size); i += iv.size
239+
System.arraycopy(ct, 0, out, i, ct.size)
240+
return out
241+
}
242+
243+
private fun decrypt(blob: ByteArray?): ByteArray? {
244+
if (blob == null || blob.size < 1 + 1 + 12) return null // iv is usually 12 bytes
245+
var i = 0
246+
val ver = blob[i++]
247+
require(ver.toInt() == 1) { "bad payload version=$ver" }
248+
val ivLen = blob[i++].toInt() and 0xFF
249+
require(ivLen in 12..32) { "bad iv length" }
250+
require(blob.size >= 1 + 1 + ivLen + 1) { "short blob" }
251+
val iv = ByteArray(ivLen)
252+
System.arraycopy(blob, i, iv, 0, ivLen); i += ivLen
253+
val ct = ByteArray(blob.size - i)
254+
System.arraycopy(blob, i, ct, 0, ct.size)
255+
256+
val key = getAesKey() ?: error("AES key missing")
257+
val c = Cipher.getInstance(AES_GCM)
258+
c.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(128, iv))
259+
return c.doFinal(ct)
260+
}
261+
262+
// chunk framing (match Go format)
263+
private fun encodeChunkBlobBE(
264+
entries: List<Pair<ByteArray, ByteArray>>,
265+
nextSeek: ByteArray?,
266+
hasMore: Boolean
267+
): ByteArray {
268+
val bos = ByteArrayOutputStream()
269+
270+
// flags (bit0 = hasMore)
271+
bos.write(if (hasMore) 0x01 else 0x00)
272+
273+
// count (u32 BE)
274+
bos.write(u32be(entries.size))
275+
276+
// entries
277+
for ((k, v) in entries) {
278+
bos.write(u32be(k.size)); bos.write(k)
279+
bos.write(u32be(v.size)); bos.write(v)
280+
}
281+
282+
// nextSeek
283+
val ns = nextSeek ?: ByteArray(0)
284+
bos.write(u32be(ns.size))
285+
if (ns.isNotEmpty()) bos.write(ns)
286+
287+
return bos.toByteArray()
288+
}
289+
290+
// ----- utils -----
291+
private fun u32be(n: Int): ByteArray {
292+
val bb = ByteBuffer.allocate(4)
293+
bb.putInt(n) // big-endian by default
294+
return bb.array()
295+
}
296+
297+
private fun hex(b: ByteArray): String {
298+
val out = CharArray(b.size * 2)
299+
val h = "0123456789abcdef".toCharArray()
300+
var i = 0
301+
for (v in b) {
302+
val x = v.toInt() and 0xFF
303+
out[i++] = h[x ushr 4]; out[i++] = h[x and 0x0F]
304+
}
305+
return String(out)
306+
}
307+
308+
private fun unhex(s: String): ByteArray {
309+
require(s.length % 2 == 0) { "odd hex length" }
310+
val out = ByteArray(s.length / 2)
311+
var i = 0; var j = 0
312+
while (i < s.length) {
313+
val hi = Character.digit(s[i++], 16)
314+
val lo = Character.digit(s[i++], 16)
315+
out[j++] = ((hi shl 4) or lo).toByte()
316+
}
317+
return out
318+
}
319+
320+
companion object {
321+
private const val ANDROID_KEYSTORE = "AndroidKeyStore"
322+
private const val AES_GCM = "AES/GCM/NoPadding"
323+
}
324+
}

expo/ios/GnonativeModule.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ public class GnonativeModule: Module {
6060
}
6161
config.rootDir = self.appRootDir!
6262
config.tmpDir = self.tmpDir!
63+
config.nativeDB = NativeDBManager.shared
6364

6465
// On simulator we can't create an UDS, see comment below
6566
#if targetEnvironment(simulator)

0 commit comments

Comments
 (0)