Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
>
> Known bugs are tracked separately under the [`bug` label](https://github.com/OpenIPC/decoder/issues?q=is%3Aissue+is%3Aopen+label%3Abug).

Expand Down Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions README.ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down Expand Up @@ -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"`).

## Совместимость с устройствами

Expand Down
4 changes: 2 additions & 2 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)下。

Expand Down Expand Up @@ -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"`)。

## 设备兼容性

Expand Down
116 changes: 116 additions & 0 deletions android/app/src/main/java/com/openipc/decoder/CredentialStore.java
Original file line number Diff line number Diff line change
@@ -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)));
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
} 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;
}
}
25 changes: 21 additions & 4 deletions android/app/src/main/java/com/openipc/decoder/Decoder.java
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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");
Expand All @@ -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;
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
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);
Expand All @@ -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);
Expand Down Expand Up @@ -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();

Expand Down
75 changes: 75 additions & 0 deletions android/app/src/main/java/com/openipc/decoder/KeystoreCipher.java
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading
Loading