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
9 changes: 3 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ A beta channel is available on [Google Play](https://play.google.com/apps/intern
- **Audio:** AAC (RFC 3640 `mpeg4-generic`, ADTS), G.711 μ-law/A-law, L16 PCM (mono/stereo)
- **Display:** single camera, or Quad mode with 4 cameras at once
- **Controls:** pinch-to-zoom, pan, double-tap to reset, Android TV remote (Leanback launcher)
- **Network:** automatic reconnection with exponential back-off, connection-quality indicator
- **Network:** Basic and Digest authentication, RTSP keepalive, automatic reconnection with exponential back-off, connection-quality indicator
- **WebUI:** built-in browser for the camera's web interface
- **More:** long-press screenshot, 4 camera slots, URLs can be shared from other apps

Expand Down Expand Up @@ -92,15 +92,12 @@ Shows all configured slots in a 2×2 grid. Each cell has its own connection and
> These are known gaps that we plan to address. Each one has an issue — comments, test results and pull requests are welcome.
>
> - **RTSP over TCP only.** No UDP or multicast — [#7](https://github.com/OpenIPC/decoder/issues/7)
> - **Basic authentication only.** Cameras that require Digest auth will not connect — [#8](https://github.com/OpenIPC/decoder/issues/8)
> - **Video codec is detected by payload type number** (96 = H.264, 97 = H.265), not from the SDP. H.265 sent on PT 96 will not play — [#9](https://github.com/OpenIPC/decoder/issues/9)
> - **Simplified RTP handling for video:** no STAP-A/AP aggregation, packets with CSRC or header extensions are dropped, no packet-loss recovery — [#10](https://github.com/OpenIPC/decoder/issues/10)
> - **No RTSP keepalive.** Servers that enforce a session timeout may drop the stream periodically — [#11](https://github.com/OpenIPC/decoder/issues/11)
> - **No audio in Quad mode** — [#12](https://github.com/OpenIPC/decoder/issues/12)
> - **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 with Basic auth) — [#16](https://github.com/OpenIPC/decoder/issues/16)
> - **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)
>
> 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 @@ -153,7 +150,7 @@ Network, video decoding and audio run on separate threads, so a slow decoder nev

## Security notes

- Credentials are taken from the URL and sent as an HTTP/RTSP `Authorization: Basic` header. They are not included in the RTSP request line.
- 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"`).

Expand Down
9 changes: 3 additions & 6 deletions README.ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
- **Звук:** AAC (RFC 3640 `mpeg4-generic`, ADTS), G.711 μ-law/A-law, L16 PCM (моно и стерео)
- **Режимы:** одна камера или Quad — 4 камеры на одном экране
- **Управление:** масштаб двумя пальцами, перемещение, сброс двойным касанием, пульт Android TV (Leanback)
- **Сеть:** автоматическое переподключение с растущей задержкой, индикатор качества связи
- **Сеть:** авторизация Basic и Digest, RTSP keepalive, автоматическое переподключение с растущей задержкой, индикатор качества связи
- **WebUI:** встроенный браузер для веб-интерфейса камеры
- **Прочее:** скриншот долгим нажатием, 4 слота для камер, приём ссылок из других приложений

Expand Down Expand Up @@ -92,15 +92,12 @@
> Это известные пробелы, которые мы планируем закрыть. На каждый заведён issue — комментарии, результаты проверок и pull request приветствуются.
>
> - **Только RTSP поверх TCP.** UDP и multicast не поддерживаются — [#7](https://github.com/OpenIPC/decoder/issues/7)
> - **Только Basic-авторизация.** Камеры, которым нужен Digest, не подключатся — [#8](https://github.com/OpenIPC/decoder/issues/8)
> - **Кодек видео определяется по номеру payload type** (96 = H.264, 97 = H.265), а не по SDP. H.265 на PT 96 не воспроизводится — [#9](https://github.com/OpenIPC/decoder/issues/9)
> - **Упрощённая обработка RTP для видео:** нет агрегации STAP-A/AP, пакеты с CSRC или расширением заголовка отбрасываются, потери пакетов не обрабатываются — [#10](https://github.com/OpenIPC/decoder/issues/10)
> - **Нет RTSP keepalive.** Серверы с таймаутом сессии могут периодически обрывать поток — [#11](https://github.com/OpenIPC/decoder/issues/11)
> - **В режиме Quad нет звука** — [#12](https://github.com/OpenIPC/decoder/issues/12)
> - **Только 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-авторизация) — [#16](https://github.com/OpenIPC/decoder/issues/16)
> - **Логин и пароль хранятся и передаются в открытом виде** (RTSP/HTTP без шифрования; Basic отправляется, если камера не запросила Digest) — [#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 @@ -153,7 +150,7 @@ export KEY_PASS=your_key_password

## Безопасность

- Логин и пароль берутся из адреса и передаются в заголовке `Authorization: Basic`. В строку RTSP-запроса они не попадают.
- Логин и пароль берутся из адреса и передаются в заголовке `Authorization`: сначала Basic, Digest — если камера его запросит. В строку RTSP-запроса они не попадают.
- RTSP и HTTP без шифрования. Используйте приложение только в доверенных сетях, см. [#16](https://github.com/OpenIPC/decoder/issues/16).
- Адреса камер вместе с паролями хранятся в закрытом хранилище приложения. Резервное копирование отключено (`allowBackup="false"`).

Expand Down
9 changes: 3 additions & 6 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
- **音频:** AAC(RFC 3640 `mpeg4-generic`、ADTS)、G.711 μ-law/A-law、L16 PCM(单声道/立体声)
- **显示:** 单路画面,或 Quad 四分屏(同时显示 4 路摄像机)
- **操作:** 双指缩放、拖动平移、双击复位,支持 Android TV 遥控器(Leanback 启动器)
- **网络:** 断线后自动重连(指数退避),连接质量指示
- **网络:** 支持 Basic 和 Digest 认证、RTSP 保活,断线后自动重连(指数退避),连接质量指示
- **WebUI:** 内置浏览器,可打开摄像机的网页管理界面
- **其他:** 长按截图,4 个摄像机槽位,可接收其他应用分享的地址

Expand Down Expand Up @@ -92,15 +92,12 @@
> 以下是我们计划解决的已知不足,每一项都有对应的 issue。欢迎留言、提交测试结果和 pull request。
>
> - **仅支持 RTSP over TCP**,不支持 UDP 和组播 — [#7](https://github.com/OpenIPC/decoder/issues/7)
> - **仅支持 Basic 认证**,需要 Digest 认证的摄像机无法连接 — [#8](https://github.com/OpenIPC/decoder/issues/8)
> - **视频编码按 payload type 编号判断**(96 = H.264,97 = H.265),而不是读取 SDP。使用 PT 96 传输的 H.265 无法播放 — [#9](https://github.com/OpenIPC/decoder/issues/9)
> - **视频的 RTP 处理较为简单:** 不支持 STAP-A/AP 聚合包,带 CSRC 或扩展头的包会被丢弃,也不处理丢包 — [#10](https://github.com/OpenIPC/decoder/issues/10)
> - **没有 RTSP 保活**,设置了会话超时的服务器可能会周期性断开视频流 — [#11](https://github.com/OpenIPC/decoder/issues/11)
> - **Quad 模式下没有声音** — [#12](https://github.com/OpenIPC/decoder/issues/12)
> - **仅有 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,Basic 认证) — [#16](https://github.com/OpenIPC/decoder/issues/16)
> - **用户名和密码以明文存储和传输**(未加密的 RTSP/HTTP;摄像机未要求 Digest 时发送 Basic) — [#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 @@ -153,7 +150,7 @@ export KEY_PASS=your_key_password

## 安全说明

- 用户名和密码从地址中提取,通过 `Authorization: Basic` 头发送,不会出现在 RTSP 请求行中。
- 用户名和密码从地址中提取,通过 `Authorization` 头发送:默认使用 Basic,摄像机要求时改用 Digest。不会出现在 RTSP 请求行中。
- RTSP 和 HTTP 均未加密,请仅在可信网络中使用,参见 [#16](https://github.com/OpenIPC/decoder/issues/16)。
- 摄像机地址(含密码)保存在应用私有存储中,并已禁用备份(`allowBackup="false"`)。

Expand Down
192 changes: 192 additions & 0 deletions android/app/src/main/java/com/openipc/decoder/RtspAuth.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
/*
* Copyright (c) OpenIPC https://openipc.org Prosperity Public License 3.0.0
*
* RtspAuth.java — RTSP Basic and Digest (RFC 2617) authentication
*
*/

package com.openipc.decoder;

import android.util.Base64;

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;

/**
* Builds the Authorization header for each request. Basic is sent pre-emptively, as cameras
* running OpenIPC expect; after a Digest challenge every request is signed with Digest.
*/
class RtspAuth {
private final String user;
private final String password;

private boolean digest;
private String realm;
private String nonce;
private String opaque;
private String algorithm;
private boolean qopAuth;
private int nonceCount;
/** MD5-sess: one client nonce per server nonce, used in H(A1) and sent every time. */
private String sessCnonce;
/** Fixed client nonce for tests; random when null. */
String testCnonce;

/** @param userInfo decoded "user:password" from the URL, or null */
RtspAuth(String userInfo) {
if (userInfo == null) {
user = null;
password = null;
} else {
int colon = userInfo.indexOf(':');
user = colon >= 0 ? userInfo.substring(0, colon) : userInfo;
password = colon >= 0 ? userInfo.substring(colon + 1) : "";
}
}

boolean hasCredentials() { return user != null; }

/** "Authorization: ...\r\n" for this request, or "" without credentials. */
String header(String method, String uri) {
if (user == null) return "";
if (!digest) {
String token = Base64.encodeToString(
(user + ":" + password).getBytes(StandardCharsets.UTF_8), Base64.NO_WRAP);
return "Authorization: Basic " + token + "\r\n";
}

String ha1 = md5(user + ":" + realm + ":" + password);
String cnonce = null;
String nc = null;
if ("MD5-SESS".equals(algorithm)) {
if (sessCnonce == null) sessCnonce = newCnonce();
cnonce = sessCnonce;
ha1 = md5(ha1 + ":" + nonce + ":" + cnonce);
} else if (qopAuth) {
cnonce = newCnonce();
}
String ha2 = md5(method + ":" + uri);
String response;
if (qopAuth) {
nc = String.format(Locale.ROOT, "%08x", ++nonceCount);
response = md5(ha1 + ":" + nonce + ":" + nc + ":" + cnonce + ":auth:" + ha2);
} else {
response = md5(ha1 + ":" + nonce + ":" + ha2);
}

StringBuilder h = new StringBuilder("Authorization: Digest ");
h.append("username=").append(quote(user)).append(", ");
h.append("realm=").append(quote(realm)).append(", ");
h.append("nonce=").append(quote(nonce)).append(", ");
h.append("uri=").append(quote(uri)).append(", ");
h.append("response=\"").append(response).append('"');
if (algorithm != null) h.append(", algorithm=").append(algorithm);
if (opaque != null) h.append(", opaque=").append(quote(opaque));
if (qopAuth) h.append(", qop=auth, nc=").append(nc);
// MD5-sess needs the cnonce even without qop, or the server cannot verify H(A1)
if (cnonce != null) h.append(", cnonce=").append(quote(cnonce));
return h.append("\r\n").toString();
}

/**
* Handle a 401 reply. Returns true if the request should be repeated: a first Digest
* challenge, or a stale nonce. Returns false when retrying cannot help (no
* credentials, an unsupported scheme, or the same credentials were already rejected).
*/
boolean onUnauthorized(List<String> challenges) {
if (user == null || challenges == null) return false;
for (String c : challenges) {
String trimmed = c.trim();
if (!trimmed.regionMatches(true, 0, "Digest", 0, 6)) continue;
Map<String, String> p = parseParams(trimmed.substring(6));
String alg = p.get("algorithm");
if (alg != null && !alg.equalsIgnoreCase("MD5") && !alg.equalsIgnoreCase("MD5-sess")) {
continue; // SHA-256 etc. are not supported
}
String qop = p.get("qop");
boolean hasAuth = false;
if (qop != null) {
for (String q : qop.split(",")) {
if (q.trim().equalsIgnoreCase("auth")) hasAuth = true;
}
// Only auth-int offered: we cannot produce that, look for another challenge
if (!hasAuth) continue;
}
boolean stale = "true".equalsIgnoreCase(p.get("stale"));
boolean retry = !digest || stale;
digest = true;
realm = p.containsKey("realm") ? p.get("realm") : "";
nonce = p.containsKey("nonce") ? p.get("nonce") : "";
opaque = p.get("opaque");
algorithm = alg != null ? alg.toUpperCase(Locale.ROOT) : null;
qopAuth = hasAuth;
nonceCount = 0;
sessCnonce = null;
return retry;
}
// Basic was already sent pre-emptively: another attempt would fail the same way
return false;
}

/** Parse "k=v, k="quoted, value", ..." into lower-case keys. */
static Map<String, String> parseParams(String s) {
Map<String, String> out = new HashMap<>();
int i = 0;
int n = s.length();
while (i < n) {
while (i < n && (s.charAt(i) == ',' || Character.isWhitespace(s.charAt(i)))) i++;
int eq = s.indexOf('=', i);
if (eq < 0) break;
String key = s.substring(i, eq).trim().toLowerCase(Locale.ROOT);
i = eq + 1;
while (i < n && Character.isWhitespace(s.charAt(i))) i++;
StringBuilder value = new StringBuilder();
if (i < n && s.charAt(i) == '"') {
i++;
while (i < n && s.charAt(i) != '"') {
if (s.charAt(i) == '\\' && i + 1 < n) i++;
value.append(s.charAt(i++));
}
i++; // closing quote
} else {
while (i < n && s.charAt(i) != ',') value.append(s.charAt(i++));
}
out.put(key, value.toString().trim());
}
return out;
}

private String newCnonce() {
return testCnonce != null ? testCnonce : randomHex(8);
}

/** Quoted-string with '"' and '\\' escaped (RFC 2616 2.2). */
static String quote(String v) {
return '"' + v.replace("\\", "\\\\").replace("\"", "\\\"") + '"';
}

static String md5(String s) {
try {
byte[] d = MessageDigest.getInstance("MD5").digest(s.getBytes(StandardCharsets.UTF_8));
StringBuilder hex = new StringBuilder(32);
for (byte b : d) hex.append(String.format(Locale.ROOT, "%02x", b & 0xFF));
return hex.toString();
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException(e);
}
}

private static String randomHex(int bytes) {
byte[] b = new byte[bytes];
new SecureRandom().nextBytes(b);
StringBuilder hex = new StringBuilder(bytes * 2);
for (byte x : b) hex.append(String.format(Locale.ROOT, "%02x", x & 0xFF));
return hex.toString();
}
}
Loading
Loading