Phase 2: passphrase-encrypted backup and restore (PBKDF2 + AES-256-GCM)
Browse files
app/src/main/java/io/github/abrarul/modularai/security/Backup.kt
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package io.github.abrarul.modularai.security
|
| 2 |
+
|
| 3 |
+
import android.content.Context
|
| 4 |
+
import io.github.abrarul.modularai.data.AppDb
|
| 5 |
+
import io.github.abrarul.modularai.data.Capture
|
| 6 |
+
import kotlinx.coroutines.Dispatchers
|
| 7 |
+
import kotlinx.coroutines.withContext
|
| 8 |
+
import org.json.JSONArray
|
| 9 |
+
import org.json.JSONObject
|
| 10 |
+
import java.io.File
|
| 11 |
+
import java.security.SecureRandom
|
| 12 |
+
import javax.crypto.AEADBadTagException
|
| 13 |
+
import javax.crypto.Cipher
|
| 14 |
+
import javax.crypto.SecretKeyFactory
|
| 15 |
+
import javax.crypto.spec.GCMParameterSpec
|
| 16 |
+
import javax.crypto.spec.PBEKeySpec
|
| 17 |
+
import javax.crypto.spec.SecretKeySpec
|
| 18 |
+
|
| 19 |
+
/** File layout: magic "MAB1" | salt(16) | iv(12) | AES-256-GCM(JSON). Key = PBKDF2-HMAC-SHA256(passphrase). */
|
| 20 |
+
object Backup {
|
| 21 |
+
private val MAGIC = "MAB1".toByteArray()
|
| 22 |
+
private const val ITER = 210_000
|
| 23 |
+
|
| 24 |
+
private fun key(pass: String, salt: ByteArray): SecretKeySpec {
|
| 25 |
+
val f = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256")
|
| 26 |
+
return SecretKeySpec(f.generateSecret(PBEKeySpec(pass.toCharArray(), salt, ITER, 256)).encoded, "AES")
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
suspend fun export(ctx: Context, pass: String): File = withContext(Dispatchers.IO) {
|
| 30 |
+
require(pass.length >= 8) { "Passphrase must be at least 8 characters" }
|
| 31 |
+
val arr = JSONArray()
|
| 32 |
+
AppDb.get(ctx).captures().list().forEach {
|
| 33 |
+
arr.put(JSONObject().put("t", it.createdAt).put("text", Vault.decrypt(it.cipher)))
|
| 34 |
+
}
|
| 35 |
+
val salt = ByteArray(16).also { SecureRandom().nextBytes(it) }
|
| 36 |
+
val c = Cipher.getInstance("AES/GCM/NoPadding")
|
| 37 |
+
c.init(Cipher.ENCRYPT_MODE, key(pass, salt))
|
| 38 |
+
val body = c.doFinal(arr.toString().toByteArray(Charsets.UTF_8))
|
| 39 |
+
val dir = ctx.getExternalFilesDir(null) ?: ctx.filesDir
|
| 40 |
+
File(dir, "backup-${System.currentTimeMillis()}.mab").also { it.writeBytes(MAGIC + salt + c.iv + body) }
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
suspend fun restore(ctx: Context, file: File, pass: String): Int = withContext(Dispatchers.IO) {
|
| 44 |
+
val raw = file.readBytes()
|
| 45 |
+
require(raw.size > 4 + 16 + 12 + 16 && raw.copyOfRange(0, 4).contentEquals(MAGIC)) { "Not a ModularAI backup file" }
|
| 46 |
+
val salt = raw.copyOfRange(4, 20)
|
| 47 |
+
val iv = raw.copyOfRange(20, 32)
|
| 48 |
+
val c = Cipher.getInstance("AES/GCM/NoPadding")
|
| 49 |
+
c.init(Cipher.DECRYPT_MODE, key(pass, salt), GCMParameterSpec(128, iv))
|
| 50 |
+
val json = try {
|
| 51 |
+
String(c.doFinal(raw, 32, raw.size - 32), Charsets.UTF_8)
|
| 52 |
+
} catch (_: AEADBadTagException) {
|
| 53 |
+
error("Wrong passphrase or damaged file")
|
| 54 |
+
}
|
| 55 |
+
val arr = JSONArray(json)
|
| 56 |
+
val dao = AppDb.get(ctx).captures()
|
| 57 |
+
for (i in 0 until arr.length()) {
|
| 58 |
+
val o = arr.getJSONObject(i)
|
| 59 |
+
dao.insert(Capture(createdAt = o.getLong("t"), cipher = Vault.encrypt(o.getString("text"))))
|
| 60 |
+
}
|
| 61 |
+
arr.length()
|
| 62 |
+
}
|
| 63 |
+
}
|