Skip to content
Draft
Show file tree
Hide file tree
Changes from 15 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions best-practices/MASTG-BEST-0x01.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
---
title: Use EncryptedFile for Sensitive Data in Internal Storage
alias: use-encrypted-file-for-sensitive-data-in-internal-storage
id: MASTG-BEST-0x01
platform: android
knowledge: [MASTG-KNOW-0x01, MASTG-KNOW-0041]
---

Use [`EncryptedFile`](https://developer.android.com/reference/androidx/security/crypto/EncryptedFile) from the [Jetpack Security library](https://developer.android.com/topic/security/data) when writing sensitive data to internal storage. `EncryptedFile` transparently encrypts file contents using [AES-256-GCM-HKDF-4KB](https://developers.google.com/tink/streaming-aead/aes_gcm_hkdf_streaming) before writing them to disk, providing transparent protection at rest.

```kotlin
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()

val encryptedFile = EncryptedFile.Builder(
context,
File(context.filesDir, "sensitive_data.bin"),
masterKey,
EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB
).build()

encryptedFile.openFileOutput().use { output ->
output.write("sensitive content".toByteArray())
}
```

The encryption key is generated and stored in the Android KeyStore, providing hardware-backed protection on supported devices. The resulting file is unreadable without the key, so even if an attacker gains access to the app's sandbox (for example, on a rooted device), the plaintext cannot be recovered without the key.

!!! warning

The **Jetpack Security crypto library**, including `EncryptedFile` and `EncryptedSharedPreferences`, has been [deprecated](https://developer.android.com/privacy-and-security/cryptography#jetpack_security_crypto_library). However, since an official replacement has not yet been released, we recommend using these classes until one is available.

## When EncryptedFile Is Not an Option

If you can't use `EncryptedFile` (for example, because of library constraints, minimum API level requirements, or native code), encrypt the data manually before writing it to disk.

The recommended approach on Android is to:

1. Generate or retrieve an AES key stored in the Android KeyStore (see @MASTG-KNOW-0043).
2. Encrypt the plaintext with `AES/GCM/NoPadding` using the [`Cipher`](https://developer.android.com/reference/javax/crypto/Cipher) API.
3. Prepend the initialization vector (IV) to the ciphertext and write the combined bytes to the file using any of the standard File APIs (see @MASTG-KNOW-0x01).
4. On read, extract the IV and decrypt with the same key.

```kotlin
// Generate or load a key from the Android KeyStore
val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
keyGenerator.init(
KeyGenParameterSpec.Builder("my_key_alias",
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.build()
)
val secretKey = keyGenerator.generateKey()

// Encrypt
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, secretKey)
val iv = cipher.iv
val ciphertext = cipher.doFinal("sensitive content".toByteArray())

// Write IV + ciphertext
val file = File(context.filesDir, "sensitive_data.bin")
FileOutputStream(file).use { fos ->
fos.write(iv)
fos.write(ciphertext)
}
```

Always use an authenticated encryption mode such as GCM.

## Native, NDK, and JNI Code

When file writes happen from native code, encrypt the data before passing it to the native layer, or use a well-audited native encryption library. Do not implement custom cryptographic primitives.

**Preferred - Encrypt in Java or Kotlin, pass ciphertext to native code:**

Encrypt the data on the Java or Kotlin side using Android KeyStore backed keys and `Cipher`, then pass only the resulting ciphertext byte array to the JNI layer for writing. This keeps key generation, storage, and access control within the Android security framework.

**Alternative - Use a native encryption library:**

If encryption must occur in native code, use a well-established library such as [Tink C++](https://developers.google.com/tink/tinkcrypto), [BoringSSL](https://boringssl.googlesource.com/boringssl/), OpenSSL, or a trusted encrypted storage library such as SQLCipher. Keys used by native code should still be generated, wrapped, or derived using Android KeyStore. They must not be hardcoded or stored in plaintext alongside the encrypted data.

**Real world example, Signal Android.**

Signal Android uses [SQLCipher for Android](https://github.com/signalapp/sqlcipher-android), a native encrypted SQLite implementation, for local database encryption. Signal's Java code generates a random 32 byte database secret, protects it with Android KeyStore through `KeyStoreHelper.seal(...)`, stores only the wrapped value in `SharedPreferences`, and later unwraps it with `KeyStoreHelper.unseal(...)` when opening the database. This behavior can be seen in Signal's [`DatabaseSecretProvider`](https://github.com/signalapp/Signal-Android/blob/main/app/src/main/java/org/thoughtcrime/securesms/crypto/DatabaseSecretProvider.java).

This is a production example of keeping key management in the Android framework while relying on a well-established native encryption layer for file backed storage. The important pattern is that the encryption secret is randomly generated and KeyStore protected, rather than being hardcoded or stored in plaintext beside the encrypted database.

Molly, a Signal fork, also [documents this design](https://github.com/mollyim/mollyim-android/wiki/Data-Encryption-At-Rest), noting that Signal stores contacts, chat history, and attachments in an SQLCipher database and wraps the database encryption key with Android KeyStore before storing it in `SharedPreferences`.
45 changes: 45 additions & 0 deletions demos/android/MASVS-STORAGE/MASTG-DEMO-0x01/MASTG-DEMO-0x01.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
---
platform: android
title: Using File APIs to Write Sensitive Data Unencrypted to the App Sandbox
id: MASTG-DEMO-0x01
code: [kotlin]
test: MASTG-TEST-0x01
tools: [MASTG-TOOL-0110]
---

## Sample

The code below uses Java File APIs to write to the app's internal storage:

- A password is stored **unencrypted** using `openFileOutput`.
- An API key is stored **unencrypted** using `FileOutputStream`.
- An encrypted secret is stored using `FileOutputStream` after AES/GCM encryption with an AndroidKeyStore-backed key.

{{ MastgTest.kt # MastgTest_reversed.java }}

## Steps

Let's run our @MASTG-TOOL-0110 rule against the sample code.

{{ ../../../../rules/mastg-android-unencrypted-internal-file-storage.yml }}

{{ run.sh }}

## Observation

The rule has identified 3 locations that indicate use of File APIs to write data to internal storage.

{{ output.txt }}

## Evaluation

The test fails because the app uses File APIs to write sensitive data to internal storage without encryption.

After reviewing the decompiled code at the locations specified in the output:

- Line 69: `openFileOutput("secret_token.txt", ...)` is followed by writing `password` in plaintext and no preceding `Cipher` calls, so the data is stored unencrypted.
- Lines 78-79: `new File(context.getFilesDir(), "api_key.txt")` is stored in `apiKeyFile` and passed to `new FileOutputStream(apiKeyFile)`, which is followed by writing `apiKey` in plaintext. Again, no preceding `Cipher` calls.

The test **passes** only for the `encrypted_data.bin` file written in lines 95-96: the data is encrypted using `AES/GCM` with a key generated in the AndroidKeyStore before being written to internal storage.

You can confirm the dynamic counterpart in @MASTG-DEMO-0x02.
82 changes: 82 additions & 0 deletions demos/android/MASVS-STORAGE/MASTG-DEMO-0x01/MastgTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package org.owasp.mastestapp

// SUMMARY: This sample demonstrates storing sensitive data unencrypted to the app's internal storage using the Java File APIs (openFileOutput and FileOutputStream), and also shows the correct approach using AES/GCM encryption with an AndroidKeyStore-backed key.

import android.content.Context
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Log
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey

class MastgTest(private val context: Context) {

private val password = "MyS3cr3tP4ssw0rd"
private val apiKey = "AKIAABCDEFGHIJKLMNOP"
private val encryptedSecret = "SensitiveDataToEncrypt"
private val keyAlias = "MastgTestKeyAlias"

private fun getOrCreateKey(): SecretKey {
val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
keyStore.getKey(keyAlias, null)?.let { return it as SecretKey }

val spec = KeyGenParameterSpec.Builder(
keyAlias,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(256)
.build()

return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
.apply { init(spec) }
.generateKey()
}

fun mastgTest(): String {
return try {
var result = ""

// FAIL: [MASTG-TEST-0x01] The app stores the password unencrypted using openFileOutput, exposing it to attackers with device access.
context.openFileOutput("secret_token.txt", Context.MODE_PRIVATE).use { output ->
output.write(password.toByteArray())
Log.d("FileAPIs", "Written unencrypted password to secret_token.txt")
}
result += "[FAIL]: Stored unencrypted password in secret_token.txt using openFileOutput.\n\n"

// FAIL: [MASTG-TEST-0x01] The app stores the API key unencrypted using FileOutputStream, making it readable by attackers with sandbox access.
val apiKeyFile = File(context.filesDir, "api_key.txt")
FileOutputStream(apiKeyFile).use { output ->
output.write(apiKey.toByteArray())
Log.d("FileAPIs", "Written unencrypted API key to api_key.txt")
}
result += "[FAIL]: Stored unencrypted API key in api_key.txt using FileOutputStream.\n\n"

// PASS: [MASTG-TEST-0x01] The app encrypts the data using AES/GCM with an AndroidKeyStore-backed key before writing it to internal storage.
val secretKey = getOrCreateKey()
val cipher = Cipher.getInstance("AES/GCM/NoPadding").apply {
init(Cipher.ENCRYPT_MODE, secretKey)
}
val iv = cipher.iv
val ciphertext = cipher.doFinal(encryptedSecret.toByteArray())
val encryptedFile = File(context.filesDir, "encrypted_data.bin")
FileOutputStream(encryptedFile).use { output ->
output.write(iv.size)
output.write(iv)
output.write(ciphertext)
Log.d("FileAPIs", "Written AES/GCM-encrypted data to encrypted_data.bin")
}
result += "[PASS]: Stored AES/GCM-encrypted data in encrypted_data.bin using an AndroidKeyStore-backed key.\n\n"

result
} catch (e: IOException) {
"Error during MastgTest: ${e.message ?: "Unknown error"}"
}
}
}
127 changes: 127 additions & 0 deletions demos/android/MASVS-STORAGE/MASTG-DEMO-0x01/MastgTest_reversed.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package org.owasp.mastestapp;

import android.content.Context;
import android.security.keystore.KeyGenParameterSpec;
import android.util.Log;
import androidx.autofill.HintConstants;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.security.InvalidAlgorithmParameterException;
import java.security.InvalidKeyException;
import java.security.Key;
import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import java.security.NoSuchProviderException;
import java.security.UnrecoverableKeyException;
import java.security.cert.CertificateException;
import javax.crypto.BadPaddingException;
import javax.crypto.Cipher;
import javax.crypto.IllegalBlockSizeException;
import javax.crypto.KeyGenerator;
import javax.crypto.NoSuchPaddingException;
import javax.crypto.SecretKey;
import kotlin.Metadata;
import kotlin.io.CloseableKt;
import kotlin.jvm.internal.Intrinsics;
import kotlin.text.Charsets;

/* compiled from: MastgTest.kt */
@Metadata(d1 = {"\u0000\"\n\u0002\u0018\u0002\n\u0002\u0010\u0000\n\u0000\n\u0002\u0018\u0002\n\u0002\b\u0003\n\u0002\u0010\u000e\n\u0002\b\u0004\n\u0002\u0018\u0002\n\u0002\b\u0002\b\u0007\u0018\u00002\u00020\u0001B\u000f\u0012\u0006\u0010\u0002\u001a\u00020\u0003¢\u0006\u0004\b\u0004\u0010\u0005J\b\u0010\u000b\u001a\u00020\fH\u0002J\u0006\u0010\r\u001a\u00020\u0007R\u000e\u0010\u0002\u001a\u00020\u0003X\u0082\u0004¢\u0006\u0002\n\u0000R\u000e\u0010\u0006\u001a\u00020\u0007X\u0082D¢\u0006\u0002\n\u0000R\u000e\u0010\b\u001a\u00020\u0007X\u0082D¢\u0006\u0002\n\u0000R\u000e\u0010\t\u001a\u00020\u0007X\u0082D¢\u0006\u0002\n\u0000R\u000e\u0010\n\u001a\u00020\u0007X\u0082D¢\u0006\u0002\n\u0000¨\u0006\u000e"}, d2 = {"Lorg/owasp/mastestapp/MastgTest;", "", "context", "Landroid/content/Context;", "<init>", "(Landroid/content/Context;)V", HintConstants.AUTOFILL_HINT_PASSWORD, "", "apiKey", "encryptedSecret", "keyAlias", "getOrCreateKey", "Ljavax/crypto/SecretKey;", "mastgTest", "app_debug"}, k = 1, mv = {2, 0, 0}, xi = 48)
/* loaded from: classes3.dex */
public final class MastgTest {
public static final int $stable = 8;
private final String apiKey;
private final Context context;
private final String encryptedSecret;
private final String keyAlias;
private final String password;

public MastgTest(Context context) {
Intrinsics.checkNotNullParameter(context, "context");
this.context = context;
this.password = "MyS3cr3tP4ssw0rd";
this.apiKey = "AKIAABCDEFGHIJKLMNOP";
this.encryptedSecret = "SensitiveDataToEncrypt";
this.keyAlias = "MastgTestKeyAlias";
}

private final SecretKey getOrCreateKey() throws NoSuchAlgorithmException, UnrecoverableKeyException, IOException, KeyStoreException, CertificateException, NoSuchProviderException, InvalidAlgorithmParameterException {
KeyStore keyStore = KeyStore.getInstance("AndroidKeyStore");
keyStore.load(null);
Key it = keyStore.getKey(this.keyAlias, null);
if (it != null) {
return (SecretKey) it;
}
KeyGenParameterSpec spec = new KeyGenParameterSpec.Builder(this.keyAlias, 3).setBlockModes("GCM").setEncryptionPaddings("NoPadding").setKeySize(256).build();
Intrinsics.checkNotNullExpressionValue(spec, "build(...)");
KeyGenerator $this$getOrCreateKey_u24lambda_u242 = KeyGenerator.getInstance("AES", "AndroidKeyStore");
$this$getOrCreateKey_u24lambda_u242.init(spec);
SecretKey secretKeyGenerateKey = $this$getOrCreateKey_u24lambda_u242.generateKey();
Intrinsics.checkNotNullExpressionValue(secretKeyGenerateKey, "generateKey(...)");
return secretKeyGenerateKey;
}

public final String mastgTest() throws BadPaddingException, NoSuchPaddingException, IllegalBlockSizeException, NoSuchAlgorithmException, UnrecoverableKeyException, InvalidKeyException, KeyStoreException, CertificateException, NoSuchProviderException, FileNotFoundException, InvalidAlgorithmParameterException {
try {
FileOutputStream fileOutputStreamOpenFileOutput = this.context.openFileOutput("secret_token.txt", 0);
try {
FileOutputStream output = fileOutputStreamOpenFileOutput;
byte[] bytes = this.password.getBytes(Charsets.UTF_8);
Intrinsics.checkNotNullExpressionValue(bytes, "getBytes(...)");
output.write(bytes);
Log.d("FileAPIs", "Written unencrypted password to secret_token.txt");
CloseableKt.closeFinally(fileOutputStreamOpenFileOutput, null);
String result = "[FAIL]: Stored unencrypted password in secret_token.txt using openFileOutput.\n\n";
File apiKeyFile = new File(this.context.getFilesDir(), "api_key.txt");
fileOutputStreamOpenFileOutput = new FileOutputStream(apiKeyFile);
try {
FileOutputStream output2 = fileOutputStreamOpenFileOutput;
byte[] bytes2 = this.apiKey.getBytes(Charsets.UTF_8);
Intrinsics.checkNotNullExpressionValue(bytes2, "getBytes(...)");
output2.write(bytes2);
Log.d("FileAPIs", "Written unencrypted API key to api_key.txt");
CloseableKt.closeFinally(fileOutputStreamOpenFileOutput, null);
String result2 = result + "[FAIL]: Stored unencrypted API key in api_key.txt using FileOutputStream.\n\n";
SecretKey secretKey = getOrCreateKey();
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(1, secretKey);
byte[] iv = cipher.getIV();
byte[] bytes3 = this.encryptedSecret.getBytes(Charsets.UTF_8);
Intrinsics.checkNotNullExpressionValue(bytes3, "getBytes(...)");
byte[] ciphertext = cipher.doFinal(bytes3);
File encryptedFile = new File(this.context.getFilesDir(), "encrypted_data.bin");
fileOutputStreamOpenFileOutput = new FileOutputStream(encryptedFile);
try {
FileOutputStream output3 = fileOutputStreamOpenFileOutput;
output3.write(iv.length);
output3.write(iv);
output3.write(ciphertext);
Log.d("FileAPIs", "Written AES/GCM-encrypted data to encrypted_data.bin");
CloseableKt.closeFinally(fileOutputStreamOpenFileOutput, null);
return result2 + "[PASS]: Stored AES/GCM-encrypted data in encrypted_data.bin using an AndroidKeyStore-backed key.\n\n";
} finally {
try {
throw th;
} finally {
}
}
} finally {
}
} finally {
try {
throw th;
} finally {
}
}
} catch (IOException e) {
String message = e.getMessage();
if (message == null) {
message = "Unknown error";
}
return "Error during MastgTest: " + message;
}
}
}
Loading
Loading