mirror of
https://github.com/hensm/fx_cast.git
synced 2026-06-11 01:59:58 +00:00
* Initial app daemon implementation * Pass script path to child bridge processes * Change WebSocket server port * Fix error sending message whilst WebSocket connection is closing * Initial ext daemon connection implementation
230 lines
6.5 KiB
TypeScript
230 lines
6.5 KiB
TypeScript
/**
|
|
* AirPlay device auth implementation.
|
|
*
|
|
* References:
|
|
* - https://htmlpreview.github.io/?https://github.com/philippe44/RAOP-Player/blob/master/doc/auth_protocol.html
|
|
* - https://github.com/funtax/AirPlayAuth
|
|
* - https://github.com/ldiqual/chrome-airplay
|
|
* - https://github.com/postlund/pyatv/blob/master/docs/airplay.rst
|
|
*/
|
|
|
|
import crypto from "crypto";
|
|
import srp6a from "fast-srp-hap";
|
|
import fetch, { Headers } from "node-fetch";
|
|
import nacl from "tweetnacl";
|
|
import bplist from "./bplist";
|
|
|
|
|
|
const AIRPLAY_PORT = 7000;
|
|
const MIMETYPE_BPLIST = "application/x-apple-binary-plist";
|
|
|
|
/**
|
|
* Client ID and keypair
|
|
*/
|
|
export class AirPlayAuthCredentials {
|
|
public clientId: string;
|
|
public clientSk: Uint8Array;
|
|
public clientPk: Uint8Array;
|
|
|
|
constructor (clientId: string, clientSk: Uint8Array) {
|
|
if (clientId && clientSk) {
|
|
this.clientId = clientId;
|
|
this.clientSk = clientSk;
|
|
} else {
|
|
// If specified without arguments, generate new credentials
|
|
const keyPair = nacl.sign.keyPair();
|
|
|
|
// Random 16-len string
|
|
this.clientId = crypto.randomBytes(8).toString("hex");
|
|
|
|
this.clientSk = keyPair.secretKey.slice(0, 32);
|
|
this.clientPk = keyPair.publicKey;
|
|
}
|
|
}
|
|
}
|
|
|
|
export class AirPlayAuth {
|
|
private address: string;
|
|
private credentials: AirPlayAuthCredentials;
|
|
private baseUrl: URL;
|
|
|
|
constructor (address: string, credentials: AirPlayAuthCredentials) {
|
|
this.address = address;
|
|
this.credentials = credentials;
|
|
|
|
this.baseUrl = new URL(`http://${this.address}:${AIRPLAY_PORT}`);
|
|
}
|
|
|
|
/**
|
|
* Begins pairing process.
|
|
*/
|
|
public async beginPairing () {
|
|
return this.sendPostRequest("/pair-pin-start");
|
|
}
|
|
|
|
/**
|
|
* Pairs client with receiver. Must be called after
|
|
* beginPairing(). Coordinates the three pairing stages and
|
|
* manages request responses.
|
|
*/
|
|
public async finishPairing (pin: string) {
|
|
// Stage 1 response
|
|
const { pk: serverPk
|
|
, salt: serverSalt } = await this.pairSetupPin1();
|
|
|
|
// SRP params must 2048-bit SHA1
|
|
const srpParams = srp6a.params[2048];
|
|
srpParams.hash = "sha1";
|
|
|
|
// Create SRP client
|
|
const srpClient = new srp6a.Client(
|
|
srpParams // Params
|
|
, serverSalt // Receiver salt
|
|
, Buffer.from(this.credentials.clientId) // Username
|
|
, Buffer.from(pin) // Password (receiver pin)
|
|
, Buffer.from(this.credentials.clientSk)); // Client secret key
|
|
|
|
// Add receiver's public key
|
|
srpClient.setB(serverPk);
|
|
|
|
// Stage 2 response
|
|
await this.pairSetupPin2(
|
|
srpClient.computeA() // SRP public key
|
|
, srpClient.computeM1()); // SRP proof
|
|
|
|
// Stage 3 response
|
|
await this.pairSetupPin3(srpClient.computeK());
|
|
}
|
|
|
|
/**
|
|
* Pairing Stage 1
|
|
* ---------------
|
|
* Triggering the receiver passcode display and receiving
|
|
* its public key / salt.
|
|
*/
|
|
public async pairSetupPin1 (): Promise<any> {
|
|
const [ response ] = await this.sendPostRequestBplist(
|
|
"/pair-setup-pin"
|
|
, {
|
|
method: "pin"
|
|
, user: this.credentials.clientId
|
|
});
|
|
|
|
return response;
|
|
}
|
|
|
|
/**
|
|
* Pairing Stage 2
|
|
* ---------------
|
|
* Generating SRP public key and proof with the client/server
|
|
* public keys, sending them to the receiver and receiving its
|
|
* proof.
|
|
*/
|
|
public async pairSetupPin2 (
|
|
pk: Buffer
|
|
, proof: Buffer): Promise<any> {
|
|
|
|
const [ response ] = await this.sendPostRequestBplist(
|
|
"/pair-setup-pin"
|
|
, { pk, proof });
|
|
|
|
return response;
|
|
}
|
|
|
|
/**
|
|
* Pairing Stage 3
|
|
* ---------------
|
|
* AES encoding the client public key with the SRP shared
|
|
* secret hash and sending it to the receiver. Receiver then
|
|
* responds confirming the pairing is complete.
|
|
*/
|
|
public async pairSetupPin3 (
|
|
sharedSecretHash: crypto.BinaryLike): Promise<any> {
|
|
|
|
// Create AES key
|
|
const aesKey = crypto.createHash("sha512")
|
|
.update("Pair-Setup-AES-Key")
|
|
.update(sharedSecretHash)
|
|
.digest()
|
|
.slice(0, 16);
|
|
|
|
// Create AES IV
|
|
const aesIv = crypto.createHash("sha512")
|
|
.update("Pair-Setup-AES-IV")
|
|
.update(sharedSecretHash)
|
|
.digest()
|
|
.slice(0, 16);
|
|
|
|
aesIv[15]++;
|
|
|
|
|
|
const cipher = crypto.createCipheriv("aes-128-gcm", aesKey, aesIv);
|
|
|
|
// Encode client public key
|
|
const epk = cipher.update(this.credentials.clientPk);
|
|
cipher.final();
|
|
const authTag = cipher.getAuthTag();
|
|
|
|
const [ response ] = await this.sendPostRequestBplist(
|
|
"/pair-setup-pin"
|
|
, { epk, authTag });
|
|
|
|
return response;
|
|
}
|
|
|
|
|
|
/**
|
|
* Sends a POST request to receiver and returns the
|
|
* response.
|
|
*/
|
|
public async sendPostRequest (
|
|
path: string
|
|
, contentType?: string
|
|
, data?: Buffer | string): Promise<any> {
|
|
|
|
// Create URL from base receiver URL and path
|
|
const requestUrl = new URL(path, this.baseUrl);
|
|
|
|
const requestHeaders = new Headers({
|
|
"User-Agent": "AirPlay/320.20"
|
|
});
|
|
|
|
// Append Content-Type header if request has body
|
|
if (data) {
|
|
requestHeaders.append("Content-Type", contentType);
|
|
}
|
|
|
|
const response = await fetch(requestUrl.href, {
|
|
method: "POST"
|
|
, headers: requestHeaders
|
|
, body: data
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`AirPlay request error: ${response.status}`);
|
|
}
|
|
|
|
return await response.buffer();
|
|
}
|
|
|
|
/**
|
|
* Encodes binary plist data, sends a POST request to
|
|
* receiver, then decodes and returns the response.
|
|
*/
|
|
public async sendPostRequestBplist (
|
|
path: string
|
|
, data?: object): Promise<any> {
|
|
|
|
// Convert data to compatible type
|
|
const requestBody = data
|
|
? bplist.create(data)
|
|
: undefined;
|
|
|
|
const response = await this.sendPostRequest(
|
|
path, MIMETYPE_BPLIST, requestBody);
|
|
|
|
// Convert response data to Buffer for bplist-parser
|
|
return bplist.parse.parseBuffer(response);
|
|
}
|
|
}
|