From ba10d62066b3b9d91bd57d02b764f8cb7d829234 Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Mon, 12 Oct 2026 13:07:31 +0000 Subject: [PATCH 1/2] feat: store camera URLs encrypted with an Android Keystore key 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. --- README.md | 4 +- README.ru.md | 4 +- README.zh-CN.md | 4 +- .../com/openipc/decoder/CredentialStore.java | 105 ++++++++++++++++ .../java/com/openipc/decoder/Decoder.java | 20 ++- .../com/openipc/decoder/KeystoreCipher.java | 75 ++++++++++++ .../openipc/decoder/CredentialStoreTest.java | 115 ++++++++++++++++++ 7 files changed, 317 insertions(+), 10 deletions(-) create mode 100644 android/app/src/main/java/com/openipc/decoder/CredentialStore.java create mode 100644 android/app/src/main/java/com/openipc/decoder/KeystoreCipher.java create mode 100644 android/app/src/test/java/com/openipc/decoder/CredentialStoreTest.java diff --git a/README.md b/README.md index df41e509..70afa5e9 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,7 @@ Shows all configured slots in a 2×2 grid. Each cell has its own connection and > - **Only 4 camera slots** — [#13](https://github.com/OpenIPC/decoder/issues/13) > - **Audio formats not supported:** MP4A-LATM, Opus, G.726, and AAC frames split across RTP packets — [#14](https://github.com/OpenIPC/decoder/issues/14) > - **Possible dependency on Google services (GAPPS)** on some devices; under investigation, so disable GAPPS with care — [#15](https://github.com/OpenIPC/decoder/issues/15) -> - **Credentials are stored and sent in clear text** (plain RTSP/HTTP; Basic auth is sent unless the camera asks for Digest) — [#16](https://github.com/OpenIPC/decoder/issues/16) +> - **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) > > Known bugs are tracked separately under the [`bug` label](https://github.com/OpenIPC/decoder/issues?q=is%3Aissue+is%3Aopen+label%3Abug). @@ -151,7 +151,7 @@ Network, video decoding and audio run on separate threads, so a slow decoder nev - Credentials are taken from the URL and sent in the `Authorization` header: Basic first, Digest when the camera asks for it. They are not included in the RTSP request line. - Plain RTSP and HTTP are not encrypted. Use the app only on trusted networks, see [#16](https://github.com/OpenIPC/decoder/issues/16). -- Camera URLs, including passwords, are kept in the app's private storage. Backup is disabled (`allowBackup="false"`). +- Camera URLs, including passwords, are stored encrypted (AES-256-GCM with a key kept in the Android Keystore, Android 6.0+; on Android 5.x they are stored as before) in the app's private storage. Backup is disabled (`allowBackup="false"`). ## Device compatibility diff --git a/README.ru.md b/README.ru.md index 50208585..68778d91 100644 --- a/README.ru.md +++ b/README.ru.md @@ -96,7 +96,7 @@ > - **Только 4 слота для камер** — [#13](https://github.com/OpenIPC/decoder/issues/13) > - **Не поддерживаются форматы звука** MP4A-LATM, Opus, G.726, а также AAC-кадры, разбитые на несколько RTP-пакетов — [#14](https://github.com/OpenIPC/decoder/issues/14) > - **Возможная зависимость от сервисов Google (GAPPS)** на некоторых устройствах; изучается, поэтому отключайте GAPPS осознанно — [#15](https://github.com/OpenIPC/decoder/issues/15) -> - **Логин и пароль хранятся и передаются в открытом виде** (RTSP/HTTP без шифрования; Basic отправляется, если камера не запросила Digest) — [#16](https://github.com/OpenIPC/decoder/issues/16) +> - **Логин и пароль передаются в открытом виде** по RTSP/HTTP без шифрования (Basic, если камера не запросила Digest); RTSPS не поддерживается — [#16](https://github.com/OpenIPC/decoder/issues/16) > > Известные ошибки собраны отдельно, под [меткой `bug`](https://github.com/OpenIPC/decoder/issues?q=is%3Aissue+is%3Aopen+label%3Abug). @@ -151,7 +151,7 @@ export KEY_PASS=your_key_password - Логин и пароль берутся из адреса и передаются в заголовке `Authorization`: сначала Basic, Digest — если камера его запросит. В строку RTSP-запроса они не попадают. - RTSP и HTTP без шифрования. Используйте приложение только в доверенных сетях, см. [#16](https://github.com/OpenIPC/decoder/issues/16). -- Адреса камер вместе с паролями хранятся в закрытом хранилище приложения. Резервное копирование отключено (`allowBackup="false"`). +- Адреса камер вместе с паролями хранятся в закрытом хранилище приложения в зашифрованном виде (AES-256-GCM, ключ в Android Keystore, Android 6.0+; на Android 5.x — как раньше). Резервное копирование отключено (`allowBackup="false"`). ## Совместимость с устройствами diff --git a/README.zh-CN.md b/README.zh-CN.md index e1f6bfbd..463aee32 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -96,7 +96,7 @@ > - **仅有 4 个摄像机槽位** — [#13](https://github.com/OpenIPC/decoder/issues/13) > - **不支持的音频格式:** MP4A-LATM、Opus、G.726,以及跨多个 RTP 包分片的 AAC 帧 — [#14](https://github.com/OpenIPC/decoder/issues/14) > - **部分设备上可能依赖 Google 服务(GAPPS)**,仍在调查中,请谨慎禁用 GAPPS — [#15](https://github.com/OpenIPC/decoder/issues/15) -> - **用户名和密码以明文存储和传输**(未加密的 RTSP/HTTP;摄像机未要求 Digest 时发送 Basic) — [#16](https://github.com/OpenIPC/decoder/issues/16) +> - **用户名和密码以明文传输**(未加密的 RTSP/HTTP;摄像机未要求 Digest 时发送 Basic),不支持 RTSPS — [#16](https://github.com/OpenIPC/decoder/issues/16) > > 已知的程序错误另行收录在 [`bug` 标签](https://github.com/OpenIPC/decoder/issues?q=is%3Aissue+is%3Aopen+label%3Abug)下。 @@ -151,7 +151,7 @@ export KEY_PASS=your_key_password - 用户名和密码从地址中提取,通过 `Authorization` 头发送:默认使用 Basic,摄像机要求时改用 Digest。不会出现在 RTSP 请求行中。 - RTSP 和 HTTP 均未加密,请仅在可信网络中使用,参见 [#16](https://github.com/OpenIPC/decoder/issues/16)。 -- 摄像机地址(含密码)保存在应用私有存储中,并已禁用备份(`allowBackup="false"`)。 +- 摄像机地址(含密码)加密保存在应用私有存储中(AES-256-GCM,密钥保存在 Android Keystore,Android 6.0 及以上;Android 5.x 仍按原方式保存),并已禁用备份(`allowBackup="false"`)。 ## 设备兼容性 diff --git a/android/app/src/main/java/com/openipc/decoder/CredentialStore.java b/android/app/src/main/java/com/openipc/decoder/CredentialStore.java new file mode 100644 index 00000000..88a67966 --- /dev/null +++ b/android/app/src/main/java/com/openipc/decoder/CredentialStore.java @@ -0,0 +1,105 @@ +/* + * Copyright (c) OpenIPC https://openipc.org Prosperity Public License 3.0.0 + * + * CredentialStore.java — camera URLs (with passwords) encrypted at rest + * + */ + +package com.openipc.decoder; + +import android.os.Build; +import android.util.Log; + +import java.nio.charset.StandardCharsets; + +/** + * Seals camera URLs before they go to SharedPreferences and opens them on load (#16). + * The key lives in the Android Keystore (Android 6.0+) and never leaves it; on older + * versions, or if the Keystore fails, URLs are stored as before. + */ +class CredentialStore { + private static final String TAG = "OpenIPCDecoder"; + static final String PREFIX = "enc1:"; + + /** Authenticated encryption; the output carries everything needed to decrypt it. */ + interface Cipherer { + byte[] encrypt(byte[] plain) throws Exception; + + byte[] decrypt(byte[] sealed) throws Exception; + } + + private final Cipherer cipher; + + CredentialStore(Cipherer cipher) { + this.cipher = cipher; + } + + static CredentialStore create() { + Cipherer c = null; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + try { + c = new KeystoreCipher(); + } catch (Exception e) { + Log.w(TAG, "Android Keystore unavailable, URLs stored unencrypted: " + e); + } + } + return new CredentialStore(c); + } + + boolean encrypts() { return cipher != null; } + + /** Value to store for {@code url}; null stays null. */ + String seal(String url) { + if (url == null || cipher == null) return url; + try { + return PREFIX + toHex(cipher.encrypt(url.getBytes(StandardCharsets.UTF_8))); + } catch (Exception e) { + Log.w(TAG, "Cannot encrypt camera URL, storing it unencrypted: " + e); + return url; + } + } + + /** + * URL from a stored value: sealed values are decrypted, older plain values are returned + * as they are. Returns null if a sealed value cannot be opened (e.g. the key is gone + * after a restore to another device), so that the slot shows as not configured. + */ + String open(String stored) { + if (stored == null || !stored.startsWith(PREFIX)) return stored; + if (cipher == null) return null; + try { + byte[] plain = cipher.decrypt(fromHex(stored.substring(PREFIX.length()))); + return new String(plain, StandardCharsets.UTF_8); + } catch (Exception e) { + Log.w(TAG, "Cannot decrypt a stored camera URL: " + e); + return null; + } + } + + /** True if {@code stored} is a plain value that should be re-saved sealed. */ + boolean needsMigration(String stored) { + return cipher != null && stored != null && !stored.startsWith(PREFIX); + } + + static String toHex(byte[] b) { + char[] digits = "0123456789abcdef".toCharArray(); + char[] out = new char[b.length * 2]; + for (int i = 0; i < b.length; i++) { + out[2 * i] = digits[(b[i] >> 4) & 0xF]; + out[2 * i + 1] = digits[b[i] & 0xF]; + } + return new String(out); + } + + static byte[] fromHex(String s) { + if (s.length() % 2 != 0) throw new IllegalArgumentException("odd length"); + byte[] out = new byte[s.length() / 2]; + for (int i = 0; i < out.length; i++) { + int hi = Character.digit(s.charAt(2 * i), 16); + int lo = Character.digit(s.charAt(2 * i + 1), 16); + if (hi < 0 || lo < 0) throw new IllegalArgumentException("not hex"); + out[i] = (byte) ((hi << 4) | lo); + } + return out; + } +} diff --git a/android/app/src/main/java/com/openipc/decoder/Decoder.java b/android/app/src/main/java/com/openipc/decoder/Decoder.java index 06ffc3c7..3808ceda 100755 --- a/android/app/src/main/java/com/openipc/decoder/Decoder.java +++ b/android/app/src/main/java/com/openipc/decoder/Decoder.java @@ -91,6 +91,8 @@ public class Decoder extends Activity private int mActive; private volatile String mHost; private String mVersion; + // Camera URLs (with passwords) are encrypted at rest (#16) + private CredentialStore credentials; String mUserAgent = "User-Agent: OpenIPC-Decoder/1.0\r\n"; // Thread management @@ -136,6 +138,7 @@ protected void onCreate(Bundle savedInstanceState) { } catch (PackageManager.NameNotFoundException ignored) {} mUserAgent = "User-Agent: OpenIPC-Decoder/" + mVersion + "\r\n"; mDensity = getResources().getDisplayMetrics().density; + credentials = CredentialStore.create(); // --- Create managers --- videoDecoder = new MediaCodecManager(new MediaCodecManager.Listener() { @@ -324,7 +327,7 @@ private void loadSettings() { if (pref.contains("host") && !pref.contains("host_0")) { SharedPreferences.Editor edit = pref.edit(); - edit.putString("host_0", pref.getString("host", DEFAULT_URL)); + edit.putString("host_0", credentials.seal(pref.getString("host", DEFAULT_URL))); edit.putBoolean("type_0", pref.getBoolean("type", false)); edit.remove("host"); edit.remove("type"); @@ -334,9 +337,18 @@ private void loadSettings() { // Restore the last camera and mode (#22); both are saved but were never read back mActive = clampSlot(pref.getInt("active", 0)); quadEnabled = pref.getBoolean("quad_enabled", false); + SharedPreferences.Editor migrate = null; for (int i = 0; i < CAM_COUNT; i++) { - mHosts[i] = pref.getString("host_" + i, DEFAULT_URL); + String stored = pref.getString("host_" + i, null); + String url = credentials.open(stored); + mHosts[i] = url != null ? url : DEFAULT_URL; + // Plain URLs from older versions are re-saved encrypted right away + if (credentials.needsMigration(stored)) { + if (migrate == null) migrate = pref.edit(); + migrate.putString("host_" + i, credentials.seal(stored)); + } } + if (migrate != null) migrate.apply(); Intent intent = getIntent(); String link = intent.getStringExtra(Intent.EXTRA_TEXT); @@ -347,7 +359,7 @@ private void loadSettings() { // still there after the next pause/resume quadEnabled = false; pref.edit() - .putString("host_" + mActive, mHosts[mActive]) + .putString("host_" + mActive, credentials.seal(mHosts[mActive])) .putBoolean("quad_enabled", false) .apply(); intent.removeExtra(Intent.EXTRA_TEXT); @@ -378,7 +390,7 @@ private void saveSettings() { SharedPreferences.Editor edit = pref.edit(); edit.putInt("active", mActive); for (int i = 0; i < CAM_COUNT; i++) { - edit.putString("host_" + i, mHosts[i]); + edit.putString("host_" + i, credentials.seal(mHosts[i])); } edit.apply(); diff --git a/android/app/src/main/java/com/openipc/decoder/KeystoreCipher.java b/android/app/src/main/java/com/openipc/decoder/KeystoreCipher.java new file mode 100644 index 00000000..1e15b573 --- /dev/null +++ b/android/app/src/main/java/com/openipc/decoder/KeystoreCipher.java @@ -0,0 +1,75 @@ +/* + * Copyright (c) OpenIPC https://openipc.org Prosperity Public License 3.0.0 + * + * KeystoreCipher.java — AES-GCM with a key kept in the Android Keystore + * + */ + +package com.openipc.decoder; + +import android.annotation.SuppressLint; +import android.annotation.TargetApi; +import android.os.Build; +import android.security.keystore.KeyGenParameterSpec; +import android.security.keystore.KeyProperties; + +import java.security.KeyStore; + +import javax.crypto.Cipher; +import javax.crypto.KeyGenerator; +import javax.crypto.SecretKey; +import javax.crypto.spec.GCMParameterSpec; + +/** + * Output layout: 12-byte IV followed by the ciphertext with its 16-byte GCM tag. + * Only created behind an SDK_INT check in CredentialStore.create(); @RequiresApi would need + * the AndroidX annotation library, which the app does not use. + */ +@SuppressLint("UseRequiresApi") +@TargetApi(Build.VERSION_CODES.M) +class KeystoreCipher implements CredentialStore.Cipherer { + private static final String KEYSTORE = "AndroidKeyStore"; + private static final String ALIAS = "camera_urls"; + private static final String TRANSFORMATION = "AES/GCM/NoPadding"; + private static final int IV_LENGTH = 12; + private static final int TAG_BITS = 128; + + private final SecretKey key; + + KeystoreCipher() throws Exception { + KeyStore ks = KeyStore.getInstance(KEYSTORE); + ks.load(null); + if (!ks.containsAlias(ALIAS)) { + KeyGenerator gen = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE); + gen.init(new KeyGenParameterSpec.Builder(ALIAS, + KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256) + .build()); + gen.generateKey(); + } + key = (SecretKey) ks.getKey(ALIAS, null); + if (key == null) throw new IllegalStateException("No key in the Android Keystore"); + } + + @Override + public byte[] encrypt(byte[] plain) throws Exception { + Cipher c = Cipher.getInstance(TRANSFORMATION); + c.init(Cipher.ENCRYPT_MODE, key); // the Keystore picks a random IV + byte[] iv = c.getIV(); + byte[] ct = c.doFinal(plain); + byte[] out = new byte[iv.length + ct.length]; + System.arraycopy(iv, 0, out, 0, iv.length); + System.arraycopy(ct, 0, out, iv.length, ct.length); + return out; + } + + @Override + public byte[] decrypt(byte[] sealed) throws Exception { + if (sealed.length <= IV_LENGTH) throw new IllegalArgumentException("too short"); + Cipher c = Cipher.getInstance(TRANSFORMATION); + c.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(TAG_BITS, sealed, 0, IV_LENGTH)); + return c.doFinal(sealed, IV_LENGTH, sealed.length - IV_LENGTH); + } +} diff --git a/android/app/src/test/java/com/openipc/decoder/CredentialStoreTest.java b/android/app/src/test/java/com/openipc/decoder/CredentialStoreTest.java new file mode 100644 index 00000000..5d608514 --- /dev/null +++ b/android/app/src/test/java/com/openipc/decoder/CredentialStoreTest.java @@ -0,0 +1,115 @@ +/* + * Copyright (c) OpenIPC https://openipc.org Prosperity Public License 3.0.0 + * + * CredentialStoreTest.java — camera URLs encrypted at rest (#16) + * + */ + +package com.openipc.decoder; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.security.SecureRandom; + +import javax.crypto.Cipher; +import javax.crypto.KeyGenerator; +import javax.crypto.SecretKey; +import javax.crypto.spec.GCMParameterSpec; + +public class CredentialStoreTest { + /** Same layout as KeystoreCipher (IV + ciphertext/tag), with an in-memory key. */ + private static final class TestCipher implements CredentialStore.Cipherer { + private final SecretKey key; + + TestCipher() throws Exception { + KeyGenerator g = KeyGenerator.getInstance("AES"); + g.init(256); + key = g.generateKey(); + } + + @Override public byte[] encrypt(byte[] plain) throws Exception { + byte[] iv = new byte[12]; + new SecureRandom().nextBytes(iv); + Cipher c = Cipher.getInstance("AES/GCM/NoPadding"); + c.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(128, iv)); + byte[] ct = c.doFinal(plain); + byte[] out = new byte[12 + ct.length]; + System.arraycopy(iv, 0, out, 0, 12); + System.arraycopy(ct, 0, out, 12, ct.length); + return out; + } + + @Override public byte[] decrypt(byte[] sealed) throws Exception { + Cipher c = Cipher.getInstance("AES/GCM/NoPadding"); + c.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(128, sealed, 0, 12)); + return c.doFinal(sealed, 12, sealed.length - 12); + } + } + + private static final String URL = "rtsp://admin:s3cr3t@192.168.1.10:554/stream=0"; + + @Test + public void sealedUrlHidesThePasswordAndOpensAgain() throws Exception { + CredentialStore store = new CredentialStore(new TestCipher()); + String sealed = store.seal(URL); + assertTrue(sealed.startsWith(CredentialStore.PREFIX)); + assertFalse("no password in the stored value", sealed.contains("s3cr3t")); + assertFalse(sealed.contains("admin")); + assertEquals(URL, store.open(sealed)); + // Random IV: the same URL is stored differently each time + assertNotEquals(sealed, store.seal(URL)); + } + + @Test + public void plainValuesFromOlderVersionsStillLoadAndAreMigrated() throws Exception { + CredentialStore store = new CredentialStore(new TestCipher()); + assertEquals(URL, store.open(URL)); + assertTrue(store.needsMigration(URL)); + assertFalse(store.needsMigration(store.seal(URL))); + assertFalse(store.needsMigration(null)); + } + + @Test + public void undecryptableValueBecomesAnEmptySlot() throws Exception { + CredentialStore store = new CredentialStore(new TestCipher()); + String sealed = store.seal(URL); + // Another key (e.g. data restored on another device) + assertNull(new CredentialStore(new TestCipher()).open(sealed)); + // Tampered or truncated data + assertNull(store.open(sealed.substring(0, sealed.length() - 2) + "00")); + assertNull(store.open(CredentialStore.PREFIX + "zz")); + assertNull(store.open(CredentialStore.PREFIX + "abc")); + } + + @Test + public void withoutKeystoreUrlsStayPlain() { + CredentialStore store = new CredentialStore(null); + assertFalse(store.encrypts()); + assertEquals(URL, store.seal(URL)); + assertEquals(URL, store.open(URL)); + assertFalse(store.needsMigration(URL)); + // A sealed value cannot be opened without the Keystore + assertNull(store.open(CredentialStore.PREFIX + "00ff")); + } + + @Test + public void nullStaysNull() throws Exception { + CredentialStore store = new CredentialStore(new TestCipher()); + assertNull(store.seal(null)); + assertNull(store.open(null)); + } + + @Test + public void hexRoundTrip() { + byte[] b = {0, 1, (byte) 0x7F, (byte) 0x80, (byte) 0xFF}; + assertEquals("00017f80ff", CredentialStore.toHex(b)); + assertArrayEquals(b, CredentialStore.fromHex("00017F80ff")); + } +} From feeb164795143858caa7eba23013d9e22c3f6cbc Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Mon, 12 Oct 2026 13:20:57 +0000 Subject: [PATCH 2/2] fix: keep stored URLs that cannot be decrypted until their slot is edited 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. --- .../java/com/openipc/decoder/CredentialStore.java | 11 +++++++++++ .../src/main/java/com/openipc/decoder/Decoder.java | 9 +++++++-- .../com/openipc/decoder/CredentialStoreTest.java | 14 ++++++++++++++ 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/android/app/src/main/java/com/openipc/decoder/CredentialStore.java b/android/app/src/main/java/com/openipc/decoder/CredentialStore.java index 88a67966..4959e09b 100644 --- a/android/app/src/main/java/com/openipc/decoder/CredentialStore.java +++ b/android/app/src/main/java/com/openipc/decoder/CredentialStore.java @@ -76,6 +76,17 @@ String open(String stored) { } } + /** + * Value to store for a slot. If the slot's stored value could not be opened + * ({@code unreadable} != null) and the slot still shows the placeholder, the original + * stored value is kept, so a temporarily unavailable key does not destroy it when + * another slot is saved. A slot the user edited is sealed as usual. + */ + String sealForSlot(String url, String unreadable, String placeholder) { + if (unreadable != null && (url == null || url.equals(placeholder))) return unreadable; + return seal(url); + } + /** True if {@code stored} is a plain value that should be re-saved sealed. */ boolean needsMigration(String stored) { return cipher != null && stored != null && !stored.startsWith(PREFIX); diff --git a/android/app/src/main/java/com/openipc/decoder/Decoder.java b/android/app/src/main/java/com/openipc/decoder/Decoder.java index 3808ceda..72c1031c 100755 --- a/android/app/src/main/java/com/openipc/decoder/Decoder.java +++ b/android/app/src/main/java/com/openipc/decoder/Decoder.java @@ -93,6 +93,8 @@ public class Decoder extends Activity private String mVersion; // Camera URLs (with passwords) are encrypted at rest (#16) private CredentialStore credentials; + // Stored values that could not be decrypted: kept as they are until the slot is edited + private final String[] mUnreadable = new String[CAM_COUNT]; String mUserAgent = "User-Agent: OpenIPC-Decoder/1.0\r\n"; // Thread management @@ -342,6 +344,7 @@ private void loadSettings() { String stored = pref.getString("host_" + i, null); String url = credentials.open(stored); mHosts[i] = url != null ? url : DEFAULT_URL; + mUnreadable[i] = url == null && stored != null ? stored : null; // Plain URLs from older versions are re-saved encrypted right away if (credentials.needsMigration(stored)) { if (migrate == null) migrate = pref.edit(); @@ -359,7 +362,8 @@ private void loadSettings() { // still there after the next pause/resume quadEnabled = false; pref.edit() - .putString("host_" + mActive, credentials.seal(mHosts[mActive])) + .putString("host_" + mActive, credentials.sealForSlot( + mHosts[mActive], mUnreadable[mActive], DEFAULT_URL)) .putBoolean("quad_enabled", false) .apply(); intent.removeExtra(Intent.EXTRA_TEXT); @@ -390,7 +394,8 @@ private void saveSettings() { SharedPreferences.Editor edit = pref.edit(); edit.putInt("active", mActive); for (int i = 0; i < CAM_COUNT; i++) { - edit.putString("host_" + i, credentials.seal(mHosts[i])); + edit.putString("host_" + i, + credentials.sealForSlot(mHosts[i], mUnreadable[i], DEFAULT_URL)); } edit.apply(); diff --git a/android/app/src/test/java/com/openipc/decoder/CredentialStoreTest.java b/android/app/src/test/java/com/openipc/decoder/CredentialStoreTest.java index 5d608514..308a418f 100644 --- a/android/app/src/test/java/com/openipc/decoder/CredentialStoreTest.java +++ b/android/app/src/test/java/com/openipc/decoder/CredentialStoreTest.java @@ -112,4 +112,18 @@ public void hexRoundTrip() { assertEquals("00017f80ff", CredentialStore.toHex(b)); assertArrayEquals(b, CredentialStore.fromHex("00017F80ff")); } + + @Test + public void unreadableSlotSurvivesSavingOtherSlots() throws Exception { + CredentialStore store = new CredentialStore(new TestCipher()); + String placeholder = "rtsp://root:12345@192.168.1.10:554/stream=0"; + String unreadable = CredentialStore.PREFIX + "00112233"; + // Slot not edited: the original stored value is written back unchanged + assertEquals(unreadable, store.sealForSlot(placeholder, unreadable, placeholder)); + // Slot edited by the user: the new URL is sealed + String sealed = store.sealForSlot(URL, unreadable, placeholder); + assertEquals(URL, store.open(sealed)); + // Normal slot + assertEquals(URL, store.open(store.sealForSlot(URL, null, placeholder))); + } }