From 23dba8ea01a6d486dc9dee2d4cae0b11854e9a05 Mon Sep 17 00:00:00 2001 From: Susan Xueqing Liu Date: Mon, 30 Mar 2026 12:26:49 -0400 Subject: [PATCH] Phone server: add STIR/SHAKEN caller ID verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read StirVerstat from Twilio webhook and pass it through to the WebSocket handler. If a caller's number matches OWNER_NUMBER but StirVerstat is not TN-Validation-Passed-A (highest attestation), downgrade from owner to verified-only — blocking full computer access for potentially spoofed calls. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../scripts/conversation-server.ts | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/skills/phone-conversation/scripts/conversation-server.ts b/skills/phone-conversation/scripts/conversation-server.ts index 1f035e26e8..2ac027cbcc 100644 --- a/skills/phone-conversation/scripts/conversation-server.ts +++ b/skills/phone-conversation/scripts/conversation-server.ts @@ -1056,10 +1056,12 @@ const server = createServer(async (req, res) => { const parentCallSid = url.searchParams.get('parentCallSid') ?? ''; const toParam = url.searchParams.get('to') ?? ''; const callerNumber = form.get('From') ?? ''; - console.log(`${ts()} [Connect] purpose=${purpose} from=${callerNumber} callSid=${form.get('CallSid') ?? '?'}`); + const stirVerstat = form.get('StirVerstat') ?? ''; + console.log(`${ts()} [Connect] purpose=${purpose} from=${callerNumber} stirVerstat=${stirVerstat} callSid=${form.get('CallSid') ?? '?'}`); const params = [``]; if (callerNumber) params.push(``); + if (stirVerstat) params.push(``); const passcodeParam = url.searchParams.get('passcode') ?? ''; if (isMeeting) { params.push(``); @@ -1132,6 +1134,7 @@ wss.on('connection', (ws: WebSocket) => { // Child calls inherit parent's verification (the parent authorized this call). // For meetings: check meeting ID against VERIFIED_MEETINGS. // Accepts original ID (e.g. "gbn-otgn-dex"), numeric PIN, or Zoom meeting ID. + const stirVerstat = cp.stirVerstat ?? ''; let callerVerified: boolean; if (isMeeting) { // Meetings must be explicitly verified — no permissive default. @@ -1143,10 +1146,20 @@ wss.on('connection', (ws: WebSocket) => { callerVerified = VERIFIED_CALLERS.size === 0 || VERIFIED_CALLERS.has(normalizedPerson); } - // Owner detection: based on phone number of the person on the line - const isOwner = OWNER_NUMBER ? normalizePhone(personOnLine) === normalizePhone(OWNER_NUMBER) : callerVerified; + // Owner detection: number match + STIR/SHAKEN verification. + // A spoofed caller ID will fail STIR/SHAKEN (StirVerstat != TN-Validation-Passed-A). + // If StirVerstat is present and NOT A-level, downgrade owner to verified-only. + const numberMatchesOwner = OWNER_NUMBER ? normalizePhone(personOnLine) === normalizePhone(OWNER_NUMBER) : false; + let isOwner: boolean; + if (numberMatchesOwner && stirVerstat && stirVerstat !== 'TN-Validation-Passed-A') { + // Number matches but caller ID not cryptographically verified — possible spoof + isOwner = false; + console.log(`${ts()} [Security] Owner number matched but StirVerstat=${stirVerstat} (not A-level) — downgrading to verified`); + } else { + isOwner = numberMatchesOwner || (!OWNER_NUMBER && callerVerified); + } - console.log(`${ts()} [WS] stream started: ${callSid}, meeting: ${isMeeting}, verified: ${callerVerified}, owner: ${isOwner}, personOnLine: ${personOnLine}, normalized: ${normalizePhone(personOnLine)}, verifiedSet: ${[...VERIFIED_CALLERS].join(',')}`); + console.log(`${ts()} [WS] stream started: ${callSid}, meeting: ${isMeeting}, verified: ${callerVerified}, owner: ${isOwner}, stirVerstat: ${stirVerstat}, personOnLine: ${personOnLine}, normalized: ${normalizePhone(personOnLine)}, verifiedSet: ${[...VERIFIED_CALLERS].join(',')}`); try { callSession = await createCallSession({