Skip to content

Commit 3918bbb

Browse files
authored
Merge pull request #46 from OpenIPC/feat/rtsp-auth-keepalive
feat: RTSP Digest authentication and keepalive (#8, #11)
2 parents a27adae + 9ecf60a commit 3918bbb

8 files changed

Lines changed: 680 additions & 54 deletions

File tree

‎README.md‎

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

@@ -92,15 +92,12 @@ Shows all configured slots in a 2×2 grid. Each cell has its own connection and
9292
> These are known gaps that we plan to address. Each one has an issue — comments, test results and pull requests are welcome.
9393
>
9494
> - **RTSP over TCP only.** No UDP or multicast — [#7](https://github.com/OpenIPC/decoder/issues/7)
95-
> - **Basic authentication only.** Cameras that require Digest auth will not connect — [#8](https://github.com/OpenIPC/decoder/issues/8)
96-
> - **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)
9795
> - **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)
98-
> - **No RTSP keepalive.** Servers that enforce a session timeout may drop the stream periodically — [#11](https://github.com/OpenIPC/decoder/issues/11)
9996
> - **No audio in Quad mode** — [#12](https://github.com/OpenIPC/decoder/issues/12)
10097
> - **Only 4 camera slots** — [#13](https://github.com/OpenIPC/decoder/issues/13)
10198
> - **Audio formats not supported:** MP4A-LATM, Opus, G.726, and AAC frames split across RTP packets — [#14](https://github.com/OpenIPC/decoder/issues/14)
10299
> - **Possible dependency on Google services (GAPPS)** on some devices; under investigation, so disable GAPPS with care — [#15](https://github.com/OpenIPC/decoder/issues/15)
103-
> - **Credentials are stored and sent in clear text** (plain RTSP/HTTP with Basic auth) — [#16](https://github.com/OpenIPC/decoder/issues/16)
100+
> - **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)
104101
>
105102
> Known bugs are tracked separately under the [`bug` label](https://github.com/OpenIPC/decoder/issues?q=is%3Aissue+is%3Aopen+label%3Abug).
106103
@@ -153,7 +150,7 @@ Network, video decoding and audio run on separate threads, so a slow decoder nev
153150

154151
## Security notes
155152

156-
- Credentials are taken from the URL and sent as an HTTP/RTSP `Authorization: Basic` header. They are not included in the RTSP request line.
153+
- 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.
157154
- Plain RTSP and HTTP are not encrypted. Use the app only on trusted networks, see [#16](https://github.com/OpenIPC/decoder/issues/16).
158155
- Camera URLs, including passwords, are kept in the app's private storage. Backup is disabled (`allowBackup="false"`).
159156

‎README.ru.md‎

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

@@ -92,15 +92,12 @@
9292
> Это известные пробелы, которые мы планируем закрыть. На каждый заведён issue — комментарии, результаты проверок и pull request приветствуются.
9393
>
9494
> - **Только RTSP поверх TCP.** UDP и multicast не поддерживаются — [#7](https://github.com/OpenIPC/decoder/issues/7)
95-
> - **Только Basic-авторизация.** Камеры, которым нужен Digest, не подключатся — [#8](https://github.com/OpenIPC/decoder/issues/8)
96-
> - **Кодек видео определяется по номеру payload type** (96 = H.264, 97 = H.265), а не по SDP. H.265 на PT 96 не воспроизводится — [#9](https://github.com/OpenIPC/decoder/issues/9)
9795
> - **Упрощённая обработка RTP для видео:** нет агрегации STAP-A/AP, пакеты с CSRC или расширением заголовка отбрасываются, потери пакетов не обрабатываются — [#10](https://github.com/OpenIPC/decoder/issues/10)
98-
> - **Нет RTSP keepalive.** Серверы с таймаутом сессии могут периодически обрывать поток — [#11](https://github.com/OpenIPC/decoder/issues/11)
9996
> - **В режиме Quad нет звука** — [#12](https://github.com/OpenIPC/decoder/issues/12)
10097
> - **Только 4 слота для камер** — [#13](https://github.com/OpenIPC/decoder/issues/13)
10198
> - **Не поддерживаются форматы звука** MP4A-LATM, Opus, G.726, а также AAC-кадры, разбитые на несколько RTP-пакетов — [#14](https://github.com/OpenIPC/decoder/issues/14)
10299
> - **Возможная зависимость от сервисов Google (GAPPS)** на некоторых устройствах; изучается, поэтому отключайте GAPPS осознанно — [#15](https://github.com/OpenIPC/decoder/issues/15)
103-
> - **Логин и пароль хранятся и передаются в открытом виде** (RTSP/HTTP без шифрования, Basic-авторизация) — [#16](https://github.com/OpenIPC/decoder/issues/16)
100+
> - **Логин и пароль хранятся и передаются в открытом виде** (RTSP/HTTP без шифрования; Basic отправляется, если камера не запросила Digest) — [#16](https://github.com/OpenIPC/decoder/issues/16)
104101
>
105102
> Известные ошибки собраны отдельно, под [меткой `bug`](https://github.com/OpenIPC/decoder/issues?q=is%3Aissue+is%3Aopen+label%3Abug).
106103
@@ -153,7 +150,7 @@ export KEY_PASS=your_key_password
153150

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

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

‎README.zh-CN.md‎

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

@@ -92,15 +92,12 @@
9292
> 以下是我们计划解决的已知不足,每一项都有对应的 issue。欢迎留言、提交测试结果和 pull request。
9393
>
9494
> - **仅支持 RTSP over TCP**,不支持 UDP 和组播 — [#7](https://github.com/OpenIPC/decoder/issues/7)
95-
> - **仅支持 Basic 认证**,需要 Digest 认证的摄像机无法连接 — [#8](https://github.com/OpenIPC/decoder/issues/8)
96-
> - **视频编码按 payload type 编号判断**(96 = H.264,97 = H.265),而不是读取 SDP。使用 PT 96 传输的 H.265 无法播放 — [#9](https://github.com/OpenIPC/decoder/issues/9)
9795
> - **视频的 RTP 处理较为简单:** 不支持 STAP-A/AP 聚合包,带 CSRC 或扩展头的包会被丢弃,也不处理丢包 — [#10](https://github.com/OpenIPC/decoder/issues/10)
98-
> - **没有 RTSP 保活**,设置了会话超时的服务器可能会周期性断开视频流 — [#11](https://github.com/OpenIPC/decoder/issues/11)
9996
> - **Quad 模式下没有声音** — [#12](https://github.com/OpenIPC/decoder/issues/12)
10097
> - **仅有 4 个摄像机槽位** — [#13](https://github.com/OpenIPC/decoder/issues/13)
10198
> - **不支持的音频格式:** MP4A-LATM、Opus、G.726,以及跨多个 RTP 包分片的 AAC 帧 — [#14](https://github.com/OpenIPC/decoder/issues/14)
10299
> - **部分设备上可能依赖 Google 服务(GAPPS)**,仍在调查中,请谨慎禁用 GAPPS — [#15](https://github.com/OpenIPC/decoder/issues/15)
103-
> - **用户名和密码以明文存储和传输**(未加密的 RTSP/HTTP,Basic 认证) — [#16](https://github.com/OpenIPC/decoder/issues/16)
100+
> - **用户名和密码以明文存储和传输**(未加密的 RTSP/HTTP;摄像机未要求 Digest 时发送 Basic) — [#16](https://github.com/OpenIPC/decoder/issues/16)
104101
>
105102
> 已知的程序错误另行收录在 [`bug` 标签](https://github.com/OpenIPC/decoder/issues?q=is%3Aissue+is%3Aopen+label%3Abug)下。
106103
@@ -153,7 +150,7 @@ export KEY_PASS=your_key_password
153150

154151
## 安全说明
155152

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

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
/*
2+
* Copyright (c) OpenIPC https://openipc.org Prosperity Public License 3.0.0
3+
*
4+
* RtspAuth.java — RTSP Basic and Digest (RFC 2617) authentication
5+
*
6+
*/
7+
8+
package com.openipc.decoder;
9+
10+
import android.util.Base64;
11+
12+
import java.nio.charset.StandardCharsets;
13+
import java.security.MessageDigest;
14+
import java.security.NoSuchAlgorithmException;
15+
import java.security.SecureRandom;
16+
import java.util.HashMap;
17+
import java.util.List;
18+
import java.util.Locale;
19+
import java.util.Map;
20+
21+
/**
22+
* Builds the Authorization header for each request. Basic is sent pre-emptively, as cameras
23+
* running OpenIPC expect; after a Digest challenge every request is signed with Digest.
24+
*/
25+
class RtspAuth {
26+
private final String user;
27+
private final String password;
28+
29+
private boolean digest;
30+
private String realm;
31+
private String nonce;
32+
private String opaque;
33+
private String algorithm;
34+
private boolean qopAuth;
35+
private int nonceCount;
36+
/** MD5-sess: one client nonce per server nonce, used in H(A1) and sent every time. */
37+
private String sessCnonce;
38+
/** Fixed client nonce for tests; random when null. */
39+
String testCnonce;
40+
41+
/** @param userInfo decoded "user:password" from the URL, or null */
42+
RtspAuth(String userInfo) {
43+
if (userInfo == null) {
44+
user = null;
45+
password = null;
46+
} else {
47+
int colon = userInfo.indexOf(':');
48+
user = colon >= 0 ? userInfo.substring(0, colon) : userInfo;
49+
password = colon >= 0 ? userInfo.substring(colon + 1) : "";
50+
}
51+
}
52+
53+
boolean hasCredentials() { return user != null; }
54+
55+
/** "Authorization: ...\r\n" for this request, or "" without credentials. */
56+
String header(String method, String uri) {
57+
if (user == null) return "";
58+
if (!digest) {
59+
String token = Base64.encodeToString(
60+
(user + ":" + password).getBytes(StandardCharsets.UTF_8), Base64.NO_WRAP);
61+
return "Authorization: Basic " + token + "\r\n";
62+
}
63+
64+
String ha1 = md5(user + ":" + realm + ":" + password);
65+
String cnonce = null;
66+
String nc = null;
67+
if ("MD5-SESS".equals(algorithm)) {
68+
if (sessCnonce == null) sessCnonce = newCnonce();
69+
cnonce = sessCnonce;
70+
ha1 = md5(ha1 + ":" + nonce + ":" + cnonce);
71+
} else if (qopAuth) {
72+
cnonce = newCnonce();
73+
}
74+
String ha2 = md5(method + ":" + uri);
75+
String response;
76+
if (qopAuth) {
77+
nc = String.format(Locale.ROOT, "%08x", ++nonceCount);
78+
response = md5(ha1 + ":" + nonce + ":" + nc + ":" + cnonce + ":auth:" + ha2);
79+
} else {
80+
response = md5(ha1 + ":" + nonce + ":" + ha2);
81+
}
82+
83+
StringBuilder h = new StringBuilder("Authorization: Digest ");
84+
h.append("username=").append(quote(user)).append(", ");
85+
h.append("realm=").append(quote(realm)).append(", ");
86+
h.append("nonce=").append(quote(nonce)).append(", ");
87+
h.append("uri=").append(quote(uri)).append(", ");
88+
h.append("response=\"").append(response).append('"');
89+
if (algorithm != null) h.append(", algorithm=").append(algorithm);
90+
if (opaque != null) h.append(", opaque=").append(quote(opaque));
91+
if (qopAuth) h.append(", qop=auth, nc=").append(nc);
92+
// MD5-sess needs the cnonce even without qop, or the server cannot verify H(A1)
93+
if (cnonce != null) h.append(", cnonce=").append(quote(cnonce));
94+
return h.append("\r\n").toString();
95+
}
96+
97+
/**
98+
* Handle a 401 reply. Returns true if the request should be repeated: a first Digest
99+
* challenge, or a stale nonce. Returns false when retrying cannot help (no
100+
* credentials, an unsupported scheme, or the same credentials were already rejected).
101+
*/
102+
boolean onUnauthorized(List<String> challenges) {
103+
if (user == null || challenges == null) return false;
104+
for (String c : challenges) {
105+
String trimmed = c.trim();
106+
if (!trimmed.regionMatches(true, 0, "Digest", 0, 6)) continue;
107+
Map<String, String> p = parseParams(trimmed.substring(6));
108+
String alg = p.get("algorithm");
109+
if (alg != null && !alg.equalsIgnoreCase("MD5") && !alg.equalsIgnoreCase("MD5-sess")) {
110+
continue; // SHA-256 etc. are not supported
111+
}
112+
String qop = p.get("qop");
113+
boolean hasAuth = false;
114+
if (qop != null) {
115+
for (String q : qop.split(",")) {
116+
if (q.trim().equalsIgnoreCase("auth")) hasAuth = true;
117+
}
118+
// Only auth-int offered: we cannot produce that, look for another challenge
119+
if (!hasAuth) continue;
120+
}
121+
boolean stale = "true".equalsIgnoreCase(p.get("stale"));
122+
boolean retry = !digest || stale;
123+
digest = true;
124+
realm = p.containsKey("realm") ? p.get("realm") : "";
125+
nonce = p.containsKey("nonce") ? p.get("nonce") : "";
126+
opaque = p.get("opaque");
127+
algorithm = alg != null ? alg.toUpperCase(Locale.ROOT) : null;
128+
qopAuth = hasAuth;
129+
nonceCount = 0;
130+
sessCnonce = null;
131+
return retry;
132+
}
133+
// Basic was already sent pre-emptively: another attempt would fail the same way
134+
return false;
135+
}
136+
137+
/** Parse "k=v, k="quoted, value", ..." into lower-case keys. */
138+
static Map<String, String> parseParams(String s) {
139+
Map<String, String> out = new HashMap<>();
140+
int i = 0;
141+
int n = s.length();
142+
while (i < n) {
143+
while (i < n && (s.charAt(i) == ',' || Character.isWhitespace(s.charAt(i)))) i++;
144+
int eq = s.indexOf('=', i);
145+
if (eq < 0) break;
146+
String key = s.substring(i, eq).trim().toLowerCase(Locale.ROOT);
147+
i = eq + 1;
148+
while (i < n && Character.isWhitespace(s.charAt(i))) i++;
149+
StringBuilder value = new StringBuilder();
150+
if (i < n && s.charAt(i) == '"') {
151+
i++;
152+
while (i < n && s.charAt(i) != '"') {
153+
if (s.charAt(i) == '\\' && i + 1 < n) i++;
154+
value.append(s.charAt(i++));
155+
}
156+
i++; // closing quote
157+
} else {
158+
while (i < n && s.charAt(i) != ',') value.append(s.charAt(i++));
159+
}
160+
out.put(key, value.toString().trim());
161+
}
162+
return out;
163+
}
164+
165+
private String newCnonce() {
166+
return testCnonce != null ? testCnonce : randomHex(8);
167+
}
168+
169+
/** Quoted-string with '"' and '\\' escaped (RFC 2616 2.2). */
170+
static String quote(String v) {
171+
return '"' + v.replace("\\", "\\\\").replace("\"", "\\\"") + '"';
172+
}
173+
174+
static String md5(String s) {
175+
try {
176+
byte[] d = MessageDigest.getInstance("MD5").digest(s.getBytes(StandardCharsets.UTF_8));
177+
StringBuilder hex = new StringBuilder(32);
178+
for (byte b : d) hex.append(String.format(Locale.ROOT, "%02x", b & 0xFF));
179+
return hex.toString();
180+
} catch (NoSuchAlgorithmException e) {
181+
throw new IllegalStateException(e);
182+
}
183+
}
184+
185+
private static String randomHex(int bytes) {
186+
byte[] b = new byte[bytes];
187+
new SecureRandom().nextBytes(b);
188+
StringBuilder hex = new StringBuilder(bytes * 2);
189+
for (byte x : b) hex.append(String.format(Locale.ROOT, "%02x", x & 0xFF));
190+
return hex.toString();
191+
}
192+
}

0 commit comments

Comments
 (0)