buzz-me-in 2.1.1 → 2.2.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +9 -0
- package/README.md +13 -9
- package/homekit.js +62 -27
- package/package.json +2 -1
- package/server.js +111 -41
- package/src/core/cli.js +5 -2
- package/src/core/config.js +11 -0
- package/src/core/stream-auth.js +57 -0
- package/src/core/types.js +12 -0
package/.env.example
CHANGED
|
@@ -18,6 +18,15 @@ TUNNEL_HOSTNAME=intercom.yourdomain.com
|
|
|
18
18
|
# Set this if your externally visible webhook URL differs.
|
|
19
19
|
#TWILIO_WEBHOOK_BASE_URL=https://intercom.yourdomain.com
|
|
20
20
|
|
|
21
|
+
# Twilio also signs the /media WebSocket handshake, so it can be verified before
|
|
22
|
+
# the socket is accepted. Defence in depth only: the signature is an HMAC over a
|
|
23
|
+
# constant URL, so it never varies between calls and cannot replace the one-time
|
|
24
|
+
# stream token. Modes: off | log | enforce.
|
|
25
|
+
# Start on `log`, confirm real calls emit media-ws-handshake-verified, then
|
|
26
|
+
# switch to `enforce`. A mismatch usually means Twilio signed a different URL
|
|
27
|
+
# variant than expected (see signedUrl in the log line).
|
|
28
|
+
TWILIO_MEDIA_SIGNATURE_MODE=log
|
|
29
|
+
|
|
21
30
|
# HMAC secret used to sign one-time WebSocket stream tokens embedded in TwiML.
|
|
22
31
|
# Must be at least 32 chars.
|
|
23
32
|
STREAM_AUTH_SECRET=replace_with_a_long_random_secret_min_32_chars
|
package/README.md
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
Apartment intercom to Apple HomeKit, bridged over Twilio.
|
|
4
4
|
|
|
5
|
+
[](https://github.com/atdr/buzz-me-in/actions/workflows/ci.yml)
|
|
6
|
+
[](https://codecov.io/gh/atdr/buzz-me-in)
|
|
7
|
+
|
|
5
8
|
A Raspberry Pi server that bridges an apartment intercom system into Apple HomeKit. When someone presses the intercom buzzer, your iPhone/HomePod receives a doorbell notification with a live camera tile. You can hear the caller, speak back, and unlock the door — all from the Home app or via Siri.
|
|
6
9
|
|
|
7
10
|
## Project docs
|
|
@@ -55,14 +58,14 @@ RPi: server.js (HTTP + WebSocket on one port)
|
|
|
55
58
|
|
|
56
59
|
All endpoints are reachable from the public internet through the tunnel; each enforces its own auth:
|
|
57
60
|
|
|
58
|
-
| Endpoint | Method | Auth
|
|
59
|
-
| ----------- | --------- |
|
|
60
|
-
| `/twiml` | POST | Twilio signature (`X-Twilio-Signature`)
|
|
61
|
-
| `/media` | WebSocket |
|
|
62
|
-
| `/status` | GET | Bearer token (`STATUS_API_TOKEN`)
|
|
63
|
-
| `/healthz` | GET | None (by design)
|
|
64
|
-
| `/readyz` | GET | None (by design)
|
|
65
|
-
| `/ringtone` | GET | None
|
|
61
|
+
| Endpoint | Method | Auth | Purpose |
|
|
62
|
+
| ----------- | --------- | -------------------------------------------------------------------------------------- | -------------------------------------- |
|
|
63
|
+
| `/twiml` | POST | Twilio signature (`X-Twilio-Signature`) | Returns TwiML for incoming calls |
|
|
64
|
+
| `/media` | WebSocket | Twilio signature on the handshake, then a one-time HMAC stream token (issued in TwiML) | Bidirectional call audio |
|
|
65
|
+
| `/status` | GET | Bearer token (`STATUS_API_TOKEN`) | Active call info |
|
|
66
|
+
| `/healthz` | GET | None (by design) | Liveness probe, returns `{"ok":true}` |
|
|
67
|
+
| `/readyz` | GET | None (by design) | Readiness probe, returns `{"ok":true}` |
|
|
68
|
+
| `/ringtone` | GET | None | Ring tone WAV for debug/manual checks |
|
|
66
69
|
|
|
67
70
|
The health endpoints are deliberately unauthenticated so systemd checks, uptime monitors, and the test sequence below can probe them; they expose nothing beyond `{"ok":true}`.
|
|
68
71
|
|
|
@@ -183,7 +186,8 @@ In the [Twilio Console](https://console.twilio.com/us1/develop/phone-numbers/man
|
|
|
183
186
|
Security hardening notes:
|
|
184
187
|
|
|
185
188
|
- `/twiml` enforces `X-Twilio-Signature` verification with your `TWILIO_AUTH_TOKEN`.
|
|
186
|
-
- TwiML embeds a short-lived, one-time signed token in the
|
|
189
|
+
- TwiML embeds a short-lived, one-time signed token as a `<Parameter>`, which Twilio sends in the WebSocket `start` event. Connections are rejected unless this token validates.
|
|
190
|
+
- `/media` additionally verifies `X-Twilio-Signature` on the WebSocket handshake, before the socket is accepted, so unauthenticated connections cost the Pi nothing. This is defence in depth: the handshake carries no body or query string, so its signature is an HMAC over a constant URL and is the same on every call. Only the one-time token is single-use. Controlled by `TWILIO_MEDIA_SIGNATURE_MODE` (`off` | `log` | `enforce`, default `log`); see `.env.example`.
|
|
187
191
|
- If your externally visible webhook base URL differs from `https://{TUNNEL_HOSTNAME}`, set `TWILIO_WEBHOOK_BASE_URL` explicitly in `.env`.
|
|
188
192
|
|
|
189
193
|
### 5. Pair with HomeKit
|
package/homekit.js
CHANGED
|
@@ -673,37 +673,68 @@ const cameraController = new CameraController({
|
|
|
673
673
|
|
|
674
674
|
accessory.configureController(cameraController);
|
|
675
675
|
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
port: config.hapPort,
|
|
680
|
-
category: Categories.VIDEO_DOORBELL,
|
|
681
|
-
advertiser: hap.MDNSAdvertiser.AVAHI,
|
|
682
|
-
});
|
|
676
|
+
// ---------------------------------------------------------------------------
|
|
677
|
+
// Startup
|
|
678
|
+
// ---------------------------------------------------------------------------
|
|
683
679
|
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
680
|
+
let started = false;
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* Put the accessory on the network and prepare the snapshot.
|
|
684
|
+
*
|
|
685
|
+
* Everything above this is in-memory construction; every side effect that
|
|
686
|
+
* reaches outside the process lives here. That split is deliberate: it lets
|
|
687
|
+
* server.js, a test, or the coverage run require this module without binding
|
|
688
|
+
* `HAP_PORT`, advertising a second accessory over Avahi alongside a live
|
|
689
|
+
* service, or spawning ffmpeg. server.js calls this from main().
|
|
690
|
+
*
|
|
691
|
+
* @returns {void}
|
|
692
|
+
*/
|
|
693
|
+
function start() {
|
|
694
|
+
if (started) {
|
|
695
|
+
logger.warn('Accessory start requested twice; ignoring', {
|
|
696
|
+
event: 'accessory-start-repeated',
|
|
697
|
+
reason: 'already-started',
|
|
698
|
+
});
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
started = true;
|
|
702
|
+
|
|
703
|
+
accessory.publish({
|
|
704
|
+
username: config.hapUsername,
|
|
705
|
+
pincode: config.hapPincode,
|
|
706
|
+
port: config.hapPort,
|
|
707
|
+
category: Categories.VIDEO_DOORBELL,
|
|
708
|
+
advertiser: hap.MDNSAdvertiser.AVAHI,
|
|
692
709
|
});
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
event: 'accessory-qr-suppressed',
|
|
697
|
-
reason: 'stdout-not-tty',
|
|
710
|
+
|
|
711
|
+
logger.info('Accessory published', {
|
|
712
|
+
event: 'accessory-published',
|
|
698
713
|
});
|
|
699
|
-
|
|
714
|
+
// The setup QR encodes the pairing pincode, so only print it on an
|
|
715
|
+
// interactive terminal — never into journald/log files.
|
|
716
|
+
if (process.stdout.isTTY) {
|
|
717
|
+
logger.info('Accessory QR setup URI generated', {
|
|
718
|
+
event: 'accessory-qr-setup',
|
|
719
|
+
});
|
|
720
|
+
qrcode.generate(accessory.setupURI(), { small: true });
|
|
721
|
+
} else {
|
|
722
|
+
logger.info(
|
|
723
|
+
'Pairing QR suppressed on non-interactive stdout; reprint it with buzz-me-in --qr',
|
|
724
|
+
{
|
|
725
|
+
event: 'accessory-qr-suppressed',
|
|
726
|
+
reason: 'stdout-not-tty',
|
|
727
|
+
}
|
|
728
|
+
);
|
|
729
|
+
}
|
|
700
730
|
|
|
701
|
-
// Kick off snapshot generation asynchronously (non-blocking)
|
|
702
|
-
initSnapshot().then(() =>
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
);
|
|
731
|
+
// Kick off snapshot generation asynchronously (non-blocking)
|
|
732
|
+
initSnapshot().then(() =>
|
|
733
|
+
logger.info('Snapshot initialized', {
|
|
734
|
+
event: 'snapshot-ready',
|
|
735
|
+
})
|
|
736
|
+
);
|
|
737
|
+
}
|
|
707
738
|
|
|
708
739
|
// ---------------------------------------------------------------------------
|
|
709
740
|
// Exports called by server.js
|
|
@@ -778,6 +809,9 @@ function setOnHapSessionStarted(handler) {
|
|
|
778
809
|
function shutdown() {
|
|
779
810
|
endHapSession();
|
|
780
811
|
removeSdpDir();
|
|
812
|
+
// Nothing to tear down if start() never ran: unpublish() on an accessory
|
|
813
|
+
// that was never published has no HAP server or advertiser to stop.
|
|
814
|
+
if (!started) return;
|
|
781
815
|
accessory.unpublish().catch((error) => {
|
|
782
816
|
logger.error('Failed to unpublish HAP accessory during shutdown', {
|
|
783
817
|
event: 'accessory-unpublish-failed',
|
|
@@ -788,6 +822,7 @@ function shutdown() {
|
|
|
788
822
|
}
|
|
789
823
|
|
|
790
824
|
module.exports = {
|
|
825
|
+
start,
|
|
791
826
|
triggerDoorbell,
|
|
792
827
|
setMulawPassthrough,
|
|
793
828
|
clearMulawPassthrough,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "buzz-me-in",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0-beta.0",
|
|
4
4
|
"description": "HomeKit-compatible apartment intercom via Twilio media streams",
|
|
5
5
|
"main": "server.js",
|
|
6
6
|
"bin": {
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"check": "node --check server.js homekit.js twilio-api.js src/core/*.js",
|
|
23
23
|
"test": "DOTENV_CONFIG_PATH=/dev/null node --test",
|
|
24
24
|
"typecheck": "tsc --noEmit",
|
|
25
|
+
"coverage": "DOTENV_CONFIG_PATH=/dev/null node --test --experimental-test-coverage --test-coverage-include='src/**' --test-coverage-include='server.js' --test-coverage-include='homekit.js' --test-coverage-include='twilio-api.js' --require ./tests/helpers/coverage-preload.cjs --test-reporter=lcov --test-reporter-destination=lcov.info --test-reporter=spec --test-reporter-destination=stdout",
|
|
25
26
|
"prepare": "husky || true"
|
|
26
27
|
},
|
|
27
28
|
"license": "MIT",
|
package/server.js
CHANGED
|
@@ -3,23 +3,29 @@
|
|
|
3
3
|
'use strict';
|
|
4
4
|
|
|
5
5
|
// CLI flags are handled before every other require, and moving this down breaks
|
|
6
|
-
// them: requiring ./
|
|
7
|
-
//
|
|
8
|
-
// and requiring ./
|
|
9
|
-
//
|
|
10
|
-
//
|
|
6
|
+
// them: requiring ./src/core/config throws on any missing env var, which under
|
|
7
|
+
// systemd lives in EnvironmentFile and so is absent from an interactive shell,
|
|
8
|
+
// and requiring ./homekit pulls in that same config. A read-only query must
|
|
9
|
+
// touch neither. tests/cli.test.cjs pins this ordering.
|
|
10
|
+
//
|
|
11
|
+
// The require.main guard keeps process.argv out of it when this file is loaded
|
|
12
|
+
// as a library rather than run: a test or the coverage run would otherwise have
|
|
13
|
+
// the test runner's own argv parsed as intercom flags. Everything below module
|
|
14
|
+
// scope is likewise inert until main() runs.
|
|
11
15
|
//
|
|
12
16
|
// Writes are synchronous because process.exit() can truncate a pending async
|
|
13
17
|
// write to a pipe.
|
|
14
|
-
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
18
|
+
if (require.main === module) {
|
|
19
|
+
const cliFs = require('fs');
|
|
20
|
+
const cliExit = require('./src/core/cli').run({
|
|
21
|
+
argv: process.argv.slice(2),
|
|
22
|
+
write: (text) => cliFs.writeSync(1, text),
|
|
23
|
+
writeErr: (text) => cliFs.writeSync(2, text),
|
|
24
|
+
isTTY: Boolean(process.stdout.isTTY),
|
|
25
|
+
cwd: process.cwd(),
|
|
26
|
+
});
|
|
27
|
+
if (cliExit !== null) process.exit(cliExit);
|
|
28
|
+
}
|
|
23
29
|
|
|
24
30
|
const http = require('http');
|
|
25
31
|
const twilio = require('twilio');
|
|
@@ -32,6 +38,7 @@ const {
|
|
|
32
38
|
buildConnectStreamTwiml,
|
|
33
39
|
STREAM_TOKEN_PARAMETER_NAME,
|
|
34
40
|
verifyAndConsumeStreamToken,
|
|
41
|
+
verifyStreamHandshakeSignature,
|
|
35
42
|
} = require('./src/core/stream-auth');
|
|
36
43
|
const { normalizeCallSid } = require('./src/core/twilio-ids');
|
|
37
44
|
const homekit = require('./homekit');
|
|
@@ -39,12 +46,14 @@ const { createLogger } = require('./src/core/log');
|
|
|
39
46
|
const { safeEqualString } = require('./src/core/safe-equal');
|
|
40
47
|
const { MediaStream } = require('./src/core/media-stream');
|
|
41
48
|
/** @import { request as WebSocketRequest } from 'websocket' */
|
|
49
|
+
/** @import { HandshakeVerificationError } from './src/core/types' */
|
|
42
50
|
|
|
43
51
|
const HTTP_SERVER_PORT = config.port;
|
|
44
52
|
const STREAM_PATH = '/media';
|
|
45
53
|
const STREAM_START_TIMEOUT_MS = config.streamStartTimeoutMs;
|
|
46
54
|
const STATUS_BEARER_PREFIX = 'Bearer ';
|
|
47
55
|
const MAX_WS_UTF8_BYTES = config.wsMaxMessageBytes;
|
|
56
|
+
const MEDIA_SIGNATURE_MODE = config.twilioMediaSignatureMode;
|
|
48
57
|
const SHUTDOWN_GRACE_MS = config.shutdownGraceMs;
|
|
49
58
|
let shuttingDown = false;
|
|
50
59
|
const activeWsConnections = new Set();
|
|
@@ -256,6 +265,36 @@ mediaws.on('request', function (request) {
|
|
|
256
265
|
wsRequest.reject(404, 'Not found');
|
|
257
266
|
return;
|
|
258
267
|
}
|
|
268
|
+
// Twilio signs the Media Streams handshake, so an unauthenticated socket can
|
|
269
|
+
// be refused here rather than after accept(). Without this the server accepts
|
|
270
|
+
// the connection and allocates a MediaStream before any auth runs, because
|
|
271
|
+
// the stream token only arrives later in the `start` frame. Defence in depth:
|
|
272
|
+
// the one-time token below remains the authority, since this signature is an
|
|
273
|
+
// HMAC over a constant URL and so never varies between calls.
|
|
274
|
+
if (MEDIA_SIGNATURE_MODE !== 'off') {
|
|
275
|
+
const handshake = verifyStreamHandshakeSignature(
|
|
276
|
+
wsRequest.httpRequest.headers['x-twilio-signature']
|
|
277
|
+
);
|
|
278
|
+
if (!handshake.ok) {
|
|
279
|
+
// strict:false disables discriminated-union narrowing; cast as elsewhere.
|
|
280
|
+
const handshakeError = /** @type {HandshakeVerificationError} */ (handshake);
|
|
281
|
+
mediaWsLogger.warn('Media websocket handshake signature rejected', {
|
|
282
|
+
event: 'media-ws-rejected',
|
|
283
|
+
reason: handshakeError.reason,
|
|
284
|
+
enforced: MEDIA_SIGNATURE_MODE === 'enforce',
|
|
285
|
+
});
|
|
286
|
+
if (MEDIA_SIGNATURE_MODE === 'enforce') {
|
|
287
|
+
wsRequest.reject(403, 'Forbidden');
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
} else {
|
|
291
|
+
mediaWsLogger.debug('Media websocket handshake signature verified', {
|
|
292
|
+
event: 'media-ws-handshake-verified',
|
|
293
|
+
signedUrl: handshake.signedUrl,
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
259
298
|
// Memory backstop only: cap total sockets so a connection flood can't grow
|
|
260
299
|
// unbounded. Only one Twilio call is ever authenticated at a time, and each
|
|
261
300
|
// un-started socket is dropped after config.streamStartTimeoutMs, so this is
|
|
@@ -369,12 +408,53 @@ function isAuthorizedForStatus(req) {
|
|
|
369
408
|
// Start server
|
|
370
409
|
// ---------------------------------------------------------------------------
|
|
371
410
|
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
411
|
+
/**
|
|
412
|
+
* Every side effect that reaches outside the process lives here: publishing the
|
|
413
|
+
* HomeKit accessory, binding the HTTP/WebSocket port, and installing the
|
|
414
|
+
* process-level signal and crash handlers. Module scope above only builds
|
|
415
|
+
* objects and registers in-process callbacks, so requiring this file from a
|
|
416
|
+
* test or the coverage run advertises nothing, binds nothing, and cannot
|
|
417
|
+
* install an uncaughtException handler that would swallow a test failure.
|
|
418
|
+
*
|
|
419
|
+
* homekit.start() comes first so the accessory is on the network before the
|
|
420
|
+
* port accepts a Twilio stream, which is the order the module-scope version
|
|
421
|
+
* produced and the order README's startup log example shows.
|
|
422
|
+
*
|
|
423
|
+
* @returns {void}
|
|
424
|
+
*/
|
|
425
|
+
function main() {
|
|
426
|
+
homekit.start();
|
|
427
|
+
|
|
428
|
+
wsserver.listen(HTTP_SERVER_PORT, () => {
|
|
429
|
+
logger.info('Server listening', {
|
|
430
|
+
event: 'server-start',
|
|
431
|
+
port: HTTP_SERVER_PORT,
|
|
432
|
+
});
|
|
376
433
|
});
|
|
377
|
-
|
|
434
|
+
|
|
435
|
+
process.on('SIGINT', () => beginShutdown('SIGINT'));
|
|
436
|
+
process.on('SIGTERM', () => beginShutdown('SIGTERM'));
|
|
437
|
+
|
|
438
|
+
// Exit on unexpected errors instead of continuing in an undefined state;
|
|
439
|
+
// systemd restarts the service.
|
|
440
|
+
process.on('uncaughtException', (err) => {
|
|
441
|
+
logger.error('Uncaught exception; exiting', {
|
|
442
|
+
event: 'uncaught-exception',
|
|
443
|
+
reason: 'uncaught-exception',
|
|
444
|
+
error: err,
|
|
445
|
+
});
|
|
446
|
+
process.exit(1);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
process.on('unhandledRejection', (reason) => {
|
|
450
|
+
logger.error('Unhandled promise rejection; exiting', {
|
|
451
|
+
event: 'unhandled-rejection',
|
|
452
|
+
reason: 'unhandled-rejection',
|
|
453
|
+
error: reason instanceof Error ? reason : new Error(String(reason)),
|
|
454
|
+
});
|
|
455
|
+
process.exit(1);
|
|
456
|
+
});
|
|
457
|
+
}
|
|
378
458
|
|
|
379
459
|
function beginShutdown(signal) {
|
|
380
460
|
if (shuttingDown) return;
|
|
@@ -415,25 +495,15 @@ function beginShutdown(signal) {
|
|
|
415
495
|
});
|
|
416
496
|
}
|
|
417
497
|
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
});
|
|
431
|
-
|
|
432
|
-
process.on('unhandledRejection', (reason) => {
|
|
433
|
-
logger.error('Unhandled promise rejection; exiting', {
|
|
434
|
-
event: 'unhandled-rejection',
|
|
435
|
-
reason: 'unhandled-rejection',
|
|
436
|
-
error: reason instanceof Error ? reason : new Error(String(reason)),
|
|
437
|
-
});
|
|
438
|
-
process.exit(1);
|
|
439
|
-
});
|
|
498
|
+
if (require.main === module) main();
|
|
499
|
+
|
|
500
|
+
module.exports = {
|
|
501
|
+
main,
|
|
502
|
+
beginShutdown,
|
|
503
|
+
buildTwiml,
|
|
504
|
+
handleRequest,
|
|
505
|
+
isAuthorizedForStatus,
|
|
506
|
+
isValidTwilioRequest,
|
|
507
|
+
parseFormUrlEncoded,
|
|
508
|
+
readRequestBody,
|
|
509
|
+
};
|
package/src/core/cli.js
CHANGED
|
@@ -10,8 +10,11 @@
|
|
|
10
10
|
//
|
|
11
11
|
// Nothing here starts a server, contacts HomeKit, or signals a running
|
|
12
12
|
// instance. run() is called from server.js BEFORE any other require, because
|
|
13
|
-
// requiring ./
|
|
14
|
-
//
|
|
13
|
+
// requiring ./src/core/config throws on a missing env var and ./homekit pulls
|
|
14
|
+
// in that same config. Neither entry point acts on being required any more —
|
|
15
|
+
// server.js starts from main() and homekit.js publishes from start() — so a
|
|
16
|
+
// --qr run that reached them would no longer advertise a second accessory, but
|
|
17
|
+
// it would still die on the missing env.
|
|
15
18
|
|
|
16
19
|
const fs = require('fs');
|
|
17
20
|
const path = require('path');
|
package/src/core/config.js
CHANGED
|
@@ -86,6 +86,16 @@ if (!/^[0-9A-Da-d*#wW]+$/.test(twilioUnlockDigits)) {
|
|
|
86
86
|
fail('TWILIO_UNLOCK_DIGITS may only contain DTMF digits 0-9, A-D, *, #, w, and W');
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
// Modes for the /media handshake signature check. Defaults to 'log' rather
|
|
90
|
+
// than 'enforce': Twilio's handshake signing has documented quirks (wss://
|
|
91
|
+
// scheme, optional trailing slash), so a deployment must confirm real calls
|
|
92
|
+
// verify before a mismatch is allowed to reject them.
|
|
93
|
+
const MEDIA_SIGNATURE_MODES = ['off', 'log', 'enforce'];
|
|
94
|
+
const twilioMediaSignatureMode = optional('TWILIO_MEDIA_SIGNATURE_MODE') || 'log';
|
|
95
|
+
if (!MEDIA_SIGNATURE_MODES.includes(twilioMediaSignatureMode)) {
|
|
96
|
+
fail(`TWILIO_MEDIA_SIGNATURE_MODE must be one of: ${MEDIA_SIGNATURE_MODES.join(', ')}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
89
99
|
const twilioWebhookBaseUrl = sanitizeBaseUrl(
|
|
90
100
|
optional('TWILIO_WEBHOOK_BASE_URL') || `https://${tunnelHostname}`
|
|
91
101
|
);
|
|
@@ -101,6 +111,7 @@ module.exports = {
|
|
|
101
111
|
'AC followed by 32 alphanumeric chars'
|
|
102
112
|
),
|
|
103
113
|
twilioAuthToken: required('TWILIO_AUTH_TOKEN'),
|
|
114
|
+
twilioMediaSignatureMode,
|
|
104
115
|
twilioUnlockDigits,
|
|
105
116
|
twilioPhoneNumber: validateRegex(
|
|
106
117
|
'TWILIO_PHONE_NUMBER',
|
package/src/core/stream-auth.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const crypto = require('crypto');
|
|
4
|
+
const twilio = require('twilio');
|
|
4
5
|
const config = require('./config');
|
|
5
6
|
const { safeEqualString } = require('./safe-equal');
|
|
6
7
|
|
|
@@ -100,6 +101,59 @@ function verifyAndConsumeStreamToken(token) {
|
|
|
100
101
|
return { ok: true, callSid: payload.callSid || null };
|
|
101
102
|
}
|
|
102
103
|
|
|
104
|
+
/**
|
|
105
|
+
* URLs Twilio may have signed for the `/media` WebSocket handshake.
|
|
106
|
+
*
|
|
107
|
+
* Twilio signs the URL it was told to connect to, i.e. the `<Stream url>` that
|
|
108
|
+
* buildConnectStreamTwiml emits. Two documented quirks make the exact string
|
|
109
|
+
* ambiguous, and both break every call if guessed wrong:
|
|
110
|
+
*
|
|
111
|
+
* - the signature is computed over the `wss://` scheme even though the
|
|
112
|
+
* request arrives as an ordinary HTTP upgrade (twilio-aspnet#162);
|
|
113
|
+
* - Twilio's own guidance is to try a trailing `/` when validation fails.
|
|
114
|
+
*
|
|
115
|
+
* Each candidate is a distinct string that still has to HMAC to the presented
|
|
116
|
+
* signature under the auth token, so trying all four grants an attacker
|
|
117
|
+
* nothing: forging any one of them already requires the token.
|
|
118
|
+
*
|
|
119
|
+
* @param {string} [tunnelHostname]
|
|
120
|
+
* @returns {string[]}
|
|
121
|
+
*/
|
|
122
|
+
function streamHandshakeSignedUrls(tunnelHostname = config.tunnelHostname) {
|
|
123
|
+
return [
|
|
124
|
+
`wss://${tunnelHostname}${STREAM_PATH}`,
|
|
125
|
+
`wss://${tunnelHostname}${STREAM_PATH}/`,
|
|
126
|
+
`https://${tunnelHostname}${STREAM_PATH}`,
|
|
127
|
+
`https://${tunnelHostname}${STREAM_PATH}/`,
|
|
128
|
+
];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Verify the `X-Twilio-Signature` on the `/media` WebSocket handshake.
|
|
133
|
+
*
|
|
134
|
+
* This is defence in depth, not a replacement for the one-time stream token.
|
|
135
|
+
* The handshake carries no body and no query string, so the signature is an
|
|
136
|
+
* HMAC over a constant URL and is identical on every call for the life of the
|
|
137
|
+
* auth token. It rejects scanners and anything that has never observed a
|
|
138
|
+
* genuine handshake; it does not resist replay by anything that has. Only
|
|
139
|
+
* verifyAndConsumeStreamToken is single-use.
|
|
140
|
+
*
|
|
141
|
+
* @param {unknown} signature value of the (lowercase) x-twilio-signature header
|
|
142
|
+
* @param {string} [tunnelHostname]
|
|
143
|
+
* @returns {import('./types').StreamHandshakeVerificationResult}
|
|
144
|
+
*/
|
|
145
|
+
function verifyStreamHandshakeSignature(signature, tunnelHostname = config.tunnelHostname) {
|
|
146
|
+
if (typeof signature !== 'string' || !signature) {
|
|
147
|
+
return { ok: false, reason: 'missing handshake signature' };
|
|
148
|
+
}
|
|
149
|
+
for (const signedUrl of streamHandshakeSignedUrls(tunnelHostname)) {
|
|
150
|
+
if (twilio.validateRequest(config.twilioAuthToken, signature, signedUrl, {})) {
|
|
151
|
+
return { ok: true, signedUrl };
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return { ok: false, reason: 'invalid handshake signature' };
|
|
155
|
+
}
|
|
156
|
+
|
|
103
157
|
/**
|
|
104
158
|
* @param {string | null} callSid
|
|
105
159
|
* @param {string} [tunnelHostname]
|
|
@@ -125,6 +179,9 @@ function escapeXmlAttribute(value) {
|
|
|
125
179
|
module.exports = {
|
|
126
180
|
buildConnectStreamTwiml,
|
|
127
181
|
issueStreamToken,
|
|
182
|
+
STREAM_PATH,
|
|
128
183
|
STREAM_TOKEN_PARAMETER_NAME,
|
|
184
|
+
streamHandshakeSignedUrls,
|
|
129
185
|
verifyAndConsumeStreamToken,
|
|
186
|
+
verifyStreamHandshakeSignature,
|
|
130
187
|
};
|
package/src/core/types.js
CHANGED
|
@@ -102,6 +102,18 @@
|
|
|
102
102
|
* @typedef {TokenVerificationOk | TokenVerificationError} StreamTokenVerificationResult
|
|
103
103
|
*/
|
|
104
104
|
|
|
105
|
+
/**
|
|
106
|
+
* @typedef {{ ok: true, signedUrl: string }} HandshakeVerificationOk
|
|
107
|
+
*/
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* @typedef {{ ok: false, reason: string }} HandshakeVerificationError
|
|
111
|
+
*/
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* @typedef {HandshakeVerificationOk | HandshakeVerificationError} StreamHandshakeVerificationResult
|
|
115
|
+
*/
|
|
116
|
+
|
|
105
117
|
/**
|
|
106
118
|
* @typedef {{
|
|
107
119
|
* callSid: string,
|