Abrarul8541 commited on
Commit
2da7fef
·
verified ·
1 Parent(s): cda6c39

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
+ }