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..4959e09b --- /dev/null +++ b/android/app/src/main/java/com/openipc/decoder/CredentialStore.java @@ -0,0 +1,116 @@ +/* + * 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; + } + } + + /** + * 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); + } + + 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..72c1031c 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,10 @@ 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; + // 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 @@ -136,6 +140,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 +329,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 +339,19 @@ 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; + 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(); + migrate.putString("host_" + i, credentials.seal(stored)); + } } + if (migrate != null) migrate.apply(); Intent intent = getIntent(); String link = intent.getStringExtra(Intent.EXTRA_TEXT); @@ -347,7 +362,8 @@ private void loadSettings() { // still there after the next pause/resume quadEnabled = false; pref.edit() - .putString("host_" + mActive, mHosts[mActive]) + .putString("host_" + mActive, credentials.sealForSlot( + mHosts[mActive], mUnreadable[mActive], DEFAULT_URL)) .putBoolean("quad_enabled", false) .apply(); intent.removeExtra(Intent.EXTRA_TEXT); @@ -378,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, mHosts[i]); + edit.putString("host_" + i, + credentials.sealForSlot(mHosts[i], mUnreadable[i], DEFAULT_URL)); } 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..308a418f --- /dev/null +++ b/android/app/src/test/java/com/openipc/decoder/CredentialStoreTest.java @@ -0,0 +1,129 @@ +/* + * 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")); + } + + @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))); + } +}