diff --git a/README.md b/README.md index 210809ba..cb3c2bc8 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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). @@ -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"`). diff --git a/README.ru.md b/README.ru.md index dc57b62f..1a05c58a 100644 --- a/README.ru.md +++ b/README.ru.md @@ -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 слота для камер, приём ссылок из других приложений @@ -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). @@ -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"`). diff --git a/README.zh-CN.md b/README.zh-CN.md index 87f73038..a6f08c21 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -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 个摄像机槽位,可接收其他应用分享的地址 @@ -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)下。 @@ -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"`)。 diff --git a/android/app/src/main/java/com/openipc/decoder/RtspAuth.java b/android/app/src/main/java/com/openipc/decoder/RtspAuth.java new file mode 100644 index 00000000..160f7ba8 --- /dev/null +++ b/android/app/src/main/java/com/openipc/decoder/RtspAuth.java @@ -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 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 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 parseParams(String s) { + Map 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(); + } +} diff --git a/android/app/src/main/java/com/openipc/decoder/RtspSession.java b/android/app/src/main/java/com/openipc/decoder/RtspSession.java index 592fa15c..030f6cd9 100644 --- a/android/app/src/main/java/com/openipc/decoder/RtspSession.java +++ b/android/app/src/main/java/com/openipc/decoder/RtspSession.java @@ -7,7 +7,6 @@ package com.openipc.decoder; -import android.util.Base64; import android.util.Log; import java.io.BufferedInputStream; @@ -18,7 +17,9 @@ import java.net.InetSocketAddress; import java.net.Socket; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import java.util.Locale; import java.util.Map; @@ -38,6 +39,8 @@ class RtspSession { // Not final so unit tests can shorten them static volatile long watchdogMs = 8000; static volatile int minRetryMs = 1000; + /** Lower bound for the keepalive interval (half the session timeout). */ + private static final int MIN_KEEPALIVE_MS = 250; static volatile int maxRetryMs = 30000; interface Callback { @@ -69,6 +72,8 @@ interface Callback { private Thread watchdogThread; private volatile boolean active; + /** Keepalive state of the connection that is playing; sent by the watchdog thread. */ + private volatile Keepalive keepalive; private volatile long lastPacketMs; private volatile long streamBytes; private volatile long bitrateMarkNs; @@ -164,6 +169,21 @@ static long retryDelay(int retryMs) { private void watchdogLoop(int gen) { while (gen == generation) { + Keepalive k = keepalive; + long nowMs = System.nanoTime() / 1_000_000; + if (active && k != null && nowMs - k.lastSentMs >= k.intervalMs) { + // From this thread, so that an idle stream (reader blocked) still gets them + k.pending = k.useOptions ? "OPTIONS" : "GET_PARAMETER"; + k.lastSentMs = nowMs; + try { + send(k.conn, k.pending, k.url, "Session: " + k.session + "\r\n"); + } catch (IOException e) { + Log.w(TAG, tag + ": keepalive failed: " + e.getMessage()); + synchronized (lock) { + if (gen == generation) closeSocketLocked(); + } + } + } if (active && System.nanoTime() / 1_000_000 - lastPacketMs > watchdogMs) { Log.w(TAG, tag + ": stream is inactive, reconnecting"); active = false; @@ -172,7 +192,7 @@ private void watchdogLoop(int gen) { } } try { - Thread.sleep(Math.min(1000, watchdogMs / 4 + 1)); + Thread.sleep(Math.min(250, watchdogMs / 4 + 1)); } catch (InterruptedException e) { break; } @@ -187,6 +207,7 @@ private boolean connect(int gen) throws IOException { if (gen != generation) return false; socket = s; } + Conn c = null; try { s.connect(new InetSocketAddress(u.host, u.port), CONNECT_TIMEOUT_MS); s.setTcpNoDelay(true); @@ -194,15 +215,9 @@ private boolean connect(int gen) throws IOException { BufferedInputStream in = new BufferedInputStream(s.getInputStream(), 65536); OutputStream out = s.getOutputStream(); - String auth = u.userInfo != null - ? "Authorization: Basic " + Base64.encodeToString( - u.userInfo.getBytes(StandardCharsets.UTF_8), Base64.NO_WRAP) + "\r\n" - : ""; - String common = auth + userAgent; - int cseq = 1; + c = new Conn(in, out, new RtspAuth(u.userInfo)); - Response r = request(in, out, "DESCRIBE", u.requestUrl, cseq++, - common + "Accept: application/sdp\r\n"); + Response r = exchange(c, "DESCRIBE", u.requestUrl, "Accept: application/sdp\r\n"); r.require("DESCRIBE"); if (r.header("content-length") == null && r.body.isEmpty()) { // Some servers omit Content-Length: take what arrives until the line goes quiet @@ -215,17 +230,18 @@ private boolean connect(int gen) throws IOException { throw new IOException("No H.264/H.265 video track in SDP"); } - r = request(in, out, "SETUP", sdp.videoUrl, cseq++, - common + "Transport: RTP/AVP/TCP;unicast;interleaved=0-1\r\n"); + r = exchange(c, "SETUP", sdp.videoUrl, + "Transport: RTP/AVP/TCP;unicast;interleaved=0-1\r\n"); r.require("SETUP video"); - String session = r.header("session"); - if (session == null) throw new IOException("No Session header in SETUP reply"); - session = session.split(";")[0].trim(); + String sessionHeader = r.header("session"); + if (sessionHeader == null) throw new IOException("No Session header in SETUP reply"); + String session = sessionHeader.split(";")[0].trim(); + long keepaliveMs = keepaliveInterval(sessionHeader); boolean audio = withAudio && sdp.audioUrl != null && !AudioConfig.NONE.equals(sdp.audio.codec); if (audio) { - r = request(in, out, "SETUP", sdp.audioUrl, cseq++, common + + r = exchange(c, "SETUP", sdp.audioUrl, "Transport: RTP/AVP/TCP;unicast;interleaved=2-3\r\n" + "Session: " + session + "\r\n"); if (!r.ok()) { @@ -240,8 +256,7 @@ private boolean connect(int gen) throws IOException { callback.onSdp(sdp); // PLAY on the request URL, as before: accepted by the servers seen so far - r = request(in, out, "PLAY", u.requestUrl, cseq++, - common + "Session: " + session + "\r\n"); + r = exchange(c, "PLAY", u.requestUrl, "Session: " + session + "\r\n"); r.require("PLAY"); synchronized (lock) { @@ -250,14 +265,17 @@ private boolean connect(int gen) throws IOException { streamBytes = 0; bitrateMarkNs = System.nanoTime(); active = true; + keepalive = new Keepalive(c, u.requestUrl, session, keepaliveMs); } status(gen, "buffering"); s.setSoTimeout(0); // the watchdog handles silence - receive(gen, in); + receive(gen, c); return true; } finally { synchronized (lock) { if (socket == s) socket = null; + Keepalive k = keepalive; + if (k != null && k.conn == c) keepalive = null; } try { s.close(); @@ -265,11 +283,29 @@ private boolean connect(int gen) throws IOException { } } - private void receive(int gen, BufferedInputStream in) throws IOException { + /** Receive loop; keepalive replies are recognised between interleaved packets. */ + private void receive(int gen, Conn c) throws IOException { byte[] buf = new byte[65535 + 4]; boolean first = true; while (active && gen == generation) { - int total = readInterleavedPacket(in, buf); + if (isResponseNext(c.in)) { + Response r = readResponse(c.in); + Keepalive k = keepalive; + if (r.code == 401) { + if (!c.auth.onUnauthorized(r.authenticate)) { + throw new IOException("Keepalive rejected: authentication failed"); + } + if (k != null) k.lastSentMs = 0; // repeat it now with the new nonce + } else if (r.code == 454) { + throw new IOException("Session expired on the server"); + } else if (!r.ok() && k != null && "GET_PARAMETER".equals(k.pending)) { + Log.i(TAG, tag + ": GET_PARAMETER rejected (" + r.code + "), using OPTIONS"); + k.useOptions = true; + } + continue; + } + + int total = readInterleavedPacket(c.in, buf); if (total < 0) throw new IOException("Connection closed by server"); streamBytes += total; int channel = buf[1] & 0xFF; @@ -286,11 +322,56 @@ private void receive(int gen, BufferedInputStream in) throws IOException { } } + private static final class Keepalive { + final Conn conn; + final String url; + final String session; + final long intervalMs; + volatile long lastSentMs = System.nanoTime() / 1_000_000; + volatile boolean useOptions; + volatile String pending; + + Keepalive(Conn conn, String url, String session, long intervalMs) { + this.conn = conn; + this.url = url; + this.session = session; + this.intervalMs = intervalMs; + } + } + + /** True if the next bytes are an RTSP reply ("RTSP/") rather than interleaved data. */ + static boolean isResponseNext(BufferedInputStream in) throws IOException { + in.mark(5); + try { + if (in.read() != 'R') return false; + return in.read() == 'T' && in.read() == 'S' && in.read() == 'P' && in.read() == '/'; + } finally { + in.reset(); + } + } + + /** Half the server's session timeout (default 60 s), at least 250 ms. */ + static long keepaliveInterval(String sessionHeader) { + int timeoutSec = 60; + for (String part : sessionHeader.split(";")) { + String p = part.trim(); + if (p.regionMatches(true, 0, "timeout=", 0, 8)) { + try { + int t = Integer.parseInt(p.substring(8).trim()); + if (t > 0) timeoutSec = t; + } catch (NumberFormatException ignored) {} + } + } + return Math.max(MIN_KEEPALIVE_MS, timeoutSec * 1000L / 2); + } + // ====================== Protocol helpers ====================== static final class Response { int code; final Map headers = new HashMap<>(); + /** Every WWW-Authenticate value; a server may offer several schemes. */ + final List authenticate = new ArrayList<>(); String body = ""; boolean ok() { return code >= 200 && code < 300; } @@ -302,13 +383,45 @@ void require(String what) throws IOException { } } - private Response request(InputStream in, OutputStream out, String method, String target, - int cseq, String headers) throws IOException { - String req = method + " " + target + " RTSP/1.0\r\n" + - "CSeq: " + cseq + "\r\n" + headers + "\r\n"; - out.write(req.getBytes(StandardCharsets.UTF_8)); - out.flush(); - return readResponse(in); + /** Connection state for one session: streams, CSeq counter and authentication. */ + private final class Conn { + final BufferedInputStream in; + final OutputStream out; + final RtspAuth auth; + int cseq = 1; + + Conn(BufferedInputStream in, OutputStream out, RtspAuth auth) { + this.in = in; + this.out = out; + this.auth = auth; + } + } + + /** Write one request. Synchronized: keepalives are sent from the watchdog thread. */ + private void send(Conn c, String method, String target, String headers) throws IOException { + synchronized (c) { + String req = method + " " + target + " RTSP/1.0\r\n" + + "CSeq: " + c.cseq++ + "\r\n" + c.auth.header(method, target) + userAgent + + headers + "\r\n"; + c.out.write(req.getBytes(StandardCharsets.UTF_8)); + c.out.flush(); + } + } + + /** Send a request and read its reply, answering one authentication challenge. */ + private Response exchange(Conn c, String method, String target, String headers) + throws IOException { + send(c, method, target, headers); + Response r = readResponse(c.in); + if (r.code == 401 && c.auth.onUnauthorized(r.authenticate)) { + send(c, method, target, headers); + r = readResponse(c.in); + } + if (r.code == 401) { + throw new IOException(method + ": authentication failed" + + (c.auth.hasCredentials() ? "" : " (no user:password in the URL)")); + } + return r; } /** @@ -348,8 +461,10 @@ static Response readResponse(InputStream in) throws IOException { Log.d(TAG, line); int colon = line.indexOf(':'); if (colon > 0) { - r.headers.put(line.substring(0, colon).trim().toLowerCase(Locale.ROOT), - line.substring(colon + 1).trim()); + String name = line.substring(0, colon).trim().toLowerCase(Locale.ROOT); + String value = line.substring(colon + 1).trim(); + r.headers.put(name, value); + if (name.equals("www-authenticate")) r.authenticate.add(value); } } @@ -503,6 +618,12 @@ static Url parse(String url) throws IOException { if (at >= 0) { u.userInfo = percentDecode(authority.substring(0, at)); authority = authority.substring(at + 1); + // A decoded CR/LF would end the Authorization header and inject new ones + for (int i = 0; i < u.userInfo.length(); i++) { + if (u.userInfo.charAt(i) < 0x20 || u.userInfo.charAt(i) == 0x7F) { + throw new IOException("Invalid character in user name or password"); + } + } } String hostPort = authority; if (hostPort.startsWith("[")) { diff --git a/android/app/src/test/java/com/openipc/decoder/RtspAuthTest.java b/android/app/src/test/java/com/openipc/decoder/RtspAuthTest.java new file mode 100644 index 00000000..384d773a --- /dev/null +++ b/android/app/src/test/java/com/openipc/decoder/RtspAuthTest.java @@ -0,0 +1,152 @@ +/* + * Copyright (c) OpenIPC https://openipc.org Prosperity Public License 3.0.0 + * + * RtspAuthTest.java — Basic and Digest authentication + * + */ + +package com.openipc.decoder; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.Map; + +public class RtspAuthTest { + private static final String RFC_CHALLENGE = "Digest realm=\"testrealm@host.com\", " + + "qop=\"auth,auth-int\", nonce=\"dcd98b7102dd2f0e8b11d0f600bfb0c093\", " + + "opaque=\"5ccc069c403ebaf9f0171e9517f40e41\""; + + @Test + public void rfc2617ExampleResponse() { + // RFC 2617 section 3.5 + RtspAuth auth = new RtspAuth("Mufasa:Circle Of Life"); + auth.testCnonce = "0a4f113b"; + assertTrue(auth.onUnauthorized(Collections.singletonList(RFC_CHALLENGE))); + String h = auth.header("GET", "/dir/index.html"); + assertTrue(h, h.startsWith("Authorization: Digest ")); + assertTrue(h, h.contains("response=\"6629fae49393a05397450978507c4ef1\"")); + assertTrue(h, h.contains("nc=00000001")); + assertTrue(h, h.contains("qop=auth")); + assertTrue(h, h.contains("opaque=\"5ccc069c403ebaf9f0171e9517f40e41\"")); + // The nonce count increases with every request + assertTrue(auth.header("GET", "/dir/index.html").contains("nc=00000002")); + } + + @Test + public void digestWithoutQop() { + RtspAuth auth = new RtspAuth("admin:12345"); + auth.onUnauthorized(Collections.singletonList("Digest realm=\"cam\", nonce=\"abc\"")); + String h = auth.header("DESCRIBE", "rtsp://cam/s"); + String ha1 = RtspAuth.md5("admin:cam:12345"); + String ha2 = RtspAuth.md5("DESCRIBE:rtsp://cam/s"); + assertTrue(h.contains("response=\"" + RtspAuth.md5(ha1 + ":abc:" + ha2) + "\"")); + assertFalse(h.contains("qop=")); + } + + @Test + public void basicIsPreemptiveAndDigestIsPreferred() { + RtspAuth auth = new RtspAuth("admin:pw"); + assertTrue(auth.header("DESCRIBE", "rtsp://cam/s").startsWith("Authorization: Basic ")); + // Basic offered first, Digest second: Digest is chosen + assertTrue(auth.onUnauthorized(Arrays.asList("Basic realm=\"cam\"", + "Digest realm=\"cam\", nonce=\"n1\""))); + assertTrue(auth.header("DESCRIBE", "rtsp://cam/s").startsWith("Authorization: Digest ")); + } + + @Test + public void noEndlessRetries() { + RtspAuth auth = new RtspAuth("admin:wrong"); + String c = "Digest realm=\"cam\", nonce=\"n1\""; + assertTrue(auth.onUnauthorized(Collections.singletonList(c))); + // Same credentials rejected again: do not retry + assertFalse(auth.onUnauthorized(Collections.singletonList(c))); + // ...unless the server says the nonce was stale + assertTrue(auth.onUnauthorized(Collections.singletonList(c + ", stale=TRUE"))); + // Basic only: Basic was already sent + assertFalse(new RtspAuth("a:b").onUnauthorized( + Collections.singletonList("Basic realm=\"cam\""))); + // No credentials at all + assertFalse(new RtspAuth(null).onUnauthorized(Collections.singletonList(c))); + assertEquals("", new RtspAuth(null).header("DESCRIBE", "rtsp://cam/s")); + } + + @Test + public void unsupportedAlgorithmIsSkipped() { + RtspAuth auth = new RtspAuth("a:b"); + assertFalse(auth.onUnauthorized(Collections.singletonList( + "Digest realm=\"cam\", nonce=\"n\", algorithm=SHA-256"))); + assertTrue(auth.onUnauthorized(Arrays.asList( + "Digest realm=\"cam\", nonce=\"n\", algorithm=SHA-256", + "Digest realm=\"cam\", nonce=\"n\", algorithm=MD5"))); + } + + @Test + public void md5SessUsesClientNonce() { + RtspAuth auth = new RtspAuth("u:p"); + auth.testCnonce = "c1"; + auth.onUnauthorized(Collections.singletonList( + "Digest realm=\"r\", nonce=\"n\", algorithm=MD5-sess")); + String ha1 = RtspAuth.md5(RtspAuth.md5("u:r:p") + ":n:c1"); + String ha2 = RtspAuth.md5("PLAY:rtsp://x"); + String h = auth.header("PLAY", "rtsp://x"); + assertTrue(h, h.contains("response=\"" + RtspAuth.md5(ha1 + ":n:" + ha2) + "\"")); + assertTrue(h, h.contains("algorithm=MD5-SESS")); + } + + @Test + public void parseParamsHandlesQuotedCommas() { + Map p = RtspAuth.parseParams( + " realm=\"a, b\", nonce=xyz ,qop=\"auth,auth-int\", empty=\"\""); + assertEquals("a, b", p.get("realm")); + assertEquals("xyz", p.get("nonce")); + assertEquals("auth,auth-int", p.get("qop")); + assertEquals("", p.get("empty")); + } + + @Test + public void authIntOnlyChallengeIsSkipped() { + RtspAuth auth = new RtspAuth("u:p"); + assertTrue(auth.onUnauthorized(Arrays.asList( + "Digest realm=\"r\", nonce=\"a\", qop=\"auth-int\"", + "Digest realm=\"r\", nonce=\"b\", qop=\"auth\""))); + String h = auth.header("DESCRIBE", "rtsp://x"); + assertTrue(h, h.contains("nonce=\"b\"")); + assertTrue(h, h.contains("qop=auth")); + assertFalse(new RtspAuth("u:p").onUnauthorized(Collections.singletonList( + "Digest realm=\"r\", nonce=\"a\", qop=\"auth-int\""))); + } + + @Test + public void md5SessWithoutQopSendsAStableCnonce() { + RtspAuth auth = new RtspAuth("u:p"); + auth.onUnauthorized(Collections.singletonList( + "Digest realm=\"r\", nonce=\"n\", algorithm=MD5-sess")); + Map first = RtspAuth.parseParams( + auth.header("SETUP", "rtsp://x").substring("Authorization: Digest ".length())); + Map second = RtspAuth.parseParams( + auth.header("PLAY", "rtsp://x").substring("Authorization: Digest ".length())); + String cnonce = first.get("cnonce"); + assertTrue("cnonce sent", cnonce != null && !cnonce.isEmpty()); + assertEquals("same cnonce for the whole nonce", cnonce, second.get("cnonce")); + String ha1 = RtspAuth.md5(RtspAuth.md5("u:r:p") + ":n:" + cnonce); + assertEquals(RtspAuth.md5(ha1 + ":n:" + RtspAuth.md5("PLAY:rtsp://x")), second.get("response")); + } + + @Test + public void quotedValuesAreEscaped() { + assertEquals("\"a\\\"b\\\\c\"", RtspAuth.quote("a\"b\\c")); + RtspAuth auth = new RtspAuth("we\"ird:p"); + auth.onUnauthorized(Collections.singletonList("Digest realm=\"r\\\"x\", nonce=\"n\"")); + String h = auth.header("DESCRIBE", "rtsp://x"); + // Parsing our own header gives back the original values + Map p = RtspAuth.parseParams(h.substring("Authorization: Digest ".length())); + assertEquals("we\"ird", p.get("username")); + assertEquals("r\"x", p.get("realm")); + } +} diff --git a/android/app/src/test/java/com/openipc/decoder/RtspSessionTest.java b/android/app/src/test/java/com/openipc/decoder/RtspSessionTest.java index 1b489b50..6f709785 100644 --- a/android/app/src/test/java/com/openipc/decoder/RtspSessionTest.java +++ b/android/app/src/test/java/com/openipc/decoder/RtspSessionTest.java @@ -52,6 +52,18 @@ private static final class FakeServer implements AutoCloseable { volatile int videoPackets = 3; /** Send the DESCRIBE body without a Content-Length header. */ volatile boolean omitContentLength; + /** When set, every request must carry a valid Digest response for these credentials. */ + volatile String digestUser; + volatile String digestPassword; + /** Session timeout announced in SETUP replies. */ + volatile int sessionTimeout = 60; + /** Reply 501 to GET_PARAMETER. */ + volatile boolean rejectGetParameter; + /** Reply 401 (Digest, never stale) to every GET_PARAMETER. */ + volatile boolean keepaliveUnauthorized; + /** After PLAY keep sending video packets every 20 ms instead of a fixed burst. */ + volatile boolean streamContinuously; + final AtomicInteger unauthorized = new AtomicInteger(); volatile int audioPackets = 1; FakeServer() throws IOException { @@ -97,7 +109,17 @@ private void serve(Socket s, int n) { } requests.add(requestLine + "\n" + headers); String method = requestLine.split(" ")[0]; + String uri = requestLine.split(" ")[1]; String reply; + if (digestUser != null && !digestOk(headers.toString(), method, uri)) { + unauthorized.incrementAndGet(); + reply = "RTSP/1.0 401 Unauthorized\r\nCSeq: " + cseq + "\r\n" + + "WWW-Authenticate: Basic realm=\"cam\"\r\n" + + "WWW-Authenticate: Digest realm=\"cam\", nonce=\"N0NCE\", " + + "qop=\"auth\"\r\n\r\n"; + write(out, reply); + continue; + } switch (method) { case "DESCRIBE": byte[] body = sdp.getBytes(StandardCharsets.UTF_8); @@ -109,28 +131,74 @@ private void serve(Socket s, int n) { case "SETUP": int code = requestLine.contains("trackID=1") ? audioSetupStatus : 200; reply = "RTSP/1.0 " + code + (code == 200 ? " OK" : " Not Found") - + "\r\nCSeq: " + cseq + "\r\nSession: ABC123;timeout=60\r\n\r\n"; + + "\r\nCSeq: " + cseq + "\r\nSession: ABC123;timeout=" + + sessionTimeout + "\r\n\r\n"; + break; + case "GET_PARAMETER": + if (keepaliveUnauthorized) { + reply = "RTSP/1.0 401 Unauthorized\r\nCSeq: " + cseq + "\r\n" + + "WWW-Authenticate: Digest realm=\"cam\", nonce=\"K\"\r\n\r\n"; + break; + } + reply = rejectGetParameter + ? "RTSP/1.0 501 Not Implemented\r\nCSeq: " + cseq + "\r\n\r\n" + : "RTSP/1.0 200 OK\r\nCSeq: " + cseq + "\r\nSession: ABC123\r\n\r\n"; break; default: reply = "RTSP/1.0 200 OK\r\nCSeq: " + cseq + "\r\n\r\n"; break; } - out.write(reply.getBytes(StandardCharsets.UTF_8)); - out.flush(); + write(out, reply); + if (method.equals("PLAY") && streamContinuously) { + Thread writer = new Thread(() -> { + try { + while (!socket.isClosed()) { + synchronized (out) { + out.write(packet(0, 96)); + out.flush(); + } + Thread.sleep(20); + } + } catch (Exception ignored) { + } + }); + writer.setDaemon(true); + writer.start(); + continue; // keep answering requests (keepalives) while streaming + } if (method.equals("PLAY")) { for (int i = 0; i < videoPackets; i++) out.write(packet(0, 96)); for (int i = 0; i < audioPackets; i++) out.write(packet(2, 98)); out.flush(); if (closeAfterPackets) return; - // Keep the session open until the client goes away - while (in.read() >= 0) { /* ignore */ } - return; + // Stay idle but keep answering requests (keepalives) + continue; } } } catch (IOException ignored) { } } + private static void write(OutputStream out, String reply) throws IOException { + synchronized (out) { + out.write(reply.getBytes(StandardCharsets.UTF_8)); + out.flush(); + } + } + + private boolean digestOk(String headers, String method, String uri) { + for (String h : headers.split("\n")) { + if (!h.startsWith("Authorization: Digest ")) continue; + java.util.Map p = RtspAuth.parseParams(h.substring(22)); + String ha1 = RtspAuth.md5(digestUser + ":cam:" + digestPassword); + String ha2 = RtspAuth.md5(method + ":" + uri); + String expected = RtspAuth.md5(ha1 + ":N0NCE:" + p.get("nc") + ":" + + p.get("cnonce") + ":auth:" + ha2); + return expected.equals(p.get("response")) && uri.equals(p.get("uri")); + } + return false; + } + static byte[] packet(int channel, int pt) { byte[] p = new byte[4 + 14]; p[0] = '$'; @@ -341,4 +409,102 @@ public void retryDelayNeverExceedsMaximum() { assertTrue(d >= 1000 && d <= 1500); } } + + @Test + public void digestAuthentication() throws Exception { + // #8: the server offers Basic and Digest and accepts only Digest + server.digestUser = "admin"; + server.digestPassword = "s3cr3t"; + runUntilVideo(server.url("admin:s3cr3t"), true); + List m = server.methods(); + assertTrue("DESCRIBE retried with Digest", m.get(1).startsWith("DESCRIBE")); + assertTrue(m.get(m.size() - 1).startsWith("PLAY")); + assertEquals("only the first request is challenged", 1, server.unauthorized.get()); + } + + @Test + public void wrongPasswordDoesNotLoop() throws Exception { + server.digestUser = "admin"; + server.digestPassword = "s3cr3t"; + session.configure(server.url("admin:wrong"), "", true); + session.start(); + Thread.sleep(500); + session.stop(); + Thread.sleep(100); + assertEquals(0, rec.sdpCount.get()); + // Each connection: Basic DESCRIBE + one Digest DESCRIBE, no endless retries + int connections = server.connections.get(); + assertTrue(connections >= 1); + assertTrue("at most 2 requests per connection", + server.methods().size() <= connections * 2); + } + + @Test + public void keepaliveUsesGetParameter() throws Exception { + // #11: timeout=2 s -> a keepalive every second + server.sessionTimeout = 2; + server.streamContinuously = true; + runUntilVideo(server.url(null), true); + Thread.sleep(2500); + long keepalives = server.methods().stream() + .filter(x -> x.startsWith("GET_PARAMETER")).count(); + assertTrue("keepalives sent: " + keepalives, keepalives >= 2); + assertTrue(session.isActive()); + assertEquals(1, server.connections.get()); + String ka = server.requests.stream().filter(x -> x.startsWith("GET_PARAMETER")) + .findFirst().get(); + assertTrue(ka.contains("Session: ABC123")); + } + + @Test + public void keepaliveFallsBackToOptions() throws Exception { + server.sessionTimeout = 2; + server.streamContinuously = true; + server.rejectGetParameter = true; + runUntilVideo(server.url(null), true); + Thread.sleep(3200); + List m = server.methods(); + assertEquals(1, m.stream().filter(x -> x.startsWith("GET_PARAMETER")).count()); + assertTrue(m.stream().anyMatch(x -> x.startsWith("OPTIONS"))); + assertTrue(session.isActive()); + assertEquals(1, server.connections.get()); + } + + @Test + public void keepaliveIntervalFromSessionHeader() { + assertEquals(30000, RtspSession.keepaliveInterval("ABC")); + assertEquals(5000, RtspSession.keepaliveInterval("ABC;timeout=10")); + // Half of a one-second session, not the whole second + assertEquals(500, RtspSession.keepaliveInterval("ABC; timeout=1")); + assertEquals(30000, RtspSession.keepaliveInterval("ABC;timeout=junk")); + assertEquals(30000, RtspSession.keepaliveInterval("ABC;timeout=0")); + } + + @Test + public void keepaliveIsSentWhileTheStreamIsIdle() throws Exception { + // Watchdog long enough not to interfere: only the keepalive is under test + RtspSession.watchdogMs = 10000; + server.sessionTimeout = 2; // keepalive every second + runUntilVideo(server.url(null), true); // 3 packets, then the server goes quiet + Thread.sleep(2600); + long keepalives = server.methods().stream() + .filter(x -> x.startsWith("GET_PARAMETER")).count(); + assertTrue("keepalives while idle: " + keepalives, keepalives >= 2); + assertEquals(1, server.connections.get()); + } + + @Test + public void rejectedKeepaliveEndsTheSession() throws Exception { + server.sessionTimeout = 1; + server.streamContinuously = true; + server.keepaliveUnauthorized = true; + runUntilVideo(server.url("admin:pw"), true); + long deadline = System.nanoTime() + 4_000_000_000L; + while (server.connections.get() < 2 && System.nanoTime() < deadline) Thread.sleep(20); + assertTrue("reconnected after the keepalive was rejected", server.connections.get() >= 2); + // Bounded: Basic, then one Digest retry per connection, no endless repetition + long perFirstConnection = server.requests.stream() + .filter(x -> x.startsWith("GET_PARAMETER")).count(); + assertTrue(perFirstConnection <= 2 * server.connections.get()); + } } diff --git a/android/app/src/test/java/com/openipc/decoder/RtspUrlTest.java b/android/app/src/test/java/com/openipc/decoder/RtspUrlTest.java index 244d828e..a17c94f2 100644 --- a/android/app/src/test/java/com/openipc/decoder/RtspUrlTest.java +++ b/android/app/src/test/java/com/openipc/decoder/RtspUrlTest.java @@ -54,5 +54,9 @@ public void invalidUrls() { assertThrows(IOException.class, () -> RtspSession.Url.parse("192.168.1.10/stream")); assertThrows(IOException.class, () -> RtspSession.Url.parse("rtsp://:554/s")); assertThrows(IOException.class, () -> RtspSession.Url.parse("rtsp://cam:99999/s")); + // Percent-encoded CR/LF must not reach the Authorization header + assertThrows(IOException.class, + () -> RtspSession.Url.parse("rtsp://admin%0D%0AX-Injected:%201:pw@cam/s")); + assertThrows(IOException.class, () -> RtspSession.Url.parse("rtsp://admin:p%0Aw@cam/s")); } }