Open mjray opened 1 year ago
You make a good point.
According to this unofficial spec, AirPlay v1 did support Digest access authentication.
Here is a super quick (untested) example of how it could be implemented..
code
public void playVideo(URL location, ServiceInfo serviceInfo) throws Exception {
if (serviceInfo == null) {
throw new Exception("Not connected to AirPlay service");
}
es.submit(new PlayVideoTask(location, serviceInfo));
}
modified method that handles authentication
import java.security.MessageDigest;
import java.util.HashMap;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
private class PlayVideoTask implements Runnable {
private URL location;
private ServiceInfo serviceInfo;
public PlayVideoTask(URL location, ServiceInfo serviceInfo) {
this.location = location;
this.serviceInfo = serviceInfo;
}
@Override
public void run() {
sendRequest(null);
}
private void sendRequest(Map<String, String> reqHeaders) {
try {
StringBuilder content = new StringBuilder();
content.append("Content-Location: ");
content.append(location.toString());
content.append("\n");
content.append("Start-Position: 0\n");
URL url = new URL(serviceInfo.getURL() + "/play");
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setDoInput(true);
conn.setDoOutput(true);
conn.setConnectTimeout(15 * 1000);
conn.setReadTimeout(15 * 1000);
conn.setRequestMethod("POST");
conn.setRequestProperty("Content-Length", "" + content.length());
conn.setRequestProperty("Content-Type", "text/parameters");
conn.setRequestProperty("X-Apple-AssetKey", UUID.randomUUID().toString());
conn.setRequestProperty("X-Apple-Session-ID", UUID.randomUUID().toString());
conn.setRequestProperty("User-Agent", "MediaControl/1.0");
if (reqHeaders != null) {
for (Map.Entry<String, String> reqHeader : reqHeaders.entrySet()) {
conn.setRequestProperty(reqHeader.getKey(), reqHeader.getValue());
}
}
BufferedOutputStream out = new BufferedOutputStream(conn.getOutputStream());
out.write(content.toString().getBytes());
out.close();
int status = conn.getResponseCode();
if (status == 401) {
sendAuthRequest(reqHeaders, conn);
return;
}
if (callback != null) {
if (status == 200)
callback.onPlayVideoSuccess(location);
else
callback.onPlayVideoError(location, "AirPlay service responded HTTP " + status);
}
}
catch (Exception e) {
if (callback != null)
callback.onPlayVideoError(location, e.getMessage());
}
}
private void sendAuthRequest(Map<String, String> reqHeaders, HttpURLConnection conn) {
// TODO: replace hard coded password with a UI prompt
String password = "1234";
if (reqHeaders == null) {
reqHeaders = (Map<String, String>) new HashMap<String, String>();
}
// https://developer.android.com/reference/java/net/URLConnection#getHeaderField(java.lang.String)
String resHeaderAuth = conn.getHeaderField("WWW-Authenticate");
if (resHeaderAuth != null) {
Pattern regex = Pattern.compile("^.*nonce=\"([^\"]+)\".*$");
Matcher match = regex.matcher(resHeaderAuth);
if (match.find()) {
String nonce = match.group(1);
// HA1 = MD5(username:realm:password)
// HA2 = MD5(method:digestURI)
// response = MD5(HA1:nonce:HA2)
String HA1 = MD5("AirPlay:AirPlay:" + password);
String HA2 = MD5("POST:/play");
String response = MD5(HA1 + ":" + nonce + ":" + HA2);
String reqHeaderAuthName = "Authorization";
String reqHeaderAuthValue = "Digest username=\"AirPlay\", realm=\"AirPlay\", nonce=\"" + nonce + "\", uri=\"/play\", response=\"" + response + "\"";
reqHeaders.put(reqHeaderAuthName, reqHeaderAuthValue);
sendRequest(reqHeaders);
}
}
}
// http://fancifulandroid.blogspot.com/2014/01/android-convert-string-to-md5-properly.html
// https://stackoverflow.com/a/21333739
private static String MD5(String md5) {
try {
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] array = md.digest(md5.getBytes("UTF-8"));
StringBuffer sb = new StringBuffer();
for (int i = 0; i < array.length; ++i) {
sb.append(Integer.toHexString((array[i] & 0xFF) | 0x100).substring(1,3));
}
return sb.toString();
}
catch (Exception e) {
return null;
}
}
}
You could test whether or not Digest authentication will work with your target receiver by hand crafting the "Authorization" request header and using curl
for a command-line client.
Such a test would be super fast and a good way to test what does (and doesn't) work. Once a solution is found that works when constructed manually.. the same methodology could be applied by the app. Conversely, the methodology that I implemented above could be easily converted to a bash script and tested without any build step.
Here is a bash script that can be used to test..
#!/usr/bin/env bash
debug='1'
username='AirPlay'
password='1234'
function sendRequest() {
if [ "$debug" == '1' ]; then
sendTestRequest
else
sendRealRequest
fi
return $?
}
function sendTestRequest() {
protected_url="https://httpbin.org/digest-auth/auth/${username}/${password}"
http_method='HEAD'
http_uri="/digest-auth/auth/${username}/${password}"
http_response=$(curl --silent -I -H "$http_auth_header" "$protected_url")
#echo "$http_response"
process_http_response
return $?
}
function sendRealRequest() {
airplay_ip='192.168.1.100:8192'
video_url='https://www.cbsnews.com/common/video/cbsn_header_prod.m3u8'
http_method='POST'
http_uri='/play'
http_response=$(curl --silent --include -X "$http_method" \
-H "$http_auth_header" \
-H "Content-Type: text/parameters" \
--data-binary "Content-Location: ${video_url}\nStart-Position: 0" \
"http://${airplay_ip}${http_uri}" \
)
#echo "$http_response"
process_http_response
return $?
}
function process_http_response() {
http_status=$(echo "$http_response" | head -n 1 | grep -s -o -P '\d{3}')
echo "$http_status"
if [ "$http_status" == '200' ];then
echo 'OK'
return 0
fi
if [ "$http_status" == '401' ];then
nonce=$(echo "$http_response" | grep -i 'WWW-Authenticate' | grep -s -o -P 'nonce="[^"]+')
nonce=${nonce:7}
#echo "$nonce"
HA1=$(MD5 "${username}:AirPlay:${password}")
HA2=$(MD5 "${http_method}:${http_uri}")
response=$(MD5 "${HA1}:${nonce}:${HA2}")
http_auth_header="Authorization: Digest username=\"${username}\", realm=\"AirPlay\", nonce=\"${nonce}\", uri=\"${http_uri}\", response=\"${response}\""
echo "$http_auth_header"
sendRequest
return $?
fi
return 1
}
function MD5() {
text="$1"
hash=$(echo -n "$text" | md5sum | awk '{print $1}')
echo "$hash"
}
http_auth_header='X-Foo: Bar'
sendRequest
exit $?
regarding something you said..
your AirPlay receiver is returning a 403 Forbidden status, rather than a 401 Unauthorized?
..that's problematic
note to self.. regarding touchpoints to integrate a new dialog into the UI:
TODO:
msg.obj
passes references to both:
client.playVideo
adds parameter
401 status
/play
request with properly formed Authorization: Digest
headerXREF.. this is very similar to:
regarding something you said..
your AirPlay receiver is returning a 403 Forbidden status, rather than a 401 Unauthorized?
..that's problematic
Yes, I've double checked this today. The toast says 403. Might that mean it's implementing an unsupported version of AirPlay?
Thanks for the bash script. I'll run that when I get time.
The bash script returns 403. Modifying it to display the full response revealed this, in case it helps:
HTTP/1.1 403 Forbidden Content-Length: 0 Server: AirTunes/377.40.00
hmm.. well, I'm no expert on Apple devices.. in fact, I'm pretty much whatever the exact opposite of that would be :smiley: I don't own any Apple products, and I don't have any desire to ever do so.. that being said, I'm not sure what device that server identification string represents.. but to determine whether or not it supports the AirPlay version 1.0 protocol.. the following might provide some added insight:
airplay_ip='192.168.1.100:8192'
curl "http://${airplay_ip}/server-info"
for example, I'm running ExoAirPlayer, and this request:
curl 'http://192.168.0.3:8192/server-info'
returns:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>deviceid</key>
<string>10:D0:7A:BB:D3:A7</string>
<key>features</key>
<integer>10623</integer>
<key>model</key>
<string>AppleTV2,1</string>
<key>protovers</key>
<string>1.0</string>
<key>srcvers</key>
<string>130.14</string>
</dict>
</plist>
the relevant part of the response is:
<key>protovers</key>
<string>1.0</string>
which identifies that the protocol version is 1.0
It doesn't look like a rtsp reply to me and GET isn't an rtsp method, so I'd expect 501 Not Implemented or some other 5xx reply. But some servers do strange things, so 403 wouldn't surprise me too much.
It's not an Apple device. It's some sort of Roku TV. However, it returns 403 Forbidden in reply to the GET /server-info too! I found a page which makes me strongly suspect it only supports AirPlay 2 https://community.roku.com/t5/Features-settings-updates/Which-devices-are-compatible-with-Apple-Airplay/td-p/691587
So unless you've plans to support later protocol versions, I guess that means I'm out of luck and still looking for a F-Droid-ish way to playback better than DLNA can.
Thanks for your help debugging this. Maybe it would be good if the app could request and display the server-info? Then if that fails, the user will know easily something basic is not as it needs to be.
Does this resolved or any other alternative for this ?
Allow me to quickly summarize the discussion in this issue up to this point:
and we concluded that it must actually be running AirPlay v2 protocols
I'd rather say we just concluded it's not running AirPlay v1. The lack of documentation from Roku about their devices, the strange behaviour under test and not having any AirPlay v2 software to test it with means I'm not confident to say it's a woking implementation of anything!
Otherwise, great summary, thanks!
The airplay target I'm trying to control has only options to ask for a PIN or password on first connection or every connection, with no option to never ask for one, so I can't control it because everything fails with a 403 HTTP status code (thanks for the toast saying that, rather than a less clear error!). I also can't hack the device because it's not mine (and its developers suck anyway).
Is it possible to add a PIN or password as a setting in this client, please? Either per-device or even systemwide would solve my problem.
Or is there even a default hardcoded? I browsed the code and didn't find it, but I'm not great at Android coding.