mirror of
https://github.com/Sea-Haven-Industries/seahaven-door-unlock-api.git
synced 2026-09-30 21:03:11 +00:00
Some checks failed
Deploy / deploy (push) Has been cancelled
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.
171 lines
5.7 KiB
TypeScript
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}`));
|
|
}
|
|
}
|