Skip to content

Encrypt device reports using Android Keystore#3563

Open
avazirna wants to merge 9 commits intomasterfrom
vaz/encrypt-device-reports-with-android-keystore
Open

Encrypt device reports using Android Keystore#3563
avazirna wants to merge 9 commits intomasterfrom
vaz/encrypt-device-reports-with-android-keystore

Conversation

@avazirna
Copy link
Contributor

@avazirna avazirna commented Feb 24, 2026

Product Description

This PR is part of series related to the retirement of the CommCareSessionService. This PR covers the encryption of Device report files using an Android Keystore-backed AES key rather than a per-record randomly generated key stored in the database. This removes the need to persist raw key material in user storage.

Technical Summary

Previously, each DeviceReportRecord generated and stored its own AES key, then used it to encrypt the Device report file. This meant raw key bytes were serialized into the database alongside the file path.

This PR replaces that approach with a shared session key managed by the new SessionManager class, which wraps an Android Keystore-backed AES/CBC key (via AesKeyStoreHandler). The Keystore key generates a fresh IV on each encryption; the IV is prepended to the ciphertext and read back during decryption.

Key changes:

  • SessionManager — new Kotlin class providing a lazily-initialised Keystore-backed AES/CBC/PKCS7 key via getEncryptionKey() / getKeyTransformation()
  • DeviceReportRecord — removes aesKey field and per-record key generation; openOutputStream() now delegates to SessionManager
  • EncryptionIO.createFileOutputStream — updated signature to accept Key, transformation, and isKeyFromAndroidKeyStore; writes IV to stream when using a Keystore key
  • EncryptedFileBody.writeTo — reads IV from stream before decryption when isKeyFromAndroidKeyStore=true
  • FormUploadUtil.getDecryptCipher / createEncryptedFilePart — updated signatures to propagate transformation and IV; correct cipher.init overload used based on IV presence
  • All existing non-Keystore callers (SaveToDiskTask, HybridFileBackedSqlStorage, FormRecordProcessor, etc.) updated to pass null, false — no behaviour change for those paths

Note: Database migration to convert existing Device report files into this model will be submitted in a separate PR.

Safety Assurance

Safety story

Successfully tested locally. Steps taken were:

  • HTTP Toolkit was used to intercept log submission requests, which allowed its inspection and confirmation that they were being sent as expected.
  • It was also observed that the requests were successfully received by HQ, with the server responding with an HTTP code 201;

Automated test coverage

There are no tests covering this at the moment. I'm inclined to add them in a separate PR.

QA Plan

  • Submit a device report log and verify it is received and readable server-side

Labels and Review

  • Do we need to enhance the manual QA test coverage ? If yes, the "QA Note" label is set correctly
  • Does the PR introduce any major changes worth communicating ? If yes, the "Release Note" label is set and a "Release Note" is specified in PR description.
  • Risk label is set correctly
  • The set of people pinged as reviewers is appropriate for the level of risk of the change

@avazirna avazirna marked this pull request as ready for review February 24, 2026 17:23
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 24, 2026

📝 Walkthrough

Walkthrough

This pull request introduces a centralized encryption key management system using Android Keystore and updates encryption method signatures throughout the codebase. A new SessionManager class provides static accessors for a session encryption key, eliminating the need for local key storage. Encryption-related methods in EncryptionIO and FormUploadUtil are expanded to accept cipher transformation strings and boolean flags indicating whether keys originate from Android Keystore. IV (initialization vector) extraction and handling logic is added for keystore-backed keys. Multiple call sites are updated to pass additional parameters, and DeviceReportRecord is refactored to retrieve encryption credentials from SessionManager instead of maintaining a local key field. The overall encryption flow and control paths remain structurally similar, with logic adjustments to support dynamic transformations and IV handling.

Sequence Diagram

sequenceDiagram
    participant Client as Task/Widget
    participant SessionMgr as SessionManager
    participant EncryptIO as EncryptionIO
    participant Keystore as Android Keystore
    participant Storage as File Storage

    Client->>SessionMgr: getEncryptionKey()
    SessionMgr->>Keystore: retrieve/generate session_encryption_key
    Keystore-->>SessionMgr: Key
    SessionMgr-->>Client: Key

    Client->>SessionMgr: getKeyTransformation()
    SessionMgr-->>Client: transformation (e.g., "AES/GCM/NoPadding")

    Client->>EncryptIO: encryptFile(path, destPath, key, transformation, isKeyFromKeystore=true)
    
    alt isKeyFromKeystore is true
        EncryptIO->>Storage: open output stream to destPath
        EncryptIO->>Storage: write IV length
        EncryptIO->>Storage: write IV bytes
    end

    EncryptIO->>EncryptIO: initialize Cipher with transformation and key
    EncryptIO->>EncryptIO: wrap file stream in CipherOutputStream
    EncryptIO->>Storage: encrypt and write file data
    EncryptIO-->>Client: encryption complete

    Client->>EncryptIO: createFileOutputStream(filename, key, transformation, isKeyFromKeystore=true)
    EncryptIO->>EncryptIO: initialize Cipher with dynamic transformation
    
    alt isKeyFromKeystore is true
        EncryptIO->>Storage: read IV length
        EncryptIO->>Storage: read IV bytes
        EncryptIO->>EncryptIO: initialize Cipher with IvParameterSpec
    end

    EncryptIO-->>Client: CipherOutputStream
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 17.24% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Encrypt device reports using Android Keystore' accurately and concisely describes the main change: migrating device report encryption from per-record keys to an Android Keystore-backed shared key.
Description check ✅ Passed The PR description is comprehensive and well-structured, covering all required template sections with relevant technical details, rationale, and safety justification.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch vaz/encrypt-device-reports-with-android-keystore

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
app/src/org/commcare/android/javarosa/DeviceReportRecord.java (1)

20-22: ⚠️ Potential issue | 🟡 Minor

Update class Javadoc to reflect keystore-managed keys.

The comment still says the record stores the key, but the key now comes from SessionManager. Please update the description to avoid confusion.

Also applies to: 68-74

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/org/commcare/android/javarosa/DeviceReportRecord.java` around lines
20 - 22, Update the Javadoc for the DeviceReportRecord class to remove the claim
that the record "stores the key" and instead state that encryption keys are
managed by the keystore via SessionManager (i.e., the record only stores disk
location and metadata, not raw keys). Edit the class-level/comment blocks
referencing key storage (including the block at the top and the block around
lines 68-74) to clearly note that keys are keystore-managed and retrieved from
SessionManager when needed.
app/src/org/commcare/network/EncryptedFileBody.java (1)

52-73: ⚠️ Potential issue | 🟠 Major

Wrap FileInputStream in try-with-resources and validate IV reads.

The current code has three critical issues:

  1. FileInputStream at line 52 is never closed if an exception occurs before line 63, causing a resource leak.
  2. fis.read() can return -1 on EOF, which becomes 255 when masked (-1 & 0xFF), creating a bogus IV. Additionally, fis.read(iv, 0, ivLength) can perform a short read without throwing, leaving the IV partially uninitialized.
  3. FormUploadUtil.getDecryptCipher() returns null on cipher initialization failure (see line 89 in FormUploadUtil.java), but the code passes this null directly to CipherInputStream, causing an NPE.

Wrap the FileInputStream in try-with-resources, fully validate the IV length and contents, and explicitly check that the cipher was initialized before proceeding.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/org/commcare/network/EncryptedFileBody.java` around lines 52 - 73,
Wrap the FileInputStream (fis) in a try-with-resources block, and when
isKeyFromAndroidKeyStore is true validate the initial length byte from
fis.read() is not -1 before masking, reject zero or implausible ivLength, then
fully populate iv using a loop or DataInput.readFully-style logic and fail with
a clear IOException if the read is short or EOF occurs; call
FormUploadUtil.getDecryptCipher(key, transformation, iv) and explicitly check
the returned cipher is non-null (throw a descriptive IOException if null) before
creating CipherInputStream, then proceed to
StreamsUtil.writeFromInputToOutputUnmanaged(cis, sink.outputStream()) and
preserve the existing InputIOException and OutputIOException handling behavior.
app/src/org/commcare/utils/FormUploadUtil.java (1)

73-90: ⚠️ Potential issue | 🟠 Major

Avoid returning null on cipher init failure.

Returning null here leads to NPEs downstream. In EncryptedFileBody.java (line 62) and FormRecordProcessor.java (lines 74, 227, 262), the cipher is passed directly to CipherInputStream without null checks. If getDecryptCipher returns null, CipherInputStream will fail when attempting cipher operations. Fail fast with a clear exception instead, consistent with how other crypto operations in the codebase handle initialization failures (e.g., TemplatePrinterUtils, EncryptionIO).

🛠️ Proposed fix
-        } catch (NoSuchAlgorithmException |
-                NoSuchPaddingException | InvalidKeyException | InvalidAlgorithmParameterException e) {
-            e.printStackTrace();
-        }
-        return null;
+        } catch (NoSuchAlgorithmException |
+                NoSuchPaddingException | InvalidKeyException | InvalidAlgorithmParameterException e) {
+            Logger.log(LogTypes.TYPE_ERROR_CRYPTO, "Failed to init decrypt cipher: " + e.getMessage());
+            throw new IllegalStateException("Failed to init decrypt cipher", e);
+        }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/org/commcare/utils/FormUploadUtil.java` around lines 73 - 90, Replace
the current null-return behavior in getDecryptCipher(Key key, String
transformation, byte[] iv) with a fail-fast unchecked exception: catch the
NoSuchAlgorithmException, NoSuchPaddingException, InvalidKeyException and
InvalidAlgorithmParameterException and throw a new IllegalStateException (or a
project-standard unchecked crypto init exception) with a clear message like
"Failed to initialize decrypt Cipher" and include the caught exception as the
cause; remove the final return null so callers such as EncryptedFileBody and
FormRecordProcessor (which pass the result to CipherInputStream) never receive
null and will fail fast with the original cause.
🧹 Nitpick comments (1)
app/src/org/commcare/tasks/LogSubmissionTask.java (1)

369-380: Remove the now-unreachable getDecryptCipher method.

No call site for private static Cipher getDecryptCipher(SecretKeySpec key) exists in this class after the refactor. Removing it also allows the import javax.crypto.spec.SecretKeySpec at line 40 to be cleaned up.

🗑️ Proposed removal
-    private static Cipher getDecryptCipher(SecretKeySpec key) {
-        Cipher cipher;
-        try {
-            cipher = Cipher.getInstance("AES");
-            cipher.init(Cipher.DECRYPT_MODE, key);
-            return cipher;
-            //TODO: Something smart here;
-        } catch (NoSuchAlgorithmException | InvalidKeyException | NoSuchPaddingException e) {
-            e.printStackTrace();
-        }
-        return null;
-    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/org/commcare/tasks/LogSubmissionTask.java` around lines 369 - 380,
Remove the unused private static method getDecryptCipher(SecretKeySpec key) from
LogSubmissionTask.java since it is no longer referenced; after deleting the
method also remove the now-unused import javax.crypto.spec.SecretKeySpec (and
any other crypto imports that become unused) to clean up imports and avoid dead
code.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/src/org/commcare/models/encryption/EncryptionIO.java`:
- Around line 61-68: The code calls cipher.getIV() after cipher.init(...) and
then uses iv.length without checking for null, which can NPE if the
transformation has no IV; update the isKeyFromAndroidKeyStore branch (the
Cipher.getInstance / cipher.init / isKeyFromAndroidKeyStore block) to retrieve
iv = cipher.getIV(), validate iv != null before writing (fos.write(iv.length);
fos.write(iv);) and if iv is null throw or log a clear, descriptive
exception/message indicating the cipher/transformation does not provide an IV so
the keystore-based encryption cannot proceed.

In `@app/src/org/commcare/tasks/LogSubmissionTask.java`:
- Around line 295-304: The call to FormUploadUtil.createEncryptedFilePart in
LogSubmissionTask is passing SessionManager.getEncryptionKey() directly, which
can be null and cause silent fallback to unencrypted uploads; before calling
createEncryptedFilePart (in the method that builds parts inside
LogSubmissionTask), explicitly check SessionManager.getEncryptionKey() (and
optionally SessionManager.getKeyTransformation()) for null and fail fast: if the
key is null, log an error via the existing logger/process flow and abort the
submission (throw or return an error) rather than proceeding to add the part;
reference the exact call site where
parts.add(FormUploadUtil.createEncryptedFilePart(...)) is invoked to apply this
guard.
- Around line 295-304: Old device-report files copied by
UserSandboxUtils.migrateDeviceReports() are still in the pre-IV format, but
LogSubmissionTask.java now calls FormUploadUtil.createEncryptedFilePart(...)
with isKeyFromAndroidKeyStore = true which expects an IV-prefixed file; fix by
updating UserSandboxUtils.migrateDeviceReports() to detect legacy-format files,
decrypt them using the old decryption path, and re-encrypt/write them in the new
IV-prefixed format (so EncryptedFileBody.writeTo and
FormUploadUtil.createEncryptedFilePart remain correct), or alternatively
implement a clear legacy-decryption path in EncryptedFileBody.writeTo to handle
files missing the IV prefix and convert them to the new format during migration;
reference UserSandboxUtils.migrateDeviceReports(), EncryptedFileBody.writeTo,
and FormUploadUtil.createEncryptedFilePart/LogSubmissionTask.java when applying
the change.

---

Outside diff comments:
In `@app/src/org/commcare/android/javarosa/DeviceReportRecord.java`:
- Around line 20-22: Update the Javadoc for the DeviceReportRecord class to
remove the claim that the record "stores the key" and instead state that
encryption keys are managed by the keystore via SessionManager (i.e., the record
only stores disk location and metadata, not raw keys). Edit the
class-level/comment blocks referencing key storage (including the block at the
top and the block around lines 68-74) to clearly note that keys are
keystore-managed and retrieved from SessionManager when needed.

In `@app/src/org/commcare/network/EncryptedFileBody.java`:
- Around line 52-73: Wrap the FileInputStream (fis) in a try-with-resources
block, and when isKeyFromAndroidKeyStore is true validate the initial length
byte from fis.read() is not -1 before masking, reject zero or implausible
ivLength, then fully populate iv using a loop or DataInput.readFully-style logic
and fail with a clear IOException if the read is short or EOF occurs; call
FormUploadUtil.getDecryptCipher(key, transformation, iv) and explicitly check
the returned cipher is non-null (throw a descriptive IOException if null) before
creating CipherInputStream, then proceed to
StreamsUtil.writeFromInputToOutputUnmanaged(cis, sink.outputStream()) and
preserve the existing InputIOException and OutputIOException handling behavior.

In `@app/src/org/commcare/utils/FormUploadUtil.java`:
- Around line 73-90: Replace the current null-return behavior in
getDecryptCipher(Key key, String transformation, byte[] iv) with a fail-fast
unchecked exception: catch the NoSuchAlgorithmException, NoSuchPaddingException,
InvalidKeyException and InvalidAlgorithmParameterException and throw a new
IllegalStateException (or a project-standard unchecked crypto init exception)
with a clear message like "Failed to initialize decrypt Cipher" and include the
caught exception as the cause; remove the final return null so callers such as
EncryptedFileBody and FormRecordProcessor (which pass the result to
CipherInputStream) never receive null and will fail fast with the original
cause.

---

Nitpick comments:
In `@app/src/org/commcare/tasks/LogSubmissionTask.java`:
- Around line 369-380: Remove the unused private static method
getDecryptCipher(SecretKeySpec key) from LogSubmissionTask.java since it is no
longer referenced; after deleting the method also remove the now-unused import
javax.crypto.spec.SecretKeySpec (and any other crypto imports that become
unused) to clean up imports and avoid dead code.

ℹ️ Review info

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 072038d and 9937dc2.

📒 Files selected for processing (13)
  • app/src/org/commcare/activities/components/ImageCaptureProcessing.java
  • app/src/org/commcare/android/javarosa/DeviceReportRecord.java
  • app/src/org/commcare/models/FormRecordProcessor.java
  • app/src/org/commcare/models/database/HybridFileBackedSqlStorage.java
  • app/src/org/commcare/models/encryption/EncryptionIO.java
  • app/src/org/commcare/network/EncryptedFileBody.java
  • app/src/org/commcare/services/SessionManager.kt
  • app/src/org/commcare/tasks/DumpTask.java
  • app/src/org/commcare/tasks/FormRecordToFileTask.java
  • app/src/org/commcare/tasks/LogSubmissionTask.java
  • app/src/org/commcare/tasks/SaveToDiskTask.java
  • app/src/org/commcare/utils/FormUploadUtil.java
  • app/src/org/commcare/views/widgets/MediaWidget.java

Comment on lines +61 to +68
Cipher cipher = Cipher.getInstance(Objects.requireNonNullElse(transformation, "AES"));
cipher.init(Cipher.ENCRYPT_MODE, symmetricKey);
byte[] iv;
if (isKeyFromAndroidKeyStore) {
iv = cipher.getIV();
fos.write(iv.length);
fos.write(iv);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

cat -n app/src/org/commcare/models/encryption/EncryptionIO.java | sed -n '50,80p'

Repository: dimagi/commcare-android

Length of output: 1970


🏁 Script executed:

fd checkstyle.xml

Repository: dimagi/commcare-android

Length of output: 49


🏁 Script executed:

web_search
Cipher.getIV() return null Android Java documentation

Repository: dimagi/commcare-android

Length of output: 261


🏁 Script executed:

find . -name "*checkstyle*" -type f

Repository: dimagi/commcare-android

Length of output: 99


🌐 Web query:

Java Cipher.getIV() null return value documentation

💡 Result:

javax.crypto.Cipher.getIV() is documented to return null in these cases:

  • The cipher’s transformation/mode doesn’t use an IV (e.g., ECB).
  • The IV “has not yet been set” (e.g., before init(...), or when decrypting if you didn’t supply IV/parameters and the provider can’t/doesn’t derive them).

Oracle Javadoc (Java SE) states: it returns “the initialization vector in a new buffer, or null if this cipher does not use an IV, or if the IV has not yet been set.” [1] (same wording in older Java docs [2]).


🏁 Script executed:

cat ./.github/linters/checkstyle.xml | head -100

Repository: dimagi/commcare-android

Length of output: 5041


🏁 Script executed:

rg "createFileOutputStream" --type java -A 2 -B 2

Repository: dimagi/commcare-android

Length of output: 3275


🏁 Script executed:

# Check the line length of the proposed fix to ensure it complies with checkstyle (115 char limit)
cat > /tmp/check_fix.java << 'EOF'
byte[] iv;
if (isKeyFromAndroidKeyStore) {
    iv = cipher.getIV();
    if (iv == null || iv.length == 0) {
        throw new IllegalStateException(
                "Keystore encryption requires an IV-producing transformation");
    }
    fos.write(iv.length);
    fos.write(iv);
}
EOF
awk '{print NR": " $0 " [len:" length($0) "]"}' /tmp/check_fix.java

Repository: dimagi/commcare-android

Length of output: 467


🏁 Script executed:

rg "transformation" app/src/org/commcare/models/encryption/EncryptionIO.java -B 3 -A 3

Repository: dimagi/commcare-android

Length of output: 1372


Add null check before accessing IV from keystore cipher.

The code at line 65 calls cipher.getIV() without null-checking the result. Per Java documentation, Cipher.getIV() returns null when the transformation doesn't use an IV. Accessing iv.length on line 66 will cause a NullPointerException if this occurs. Add a guard with a clear error message before attempting to write the IV:

Proposed fix
byte[] iv;
if (isKeyFromAndroidKeyStore) {
    iv = cipher.getIV();
+   if (iv == null || iv.length == 0) {
+       throw new IllegalStateException(
+               "Keystore encryption requires an IV-producing transformation");
+   }
    fos.write(iv.length);
    fos.write(iv);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/org/commcare/models/encryption/EncryptionIO.java` around lines 61 -
68, The code calls cipher.getIV() after cipher.init(...) and then uses iv.length
without checking for null, which can NPE if the transformation has no IV; update
the isKeyFromAndroidKeyStore branch (the Cipher.getInstance / cipher.init /
isKeyFromAndroidKeyStore block) to retrieve iv = cipher.getIV(), validate iv !=
null before writing (fos.write(iv.length); fos.write(iv);) and if iv is null
throw or log a clear, descriptive exception/message indicating the
cipher/transformation does not provide an IV so the keystore-based encryption
cannot proceed.

Comment on lines +295 to +304
parts.add(
FormUploadUtil.createEncryptedFilePart(
"xml_submission_file",
f,
"text/xml",
SessionManager.getEncryptionKey(),
SessionManager.getKeyTransformation(),
true
)
);
Copy link
Contributor

@coderabbitai coderabbitai bot Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Guard against SessionManager.getEncryptionKey() returning null.

EncryptionIO.createFileOutputStream silently falls back to a plain, unencrypted FileOutputStream when symmetricKey == null. If SessionManager has not yet initialised its Keystore-backed key (e.g., first boot, Keystore failure), device report logs will be transmitted in plaintext to the server.

Add an explicit null check and fail fast rather than silently degrading encryption:

🛡️ Proposed guard
+        Key sessionKey = SessionManager.getEncryptionKey();
+        if (sessionKey == null) {
+            Logger.log(LogTypes.TYPE_ERROR_CRYPTO, "Aborting log submission: session encryption key unavailable.");
+            return LogSubmitOutcomes.ERROR;
+        }
         parts.add(
                 FormUploadUtil.createEncryptedFilePart(
                         "xml_submission_file",
                         f,
                         "text/xml",
-                        SessionManager.getEncryptionKey(),
+                        sessionKey,
                         SessionManager.getKeyTransformation(),
                         true
                 )
         );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
parts.add(
FormUploadUtil.createEncryptedFilePart(
"xml_submission_file",
f,
"text/xml",
SessionManager.getEncryptionKey(),
SessionManager.getKeyTransformation(),
true
)
);
Key sessionKey = SessionManager.getEncryptionKey();
if (sessionKey == null) {
Logger.log(LogTypes.TYPE_ERROR_CRYPTO, "Aborting log submission: session encryption key unavailable.");
return LogSubmitOutcomes.ERROR;
}
parts.add(
FormUploadUtil.createEncryptedFilePart(
"xml_submission_file",
f,
"text/xml",
sessionKey,
SessionManager.getKeyTransformation(),
true
)
);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/org/commcare/tasks/LogSubmissionTask.java` around lines 295 - 304,
The call to FormUploadUtil.createEncryptedFilePart in LogSubmissionTask is
passing SessionManager.getEncryptionKey() directly, which can be null and cause
silent fallback to unencrypted uploads; before calling createEncryptedFilePart
(in the method that builds parts inside LogSubmissionTask), explicitly check
SessionManager.getEncryptionKey() (and optionally
SessionManager.getKeyTransformation()) for null and fail fast: if the key is
null, log an error via the existing logger/process flow and abort the submission
(throw or return an error) rather than proceeding to add the part; reference the
exact call site where parts.add(FormUploadUtil.createEncryptedFilePart(...)) is
invoked to apply this guard.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Search for migration code and DeviceReportRecord references
fd -type f -name "*.java" -o -name "*.kt" | xargs grep -l "MigrationTo30\|DbMigration.*30\|migration.*v30\|DB_VERSION.*30" 2>/dev/null | head -20

Repository: dimagi/commcare-android

Length of output: 237


🏁 Script executed:

# Search for DeviceReportRecord and migration-related code
rg -n "DeviceReportRecord|aesKey|reencrypt|re.encrypt|MigrationTo30" --type java -A 3 -B 1 | head -200

Repository: dimagi/commcare-android

Length of output: 20486


🏁 Script executed:

# Find the migration class and version constants
rg -n "DB_VERSION|DbMigration|MigrationTo" --type java -A 2 | head -150

Repository: dimagi/commcare-android

Length of output: 6637


🏁 Script executed:

# Search for EncryptedFileBody to understand writeTo behavior
fd -type f -name "*.java" | xargs grep -l "EncryptedFileBody" | head -10

Repository: dimagi/commcare-android

Length of output: 237


🏁 Script executed:

# Read the migrateDeviceReports method from UserSandboxUtils
cat -n app/src/org/commcare/models/database/user/UserSandboxUtils.java | sed -n '97,106p'

Repository: dimagi/commcare-android

Length of output: 640


🏁 Script executed:

# Search for EncryptedFileBody implementation
fd -type f -name "*.java" | xargs grep -l "EncryptedFileBody" | head -5

Repository: dimagi/commcare-android

Length of output: 237


🏁 Script executed:

# Search for the actual v30 migration or staged migration
rg -n "version.*30|30.*migration|v30|V30|MigrationTo30" --type java -A 3 -B 1 | head -100

Repository: dimagi/commcare-android

Length of output: 49


🏁 Script executed:

# Check FormUploadUtil.createEncryptedFilePart to understand the isKeyFromAndroidKeyStore parameter
rg -n "createEncryptedFilePart|isKeyFromAndroidKeyStore" -A 5 --type java | head -150

Repository: dimagi/commcare-android

Length of output: 6722


🏁 Script executed:

# Look at DeviceReportRecord class definition
fd -type f -name "DeviceReportRecord.java" -exec cat -n {} \;

Repository: dimagi/commcare-android

Length of output: 237


🏁 Script executed:

# Check HybridFileBackedSqlStorage - how are per-record AES keys retrieved and used?
cat -n app/src/org/commcare/models/database/HybridFileBackedSqlStorage.java | sed -n '140,170p'

Repository: dimagi/commcare-android

Length of output: 1448


🏁 Script executed:

# Search for any legacy decryption or old key handling
rg -n "aesKey|per.record|legacy|old.*encrypt" app/src/org/commcare/models/database/ -A 2 -B 1

Repository: dimagi/commcare-android

Length of output: 6299


🏁 Script executed:

# Check DeviceReportRecord to see if it stores per-record encryption keys
fd -name "DeviceReportRecord.java" -exec cat {} \;

Repository: dimagi/commcare-android

Length of output: 297


🏁 Script executed:

# Check if there's version detection or migration flag in DeviceReportRecord
rg -n "class DeviceReportRecord|version|migrat" -A 10 app/src/org/commcare/android/javarosa/

Repository: dimagi/commcare-android

Length of output: 1100


🏁 Script executed:

# Read more context of LogSubmissionTask to see how file f is obtained
cat -n app/src/org/commcare/tasks/LogSubmissionTask.java | sed -n '280,310p'

Repository: dimagi/commcare-android

Length of output: 1306


🏁 Script executed:

# Search for where the DeviceReportRecord file is read/obtained before submission
rg -n "submitReports|getFilePath" app/src/org/commcare/tasks/LogSubmissionTask.java -A 5 -B 2

Repository: dimagi/commcare-android

Length of output: 1621


🏁 Script executed:

# Check if there's a v29→v30 migration being staged or planned
rg -n "USER_DB_VERSION|29|30" app/src/org/commcare/models/database/user/UserDatabaseSchemaManager.java -A 3 -B 3

Repository: dimagi/commcare-android

Length of output: 818


🏁 Script executed:

# Search for any code that removes aesKey from database or migration logic
rg -n "aesKey|AES_COL|removeColumn|dropColumn|migration" app/src/org/commcare/models/database/user/ -i

Repository: dimagi/commcare-android

Length of output: 3711


🏁 Script executed:

# Check UserDatabaseUpgrader to see how it handles DeviceReportRecord during migrations
rg -n "DeviceReportRecord|version.*29\|29.*version" app/src/org/commcare/models/database/user/UserDatabaseUpgrader.java -A 3 -B 1

Repository: dimagi/commcare-android

Length of output: 49


🏁 Script executed:

# Look at the key difference - when reading DeviceReportRecord for storage operations vs submission
rg -n "getInputStreamFromFile|SessionManager.getEncryptionKey|getEncryptionKey" app/src/org/commcare/ -B 2 -A 2

Repository: dimagi/commcare-android

Length of output: 5508


🏁 Script executed:

# Check the actual DB schema - does it still have AES_COL for DeviceReportRecord?
rg -n "DeviceReportRecord|AES_COL|FILE_COL" app/src/org/commcare/models/database/user/UserDatabaseSchemaManager.java -A 2 -B 1

Repository: dimagi/commcare-android

Length of output: 421


🏁 Script executed:

# Check if there's any indication of v30 migration code or staged changes
rg -n "version.*30|VERSION.*30|v30|V30|MigrationTo30" app/src/

Repository: dimagi/commcare-android

Length of output: 49


🏁 Script executed:

# Look at how DeviceReportRecord.write() actually writes files - is it really using SessionManager key?
cat -n app/src/org/commcare/android/javarosa/DeviceReportRecord.java | sed -n '65,85p'

Repository: dimagi/commcare-android

Length of output: 502


🏁 Script executed:

# Important: When old DeviceReportRecords are stored, what key do they use? Check serializeLogs
cat -n app/src/org/commcare/tasks/LogSubmissionTask.java | sed -n '175,235p'

Repository: dimagi/commcare-android

Length of output: 4315


🏁 Script executed:

# Check how the file is written in serializeLogs - what encryption context is used?
rg -n "serializeLogs|write.*record|getOutputFileStream" app/src/org/commcare/tasks/LogSubmissionTask.java -A 8 -B 2

Repository: dimagi/commcare-android

Length of output: 1780


🏁 Script executed:

# Check git history to see when LogSubmissionTask was changed to use SessionManager.getEncryptionKey with true flag
git log --oneline -20 app/src/org/commcare/tasks/LogSubmissionTask.java

Repository: dimagi/commcare-android

Length of output: 111


🏁 Script executed:

# Check if there's any migration step between v28 and v29 that handles DeviceReportRecord
rg -n "v28|v29|version.*28|version.*29" app/src/org/commcare/models/database/user/UserDatabaseUpgrader.java -A 5 -B 1

Repository: dimagi/commcare-android

Length of output: 49


🏁 Script executed:

# Check the old encryption scheme - was there a different path before?
rg -n "ECB|PKCS5Padding|AES/ECB" app/src/org/commcare/ --type java

Repository: dimagi/commcare-android

Length of output: 49


🏁 Script executed:

# Look for any code that handles pre-migration records or version detection
rg -n "isKeyFromAndroidKeyStore|version.*flag|record.*version" app/src/org/commcare/android/javarosa/ -A 3 -B 1

Repository: dimagi/commcare-android

Length of output: 49


🏁 Script executed:

# Check when the isKeyFromAndroidKeyStore=true parameter was added to LogSubmissionTask
git log -p --all -- app/src/org/commcare/tasks/LogSubmissionTask.java | grep -A 10 -B 10 "isKeyFromAndroidKeyStore" | head -80

Repository: dimagi/commcare-android

Length of output: 49


🏁 Script executed:

# Check if there are any other places where DeviceReportRecord encryption is handled
rg -n "DeviceReportRecord" app/src/org/commcare/ -A 3 | grep -i "encrypt\|key\|aes"

Repository: dimagi/commcare-android

Length of output: 1143


🏁 Script executed:

# Verify the actual behavior: check if HybridFileBackedSqlStorage has a special case for DeviceReportRecord
rg -n "class.*Storage.*DeviceReport\|extends.*HybridFileBackedSqlStorage" app/src/org/commcare/ -A 5

Repository: dimagi/commcare-android

Length of output: 49


🏁 Script executed:

# Check git history of DeviceReportRecord to see when isKeyFromAndroidKeyStore was introduced
git log -p --all -- app/src/org/commcare/android/javarosa/DeviceReportRecord.java | grep -A 15 -B 5 "openOutputStream\|isKeyFromAndroidKeyStore\|true" | head -100

Repository: dimagi/commcare-android

Length of output: 49


🏁 Script executed:

# Check SessionManager to see if getEncryptionKey was recently changed
git log -p --all -- app/src/org/commcare/services/SessionManager.kt | head -150

Repository: dimagi/commcare-android

Length of output: 49


🏁 Script executed:

# One final check: verify that the review comment's premise about per-record keys is correct
# by looking at the old DB schema or comments about it
rg -n "aesKey|per.record|per-record|key.*record" app/src/org/commcare/models/database/ -i -B 2 -A 2 | head -100

Repository: dimagi/commcare-android

Length of output: 10707


🏁 Script executed:

# Check if there's any fallback or try-catch logic when IV reading fails
cat -n app/src/org/commcare/network/EncryptedFileBody.java | sed -n '45,75p'

Repository: dimagi/commcare-android

Length of output: 1539


Pre-migration unsubmitted DeviceReportRecord files will fail to submit due to encryption format mismatch.

The submission code (lines 295–304) now passes isKeyFromAndroidKeyStore = true, instructing EncryptedFileBody.writeTo to read an IV-length byte + IV from the front of the file before decrypting. However, the migration in UserSandboxUtils.migrateDeviceReports() (lines 103–104) only copies files via FileUtil.copyFile() without re-encrypting them, despite its misleading comment claiming otherwise.

If old DeviceReportRecord files were previously encrypted without an IV prefix, the submission attempt will misinterpret the first bytes as an IV length, producing garbled decryption output and silent failure.

The migration should either:

  • Re-encrypt on-disk log files with the new encryption format, or
  • Detect and discard pre-migration records on upgrade, or
  • Provide a legacy decryption code path for files missing the IV prefix.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/org/commcare/tasks/LogSubmissionTask.java` around lines 295 - 304,
Old device-report files copied by UserSandboxUtils.migrateDeviceReports() are
still in the pre-IV format, but LogSubmissionTask.java now calls
FormUploadUtil.createEncryptedFilePart(...) with isKeyFromAndroidKeyStore = true
which expects an IV-prefixed file; fix by updating
UserSandboxUtils.migrateDeviceReports() to detect legacy-format files, decrypt
them using the old decryption path, and re-encrypt/write them in the new
IV-prefixed format (so EncryptedFileBody.writeTo and
FormUploadUtil.createEncryptedFilePart remain correct), or alternatively
implement a clear legacy-decryption path in EncryptedFileBody.writeTo to handle
files missing the IV prefix and convert them to the new format during migration;
reference UserSandboxUtils.migrateDeviceReports(), EncryptedFileBody.writeTo,
and FormUploadUtil.createEncryptedFilePart/LogSubmissionTask.java when applying
the change.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Db migration will be handled in a separate PR

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@avazirna, understood! I'll note that the migration handling will be addressed separately.

Would you like me to create a follow-up issue to track the migration work for pre-existing DeviceReportRecord files? This would ensure the encryption format compatibility concern is captured for the upcoming PR.

Also, I wanted to check: did you get a chance to review the first concern about adding a null check for SessionManager.getEncryptionKey() to prevent silent fallback to unencrypted uploads? That one could potentially be addressed in this PR since it's a simpler guard condition.


🧠 Learnings used
Learnt from: Jignesh-dimagi
Repo: dimagi/commcare-android PR: 0
File: :0-0
Timestamp: 2025-05-08T13:40:19.645Z
Learning: PR `#3048` introduces a comprehensive messaging system in the Connect feature, implementing secure encryption using AES-GCM for message content, proper channel management with consent flows, and a well-designed UI separation between sent and received messages with real-time notification integration.

Copy link
Contributor

@conroy-ricketts conroy-ricketts left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we update the safety story with specific steps taken to be confident in these changes? The current safety story description is very vague and does not give a good sense of how exactly this was tested successfully

@avazirna
Copy link
Contributor Author

Can we update the safety story with specific steps taken to be confident in these changes? The current safety story description is very vague and does not give a good sense of how exactly this was tested successfully

Very true, let me correct that.

OrangeAndGreen
OrangeAndGreen previously approved these changes Feb 25, 2026
Copy link
Contributor

@OrangeAndGreen OrangeAndGreen left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A couple suggestions about code arrangement but otherwise all looks good to me and I didn't encounter any issues in basic testing.

".html", ".rtf", ".txt", ".docx", ".xlsx", ".msg"};

public static Cipher getDecryptCipher(SecretKeySpec key) {
public static Cipher getDecryptCipher(Key key, String transformation, byte[] iv) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seeing 6 callers to this function and all but one pass "null, null" for the new parameters, thinking keeping the original function signature and having it call the new one would reduce the number of files touched by this PR as those callers could remain unchanged.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point.

public class EncryptionIO {

public static void encryptFile(String sourceFilePath, String destPath, SecretKeySpec symetricKey) throws IOException {
public static void encryptFile(String sourceFilePath, String destPath, SecretKeySpec symmetricKey, String transformation, boolean isKeyFromKeystore) throws IOException {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new parameters are always "null, false" in practice (2 callers), why expose the option?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this is because of the db migration, I've pushed that in a separate PR. But yes, unnecessary here.

public static OutputStream createFileOutputStream(String filename,
SecretKeySpec symetricKey)
throws FileNotFoundException {
Key symmetricKey,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to another comment, I suggest:

  • Keeping the existing function signature
  • Creating a new createKeyStoreBackedFileOuptStream with the extra transformation string
  • Have the above two both redirect to a private function with the new signature proposed above (including isKeyFromKeystore)

That saves unaffected code from needing to change

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think method overloading is sufficient to reduce blast radius here. I plan to refactor this in the migration PR but around the cipher initiation which is real reason why we need these extra fields.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we have the refactoring in this PR itself as it feels important for this review, I also think any migrations for this work should preceed this work or part of this PR itself.

@avazirna
Copy link
Contributor Author

Can we update the safety story with specific steps taken to be confident in these changes? The current safety story description is very vague and does not give a good sense of how exactly this was tested successfully

@conroy-ricketts I've added some notes around the testing.

@avazirna avazirna force-pushed the vaz/encrypt-device-reports-with-android-keystore branch 4 times, most recently from d5e3c6b to d592a94 Compare February 26, 2026 08:20
@avazirna avazirna force-pushed the vaz/encrypt-device-reports-with-android-keystore branch from d592a94 to 4c8d8c8 Compare February 26, 2026 08:22


public static MultipartBody.Part createEncryptedFilePart(String partName, File file, String contentType, SecretKeySpec key) {
public static MultipartBody.Part createEncryptedFilePart(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This may be another good place to do method overloading similarly to EncryptionIO.createFileOutputStream()

Copy link
Contributor

@shubham1g5 shubham1g5 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have not reviewed it fully but made some comments around the approach on this PR.

* Currently used to manage the session Android keystore backed encryption key,
*/
@RequiresApi(Build.VERSION_CODES.M)
class SessionManager {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@RequiresApi(Build.VERSION_CODES.M) is not required given that is already the minSdk we support.

* Currently used to manage the session Android keystore backed encryption key,
*/
@RequiresApi(Build.VERSION_CODES.M)
class SessionManager {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Think the naming here should not be coupled with any session manangement and we should rename this CommCareKeyManager or similar.

"xml_submission_file",
f,
"text/xml",
SessionManager.getEncryptionKey(),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

think we should fallback to the old mechanism using the slr.getKey() here if SessionManager.getEncryptionKey()==null. This is because we don't know for sure if the keystore encryption is supported on all devices used by CommCare and given CommCare is used on a much larger variety of devices, think we should be doing this implementation in a fail-safe way.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants