Skip to content

feat: store camera URLs encrypted with an Android Keystore key (part of #16) - #54

Merged
compot-dev merged 2 commits into
v2from
feat/credential-store
Oct 12, 2026
Merged

compot-dev merged 2 commits into
v2from
feat/credential-store

Conversation

@compot-dev

Copy link
Copy Markdown
Collaborator

Part of #16. Transport stays unencrypted: RTSP and HTTP have no TLS, and RTSPS support would be a separate, larger task. #16 stays open for that.

Stacked on #48: it changes loadSettings()/saveSettings() as modified there. After #48 is merged, the base becomes v2.

⚠️ Do not merge before testing on a device (label awaiting device test). The Android Keystore cannot be exercised in JVM tests.

How it works

  • CredentialStore seals every camera URL before it goes to SharedPreferences: enc1: + hex(IV + AES-256-GCM ciphertext with its tag). It opens the value on load.
  • KeystoreCipher keeps the key in the Android Keystore (Android 6.0+); the key never leaves it.
    • On Android 5.x, or if the Keystore fails, URLs are stored as before, with a log message.
  • Upgrade: URLs stored unencrypted by older versions are re-saved encrypted on the first load.
  • Key lost (for example, data restored to another device): the value cannot be decrypted, and the slot becomes "not configured" instead of crashing the app.

README in all three languages: the security notes describe encrypted storage, and the #16 line in "Known limitations" now covers only the unencrypted transport.

Tests

CredentialStoreTest, 6 tests, using a JVM AES-GCM cipher with the same layout as KeystoreCipher:

  • round trip; the stored value contains neither the user name nor the password; a random IV;
  • older unencrypted values still load and are migrated;
  • another key, tampered or malformed data → empty slot;
  • no Keystore → stored as before;
  • null; hex helpers.

97 JVM tests on this branch (stacked on #48), all pass. assembleDebug and lintDebug pass.

Device checklist

  • Upgrading over the current version keeps all 4 camera URLs
  • After that, adb shell run-as com.openipc.decoder cat shared_prefs/settings.xml shows enc1:… values with no passwords (debug build)
  • Editing and saving a URL, Home → back, a full restart: the URL is kept
  • Android 5.x (if available): works as before, with unencrypted values

@compot-dev compot-dev added the awaiting device test PR ready, merge only after testing on a real device label Oct 12, 2026
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Encrypt stored camera URLs with an Android Keystore key

✨ Enhancement 🧪 Tests 📝 Documentation 🕐 20-40 Minutes

Grey Divider

AI Description

• Encrypt camera URLs at rest on Android 6.0+ without changing RTSP or HTTP transport.
• Migrate existing plain URLs on load and leave undecryptable slots unconfigured.
• Preserve plaintext storage when Keystore is unavailable; add tests and update security guidance.
Diagram

graph TD
  D["Decoder settings"] -->|seal or open| C["Credential store"] -->|encrypt or decrypt| K["Keystore cipher"] -->|key operations| A["Android Keystore"]
  C -->|enc1 values| P[("SharedPreferences")]
  P -->|stored values| C
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Encrypt all preferences with a library
  • ➕ Centralizes encryption of preference values.
  • ➖ Adds a dependency and broadens migration beyond camera URLs.
  • ➖ Still needs a policy for Android 5.x and unavailable keys.
2. Fail closed when encryption is unavailable
  • ➕ Never writes new camera credentials in plaintext on supported devices.
  • ➖ Prevents saving URLs on devices with Keystore failures and changes existing compatibility behavior.

Recommendation: The scoped CredentialStore approach fits this change: it protects the sensitive preference without migrating unrelated settings. Retain the compatibility fallback only if plaintext storage is an accepted product policy, and complete the stated device tests before merging.

Files changed (7) +317 / -10

Enhancement (3) +196 / -4
CredentialStore.javaAdd a versioned camera URL credential store +105/-0

Add a versioned camera URL credential store

• Seals URLs as enc1-prefixed hex values and opens them on load. Identifies legacy plaintext values for migration, while returning an unconfigured value when sealed data cannot be opened.

android/app/src/main/java/com/openipc/decoder/CredentialStore.java

Decoder.javaApply credential storage to camera settings +16/-4

Apply credential storage to camera settings

• Seals URLs during legacy-setting migration, shared-link handling, and normal saves. Opens stored URLs on load and re-saves legacy plaintext values when encryption is available.

android/app/src/main/java/com/openipc/decoder/Decoder.java

KeystoreCipher.javaAdd Android Keystore-backed AES-GCM cipher +75/-0

Add Android Keystore-backed AES-GCM cipher

• Creates or retrieves an AES-256 key from Android Keystore. Encrypts URLs with a generated IV and stores the IV alongside authenticated ciphertext for later decryption.

android/app/src/main/java/com/openipc/decoder/KeystoreCipher.java

Tests (1) +115 / -0
CredentialStoreTest.javaTest credential storage and failure behavior +115/-0

Test credential storage and failure behavior

• Uses an in-memory AES-GCM cipher to test round trips, randomized ciphertext, legacy values, unavailable keys, malformed data, nulls, and hex encoding. Android Keystore behavior remains outside JVM test coverage.

android/app/src/test/java/com/openipc/decoder/CredentialStoreTest.java

Documentation (3) +6 / -6
README.mdClarify encrypted storage and unencrypted transport +2/-2

Clarify encrypted storage and unencrypted transport

• Documents Keystore-backed URL storage on Android 6.0+ and the Android 5.x exception. Narrows the remaining credential limitation to RTSP/HTTP transport.

README.md

README.ru.mdUpdate Russian security guidance +2/-2

Update Russian security guidance

• Documents encrypted camera URL storage and the Android 5.x exception. Clarifies that the outstanding limitation is unencrypted transport.

README.ru.md

README.zh-CN.mdUpdate Chinese security guidance +2/-2

Update Chinese security guidance

• Documents encrypted camera URL storage and the Android 5.x exception. Clarifies that the outstanding limitation is unencrypted transport.

README.zh-CN.md

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (3) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Android 5 devices still expose passwords 📎 Requirement gap ⛨ Security
Description
CredentialStore.seal() returns the original URL when no Keystore cipher exists or encryption
throws. Android versions below 6.0, Keystore initialization failures, and individual encryption
failures therefore reach every camera URL write path and leave credentials in plain text.
Code

android/app/src/main/java/com/openipc/decoder/CredentialStore.java[R53-55]

+        if (url == null || cipher == null) return url;
+        try {
+            return PREFIX + toHex(cipher.encrypt(url.getBytes(StandardCharsets.UTF_8)));
Evidence
The new credential store explicitly returns plaintext when cipher is null, and also returns the
original URL after encryption errors. Its callers pass that result directly to SharedPreferences, so
the PR's fallback path fails the requirement to protect stored credentials.

Protect stored camera credentials
android/app/src/main/java/com/openipc/decoder/CredentialStore.java[37-59]
android/app/src/main/java/com/openipc/decoder/Decoder.java[390-395]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Camera URLs remain unencrypted when the Android Keystore is unavailable or an encryption operation fails, violating the requirement that credentials not be stored in plain-text SharedPreferences.
## Fix Focus Areas
- android/app/src/main/java/com/openipc/decoder/CredentialStore.java[37-59]
- android/app/src/main/java/com/openipc/decoder/Decoder.java[390-395]
## Recommended Fix
Do not return or persist the original credential-bearing URL when encryption is unavailable or fails. Block the save, surface a safe configuration error, or use another protected storage mechanism; preserve existing plaintext values only long enough to migrate them successfully.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Editing one camera erases other camera URLs ✓ Resolved
Description
loadSettings() replaces an encrypted URL with DEFAULT_URL when credentials.open() cannot
decrypt it, and saveSettings() writes that default back to every slot. If Keystore access is
temporarily unavailable, saving an unrelated camera overwrites the original encrypted values, so
those cameras cannot recover when access returns.
Code

android/app/src/main/java/com/openipc/decoder/Decoder.java[344]

+            mHosts[i] = url != null ? url : DEFAULT_URL;
Evidence
A sealed value returns null when the cipher is unavailable or decryption fails. The new load path
converts null to the default URL; the URL editor then invokes a save that persists every in-memory
slot, including defaults substituted for unopened values.

android/app/src/main/java/com/openipc/decoder/CredentialStore.java[67-76]
android/app/src/main/java/com/openipc/decoder/Decoder.java[342-349]
android/app/src/main/java/com/openipc/decoder/MenuManager.java[285-301]
android/app/src/main/java/com/openipc/decoder/Decoder.java[388-395]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
An encrypted URL that cannot be opened is replaced in memory with a default, then saving any camera writes that default over the original encrypted value.
## Fix Focus Areas
- android/app/src/main/java/com/openipc/decoder/Decoder.java[342-349]
- android/app/src/main/java/com/openipc/decoder/Decoder.java[388-395]
## Recommended Fix
Track which stored URLs could not be opened and preserve their original preference values when saving other slots. Replace an unopened value only when the user explicitly edits that slot.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

3. RTSPS camera sessions remain unencrypted 📎 Requirement gap ⛨ Security
Description
RtspSession opens a regular java.net.Socket and does not create a TLS socket for camera
connections. Because RTSPS is still unsupported, camera traffic and any authentication exchange
remain exposed on networks that can observe the connection.
Code

README.md[99]

+> - **Credentials are sent in clear text** over plain RTSP/HTTP (Basic auth unless the camera asks for Digest); RTSPS is not supported — [#16](https://github.com/OpenIPC/decoder/issues/16)
Evidence
The changed README explicitly states that plain RTSP/HTTP transport remains in use and RTSPS is
unsupported. The networking implementation uses a regular socket rather than TLS, confirming that
the checklist's RTSPS requirement is still unmet; this behavior predates the PR and is documented
rather than introduced here.

Protect RTSP authentication and transport
README.md[99-99]
android/app/src/main/java/com/openipc/decoder/RtspSession.java[202-220]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Camera connections support plain RTSP and HTTP but do not provide RTSPS, leaving credential-bearing camera traffic unencrypted on the network.
## Fix Focus Areas
- android/app/src/main/java/com/openipc/decoder/RtspSession.java[202-220]
- README.md[99-99]
## Recommended Fix
Recognize `rtsps://` URLs and establish an `SSLSocket` or equivalent TLS connection before sending RTSP authentication and requests. Validate the TLS handshake and certificate according to the app's trust policy, while retaining Digest authentication where required.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


4. WebUI passwords cross the network in clear text 📎 Requirement gap ⛨ Security
Description
The WebUI supplies URL credentials through the HTTP authentication callback while the application
permits clear-text traffic and loads plain HTTP URLs. Any credential-bearing WebUI connection using
HTTP can therefore transmit the username and password without transport encryption, and this path is
unchanged by the PR.
Code

README.md[99]

+> - **Credentials are sent in clear text** over plain RTSP/HTTP (Basic auth unless the camera asks for Digest); RTSPS is not supported — [#16](https://github.com/OpenIPC/decoder/issues/16)
Evidence
The changed README states that credentials are transmitted over unencrypted HTTP. The WebUI
authentication callback passes credentials to the HTTP handler, while the manifest enables
clear-text traffic, confirming the checklist violation; these implementation paths are pre-existing
and unaffected by the credential-at-rest changes.

Prevent clear-text WebUI credential transmission
README.md[99-99]
android/app/src/main/java/com/openipc/decoder/MenuManager.java[337-367]
android/app/src/main/AndroidManifest.xml[15-20]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
WebUI HTTP Basic credentials can be sent over clear-text HTTP because the application permits clear-text traffic and does not require HTTPS for credential-bearing connections.
## Fix Focus Areas
- android/app/src/main/AndroidManifest.xml[15-20]
- android/app/src/main/java/com/openipc/decoder/MenuManager.java[337-367]
- README.md[99-99]
## Recommended Fix
Require HTTPS for WebUI URLs carrying credentials, reject or safely upgrade clear-text HTTP connections, and remove the broad clear-text traffic permission unless a narrowly scoped non-credential exception is required. Ensure the authentication callback is used only after a TLS connection is established.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Tip of the day
💡 Did you know, you can commit Qodo's fix in one click with committable suggestions (GitHub & GitLab)

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread android/app/src/main/java/com/openipc/decoder/CredentialStore.java
Comment thread README.md
Comment thread README.md
Comment thread android/app/src/main/java/com/openipc/decoder/Decoder.java
@compot-dev
compot-dev force-pushed the feat/credential-store branch from 43fa9d3 to 6b9a2a4 Compare October 12, 2026 14:27
Part of #16 (transport remains plain RTSP/HTTP):

- CredentialStore seals each camera URL as "enc1:" + hex(IV + AES-256-GCM
  ciphertext) before it goes to SharedPreferences, and opens it on load.
- KeystoreCipher keeps the key in the Android Keystore (Android 6.0+); it
  never leaves the Keystore. On Android 5.x, or if the Keystore fails,
  URLs are stored as before.
- Plain URLs from older versions are re-saved encrypted on the first
  load. A value that cannot be decrypted (key gone, e.g. data restored to
  another device) leaves the slot unconfigured instead of crashing.
- README (EN/RU/ZH): security notes updated; #16 now covers only the
  unencrypted transport.

Tests: round trip with no password in the stored value, random IV,
legacy plain values and their migration, another key / tampered /
malformed values, no Keystore, null handling, hex helpers.
…ited

If a sealed URL could not be opened (e.g. the Keystore was temporarily
unavailable), its slot showed the placeholder, and saving any other camera
wrote the placeholder over the encrypted value. The original stored value
is now kept for such slots unless the user edits that slot.
compot-dev added a commit that referenced this pull request Oct 12, 2026
RtspSessionTest iterated server.requests (a synchronized list) while the
server thread was still appending, which threw ConcurrentModification-
Exception now and then (failed once in CI on #54). Tests now read a copy
taken under the list's lock.
@compot-dev
compot-dev force-pushed the feat/credential-store branch from 6b9a2a4 to feeb164 Compare October 12, 2026 14:49
@compot-dev
compot-dev changed the base branch from fix/decoder-lifecycle to v2 October 12, 2026 14:57
@compot-dev

Copy link
Copy Markdown
Collaborator Author

Device-tested (#53, run 4): after an upgrade from v2 the stored URLs are kept and migrated to enc1:, the key survives a second upgrade, and the camera connects with the decrypted credentials. CI is green. The 3 remaining Qodo requirement gaps are out of scope by design (answered above): the plain-text fallback for Android 5 and Keystore failures and RTSPS stay tracked in #16, and WebUI transport is a separate topic.

@compot-dev compot-dev removed the awaiting device test PR ready, merge only after testing on a real device label Oct 12, 2026
@compot-dev
compot-dev merged commit 2f467f3 into v2 Oct 12, 2026
4 checks passed
@compot-dev
compot-dev deleted the feat/credential-store branch October 13, 2026 17:15
compot-dev added a commit that referenced this pull request Oct 15, 2026
RtspSessionTest iterated server.requests (a synchronized list) while the
server thread was still appending, which threw ConcurrentModification-
Exception now and then (failed once in CI on #54). Tests now read a copy
taken under the list's lock.
compot-dev added a commit that referenced this pull request Oct 15, 2026
feat: store camera URLs encrypted with an Android Keystore key (part of #16)
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.

1 participant