Skip to content

feat: RTSP Digest authentication and keepalive (#8, #11) - #46

Merged
compot-dev merged 3 commits into
v2from
feat/rtsp-auth-keepalive
Oct 12, 2026
Merged

compot-dev merged 3 commits into
v2from
feat/rtsp-auth-keepalive

Conversation

@compot-dev

Copy link
Copy Markdown
Collaborator

Fixes #8, fixes #11.

Digest authentication (#8)

New class RtspAuth, built per RFC 2617:

  • MD5 and MD5-sess;
  • qop=auth with nonce count (nc) and client nonce (cnonce);
  • opaque;
  • challenges with unsupported algorithms (SHA-256) are skipped.

Basic is still sent with the first request, as the app already does with OpenIPC cameras. If the camera replies 401:

  • the Digest challenge is chosen from all WWW-Authenticate headers (a camera may offer both Basic and Digest);
  • the request is repeated once;
  • wrong credentials fail the connection attempt (the usual back-off follows) instead of looping;
  • if the server reports stale=true, the request is retried with the new nonce.

Keepalive (#11)

  • After PLAY, GET_PARAMETER with Session is sent every half of the session timeout, taken from Session: …;timeout=N (60 s if absent). It goes over the same TCP connection from the network thread, so no extra thread or lock is needed.
  • If the server rejects GET_PARAMETER (501/405/…), OPTIONS is used from then on.
  • Replies are recognised between interleaved packets by the RTSP/ prefix:
    • 454 Session Not Found → reconnect;
    • 401 → the Digest nonce is refreshed for the next request.

README

Digest and keepalive are listed under features. #8, #9 (fixed in #45) and #11 are removed from "Known limitations" in all three languages.

Tests

68 in total; 12 are new.

  • RtspAuthTest:
    • the RFC 2617 example vector (6629fae49393a05397450978507c4ef1);
    • MD5-sess, Digest without qop;
    • Digest is chosen over Basic;
    • no endless retries; stale handling;
    • SHA-256 is skipped;
    • parameters with commas inside quotes.
  • RtspSessionTest, against the scripted server:
    • a camera that accepts only Digest;
    • a wrong password does not loop;
    • with timeout=2 a keepalive goes out every second while the stream keeps flowing on one connection;
    • OPTIONS fallback;
    • interval calculation.
  • Checked that the tests catch errors: disabling keepalive fails 2 tests, disabling the 401 handling fails 1.

Not tested on a device or with Digest cameras. Hikvision and Dahua cameras use Digest by default.

Digest (fixes #8):
- RtspAuth signs requests per RFC 2617: MD5 and MD5-sess, qop=auth with
  nonce count and client nonce, opaque; SHA-256 challenges are skipped.
- Basic is still sent pre-emptively, as before. On 401 the session picks
  the Digest challenge among all WWW-Authenticate headers and repeats the
  request once. Wrong credentials fail the attempt instead of looping;
  a stale nonce is retried.

Keepalive (fixes #11):
- After PLAY a GET_PARAMETER is sent every half session timeout (from
  "Session: ...;timeout=N", default 60 s) on the same connection. If the
  server rejects it, OPTIONS is used. Replies are recognised between
  interleaved packets; 454 (session gone) triggers a reconnect, 401
  refreshes the Digest nonce.

Tests: the RFC 2617 example vector, MD5-sess, qop-less Digest, scheme
choice, no endless retries, quoted parameters; against the scripted
server: Digest-only camera, wrong password, keepalive cadence and the
OPTIONS fallback.
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Add RTSP Digest authentication and session keepalive

✨ Enhancement 🧪 Tests 📝 Documentation 🕐 40+ Minutes

Grey Divider

AI Description

• Support Digest-only cameras while retaining preemptive Basic authentication and bounded challenge
 retries.
• Keep streaming sessions alive with timeout-based requests and an OPTIONS fallback.
• Add protocol tests and update feature and security guidance in three languages.
Diagram

sequenceDiagram
participant S as RTSP session
participant A as Auth signer
participant C as Camera server
S->>A: Build Basic header
S->>C: DESCRIBE
C-->>S: 401 Digest challenge
S->>A: Select challenge
S->>C: Retry with Digest
C-->>S: SDP response
S->>C: SETUP and PLAY
C-->>S: Session timeout and RTP
loop Half session timeout
S->>C: GET_PARAMETER with Session
C-->>S: Reply amid RTP
alt GET_PARAMETER rejected
S->>C: Subsequent OPTIONS
else Session expired
S->>S: Reconnect on 454
else Unauthorized
S->>A: Refresh nonce on 401
end
end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Schedule keepalives independently
  • ➕ Can send a keepalive on time even when the media receive loop is blocked waiting for packets.
  • ➖ Requires coordinated writes and reply handling across threads, increasing connection-state complexity.

Recommendation: Using the existing network thread keeps RTSP writes and response parsing together and is the simpler choice for actively streaming sessions. Reviewers should consider whether cameras that pause media need keepalives before the receive loop unblocks or the watchdog reconnects.

Files changed (7) +542 / -49

Enhancement (2) +288 / -28
RtspAuth.javaAdd stateful RTSP Basic and Digest authorization +176/-0

Add stateful RTSP Basic and Digest authorization

• Builds preemptive Basic headers and RFC 2617 MD5 or MD5-sess Digest headers, including qop=auth, nonce counts, client nonces, and opaque values. Selects supported Digest challenges, parses quoted parameters, and limits retries except for stale nonces.

android/app/src/main/java/com/openipc/decoder/RtspAuth.java

RtspSession.javaIntegrate Digest retries and in-band session keepalive +112/-28

Integrate Digest retries and in-band session keepalive

• Moves connection-scoped authentication and CSeq tracking into a shared request path. Schedules keepalives from the SETUP timeout, reads RTSP replies between interleaved packets, falls back to OPTIONS on rejection, and reconnects on session expiry.

android/app/src/main/java/com/openipc/decoder/RtspSession.java

Tests (2) +245 / -3
RtspAuthTest.javaTest Digest calculation and challenge selection +111/-0

Test Digest calculation and challenge selection

• Covers the RFC 2617 response vector, MD5-sess, Digest without qop, Basic-to-Digest selection, bounded retries, stale challenges, unsupported algorithms, and quoted commas.

android/app/src/test/java/com/openipc/decoder/RtspAuthTest.java

RtspSessionTest.javaExercise authentication and keepalive against a scripted server +134/-3

Exercise authentication and keepalive against a scripted server

• Extends the fake server to require Digest, advertise configurable timeouts, stream continuously, and reject GET_PARAMETER. Adds session tests for Digest-only access, wrong-password retry bounds, keepalive cadence, OPTIONS fallback, and timeout calculation.

android/app/src/test/java/com/openipc/decoder/RtspSessionTest.java

Documentation (3) +9 / -18
README.mdDocument authentication and keepalive support in English +3/-6

Document authentication and keepalive support in English

• Lists Basic, Digest, and keepalive as network features; removes resolved limitations for issues #8, #9, and #11. Clarifies that Basic is sent first and plain RTSP remains unencrypted.

README.md

README.ru.mdUpdate Russian network and security guidance +3/-6

Update Russian network and security guidance

• Adds Digest and keepalive to the feature list, removes the same resolved limitations, and explains the Basic-first behavior and remaining plaintext risk.

README.ru.md

README.zh-CN.mdUpdate Chinese network and security guidance +3/-6

Update Chinese network and security guidance

• Adds Digest and keepalive to the feature list, removes the same resolved limitations, and explains the Basic-first behavior and remaining plaintext risk.

README.zh-CN.md

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Idle streams can lose their RTSP session ✓ Resolved
Description
receive() checks the keepalive deadline only before blocking network reads, while connect()
disables the socket read timeout during playback. When a stream is idle, the network thread cannot
return to the deadline check to send GET_PARAMETER or OPTIONS before the advertised session
timeout.
Code

android/app/src/main/java/com/openipc/decoder/RtspSession.java[297]

+            int total = readInterleavedPacket(c.in, buf);
Evidence
The keepalive check runs once per receive-loop iteration, before reads that wait for network input.
Because the playback socket has no read timeout, a quiet stream can leave the response probe or
interleaved-packet reader blocked past the keepalive deadline, preventing the loop from sending
GET_PARAMETER or OPTIONS at approximately half the session timeout.

Send RTSP keepalives during playback
android/app/src/main/java/com/openipc/decoder/RtspSession.java[252-297]
android/app/src/main/java/com/openipc/decoder/RtspSession.java[250-253]
android/app/src/main/java/com/openipc/decoder/RtspSession.java[274-297]
android/app/src/main/java/com/openipc/decoder/RtspSession.java[314-323]
android/app/src/main/java/com/openipc/decoder/RtspSession.java[503-510]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The receive loop cannot send a scheduled keepalive while the network thread is blocked waiting for input on a quiet stream, so the RTSP session may expire.
## Fix Focus Areas
- android/app/src/main/java/com/openipc/decoder/RtspSession.java[250-253]
- android/app/src/main/java/com/openipc/decoder/RtspSession.java[265-297]
- android/app/src/main/java/com/openipc/decoder/RtspSession.java[314-323]
## Recommended Fix
Make network reads wake at keepalive deadlines, using a bounded socket read timeout, select/poll deadline, or deadline-aware incremental parsing. Recheck the deadline and send the scheduled keepalive even when no RTP packet arrives, without discarding partially received RTSP or interleaved frames. Test with a server that pauses packets longer than its keepalive interval.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. One-second sessions can expire early ✓ Resolved
Description
keepaliveInterval() clamps half the advertised session timeout to minKeepaliveMs, which defaults
to 1,000 milliseconds. When a server advertises timeout=1, the receive loop waits until the full
one-second expiry before sending the first keepalive, even if packets keep the loop running.
Code

android/app/src/main/java/com/openipc/decoder/RtspSession.java[R325-337]

+    /** Half the server's session timeout (default 60 s), at least minKeepaliveMs. */
+    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(minKeepaliveMs, timeoutSec * 1000L / 2);
Evidence
The interval calculation uses Math.max(minKeepaliveMs, timeoutSec * 1000L / 2), with
minKeepaliveMs defaulting to 1,000 ms. For timeoutSec=1, the half-timeout calculation yields 500
ms but the maximum returns 1,000 ms, which the receive loop waits before sending a keepalive.

Send RTSP keepalives during playback
android/app/src/main/java/com/openipc/decoder/RtspSession.java[42-43]
android/app/src/main/java/com/openipc/decoder/RtspSession.java[325-337]
android/app/src/main/java/com/openipc/decoder/RtspSession.java[39-44]
android/app/src/main/java/com/openipc/decoder/RtspSession.java[274-281]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The minimum keepalive interval can equal the entire lifetime of a one-second session instead of scheduling a keepalive at approximately half the advertised timeout.
## Fix Focus Areas
- android/app/src/main/java/com/openipc/decoder/RtspSession.java[325-337]
- android/app/src/main/java/com/openipc/decoder/RtspSession.java[274-281]
## Recommended Fix
Calculate the interval from half of a valid advertised timeout without imposing a one-second floor. If retaining a configurable floor, keep the interval positive and ensure the floor cannot push it beyond the half-timeout cadence for supported sessions, including `timeout=1`. Add an interval test for a one-second session.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Shared camera links can inject headers ✓ Resolved
Description
RtspAuth.header() inserts the decoded username into a Digest header without escaping or rejecting
carriage returns and line feeds. A shared camera URL containing a percent-encoded newline passes the
URL sanitizer, is decoded before authentication, and writes attacker-controlled header lines to the
camera connection after a Digest challenge.
Code

android/app/src/main/java/com/openipc/decoder/RtspAuth.java[81]

+        h.append("username=\"").append(user).append("\", ");
Evidence
Shared URLs are sanitized before use, but the sanitizer removes only literal newlines. URL parsing
then percent-decodes user information, and the new Digest path writes that decoded username directly
into the request.

android/app/src/main/java/com/openipc/decoder/Decoder.java[334-347]
android/app/src/main/java/com/openipc/decoder/RtspSession.java[570-574]
android/app/src/main/java/com/openipc/decoder/RtspSession.java[620-638]
android/app/src/main/java/com/openipc/decoder/RtspAuth.java[80-91]
android/app/src/main/java/com/openipc/decoder/RtspSession.java[372-378]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Percent-decoded URL usernames can put raw CRLF into the Digest Authorization header; quoted challenge values are also emitted without escaping.
## Fix Focus Areas
- android/app/src/main/java/com/openipc/decoder/RtspAuth.java[80-91]
- android/app/src/main/java/com/openipc/decoder/RtspSession.java[372-378]
## Recommended Fix
Reject CR/LF in every value used to build an RTSP header, and escape quotes and backslashes in Digest quoted values. Add a regression test with percent-encoded CRLF in a username.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

4. Rejected keepalives repeat indefinitely ✓ Resolved
Description
receive() ignores the boolean returned by RtspAuth.onUnauthorized() and continues after every
keepalive 401. When a camera rejects the current Digest credentials without marking its nonce stale,
subsequent intervals send the same rejected keepalive instead of ending the failed session.
Code

android/app/src/main/java/com/openipc/decoder/RtspSession.java[R285-288]

+                Response r = readResponse(c.in);
+                if (r.code == 401) {
+                    c.auth.onUnauthorized(r.authenticate); // next keepalive uses the new nonce
+                } else if (r.code == 454) {
Evidence
The authentication helper returns false for an already-active, non-stale Digest challenge. The
handshake path acts on that result, but the receive path discards it and retains its periodic send
loop.

android/app/src/main/java/com/openipc/decoder/RtspSession.java[274-294]
android/app/src/main/java/com/openipc/decoder/RtspAuth.java[99-124]
android/app/src/main/java/com/openipc/decoder/RtspSession.java[380-393]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A keepalive 401 whose challenge cannot be retried is ignored, so unauthorized requests continue for the lifetime of playback.
## Fix Focus Areas
- android/app/src/main/java/com/openipc/decoder/RtspSession.java[284-294]
- android/app/src/main/java/com/openipc/decoder/RtspSession.java[380-393]
## Recommended Fix
Honor the authentication retry result in the receive loop. Retry a refreshable challenge with a bounded attempt, and fail the session when authentication remains rejected. Add a scripted keepalive-401 test.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. Supported digest offers can be skipped ✓ Resolved
Description
RtspAuth.onUnauthorized() accepts the first Digest challenge with a supported algorithm even when
its qop offers only auth-int, then sends a response without any qop. If a later challenge
offers supported auth, the client never examines it and the camera rejects the handshake.
Code

android/app/src/main/java/com/openipc/decoder/RtspAuth.java[R116-119]

+            String qop = p.get("qop");
+            qopAuth = false;
+            if (qop != null) {
+                for (String q : qop.split(",")) {
Evidence
A present qop list without auth leaves qopAuth false, but the method still returns from that
challenge. Header generation then takes the legacy no-qop branch rather than trying another
challenge.

android/app/src/main/java/com/openipc/decoder/RtspAuth.java[101-125]
android/app/src/main/java/com/openipc/decoder/RtspAuth.java[73-90]
android/app/src/main/java/com/openipc/decoder/RtspSession.java[383-393]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A Digest challenge offering only auth-int is selected despite the client being unable to generate that response, hiding a later supported challenge.
## Fix Focus Areas
- android/app/src/main/java/com/openipc/decoder/RtspAuth.java[101-125]
- android/app/src/test/java/com/openipc/decoder/RtspAuthTest.java[67-77]
## Recommended Fix
Before selecting a challenge, reject any nonempty qop list that does not include auth, then continue searching the remaining challenges. Test auth-int followed by auth.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Informational

6. MD5-sess cameras without qop reject every signed request ✓ Resolved
Description
RtspAuth.header() includes a random cnonce in the MD5-sess H(A1) calculation but writes it to
the Authorization header only when qopAuth is true. When a camera challenges with MD5-sess and no
qop, it cannot verify the response because it never receives the cnonce, so the retried
handshake ends with exchange reporting “authentication failed”.
Code

android/app/src/main/java/com/openipc/decoder/RtspAuth.java[R65-70]

+        if ("MD5-SESS".equals(algorithm) || qopAuth) {
+            cnonce = testCnonce != null ? testCnonce : randomHex(8);
+        }
+        if ("MD5-SESS".equals(algorithm)) {
+            ha1 = md5(ha1 + ":" + nonce + ":" + cnonce);
+        }
Evidence
Lines 65–69 create the cnonce and incorporate it into H(A1) for MD5-sess regardless of qop,
while lines 88–90 append it to the header only when qopAuth is true. Thus, on the no-qop path,
the response depends on a value absent from the Authorization header; the existing test checks the
hash but not whether the header supplies that value.

android/app/src/main/java/com/openipc/decoder/RtspAuth.java[62-91]
android/app/src/main/java/com/openipc/decoder/RtspAuth.java[65-77]
android/app/src/main/java/com/openipc/decoder/RtspAuth.java[80-91]
android/app/src/test/java/com/openipc/decoder/RtspAuthTest.java[79-91]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
An MD5-sess challenge without `qop` produces a response calculated with a `cnonce` that is missing from the Authorization header, leaving the server unable to verify it.
## Fix Focus Areas
- android/app/src/main/java/com/openipc/decoder/RtspAuth.java[62-91]
- android/app/src/test/java/com/openipc/decoder/RtspAuthTest.java[79-91]
## Recommended Fix
Append `, cnonce="..."` whenever `cnonce != null`, including when `qopAuth` is false, and assert its presence in the existing MD5-sess test. Compute the MD5-sess H(A1) once per nonce using the first `cnonce`, then reuse it for later requests. Alternatively, skip MD5-sess challenges without `qop` in `onUnauthorized`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


7. Quotes or backslashes in credentials break the Digest header ✓ Resolved
Description
RtspAuth.header writes user, realm, nonce, uri and opaque between double quotes as-is,
while parseParams has already removed backslash escapes from the challenge values. A username or
realm that contains " or \ produces a malformed or wrongly matching Authorization: Digest
header, and the camera rejects the login.
Code

android/app/src/main/java/com/openipc/decoder/RtspAuth.java[R81-84]

+        h.append("username=\"").append(user).append("\", ");
+        h.append("realm=\"").append(realm).append("\", ");
+        h.append("nonce=\"").append(nonce).append("\", ");
+        h.append("uri=\"").append(uri).append("\", ");
Evidence
parseParams (lines 145-147) removes backslash escapes inside quoted strings. Lines 81-87 write the
values back between quotes without escaping them again, so the quoted-string syntax is not preserved
on the way out.

android/app/src/main/java/com/openipc/decoder/RtspAuth.java[143-149]
android/app/src/main/java/com/openipc/decoder/RtspAuth.java[80-87]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Digest header values are written between quotes without escaping `"` and `\`. The parser removes escapes from the challenge, so values containing these characters are not restored correctly on the way out.
## Fix Focus Areas
- android/app/src/main/java/com/openipc/decoder/RtspAuth.java[80-87]
## Recommended Fix
Add a helper `quote(String v)` that replaces `\` with `\\` and `"` with `\"`. Use it for the username, realm, nonce, uri, opaque and cnonce values in `header()`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Tip of the day
💡 Did you know, you can commit Qodo's fix in one click with committable suggestions (GitHub & GitLab)

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread android/app/src/main/java/com/openipc/decoder/RtspSession.java
Comment thread android/app/src/main/java/com/openipc/decoder/RtspSession.java Outdated
Comment thread android/app/src/main/java/com/openipc/decoder/RtspAuth.java Outdated
Comment thread android/app/src/main/java/com/openipc/decoder/RtspSession.java
Comment thread android/app/src/main/java/com/openipc/decoder/RtspAuth.java Outdated
Comment thread android/app/src/main/java/com/openipc/decoder/RtspAuth.java Outdated
Comment thread android/app/src/main/java/com/openipc/decoder/RtspAuth.java Outdated
- Keepalives are sent from the watchdog thread, so an idle stream (the
  reader blocked with no data) still keeps its session; requests are
  written under the connection lock.
- The keepalive interval is half the session timeout with a 250 ms
  floor, so timeout=1 is refreshed every 500 ms.
- Credentials containing control characters (e.g. a percent-encoded
  CR/LF from a shared link) are rejected; quoted Digest values escape
  '"' and '\'.
- A 401 to a keepalive that cannot be answered ends the session (and
  reconnects) instead of repeating forever; a stale nonce resends at once.
- Digest challenges offering only qop=auth-int are skipped in favour of
  a later supported one.
- MD5-sess sends its cnonce even without qop and keeps one cnonce per
  server nonce.
@compot-dev
compot-dev merged commit 3918bbb into v2 Oct 12, 2026
4 checks passed
@compot-dev
compot-dev deleted the feat/rtsp-auth-keepalive branch October 13, 2026 17:16
compot-dev added a commit that referenced this pull request Oct 15, 2026
feat: RTSP Digest authentication and keepalive (#8, #11)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

No RTSP keepalive during playback Digest authentication is not supported (Basic only)

1 participant