appium-ios-remotexpc 0.21.2 → 0.23.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 +12 -0
- package/build/src/index.d.ts +1 -1
- package/build/src/index.d.ts.map +1 -1
- package/build/src/lib/apple-tv/constants.d.ts +4 -3
- package/build/src/lib/apple-tv/constants.d.ts.map +1 -1
- package/build/src/lib/apple-tv/constants.js +10 -3
- package/build/src/lib/apple-tv/discovery/device-discovery.d.ts.map +1 -1
- package/build/src/lib/apple-tv/discovery/device-discovery.js +2 -2
- package/build/src/lib/apple-tv/encryption/index.d.ts +1 -0
- package/build/src/lib/apple-tv/encryption/index.d.ts.map +1 -1
- package/build/src/lib/apple-tv/encryption/index.js +1 -0
- package/build/src/lib/apple-tv/encryption/x25519.d.ts +8 -0
- package/build/src/lib/apple-tv/encryption/x25519.d.ts.map +1 -0
- package/build/src/lib/apple-tv/encryption/x25519.js +52 -0
- package/build/src/lib/apple-tv/index.d.ts +1 -0
- package/build/src/lib/apple-tv/index.d.ts.map +1 -1
- package/build/src/lib/apple-tv/index.js +1 -0
- package/build/src/lib/apple-tv/network/network-client.js +2 -2
- package/build/src/lib/apple-tv/pairing/user-input-service.d.ts.map +1 -1
- package/build/src/lib/apple-tv/pairing/user-input-service.js +2 -2
- package/build/src/lib/apple-tv/pairing-protocol/constants.d.ts +17 -0
- package/build/src/lib/apple-tv/pairing-protocol/constants.d.ts.map +1 -1
- package/build/src/lib/apple-tv/pairing-protocol/constants.js +25 -0
- package/build/src/lib/apple-tv/pairing-protocol/index.d.ts +2 -1
- package/build/src/lib/apple-tv/pairing-protocol/index.d.ts.map +1 -1
- package/build/src/lib/apple-tv/pairing-protocol/index.js +2 -1
- package/build/src/lib/apple-tv/pairing-protocol/pair-verification-protocol.d.ts +66 -0
- package/build/src/lib/apple-tv/pairing-protocol/pair-verification-protocol.d.ts.map +1 -0
- package/build/src/lib/apple-tv/pairing-protocol/pair-verification-protocol.js +178 -0
- package/build/src/lib/apple-tv/pairing-protocol/pairing-protocol.d.ts +35 -2
- package/build/src/lib/apple-tv/pairing-protocol/pairing-protocol.d.ts.map +1 -1
- package/build/src/lib/apple-tv/pairing-protocol/pairing-protocol.js +48 -14
- package/build/src/lib/apple-tv/storage/index.d.ts +1 -1
- package/build/src/lib/apple-tv/storage/index.d.ts.map +1 -1
- package/build/src/lib/apple-tv/storage/pairing-storage.d.ts +4 -1
- package/build/src/lib/apple-tv/storage/pairing-storage.d.ts.map +1 -1
- package/build/src/lib/apple-tv/storage/pairing-storage.js +59 -4
- package/build/src/lib/apple-tv/storage/types.d.ts +7 -1
- package/build/src/lib/apple-tv/storage/types.d.ts.map +1 -1
- package/build/src/lib/apple-tv/tunnel/index.d.ts +3 -0
- package/build/src/lib/apple-tv/tunnel/index.d.ts.map +1 -0
- package/build/src/lib/apple-tv/tunnel/index.js +1 -0
- package/build/src/lib/apple-tv/tunnel/tunnel-service.d.ts +57 -0
- package/build/src/lib/apple-tv/tunnel/tunnel-service.d.ts.map +1 -0
- package/build/src/lib/apple-tv/tunnel/tunnel-service.js +357 -0
- package/build/src/lib/apple-tv/tunnel/types.d.ts +22 -0
- package/build/src/lib/apple-tv/tunnel/types.d.ts.map +1 -0
- package/build/src/lib/apple-tv/tunnel/types.js +1 -0
- package/build/src/lib/bonjour/bonjour-discovery.d.ts.map +1 -1
- package/build/src/lib/bonjour/bonjour-discovery.js +3 -3
- package/build/src/lib/lockdown/index.d.ts.map +1 -1
- package/build/src/lib/plist/length-based-splitter.d.ts.map +1 -1
- package/build/src/lib/plist/length-based-splitter.js +0 -7
- package/build/src/lib/tunnel/index.d.ts +1 -0
- package/build/src/lib/tunnel/index.d.ts.map +1 -1
- package/build/src/lib/tunnel/packet-stream-server.d.ts.map +1 -1
- package/build/src/lib/tunnel/tunnel-registry-server.d.ts +1 -0
- package/build/src/lib/tunnel/tunnel-registry-server.d.ts.map +1 -1
- package/build/src/lib/tunnel/tunnel-registry-server.js +1 -1
- package/build/src/lib/types.d.ts +59 -0
- package/build/src/lib/types.d.ts.map +1 -1
- package/build/src/services/index.d.ts +2 -1
- package/build/src/services/index.d.ts.map +1 -1
- package/build/src/services/index.js +2 -1
- package/build/src/services/ios/afc/codec.d.ts +12 -0
- package/build/src/services/ios/afc/codec.d.ts.map +1 -1
- package/build/src/services/ios/afc/codec.js +26 -0
- package/build/src/services/ios/afc/index.d.ts.map +1 -1
- package/build/src/services/ios/afc/index.js +3 -14
- package/build/src/services/ios/afc/stream-utils.d.ts.map +1 -1
- package/build/src/services/ios/afc/stream-utils.js +0 -2
- package/build/src/services/ios/crash-reports/index.d.ts +54 -0
- package/build/src/services/ios/crash-reports/index.d.ts.map +1 -0
- package/build/src/services/ios/crash-reports/index.js +136 -0
- package/build/src/services/ios/mobile-config/index.js +2 -2
- package/build/src/services.d.ts +6 -1
- package/build/src/services.d.ts.map +1 -1
- package/build/src/services.js +14 -0
- package/package.json +3 -1
- package/scripts/pair-appletv.ts +2 -2
- package/scripts/start-appletv-tunnel.ts +178 -0
- package/scripts/test-tunnel-creation.ts +32 -23
- package/src/index.ts +3 -0
- package/src/lib/apple-tv/constants.ts +11 -3
- package/src/lib/apple-tv/discovery/device-discovery.ts +2 -3
- package/src/lib/apple-tv/encryption/index.ts +6 -0
- package/src/lib/apple-tv/encryption/x25519.ts +79 -0
- package/src/lib/apple-tv/index.ts +1 -0
- package/src/lib/apple-tv/network/network-client.ts +2 -2
- package/src/lib/apple-tv/pairing/user-input-service.ts +2 -2
- package/src/lib/apple-tv/pairing-protocol/constants.ts +29 -0
- package/src/lib/apple-tv/pairing-protocol/index.ts +12 -1
- package/src/lib/apple-tv/pairing-protocol/pair-verification-protocol.ts +329 -0
- package/src/lib/apple-tv/pairing-protocol/pairing-protocol.ts +49 -19
- package/src/lib/apple-tv/storage/index.ts +1 -1
- package/src/lib/apple-tv/storage/pairing-storage.ts +73 -5
- package/src/lib/apple-tv/storage/types.ts +8 -1
- package/src/lib/apple-tv/tunnel/index.ts +2 -0
- package/src/lib/apple-tv/tunnel/tunnel-service.ts +543 -0
- package/src/lib/apple-tv/tunnel/types.ts +23 -0
- package/src/lib/bonjour/bonjour-discovery.ts +3 -5
- package/src/lib/lockdown/index.ts +0 -7
- package/src/lib/plist/length-based-splitter.ts +0 -22
- package/src/lib/tunnel/index.ts +2 -8
- package/src/lib/tunnel/packet-stream-server.ts +0 -8
- package/src/lib/tunnel/tunnel-registry-server.ts +1 -1
- package/src/lib/types.ts +70 -0
- package/src/services/index.ts +2 -0
- package/src/services/ios/afc/codec.ts +34 -0
- package/src/services/ios/afc/index.ts +4 -17
- package/src/services/ios/afc/stream-utils.ts +0 -2
- package/src/services/ios/crash-reports/index.ts +180 -0
- package/src/services/ios/mobile-config/index.ts +2 -2
- package/src/services.ts +27 -0
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import { type KeyObject } from 'node:crypto';
|
|
2
|
+
import { hostname } from 'node:os';
|
|
3
|
+
|
|
4
|
+
import { getLogger } from '../../logger.js';
|
|
5
|
+
import { PairingDataComponentType } from '../constants.js';
|
|
6
|
+
import {
|
|
7
|
+
createEd25519Signature,
|
|
8
|
+
encryptChaCha20Poly1305,
|
|
9
|
+
generateX25519KeyPair,
|
|
10
|
+
hkdf,
|
|
11
|
+
performX25519DiffieHellman,
|
|
12
|
+
} from '../encryption/index.js';
|
|
13
|
+
import { PairingError } from '../errors.js';
|
|
14
|
+
import type { NetworkClientInterface } from '../network/types.js';
|
|
15
|
+
import type { PairRecord } from '../storage/types.js';
|
|
16
|
+
import { decodeTLV8ToDict, encodeTLV8 } from '../tlv/index.js';
|
|
17
|
+
import { generateHostId } from '../utils/uuid-generator.js';
|
|
18
|
+
import {
|
|
19
|
+
ENCRYPTION_MESSAGES,
|
|
20
|
+
PAIR_VERIFY_ERROR_DESCRIPTIONS,
|
|
21
|
+
PAIR_VERIFY_MESSAGES,
|
|
22
|
+
PAIR_VERIFY_STATES,
|
|
23
|
+
} from './constants.js';
|
|
24
|
+
import type { PairingRequest } from './types.js';
|
|
25
|
+
|
|
26
|
+
const log = getLogger('PairVerificationProtocol');
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Implements the HomeKit Accessory Protocol (HAP) Pair-Verify process for Apple TV.
|
|
30
|
+
*
|
|
31
|
+
* Protocol Overview:
|
|
32
|
+
* This class implements the HAP Pair-Verify protocol, which establishes an encrypted
|
|
33
|
+
* session between a previously paired controller (this client) and an Apple TV accessory.
|
|
34
|
+
* Unlike Pair-Setup, Pair-Verify uses existing long-term keys to authenticate both parties
|
|
35
|
+
* and derive session-specific encryption keys without requiring user interaction.
|
|
36
|
+
*
|
|
37
|
+
* State Machine Flow (4 states):
|
|
38
|
+
* - STATE=1: Client sends ephemeral X25519 public key to device
|
|
39
|
+
* - STATE=2: Device responds with its ephemeral X25519 public key and encrypted proof
|
|
40
|
+
* - STATE=3: Client sends encrypted signature proving identity using Ed25519 private key
|
|
41
|
+
* - STATE=4: Device confirms verification success or returns error
|
|
42
|
+
*
|
|
43
|
+
* After successful verification, both parties derive shared session keys for encrypting
|
|
44
|
+
* all subsequent communication during this session.
|
|
45
|
+
*
|
|
46
|
+
* Technical Details:
|
|
47
|
+
* - Uses X25519 Elliptic Curve Diffie-Hellman for ephemeral key exchange (RFC 7748)
|
|
48
|
+
* - Employs Ed25519 signatures for authentication using long-term keys (RFC 8032)
|
|
49
|
+
* - Uses ChaCha20-Poly1305 for authenticated encryption (RFC 8439)
|
|
50
|
+
* - Derives session keys using HKDF with protocol-specific salt/info (RFC 5869)
|
|
51
|
+
* - Encodes messages in TLV8 (Type-Length-Value) format
|
|
52
|
+
*
|
|
53
|
+
* Security Properties:
|
|
54
|
+
* - Perfect Forward Secrecy: Each session uses unique ephemeral keys
|
|
55
|
+
* - Mutual Authentication: Both client and device prove their identities
|
|
56
|
+
* - Replay Protection: Ephemeral keys prevent replay attacks
|
|
57
|
+
*
|
|
58
|
+
* References:
|
|
59
|
+
* - HAP Specification: https://developer.apple.com/homekit/ (Apple Developer)
|
|
60
|
+
* - HAP-NodeJS (community implementation): https://github.com/homebridge/HAP-NodeJS
|
|
61
|
+
* - X25519 ECDH: https://datatracker.ietf.org/doc/html/rfc7748
|
|
62
|
+
* - Ed25519 Signatures: https://datatracker.ietf.org/doc/html/rfc8032
|
|
63
|
+
* - ChaCha20-Poly1305: https://datatracker.ietf.org/doc/html/rfc8439
|
|
64
|
+
* - HKDF: https://datatracker.ietf.org/doc/html/rfc5869
|
|
65
|
+
*
|
|
66
|
+
* @see PairingProtocol for the initial pairing process that generates the long-term keys
|
|
67
|
+
* @see PairRecord for the stored credentials used in verification
|
|
68
|
+
*/
|
|
69
|
+
|
|
70
|
+
export interface VerificationKeys {
|
|
71
|
+
encryptionKey: Buffer;
|
|
72
|
+
clientEncryptionKey: Buffer;
|
|
73
|
+
serverEncryptionKey: Buffer;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export class PairVerificationProtocol {
|
|
77
|
+
private readonly hostIdentifier: string;
|
|
78
|
+
private sequenceNumber: number = 0;
|
|
79
|
+
|
|
80
|
+
constructor(private readonly networkClient: NetworkClientInterface) {
|
|
81
|
+
this.hostIdentifier = generateHostId(hostname());
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async verify(
|
|
85
|
+
pairRecord: PairRecord,
|
|
86
|
+
deviceId: string,
|
|
87
|
+
): Promise<VerificationKeys> {
|
|
88
|
+
log.debug('Starting pair verification (4-step process)');
|
|
89
|
+
|
|
90
|
+
const { publicKey, privateKey } = generateX25519KeyPair();
|
|
91
|
+
|
|
92
|
+
log.debug(' - STATE=1: Send X25519 public key to device');
|
|
93
|
+
await this.sendState1(publicKey);
|
|
94
|
+
|
|
95
|
+
const devicePublicKey = await this.processState2Response();
|
|
96
|
+
|
|
97
|
+
const sharedSecret = this.computeSharedSecret(privateKey, devicePublicKey);
|
|
98
|
+
const pairVerifyKey = this.derivePairVerifyKey(sharedSecret);
|
|
99
|
+
|
|
100
|
+
log.debug(
|
|
101
|
+
' - STATE=3: Send encrypted signature using Ed25519 private key from pair record',
|
|
102
|
+
);
|
|
103
|
+
log.debug(` Using pair record: ${deviceId}`);
|
|
104
|
+
|
|
105
|
+
await this.sendState3(
|
|
106
|
+
pairRecord,
|
|
107
|
+
publicKey,
|
|
108
|
+
devicePublicKey,
|
|
109
|
+
pairVerifyKey,
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
await this.validateState4Response();
|
|
113
|
+
|
|
114
|
+
log.debug(' - STATE=4: Receive verification success from device');
|
|
115
|
+
|
|
116
|
+
return this.deriveEncryptionKeys(sharedSecret);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
getSequenceNumber(): number {
|
|
120
|
+
return this.sequenceNumber;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
setSequenceNumber(value: number): void {
|
|
124
|
+
this.sequenceNumber = value;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private async processState2Response(): Promise<Buffer> {
|
|
128
|
+
const state2Response = await this.networkClient.receiveResponse();
|
|
129
|
+
|
|
130
|
+
const pairingData =
|
|
131
|
+
state2Response.message?.plain?._0?.event?._0?.pairingData?._0?.data;
|
|
132
|
+
if (!pairingData) {
|
|
133
|
+
throw new PairingError(
|
|
134
|
+
'No pairing data in STATE=2 response',
|
|
135
|
+
'STATE_2_NO_DATA',
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const tlvData = decodeTLV8ToDict(Buffer.from(pairingData, 'base64'));
|
|
140
|
+
|
|
141
|
+
if (tlvData[PairingDataComponentType.ERROR]) {
|
|
142
|
+
const errorCode = tlvData[PairingDataComponentType.ERROR] as Buffer;
|
|
143
|
+
const errorDecimal = errorCode[0];
|
|
144
|
+
log.error(
|
|
145
|
+
`Device returned error in STATE=2: ${errorCode.toString('hex')} (decimal: ${errorDecimal})`,
|
|
146
|
+
);
|
|
147
|
+
throw new PairingError(
|
|
148
|
+
`Authentication failed at STATE=2 (error: ${errorDecimal})`,
|
|
149
|
+
'STATE_2_ERROR',
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const devicePublicKey = tlvData[PairingDataComponentType.PUBLIC_KEY];
|
|
154
|
+
if (!devicePublicKey) {
|
|
155
|
+
throw new PairingError(
|
|
156
|
+
'No device public key in STATE=2',
|
|
157
|
+
'STATE_2_NO_PUBLIC_KEY',
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
log.debug(' - STATE=2: Receive devices X25519 public key + encrypted data');
|
|
162
|
+
|
|
163
|
+
return devicePublicKey;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private computeSharedSecret(
|
|
167
|
+
privateKey: KeyObject,
|
|
168
|
+
devicePublicKey: Buffer,
|
|
169
|
+
): Buffer {
|
|
170
|
+
return performX25519DiffieHellman(privateKey, devicePublicKey);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private derivePairVerifyKey(sharedSecret: Buffer): Buffer {
|
|
174
|
+
return hkdf({
|
|
175
|
+
ikm: sharedSecret,
|
|
176
|
+
salt: Buffer.from(PAIR_VERIFY_MESSAGES.ENCRYPT_SALT),
|
|
177
|
+
info: Buffer.from(PAIR_VERIFY_MESSAGES.ENCRYPT_INFO),
|
|
178
|
+
length: 32,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
private async validateState4Response(): Promise<void> {
|
|
183
|
+
const state4Response = await this.networkClient.receiveResponse();
|
|
184
|
+
|
|
185
|
+
const state4Data =
|
|
186
|
+
state4Response.message?.plain?._0?.event?._0?.pairingData?._0?.data;
|
|
187
|
+
if (!state4Data) {
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const state4TLV = decodeTLV8ToDict(Buffer.from(state4Data, 'base64'));
|
|
192
|
+
|
|
193
|
+
if (state4TLV[PairingDataComponentType.ERROR]) {
|
|
194
|
+
const errorCode = state4TLV[PairingDataComponentType.ERROR] as Buffer;
|
|
195
|
+
const errorDecimal = errorCode[0];
|
|
196
|
+
|
|
197
|
+
const errorDescription =
|
|
198
|
+
PAIR_VERIFY_ERROR_DESCRIPTIONS[errorDecimal] || 'Unknown error';
|
|
199
|
+
|
|
200
|
+
log.error(
|
|
201
|
+
`Device returned error in STATE=4: ${errorCode.toString('hex')} (decimal: ${errorDecimal})`,
|
|
202
|
+
);
|
|
203
|
+
log.error(`Error description: ${errorDescription}`);
|
|
204
|
+
throw new PairingError(
|
|
205
|
+
`Pair verification failed: ${errorDescription}`,
|
|
206
|
+
'STATE_4_ERROR',
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private createPairingPayload(
|
|
212
|
+
data: string,
|
|
213
|
+
startNewSession: boolean,
|
|
214
|
+
): PairingRequest {
|
|
215
|
+
return {
|
|
216
|
+
message: {
|
|
217
|
+
plain: {
|
|
218
|
+
_0: {
|
|
219
|
+
event: {
|
|
220
|
+
_0: {
|
|
221
|
+
pairingData: {
|
|
222
|
+
_0: {
|
|
223
|
+
data,
|
|
224
|
+
kind: 'verifyManualPairing',
|
|
225
|
+
startNewSession,
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
originatedBy: 'host',
|
|
234
|
+
sequenceNumber: this.sequenceNumber++,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
private async sendState1(x25519PublicKey: Buffer): Promise<void> {
|
|
239
|
+
const tlvData = encodeTLV8([
|
|
240
|
+
{
|
|
241
|
+
type: PairingDataComponentType.STATE,
|
|
242
|
+
data: Buffer.from([PAIR_VERIFY_STATES.STATE_01]),
|
|
243
|
+
},
|
|
244
|
+
{ type: PairingDataComponentType.PUBLIC_KEY, data: x25519PublicKey },
|
|
245
|
+
]);
|
|
246
|
+
|
|
247
|
+
const payload = this.createPairingPayload(tlvData.toString('base64'), true);
|
|
248
|
+
|
|
249
|
+
await this.networkClient.sendPacket(payload);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
private async sendState3(
|
|
253
|
+
pairRecord: PairRecord,
|
|
254
|
+
x25519PublicKey: Buffer,
|
|
255
|
+
devicePublicKey: Buffer,
|
|
256
|
+
pairVerifyEncryptionKey: Buffer,
|
|
257
|
+
): Promise<void> {
|
|
258
|
+
const signData = Buffer.concat([
|
|
259
|
+
x25519PublicKey,
|
|
260
|
+
Buffer.from(this.hostIdentifier, 'utf8'),
|
|
261
|
+
devicePublicKey,
|
|
262
|
+
]);
|
|
263
|
+
|
|
264
|
+
const signature = createEd25519Signature(signData, pairRecord.privateKey);
|
|
265
|
+
|
|
266
|
+
const responseTLV = encodeTLV8([
|
|
267
|
+
{
|
|
268
|
+
type: PairingDataComponentType.IDENTIFIER,
|
|
269
|
+
data: Buffer.from(this.hostIdentifier, 'utf8'),
|
|
270
|
+
},
|
|
271
|
+
{ type: PairingDataComponentType.SIGNATURE, data: signature },
|
|
272
|
+
]);
|
|
273
|
+
|
|
274
|
+
const nonce = Buffer.concat([
|
|
275
|
+
Buffer.alloc(4),
|
|
276
|
+
Buffer.from(PAIR_VERIFY_MESSAGES.STATE_03_NONCE),
|
|
277
|
+
]);
|
|
278
|
+
|
|
279
|
+
const encryptedResponse = encryptChaCha20Poly1305({
|
|
280
|
+
plaintext: responseTLV,
|
|
281
|
+
key: pairVerifyEncryptionKey,
|
|
282
|
+
nonce,
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
const finalTLV = encodeTLV8([
|
|
286
|
+
{
|
|
287
|
+
type: PairingDataComponentType.STATE,
|
|
288
|
+
data: Buffer.from([PAIR_VERIFY_STATES.STATE_03]),
|
|
289
|
+
},
|
|
290
|
+
{
|
|
291
|
+
type: PairingDataComponentType.ENCRYPTED_DATA,
|
|
292
|
+
data: encryptedResponse,
|
|
293
|
+
},
|
|
294
|
+
]);
|
|
295
|
+
|
|
296
|
+
const payload = this.createPairingPayload(
|
|
297
|
+
finalTLV.toString('base64'),
|
|
298
|
+
false,
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
await this.networkClient.sendPacket(payload);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
private deriveEncryptionKeys(sharedSecret: Buffer): VerificationKeys {
|
|
305
|
+
log.debug('Deriving main encryption keys');
|
|
306
|
+
|
|
307
|
+
const clientEncryptionKey = hkdf({
|
|
308
|
+
ikm: sharedSecret,
|
|
309
|
+
salt: null,
|
|
310
|
+
info: Buffer.from(ENCRYPTION_MESSAGES.CLIENT_ENCRYPT),
|
|
311
|
+
length: 32,
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
const serverEncryptionKey = hkdf({
|
|
315
|
+
ikm: sharedSecret,
|
|
316
|
+
salt: null,
|
|
317
|
+
info: Buffer.from(ENCRYPTION_MESSAGES.SERVER_ENCRYPT),
|
|
318
|
+
length: 32,
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
log.debug('Derived client/server encryption keys using HKDF');
|
|
322
|
+
|
|
323
|
+
return {
|
|
324
|
+
encryptionKey: sharedSecret,
|
|
325
|
+
clientEncryptionKey,
|
|
326
|
+
serverEncryptionKey,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { logger } from '@appium/support';
|
|
2
1
|
import { randomBytes } from 'node:crypto';
|
|
3
2
|
import { hostname } from 'node:os';
|
|
4
3
|
|
|
5
4
|
import type { AppleTVDevice } from '../../bonjour/bonjour-discovery.js';
|
|
5
|
+
import { getLogger } from '../../logger.js';
|
|
6
6
|
import {
|
|
7
7
|
DEFAULT_PAIRING_CONFIG,
|
|
8
8
|
PairingDataComponentType,
|
|
@@ -36,9 +36,44 @@ import type {
|
|
|
36
36
|
UserInputInterface,
|
|
37
37
|
} from './types.js';
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
const log = getLogger('PairingProtocol');
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Implements the HomeKit Accessory Protocol (HAP) Pair-Setup process for Apple TV.
|
|
43
|
+
*
|
|
44
|
+
* Protocol Overview:
|
|
45
|
+
* This class implements the HAP Pair-Setup protocol, which establishes a secure pairing
|
|
46
|
+
* between a controller (this client) and an Apple TV accessory. The protocol uses SRP
|
|
47
|
+
* (Secure Remote Password) authentication to verify a user-provided PIN without transmitting
|
|
48
|
+
* the PIN itself over the network.
|
|
49
|
+
*
|
|
50
|
+
* Message Exchange Flow (M1-M6):
|
|
51
|
+
* - M1/M2: Initial setup request and SRP challenge (salt + server public key)
|
|
52
|
+
* - M3/M4: Client sends SRP proof, server validates and responds
|
|
53
|
+
* - M5/M6: Exchange of long-term public keys and signatures (encrypted)
|
|
54
|
+
*
|
|
55
|
+
* After successful pairing, the generated Ed25519 key pair is stored and used for
|
|
56
|
+
* subsequent Pair-Verify operations to establish encrypted sessions.
|
|
57
|
+
*
|
|
58
|
+
* Technical Details:
|
|
59
|
+
* - Uses SRP-6a protocol for password-authenticated key exchange (RFC 5054)
|
|
60
|
+
* - Employs Ed25519 for long-term identity keys (RFC 8032)
|
|
61
|
+
* - Uses ChaCha20-Poly1305 for authenticated encryption (RFC 8439)
|
|
62
|
+
* - Derives session keys using HKDF (RFC 5869)
|
|
63
|
+
* - Encodes messages in TLV8 (Type-Length-Value) format
|
|
64
|
+
*
|
|
65
|
+
* References:
|
|
66
|
+
* - HAP Specification: https://developer.apple.com/homekit/ (Apple Developer)
|
|
67
|
+
* - HAP-NodeJS (community implementation): https://github.com/homebridge/HAP-NodeJS
|
|
68
|
+
* - SRP Protocol: https://datatracker.ietf.org/doc/html/rfc5054
|
|
69
|
+
* - Ed25519: https://datatracker.ietf.org/doc/html/rfc8032
|
|
70
|
+
* - ChaCha20-Poly1305: https://datatracker.ietf.org/doc/html/rfc8439
|
|
71
|
+
* - HKDF: https://datatracker.ietf.org/doc/html/rfc5869
|
|
72
|
+
*
|
|
73
|
+
* @see PairVerificationProtocol for the verification protocol used after pairing
|
|
74
|
+
* @see SRPClient for SRP authentication implementation
|
|
75
|
+
*/
|
|
40
76
|
export class PairingProtocol implements PairingProtocolInterface {
|
|
41
|
-
private static readonly log = logger.getLogger('PairingProtocol');
|
|
42
77
|
private _sequenceNumber = 0;
|
|
43
78
|
|
|
44
79
|
constructor(
|
|
@@ -81,7 +116,7 @@ export class PairingProtocol implements PairingProtocolInterface {
|
|
|
81
116
|
|
|
82
117
|
return this.createPairingResult(device, ltpk, ltsk);
|
|
83
118
|
} catch (error) {
|
|
84
|
-
|
|
119
|
+
log.error('Pairing flow failed:', error);
|
|
85
120
|
throw error;
|
|
86
121
|
}
|
|
87
122
|
}
|
|
@@ -136,7 +171,7 @@ export class PairingProtocol implements PairingProtocolInterface {
|
|
|
136
171
|
const request = this.createHandshakeRequest();
|
|
137
172
|
await this.networkClient.sendPacket(request);
|
|
138
173
|
await this.networkClient.receiveResponse();
|
|
139
|
-
|
|
174
|
+
log.debug('Handshake completed');
|
|
140
175
|
}
|
|
141
176
|
|
|
142
177
|
/**
|
|
@@ -150,7 +185,7 @@ export class PairingProtocol implements PairingProtocolInterface {
|
|
|
150
185
|
|
|
151
186
|
const failedRequest = this.createPairVerifyFailedRequest();
|
|
152
187
|
await this.networkClient.sendPacket(failedRequest);
|
|
153
|
-
|
|
188
|
+
log.debug('Pair verification attempt completed');
|
|
154
189
|
}
|
|
155
190
|
|
|
156
191
|
/**
|
|
@@ -162,7 +197,7 @@ export class PairingProtocol implements PairingProtocolInterface {
|
|
|
162
197
|
const request = this.createSetupManualPairingRequest();
|
|
163
198
|
await this.networkClient.sendPacket(request);
|
|
164
199
|
const response = await this.networkClient.receiveResponse();
|
|
165
|
-
|
|
200
|
+
log.debug('Manual pairing setup completed');
|
|
166
201
|
return response;
|
|
167
202
|
}
|
|
168
203
|
|
|
@@ -201,7 +236,7 @@ export class PairingProtocol implements PairingProtocolInterface {
|
|
|
201
236
|
const response = await this.networkClient.receiveResponse();
|
|
202
237
|
this.validateSRPProofResponse(response);
|
|
203
238
|
|
|
204
|
-
|
|
239
|
+
log.debug('SRP authentication completed');
|
|
205
240
|
return srpClient;
|
|
206
241
|
}
|
|
207
242
|
|
|
@@ -212,12 +247,12 @@ export class PairingProtocol implements PairingProtocolInterface {
|
|
|
212
247
|
*/
|
|
213
248
|
private async receiveM6Completion(decryptKey: Buffer): Promise<void> {
|
|
214
249
|
const m6Response = await this.networkClient.receiveResponse();
|
|
215
|
-
|
|
250
|
+
log.info('M6 Response received');
|
|
216
251
|
|
|
217
252
|
try {
|
|
218
253
|
this.processM6Response(m6Response, decryptKey);
|
|
219
254
|
} catch (error) {
|
|
220
|
-
|
|
255
|
+
log.warn(
|
|
221
256
|
'M6 decryption failed - but pairing may still be successful:',
|
|
222
257
|
(error as Error).message,
|
|
223
258
|
);
|
|
@@ -568,16 +603,14 @@ export class PairingProtocol implements PairingProtocolInterface {
|
|
|
568
603
|
const m6TLVBuffer = Buffer.from(m6DataBase64, 'base64');
|
|
569
604
|
const m6Parsed = decodeTLV8ToDict(m6TLVBuffer);
|
|
570
605
|
|
|
571
|
-
|
|
606
|
+
log.debug(
|
|
572
607
|
'M6 TLV types received:',
|
|
573
608
|
Object.keys(m6Parsed).map((k) => `0x${Number(k).toString(16)}`),
|
|
574
609
|
);
|
|
575
610
|
|
|
576
611
|
const stateData = m6Parsed[PairingDataComponentType.STATE];
|
|
577
612
|
if (stateData && stateData[0] === PAIRING_STATES.M6) {
|
|
578
|
-
|
|
579
|
-
'✅ Pairing completed successfully (STATE=0x06)',
|
|
580
|
-
);
|
|
613
|
+
log.info('✅ Pairing completed successfully (STATE=0x06)');
|
|
581
614
|
}
|
|
582
615
|
|
|
583
616
|
const encryptedData = m6Parsed[PairingDataComponentType.ENCRYPTED_DATA];
|
|
@@ -589,10 +622,7 @@ export class PairingProtocol implements PairingProtocolInterface {
|
|
|
589
622
|
nonce,
|
|
590
623
|
});
|
|
591
624
|
const decryptedTLV = decodeTLV8ToDict(decrypted);
|
|
592
|
-
|
|
593
|
-
'M6 decrypted content types:',
|
|
594
|
-
Object.keys(decryptedTLV),
|
|
595
|
-
);
|
|
625
|
+
log.debug('M6 decrypted content types:', Object.keys(decryptedTLV));
|
|
596
626
|
}
|
|
597
627
|
}
|
|
598
628
|
|
|
@@ -610,7 +640,7 @@ export class PairingProtocol implements PairingProtocolInterface {
|
|
|
610
640
|
length: 32,
|
|
611
641
|
});
|
|
612
642
|
|
|
613
|
-
|
|
643
|
+
log.debug('Derived encryption keys');
|
|
614
644
|
return {
|
|
615
645
|
encryptKey: sharedKey,
|
|
616
646
|
decryptKey: sharedKey,
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export { PairingStorage } from './pairing-storage.js';
|
|
2
|
-
export type { PairingStorageInterface } from './types.js';
|
|
2
|
+
export type { PairingStorageInterface, PairRecord } from './types.js';
|
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
import { strongbox } from '@appium/strongbox';
|
|
2
|
-
import {
|
|
2
|
+
import { readdir } from 'node:fs/promises';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
3
4
|
|
|
4
5
|
import { STRONGBOX_CONTAINER_NAME } from '../../../constants.js';
|
|
5
|
-
import {
|
|
6
|
+
import { getLogger } from '../../logger.js';
|
|
7
|
+
import { createXmlPlist, parseXmlPlist } from '../../plist/index.js';
|
|
8
|
+
import { APPLETV_PAIRING_PREFIX } from '../constants.js';
|
|
6
9
|
import { PairingError } from '../errors.js';
|
|
7
10
|
import type { PairingConfig } from '../types.js';
|
|
8
|
-
import type { PairingStorageInterface } from './types.js';
|
|
11
|
+
import type { PairRecord, PairingStorageInterface } from './types.js';
|
|
9
12
|
|
|
10
13
|
/** Manages persistent storage of pairing credentials as plist files */
|
|
11
14
|
export class PairingStorage implements PairingStorageInterface {
|
|
12
|
-
private readonly log =
|
|
15
|
+
private readonly log = getLogger('PairingStorage');
|
|
13
16
|
private readonly box;
|
|
17
|
+
private strongboxDir?: string;
|
|
14
18
|
|
|
15
19
|
constructor(private readonly config: PairingConfig) {
|
|
16
20
|
this.box = strongbox(STRONGBOX_CONTAINER_NAME);
|
|
@@ -23,7 +27,7 @@ export class PairingStorage implements PairingStorageInterface {
|
|
|
23
27
|
remoteUnlockHostKey = '',
|
|
24
28
|
): Promise<string> {
|
|
25
29
|
try {
|
|
26
|
-
const itemName =
|
|
30
|
+
const itemName = `${APPLETV_PAIRING_PREFIX}${deviceId}`;
|
|
27
31
|
const plistContent = this.createPlistContent(
|
|
28
32
|
ltpk,
|
|
29
33
|
ltsk,
|
|
@@ -46,6 +50,70 @@ export class PairingStorage implements PairingStorageInterface {
|
|
|
46
50
|
}
|
|
47
51
|
}
|
|
48
52
|
|
|
53
|
+
async load(deviceId: string): Promise<PairRecord | null> {
|
|
54
|
+
const itemName = `${APPLETV_PAIRING_PREFIX}${deviceId}`;
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
const item =
|
|
58
|
+
this.box.getItem(itemName) ?? (await this.box.createItem(itemName));
|
|
59
|
+
|
|
60
|
+
const pairingData = await item.read();
|
|
61
|
+
|
|
62
|
+
if (!pairingData) {
|
|
63
|
+
this.log.debug(`No pair record found for device ${deviceId}`);
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const parsed = parseXmlPlist(pairingData);
|
|
68
|
+
|
|
69
|
+
if (!parsed.private_key || !parsed.public_key) {
|
|
70
|
+
throw new Error('Could not parse pairing record keys');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const privateKey = Buffer.isBuffer(parsed.private_key)
|
|
74
|
+
? parsed.private_key
|
|
75
|
+
: Buffer.from(parsed.private_key as string, 'base64');
|
|
76
|
+
const publicKey = Buffer.isBuffer(parsed.public_key)
|
|
77
|
+
? parsed.public_key
|
|
78
|
+
: Buffer.from(parsed.public_key as string, 'base64');
|
|
79
|
+
|
|
80
|
+
this.log.debug(`Loaded pair record for ${deviceId}`);
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
privateKey,
|
|
84
|
+
publicKey,
|
|
85
|
+
remoteUnlockHostKey: (parsed.remote_unlock_host_key as string) || '',
|
|
86
|
+
};
|
|
87
|
+
} catch (error) {
|
|
88
|
+
this.log.error(`Failed to load pair record for ${deviceId}:`, error);
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async getAvailableDeviceIds(): Promise<string[]> {
|
|
94
|
+
try {
|
|
95
|
+
if (!this.strongboxDir) {
|
|
96
|
+
const dummyItem = await this.box.createItem('_temp');
|
|
97
|
+
this.strongboxDir = dirname(dummyItem.id);
|
|
98
|
+
// Clean up the temporary item after extracting the directory path
|
|
99
|
+
await dummyItem.clear();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const files = await readdir(this.strongboxDir);
|
|
103
|
+
const deviceIds = files
|
|
104
|
+
.filter((file: string) => file.startsWith(APPLETV_PAIRING_PREFIX))
|
|
105
|
+
.map((file: string) => file.replace(APPLETV_PAIRING_PREFIX, ''));
|
|
106
|
+
|
|
107
|
+
this.log.debug(
|
|
108
|
+
`Found ${deviceIds.length} pair record(s): ${deviceIds.join(', ')}`,
|
|
109
|
+
);
|
|
110
|
+
return deviceIds;
|
|
111
|
+
} catch (error) {
|
|
112
|
+
this.log.debug('Error getting available device IDs:', error);
|
|
113
|
+
return [];
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
49
117
|
private createPlistContent(
|
|
50
118
|
publicKey: Buffer,
|
|
51
119
|
privateKey: Buffer,
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
|
|
1
|
+
export interface PairRecord {
|
|
2
|
+
publicKey: Buffer;
|
|
3
|
+
privateKey: Buffer;
|
|
4
|
+
remoteUnlockHostKey: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
2
7
|
export interface PairingStorageInterface {
|
|
3
8
|
save(
|
|
4
9
|
deviceId: string,
|
|
@@ -6,4 +11,6 @@ export interface PairingStorageInterface {
|
|
|
6
11
|
ltsk: Buffer,
|
|
7
12
|
remoteUnlockHostKey?: string,
|
|
8
13
|
): Promise<string>;
|
|
14
|
+
load(deviceId: string): Promise<PairRecord | null>;
|
|
15
|
+
getAvailableDeviceIds(): Promise<string[]>;
|
|
9
16
|
}
|