seahaven-door-unlock-api/lambda/lockdown/lockdown-handler.ts
Adam Moussa fe04a199de fix: use constant-time auth token comparison (INFRA-21) (#30)
Replace plain token !== secrets.authToken checks in the unlock and
lockdown handlers with crypto.timingSafeEqual, guarding for unequal
buffer lengths first (timingSafeEqual throws on different lengths).
Prevents timing side-channel leakage of the auth token. Handler
signatures, event shape, and return contract are unchanged.
2026-06-05 17:26:12 -04:00

171 lines
5.7 KiB
TypeScript

import {
SSMClient,
GetParameterCommand,
} from "@aws-sdk/client-ssm";
import { timingSafeEqual } from "node:crypto";
const ssm = new SSMClient({});
function tokensMatch(provided: string, expected: string): boolean {
const a = Buffer.from(provided);
const b = Buffer.from(expected);
// timingSafeEqual throws on unequal-length buffers; check length first.
return a.length === b.length && timingSafeEqual(a, b);
}
let cachedAuthToken: string | undefined;
let cachedApiKey: string | undefined;
const LOCKDOWN_PROFILES: Record<string, { id: string; name: string; linekey: number }> = {
bohemia: { id: "4b4a3e6b-c903-4cce-8cd6-288612bf0542", name: "Bohemia - Whole Building", linekey: 3 },
ronkonkoma: { id: "ff9876bc-c54f-472e-aef9-d2bffd4b7cf7", name: "Ronkonkoma - Whole Building", linekey: 4 },
};
const ELEMENTS_BASE_URL = "https://api.elementssecure.com/v1";
async function getParameter(name: string, decrypt: boolean): Promise<string> {
const res = await ssm.send(
new GetParameterCommand({ Name: name, WithDecryption: decrypt })
);
return res.Parameter!.Value!;
}
async function loadSecrets() {
const [authToken, apiKey] = await Promise.all([
cachedAuthToken ?? getParameter(process.env.AUTH_TOKEN_PARAM!, true),
cachedApiKey ?? getParameter(process.env.ELEMENTS_API_KEY_PARAM!, true),
]);
cachedAuthToken = authToken;
cachedApiKey = apiKey;
return { authToken, apiKey };
}
async function getLockdownStatus(lockdownId: string, apiKey: string): Promise<boolean> {
const response = await fetch(`${ELEMENTS_BASE_URL}/lockdowns/${lockdownId}`, {
headers: { "api-key": apiKey },
});
if (!response.ok) {
const body = await response.text();
throw new Error(`Elements status check failed: ${response.status} - ${body}`);
}
const data = await response.json() as Record<string, unknown>;
console.log(JSON.stringify({ action: "lockdown_raw_status", lockdownId, data }));
return String(data.status).toLowerCase() === "active";
}
async function setLockdown(lockdownId: string, apiKey: string, start: boolean): Promise<void> {
const action = start ? "start" : "stop";
const response = await fetch(`${ELEMENTS_BASE_URL}/lockdowns/${lockdownId}/${action}`, {
method: "POST",
headers: {
"api-key": apiKey,
"Content-Type": "application/json",
},
body: JSON.stringify({}),
});
if (!response.ok) {
const body = await response.text();
throw new Error(`Elements ${action} failed: ${response.status} - ${body}`);
}
}
function textScreenXml(title: string, text: string): string {
return [
`<?xml version="1.0" encoding="UTF-8"?>`,
`<YealinkIPPhoneTextScreen>`,
` <Title>${title}</Title>`,
` <Text>${text}</Text>`,
`</YealinkIPPhoneTextScreen>`,
].join("\n");
}
function xmlResponse(statusCode: number, body: string) {
return {
statusCode,
headers: { "Content-Type": "application/xml" },
body,
};
}
export async function handler(event: {
queryStringParameters?: Record<string, string>;
rawPath?: string;
requestContext?: { http?: { sourceIp?: string } };
}) {
const sourceIp = event.requestContext?.http?.sourceIp ?? "unknown";
const token = event.queryStringParameters?.token;
const profile = event.queryStringParameters?.profile;
const path = event.rawPath ?? "";
if (!token) {
console.log(JSON.stringify({ action: "lockdown", status: "rejected", reason: "missing_token", sourceIp }));
return { statusCode: 403, body: JSON.stringify({ error: "Forbidden" }) };
}
let secrets;
try {
secrets = await loadSecrets();
} catch (err) {
console.error(JSON.stringify({ action: "lockdown", status: "error", reason: "ssm_failure", sourceIp, error: String(err) }));
return xmlResponse(500, textScreenXml("Error", "Internal error"));
}
if (!tokensMatch(token, secrets.authToken)) {
console.log(JSON.stringify({ action: "lockdown", status: "rejected", reason: "invalid_token", sourceIp }));
return { statusCode: 403, body: JSON.stringify({ error: "Forbidden" }) };
}
if (path === "/lockdown/status") {
return handleStatus(secrets.apiKey, sourceIp);
}
return handleToggle(profile, secrets.apiKey, sourceIp);
}
async function handleStatus(apiKey: string, sourceIp: string) {
try {
const lines: string[] = [];
for (const [key, config] of Object.entries(LOCKDOWN_PROFILES)) {
const active = await getLockdownStatus(config.id, apiKey);
lines.push(`${config.name}: ${active ? "LOCKED DOWN" : "Normal"}`);
console.log(JSON.stringify({ action: "lockdown_status", profile: key, active, sourceIp }));
}
return xmlResponse(200, textScreenXml("Lockdown Status", lines.join("\n")));
} catch (err) {
console.error(JSON.stringify({ action: "lockdown_status", status: "error", sourceIp, error: String(err) }));
return xmlResponse(502, textScreenXml("Error", "Unable to check lockdown status"));
}
}
async function handleToggle(profile: string | undefined, apiKey: string, sourceIp: string) {
if (!profile || !LOCKDOWN_PROFILES[profile]) {
return xmlResponse(400, textScreenXml("Error", "Invalid profile"));
}
const config = LOCKDOWN_PROFILES[profile];
try {
const wasActive = await getLockdownStatus(config.id, apiKey);
await setLockdown(config.id, apiKey, !wasActive);
const nowActive = !wasActive;
console.log(JSON.stringify({
action: "lockdown_toggle",
profile,
wasActive,
nowActive,
sourceIp,
}));
const statusText = nowActive ? "LOCKED DOWN" : "Normal";
return xmlResponse(200, textScreenXml(config.name, statusText));
} catch (err) {
console.error(JSON.stringify({ action: "lockdown_toggle", status: "error", profile, sourceIp, error: String(err) }));
return xmlResponse(502, textScreenXml("Error", `Lockdown error: ${config.name}`));
}
}