appium-ios-remotexpc 0.12.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/build/src/constants.d.ts +2 -0
- package/build/src/constants.d.ts.map +1 -0
- package/build/src/constants.js +3 -0
- package/build/src/index.d.ts +2 -1
- package/build/src/index.d.ts.map +1 -1
- package/build/src/index.js +2 -1
- package/build/src/lib/apple-tv/constants.d.ts +0 -1
- package/build/src/lib/apple-tv/constants.d.ts.map +1 -1
- package/build/src/lib/apple-tv/constants.js +0 -1
- package/build/src/lib/apple-tv/discovery/device-discovery.d.ts +10 -0
- package/build/src/lib/apple-tv/discovery/device-discovery.d.ts.map +1 -0
- package/build/src/lib/apple-tv/discovery/device-discovery.js +22 -0
- package/build/src/lib/apple-tv/discovery/index.d.ts +2 -0
- package/build/src/lib/apple-tv/discovery/index.d.ts.map +1 -0
- package/build/src/lib/apple-tv/discovery/index.js +1 -0
- package/build/src/lib/apple-tv/index.d.ts +5 -0
- package/build/src/lib/apple-tv/index.d.ts.map +1 -1
- package/build/src/lib/apple-tv/index.js +5 -0
- package/build/src/lib/apple-tv/network/constants.d.ts +10 -0
- package/build/src/lib/apple-tv/network/constants.d.ts.map +1 -0
- package/build/src/lib/apple-tv/network/constants.js +9 -0
- package/build/src/lib/apple-tv/network/index.d.ts +4 -0
- package/build/src/lib/apple-tv/network/index.d.ts.map +1 -0
- package/build/src/lib/apple-tv/network/index.js +2 -0
- package/build/src/lib/apple-tv/network/network-client.d.ts +16 -0
- package/build/src/lib/apple-tv/network/network-client.d.ts.map +1 -0
- package/build/src/lib/apple-tv/network/network-client.js +169 -0
- package/build/src/lib/apple-tv/network/types.d.ts +8 -0
- package/build/src/lib/apple-tv/network/types.d.ts.map +1 -0
- package/build/src/lib/apple-tv/network/types.js +1 -0
- package/build/src/lib/apple-tv/pairing/index.d.ts +3 -0
- package/build/src/lib/apple-tv/pairing/index.d.ts.map +1 -0
- package/build/src/lib/apple-tv/pairing/index.js +2 -0
- package/build/src/lib/apple-tv/pairing/pairing-service.d.ts +15 -0
- package/build/src/lib/apple-tv/pairing/pairing-service.d.ts.map +1 -0
- package/build/src/lib/apple-tv/pairing/pairing-service.js +112 -0
- package/build/src/lib/apple-tv/pairing/user-input-service.d.ts +8 -0
- package/build/src/lib/apple-tv/pairing/user-input-service.d.ts.map +1 -0
- package/build/src/lib/apple-tv/pairing/user-input-service.js +61 -0
- package/build/src/lib/apple-tv/pairing-protocol/constants.d.ts +18 -0
- package/build/src/lib/apple-tv/pairing-protocol/constants.d.ts.map +1 -0
- package/build/src/lib/apple-tv/pairing-protocol/constants.js +17 -0
- package/build/src/lib/apple-tv/pairing-protocol/index.d.ts +4 -0
- package/build/src/lib/apple-tv/pairing-protocol/index.d.ts.map +1 -0
- package/build/src/lib/apple-tv/pairing-protocol/index.js +2 -0
- package/build/src/lib/apple-tv/pairing-protocol/pairing-protocol.d.ts +159 -0
- package/build/src/lib/apple-tv/pairing-protocol/pairing-protocol.d.ts.map +1 -0
- package/build/src/lib/apple-tv/pairing-protocol/pairing-protocol.js +494 -0
- package/build/src/lib/apple-tv/pairing-protocol/types.d.ts +57 -0
- package/build/src/lib/apple-tv/pairing-protocol/types.d.ts.map +1 -0
- package/build/src/lib/apple-tv/pairing-protocol/types.js +1 -0
- package/build/src/lib/apple-tv/storage/index.d.ts +3 -0
- package/build/src/lib/apple-tv/storage/index.d.ts.map +1 -0
- package/build/src/lib/apple-tv/storage/index.js +1 -0
- package/build/src/lib/apple-tv/storage/pairing-storage.d.ts +12 -0
- package/build/src/lib/apple-tv/storage/pairing-storage.d.ts.map +1 -0
- package/build/src/lib/apple-tv/storage/pairing-storage.js +36 -0
- package/build/src/lib/apple-tv/storage/types.d.ts +5 -0
- package/build/src/lib/apple-tv/storage/types.d.ts.map +1 -0
- package/build/src/lib/apple-tv/storage/types.js +1 -0
- package/build/src/lib/apple-tv/types.d.ts +0 -1
- package/build/src/lib/apple-tv/types.d.ts.map +1 -1
- package/build/src/lib/bonjour/bonjour-discovery.d.ts.map +1 -1
- package/build/src/lib/bonjour/bonjour-discovery.js +2 -0
- package/package.json +4 -2
- package/scripts/pair-appletv.ts +79 -0
- package/scripts/test-tunnel-creation.ts +1 -1
- package/src/constants.ts +4 -0
- package/src/index.ts +2 -0
- package/src/lib/apple-tv/constants.ts +0 -1
- package/src/lib/apple-tv/discovery/device-discovery.ts +34 -0
- package/src/lib/apple-tv/discovery/index.ts +1 -0
- package/src/lib/apple-tv/index.ts +5 -0
- package/src/lib/apple-tv/network/constants.ts +9 -0
- package/src/lib/apple-tv/network/index.ts +3 -0
- package/src/lib/apple-tv/network/network-client.ts +214 -0
- package/src/lib/apple-tv/network/types.ts +7 -0
- package/src/lib/apple-tv/pairing/index.ts +2 -0
- package/src/lib/apple-tv/pairing/pairing-service.ts +175 -0
- package/src/lib/apple-tv/pairing/user-input-service.ts +71 -0
- package/src/lib/apple-tv/pairing-protocol/constants.ts +19 -0
- package/src/lib/apple-tv/pairing-protocol/index.ts +8 -0
- package/src/lib/apple-tv/pairing-protocol/pairing-protocol.ts +636 -0
- package/src/lib/apple-tv/pairing-protocol/types.ts +60 -0
- package/src/lib/apple-tv/storage/index.ts +2 -0
- package/src/lib/apple-tv/storage/pairing-storage.ts +60 -0
- package/src/lib/apple-tv/storage/types.ts +9 -0
- package/src/lib/apple-tv/types.ts +0 -1
- package/src/lib/bonjour/bonjour-discovery.ts +2 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Constants for pairing protocol states */
|
|
2
|
+
export const PAIRING_STATES = {
|
|
3
|
+
M3: 0x03,
|
|
4
|
+
M5: 0x05,
|
|
5
|
+
M6: 0x06,
|
|
6
|
+
} as const;
|
|
7
|
+
|
|
8
|
+
/** Constants for pairing protocol messages */
|
|
9
|
+
export const PAIRING_MESSAGES = {
|
|
10
|
+
ENCRYPT_SALT: 'Pair-Setup-Encrypt-Salt',
|
|
11
|
+
ENCRYPT_INFO: 'Pair-Setup-Encrypt-Info',
|
|
12
|
+
SIGN_SALT: 'Pair-Setup-Controller-Sign-Salt',
|
|
13
|
+
SIGN_INFO: 'Pair-Setup-Controller-Sign-Info',
|
|
14
|
+
M5_NONCE: 'PS-Msg05',
|
|
15
|
+
M6_NONCE: 'PS-Msg06',
|
|
16
|
+
} as const;
|
|
17
|
+
|
|
18
|
+
/** TLV type for device info */
|
|
19
|
+
export const INFO_TYPE = 0x11;
|
|
@@ -0,0 +1,636 @@
|
|
|
1
|
+
import { logger } from '@appium/support';
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import { hostname } from 'node:os';
|
|
4
|
+
|
|
5
|
+
import type { AppleTVDevice } from '../../bonjour/bonjour-discovery.js';
|
|
6
|
+
import {
|
|
7
|
+
DEFAULT_PAIRING_CONFIG,
|
|
8
|
+
PairingDataComponentType,
|
|
9
|
+
} from '../constants.js';
|
|
10
|
+
import { encodeAppleTVDeviceInfo } from '../deviceInfo/index.js';
|
|
11
|
+
import {
|
|
12
|
+
createEd25519Signature,
|
|
13
|
+
decryptChaCha20Poly1305,
|
|
14
|
+
encryptChaCha20Poly1305,
|
|
15
|
+
generateEd25519KeyPair,
|
|
16
|
+
hkdf,
|
|
17
|
+
} from '../encryption/index.js';
|
|
18
|
+
import { PairingError } from '../errors.js';
|
|
19
|
+
import { NETWORK_CONSTANTS } from '../network/constants.js';
|
|
20
|
+
import type { NetworkClientInterface } from '../network/types.js';
|
|
21
|
+
import { SRPClient } from '../srp/index.js';
|
|
22
|
+
import { PairingStorage } from '../storage/pairing-storage.js';
|
|
23
|
+
import {
|
|
24
|
+
createPairVerificationData,
|
|
25
|
+
createSetupManualPairingData,
|
|
26
|
+
decodeTLV8ToDict,
|
|
27
|
+
encodeTLV8,
|
|
28
|
+
} from '../tlv/index.js';
|
|
29
|
+
import type { TLV8Item } from '../types.js';
|
|
30
|
+
import { generateHostId } from '../utils/uuid-generator.js';
|
|
31
|
+
import { INFO_TYPE, PAIRING_MESSAGES, PAIRING_STATES } from './constants.js';
|
|
32
|
+
import type {
|
|
33
|
+
EncryptionKeys,
|
|
34
|
+
PairingProtocolInterface,
|
|
35
|
+
PairingRequest,
|
|
36
|
+
UserInputInterface,
|
|
37
|
+
} from './types.js';
|
|
38
|
+
|
|
39
|
+
/** Implements the Apple TV pairing protocol including SRP authentication and key exchange */
|
|
40
|
+
export class PairingProtocol implements PairingProtocolInterface {
|
|
41
|
+
private static readonly log = logger.getLogger('PairingProtocol');
|
|
42
|
+
private _sequenceNumber = 0;
|
|
43
|
+
|
|
44
|
+
constructor(
|
|
45
|
+
private readonly networkClient: NetworkClientInterface,
|
|
46
|
+
private readonly userInput: UserInputInterface,
|
|
47
|
+
) {}
|
|
48
|
+
|
|
49
|
+
async executePairingFlow(device: AppleTVDevice): Promise<string> {
|
|
50
|
+
this._sequenceNumber = 1;
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
// Step 1: Handshake
|
|
54
|
+
await this.performHandshake();
|
|
55
|
+
|
|
56
|
+
// Step 2: Pair verification attempt
|
|
57
|
+
await this.attemptPairVerification();
|
|
58
|
+
|
|
59
|
+
// Step 3: Setup manual pairing
|
|
60
|
+
const setupResponse = await this.setupManualPairing();
|
|
61
|
+
const srpData = this.extractAndValidatePairingData(setupResponse);
|
|
62
|
+
|
|
63
|
+
// Step 4: SRP Authentication
|
|
64
|
+
const srpClient = await this.performSRPAuthentication(srpData);
|
|
65
|
+
|
|
66
|
+
// Step 5: Generate keys and send M5
|
|
67
|
+
const encryptionKeys = this.deriveEncryptionKeys(srpClient.sessionKey);
|
|
68
|
+
const { publicKey: ltpk, privateKey: ltsk } = generateEd25519KeyPair();
|
|
69
|
+
const devicePairingID = generateHostId(hostname());
|
|
70
|
+
|
|
71
|
+
await this.sendM5Message(
|
|
72
|
+
encryptionKeys.encryptKey,
|
|
73
|
+
devicePairingID,
|
|
74
|
+
ltpk,
|
|
75
|
+
ltsk,
|
|
76
|
+
srpClient.sessionKey,
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
// Step 6: Receive M6 completion
|
|
80
|
+
await this.receiveM6Completion(encryptionKeys.decryptKey);
|
|
81
|
+
|
|
82
|
+
return this.createPairingResult(device, ltpk, ltsk);
|
|
83
|
+
} catch (error) {
|
|
84
|
+
PairingProtocol.log.error('Pairing flow failed:', error);
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private createRequest(content: any, sequenceNumber?: number): PairingRequest {
|
|
90
|
+
return {
|
|
91
|
+
message: {
|
|
92
|
+
plain: {
|
|
93
|
+
_0: content,
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
originatedBy: 'host',
|
|
97
|
+
sequenceNumber: sequenceNumber ?? this._sequenceNumber++,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Fragments a buffer into TLV8 items of maximum fragment size
|
|
103
|
+
* @param buffer Buffer to fragment
|
|
104
|
+
* @param type TLV8 type identifier
|
|
105
|
+
* @returns Array of TLV8 items
|
|
106
|
+
*/
|
|
107
|
+
private fragmentBuffer(buffer: Buffer, type: number): TLV8Item[] {
|
|
108
|
+
const fragments: TLV8Item[] = [];
|
|
109
|
+
for (
|
|
110
|
+
let i = 0;
|
|
111
|
+
i < buffer.length;
|
|
112
|
+
i += NETWORK_CONSTANTS.MAX_TLV_FRAGMENT_SIZE
|
|
113
|
+
) {
|
|
114
|
+
fragments.push({
|
|
115
|
+
type,
|
|
116
|
+
data: buffer.subarray(i, i + NETWORK_CONSTANTS.MAX_TLV_FRAGMENT_SIZE),
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
return fragments;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Creates a nonce buffer with prefix padding
|
|
124
|
+
* @param nonceString The nonce string identifier
|
|
125
|
+
* @returns Padded nonce buffer
|
|
126
|
+
*/
|
|
127
|
+
private createNonce(nonceString: string): Buffer {
|
|
128
|
+
return Buffer.concat([Buffer.alloc(4), Buffer.from(nonceString)]);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Performs initial handshake with Apple TV to establish connection
|
|
133
|
+
* Sends handshake request with host options and wire protocol version
|
|
134
|
+
*/
|
|
135
|
+
private async performHandshake(): Promise<void> {
|
|
136
|
+
const request = this.createHandshakeRequest();
|
|
137
|
+
await this.networkClient.sendPacket(request);
|
|
138
|
+
await this.networkClient.receiveResponse();
|
|
139
|
+
PairingProtocol.log.debug('Handshake completed');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Attempts to verify existing pairing credentials with Apple TV
|
|
144
|
+
* Creates pair verification request and handles expected failure for new pairing flow
|
|
145
|
+
*/
|
|
146
|
+
private async attemptPairVerification(): Promise<void> {
|
|
147
|
+
const request = this.createPairVerificationRequest();
|
|
148
|
+
await this.networkClient.sendPacket(request);
|
|
149
|
+
await this.networkClient.receiveResponse();
|
|
150
|
+
|
|
151
|
+
const failedRequest = this.createPairVerifyFailedRequest();
|
|
152
|
+
await this.networkClient.sendPacket(failedRequest);
|
|
153
|
+
PairingProtocol.log.debug('Pair verification attempt completed');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Initiates manual pairing setup process with Apple TV
|
|
158
|
+
* Sends setup request and receives SRP challenge data
|
|
159
|
+
* @returns Response containing SRP salt and server public key
|
|
160
|
+
*/
|
|
161
|
+
private async setupManualPairing(): Promise<any> {
|
|
162
|
+
const request = this.createSetupManualPairingRequest();
|
|
163
|
+
await this.networkClient.sendPacket(request);
|
|
164
|
+
const response = await this.networkClient.receiveResponse();
|
|
165
|
+
PairingProtocol.log.debug('Manual pairing setup completed');
|
|
166
|
+
return response;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Extracts and validates SRP pairing data from Apple TV response
|
|
171
|
+
* @param response Network response containing pairing data
|
|
172
|
+
* @returns Parsed TLV8 dictionary with SRP challenge components
|
|
173
|
+
* @throws PairingError if pairing data is missing or invalid
|
|
174
|
+
*/
|
|
175
|
+
private extractAndValidatePairingData(response: any): Record<number, Buffer> {
|
|
176
|
+
const srpData =
|
|
177
|
+
response.message?.plain?._0?.event?._0?.pairingData?._0?.data;
|
|
178
|
+
if (!srpData) {
|
|
179
|
+
throw new PairingError('No pairing data received', 'NO_PAIRING_DATA');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const parsedSRP = this.parseTLV8Response(srpData);
|
|
183
|
+
this.validateSRPResponse(parsedSRP);
|
|
184
|
+
return parsedSRP;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Performs SRP authentication using user-provided PIN
|
|
189
|
+
* Prompts for PIN, creates SRP client, sends proof, and validates response
|
|
190
|
+
* @param parsedSRP SRP challenge data from Apple TV
|
|
191
|
+
* @returns Authenticated SRP client with session key
|
|
192
|
+
* @throws PairingError if PIN is incorrect or authentication fails
|
|
193
|
+
*/
|
|
194
|
+
private async performSRPAuthentication(
|
|
195
|
+
parsedSRP: Record<number, Buffer>,
|
|
196
|
+
): Promise<SRPClient> {
|
|
197
|
+
const pin = await this.userInput.promptForPIN();
|
|
198
|
+
const srpClient = this.createSRPClient(pin, parsedSRP);
|
|
199
|
+
|
|
200
|
+
await this.sendSRPProof(srpClient);
|
|
201
|
+
const response = await this.networkClient.receiveResponse();
|
|
202
|
+
this.validateSRPProofResponse(response);
|
|
203
|
+
|
|
204
|
+
PairingProtocol.log.debug('SRP authentication completed');
|
|
205
|
+
return srpClient;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Receives and processes M6 pairing completion message from Apple TV
|
|
210
|
+
* Attempts to decrypt and validate final pairing state
|
|
211
|
+
* @param decryptKey Decryption key for M6 encrypted data
|
|
212
|
+
*/
|
|
213
|
+
private async receiveM6Completion(decryptKey: Buffer): Promise<void> {
|
|
214
|
+
const m6Response = await this.networkClient.receiveResponse();
|
|
215
|
+
PairingProtocol.log.info('M6 Response received');
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
this.processM6Response(m6Response, decryptKey);
|
|
219
|
+
} catch (error) {
|
|
220
|
+
PairingProtocol.log.warn(
|
|
221
|
+
'M6 decryption failed - but pairing may still be successful:',
|
|
222
|
+
(error as Error).message,
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Constructs initial handshake request packet
|
|
229
|
+
* Includes host options and wire protocol version for pairing session
|
|
230
|
+
* @returns Handshake request with sequence number 0
|
|
231
|
+
*/
|
|
232
|
+
private createHandshakeRequest(): PairingRequest {
|
|
233
|
+
return {
|
|
234
|
+
message: {
|
|
235
|
+
plain: {
|
|
236
|
+
_0: {
|
|
237
|
+
request: {
|
|
238
|
+
_0: {
|
|
239
|
+
handshake: {
|
|
240
|
+
_0: {
|
|
241
|
+
hostOptions: { attemptPairVerify: true },
|
|
242
|
+
wireProtocolVersion: 19,
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
originatedBy: 'host',
|
|
251
|
+
sequenceNumber: 0,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Creates pair verification request with X25519 public key
|
|
257
|
+
* Used to attempt verification of existing pairing credentials
|
|
258
|
+
* @returns Pair verification request with random X25519 key
|
|
259
|
+
*/
|
|
260
|
+
private createPairVerificationRequest(): PairingRequest {
|
|
261
|
+
const x25519PublicKey = randomBytes(32);
|
|
262
|
+
const pairingData = createPairVerificationData(x25519PublicKey);
|
|
263
|
+
|
|
264
|
+
return this.createRequest({
|
|
265
|
+
event: {
|
|
266
|
+
_0: {
|
|
267
|
+
pairingData: {
|
|
268
|
+
_0: {
|
|
269
|
+
data: pairingData,
|
|
270
|
+
kind: 'verifyManualPairing',
|
|
271
|
+
startNewSession: true,
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
},
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Creates pair verification failed event request
|
|
281
|
+
* Sent after verification attempt to proceed with manual pairing setup
|
|
282
|
+
* @returns Pair verify failed event packet
|
|
283
|
+
*/
|
|
284
|
+
private createPairVerifyFailedRequest(): PairingRequest {
|
|
285
|
+
return this.createRequest({
|
|
286
|
+
event: {
|
|
287
|
+
_0: {
|
|
288
|
+
pairVerifyFailed: {},
|
|
289
|
+
},
|
|
290
|
+
},
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Constructs manual pairing setup request packet
|
|
296
|
+
* Initiates M1/M2 exchange with setup pairing data
|
|
297
|
+
* @returns Setup manual pairing request with host information
|
|
298
|
+
*/
|
|
299
|
+
private createSetupManualPairingRequest(): PairingRequest {
|
|
300
|
+
const setupData = createSetupManualPairingData();
|
|
301
|
+
|
|
302
|
+
return this.createRequest({
|
|
303
|
+
event: {
|
|
304
|
+
_0: {
|
|
305
|
+
pairingData: {
|
|
306
|
+
_0: {
|
|
307
|
+
data: setupData,
|
|
308
|
+
kind: 'setupManualPairing',
|
|
309
|
+
sendingHost: hostname(),
|
|
310
|
+
startNewSession: true,
|
|
311
|
+
},
|
|
312
|
+
},
|
|
313
|
+
},
|
|
314
|
+
},
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Parses base64-encoded TLV8 response into dictionary
|
|
320
|
+
* Decodes base64 string and converts TLV8 format to key-value pairs
|
|
321
|
+
* @param data Base64-encoded TLV8 data
|
|
322
|
+
* @returns Dictionary mapping TLV8 type numbers to buffer values
|
|
323
|
+
* @throws PairingError if TLV8 parsing fails
|
|
324
|
+
*/
|
|
325
|
+
private parseTLV8Response(data: string): Record<number, Buffer> {
|
|
326
|
+
try {
|
|
327
|
+
const buffer = Buffer.from(data, 'base64');
|
|
328
|
+
const decoded = decodeTLV8ToDict(buffer);
|
|
329
|
+
|
|
330
|
+
const result: Record<number, Buffer> = {};
|
|
331
|
+
for (const [key, value] of Object.entries(decoded)) {
|
|
332
|
+
if (value !== undefined) {
|
|
333
|
+
result[Number(key)] = value;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return result;
|
|
337
|
+
} catch (error) {
|
|
338
|
+
throw new PairingError(
|
|
339
|
+
'Failed to parse TLV8 response',
|
|
340
|
+
'TLV8_PARSE_ERROR',
|
|
341
|
+
error,
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Validates SRP response for errors and required challenge data
|
|
348
|
+
* Checks for error codes and verifies presence of salt and public key
|
|
349
|
+
* @param parsedSRP Parsed SRP response dictionary
|
|
350
|
+
* @throws PairingError if response contains errors or missing required data
|
|
351
|
+
*/
|
|
352
|
+
private validateSRPResponse(parsedSRP: Record<number, Buffer>): void {
|
|
353
|
+
const errorBuffer = parsedSRP[PairingDataComponentType.ERROR];
|
|
354
|
+
if (errorBuffer) {
|
|
355
|
+
if (errorBuffer.length === 0) {
|
|
356
|
+
throw new PairingError(
|
|
357
|
+
'Apple TV returned empty error buffer',
|
|
358
|
+
'INVALID_ERROR_RESPONSE',
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
const errorCode = errorBuffer[0];
|
|
362
|
+
throw new PairingError(
|
|
363
|
+
`Apple TV rejected request with error ${errorCode}`,
|
|
364
|
+
'APPLE_TV_ERROR',
|
|
365
|
+
{ errorCode },
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if (
|
|
370
|
+
!parsedSRP[PairingDataComponentType.SALT] ||
|
|
371
|
+
!parsedSRP[PairingDataComponentType.PUBLIC_KEY]
|
|
372
|
+
) {
|
|
373
|
+
throw new PairingError('Missing SRP challenge data', 'MISSING_SRP_DATA');
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Initializes SRP client with PIN and server challenge data
|
|
379
|
+
* Sets up identity, salt, and server public key for proof computation
|
|
380
|
+
* @param pin User-provided pairing PIN
|
|
381
|
+
* @param parsedSRP SRP challenge data containing salt and server public key
|
|
382
|
+
* @returns Configured SRP client ready for proof generation
|
|
383
|
+
* @throws PairingError if required SRP data is missing
|
|
384
|
+
*/
|
|
385
|
+
private createSRPClient(
|
|
386
|
+
pin: string,
|
|
387
|
+
parsedSRP: Record<number, Buffer>,
|
|
388
|
+
): SRPClient {
|
|
389
|
+
try {
|
|
390
|
+
const salt = parsedSRP[PairingDataComponentType.SALT];
|
|
391
|
+
const serverPublicKey = parsedSRP[PairingDataComponentType.PUBLIC_KEY];
|
|
392
|
+
|
|
393
|
+
const srpClient = new SRPClient();
|
|
394
|
+
srpClient.setIdentity('Pair-Setup', pin);
|
|
395
|
+
srpClient.salt = salt;
|
|
396
|
+
srpClient.serverPublicKey = serverPublicKey;
|
|
397
|
+
return srpClient;
|
|
398
|
+
} catch (error) {
|
|
399
|
+
throw new PairingError(
|
|
400
|
+
'Failed to create SRP client',
|
|
401
|
+
'SRP_CLIENT_ERROR',
|
|
402
|
+
error,
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Sends M3 message containing SRP proof to Apple TV
|
|
409
|
+
* Includes client public key and computed proof for authentication
|
|
410
|
+
* @param srpClient Configured SRP client with computed proof
|
|
411
|
+
*/
|
|
412
|
+
private async sendSRPProof(srpClient: SRPClient): Promise<void> {
|
|
413
|
+
const clientPublicKey = srpClient.publicKey;
|
|
414
|
+
const clientProof = srpClient.computeProof();
|
|
415
|
+
|
|
416
|
+
const tlvItems: TLV8Item[] = [
|
|
417
|
+
{
|
|
418
|
+
type: PairingDataComponentType.STATE,
|
|
419
|
+
data: Buffer.from([PAIRING_STATES.M3]),
|
|
420
|
+
},
|
|
421
|
+
...this.fragmentBuffer(
|
|
422
|
+
clientPublicKey,
|
|
423
|
+
PairingDataComponentType.PUBLIC_KEY,
|
|
424
|
+
),
|
|
425
|
+
{ type: PairingDataComponentType.PROOF, data: clientProof },
|
|
426
|
+
];
|
|
427
|
+
const tlv = encodeTLV8(tlvItems);
|
|
428
|
+
|
|
429
|
+
const request = this.createRequest({
|
|
430
|
+
event: {
|
|
431
|
+
_0: {
|
|
432
|
+
pairingData: {
|
|
433
|
+
_0: {
|
|
434
|
+
data: tlv.toString('base64'),
|
|
435
|
+
kind: 'setupManualPairing',
|
|
436
|
+
sendingHost: hostname(),
|
|
437
|
+
startNewSession: false,
|
|
438
|
+
},
|
|
439
|
+
},
|
|
440
|
+
},
|
|
441
|
+
},
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
await this.networkClient.sendPacket(request);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Validates M4 SRP proof response from Apple TV
|
|
449
|
+
* Checks for authentication errors indicating incorrect PIN
|
|
450
|
+
* @param response Network response containing SRP proof validation result
|
|
451
|
+
* @throws PairingError if PIN authentication failed
|
|
452
|
+
*/
|
|
453
|
+
private validateSRPProofResponse(response: any): void {
|
|
454
|
+
if (response.message?.plain?._0?.event?._0?.pairingData?._0?.data) {
|
|
455
|
+
const proofData = Buffer.from(
|
|
456
|
+
response.message.plain._0.event._0.pairingData._0.data,
|
|
457
|
+
'base64',
|
|
458
|
+
);
|
|
459
|
+
const parsedProof = decodeTLV8ToDict(proofData);
|
|
460
|
+
|
|
461
|
+
if (parsedProof[PairingDataComponentType.ERROR]) {
|
|
462
|
+
throw new PairingError(
|
|
463
|
+
'SRP authentication failed - wrong PIN',
|
|
464
|
+
'WRONG_PIN',
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Sends M5 exchange message with encrypted pairing credentials
|
|
472
|
+
* Includes long-term public key, signature, and device information encrypted with session key
|
|
473
|
+
* @param encryptKey Encryption key derived from session key
|
|
474
|
+
* @param devicePairingID Host device pairing identifier
|
|
475
|
+
* @param ltpk Long-term public key (Ed25519)
|
|
476
|
+
* @param ltsk Long-term secret key (Ed25519)
|
|
477
|
+
* @param sessionKey SRP session key for signature derivation
|
|
478
|
+
* @throws PairingError if M5 message creation fails
|
|
479
|
+
*/
|
|
480
|
+
private async sendM5Message(
|
|
481
|
+
encryptKey: Buffer,
|
|
482
|
+
devicePairingID: string,
|
|
483
|
+
ltpk: Buffer,
|
|
484
|
+
ltsk: Buffer,
|
|
485
|
+
sessionKey: Buffer,
|
|
486
|
+
): Promise<void> {
|
|
487
|
+
try {
|
|
488
|
+
const signingKey = hkdf({
|
|
489
|
+
ikm: sessionKey,
|
|
490
|
+
salt: Buffer.from(PAIRING_MESSAGES.SIGN_SALT, 'utf8'),
|
|
491
|
+
info: Buffer.from(PAIRING_MESSAGES.SIGN_INFO, 'utf8'),
|
|
492
|
+
length: 32,
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
const devicePairingIDBuffer = Buffer.from(devicePairingID, 'utf8');
|
|
496
|
+
const dataToSign = Buffer.concat([
|
|
497
|
+
signingKey,
|
|
498
|
+
devicePairingIDBuffer,
|
|
499
|
+
ltpk,
|
|
500
|
+
]);
|
|
501
|
+
const signature = createEd25519Signature(dataToSign, ltsk);
|
|
502
|
+
const deviceInfo = encodeAppleTVDeviceInfo(devicePairingID);
|
|
503
|
+
|
|
504
|
+
const tlvItems: TLV8Item[] = [
|
|
505
|
+
{
|
|
506
|
+
type: PairingDataComponentType.IDENTIFIER,
|
|
507
|
+
data: devicePairingIDBuffer,
|
|
508
|
+
},
|
|
509
|
+
{ type: PairingDataComponentType.PUBLIC_KEY, data: ltpk },
|
|
510
|
+
{ type: PairingDataComponentType.SIGNATURE, data: signature },
|
|
511
|
+
{ type: INFO_TYPE as any, data: deviceInfo },
|
|
512
|
+
];
|
|
513
|
+
|
|
514
|
+
const tlvData = encodeTLV8(tlvItems);
|
|
515
|
+
const nonce = this.createNonce(PAIRING_MESSAGES.M5_NONCE);
|
|
516
|
+
const encrypted = encryptChaCha20Poly1305({
|
|
517
|
+
plaintext: tlvData,
|
|
518
|
+
key: encryptKey,
|
|
519
|
+
nonce,
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
const encryptedTLVItems: TLV8Item[] = [
|
|
523
|
+
...this.fragmentBuffer(
|
|
524
|
+
encrypted,
|
|
525
|
+
PairingDataComponentType.ENCRYPTED_DATA,
|
|
526
|
+
),
|
|
527
|
+
{
|
|
528
|
+
type: PairingDataComponentType.STATE,
|
|
529
|
+
data: Buffer.from([PAIRING_STATES.M5]),
|
|
530
|
+
},
|
|
531
|
+
];
|
|
532
|
+
const encryptedTLV = encodeTLV8(encryptedTLVItems);
|
|
533
|
+
|
|
534
|
+
const request = this.createRequest({
|
|
535
|
+
event: {
|
|
536
|
+
_0: {
|
|
537
|
+
pairingData: {
|
|
538
|
+
_0: {
|
|
539
|
+
data: encryptedTLV.toString('base64'),
|
|
540
|
+
kind: 'setupManualPairing',
|
|
541
|
+
sendingHost: hostname(),
|
|
542
|
+
startNewSession: false,
|
|
543
|
+
},
|
|
544
|
+
},
|
|
545
|
+
},
|
|
546
|
+
},
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
await this.networkClient.sendPacket(request);
|
|
550
|
+
} catch (error) {
|
|
551
|
+
throw new PairingError('Failed to create M5 message', 'M5_ERROR', error);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Processes and decrypts M6 completion response from Apple TV
|
|
557
|
+
* Validates pairing completion state and decrypts final exchange data
|
|
558
|
+
* @param m6Response Network response containing M6 completion message
|
|
559
|
+
* @param decryptKey Decryption key for M6 encrypted data
|
|
560
|
+
*/
|
|
561
|
+
private processM6Response(m6Response: any, decryptKey: Buffer): void {
|
|
562
|
+
if (!m6Response.message?.plain?._0?.event?._0?.pairingData?._0?.data) {
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const m6DataBase64 =
|
|
567
|
+
m6Response.message.plain._0.event._0.pairingData._0.data;
|
|
568
|
+
const m6TLVBuffer = Buffer.from(m6DataBase64, 'base64');
|
|
569
|
+
const m6Parsed = decodeTLV8ToDict(m6TLVBuffer);
|
|
570
|
+
|
|
571
|
+
PairingProtocol.log.debug(
|
|
572
|
+
'M6 TLV types received:',
|
|
573
|
+
Object.keys(m6Parsed).map((k) => `0x${Number(k).toString(16)}`),
|
|
574
|
+
);
|
|
575
|
+
|
|
576
|
+
const stateData = m6Parsed[PairingDataComponentType.STATE];
|
|
577
|
+
if (stateData && stateData[0] === PAIRING_STATES.M6) {
|
|
578
|
+
PairingProtocol.log.info(
|
|
579
|
+
'✅ Pairing completed successfully (STATE=0x06)',
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const encryptedData = m6Parsed[PairingDataComponentType.ENCRYPTED_DATA];
|
|
584
|
+
if (encryptedData) {
|
|
585
|
+
const nonce = this.createNonce(PAIRING_MESSAGES.M6_NONCE);
|
|
586
|
+
const decrypted = decryptChaCha20Poly1305({
|
|
587
|
+
ciphertext: encryptedData,
|
|
588
|
+
key: decryptKey,
|
|
589
|
+
nonce,
|
|
590
|
+
});
|
|
591
|
+
const decryptedTLV = decodeTLV8ToDict(decrypted);
|
|
592
|
+
PairingProtocol.log.debug(
|
|
593
|
+
'M6 decrypted content types:',
|
|
594
|
+
Object.keys(decryptedTLV),
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Derives encryption and decryption keys from SRP session key
|
|
601
|
+
* Uses HKDF with pairing-specific salt and info strings
|
|
602
|
+
* @param sessionKey SRP session key from authentication
|
|
603
|
+
* @returns Encryption keys for M5/M6 message exchange
|
|
604
|
+
*/
|
|
605
|
+
private deriveEncryptionKeys(sessionKey: Buffer): EncryptionKeys {
|
|
606
|
+
const sharedKey = hkdf({
|
|
607
|
+
ikm: sessionKey,
|
|
608
|
+
salt: Buffer.from(PAIRING_MESSAGES.ENCRYPT_SALT, 'utf8'),
|
|
609
|
+
info: Buffer.from(PAIRING_MESSAGES.ENCRYPT_INFO, 'utf8'),
|
|
610
|
+
length: 32,
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
PairingProtocol.log.debug('Derived encryption keys');
|
|
614
|
+
return {
|
|
615
|
+
encryptKey: sharedKey,
|
|
616
|
+
decryptKey: sharedKey,
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Saves pairing credentials to storage and returns credential path
|
|
622
|
+
* Persists long-term public and private keys for future connections
|
|
623
|
+
* @param device Apple TV device information
|
|
624
|
+
* @param ltpk Long-term public key to save
|
|
625
|
+
* @param ltsk Long-term secret key to save
|
|
626
|
+
* @returns Path to saved pairing credentials file
|
|
627
|
+
*/
|
|
628
|
+
private async createPairingResult(
|
|
629
|
+
device: AppleTVDevice,
|
|
630
|
+
ltpk: Buffer,
|
|
631
|
+
ltsk: Buffer,
|
|
632
|
+
): Promise<string> {
|
|
633
|
+
const storage = new PairingStorage(DEFAULT_PAIRING_CONFIG);
|
|
634
|
+
return await storage.save(device.identifier || device.name, ltpk, ltsk);
|
|
635
|
+
}
|
|
636
|
+
}
|