@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.
- package/LICENSE.md +21 -0
- package/README.md +80 -0
- package/dist/airplay/auth.d.ts +24 -0
- package/dist/airplay/auth.d.ts.map +1 -0
- package/dist/airplay/auth.js +195 -0
- package/dist/airplay/auth.js.map +1 -0
- package/dist/bplist.d.ts +19 -0
- package/dist/bplist.d.ts.map +1 -0
- package/dist/bplist.js +141 -0
- package/dist/bplist.js.map +1 -0
- package/dist/companion/auth.d.ts +34 -0
- package/dist/companion/auth.d.ts.map +1 -0
- package/dist/companion/auth.js +119 -0
- package/dist/companion/auth.js.map +1 -0
- package/dist/companion/connection.d.ts +50 -0
- package/dist/companion/connection.d.ts.map +1 -0
- package/dist/companion/connection.js +170 -0
- package/dist/companion/connection.js.map +1 -0
- package/dist/companion/keyboard.d.ts +39 -0
- package/dist/companion/keyboard.d.ts.map +1 -0
- package/dist/companion/keyboard.js +127 -0
- package/dist/companion/keyboard.js.map +1 -0
- package/dist/companion/pairing_keepalive.d.ts +4 -0
- package/dist/companion/pairing_keepalive.d.ts.map +1 -0
- package/dist/companion/pairing_keepalive.js +68 -0
- package/dist/companion/pairing_keepalive.js.map +1 -0
- package/dist/companion/protocol.d.ts +64 -0
- package/dist/companion/protocol.d.ts.map +1 -0
- package/dist/companion/protocol.js +246 -0
- package/dist/companion/protocol.js.map +1 -0
- package/dist/companion/remote.d.ts +75 -0
- package/dist/companion/remote.d.ts.map +1 -0
- package/dist/companion/remote.js +142 -0
- package/dist/companion/remote.js.map +1 -0
- package/dist/crypto/chacha20.d.ts +27 -0
- package/dist/crypto/chacha20.d.ts.map +1 -0
- package/dist/crypto/chacha20.js +88 -0
- package/dist/crypto/chacha20.js.map +1 -0
- package/dist/crypto/hkdf.d.ts +6 -0
- package/dist/crypto/hkdf.d.ts.map +1 -0
- package/dist/crypto/hkdf.js +45 -0
- package/dist/crypto/hkdf.js.map +1 -0
- package/dist/index.d.ts +85 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +191 -0
- package/dist/index.js.map +1 -0
- package/dist/mdns.d.ts +20 -0
- package/dist/mdns.d.ts.map +1 -0
- package/dist/mdns.js +165 -0
- package/dist/mdns.js.map +1 -0
- package/dist/opack.d.ts +21 -0
- package/dist/opack.d.ts.map +1 -0
- package/dist/opack.js +350 -0
- package/dist/opack.js.map +1 -0
- package/dist/pairing/credentials.d.ts +23 -0
- package/dist/pairing/credentials.d.ts.map +1 -0
- package/dist/pairing/credentials.js +31 -0
- package/dist/pairing/credentials.js.map +1 -0
- package/dist/pairing/srp.d.ts +54 -0
- package/dist/pairing/srp.d.ts.map +1 -0
- package/dist/pairing/srp.js +221 -0
- package/dist/pairing/srp.js.map +1 -0
- package/dist/pairing/tlv.d.ts +26 -0
- package/dist/pairing/tlv.d.ts.map +1 -0
- package/dist/pairing/tlv.js +68 -0
- package/dist/pairing/tlv.js.map +1 -0
- package/examples/pair.ts +103 -0
- package/examples/remote.ts +212 -0
- package/package.json +33 -0
- package/src/airplay/auth.ts +207 -0
- package/src/bplist.ts +136 -0
- package/src/companion/auth.ts +141 -0
- package/src/companion/connection.ts +161 -0
- package/src/companion/keyboard.ts +155 -0
- package/src/companion/pairing_keepalive.ts +75 -0
- package/src/companion/protocol.ts +253 -0
- package/src/companion/remote.ts +151 -0
- package/src/crypto/chacha20.ts +93 -0
- package/src/crypto/hkdf.ts +18 -0
- package/src/index.ts +248 -0
- package/src/mdns.ts +198 -0
- package/src/opack.ts +299 -0
- package/src/pairing/credentials.ts +44 -0
- package/src/pairing/srp.ts +234 -0
- package/src/pairing/tlv.ts +64 -0
- package/tsconfig.json +19 -0
package/src/opack.ts
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OPACK serialization format - Apple's binary encoding (similar to MessagePack).
|
|
3
|
+
* Direct port of pyatv/support/opack.py.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Wrapper to force a number to be encoded as float64.
|
|
8
|
+
* In JavaScript, 1000.0 === 1000, so we can't distinguish floats from integers.
|
|
9
|
+
* Use opackFloat(1000) to ensure encoding as float64.
|
|
10
|
+
*/
|
|
11
|
+
export class OpackFloat {
|
|
12
|
+
constructor(public readonly value: number) {}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function opackFloat(value: number): OpackFloat {
|
|
16
|
+
return new OpackFloat(value);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function pack(data: unknown): Buffer {
|
|
20
|
+
return Buffer.from(_pack(data, []));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function _pack(data: unknown, objectList: Uint8Array[]): Uint8Array {
|
|
24
|
+
let packed: Uint8Array;
|
|
25
|
+
|
|
26
|
+
if (data === null || data === undefined) {
|
|
27
|
+
packed = new Uint8Array([0x04]);
|
|
28
|
+
} else if (typeof data === 'boolean') {
|
|
29
|
+
packed = new Uint8Array([data ? 0x01 : 0x02]);
|
|
30
|
+
} else if (data instanceof OpackFloat) {
|
|
31
|
+
// Force float64 encoding
|
|
32
|
+
const buf = Buffer.alloc(9);
|
|
33
|
+
buf[0] = 0x36;
|
|
34
|
+
buf.writeDoubleLE(data.value, 1);
|
|
35
|
+
packed = buf;
|
|
36
|
+
} else if (typeof data === 'number') {
|
|
37
|
+
if (Number.isInteger(data)) {
|
|
38
|
+
packed = packInteger(data);
|
|
39
|
+
} else {
|
|
40
|
+
// float64
|
|
41
|
+
const buf = Buffer.alloc(9);
|
|
42
|
+
buf[0] = 0x36;
|
|
43
|
+
buf.writeDoubleLE(data, 1);
|
|
44
|
+
packed = buf;
|
|
45
|
+
}
|
|
46
|
+
} else if (typeof data === 'string') {
|
|
47
|
+
packed = packString(data);
|
|
48
|
+
} else if (Buffer.isBuffer(data) || data instanceof Uint8Array) {
|
|
49
|
+
packed = packBytes(data instanceof Uint8Array ? Buffer.from(data) : data);
|
|
50
|
+
} else if (Array.isArray(data)) {
|
|
51
|
+
const parts: Uint8Array[] = [new Uint8Array([0xd0 + Math.min(data.length, 0xf)])];
|
|
52
|
+
for (const item of data) {
|
|
53
|
+
parts.push(_pack(item, objectList));
|
|
54
|
+
}
|
|
55
|
+
if (data.length >= 0xf) {
|
|
56
|
+
parts.push(new Uint8Array([0x03]));
|
|
57
|
+
}
|
|
58
|
+
packed = concatBytes(parts);
|
|
59
|
+
} else if (typeof data === 'object') {
|
|
60
|
+
const entries = Object.entries(data as Record<string, unknown>);
|
|
61
|
+
const parts: Uint8Array[] = [new Uint8Array([0xe0 + Math.min(entries.length, 0xf)])];
|
|
62
|
+
for (const [key, value] of entries) {
|
|
63
|
+
parts.push(_pack(key, objectList));
|
|
64
|
+
parts.push(_pack(value, objectList));
|
|
65
|
+
}
|
|
66
|
+
if (entries.length >= 0xf) {
|
|
67
|
+
parts.push(new Uint8Array([0x03]));
|
|
68
|
+
}
|
|
69
|
+
packed = concatBytes(parts);
|
|
70
|
+
} else {
|
|
71
|
+
throw new TypeError(`Unsupported type: ${typeof data}`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Object deduplication
|
|
75
|
+
const idx = findInObjectList(objectList, packed);
|
|
76
|
+
if (idx >= 0) {
|
|
77
|
+
if (idx < 0x21) {
|
|
78
|
+
packed = new Uint8Array([0xa0 + idx]);
|
|
79
|
+
} else if (idx <= 0xff) {
|
|
80
|
+
packed = new Uint8Array([0xc1, idx]);
|
|
81
|
+
} else if (idx <= 0xffff) {
|
|
82
|
+
const buf = Buffer.alloc(3);
|
|
83
|
+
buf[0] = 0xc2;
|
|
84
|
+
buf.writeUInt16LE(idx, 1);
|
|
85
|
+
packed = buf;
|
|
86
|
+
}
|
|
87
|
+
} else if (packed.length > 1) {
|
|
88
|
+
objectList.push(packed);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return packed;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function packInteger(data: number): Uint8Array {
|
|
95
|
+
if (data >= 0 && data < 0x28) {
|
|
96
|
+
return new Uint8Array([data + 8]);
|
|
97
|
+
} else if (data >= 0 && data <= 0xff) {
|
|
98
|
+
return new Uint8Array([0x30, data]);
|
|
99
|
+
} else if (data >= 0 && data <= 0xffff) {
|
|
100
|
+
const buf = Buffer.alloc(3);
|
|
101
|
+
buf[0] = 0x31;
|
|
102
|
+
buf.writeUInt16LE(data, 1);
|
|
103
|
+
return buf;
|
|
104
|
+
} else if (data >= 0 && data <= 0xffffffff) {
|
|
105
|
+
const buf = Buffer.alloc(5);
|
|
106
|
+
buf[0] = 0x32;
|
|
107
|
+
buf.writeUInt32LE(data, 1);
|
|
108
|
+
return buf;
|
|
109
|
+
} else {
|
|
110
|
+
const buf = Buffer.alloc(9);
|
|
111
|
+
buf[0] = 0x33;
|
|
112
|
+
buf.writeBigUInt64LE(BigInt(data), 1);
|
|
113
|
+
return buf;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function packString(data: string): Uint8Array {
|
|
118
|
+
const encoded = Buffer.from(data, 'utf-8');
|
|
119
|
+
if (encoded.length <= 0x20) {
|
|
120
|
+
return Buffer.concat([Buffer.from([0x40 + encoded.length]), encoded]);
|
|
121
|
+
} else if (encoded.length <= 0xff) {
|
|
122
|
+
return Buffer.concat([Buffer.from([0x61, encoded.length]), encoded]);
|
|
123
|
+
} else if (encoded.length <= 0xffff) {
|
|
124
|
+
const hdr = Buffer.alloc(3);
|
|
125
|
+
hdr[0] = 0x62;
|
|
126
|
+
hdr.writeUInt16LE(encoded.length, 1);
|
|
127
|
+
return Buffer.concat([hdr, encoded]);
|
|
128
|
+
} else if (encoded.length <= 0xffffff) {
|
|
129
|
+
const hdr = Buffer.alloc(4);
|
|
130
|
+
hdr[0] = 0x63;
|
|
131
|
+
hdr.writeUIntLE(encoded.length, 1, 3);
|
|
132
|
+
return Buffer.concat([hdr, encoded]);
|
|
133
|
+
} else {
|
|
134
|
+
const hdr = Buffer.alloc(5);
|
|
135
|
+
hdr[0] = 0x64;
|
|
136
|
+
hdr.writeUInt32LE(encoded.length, 1);
|
|
137
|
+
return Buffer.concat([hdr, encoded]);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function packBytes(data: Buffer): Uint8Array {
|
|
142
|
+
if (data.length <= 0x20) {
|
|
143
|
+
return Buffer.concat([Buffer.from([0x70 + data.length]), data]);
|
|
144
|
+
} else if (data.length <= 0xff) {
|
|
145
|
+
return Buffer.concat([Buffer.from([0x91, data.length]), data]);
|
|
146
|
+
} else if (data.length <= 0xffff) {
|
|
147
|
+
const hdr = Buffer.alloc(3);
|
|
148
|
+
hdr[0] = 0x92;
|
|
149
|
+
hdr.writeUInt16LE(data.length, 1);
|
|
150
|
+
return Buffer.concat([hdr, data]);
|
|
151
|
+
} else if (data.length <= 0xffffffff) {
|
|
152
|
+
const hdr = Buffer.alloc(5);
|
|
153
|
+
hdr[0] = 0x93;
|
|
154
|
+
hdr.writeUInt32LE(data.length, 1);
|
|
155
|
+
return Buffer.concat([hdr, data]);
|
|
156
|
+
} else {
|
|
157
|
+
const hdr = Buffer.alloc(9);
|
|
158
|
+
hdr[0] = 0x94;
|
|
159
|
+
hdr.writeBigUInt64LE(BigInt(data.length), 1);
|
|
160
|
+
return Buffer.concat([hdr, data]);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function concatBytes(parts: Uint8Array[]): Uint8Array {
|
|
165
|
+
return Buffer.concat(parts.map(p => Buffer.from(p)));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function findInObjectList(list: Uint8Array[], item: Uint8Array): number {
|
|
169
|
+
for (let i = 0; i < list.length; i++) {
|
|
170
|
+
if (Buffer.from(list[i]).equals(Buffer.from(item))) return i;
|
|
171
|
+
}
|
|
172
|
+
return -1;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export interface UnpackResult {
|
|
176
|
+
value: unknown;
|
|
177
|
+
remaining: Buffer;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function unpack(data: Buffer): UnpackResult {
|
|
181
|
+
const [value, remaining] = _unpack(data, []);
|
|
182
|
+
return { value, remaining: Buffer.from(remaining) };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function _unpack(data: Buffer, objectList: unknown[]): [unknown, Buffer] {
|
|
186
|
+
const byte0 = data[0];
|
|
187
|
+
let value: unknown;
|
|
188
|
+
let remaining: Buffer;
|
|
189
|
+
let addToObjectList = true;
|
|
190
|
+
|
|
191
|
+
if (byte0 === 0x01) {
|
|
192
|
+
value = true; remaining = data.subarray(1); addToObjectList = false;
|
|
193
|
+
} else if (byte0 === 0x02) {
|
|
194
|
+
value = false; remaining = data.subarray(1); addToObjectList = false;
|
|
195
|
+
} else if (byte0 === 0x04) {
|
|
196
|
+
value = null; remaining = data.subarray(1); addToObjectList = false;
|
|
197
|
+
} else if (byte0 === 0x05) {
|
|
198
|
+
// UUID - 16 bytes
|
|
199
|
+
value = data.subarray(1, 17);
|
|
200
|
+
remaining = data.subarray(17);
|
|
201
|
+
} else if (byte0 === 0x06) {
|
|
202
|
+
// Absolute time as integer
|
|
203
|
+
value = Number(data.readBigUInt64LE(1));
|
|
204
|
+
remaining = data.subarray(9);
|
|
205
|
+
} else if (byte0 >= 0x08 && byte0 <= 0x2f) {
|
|
206
|
+
value = byte0 - 8; remaining = data.subarray(1); addToObjectList = false;
|
|
207
|
+
} else if (byte0 === 0x35) {
|
|
208
|
+
value = data.readFloatLE(1); remaining = data.subarray(5);
|
|
209
|
+
} else if (byte0 === 0x36) {
|
|
210
|
+
value = data.readDoubleLE(1); remaining = data.subarray(9);
|
|
211
|
+
} else if ((byte0 & 0xf0) === 0x30) {
|
|
212
|
+
const numBytes = 1 << (byte0 & 0xf);
|
|
213
|
+
value = readUIntLE(data, 1, numBytes);
|
|
214
|
+
remaining = data.subarray(1 + numBytes);
|
|
215
|
+
} else if (byte0 >= 0x40 && byte0 <= 0x60) {
|
|
216
|
+
const length = byte0 - 0x40;
|
|
217
|
+
value = data.subarray(1, 1 + length).toString('utf-8');
|
|
218
|
+
remaining = data.subarray(1 + length);
|
|
219
|
+
} else if (byte0 > 0x60 && byte0 <= 0x64) {
|
|
220
|
+
const numBytes = byte0 & 0xf;
|
|
221
|
+
const length = readUIntLE(data, 1, numBytes);
|
|
222
|
+
value = data.subarray(1 + numBytes, 1 + numBytes + length).toString('utf-8');
|
|
223
|
+
remaining = data.subarray(1 + numBytes + length);
|
|
224
|
+
} else if (byte0 >= 0x70 && byte0 <= 0x90) {
|
|
225
|
+
const length = byte0 - 0x70;
|
|
226
|
+
value = Buffer.from(data.subarray(1, 1 + length));
|
|
227
|
+
remaining = data.subarray(1 + length);
|
|
228
|
+
} else if (byte0 >= 0x91 && byte0 <= 0x94) {
|
|
229
|
+
const numBytes = 1 << ((byte0 & 0xf) - 1);
|
|
230
|
+
const length = readUIntLE(data, 1, numBytes);
|
|
231
|
+
value = Buffer.from(data.subarray(1 + numBytes, 1 + numBytes + length));
|
|
232
|
+
remaining = data.subarray(1 + numBytes + length);
|
|
233
|
+
} else if ((byte0 & 0xf0) === 0xd0) {
|
|
234
|
+
const count = byte0 & 0xf;
|
|
235
|
+
const output: unknown[] = [];
|
|
236
|
+
let ptr = data.subarray(1);
|
|
237
|
+
if (count === 0xf) {
|
|
238
|
+
while (ptr[0] !== 0x03) {
|
|
239
|
+
const [v, rest] = _unpack(Buffer.from(ptr), objectList);
|
|
240
|
+
output.push(v);
|
|
241
|
+
ptr = rest;
|
|
242
|
+
}
|
|
243
|
+
ptr = ptr.subarray(1);
|
|
244
|
+
} else {
|
|
245
|
+
for (let i = 0; i < count; i++) {
|
|
246
|
+
const [v, rest] = _unpack(Buffer.from(ptr), objectList);
|
|
247
|
+
output.push(v);
|
|
248
|
+
ptr = rest;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
value = output; remaining = Buffer.from(ptr); addToObjectList = false;
|
|
252
|
+
} else if ((byte0 & 0xe0) === 0xe0) {
|
|
253
|
+
const count = byte0 & 0xf;
|
|
254
|
+
const output: Record<string, unknown> = {};
|
|
255
|
+
let ptr = data.subarray(1);
|
|
256
|
+
if (count === 0xf) {
|
|
257
|
+
while (ptr[0] !== 0x03) {
|
|
258
|
+
const [k, rest1] = _unpack(Buffer.from(ptr), objectList);
|
|
259
|
+
const [v, rest2] = _unpack(Buffer.from(rest1), objectList);
|
|
260
|
+
output[String(k)] = v;
|
|
261
|
+
ptr = rest2;
|
|
262
|
+
}
|
|
263
|
+
ptr = ptr.subarray(1);
|
|
264
|
+
} else {
|
|
265
|
+
for (let i = 0; i < count; i++) {
|
|
266
|
+
const [k, rest1] = _unpack(Buffer.from(ptr), objectList);
|
|
267
|
+
const [v, rest2] = _unpack(Buffer.from(rest1), objectList);
|
|
268
|
+
output[String(k)] = v;
|
|
269
|
+
ptr = rest2;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
value = output; remaining = Buffer.from(ptr); addToObjectList = false;
|
|
273
|
+
} else if (byte0 >= 0xa0 && byte0 <= 0xc0) {
|
|
274
|
+
value = objectList[byte0 - 0xa0]; remaining = data.subarray(1);
|
|
275
|
+
addToObjectList = false;
|
|
276
|
+
} else if (byte0 >= 0xc1 && byte0 <= 0xc4) {
|
|
277
|
+
const length = byte0 - 0xc0;
|
|
278
|
+
const uid = readUIntLE(data, 1, length);
|
|
279
|
+
value = objectList[uid];
|
|
280
|
+
remaining = data.subarray(1 + length);
|
|
281
|
+
addToObjectList = false;
|
|
282
|
+
} else {
|
|
283
|
+
throw new TypeError(`Unknown OPACK type: 0x${byte0.toString(16)}`);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (addToObjectList && !objectList.includes(value)) {
|
|
287
|
+
objectList.push(value);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return [value, remaining];
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function readUIntLE(buf: Buffer, offset: number, numBytes: number): number {
|
|
294
|
+
if (numBytes === 1) return buf[offset];
|
|
295
|
+
if (numBytes === 2) return buf.readUInt16LE(offset);
|
|
296
|
+
if (numBytes === 4) return buf.readUInt32LE(offset);
|
|
297
|
+
if (numBytes === 8) return Number(buf.readBigUInt64LE(offset));
|
|
298
|
+
throw new Error(`Unsupported integer size: ${numBytes}`);
|
|
299
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HAP credentials storage and parsing.
|
|
3
|
+
* Port of pyatv/auth/hap_pairing.py HapCredentials + parse_credentials().
|
|
4
|
+
*
|
|
5
|
+
* Format: "LTPK:LTSK:ATV_ID:CLIENT_ID" (all hex-encoded)
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface HapCredentials {
|
|
9
|
+
/** Apple TV's Ed25519 public key */
|
|
10
|
+
ltpk: Buffer;
|
|
11
|
+
/** Client's Ed25519 private key */
|
|
12
|
+
ltsk: Buffer;
|
|
13
|
+
/** Device identifier */
|
|
14
|
+
atvId: Buffer;
|
|
15
|
+
/** Client identifier (UUID) */
|
|
16
|
+
clientId: Buffer;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface Credentials {
|
|
20
|
+
airplay: string;
|
|
21
|
+
companion: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function serializeCredentials(creds: HapCredentials): string {
|
|
25
|
+
return [
|
|
26
|
+
creds.ltpk.toString('hex'),
|
|
27
|
+
creds.ltsk.toString('hex'),
|
|
28
|
+
creds.atvId.toString('hex'),
|
|
29
|
+
creds.clientId.toString('hex'),
|
|
30
|
+
].join(':');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function parseCredentials(credString: string): HapCredentials {
|
|
34
|
+
const parts = credString.split(':');
|
|
35
|
+
if (parts.length === 4) {
|
|
36
|
+
return {
|
|
37
|
+
ltpk: Buffer.from(parts[0], 'hex'),
|
|
38
|
+
ltsk: Buffer.from(parts[1], 'hex'),
|
|
39
|
+
atvId: Buffer.from(parts[2], 'hex'),
|
|
40
|
+
clientId: Buffer.from(parts[3], 'hex'),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
throw new Error(`Invalid credentials format: expected 4 hex parts separated by ':'`);
|
|
44
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SRP authentication handler for HAP pair-setup and pair-verify.
|
|
3
|
+
* Port of pyatv/auth/hap_srp.py SRPAuthHandler.
|
|
4
|
+
*
|
|
5
|
+
* Uses fast-srp-hap for SRP 3072-bit with SHA-512,
|
|
6
|
+
* and Node.js crypto for Ed25519 and X25519.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as crypto from 'crypto';
|
|
10
|
+
import { SRP, SrpClient } from 'fast-srp-hap';
|
|
11
|
+
import { Chacha20Cipher8byteNonce } from '../crypto/chacha20';
|
|
12
|
+
import { hkdfExpand } from '../crypto/hkdf';
|
|
13
|
+
import { HapCredentials } from './credentials';
|
|
14
|
+
import { TlvValue, readTlv, writeTlv } from './tlv';
|
|
15
|
+
import { pack as opackPack } from '../opack';
|
|
16
|
+
|
|
17
|
+
// Ed25519 DER prefixes for raw key import/export
|
|
18
|
+
const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
|
|
19
|
+
const ED25519_PKCS8_PREFIX = Buffer.from('302e020100300506032b657004220420', 'hex');
|
|
20
|
+
const X25519_SPKI_PREFIX = Buffer.from('302a300506032b656e032100', 'hex');
|
|
21
|
+
const X25519_PKCS8_PREFIX = Buffer.from('302e020100300506032b656e04220420', 'hex');
|
|
22
|
+
|
|
23
|
+
export class SRPAuthHandler {
|
|
24
|
+
pairingId: Buffer;
|
|
25
|
+
private signingKey: crypto.KeyObject | null = null;
|
|
26
|
+
private authPrivate: Buffer | null = null;
|
|
27
|
+
private authPublic: Buffer | null = null;
|
|
28
|
+
private verifyPrivate: crypto.KeyObject | null = null;
|
|
29
|
+
private verifyPublic: Buffer | null = null;
|
|
30
|
+
private srpClient: SrpClient | null = null;
|
|
31
|
+
private shared: Buffer | null = null;
|
|
32
|
+
private sessionKey: Buffer | null = null;
|
|
33
|
+
private pin: string = '';
|
|
34
|
+
private clientSecret: Buffer | null = null;
|
|
35
|
+
|
|
36
|
+
constructor() {
|
|
37
|
+
this.pairingId = Buffer.from(crypto.randomUUID(), 'utf-8');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Initialize by generating new Ed25519 signing keys and X25519 verify keys.
|
|
42
|
+
* Returns [authPublic, verifyPublic].
|
|
43
|
+
*/
|
|
44
|
+
initialize(): [Buffer, Buffer] {
|
|
45
|
+
// Generate raw 32-byte seeds first, like pyatv does
|
|
46
|
+
// pyatv: self._signing_key = Ed25519PrivateKey.from_private_bytes(os.urandom(32))
|
|
47
|
+
const authSeed = crypto.randomBytes(32);
|
|
48
|
+
const verifySeed = crypto.randomBytes(32);
|
|
49
|
+
|
|
50
|
+
// Create Ed25519 signing key from seed
|
|
51
|
+
// pyatv exports with Encoding.Raw, Format.Raw which gives the 32-byte seed
|
|
52
|
+
this.signingKey = crypto.createPrivateKey({
|
|
53
|
+
key: Buffer.concat([ED25519_PKCS8_PREFIX, authSeed]),
|
|
54
|
+
format: 'der',
|
|
55
|
+
type: 'pkcs8',
|
|
56
|
+
});
|
|
57
|
+
this.authPrivate = authSeed;
|
|
58
|
+
this.authPublic = crypto.createPublicKey(this.signingKey)
|
|
59
|
+
.export({ type: 'spki', format: 'der' }).subarray(-32);
|
|
60
|
+
|
|
61
|
+
// X25519 verify key
|
|
62
|
+
this.verifyPrivate = crypto.createPrivateKey({
|
|
63
|
+
key: Buffer.concat([X25519_PKCS8_PREFIX, verifySeed]),
|
|
64
|
+
format: 'der',
|
|
65
|
+
type: 'pkcs8',
|
|
66
|
+
});
|
|
67
|
+
this.verifyPublic = crypto.createPublicKey(this.verifyPrivate)
|
|
68
|
+
.export({ type: 'spki', format: 'der' }).subarray(-32);
|
|
69
|
+
|
|
70
|
+
// Use the raw seed as SRP client secret (matches pyatv behavior)
|
|
71
|
+
// pyatv uses binascii.hexlify(self._auth_private) as the SRP exponent 'a'
|
|
72
|
+
this.clientSecret = this.authPrivate;
|
|
73
|
+
|
|
74
|
+
return [this.authPublic, this.verifyPublic];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---- Pair-Verify ----
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Pair-Verify step 1: X25519 shared secret + decrypt server identity + sign our identity.
|
|
81
|
+
*/
|
|
82
|
+
verify1(credentials: HapCredentials, sessionPubKey: Buffer, encrypted: Buffer): Buffer {
|
|
83
|
+
const serverKey = crypto.createPublicKey({
|
|
84
|
+
key: Buffer.concat([X25519_SPKI_PREFIX, sessionPubKey]),
|
|
85
|
+
format: 'der',
|
|
86
|
+
type: 'spki',
|
|
87
|
+
});
|
|
88
|
+
this.shared = crypto.diffieHellman({
|
|
89
|
+
privateKey: this.verifyPrivate!,
|
|
90
|
+
publicKey: serverKey,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const verifyKey = hkdfExpand('Pair-Verify-Encrypt-Salt', 'Pair-Verify-Encrypt-Info', this.shared);
|
|
94
|
+
|
|
95
|
+
const chacha = new Chacha20Cipher8byteNonce(verifyKey, verifyKey);
|
|
96
|
+
const decryptedBytes = chacha.decrypt(encrypted, Buffer.from('PV-Msg02', 'utf-8'));
|
|
97
|
+
const decryptedTlv = readTlv(decryptedBytes);
|
|
98
|
+
|
|
99
|
+
const identifier = decryptedTlv.get(TlvValue.Identifier)!;
|
|
100
|
+
const signature = decryptedTlv.get(TlvValue.Signature)!;
|
|
101
|
+
|
|
102
|
+
if (!identifier.equals(credentials.atvId)) {
|
|
103
|
+
throw new Error('Incorrect device response: identifier mismatch');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Verify server Ed25519 signature
|
|
107
|
+
const info = Buffer.concat([sessionPubKey, identifier, this.verifyPublic!]);
|
|
108
|
+
const ltpk = crypto.createPublicKey({
|
|
109
|
+
key: Buffer.concat([ED25519_SPKI_PREFIX, credentials.ltpk]),
|
|
110
|
+
format: 'der',
|
|
111
|
+
type: 'spki',
|
|
112
|
+
});
|
|
113
|
+
if (!crypto.verify(null, info, ltpk, signature)) {
|
|
114
|
+
throw new Error('Signature verification failed');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Sign our identity
|
|
118
|
+
const deviceInfo = Buffer.concat([this.verifyPublic!, credentials.clientId, sessionPubKey]);
|
|
119
|
+
const ltsk = crypto.createPrivateKey({
|
|
120
|
+
key: Buffer.concat([ED25519_PKCS8_PREFIX, credentials.ltsk]),
|
|
121
|
+
format: 'der',
|
|
122
|
+
type: 'pkcs8',
|
|
123
|
+
});
|
|
124
|
+
const deviceSignature = crypto.sign(null, deviceInfo, ltsk);
|
|
125
|
+
|
|
126
|
+
const tlv = writeTlv(new Map([
|
|
127
|
+
[TlvValue.Identifier, credentials.clientId],
|
|
128
|
+
[TlvValue.Signature, deviceSignature],
|
|
129
|
+
]));
|
|
130
|
+
|
|
131
|
+
return chacha.encrypt(tlv, Buffer.from('PV-Msg03', 'utf-8'));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Pair-Verify step 2: derive final encryption keys.
|
|
136
|
+
*/
|
|
137
|
+
verify2(salt: string, outputInfo: string, inputInfo: string): [Buffer, Buffer] {
|
|
138
|
+
if (!this.shared) throw new Error('Must call verify1 first');
|
|
139
|
+
const outputKey = hkdfExpand(salt, outputInfo, this.shared);
|
|
140
|
+
const inputKey = hkdfExpand(salt, inputInfo, this.shared);
|
|
141
|
+
return [outputKey, inputKey];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ---- Pair-Setup ----
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Pair-Setup step 1: store PIN for later use with salt.
|
|
148
|
+
*/
|
|
149
|
+
step1(pin: string): void {
|
|
150
|
+
this.pin = pin;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Pair-Setup step 2: process server's public key and salt, compute SRP proof.
|
|
155
|
+
* Returns [clientPublicKey, clientProof].
|
|
156
|
+
*/
|
|
157
|
+
step2(atvPubKey: Buffer, atvSalt: Buffer): [Buffer, Buffer] {
|
|
158
|
+
// HAP uses 3072-bit SRP with SHA-512 - use the 'hap' preset
|
|
159
|
+
const params = SRP.params.hap;
|
|
160
|
+
|
|
161
|
+
this.srpClient = new SrpClient(
|
|
162
|
+
params,
|
|
163
|
+
atvSalt,
|
|
164
|
+
Buffer.from('Pair-Setup', 'utf-8'),
|
|
165
|
+
Buffer.from(this.pin, 'utf-8'),
|
|
166
|
+
this.clientSecret!,
|
|
167
|
+
);
|
|
168
|
+
this.srpClient.setB(atvPubKey);
|
|
169
|
+
|
|
170
|
+
const pubKey = this.srpClient.computeA();
|
|
171
|
+
const proof = this.srpClient.computeM1();
|
|
172
|
+
|
|
173
|
+
return [pubKey, proof];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Pair-Setup step 3: sign identity and encrypt with session key.
|
|
178
|
+
* Returns encrypted data to send as SeqNo 0x05.
|
|
179
|
+
*/
|
|
180
|
+
step3(name?: string): Buffer {
|
|
181
|
+
const srpKey = this.srpClient!.computeK();
|
|
182
|
+
|
|
183
|
+
const iosDeviceX = hkdfExpand(
|
|
184
|
+
'Pair-Setup-Controller-Sign-Salt',
|
|
185
|
+
'Pair-Setup-Controller-Sign-Info',
|
|
186
|
+
srpKey,
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
this.sessionKey = hkdfExpand(
|
|
190
|
+
'Pair-Setup-Encrypt-Salt',
|
|
191
|
+
'Pair-Setup-Encrypt-Info',
|
|
192
|
+
srpKey,
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
const deviceInfo = Buffer.concat([iosDeviceX, this.pairingId, this.authPublic!]);
|
|
196
|
+
const deviceSignature = crypto.sign(null, deviceInfo, this.signingKey!);
|
|
197
|
+
|
|
198
|
+
const tlvData = new Map<number, Buffer>([
|
|
199
|
+
[TlvValue.Identifier, this.pairingId],
|
|
200
|
+
[TlvValue.PublicKey, this.authPublic!],
|
|
201
|
+
[TlvValue.Signature, deviceSignature],
|
|
202
|
+
]);
|
|
203
|
+
|
|
204
|
+
if (name) {
|
|
205
|
+
tlvData.set(TlvValue.Name, opackPack({ name }));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const chacha = new Chacha20Cipher8byteNonce(this.sessionKey, this.sessionKey);
|
|
209
|
+
return chacha.encrypt(writeTlv(tlvData), Buffer.from('PS-Msg05', 'utf-8'));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Pair-Setup step 4: decrypt device response and extract credentials.
|
|
214
|
+
*/
|
|
215
|
+
step4(encryptedData: Buffer): HapCredentials {
|
|
216
|
+
const chacha = new Chacha20Cipher8byteNonce(this.sessionKey!, this.sessionKey!);
|
|
217
|
+
const decrypted = chacha.decrypt(encryptedData, Buffer.from('PS-Msg06', 'utf-8'));
|
|
218
|
+
|
|
219
|
+
if (!decrypted || decrypted.length === 0) {
|
|
220
|
+
throw new Error('Failed to decrypt pairing response');
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const tlv = readTlv(decrypted);
|
|
224
|
+
const atvIdentifier = tlv.get(TlvValue.Identifier)!;
|
|
225
|
+
const atvPubKey = tlv.get(TlvValue.PublicKey)!;
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
ltpk: atvPubKey,
|
|
229
|
+
ltsk: this.authPrivate!,
|
|
230
|
+
atvId: atvIdentifier,
|
|
231
|
+
clientId: this.pairingId,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TLV8 encoding/decoding for HAP (HomeKit Accessory Protocol).
|
|
3
|
+
* Direct port of pyatv/auth/hap_tlv8.py.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export enum TlvValue {
|
|
7
|
+
Method = 0x00,
|
|
8
|
+
Identifier = 0x01,
|
|
9
|
+
Salt = 0x02,
|
|
10
|
+
PublicKey = 0x03,
|
|
11
|
+
Proof = 0x04,
|
|
12
|
+
EncryptedData = 0x05,
|
|
13
|
+
SeqNo = 0x06,
|
|
14
|
+
Error = 0x07,
|
|
15
|
+
BackOff = 0x08,
|
|
16
|
+
Certificate = 0x09,
|
|
17
|
+
Signature = 0x0a,
|
|
18
|
+
Permissions = 0x0b,
|
|
19
|
+
FragmentData = 0x0c,
|
|
20
|
+
FragmentLast = 0x0d,
|
|
21
|
+
Name = 0x11,
|
|
22
|
+
Flags = 0x13,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type TlvData = Map<number, Buffer>;
|
|
26
|
+
|
|
27
|
+
export function readTlv(data: Buffer): TlvData {
|
|
28
|
+
const result = new Map<number, Buffer>();
|
|
29
|
+
let pos = 0;
|
|
30
|
+
while (pos < data.length) {
|
|
31
|
+
const tag = data[pos];
|
|
32
|
+
const length = data[pos + 1];
|
|
33
|
+
const value = data.subarray(pos + 2, pos + 2 + length);
|
|
34
|
+
if (result.has(tag)) {
|
|
35
|
+
result.set(tag, Buffer.concat([result.get(tag)!, value]));
|
|
36
|
+
} else {
|
|
37
|
+
result.set(tag, Buffer.from(value));
|
|
38
|
+
}
|
|
39
|
+
pos += 2 + length;
|
|
40
|
+
}
|
|
41
|
+
return result;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function writeTlv(data: Map<number, Buffer> | Record<number, Buffer>): Buffer {
|
|
45
|
+
const entries = data instanceof Map ? Array.from(data.entries()) : Object.entries(data).map(([k, v]) => [Number(k), v] as [number, Buffer]);
|
|
46
|
+
const parts: Buffer[] = [];
|
|
47
|
+
for (const [key, value] of entries) {
|
|
48
|
+
const tag = Buffer.from([key]);
|
|
49
|
+
let pos = 0;
|
|
50
|
+
let remaining = value.length;
|
|
51
|
+
while (pos < value.length || remaining === 0) {
|
|
52
|
+
const size = Math.min(remaining, 255);
|
|
53
|
+
parts.push(tag);
|
|
54
|
+
parts.push(Buffer.from([size]));
|
|
55
|
+
if (size > 0) {
|
|
56
|
+
parts.push(value.subarray(pos, pos + size));
|
|
57
|
+
}
|
|
58
|
+
pos += size;
|
|
59
|
+
remaining -= size;
|
|
60
|
+
if (remaining === 0 && pos >= value.length) break;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return Buffer.concat(parts);
|
|
64
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "commonjs",
|
|
5
|
+
"lib": ["ES2022"],
|
|
6
|
+
"outDir": "./dist",
|
|
7
|
+
"rootDir": "./src",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"forceConsistentCasingInFileNames": true,
|
|
12
|
+
"resolveJsonModule": true,
|
|
13
|
+
"declaration": true,
|
|
14
|
+
"declarationMap": true,
|
|
15
|
+
"sourceMap": true
|
|
16
|
+
},
|
|
17
|
+
"include": ["src/**/*"],
|
|
18
|
+
"exclude": ["node_modules", "dist"]
|
|
19
|
+
}
|