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 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
+ [![CI](https://img.shields.io/github/actions/workflow/status/atdr/buzz-me-in/ci.yml?style=flat-square&label=CI)](https://github.com/atdr/buzz-me-in/actions/workflows/ci.yml)
6
+ [![Coverage](https://img.shields.io/codecov/c/github/atdr/buzz-me-in?style=flat-square&label=coverage)](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 | Purpose |
59
- | ----------- | --------- | -------------------------------------------- | -------------------------------------- |
60
- | `/twiml` | POST | Twilio signature (`X-Twilio-Signature`) | Returns TwiML for incoming calls |
61
- | `/media` | WebSocket | One-time HMAC stream token (issued in TwiML) | Bidirectional call audio |
62
- | `/status` | GET | Bearer token (`STATUS_API_TOKEN`) | Active call info |
63
- | `/healthz` | GET | None (by design) | Liveness probe, returns `{"ok":true}` |
64
- | `/readyz` | GET | None (by design) | Readiness probe, returns `{"ok":true}` |
65
- | `/ringtone` | GET | None | Ring tone WAV for debug/manual checks |
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 media stream URL. WebSocket connections are rejected unless this token validates.
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
- accessory.publish({
677
- username: config.hapUsername,
678
- pincode: config.hapPincode,
679
- port: config.hapPort,
680
- category: Categories.VIDEO_DOORBELL,
681
- advertiser: hap.MDNSAdvertiser.AVAHI,
682
- });
676
+ // ---------------------------------------------------------------------------
677
+ // Startup
678
+ // ---------------------------------------------------------------------------
683
679
 
684
- logger.info('Accessory published', {
685
- event: 'accessory-published',
686
- });
687
- // The setup QR encodes the pairing pincode, so only print it on an
688
- // interactive terminal — never into journald/log files.
689
- if (process.stdout.isTTY) {
690
- logger.info('Accessory QR setup URI generated', {
691
- event: 'accessory-qr-setup',
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
- qrcode.generate(accessory.setupURI(), { small: true });
694
- } else {
695
- logger.info('Pairing QR suppressed on non-interactive stdout; reprint it with buzz-me-in --qr', {
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
- logger.info('Snapshot initialized', {
704
- event: 'snapshot-ready',
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.1.1",
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 ./homekit calls accessory.publish() at module scope, which
7
- // would put a second accessory on the network alongside the running service,
8
- // and requiring ./src/core/config throws on any missing env var, which under
9
- // systemd lives in EnvironmentFile and so is absent from an interactive shell.
10
- // A read-only query must touch neither. tests/cli.test.cjs pins this ordering.
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
- const cliFs = require('fs');
15
- const cliExit = require('./src/core/cli').run({
16
- argv: process.argv.slice(2),
17
- write: (text) => cliFs.writeSync(1, text),
18
- writeErr: (text) => cliFs.writeSync(2, text),
19
- isTTY: Boolean(process.stdout.isTTY),
20
- cwd: process.cwd(),
21
- });
22
- if (cliExit !== null) process.exit(cliExit);
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
- wsserver.listen(HTTP_SERVER_PORT, () => {
373
- logger.info('Server listening', {
374
- event: 'server-start',
375
- port: HTTP_SERVER_PORT,
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
- process.on('SIGINT', () => beginShutdown('SIGINT'));
419
- process.on('SIGTERM', () => beginShutdown('SIGTERM'));
420
-
421
- // Exit on unexpected errors instead of continuing in an undefined state;
422
- // systemd restarts the service.
423
- process.on('uncaughtException', (err) => {
424
- logger.error('Uncaught exception; exiting', {
425
- event: 'uncaught-exception',
426
- reason: 'uncaught-exception',
427
- error: err,
428
- });
429
- process.exit(1);
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 ./homekit publishes an accessory at module scope and requiring
14
- // ./src/core/config throws on a missing env var.
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');
@@ -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',
@@ -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,