@ciphore/radiocli 0.2.4 → 0.2.5
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 +70 -2
- package/CONTRIBUTING.md +10 -0
- package/README.md +108 -253
- package/SECURITY.md +23 -1
- package/dist/agent/alarm-service.js +10 -7
- package/dist/agent/cli.js +16 -1
- package/dist/agent/command-schema.js +47 -0
- package/dist/agent/headless-host.js +226 -81
- package/dist/agent/launcher.js +2 -1
- package/dist/agent/mcp-server.js +10 -5
- package/dist/agent/service.js +75 -40
- package/dist/agent/session.js +214 -24
- package/dist/agent/tui-session.js +213 -0
- package/dist/alarms/cli.js +19 -3
- package/dist/alarms/occurrence-cache.js +25 -0
- package/dist/alarms/runner.js +87 -23
- package/dist/alarms/schedule.js +29 -1
- package/dist/alarms/scheduler.js +11 -3
- package/dist/alarms/system-volume-linux.js +160 -0
- package/dist/alarms/system-volume-ownership.js +76 -8
- package/dist/alarms/system-volume.js +44 -23
- package/dist/cli/arguments.js +212 -0
- package/dist/cli/completion.js +44 -0
- package/dist/cli/doctor.js +156 -0
- package/dist/cli/interactive.js +79 -0
- package/dist/cli/listener.js +227 -0
- package/dist/cli/output.js +121 -0
- package/dist/cli/render-tui.js +34 -0
- package/dist/cli.js +178 -199
- package/dist/platform/capabilities.js +8 -1
- package/dist/platform/desktop-media.js +61 -0
- package/dist/platform/diagnostics.js +273 -0
- package/dist/platform/json-worker.js +111 -0
- package/dist/platform/launch-command.js +9 -6
- package/dist/platform/mpris.js +356 -0
- package/dist/platform/network.js +69 -1
- package/dist/platform/omarchy.js +147 -0
- package/dist/platform/packages.js +7 -1
- package/dist/platform/runtime.js +1 -1
- package/dist/platform/terminals.js +24 -4
- package/dist/player/airplay-discovery.js +144 -61
- package/dist/player/airplay-policy.js +15 -0
- package/dist/player/airplay-process.js +63 -0
- package/dist/player/airplay-readiness.js +42 -0
- package/dist/player/airplay-sender-health.js +49 -49
- package/dist/player/airplay-sender-package.js +5 -0
- package/dist/player/airplay-sender-patch.js +102 -4
- package/dist/player/airplay-setup.js +51 -0
- package/dist/player/airplay-worker.js +97 -41
- package/dist/player/backend-install.js +9 -6
- package/dist/player/ffmpeg-input.js +37 -0
- package/dist/player/mpv-desktop-media.js +194 -0
- package/dist/player/mpv-ipc-client.js +51 -2
- package/dist/player/player-controller.js +390 -73
- package/dist/player/radio-engine.js +282 -0
- package/dist/playlists/playlist.js +18 -16
- package/dist/providers/cache.js +291 -75
- package/dist/providers/discovery-session.js +111 -0
- package/dist/providers/provider-manager.js +106 -28
- package/dist/providers/radio-browser.js +272 -41
- package/dist/providers/radio-garden.js +4 -2
- package/dist/setup-playback.js +152 -0
- package/dist/setup.js +88 -21
- package/dist/storage/file-lock.js +113 -0
- package/dist/storage/read-settings.js +33 -0
- package/dist/storage/replace-file.js +20 -0
- package/dist/storage/repository.js +247 -0
- package/dist/storage/station-identity.js +21 -0
- package/dist/storage/station-schema.js +27 -0
- package/dist/storage/store.js +249 -76
- package/dist/ui/AdaptiveContent.js +18 -10
- package/dist/ui/App.js +708 -454
- package/dist/ui/AppContent.js +5 -5
- package/dist/ui/airplay-settings.js +12 -5
- package/dist/ui/bidi-ranges.js +45 -0
- package/dist/ui/components/AdaptiveMarquee.js +16 -9
- package/dist/ui/format.js +37 -4
- package/dist/ui/help-content.js +5 -2
- package/dist/ui/rtl-output.js +137 -0
- package/dist/ui/screen-items.js +10 -4
- package/dist/ui/screens/AirPlaySettingsScreen.js +3 -3
- package/dist/ui/screens/AlarmsScreen.js +5 -2
- package/dist/ui/screens/HelpScreen.js +3 -3
- package/dist/ui/screens/SettingsScreen.js +16 -16
- package/dist/ui/screens/StatsScreen.js +1 -1
- package/dist/ui/text-direction.js +9 -0
- package/dist/ui/use-alarm-tui.js +24 -24
- package/dist/ui/use-app-input.js +42 -8
- package/dist/ui/use-command-executor.js +21 -14
- package/dist/ui/visualizers/gallop.js +1 -1
- package/dist/ui/visualizers/receiver-style-registry.js +2 -1
- package/dist/update-check.js +2 -1
- package/docs/THIRD_PARTY_NOTICES.md +6 -4
- package/package.json +34 -11
- package/scripts/build-airplay-native.mjs +22 -0
- package/vendor/node-airtunes2/LICENSE +208 -0
- package/vendor/node-airtunes2/RADIOCLI_PATCH.md +29 -0
- package/vendor/node-airtunes2/README.md +225 -0
- package/vendor/node-airtunes2/UPSTREAM.json +50 -0
- package/vendor/node-airtunes2/binding.gyp +41 -0
- package/vendor/node-airtunes2/lib/ap2_test.js +4 -0
- package/vendor/node-airtunes2/lib/atv.js +307 -0
- package/vendor/node-airtunes2/lib/atvAuthenticator.js +136 -0
- package/vendor/node-airtunes2/lib/audio_out.js +69 -0
- package/vendor/node-airtunes2/lib/circular_buffer.js +133 -0
- package/vendor/node-airtunes2/lib/config.js +26 -0
- package/vendor/node-airtunes2/lib/device_airtunes.js +532 -0
- package/vendor/node-airtunes2/lib/devices.js +266 -0
- package/vendor/node-airtunes2/lib/ed25519.js +26 -0
- package/vendor/node-airtunes2/lib/homekit/credentials.js +86 -0
- package/vendor/node-airtunes2/lib/homekit/encryption.js +69 -0
- package/vendor/node-airtunes2/lib/homekit/number.js +44 -0
- package/vendor/node-airtunes2/lib/homekit/tlv.js +95 -0
- package/vendor/node-airtunes2/lib/http.js +189 -0
- package/vendor/node-airtunes2/lib/index.d.ts +141 -0
- package/vendor/node-airtunes2/lib/index.js +127 -0
- package/vendor/node-airtunes2/lib/ntp.js +37 -0
- package/vendor/node-airtunes2/lib/num_util.js +21 -0
- package/vendor/node-airtunes2/lib/packet_pool.js +43 -0
- package/vendor/node-airtunes2/lib/rtsp.js +1807 -0
- package/vendor/node-airtunes2/lib/srp.js +158 -0
- package/vendor/node-airtunes2/lib/udp_servers.js +214 -0
- package/vendor/node-airtunes2/lib/util.js +12 -0
- package/vendor/node-airtunes2/package.json +31 -0
- package/vendor/node-airtunes2/src/CADebugMacros.h +257 -0
- package/vendor/node-airtunes2/src/CAHostTimeBase.cpp +105 -0
- package/vendor/node-airtunes2/src/CAHostTimeBase.h +224 -0
- package/vendor/node-airtunes2/src/aes_utils.c +864 -0
- package/vendor/node-airtunes2/src/aes_utils.h +24 -0
- package/vendor/node-airtunes2/src/base64.c +136 -0
- package/vendor/node-airtunes2/src/base64.h +42 -0
- package/vendor/node-airtunes2/src/bindings.cc +29 -0
- package/vendor/node-airtunes2/src/codec.cc +170 -0
- package/vendor/node-airtunes2/src/coreaudio.cc +332 -0
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
const crypto = require('crypto')
|
|
2
|
+
const fs = require('fs')
|
|
3
|
+
const bplistCreator = require('bplist-creator')
|
|
4
|
+
const bplistParser = require('bplist-parser')
|
|
5
|
+
const SRP = require('./srp')
|
|
6
|
+
const ATVAuthenticator = require('./atvAuthenticator')
|
|
7
|
+
const httpClientFactory = require('./http')
|
|
8
|
+
|
|
9
|
+
// ...
|
|
10
|
+
// Configuration.
|
|
11
|
+
const loadConfig = configFilePath => !fs.existsSync(configFilePath) ? null : JSON.parse(fs.readFileSync(configFilePath))
|
|
12
|
+
const saveConfig = (configFilePath, config) => fs.writeFileSync(configFilePath, JSON.stringify(config, null, '\t'))
|
|
13
|
+
|
|
14
|
+
// ...
|
|
15
|
+
|
|
16
|
+
class ATV {
|
|
17
|
+
constructor(addr, port) {
|
|
18
|
+
this.addr = addr
|
|
19
|
+
this.port = port || 7000
|
|
20
|
+
|
|
21
|
+
this.httpClient = httpClientFactory()
|
|
22
|
+
this.auth_secret = null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ...
|
|
26
|
+
|
|
27
|
+
auth(configFilePath, authenticator) {
|
|
28
|
+
async function auth(owner) {
|
|
29
|
+
await owner.httpClient.connect(owner.addr, owner.port)
|
|
30
|
+
|
|
31
|
+
let conf = loadConfig(configFilePath)
|
|
32
|
+
if (!conf || !conf['auth_secret']) {
|
|
33
|
+
// a pairing does not exist and must be performed.
|
|
34
|
+
|
|
35
|
+
// ...
|
|
36
|
+
// SRP parameters.
|
|
37
|
+
const srp = new SRP(2048)
|
|
38
|
+
|
|
39
|
+
let I = '366B4165DD64AD3A'
|
|
40
|
+
let P
|
|
41
|
+
let s
|
|
42
|
+
let B
|
|
43
|
+
let a
|
|
44
|
+
let A
|
|
45
|
+
let M1
|
|
46
|
+
|
|
47
|
+
await owner.httpClient.request('POST', '/pair-pin-start')
|
|
48
|
+
.then(() => authenticator())
|
|
49
|
+
.then((pin) => {
|
|
50
|
+
P = pin
|
|
51
|
+
|
|
52
|
+
return owner.httpClient.request(
|
|
53
|
+
'POST',
|
|
54
|
+
'/pair-setup-pin',
|
|
55
|
+
{
|
|
56
|
+
'Content-Type': 'application/x-apple-binary-plist',
|
|
57
|
+
},
|
|
58
|
+
bplistCreator({
|
|
59
|
+
user: '366B4165DD64AD3A',
|
|
60
|
+
method: 'pin',
|
|
61
|
+
}),
|
|
62
|
+
)
|
|
63
|
+
})
|
|
64
|
+
.then((res) => {
|
|
65
|
+
const { pk, salt } = bplistParser.parseBuffer(res.body)[0]
|
|
66
|
+
|
|
67
|
+
s = salt.toString('hex')
|
|
68
|
+
B = pk.toString('hex')
|
|
69
|
+
|
|
70
|
+
// SRP: Generate random auth_secret, 'a'; if pairing is successful, it'll be utilized in
|
|
71
|
+
// subsequent session authentication(s).
|
|
72
|
+
a = crypto.randomBytes(32).toString('hex')
|
|
73
|
+
|
|
74
|
+
// SRP: Compute A and M1.
|
|
75
|
+
A = srp.A(a)
|
|
76
|
+
M1 = srp.M1(I, P, s, a, B)
|
|
77
|
+
|
|
78
|
+
return owner.httpClient.request(
|
|
79
|
+
'POST',
|
|
80
|
+
'/pair-setup-pin',
|
|
81
|
+
{
|
|
82
|
+
'Content-Type': 'application/x-apple-binary-plist',
|
|
83
|
+
},
|
|
84
|
+
bplistCreator({
|
|
85
|
+
pk: Buffer.from(A, 'hex'),
|
|
86
|
+
proof: Buffer.from(M1, 'hex'),
|
|
87
|
+
}),
|
|
88
|
+
)
|
|
89
|
+
})
|
|
90
|
+
.then(() => {
|
|
91
|
+
// confirm the auth secret (a).
|
|
92
|
+
const { epk, authTag } = ATVAuthenticator.confirm(a, srp.K(I, P, s, a, B))
|
|
93
|
+
|
|
94
|
+
// complete pair-setup-pin by registering the auth secret with the target device.
|
|
95
|
+
return owner.httpClient.request(
|
|
96
|
+
'POST',
|
|
97
|
+
'/pair-setup-pin',
|
|
98
|
+
{
|
|
99
|
+
'Content-Type': 'application/x-apple-binary-plist',
|
|
100
|
+
},
|
|
101
|
+
bplistCreator({
|
|
102
|
+
epk: Buffer.from(epk, 'hex'),
|
|
103
|
+
authTag: Buffer.from(authTag, 'hex'),
|
|
104
|
+
}),
|
|
105
|
+
)
|
|
106
|
+
})
|
|
107
|
+
.then(() => {
|
|
108
|
+
// save the auth secret for subsequent session authentication(s).
|
|
109
|
+
!conf && (conf = {})
|
|
110
|
+
conf['auth_secret'] = a
|
|
111
|
+
saveConfig(configFilePath, conf)
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ...
|
|
116
|
+
// Authenticate session with the target device using existing pairing information.
|
|
117
|
+
const verifier = ATVAuthenticator.verifier(conf['auth_secret'])
|
|
118
|
+
|
|
119
|
+
return owner.httpClient.request(
|
|
120
|
+
'POST',
|
|
121
|
+
'/pair-verify',
|
|
122
|
+
{
|
|
123
|
+
'Content-Type': 'application/octet-stream',
|
|
124
|
+
},
|
|
125
|
+
verifier.verifierBody,
|
|
126
|
+
)
|
|
127
|
+
.then((res) => {
|
|
128
|
+
const atv_pub = res.body.slice(0, 32).toString('hex')
|
|
129
|
+
const atv_data = res.body.slice(32).toString('hex')
|
|
130
|
+
|
|
131
|
+
const shared = ATVAuthenticator.shared(verifier.v_pri, atv_pub)
|
|
132
|
+
const signed = ATVAuthenticator.signed(conf['auth_secret'], verifier.v_pub, atv_pub)
|
|
133
|
+
const signature = Buffer.from(
|
|
134
|
+
Buffer.from([0x00, 0x00, 0x00, 0x00]).toString('hex')
|
|
135
|
+
+ ATVAuthenticator.signature(shared, atv_data, signed),
|
|
136
|
+
'hex',
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
return owner.httpClient.request(
|
|
140
|
+
'POST',
|
|
141
|
+
'/pair-verify',
|
|
142
|
+
{
|
|
143
|
+
'Content-Type': 'application/octet-stream',
|
|
144
|
+
},
|
|
145
|
+
signature,
|
|
146
|
+
)
|
|
147
|
+
})
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
return auth(this)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
authSecret() {
|
|
154
|
+
return this.auth_secret
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
authSimple(authenticator) {
|
|
158
|
+
async function auth(owner) {
|
|
159
|
+
await owner.httpClient.connect(owner.addr, owner.port)
|
|
160
|
+
|
|
161
|
+
let conf = null
|
|
162
|
+
if (!conf || !conf['auth_secret']) {
|
|
163
|
+
// a pairing does not exist and must be performed.
|
|
164
|
+
|
|
165
|
+
// ...
|
|
166
|
+
// SRP parameters.
|
|
167
|
+
const srp = new SRP(2048)
|
|
168
|
+
|
|
169
|
+
let I = '366B4165DD64AD3A'
|
|
170
|
+
let P
|
|
171
|
+
let s
|
|
172
|
+
let B
|
|
173
|
+
let a
|
|
174
|
+
let A
|
|
175
|
+
let M1
|
|
176
|
+
|
|
177
|
+
await owner.httpClient.request('POST', '/pair-pin-start')
|
|
178
|
+
.then(() => authenticator())
|
|
179
|
+
.then((pin) => {
|
|
180
|
+
P = pin
|
|
181
|
+
|
|
182
|
+
return owner.httpClient.request(
|
|
183
|
+
'POST',
|
|
184
|
+
'/pair-setup-pin',
|
|
185
|
+
{
|
|
186
|
+
'Content-Type': 'application/x-apple-binary-plist',
|
|
187
|
+
},
|
|
188
|
+
bplistCreator({
|
|
189
|
+
user: '366B4165DD64AD3A',
|
|
190
|
+
method: 'pin',
|
|
191
|
+
}),
|
|
192
|
+
)
|
|
193
|
+
})
|
|
194
|
+
.then((res) => {
|
|
195
|
+
const { pk, salt } = bplistParser.parseBuffer(res.body)[0]
|
|
196
|
+
|
|
197
|
+
s = salt.toString('hex')
|
|
198
|
+
B = pk.toString('hex')
|
|
199
|
+
|
|
200
|
+
// SRP: Generate random auth_secret, 'a'; if pairing is successful, it'll be utilized in
|
|
201
|
+
// subsequent session authentication(s).
|
|
202
|
+
a = crypto.randomBytes(32).toString('hex')
|
|
203
|
+
|
|
204
|
+
// SRP: Compute A and M1.
|
|
205
|
+
A = srp.A(a)
|
|
206
|
+
M1 = srp.M1(I, P, s, a, B)
|
|
207
|
+
|
|
208
|
+
return owner.httpClient.request(
|
|
209
|
+
'POST',
|
|
210
|
+
'/pair-setup-pin',
|
|
211
|
+
{
|
|
212
|
+
'Content-Type': 'application/x-apple-binary-plist',
|
|
213
|
+
},
|
|
214
|
+
bplistCreator({
|
|
215
|
+
pk: Buffer.from(A, 'hex'),
|
|
216
|
+
proof: Buffer.from(M1, 'hex'),
|
|
217
|
+
}),
|
|
218
|
+
)
|
|
219
|
+
}).then(() => {
|
|
220
|
+
// confirm the auth secret (a).
|
|
221
|
+
const { epk, authTag } = ATVAuthenticator.confirm(a, srp.K(I, P, s, a, B))
|
|
222
|
+
|
|
223
|
+
// complete pair-setup-pin by registering the auth secret with the target device.
|
|
224
|
+
return owner.httpClient.request(
|
|
225
|
+
'POST',
|
|
226
|
+
'/pair-setup-pin',
|
|
227
|
+
{
|
|
228
|
+
'Content-Type': 'application/x-apple-binary-plist',
|
|
229
|
+
},
|
|
230
|
+
bplistCreator({
|
|
231
|
+
epk: Buffer.from(epk, 'hex'),
|
|
232
|
+
authTag: Buffer.from(authTag, 'hex'),
|
|
233
|
+
}),
|
|
234
|
+
)
|
|
235
|
+
})
|
|
236
|
+
.then(() => {
|
|
237
|
+
// save the auth secret for subsequent session authentication(s).
|
|
238
|
+
console.log(a)
|
|
239
|
+
owner.auth_secret = a
|
|
240
|
+
})
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return auth(this)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
verifySimple(secret) {
|
|
247
|
+
// ...
|
|
248
|
+
// Authenticate session with the target device using existing pairing information.
|
|
249
|
+
const verifier = ATVAuthenticator.verifier(secret)
|
|
250
|
+
|
|
251
|
+
return owner.httpClient.request(
|
|
252
|
+
'POST',
|
|
253
|
+
'/pair-verify',
|
|
254
|
+
{
|
|
255
|
+
'Content-Type': 'application/octet-stream',
|
|
256
|
+
},
|
|
257
|
+
verifier.verifierBody,
|
|
258
|
+
)
|
|
259
|
+
.then((res) => {
|
|
260
|
+
const atv_pub = res.body.slice(0, 32).toString('hex')
|
|
261
|
+
const atv_data = res.body.slice(32).toString('hex')
|
|
262
|
+
|
|
263
|
+
const shared = ATVAuthenticator.shared(verifier.v_pri, atv_pub)
|
|
264
|
+
const signed = ATVAuthenticator.signed(secret, verifier.v_pub, atv_pub)
|
|
265
|
+
const signature = Buffer.from(
|
|
266
|
+
Buffer.from([0x00, 0x00, 0x00, 0x00]).toString('hex')
|
|
267
|
+
+ ATVAuthenticator.signature(shared, atv_data, signed),
|
|
268
|
+
'hex',
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
return owner.httpClient.request(
|
|
272
|
+
'POST',
|
|
273
|
+
'/pair-verify',
|
|
274
|
+
{
|
|
275
|
+
'Content-Type': 'application/octet-stream',
|
|
276
|
+
},
|
|
277
|
+
signature,
|
|
278
|
+
)
|
|
279
|
+
})
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
play(videoUrl) {
|
|
283
|
+
return this.httpClient.request(
|
|
284
|
+
'POST',
|
|
285
|
+
'/play',
|
|
286
|
+
{
|
|
287
|
+
'Content-Type': 'application/x-apple-binary-plist',
|
|
288
|
+
},
|
|
289
|
+
bplistCreator({
|
|
290
|
+
'Content-Location': videoUrl,
|
|
291
|
+
'Start-Location': 0,
|
|
292
|
+
}),
|
|
293
|
+
)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
stop() {
|
|
297
|
+
return this.httpClient.request('POST', '/stop')
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
close() {
|
|
301
|
+
this.httpClient.close()
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ...
|
|
306
|
+
|
|
307
|
+
module.exports = ATV
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// RadioCLI modification, 2026-10-16: replace elliptic with native Ed25519.
|
|
2
|
+
// Distributed under the accompanying AGPLv3; pairing formats remain unchanged.
|
|
3
|
+
const ed25519 = require('./ed25519')
|
|
4
|
+
const crypto = require('crypto')
|
|
5
|
+
const axlsign = require('axlsign')
|
|
6
|
+
const { hexString2ArrayBuffer, buf2hex } = require('./util')
|
|
7
|
+
|
|
8
|
+
// ...
|
|
9
|
+
// Note: All functions expect parameters to be hex strings.
|
|
10
|
+
|
|
11
|
+
function pair_setup_aes_key(K) {
|
|
12
|
+
return crypto.createHash('sha512')
|
|
13
|
+
.update('Pair-Setup-AES-Key')
|
|
14
|
+
.update(hexString2ArrayBuffer(K))
|
|
15
|
+
.digest('hex')
|
|
16
|
+
.substring(0, 32)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function pair_setup_aes_iv(K) {
|
|
20
|
+
let ab = crypto.createHash('sha512')
|
|
21
|
+
.update('Pair-Setup-AES-IV')
|
|
22
|
+
.update(hexString2ArrayBuffer(K))
|
|
23
|
+
.digest()
|
|
24
|
+
|
|
25
|
+
ab = ab.slice(0, 16)
|
|
26
|
+
ab[ab.length - 1] += 0x01
|
|
27
|
+
|
|
28
|
+
return buf2hex(ab)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function pair_verify_aes_key(shared) {
|
|
32
|
+
return buf2hex(
|
|
33
|
+
crypto.createHash('sha512')
|
|
34
|
+
.update('Pair-Verify-AES-Key')
|
|
35
|
+
.update(hexString2ArrayBuffer(shared))
|
|
36
|
+
.digest()
|
|
37
|
+
.slice(0, 16),
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function pair_verify_aes_iv(shared) {
|
|
42
|
+
return buf2hex(
|
|
43
|
+
crypto.createHash('sha512')
|
|
44
|
+
.update('Pair-Verify-AES-IV')
|
|
45
|
+
.update(hexString2ArrayBuffer(shared))
|
|
46
|
+
.digest()
|
|
47
|
+
.slice(0, 16),
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ...
|
|
52
|
+
// Public.
|
|
53
|
+
|
|
54
|
+
function a_pub(a) {
|
|
55
|
+
return ed25519.publicKey(a)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function confirm(a, K) {
|
|
59
|
+
const key = pair_setup_aes_key(K)
|
|
60
|
+
const iv = pair_setup_aes_iv(K)
|
|
61
|
+
|
|
62
|
+
const cipher = crypto.createCipheriv(
|
|
63
|
+
'aes-128-gcm',
|
|
64
|
+
hexString2ArrayBuffer(key),
|
|
65
|
+
hexString2ArrayBuffer(iv),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
let encrypted = cipher.update(hexString2ArrayBuffer(a_pub(a)), null, 'hex')
|
|
69
|
+
encrypted += cipher.final('hex')
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
epk: encrypted,
|
|
73
|
+
authTag: buf2hex(cipher.getAuthTag()),
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function verifier(a) {
|
|
78
|
+
const keyPair = axlsign.generateKeyPair(crypto.randomBytes(32))
|
|
79
|
+
const v_pri = buf2hex(keyPair.private)
|
|
80
|
+
const v_pub = buf2hex(keyPair.public)
|
|
81
|
+
|
|
82
|
+
const header = Buffer.from([0x01, 0x00, 0x00, 0x00])
|
|
83
|
+
const a_pub_buf = Buffer.from(a_pub(a), 'hex')
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
verifierBody: Buffer.concat(
|
|
87
|
+
[header, keyPair.public, a_pub_buf],
|
|
88
|
+
header.byteLength + keyPair.public.byteLength + a_pub_buf.byteLength,
|
|
89
|
+
),
|
|
90
|
+
v_pri,
|
|
91
|
+
v_pub,
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function shared(v_pri, atv_pub) {
|
|
96
|
+
return buf2hex(
|
|
97
|
+
axlsign.sharedKey(
|
|
98
|
+
hexString2ArrayBuffer(v_pri),
|
|
99
|
+
hexString2ArrayBuffer(atv_pub),
|
|
100
|
+
),
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function signed(a, v_pub, atv_pub) {
|
|
105
|
+
return ed25519.sign(a, v_pub + atv_pub)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function signature(shared, atv_data, signed) {
|
|
109
|
+
const cipher = crypto.createCipheriv(
|
|
110
|
+
'aes-128-ctr',
|
|
111
|
+
hexString2ArrayBuffer(pair_verify_aes_key(shared)),
|
|
112
|
+
hexString2ArrayBuffer(pair_verify_aes_iv(shared)),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
// discard the result of encrypting atv_data.
|
|
116
|
+
cipher.update(hexString2ArrayBuffer(atv_data))
|
|
117
|
+
|
|
118
|
+
let encrypted = cipher.update(Buffer.from(signed, 'hex'), null, 'hex')
|
|
119
|
+
encrypted += cipher.final('hex')
|
|
120
|
+
|
|
121
|
+
return encrypted
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
module.exports = {
|
|
125
|
+
pair_setup_aes_key,
|
|
126
|
+
pair_setup_aes_iv,
|
|
127
|
+
pair_verify_aes_key,
|
|
128
|
+
pair_verify_aes_iv,
|
|
129
|
+
|
|
130
|
+
a_pub,
|
|
131
|
+
confirm,
|
|
132
|
+
verifier,
|
|
133
|
+
shared,
|
|
134
|
+
signed,
|
|
135
|
+
signature,
|
|
136
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
const events = require('events')
|
|
2
|
+
const util = require('util')
|
|
3
|
+
const config = require('./config')
|
|
4
|
+
const nu = require('./num_util')
|
|
5
|
+
|
|
6
|
+
const SEQ_NUM_WRAP = Math.pow(2, 16)
|
|
7
|
+
|
|
8
|
+
function AudioOut() {
|
|
9
|
+
events.EventEmitter.call(this)
|
|
10
|
+
|
|
11
|
+
this.lastSeq = -1
|
|
12
|
+
this.hasAirTunes = false
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
util.inherits(AudioOut, events.EventEmitter)
|
|
16
|
+
|
|
17
|
+
AudioOut.prototype.init = function (devices, circularBuffer) {
|
|
18
|
+
var self = this
|
|
19
|
+
config.rtp_time_ref = new Date().getTime()
|
|
20
|
+
|
|
21
|
+
devices.on('airtunes_devices', function (hasAirTunes) {
|
|
22
|
+
self.hasAirTunes = hasAirTunes
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
devices.on('need_sync', function () {
|
|
26
|
+
// a sync is forced when a new remote device is added
|
|
27
|
+
self.emit('need_sync', self.lastSeq)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
function sendPacket(seq) {
|
|
31
|
+
var packet = circularBuffer.readPacket()
|
|
32
|
+
|
|
33
|
+
packet.seq = seq % SEQ_NUM_WRAP
|
|
34
|
+
packet.timestamp = nu.low32(seq * config.frames_per_packet + 2 * config.sampling_rate)
|
|
35
|
+
|
|
36
|
+
if (self.hasAirTunes && seq % config.sync_period == 0)
|
|
37
|
+
self.emit('need_sync', seq)
|
|
38
|
+
|
|
39
|
+
self.emit('packet', packet)
|
|
40
|
+
packet.release()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function syncAudio() {
|
|
44
|
+
/*
|
|
45
|
+
* Each time syncAudio() runs, a burst of packet is sent.
|
|
46
|
+
* Increasing config.stream_latency lowers CPU usage but increases the size of the burst.
|
|
47
|
+
* If the burst size exceeds the UDP windows size (which we do not know), packets are lost.
|
|
48
|
+
*/
|
|
49
|
+
var elapsed = new Date().getTime() - config.rtp_time_ref
|
|
50
|
+
|
|
51
|
+
/*
|
|
52
|
+
* currentSeq is the # of the packet we should be sending now. We have some packets to catch-up
|
|
53
|
+
* since syncAudio is not always running.
|
|
54
|
+
*/
|
|
55
|
+
var currentSeq = Math.floor(elapsed * config.sampling_rate / (config.frames_per_packet * 1000))
|
|
56
|
+
|
|
57
|
+
for (let i = self.lastSeq + 1; i <= currentSeq; i++)
|
|
58
|
+
sendPacket(i)
|
|
59
|
+
|
|
60
|
+
self.lastSeq = currentSeq
|
|
61
|
+
|
|
62
|
+
// reschedule ourselves later
|
|
63
|
+
setTimeout(syncAudio, config.stream_latency)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
syncAudio()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = AudioOut
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
const Stream = require('stream')
|
|
2
|
+
const util = require('util')
|
|
3
|
+
const PacketPool = require('./packet_pool')
|
|
4
|
+
|
|
5
|
+
const WAITING = 0
|
|
6
|
+
const FILLING = 1
|
|
7
|
+
const NORMAL = 2
|
|
8
|
+
const DRAINING = 3
|
|
9
|
+
const ENDING = 4
|
|
10
|
+
const ENDED = 5
|
|
11
|
+
|
|
12
|
+
function CircularBuffer(packetsInBuffer, packetSize) {
|
|
13
|
+
Stream.call(this)
|
|
14
|
+
|
|
15
|
+
this.packetPool = new PacketPool()
|
|
16
|
+
this.maxSize = packetsInBuffer * packetSize
|
|
17
|
+
this.packetSize = packetSize
|
|
18
|
+
this.writable = true
|
|
19
|
+
this.muted = false
|
|
20
|
+
this.buffers = []
|
|
21
|
+
this.currentSize = 0
|
|
22
|
+
this.status = WAITING
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
util.inherits(CircularBuffer, Stream)
|
|
26
|
+
|
|
27
|
+
CircularBuffer.prototype.write = function (chunk) {
|
|
28
|
+
this.buffers.push(chunk)
|
|
29
|
+
this.currentSize += chunk.length
|
|
30
|
+
|
|
31
|
+
if (this.status === ENDING || this.status === ENDED)
|
|
32
|
+
throw new Error('Cannot write in buffer after closing it')
|
|
33
|
+
|
|
34
|
+
if (this.status === WAITING) {
|
|
35
|
+
// notify when we receive the first chunk
|
|
36
|
+
this.emit('status', 'buffering')
|
|
37
|
+
this.status = FILLING
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (this.status === FILLING && this.currentSize > this.maxSize / 2) {
|
|
41
|
+
this.status = NORMAL
|
|
42
|
+
this.emit('status', 'playing')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (this.currentSize >= this.maxSize) {
|
|
46
|
+
this.status = DRAINING
|
|
47
|
+
return false
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
return true
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
CircularBuffer.prototype.readPacket = function () {
|
|
55
|
+
var packet = this.packetPool.getPacket()
|
|
56
|
+
|
|
57
|
+
// play silence until buffer is filled enough
|
|
58
|
+
if (this.status !== ENDING && this.status !== ENDED
|
|
59
|
+
&& (this.status === FILLING || this.currentSize < this.packetSize)) {
|
|
60
|
+
packet.pcm.fill(0)
|
|
61
|
+
|
|
62
|
+
if (this.status !== FILLING && this.status !== WAITING) {
|
|
63
|
+
this.status = FILLING
|
|
64
|
+
this.emit('status', 'buffering')
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
var offset = 0, remaining = this.packetSize
|
|
69
|
+
|
|
70
|
+
// fill a whole packet with data
|
|
71
|
+
while (remaining > 0) {
|
|
72
|
+
// pad packet with silence if buffer is empty
|
|
73
|
+
if (this.buffers.length === 0) {
|
|
74
|
+
packet.pcm.fill(0, offset)
|
|
75
|
+
remaining = 0
|
|
76
|
+
break
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
var first = this.buffers[0]
|
|
80
|
+
|
|
81
|
+
if (first.length <= remaining) {
|
|
82
|
+
// pop the whole buffer from the queue
|
|
83
|
+
first.copy(packet.pcm, offset)
|
|
84
|
+
offset += first.length
|
|
85
|
+
remaining -= first.length
|
|
86
|
+
this.buffers.shift()
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
// first buffer contains enough data to fill a packet: slice it
|
|
90
|
+
first.copy(packet.pcm, offset, 0, remaining)
|
|
91
|
+
this.buffers[0] = first.slice(remaining)
|
|
92
|
+
offset += remaining
|
|
93
|
+
remaining = 0
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
this.currentSize -= this.packetSize
|
|
98
|
+
|
|
99
|
+
// emit 'end' only once
|
|
100
|
+
if (this.status === ENDING && this.currentSize <= 0) {
|
|
101
|
+
this.status = ENDED
|
|
102
|
+
this.currentSize = 0
|
|
103
|
+
this.emit('status', 'end')
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// notify that the buffer now has enough room if needed
|
|
107
|
+
if (this.status === DRAINING && this.currentSize < this.maxSize / 2) {
|
|
108
|
+
this.status = NORMAL
|
|
109
|
+
this.emit('drain')
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (this.muted)
|
|
114
|
+
packet.pcm.fill(0)
|
|
115
|
+
|
|
116
|
+
return packet
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
CircularBuffer.prototype.end = function () {
|
|
120
|
+
// flush the buffer if it was filling
|
|
121
|
+
if (this.status === FILLING)
|
|
122
|
+
this.emit('status', 'playing')
|
|
123
|
+
|
|
124
|
+
this.status = ENDING
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
CircularBuffer.prototype.reset = function () {
|
|
128
|
+
this.buffers = []
|
|
129
|
+
this.currentSize = 0
|
|
130
|
+
this.status = WAITING
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
module.exports = CircularBuffer
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
const nu = require('./num_util')
|
|
2
|
+
|
|
3
|
+
const Config = {
|
|
4
|
+
user_agent: 'iTunes/11.3.1 (Windows; Microsoft Windows 10 x64 (Build 19044); x64) (dt:2)',
|
|
5
|
+
udp_default_port: 54621, // preferred starting port in AirTunes v2
|
|
6
|
+
frames_per_packet: 352, // samples per frames in ALAC packets
|
|
7
|
+
channels_per_frame: 2, // always stereo in AirTunes v2
|
|
8
|
+
bits_per_channel: 16, // -> 2 bytes per channel
|
|
9
|
+
packet_size: 352 * 2 * 2, // frames*channels*bytes
|
|
10
|
+
packets_in_buffer: 200, // increase this buffer protects against network issues
|
|
11
|
+
coreaudio_min_level: 5, // if CoreAudio's internal buffer drops too much, inject some silence to raise it
|
|
12
|
+
coreaudio_check_period: 2000, // CoreAudio buffer level check period
|
|
13
|
+
coreaudio_preload: 352 * 2 * 2 * 50, // ~0.5s of silence pushed to CoreAudio to avoid draining AudioQueue
|
|
14
|
+
sampling_rate: 44100, // fixed by AirTunes v2
|
|
15
|
+
sync_period: 126, // UDP sync packets are sent to all AirTunes devices regularly
|
|
16
|
+
stream_latency: 100, // audio UDP packets are flushed in bursts periodically
|
|
17
|
+
rtsp_timeout: 2147483647, // RTSP servers are considered gone if no reply is received before the timeout
|
|
18
|
+
rtsp_heartbeat: 15000, // some RTSP (like HomePod) servers requires heartbeat. Recommanded value: 60000 (1 minute). Set to 0 if you want to disable it.
|
|
19
|
+
rtp_time_ref: 0,
|
|
20
|
+
device_magic: nu.randomInt(9),
|
|
21
|
+
ntp_epoch: 0x83aa7e80,
|
|
22
|
+
iv_base64: 'ePRBLI0XN5ArFaaz7ncNZw',
|
|
23
|
+
rsa_aeskey_base64: 'VjVbxWcmYgbBbhwBNlCh3K0CMNtWoB844BuiHGUJT51zQS7SDpMnlbBIobsKbfEJ3SCgWHRXjYWf7VQWRYtEcfx7ejA8xDIk5PSBYTvXP5dU2QoGrSBv0leDS6uxlEWuxBq3lIxCxpWO2YswHYKJBt06Uz9P2Fq2hDUwl3qOQ8oXb0OateTKtfXEwHJMprkhsJsGDrIc5W5NJFMAo6zCiM9bGSDeH2nvTlyW6bfI/Q0v0cDGUNeY3ut6fsoafRkfpCwYId+bg3diJh+uzw5htHDyZ2sN+BFYHzEfo8iv4KDxzeya9llqg6fRNQ8d5YjpvTnoeEQ9ye9ivjkBjcAfVw',
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
module.exports = Config
|