Coverage Summary for Class: EncryptedBackupCodec (com.georgeci.moneysurfer.data.backup.crypto)
| Class |
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
| EncryptedBackupCodec |
100%
(6/6)
|
87.5%
(14/16)
|
100%
(37/37)
|
98.9%
(188/190)
|
| EncryptedBackupCodec$DerivedKeys |
100%
(1/1)
|
|
100%
(1/1)
|
100%
(12/12)
|
| Total |
100%
(7/7)
|
87.5%
(14/16)
|
100%
(38/38)
|
99%
(200/202)
|
package com.georgeci.moneysurfer.data.backup.crypto
import com.georgeci.moneysurfer.domain.backup.BackupError
/**
* Sealed-blob format behind `payload.enc` (scheme
* [com.georgeci.moneysurfer.domain.backup.BackupEncryptionInfo.SCHEME_V1]):
*
* ```
* magic 8 bytes "MSBKENC1"
* salt 16 bytes PBKDF2 salt
* iv 16 bytes AES-CTR initial counter block
* ciphertext N bytes AES-256-CTR of the inner backup ZIP
* mac 32 bytes HMAC-SHA256 over magic ‖ salt ‖ iv ‖ ciphertext
* ```
*
* Encrypt-then-MAC: [open] verifies the MAC over the full prefix before
* touching the ciphertext, so a tampered or wrong-passphrase blob is rejected
* without ever feeding attacker-controlled plaintext further down the import
* pipeline. PBKDF2 derives 64 bytes — first half AES key, second half MAC key.
*/
internal object EncryptedBackupCodec {
fun seal(plaintext: ByteArray, passphrase: String): ByteArray {
val salt = BackupCrypto.randomBytes(SALT_SIZE)
val iv = BackupCrypto.randomBytes(IV_SIZE)
val keys = deriveKeys(passphrase, salt)
val ciphertext = BackupCrypto.aesCtr(keys.aesKey, iv, plaintext)
val macInput = MAGIC + salt + iv + ciphertext
val mac = BackupCrypto.hmacSha256(keys.macKey, macInput)
return macInput + mac
}
fun open(blob: ByteArray, passphrase: String): ByteArray {
if (blob.size < HEADER_SIZE + MAC_SIZE || !blob.startsWith(MAGIC)) {
throw BackupError.InvalidArchive("Encrypted payload has an unrecognised format")
}
val salt = blob.copyOfRange(MAGIC_SIZE, MAGIC_SIZE + SALT_SIZE)
val iv = blob.copyOfRange(MAGIC_SIZE + SALT_SIZE, HEADER_SIZE)
val keys = deriveKeys(passphrase, salt)
val macInput = blob.copyOfRange(0, blob.size - MAC_SIZE)
val mac = blob.copyOfRange(blob.size - MAC_SIZE, blob.size)
val expectedMac = BackupCrypto.hmacSha256(keys.macKey, macInput)
if (!constantTimeEquals(expectedMac, mac)) {
throw BackupError.WrongPassphrase
}
val ciphertext = blob.copyOfRange(HEADER_SIZE, blob.size - MAC_SIZE)
return BackupCrypto.aesCtr(keys.aesKey, iv, ciphertext)
}
private fun deriveKeys(passphrase: String, salt: ByteArray): DerivedKeys {
val derived = BackupCrypto.pbkdf2Sha256(
passphrase = passphrase,
salt = salt,
iterations = PBKDF2_ITERATIONS,
keyLengthBytes = KEY_SIZE * 2,
)
return DerivedKeys(
aesKey = derived.copyOfRange(0, KEY_SIZE),
macKey = derived.copyOfRange(KEY_SIZE, KEY_SIZE * 2),
)
}
private class DerivedKeys(val aesKey: ByteArray, val macKey: ByteArray)
private fun ByteArray.startsWith(prefix: ByteArray): Boolean {
if (size < prefix.size) return false
for (i in prefix.indices) {
if (this[i] != prefix[i]) return false
}
return true
}
private fun constantTimeEquals(a: ByteArray, b: ByteArray): Boolean {
if (a.size != b.size) return false
var diff = 0
for (i in a.indices) {
diff = diff or (a[i].toInt() xor b[i].toInt())
}
return diff == 0
}
val MAGIC: ByteArray = "MSBKENC1".encodeToByteArray()
private const val MAGIC_SIZE = 8
private const val SALT_SIZE = 16
private const val IV_SIZE = 16
private const val KEY_SIZE = 32
private const val MAC_SIZE = 32
private const val HEADER_SIZE = MAGIC_SIZE + SALT_SIZE + IV_SIZE
/** OWASP-recommended order of magnitude for PBKDF2-HMAC-SHA256 on mobile. */
const val PBKDF2_ITERATIONS: Int = 210_000
}