|
| 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 | +} |
0 commit comments