buzz-me-in 2.1.2-beta.0 → 2.2.0-beta.1
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 +12 -10
- package/homekit.js +6 -2
- package/package.json +1 -1
- package/server.js +33 -0
- 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
|
@@ -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
|
|
62
|
-
| ----------- | --------- |
|
|
63
|
-
| `/twiml` | POST | Twilio signature (`X-Twilio-Signature`)
|
|
64
|
-
| `/media` | WebSocket |
|
|
65
|
-
| `/status` | GET | Bearer token (`STATUS_API_TOKEN`)
|
|
66
|
-
| `/healthz` | GET | None (by design)
|
|
67
|
-
| `/readyz` | GET | None (by design)
|
|
68
|
-
| `/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 |
|
|
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
|
|
|
@@ -86,7 +86,8 @@ The health endpoints are deliberately unauthenticated so systemd checks, uptime
|
|
|
86
86
|
- Node.js >= 20
|
|
87
87
|
- Runtime is tested on Node 20+.
|
|
88
88
|
- Repo tooling/CI currently runs on Node 20, 22, and 24.
|
|
89
|
-
- ffmpeg with libx264 and libopus: `sudo apt install ffmpeg`
|
|
89
|
+
- ffmpeg >= 5.1 with libx264 and libopus: `sudo apt install ffmpeg`
|
|
90
|
+
- The 5.1 floor is the `-ch_layout` option used on the inbound audio pipeline. Debian 12 (bookworm) ships 5.1 and Raspberry Pi OS based on Debian 13 ships 7.1, so apt satisfies this on any currently supported release.
|
|
90
91
|
|
|
91
92
|
### Accounts and services
|
|
92
93
|
|
|
@@ -186,7 +187,8 @@ In the [Twilio Console](https://console.twilio.com/us1/develop/phone-numbers/man
|
|
|
186
187
|
Security hardening notes:
|
|
187
188
|
|
|
188
189
|
- `/twiml` enforces `X-Twilio-Signature` verification with your `TWILIO_AUTH_TOKEN`.
|
|
189
|
-
- TwiML embeds a short-lived, one-time signed token in the
|
|
190
|
+
- 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.
|
|
191
|
+
- `/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
192
|
- If your externally visible webhook base URL differs from `https://{TUNNEL_HOSTNAME}`, set `TWILIO_WEBHOOK_BASE_URL` explicitly in `.env`.
|
|
191
193
|
|
|
192
194
|
### 5. Pair with HomeKit
|
package/homekit.js
CHANGED
|
@@ -291,6 +291,10 @@ function _startSession(sessionID, s, request, callback) {
|
|
|
291
291
|
// Input 0 – raw mulaw/8kHz from Twilio via stdin
|
|
292
292
|
// Let the raw audio demuxer derive PTS from sample count. Using wall-clock
|
|
293
293
|
// timestamps breaks when ffmpeg drains the buffered startup audio burst.
|
|
294
|
+
// -ch_layout declares mono rather than -ac counting one channel: a count
|
|
295
|
+
// leaves the layout unset, so the decoder guesses it and logs "Guessed
|
|
296
|
+
// Channel Layout: mono" on every call. Our stderr handler is a warn, so
|
|
297
|
+
// that guess looked like a fault in the journal. Needs ffmpeg >= 5.1.
|
|
294
298
|
//
|
|
295
299
|
// Input 1 – synthetic black video (lavfi color source)
|
|
296
300
|
// Generates H.264 Baseline/3.1 frames at 15 fps.
|
|
@@ -313,8 +317,8 @@ function _startSession(sessionID, s, request, callback) {
|
|
|
313
317
|
'mulaw',
|
|
314
318
|
'-ar',
|
|
315
319
|
'8000',
|
|
316
|
-
'-
|
|
317
|
-
'
|
|
320
|
+
'-ch_layout',
|
|
321
|
+
'mono',
|
|
318
322
|
'-i',
|
|
319
323
|
'pipe:0',
|
|
320
324
|
|
package/package.json
CHANGED
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
|
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,
|