diff --git a/app/package-lock.json b/app/package-lock.json index 2bca649..9d326ef 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -3037,6 +3037,11 @@ "to-regex": "^3.0.1" } }, + "node-fetch": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.3.0.tgz", + "integrity": "sha512-MOd8pV3fxENbryESLgVIeaGKrdl+uaYhCSSVkjeOb/31/njTpcis5aWfdqgNlHIrKOLRbMnfPINPOML2CIFeXA==" + }, "node-modules-regexp": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-modules-regexp/-/node-modules-regexp-1.0.0.tgz", diff --git a/app/package.json b/app/package.json index 68a74f5..cda1b6f 100644 --- a/app/package.json +++ b/app/package.json @@ -13,7 +13,8 @@ "dependencies": { "@babel/runtime": "^7.2.0", "castv2": "^0.1.9", - "dnssd": "^0.4.1" + "dnssd": "^0.4.1", + "node-fetch": "^2.3.0" }, "devDependencies": { "@babel/cli": "^7.2.0", diff --git a/app/src/airplay/auth.js b/app/src/airplay/auth.js new file mode 100644 index 0000000..4556882 --- /dev/null +++ b/app/src/airplay/auth.js @@ -0,0 +1,212 @@ +/** + * 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 nacl from "tweetnacl"; +import srp6a from "fast-srp-hap"; +import crypto from "crypto"; +import fetch from "node-fetch"; +import bplist from "./bplist"; + + +const AIRPLAY_PORT = 7000; +const MIMETYPE_BPLIST = "application/x-apple-binary-plist"; + +/** + * Client ID and keypair + */ +export class AirPlayAuthCredentials { + constructor (clientId, clientSk) { + 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 { + constructor (address, credentials) { + this.address = address; + this.clientId = credentials.clientId; + this.clientSk = credentials.clientSk; + this.clientPk = credentials.clientPk; + + this.baseUrl = new URL(`http://${this.address}:${AIRPLAY_PORT}`); + } + + /** + * Begins pairing process. + */ + 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. + */ + async finishPairing (pin) { + // 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.clientId) // Username + , Buffer.from(pin) // Password (receiver pin) + , Buffer.from(this.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 + console.log(await this.pairSetupPin3(srpClient.computeK())); + } + + /** + * Pairing Stage 1 + * --------------- + * Triggering the receiver passcode display and receiving + * its public key / salt. + */ + async pairSetupPin1 () { + const [ response ] = await this.sendPostRequestBplist( + "/pair-setup-pin" + , { + method: "pin" + , user: this.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. + */ + async pairSetupPin2 (pk, proof) { + 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. + */ + async pairSetupPin3 (sharedSecretHash) { + // 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.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. + */ + async sendPostRequest (path, contentType, data) { + // 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.arrayBuffer(); + } + + /** + * Encodes binary plist data, sends a POST request to + * receiver, then decodes and returns the response. + */ + async sendPostRequestBplist (path, data) { + // Convert data to compatible type + const requestBody = data + ? bplist.create(data) + : null; + + const responseArrayBuffer = await this.sendPostRequest( + path, MIMETYPE_BPLIST, requestBody); + + // Convert response data to Buffer for bplist-parser + return bplist.parse.parseBuffer( + Buffer.from(responseArrayBuffer)); + } +} diff --git a/app/src/airplay/bplist.js b/app/src/airplay/bplist.js new file mode 100644 index 0000000..1feb3a4 --- /dev/null +++ b/app/src/airplay/bplist.js @@ -0,0 +1,4 @@ +import create from "bplist-creator"; +import parse from "bplist-parser"; + +export default { create, parse };