@basmilius/apple-companion-link 0.0.2 → 0.0.4

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.
@@ -1,353 +0,0 @@
1
- import { randomInt } from 'node:crypto';
2
- import { debug, waitFor } from '@/cli';
3
- import { opackFloat, parseBinaryPlist } from '@/encoding';
4
- import { CompanionLinkFrameType, CompanionLinkMessageType, type CompanionLinkSocket } from '@/socket';
5
- import type CompanionLink from '../companionLink';
6
-
7
- export default class Api {
8
- readonly #protocol: CompanionLink;
9
- readonly #socket: CompanionLinkSocket;
10
-
11
- constructor(protocol: CompanionLink, socket: CompanionLinkSocket) {
12
- this.#protocol = protocol;
13
- this.#socket = socket;
14
- }
15
-
16
- async fetchMediaControlStatus(): Promise<void> {
17
- const [, payload] = await this.#socket.exchange(CompanionLinkFrameType.E_OPACK, {
18
- _i: 'FetchMediaControlStatus',
19
- _t: CompanionLinkMessageType.Request,
20
- _c: {}
21
- });
22
- }
23
-
24
- async fetchNowPlayingInfo(): Promise<void> {
25
- const [, payload] = await this.#socket.exchange(CompanionLinkFrameType.E_OPACK, {
26
- _i: 'FetchCurrentNowPlayingInfoEvent',
27
- _t: CompanionLinkMessageType.Request,
28
- _c: {}
29
- });
30
- }
31
-
32
- async fetchSupportedActions(): Promise<void> {
33
- const [, payload] = await this.#socket.exchange(CompanionLinkFrameType.E_OPACK, {
34
- _i: 'FetchSupportedActionsEvent',
35
- _t: CompanionLinkMessageType.Request,
36
- _c: {}
37
- });
38
- }
39
-
40
- async getAttentionState(): Promise<AttentionState> {
41
- const [, payload] = await this.#socket.exchange(CompanionLinkFrameType.E_OPACK, {
42
- _i: 'FetchAttentionState',
43
- _t: CompanionLinkMessageType.Request,
44
- _c: {}
45
- });
46
-
47
- const {_c} = objectOrFail<AttentionStateResponse>(payload);
48
-
49
- switch (_c.state) {
50
- case 0x01:
51
- return 'asleep';
52
-
53
- case 0x02:
54
- return 'screensaver';
55
-
56
- case 0x03:
57
- return 'awake';
58
-
59
- case 0x04:
60
- return 'idle';
61
-
62
- default:
63
- return 'unknown';
64
- }
65
- }
66
-
67
- async getLaunchableApps(): Promise<LaunchableApp[]> {
68
- const [, payload] = await this.#socket.exchange(CompanionLinkFrameType.E_OPACK, {
69
- _i: 'FetchLaunchableApplicationssEvent',
70
- _t: CompanionLinkMessageType.Request,
71
- _c: {}
72
- });
73
-
74
- const {_c} = objectOrFail<LaunchableAppsResponse>(payload);
75
-
76
- return Object.entries(_c).map(([bundleId, name]) => ({
77
- bundleId,
78
- name
79
- }));
80
- }
81
-
82
- async getSiriRemoteInfo(): Promise<any> {
83
- const [, payload] = await this.#socket.exchange(CompanionLinkFrameType.E_OPACK, {
84
- _i: 'FetchSiriRemoteInfo',
85
- _t: CompanionLinkMessageType.Request,
86
- _c: {}
87
- });
88
-
89
- return parseBinaryPlist(Buffer.from(payload['_c']['SiriRemoteInfoKey']).buffer);
90
- }
91
-
92
- async getUserAccounts(): Promise<UserAccount[]> {
93
- const [, payload] = await this.#socket.exchange(CompanionLinkFrameType.E_OPACK, {
94
- _i: 'FetchUserAccountsEvent',
95
- _t: CompanionLinkMessageType.Request,
96
- _c: {}
97
- });
98
-
99
- const {_c} = objectOrFail<UserAccountsResponse>(payload);
100
-
101
- return Object.entries(_c).map(([accountId, name]) => ({
102
- accountId,
103
- name
104
- }));
105
- }
106
-
107
- async hidCommand(command: keyof typeof HidCommand, down = false): Promise<void> {
108
- await this.#socket.exchange(CompanionLinkFrameType.E_OPACK, {
109
- _i: '_hidC',
110
- _t: CompanionLinkMessageType.Request,
111
- _c: {
112
- _hBtS: down ? 1 : 2,
113
- _hidC: HidCommand[command]
114
- }
115
- });
116
- }
117
-
118
- async launchApp(bundleId: string): Promise<void> {
119
- await this.#socket.exchange(CompanionLinkFrameType.E_OPACK, {
120
- _i: '_launchApp',
121
- _t: CompanionLinkMessageType.Request,
122
- _c: {
123
- _bundleID: bundleId
124
- }
125
- });
126
- }
127
-
128
- async launchUrl(url: string): Promise<void> {
129
- await this.#socket.exchange(CompanionLinkFrameType.E_OPACK, {
130
- _i: '_launchApp',
131
- _t: CompanionLinkMessageType.Request,
132
- _c: {
133
- _urlS: url
134
- }
135
- });
136
- }
137
-
138
- async mediaControlCommand(command: keyof typeof MediaControlCommand, content?: object): Promise<object> {
139
- const [, payload] = await this.#socket.exchange(CompanionLinkFrameType.E_OPACK, {
140
- _i: '_mcc',
141
- _t: CompanionLinkMessageType.Request,
142
- _c: {
143
- _mcc: MediaControlCommand[command],
144
- ...(content || {})
145
- }
146
- });
147
-
148
- return objectOrFail(payload);
149
- }
150
-
151
- async pressButton(command: keyof typeof HidCommand, type: ButtonPressType = 'SingleTap', holdDelayMs = 500): Promise<void> {
152
- switch (type) {
153
- case 'DoubleTap':
154
- await this.hidCommand(command, true);
155
- await this.hidCommand(command, false);
156
-
157
- await this.hidCommand(command, true);
158
- await this.hidCommand(command, false);
159
- break;
160
-
161
- case 'Hold':
162
- await this.hidCommand(command, true);
163
- await waitFor(holdDelayMs);
164
- await this.hidCommand(command, false);
165
- break;
166
-
167
- case 'SingleTap':
168
- await this.hidCommand(command, true);
169
- await this.hidCommand(command, false);
170
- break;
171
- }
172
- }
173
-
174
- async switchUserAccount(accountId: string): Promise<void> {
175
- await this.#socket.exchange(CompanionLinkFrameType.E_OPACK, {
176
- _i: 'SwitchUserAccountEvent',
177
- _t: CompanionLinkMessageType.Request,
178
- _c: {
179
- SwitchAccountID: accountId
180
- }
181
- });
182
- }
183
-
184
- async _subscribe(event: string, fn: EventListener): Promise<void> {
185
- this.#socket.addEventListener(event, fn);
186
-
187
- await this.#socket.send(CompanionLinkFrameType.E_OPACK, {
188
- _i: '_interest',
189
- _t: CompanionLinkMessageType.Event,
190
- _c: {
191
- _regEvents: [event]
192
- }
193
- });
194
- }
195
-
196
- async _unsubscribe(event: string, fn?: EventListener): Promise<void> {
197
- if (fn) {
198
- this.#socket.removeEventListener(event, fn);
199
- }
200
-
201
- await this.#socket.send(CompanionLinkFrameType.E_OPACK, {
202
- _i: '_interest',
203
- _t: CompanionLinkMessageType.Event,
204
- _c: {
205
- _deregEvents: [event]
206
- }
207
- });
208
- }
209
-
210
- async _sessionStart(): Promise<object> {
211
- const [, payload] = await this.#socket.exchange(CompanionLinkFrameType.E_OPACK, {
212
- _i: '_sessionStart',
213
- _t: CompanionLinkMessageType.Request,
214
- _c: {
215
- _srvT: 'com.apple.tvremoteservices',
216
- _sid: randomInt(0, 2 ** 32 - 1)
217
- }
218
- });
219
-
220
- return objectOrFail(payload);
221
- }
222
-
223
- async _systemInfo(pairingId: Buffer): Promise<object> {
224
- const [, payload] = await this.#socket.exchange(CompanionLinkFrameType.E_OPACK, {
225
- _i: '_systemInfo',
226
- _t: CompanionLinkMessageType.Request,
227
- _c: {
228
- _bf: 0,
229
- _cf: 512,
230
- _clFl: 128,
231
- _i: 'cafecafecafe',
232
- _idsID: pairingId.toString(),
233
- _pubID: 'FF:70:79:61:74:76',
234
- _sf: 256,
235
- _sv: '170.18',
236
- model: 'iPhone10,6',
237
- name: 'Bas Companion Link'
238
- }
239
- });
240
-
241
- return objectOrFail(payload);
242
- }
243
-
244
- async _touchStart(): Promise<object> {
245
- const [, payload] = await this.#socket.exchange(CompanionLinkFrameType.E_OPACK, {
246
- _i: '_touchStart',
247
- _t: CompanionLinkMessageType.Request,
248
- _c: {
249
- _height: opackFloat(1000.0),
250
- _tFl: 0,
251
- _width: opackFloat(1000.0)
252
- }
253
- });
254
-
255
- return objectOrFail(payload);
256
- }
257
-
258
- async _tvrcSessionStart(): Promise<object> {
259
- const [, payload] = await this.#socket.exchange(CompanionLinkFrameType.E_OPACK, {
260
- _i: 'TVRCSessionStart',
261
- _t: CompanionLinkMessageType.Request,
262
- _btHP: false,
263
- _inUseProc: 'tvremoted',
264
- _c: {}
265
- });
266
-
267
- return objectOrFail(payload);
268
- }
269
- }
270
-
271
- const HidCommand = {
272
- Up: 1,
273
- Down: 2,
274
- Left: 3,
275
- Right: 4,
276
- Menu: 5,
277
- Select: 6,
278
- Home: 7,
279
- VolumeUp: 8,
280
- VolumeDown: 9,
281
- Siri: 10,
282
- Screensaver: 11,
283
- Sleep: 12,
284
- Wake: 13,
285
- PlayPause: 14,
286
- ChannelIncrement: 15,
287
- ChannelDecrement: 16,
288
- Guide: 17,
289
- PageUp: 18,
290
- PageDown: 19
291
- } as const;
292
-
293
- const MediaControlCommand = {
294
- Play: 1,
295
- Pause: 2,
296
- NextTrack: 3,
297
- PreviousTrack: 4,
298
- GetVolume: 5,
299
- SetVolume: 6,
300
- SkipBy: 7,
301
- FastForwardBegin: 8,
302
- FastForwardEnd: 9,
303
- RewindBegin: 10,
304
- RewindEnd: 11,
305
- GetCaptionSettings: 12,
306
- SetCaptionSettings: 13
307
- } as const;
308
-
309
- type ButtonPressType =
310
- | 'DoubleTap'
311
- | 'Hold'
312
- | 'SingleTap';
313
-
314
- function objectOrFail<T = object>(obj: unknown): T {
315
- if (typeof obj === 'object') {
316
- return obj as T;
317
- }
318
-
319
- debug('Expected an object.', {obj});
320
-
321
- throw new Error('Expected an object.');
322
- }
323
-
324
- type AttentionState =
325
- | 'unknown'
326
- | 'asleep'
327
- | 'screensaver'
328
- | 'awake'
329
- | 'idle';
330
-
331
- type AttentionStateResponse = {
332
- readonly _c: {
333
- readonly state: number;
334
- };
335
- };
336
-
337
- type LaunchableApp = {
338
- readonly bundleId: string;
339
- readonly name: string;
340
- };
341
-
342
- type LaunchableAppsResponse = {
343
- readonly _c: Record<string, string>;
344
- };
345
-
346
- type UserAccount = {
347
- readonly accountId: string;
348
- readonly name: string;
349
- };
350
-
351
- type UserAccountsResponse = {
352
- readonly _c: Record<string, string>;
353
- };
@@ -1,41 +0,0 @@
1
- import { Result } from 'node-dns-sd';
2
- import { CompanionLinkSocket } from '@/socket';
3
- import Api from './api/companionLink';
4
- import Pairing from './pairing/companionLink';
5
- import Verify from './verify/companionLink';
6
-
7
- export default class CompanionLink {
8
- get api(): Api {
9
- return this.#api;
10
- }
11
-
12
- get socket(): CompanionLinkSocket {
13
- return this.#socket;
14
- }
15
-
16
- get pairing(): Pairing {
17
- return this.#pairing;
18
- }
19
-
20
- get verify(): Verify {
21
- return this.#verify;
22
- }
23
-
24
- readonly #api: Api;
25
- readonly #device: Result;
26
- readonly #socket: CompanionLinkSocket;
27
- readonly #pairing: Pairing;
28
- readonly #verify: Verify;
29
-
30
- constructor(device: Result) {
31
- this.#device = device;
32
- this.#socket = new CompanionLinkSocket(device.address, device.service.port);
33
- this.#api = new Api(this, this.#socket);
34
- this.#pairing = new Pairing(this, this.#socket);
35
- this.#verify = new Verify(this, this.#socket);
36
- }
37
-
38
- async connect(): Promise<void> {
39
- await this.#socket.connect();
40
- }
41
- }
@@ -1 +0,0 @@
1
- export { default as CompanionLink } from './companionLink';
@@ -1,286 +0,0 @@
1
- import { CompanionLinkFrameType, type CompanionLinkSocket } from '@/socket';
2
- import { SRP, SrpClient } from 'fast-srp-hap';
3
- import { v4 as uuid } from 'uuid';
4
- import tweetnacl from 'tweetnacl';
5
- import { debug } from '@/cli';
6
- import { AIRPLAY_TRANSIENT_PIN } from '@/const';
7
- import { decryptChacha20, encryptChacha20, hkdf } from '@/crypto';
8
- import { bailTlv, decodeTlv, encodeOPack, encodeTlv, TlvFlags, TlvMethod, TlvState, TlvValue } from '@/encoding';
9
- import CompanionLink from '../companionLink';
10
-
11
- export default class {
12
- get name(): string {
13
- return this.#name;
14
- }
15
-
16
- get pairingId(): Buffer {
17
- return this.#pairingId;
18
- }
19
-
20
- readonly #name: string;
21
- readonly #pairingId: Buffer;
22
- readonly #protocol: CompanionLink;
23
- readonly #socket: CompanionLinkSocket;
24
- #publicKey: Buffer;
25
- #secretKey: Buffer;
26
- #srp: SrpClient;
27
-
28
- constructor(protocol: CompanionLink, socket: CompanionLinkSocket) {
29
- this.#protocol = protocol;
30
- this.#socket = socket;
31
-
32
- this.#name = 'Bas Companion Link';
33
- this.#pairingId = Buffer.from(uuid().toUpperCase());
34
- }
35
-
36
- async start(): Promise<void> {
37
- const keyPair = tweetnacl.sign.keyPair();
38
- this.#publicKey = Buffer.from(keyPair.publicKey);
39
- this.#secretKey = Buffer.from(keyPair.secretKey);
40
- }
41
-
42
- async pin(askPin: () => Promise<string>): Promise<PairingCredentials> {
43
- const m1 = await this.m1();
44
- const m2 = await this.m2(m1, await askPin());
45
- const m3 = await this.m3(m2);
46
- const m4 = await this.m4(m3);
47
- const m5 = await this.m5(m4);
48
- const m6 = await this.m6(m4, m5);
49
-
50
- if (!m6) {
51
- throw new Error('Pairing failed, could not get accessory keys.');
52
- }
53
-
54
- return m6;
55
- }
56
-
57
- async transient(): Promise<TransientPairingCredentials> {
58
- const m1 = await this.m1([[TlvValue.Flags, TlvFlags.TransientPairing]]);
59
- const m2 = await this.m2(m1);
60
- const m3 = await this.m3(m2);
61
- const m4 = await this.m4(m3);
62
-
63
- const accessoryToControllerKey = hkdf({
64
- hash: 'sha512',
65
- key: m4.sharedSecret,
66
- length: 32,
67
- salt: Buffer.from('Control-Salt'),
68
- info: Buffer.from('Control-Read-Encryption-Key')
69
- });
70
-
71
- const controllerToAccessoryKey = hkdf({
72
- hash: 'sha512',
73
- key: m4.sharedSecret,
74
- length: 32,
75
- salt: Buffer.from('Control-Salt'),
76
- info: Buffer.from('Control-Write-Encryption-Key')
77
- });
78
-
79
- return {
80
- pairingId: this.#pairingId,
81
- sharedSecret: m4.sharedSecret,
82
- accessoryToControllerKey,
83
- controllerToAccessoryKey
84
- };
85
- }
86
-
87
- async m1(additionalTlv: [number, number | Buffer][] = []): Promise<M1> {
88
- const [, response] = await this.#socket.exchange(CompanionLinkFrameType.PS_Start, {
89
- _pd: encodeTlv([
90
- [TlvValue.Method, TlvMethod.PairSetup],
91
- [TlvValue.State, TlvState.M1],
92
- ...additionalTlv
93
- ]),
94
- _pwTy: 1
95
- });
96
-
97
- const data = this.#tlv(response);
98
- const publicKey = data.get(TlvValue.PublicKey);
99
- const salt = data.get(TlvValue.Salt);
100
-
101
- return {publicKey, salt};
102
- }
103
-
104
- async m2(m1: M1, pin: string = AIRPLAY_TRANSIENT_PIN): Promise<M2> {
105
- const srpKey = await SRP.genKey(32);
106
-
107
- this.#srp = new SrpClient(SRP.params.hap, m1.salt, Buffer.from('Pair-Setup'), Buffer.from(pin), srpKey, true);
108
- this.#srp.setB(m1.publicKey);
109
-
110
- const publicKey = this.#srp.computeA();
111
- const proof = this.#srp.computeM1();
112
-
113
- return {publicKey, proof};
114
- }
115
-
116
- async m3(m2: M2): Promise<M3> {
117
- const [, response] = await this.#socket.exchange(CompanionLinkFrameType.PS_Next, {
118
- _pd: encodeTlv([
119
- [TlvValue.State, TlvState.M3],
120
- [TlvValue.PublicKey, m2.publicKey],
121
- [TlvValue.Proof, m2.proof]
122
- ]),
123
- _pwTy: 1
124
- });
125
-
126
- const data = this.#tlv(response);
127
- const serverProof = data.get(TlvValue.Proof);
128
-
129
- return {serverProof};
130
- }
131
-
132
- async m4(m3: M3): Promise<M4> {
133
- this.#srp.checkM2(m3.serverProof);
134
-
135
- const sharedSecret = this.#srp.computeK();
136
-
137
- return {sharedSecret};
138
- }
139
-
140
- async m5(m4: M4): Promise<M5> {
141
- const iosDeviceX = hkdf({
142
- hash: 'sha512',
143
- key: m4.sharedSecret,
144
- length: 32,
145
- salt: Buffer.from('Pair-Setup-Controller-Sign-Salt', 'utf8'),
146
- info: Buffer.from('Pair-Setup-Controller-Sign-Info', 'utf8')
147
- });
148
-
149
- const sessionKey = hkdf({
150
- hash: 'sha512',
151
- key: m4.sharedSecret,
152
- length: 32,
153
- salt: Buffer.from('Pair-Setup-Encrypt-Salt', 'utf8'),
154
- info: Buffer.from('Pair-Setup-Encrypt-Info', 'utf8')
155
- });
156
-
157
- const deviceInfo = Buffer.concat([
158
- iosDeviceX,
159
- this.#pairingId,
160
- this.#publicKey
161
- ]);
162
-
163
- const signature = tweetnacl.sign.detached(deviceInfo, this.#secretKey);
164
-
165
- const innerTlv = encodeTlv([
166
- [TlvValue.Identifier, this.#pairingId],
167
- [TlvValue.PublicKey, this.#publicKey],
168
- [TlvValue.Signature, Buffer.from(signature)],
169
- [TlvValue.Name, Buffer.from(encodeOPack({
170
- name: this.#name
171
- }))]
172
- ]);
173
-
174
- const {authTag, ciphertext} = encryptChacha20(sessionKey, Buffer.from('PS-Msg05'), null, innerTlv);
175
- const encrypted = Buffer.concat([ciphertext, authTag]);
176
-
177
- const [, response] = await this.#socket.exchange(CompanionLinkFrameType.PS_Next, {
178
- _pd: encodeTlv([
179
- [TlvValue.State, TlvState.M5],
180
- [TlvValue.EncryptedData, encrypted]
181
- ]),
182
- _pwTy: 1
183
- });
184
-
185
- const data = this.#tlv(response);
186
- const encryptedDataRaw = data.get(TlvValue.EncryptedData);
187
- const encryptedData = encryptedDataRaw.subarray(0, -16);
188
- const encryptedTag = encryptedDataRaw.subarray(-16);
189
-
190
- return {
191
- authTag: encryptedTag,
192
- data: encryptedData,
193
- sessionKey
194
- };
195
- }
196
-
197
- async m6(m4: M4, m5: M5): Promise<PairingCredentials> {
198
- const data = decryptChacha20(m5.sessionKey, Buffer.from('PS-Msg06'), null, m5.data, m5.authTag);
199
- const tlv = decodeTlv(data);
200
-
201
- const accessoryIdentifier = tlv.get(TlvValue.Identifier);
202
- const accessoryLongTermPublicKey = tlv.get(TlvValue.PublicKey);
203
- const accessorySignature = tlv.get(TlvValue.Signature);
204
-
205
- const accessoryX = hkdf({
206
- hash: 'sha512',
207
- key: m4.sharedSecret,
208
- length: 32,
209
- salt: Buffer.from('Pair-Setup-Accessory-Sign-Salt'),
210
- info: Buffer.from('Pair-Setup-Accessory-Sign-Info')
211
- });
212
-
213
- const accessoryInfo = Buffer.concat([
214
- accessoryX,
215
- accessoryIdentifier,
216
- accessoryLongTermPublicKey
217
- ]);
218
-
219
- if (!tweetnacl.sign.detached.verify(accessoryInfo, accessorySignature, accessoryLongTermPublicKey)) {
220
- throw new Error('Invalid accessory signature.');
221
- }
222
-
223
- return {
224
- accessoryIdentifier: accessoryIdentifier.toString(),
225
- accessoryLongTermPublicKey: accessoryLongTermPublicKey,
226
- pairingId: this.#pairingId,
227
- publicKey: this.#publicKey,
228
- secretKey: this.#secretKey
229
- };
230
- }
231
-
232
- #tlv(response: unknown): Map<number, Buffer> {
233
- if (typeof response !== 'object' || response === null) {
234
- throw new Error('Invalid response from receiver.');
235
- }
236
-
237
- const data = decodeTlv(response['_pd']);
238
-
239
- if (data.has(TlvValue.Error)) {
240
- bailTlv(data);
241
- }
242
-
243
- debug('Decoded TLV', data);
244
-
245
- return data;
246
- }
247
- }
248
-
249
- type M1 = {
250
- readonly publicKey: Buffer;
251
- readonly salt: Buffer;
252
- }
253
-
254
- type M2 = {
255
- readonly publicKey: Buffer;
256
- readonly proof: Buffer;
257
- };
258
-
259
- type M3 = {
260
- readonly serverProof: Buffer;
261
- };
262
-
263
- type M4 = {
264
- readonly sharedSecret: Buffer;
265
- };
266
-
267
- type M5 = {
268
- readonly authTag: Buffer;
269
- readonly data: Buffer;
270
- readonly sessionKey: Buffer;
271
- };
272
-
273
- type PairingCredentials = {
274
- readonly accessoryIdentifier: string;
275
- readonly accessoryLongTermPublicKey: Buffer;
276
- readonly pairingId: Buffer;
277
- readonly publicKey: Buffer;
278
- readonly secretKey: Buffer;
279
- };
280
-
281
- type TransientPairingCredentials = {
282
- readonly pairingId: Buffer;
283
- readonly sharedSecret: Buffer;
284
- readonly accessoryToControllerKey: Buffer;
285
- readonly controllerToAccessoryKey: Buffer;
286
- };