@bharper/atv-js 0.2.3

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.
Files changed (86) hide show
  1. package/LICENSE.md +21 -0
  2. package/README.md +80 -0
  3. package/dist/airplay/auth.d.ts +24 -0
  4. package/dist/airplay/auth.d.ts.map +1 -0
  5. package/dist/airplay/auth.js +195 -0
  6. package/dist/airplay/auth.js.map +1 -0
  7. package/dist/bplist.d.ts +19 -0
  8. package/dist/bplist.d.ts.map +1 -0
  9. package/dist/bplist.js +141 -0
  10. package/dist/bplist.js.map +1 -0
  11. package/dist/companion/auth.d.ts +34 -0
  12. package/dist/companion/auth.d.ts.map +1 -0
  13. package/dist/companion/auth.js +119 -0
  14. package/dist/companion/auth.js.map +1 -0
  15. package/dist/companion/connection.d.ts +50 -0
  16. package/dist/companion/connection.d.ts.map +1 -0
  17. package/dist/companion/connection.js +170 -0
  18. package/dist/companion/connection.js.map +1 -0
  19. package/dist/companion/keyboard.d.ts +39 -0
  20. package/dist/companion/keyboard.d.ts.map +1 -0
  21. package/dist/companion/keyboard.js +127 -0
  22. package/dist/companion/keyboard.js.map +1 -0
  23. package/dist/companion/pairing_keepalive.d.ts +4 -0
  24. package/dist/companion/pairing_keepalive.d.ts.map +1 -0
  25. package/dist/companion/pairing_keepalive.js +68 -0
  26. package/dist/companion/pairing_keepalive.js.map +1 -0
  27. package/dist/companion/protocol.d.ts +64 -0
  28. package/dist/companion/protocol.d.ts.map +1 -0
  29. package/dist/companion/protocol.js +246 -0
  30. package/dist/companion/protocol.js.map +1 -0
  31. package/dist/companion/remote.d.ts +75 -0
  32. package/dist/companion/remote.d.ts.map +1 -0
  33. package/dist/companion/remote.js +142 -0
  34. package/dist/companion/remote.js.map +1 -0
  35. package/dist/crypto/chacha20.d.ts +27 -0
  36. package/dist/crypto/chacha20.d.ts.map +1 -0
  37. package/dist/crypto/chacha20.js +88 -0
  38. package/dist/crypto/chacha20.js.map +1 -0
  39. package/dist/crypto/hkdf.d.ts +6 -0
  40. package/dist/crypto/hkdf.d.ts.map +1 -0
  41. package/dist/crypto/hkdf.js +45 -0
  42. package/dist/crypto/hkdf.js.map +1 -0
  43. package/dist/index.d.ts +85 -0
  44. package/dist/index.d.ts.map +1 -0
  45. package/dist/index.js +191 -0
  46. package/dist/index.js.map +1 -0
  47. package/dist/mdns.d.ts +20 -0
  48. package/dist/mdns.d.ts.map +1 -0
  49. package/dist/mdns.js +165 -0
  50. package/dist/mdns.js.map +1 -0
  51. package/dist/opack.d.ts +21 -0
  52. package/dist/opack.d.ts.map +1 -0
  53. package/dist/opack.js +350 -0
  54. package/dist/opack.js.map +1 -0
  55. package/dist/pairing/credentials.d.ts +23 -0
  56. package/dist/pairing/credentials.d.ts.map +1 -0
  57. package/dist/pairing/credentials.js +31 -0
  58. package/dist/pairing/credentials.js.map +1 -0
  59. package/dist/pairing/srp.d.ts +54 -0
  60. package/dist/pairing/srp.d.ts.map +1 -0
  61. package/dist/pairing/srp.js +221 -0
  62. package/dist/pairing/srp.js.map +1 -0
  63. package/dist/pairing/tlv.d.ts +26 -0
  64. package/dist/pairing/tlv.d.ts.map +1 -0
  65. package/dist/pairing/tlv.js +68 -0
  66. package/dist/pairing/tlv.js.map +1 -0
  67. package/examples/pair.ts +103 -0
  68. package/examples/remote.ts +212 -0
  69. package/package.json +33 -0
  70. package/src/airplay/auth.ts +207 -0
  71. package/src/bplist.ts +136 -0
  72. package/src/companion/auth.ts +141 -0
  73. package/src/companion/connection.ts +161 -0
  74. package/src/companion/keyboard.ts +155 -0
  75. package/src/companion/pairing_keepalive.ts +75 -0
  76. package/src/companion/protocol.ts +253 -0
  77. package/src/companion/remote.ts +151 -0
  78. package/src/crypto/chacha20.ts +93 -0
  79. package/src/crypto/hkdf.ts +18 -0
  80. package/src/index.ts +248 -0
  81. package/src/mdns.ts +198 -0
  82. package/src/opack.ts +299 -0
  83. package/src/pairing/credentials.ts +44 -0
  84. package/src/pairing/srp.ts +234 -0
  85. package/src/pairing/tlv.ts +64 -0
  86. package/tsconfig.json +19 -0
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@bharper/atv-js",
3
+ "version": "0.2.3",
4
+ "description": "Apple TV control library - mDNS discovery, AirPlay/Companion pairing, remote control, keyboard input",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/bsharper/atvjs.git"
8
+ },
9
+ "main": "dist/index.js",
10
+ "types": "dist/index.d.ts",
11
+ "scripts": {
12
+ "build": "tsc",
13
+ "clean": "rm -rf dist",
14
+ "pair": "ts-node examples/pair.ts",
15
+ "remote": "ts-node examples/remote.ts"
16
+ },
17
+ "dependencies": {
18
+ "@noble/ciphers": "^2.1.1",
19
+ "bplist-creator": "^0.1.0",
20
+ "bplist-parser": "^0.3.2",
21
+ "fast-srp-hap": "^2.0.4",
22
+ "multicast-dns": "^7.2.5"
23
+ },
24
+ "devDependencies": {
25
+ "@types/multicast-dns": "^7.2.4",
26
+ "@types/node": "^20.0.0",
27
+ "ts-node": "^10.9.0",
28
+ "typescript": "^5.3.0"
29
+ },
30
+ "engines": {
31
+ "node": ">=18.0.0"
32
+ }
33
+ }
@@ -0,0 +1,207 @@
1
+ /**
2
+ * AirPlay HTTP-based HAP pair-setup and pair-verify.
3
+ * Port of pyatv/protocols/airplay/auth/hap.py.
4
+ */
5
+
6
+ import * as http from 'http';
7
+ import { SRPAuthHandler } from '../pairing/srp';
8
+ import { HapCredentials } from '../pairing/credentials';
9
+ import { TlvValue, readTlv, writeTlv } from '../pairing/tlv';
10
+
11
+ const AIRPLAY_HEADERS: Record<string, string> = {
12
+ 'User-Agent': 'AirPlay/320.20',
13
+ 'Connection': 'keep-alive',
14
+ 'X-Apple-HKP': '3',
15
+ 'Content-Type': 'application/octet-stream',
16
+ };
17
+
18
+ async function httpPost(
19
+ host: string,
20
+ port: number,
21
+ path: string,
22
+ body?: Buffer,
23
+ agent?: http.Agent,
24
+ ): Promise<Buffer> {
25
+ return new Promise((resolve, reject) => {
26
+ const headers: Record<string, string | number> = { ...AIRPLAY_HEADERS };
27
+ if (body) {
28
+ headers['Content-Length'] = body.length;
29
+ }
30
+
31
+ const req = http.request({ hostname: host, port, path, method: 'POST', headers, agent }, (res) => {
32
+ const chunks: Buffer[] = [];
33
+ res.on('data', (chunk: Buffer) => chunks.push(chunk));
34
+ res.on('end', () => resolve(Buffer.concat(chunks)));
35
+ });
36
+ req.on('error', reject);
37
+ if (body) req.write(body);
38
+ req.end();
39
+ });
40
+ }
41
+
42
+ export interface AirPlayPairingSession {
43
+ srp: SRPAuthHandler;
44
+ host: string;
45
+ port: number;
46
+ atvSalt: Buffer;
47
+ atvPubKey: Buffer;
48
+ }
49
+
50
+ const AIRPLAY_AGENT_IDLE_MS = 2 * 60 * 1000;
51
+
52
+ type AgentEntry = {
53
+ agent: http.Agent;
54
+ lastUsed: number;
55
+ timer: NodeJS.Timeout;
56
+ };
57
+
58
+ const agentCache = new Map<string, AgentEntry>();
59
+
60
+ function agentKey(host: string, port: number): string {
61
+ return `${host}:${port}`;
62
+ }
63
+
64
+ function touchAgent(entry: AgentEntry): void {
65
+ entry.lastUsed = Date.now();
66
+ if (entry.timer) clearTimeout(entry.timer);
67
+ entry.timer = setTimeout(() => {
68
+ if (Date.now() - entry.lastUsed >= AIRPLAY_AGENT_IDLE_MS) {
69
+ entry.agent.destroy();
70
+ // Find and remove by identity
71
+ for (const [key, value] of agentCache) {
72
+ if (value === entry) {
73
+ agentCache.delete(key);
74
+ break;
75
+ }
76
+ }
77
+ }
78
+ }, AIRPLAY_AGENT_IDLE_MS);
79
+ if (typeof entry.timer.unref === 'function') entry.timer.unref();
80
+ }
81
+
82
+ function getPairingAgent(host: string, port: number): http.Agent {
83
+ const key = agentKey(host, port);
84
+ const existing = agentCache.get(key);
85
+ if (existing) {
86
+ touchAgent(existing);
87
+ return existing.agent;
88
+ }
89
+
90
+ const agent = new http.Agent({ keepAlive: true, maxSockets: 1, maxFreeSockets: 1 });
91
+ agent.on('free', (socket) => {
92
+ socket.unref();
93
+ });
94
+ const entry: AgentEntry = {
95
+ agent,
96
+ lastUsed: Date.now(),
97
+ timer: setTimeout(() => {
98
+ agent.destroy();
99
+ agentCache.delete(key);
100
+ }, AIRPLAY_AGENT_IDLE_MS),
101
+ };
102
+ if (typeof entry.timer.unref === 'function') entry.timer.unref();
103
+ agentCache.set(key, entry);
104
+ return agent;
105
+ }
106
+
107
+ /**
108
+ * Start AirPlay pairing: triggers PIN display on the Apple TV.
109
+ * Returns a session object to pass to finishAirPlayPairing.
110
+ */
111
+ export async function startAirPlayPairing(host: string, port: number): Promise<AirPlayPairingSession> {
112
+ const srp = new SRPAuthHandler();
113
+ srp.initialize();
114
+ const agent = getPairingAgent(host, port);
115
+
116
+ // Trigger PIN display
117
+ await httpPost(host, port, '/pair-pin-start', undefined, agent);
118
+
119
+ // Send pair-setup SeqNo 1
120
+ const tlvData = writeTlv(new Map<number, Buffer>([
121
+ [TlvValue.Method, Buffer.from([0x00])],
122
+ [TlvValue.SeqNo, Buffer.from([0x01])],
123
+ ]));
124
+
125
+ const resp = await httpPost(host, port, '/pair-setup', tlvData, agent);
126
+ const pairingData = readTlv(resp);
127
+
128
+ const atvSalt = pairingData.get(TlvValue.Salt);
129
+ const atvPubKey = pairingData.get(TlvValue.PublicKey);
130
+
131
+ if (!atvSalt || !atvPubKey) {
132
+ throw new Error('Missing salt or public key in pair-setup response');
133
+ }
134
+
135
+ return { srp, host, port, atvSalt, atvPubKey };
136
+ }
137
+
138
+ /**
139
+ * Finish AirPlay pairing with the PIN shown on screen.
140
+ * Returns credentials for later connection.
141
+ */
142
+ export async function finishAirPlayPairing(
143
+ session: AirPlayPairingSession,
144
+ pin: string,
145
+ displayName?: string,
146
+ ): Promise<HapCredentials> {
147
+ const { srp, host, port, atvSalt, atvPubKey } = session;
148
+ const agent = getPairingAgent(host, port);
149
+
150
+ console.log('[DEBUG] ATV Salt:', atvSalt.toString('hex'));
151
+ console.log('[DEBUG] ATV PubKey length:', atvPubKey.length);
152
+ console.log('[DEBUG] PIN:', pin);
153
+
154
+ // SRP step 1: set PIN
155
+ srp.step1(pin.trim());
156
+
157
+ // SRP step 2: compute proof
158
+ const [pubKey, proof] = srp.step2(atvPubKey, atvSalt);
159
+ console.log('[DEBUG] Client PubKey length:', pubKey.length);
160
+ console.log('[DEBUG] Client Proof length:', proof.length);
161
+
162
+ // Send pair-setup SeqNo 3
163
+ const seq3Data = writeTlv(new Map<number, Buffer>([
164
+ [TlvValue.SeqNo, Buffer.from([0x03])],
165
+ [TlvValue.PublicKey, pubKey],
166
+ [TlvValue.Proof, proof],
167
+ ]));
168
+ const seq3Resp = await httpPost(host, port, '/pair-setup', seq3Data, agent);
169
+
170
+ // Check for errors in SeqNo 3 response (wrong PIN, etc.)
171
+ const seq3Tlv = readTlv(seq3Resp);
172
+ console.log('[DEBUG] SeqNo 3 response TLV keys:', Array.from(seq3Tlv.keys()));
173
+ const errorCode = seq3Tlv.get(TlvValue.Error);
174
+ if (errorCode) {
175
+ const code = errorCode[0];
176
+ const errorMessages: Record<number, string> = {
177
+ 1: 'Unknown error',
178
+ 2: 'Authentication failed (wrong PIN?)',
179
+ 3: 'Too many attempts, backoff required',
180
+ 4: 'Unknown peer',
181
+ 5: 'Max peers reached',
182
+ 6: 'Max authentication attempts reached',
183
+ };
184
+ console.log('[DEBUG] Error code:', code);
185
+ console.log('[DEBUG] Full response hex:', seq3Resp.toString('hex'));
186
+ throw new Error(`Pairing failed: ${errorMessages[code] || `error code ${code}`}`);
187
+ }
188
+
189
+ // SRP step 3: sign and encrypt identity
190
+ const encrypted = srp.step3(displayName || undefined);
191
+
192
+ // Send pair-setup SeqNo 5
193
+ const seq5Data = writeTlv(new Map<number, Buffer>([
194
+ [TlvValue.SeqNo, Buffer.from([0x05])],
195
+ [TlvValue.EncryptedData, encrypted],
196
+ ]));
197
+ const resp = await httpPost(host, port, '/pair-setup', seq5Data, agent);
198
+ const pairingData = readTlv(resp);
199
+
200
+ const encryptedResponse = pairingData.get(TlvValue.EncryptedData);
201
+ if (!encryptedResponse) {
202
+ throw new Error('Missing encrypted data in pair-setup step 4 response');
203
+ }
204
+
205
+ // SRP step 4: decrypt and extract credentials
206
+ return srp.step4(encryptedResponse);
207
+ }
package/src/bplist.ts ADDED
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Minimal binary plist support for RTI (Remote Text Input) NSKeyedArchiver payloads.
3
+ * Uses bplist-creator for encoding and bplist-parser for decoding.
4
+ */
5
+
6
+ // @ts-ignore - no type definitions
7
+ import bplistCreator from 'bplist-creator';
8
+ // @ts-ignore - no type definitions
9
+ import bplistParser from 'bplist-parser';
10
+
11
+ /** UID reference type for NSKeyedArchiver plists. */
12
+ class UID {
13
+ UID: number;
14
+ constructor(value: number) {
15
+ this.UID = value;
16
+ }
17
+ }
18
+
19
+ /**
20
+ * Create an RTI "clear text" payload (NSKeyedArchiver encoded).
21
+ * Matches pyatv/protocols/companion/plist_payloads/rti_text_operations.py
22
+ */
23
+ export function createRtiClearTextPayload(sessionUuid: Buffer): Buffer {
24
+ return bplistCreator({
25
+ '$version': 100000,
26
+ '$archiver': 'RTIKeyedArchiver',
27
+ '$top': {
28
+ textOperations: new UID(1),
29
+ },
30
+ '$objects': [
31
+ '$null',
32
+ {
33
+ '$class': new UID(7),
34
+ targetSessionUUID: new UID(5),
35
+ keyboardOutput: new UID(2),
36
+ textToAssert: new UID(4),
37
+ },
38
+ {
39
+ '$class': new UID(3),
40
+ },
41
+ {
42
+ '$classname': 'TIKeyboardOutput',
43
+ '$classes': ['TIKeyboardOutput', 'NSObject'],
44
+ },
45
+ '', // empty text assertion = clear
46
+ {
47
+ 'NS.uuidbytes': sessionUuid,
48
+ '$class': new UID(6),
49
+ },
50
+ {
51
+ '$classname': 'NSUUID',
52
+ '$classes': ['NSUUID', 'NSObject'],
53
+ },
54
+ {
55
+ '$classname': 'RTITextOperations',
56
+ '$classes': ['RTITextOperations', 'NSObject'],
57
+ },
58
+ ],
59
+ });
60
+ }
61
+
62
+ /**
63
+ * Create an RTI "input text" payload (NSKeyedArchiver encoded).
64
+ */
65
+ export function createRtiInputTextPayload(sessionUuid: Buffer, text: string): Buffer {
66
+ return bplistCreator({
67
+ '$version': 100000,
68
+ '$archiver': 'RTIKeyedArchiver',
69
+ '$top': {
70
+ textOperations: new UID(1),
71
+ },
72
+ '$objects': [
73
+ '$null',
74
+ {
75
+ keyboardOutput: new UID(2),
76
+ '$class': new UID(7),
77
+ targetSessionUUID: new UID(5),
78
+ },
79
+ {
80
+ insertionText: new UID(3),
81
+ '$class': new UID(4),
82
+ },
83
+ text,
84
+ {
85
+ '$classname': 'TIKeyboardOutput',
86
+ '$classes': ['TIKeyboardOutput', 'NSObject'],
87
+ },
88
+ {
89
+ 'NS.uuidbytes': sessionUuid,
90
+ '$class': new UID(6),
91
+ },
92
+ {
93
+ '$classname': 'NSUUID',
94
+ '$classes': ['NSUUID', 'NSObject'],
95
+ },
96
+ {
97
+ '$classname': 'RTITextOperations',
98
+ '$classes': ['RTITextOperations', 'NSObject'],
99
+ },
100
+ ],
101
+ });
102
+ }
103
+
104
+ /**
105
+ * Parse a binary plist buffer and extract properties by path.
106
+ * Matches pyatv/protocols/companion/keyed_archiver.py read_archive_properties().
107
+ */
108
+ export function readArchiveProperties(archive: Buffer, ...paths: string[][]): (unknown | null)[] {
109
+ let parsed: any[];
110
+ try {
111
+ parsed = bplistParser.parseBuffer(archive);
112
+ } catch {
113
+ return paths.map(() => null);
114
+ }
115
+ if (!parsed || !Array.isArray(parsed) || parsed.length < 1) return paths.map(() => null);
116
+
117
+ const data = parsed[0];
118
+ const objects = data['$objects'];
119
+ const top = data['$top'];
120
+
121
+ return paths.map((path) => {
122
+ let element: any = top;
123
+ try {
124
+ for (const key of path) {
125
+ element = element[key];
126
+ // Resolve UID references
127
+ if (element && typeof element === 'object' && 'UID' in element) {
128
+ element = objects[element.UID];
129
+ }
130
+ }
131
+ return element ?? null;
132
+ } catch {
133
+ return null;
134
+ }
135
+ });
136
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Companion protocol pair-setup and pair-verify procedures.
3
+ * Port of pyatv/protocols/companion/auth.py.
4
+ */
5
+
6
+ import { SRPAuthHandler } from '../pairing/srp';
7
+ import { HapCredentials } from '../pairing/credentials';
8
+ import { TlvValue, readTlv, writeTlv } from '../pairing/tlv';
9
+ import { CompanionProtocol } from './protocol';
10
+ import { FrameType } from './connection';
11
+
12
+ const PAIRING_DATA_KEY = '_pd';
13
+
14
+ // Companion encryption key derivation constants
15
+ export const SRP_SALT = '';
16
+ export const SRP_OUTPUT_INFO = 'ClientEncrypt-main';
17
+ export const SRP_INPUT_INFO = 'ServerEncrypt-main';
18
+
19
+ function getPairingData(message: Record<string, unknown>): Map<number, Buffer> {
20
+ const pd = message[PAIRING_DATA_KEY];
21
+ if (!pd || !Buffer.isBuffer(pd)) {
22
+ throw new Error('No pairing data in message or unexpected type');
23
+ }
24
+ const tlv = readTlv(pd);
25
+ if (tlv.has(TlvValue.Error)) {
26
+ throw new Error(`Pairing error: ${tlv.get(TlvValue.Error)!.toString('hex')}`);
27
+ }
28
+ return tlv;
29
+ }
30
+
31
+ /**
32
+ * Perform Companion pair-setup (initial pairing that requires PIN).
33
+ */
34
+ export class CompanionPairSetupProcedure {
35
+ private protocol: CompanionProtocol;
36
+ private srp: SRPAuthHandler;
37
+ private atvSalt: Buffer | null = null;
38
+ private atvPubKey: Buffer | null = null;
39
+
40
+ constructor(protocol: CompanionProtocol, srp: SRPAuthHandler) {
41
+ this.protocol = protocol;
42
+ this.srp = srp;
43
+ }
44
+
45
+ async startPairing(): Promise<void> {
46
+ this.srp.initialize();
47
+ await this.protocol.start();
48
+
49
+ const resp = await this.protocol.exchangeAuth(FrameType.PS_Start, {
50
+ [PAIRING_DATA_KEY]: writeTlv(new Map<number, Buffer>([
51
+ [TlvValue.Method, Buffer.from([0x00])],
52
+ [TlvValue.SeqNo, Buffer.from([0x01])],
53
+ ])),
54
+ _pwTy: 1,
55
+ });
56
+
57
+ const pairingData = getPairingData(resp);
58
+ this.atvSalt = pairingData.get(TlvValue.Salt)!;
59
+ this.atvPubKey = pairingData.get(TlvValue.PublicKey)!;
60
+ }
61
+
62
+ async finishPairing(pin: string, displayName?: string): Promise<HapCredentials> {
63
+ this.srp.step1(pin);
64
+
65
+ const [pubKey, proof] = this.srp.step2(this.atvPubKey!, this.atvSalt!);
66
+
67
+ // SeqNo 3: send proof
68
+ const resp3 = await this.protocol.exchangeAuth(FrameType.PS_Next, {
69
+ [PAIRING_DATA_KEY]: writeTlv(new Map<number, Buffer>([
70
+ [TlvValue.SeqNo, Buffer.from([0x03])],
71
+ [TlvValue.PublicKey, pubKey],
72
+ [TlvValue.Proof, proof],
73
+ ])),
74
+ _pwTy: 1,
75
+ });
76
+
77
+ // Verify server proof is present
78
+ getPairingData(resp3);
79
+
80
+ // SeqNo 5: send encrypted identity
81
+ const encrypted = this.srp.step3(displayName);
82
+ const resp5 = await this.protocol.exchangeAuth(FrameType.PS_Next, {
83
+ [PAIRING_DATA_KEY]: writeTlv(new Map<number, Buffer>([
84
+ [TlvValue.SeqNo, Buffer.from([0x05])],
85
+ [TlvValue.EncryptedData, encrypted],
86
+ ])),
87
+ _pwTy: 1,
88
+ });
89
+
90
+ const pairingData5 = getPairingData(resp5);
91
+ const encryptedData = pairingData5.get(TlvValue.EncryptedData)!;
92
+
93
+ return this.srp.step4(encryptedData);
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Verify Companion credentials and derive encryption keys.
99
+ */
100
+ export class CompanionPairVerifyProcedure {
101
+ private protocol: CompanionProtocol;
102
+ private srp: SRPAuthHandler;
103
+ private credentials: HapCredentials;
104
+
105
+ constructor(protocol: CompanionProtocol, srp: SRPAuthHandler, credentials: HapCredentials) {
106
+ this.protocol = protocol;
107
+ this.srp = srp;
108
+ this.credentials = credentials;
109
+ }
110
+
111
+ async verifyCredentials(): Promise<boolean> {
112
+ const [, publicKey] = this.srp.initialize();
113
+
114
+ const resp = await this.protocol.exchangeAuth(FrameType.PV_Start, {
115
+ [PAIRING_DATA_KEY]: writeTlv(new Map<number, Buffer>([
116
+ [TlvValue.SeqNo, Buffer.from([0x01])],
117
+ [TlvValue.PublicKey, publicKey],
118
+ ])),
119
+ _auTy: 4,
120
+ });
121
+
122
+ const pairingData = getPairingData(resp);
123
+ const serverPubKey = pairingData.get(TlvValue.PublicKey)!;
124
+ const encrypted = pairingData.get(TlvValue.EncryptedData)!;
125
+
126
+ const encryptedData = this.srp.verify1(this.credentials, serverPubKey, encrypted);
127
+
128
+ await this.protocol.exchangeAuth(FrameType.PV_Next, {
129
+ [PAIRING_DATA_KEY]: writeTlv(new Map<number, Buffer>([
130
+ [TlvValue.SeqNo, Buffer.from([0x03])],
131
+ [TlvValue.EncryptedData, encryptedData],
132
+ ])),
133
+ });
134
+
135
+ return true;
136
+ }
137
+
138
+ encryptionKeys(): [Buffer, Buffer] {
139
+ return this.srp.verify2(SRP_SALT, SRP_OUTPUT_INFO, SRP_INPUT_INFO);
140
+ }
141
+ }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Companion protocol TCP connection with frame-based protocol.
3
+ * Port of pyatv/protocols/companion/connection.py.
4
+ *
5
+ * Frame format: [1-byte FrameType][3-byte big-endian payload length][payload]
6
+ * When encrypted, payload includes 16-byte auth tag in the length field.
7
+ */
8
+
9
+ import * as net from 'net';
10
+ import { EventEmitter } from 'events';
11
+ import { Chacha20Cipher, AUTH_TAG_LENGTH } from '../crypto/chacha20';
12
+ import * as opack from '../opack';
13
+
14
+ export enum FrameType {
15
+ Unknown = 0,
16
+ NoOp = 1,
17
+ PS_Start = 3,
18
+ PS_Next = 4,
19
+ PV_Start = 5,
20
+ PV_Next = 6,
21
+ U_OPACK = 7,
22
+ E_OPACK = 8,
23
+ P_OPACK = 9,
24
+ PA_Req = 10,
25
+ PA_Rsp = 11,
26
+ SessionStartRequest = 16,
27
+ SessionStartResponse = 17,
28
+ SessionData = 18,
29
+ FamilyIdentityRequest = 32,
30
+ FamilyIdentityResponse = 33,
31
+ FamilyIdentityUpdate = 34,
32
+ }
33
+
34
+ const HEADER_LENGTH = 4;
35
+
36
+ export interface FrameListener {
37
+ frameReceived(frameType: FrameType, payload: Buffer): void;
38
+ connectionLost(error?: Error): void;
39
+ }
40
+
41
+ export class CompanionConnection {
42
+ private socket: net.Socket | null = null;
43
+ private buffer = Buffer.alloc(0);
44
+ private chacha: Chacha20Cipher | null = null;
45
+ private listener: FrameListener;
46
+ private host: string;
47
+ private port: number;
48
+ private connected = false;
49
+
50
+ constructor(host: string, port: number, listener?: FrameListener) {
51
+ this.host = host;
52
+ this.port = port;
53
+ this.listener = listener || { frameReceived: () => {}, connectionLost: () => {} };
54
+ }
55
+
56
+ setListener(listener: FrameListener): void {
57
+ this.listener = listener;
58
+ }
59
+
60
+ async connect(): Promise<void> {
61
+ if (this.connected) return;
62
+ return new Promise((resolve, reject) => {
63
+ this.socket = net.createConnection({ host: this.host, port: this.port }, () => {
64
+ this.connected = true;
65
+ if (this.socket && typeof this.socket.unref === 'function') this.socket.unref();
66
+ resolve();
67
+ });
68
+
69
+ this.socket.on('data', (data: Buffer) => {
70
+ this.buffer = Buffer.concat([this.buffer, data]);
71
+ this.processBuffer();
72
+ });
73
+
74
+ this.socket.on('error', (err) => {
75
+ if (!this.connected) {
76
+ reject(err);
77
+ } else {
78
+ this.listener.connectionLost(err);
79
+ }
80
+ });
81
+
82
+ this.socket.on('close', () => {
83
+ this.connected = false;
84
+ this.listener.connectionLost();
85
+ });
86
+ });
87
+ }
88
+
89
+ private processBuffer(): void {
90
+ while (this.buffer.length >= HEADER_LENGTH) {
91
+ const payloadLength = HEADER_LENGTH + (
92
+ (this.buffer[1] << 16) | (this.buffer[2] << 8) | this.buffer[3]
93
+ );
94
+
95
+ if (this.buffer.length < payloadLength) break;
96
+
97
+ const header = this.buffer.subarray(0, HEADER_LENGTH);
98
+ let payload: Buffer = Buffer.from(this.buffer.subarray(HEADER_LENGTH, payloadLength)) as Buffer;
99
+ this.buffer = this.buffer.subarray(payloadLength);
100
+
101
+ if (this.chacha && payload.length > 0) {
102
+ try {
103
+ payload = this.chacha.decrypt(payload, undefined, header);
104
+ } catch (err) {
105
+ // Decryption failed, skip this frame
106
+ continue;
107
+ }
108
+ }
109
+
110
+ this.listener.frameReceived(header[0] as FrameType, payload);
111
+ }
112
+ }
113
+
114
+ send(frameType: FrameType, data: Buffer): void {
115
+ if (!this.socket || !this.connected) {
116
+ throw new Error('Not connected');
117
+ }
118
+
119
+ let payloadLength = data.length;
120
+ if (this.chacha && payloadLength > 0) {
121
+ payloadLength += AUTH_TAG_LENGTH;
122
+ }
123
+
124
+ const header = Buffer.alloc(HEADER_LENGTH);
125
+ header[0] = frameType;
126
+ header[1] = (payloadLength >> 16) & 0xff;
127
+ header[2] = (payloadLength >> 8) & 0xff;
128
+ header[3] = payloadLength & 0xff;
129
+
130
+ let payload = data;
131
+ if (this.chacha && data.length > 0) {
132
+ payload = this.chacha.encrypt(data, undefined, header);
133
+ }
134
+
135
+ this.socket.write(Buffer.concat([header, payload]));
136
+ }
137
+
138
+ enableEncryption(outputKey: Buffer, inputKey: Buffer): void {
139
+ this.chacha = new Chacha20Cipher(outputKey, inputKey, 12);
140
+ }
141
+
142
+ close(): void {
143
+ this.connected = false;
144
+ if (this.socket) {
145
+ this.socket.destroy();
146
+ this.socket = null;
147
+ }
148
+ }
149
+
150
+ get isConnected(): boolean {
151
+ return this.connected;
152
+ }
153
+
154
+ getHost(): string {
155
+ return this.host;
156
+ }
157
+
158
+ getPort(): number {
159
+ return this.port;
160
+ }
161
+ }