buzz-me-in 2.1.2-beta.0 → 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
@@ -58,14 +58,14 @@ RPi: server.js (HTTP + WebSocket on one port)
58
58
 
59
59
  All endpoints are reachable from the public internet through the tunnel; each enforces its own auth:
60
60
 
61
- | Endpoint | Method | Auth | Purpose |
62
- | ----------- | --------- | -------------------------------------------- | -------------------------------------- |
63
- | `/twiml` | POST | Twilio signature (`X-Twilio-Signature`) | Returns TwiML for incoming calls |
64
- | `/media` | WebSocket | 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 |
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 |
69
69
 
70
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}`.
71
71
 
@@ -186,7 +186,8 @@ In the [Twilio Console](https://console.twilio.com/us1/develop/phone-numbers/man
186
186
  Security hardening notes:
187
187
 
188
188
  - `/twiml` enforces `X-Twilio-Signature` verification with your `TWILIO_AUTH_TOKEN`.
189
- - 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`.
190
191
  - If your externally visible webhook base URL differs from `https://{TUNNEL_HOSTNAME}`, set `TWILIO_WEBHOOK_BASE_URL` explicitly in `.env`.
191
192
 
192
193
  ### 5. Pair with HomeKit
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "buzz-me-in",
3
- "version": "2.1.2-beta.0",
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": {
package/server.js CHANGED
@@ -38,6 +38,7 @@ const {
38
38
  buildConnectStreamTwiml,
39
39
  STREAM_TOKEN_PARAMETER_NAME,
40
40
  verifyAndConsumeStreamToken,
41
+ verifyStreamHandshakeSignature,
41
42
  } = require('./src/core/stream-auth');
42
43
  const { normalizeCallSid } = require('./src/core/twilio-ids');
43
44
  const homekit = require('./homekit');
@@ -45,12 +46,14 @@ const { createLogger } = require('./src/core/log');
45
46
  const { safeEqualString } = require('./src/core/safe-equal');
46
47
  const { MediaStream } = require('./src/core/media-stream');
47
48
  /** @import { request as WebSocketRequest } from 'websocket' */
49
+ /** @import { HandshakeVerificationError } from './src/core/types' */
48
50
 
49
51
  const HTTP_SERVER_PORT = config.port;
50
52
  const STREAM_PATH = '/media';
51
53
  const STREAM_START_TIMEOUT_MS = config.streamStartTimeoutMs;
52
54
  const STATUS_BEARER_PREFIX = 'Bearer ';
53
55
  const MAX_WS_UTF8_BYTES = config.wsMaxMessageBytes;
56
+ const MEDIA_SIGNATURE_MODE = config.twilioMediaSignatureMode;
54
57
  const SHUTDOWN_GRACE_MS = config.shutdownGraceMs;
55
58
  let shuttingDown = false;
56
59
  const activeWsConnections = new Set();
@@ -262,6 +265,36 @@ mediaws.on('request', function (request) {
262
265
  wsRequest.reject(404, 'Not found');
263
266
  return;
264
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
+
265
298
  // Memory backstop only: cap total sockets so a connection flood can't grow
266
299
  // unbounded. Only one Twilio call is ever authenticated at a time, and each
267
300
  // un-started socket is dropped after config.streamStartTimeoutMs, so this is
@@ -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,