Move AirPlay auth module to extension and add initial options UI

This commit is contained in:
hensm
2019-10-31 04:52:42 +00:00
parent b3b14f782d
commit b93bdcad8c
21 changed files with 740 additions and 168 deletions

View File

@@ -1,15 +0,0 @@
/// <reference types="node" />
declare module "bplist-creator" {
import Buffer from "buffer";
function bplist (dicts: object): Buffer;
export = bplist;
namespace bplist {
export class Real {
public value: number;
constructor (value: number);
}
}
}

View File

@@ -1,23 +0,0 @@
/// <reference types="node" />
declare module "bplist-parser" {
import Buffer from "buffer";
export var maxObjectSize: number;
export var maxObjectCount: number;
export class UID {
constructor (id: number);
UID: number;
}
type ParseFileCallback = (
err: string
, result?: Buffer) => void;
export function parseFile (
fileNameOrBuffer: Buffer | string
, callback: ParseFileCallback): void;
export function parseBuffer (buffer: Buffer): any;
}

View File

@@ -1,55 +0,0 @@
/// <reference types="node" />
declare module "fast-srp-hap" {
import Buffer from "buffer";
interface Param {
N_length_bits: number;
N: any;
g: any;
hash: string;
}
export const params: { [key: number]: Param };
type GenKeyCallback = (
err: string
, buf: Buffer) => void;
export function genKey (
bytes: number
, callback: GenKeyCallback): void;
export function computeVerifier (
params: object
, salt: Buffer
, I: Buffer
, P: Buffer): Buffer;
export class Client {
constructor (
params: object
, salt_buf: Buffer
, identity_buf: Buffer
, password_buf: Buffer
, secret1_buf: Buffer);
computeA (): Buffer;
setB(B_buf: Buffer): void;
computeM1 (): Buffer;
checkM2 (serverM2_buf: Buffer): void;
computeK (): Buffer;
}
export class Server {
constructor (
params: object
, verifier_buf: Buffer
, secret2_buf: Buffer);
computeB (): Buffer;
setA (A_buf: Buffer): void;
checkM1 (clientM1_buf: Buffer): Buffer;
computeK (): Buffer;
}
}

41
app/package-lock.json generated
View File

@@ -364,27 +364,6 @@
}
}
},
"big-integer": {
"version": "1.6.44",
"resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.44.tgz",
"integrity": "sha512-7MzElZPTyJ2fNvBkPxtFQ2fWIkVmuzw41+BZHSzpEq3ymB2MfeKp1+yXl/tS75xCx+WnyV+yb0kp+K1C3UNwmQ=="
},
"bplist-creator": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.0.8.tgz",
"integrity": "sha512-Za9JKzD6fjLC16oX2wsXfc+qBEhJBJB1YPInoAQpMLhDuj5aVOv1baGeIQSq1Fr3OCqzvsoQcSBSwGId/Ja2PA==",
"requires": {
"stream-buffers": "~2.2.0"
}
},
"bplist-parser": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.2.0.tgz",
"integrity": "sha512-z0M+byMThzQmD9NILRniCUXYsYpjwnlO8N5uCFaCqIOpqRsJCrQL9NK3JsD67CN5a08nF5oIL2bD6loTdHOuKw==",
"requires": {
"big-integer": "^1.6.44"
}
},
"brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
@@ -902,11 +881,6 @@
"integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=",
"dev": true
},
"fast-srp-hap": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/fast-srp-hap/-/fast-srp-hap-1.0.1.tgz",
"integrity": "sha1-N3Ek0Za8alFXquWze/X6NbtK0tk="
},
"fill-range": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz",
@@ -1557,11 +1531,6 @@
"to-regex": "^3.0.1"
}
},
"node-fetch": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.0.tgz",
"integrity": "sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA=="
},
"oauth-sign": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz",
@@ -2177,11 +2146,6 @@
}
}
},
"stream-buffers": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/stream-buffers/-/stream-buffers-2.2.0.tgz",
"integrity": "sha1-kdX1Ew0c75bc+n9yaUUYh0HQnuQ="
},
"stream-meter": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/stream-meter/-/stream-meter-1.0.4.tgz",
@@ -2328,11 +2292,6 @@
"safe-buffer": "^5.0.1"
}
},
"tweetnacl": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.1.tgz",
"integrity": "sha512-kcoMoKTPYnoeS50tzoqjPY3Uv9axeuuFAZY9M/9zFnhoVvRfxz9K29IMPD7jGmt2c8SW7i3gT9WqDl2+nV7p4A=="
},
"type-check": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz",

View File

@@ -11,14 +11,9 @@
"lint": "tslint -c tslint.json -p ."
},
"dependencies": {
"bplist-creator": "0.0.8",
"bplist-parser": "^0.2.0",
"castv2": "^0.1.10",
"dnssd": "^0.4.1",
"fast-srp-hap": "^1.0.1",
"mime-types": "^2.1.24",
"node-fetch": "^2.6.0",
"tweetnacl": "^1.0.1"
"mime-types": "^2.1.24"
},
"devDependencies": {
"@types/dnssd": "^0.4.1",

View File

@@ -1,234 +0,0 @@
/**
* 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
, clientPk?: Uint8Array) {
if (clientId && clientSk && clientPk) {
this.clientId = clientId;
this.clientSk = clientSk;
this.clientPk = clientPk;
} 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 && contentType) {
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);
}
}