/** * 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 { 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 { 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 { // 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 { // 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 { // 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); } }