mirror of
https://github.com/Sea-Haven-Industries/seahaven-door-unlock-api.git
synced 2026-09-30 16:23:12 +00:00
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.
98 lines
3.6 KiB
TypeScript
98 lines
3.6 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;
|
|
let cachedDoorId: string | undefined;
|
|
let lastUnlockTime = 0;
|
|
|
|
const COOLDOWN_MS = 5_000;
|
|
|
|
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, doorId] = await Promise.all([
|
|
cachedAuthToken ?? getParameter(process.env.AUTH_TOKEN_PARAM!, true),
|
|
cachedApiKey ?? getParameter(process.env.ELEMENTS_API_KEY_PARAM!, true),
|
|
cachedDoorId ?? getParameter(process.env.DOOR_ID_PARAM!, false),
|
|
]);
|
|
cachedAuthToken = authToken;
|
|
cachedApiKey = apiKey;
|
|
cachedDoorId = doorId;
|
|
return { authToken, apiKey, doorId };
|
|
}
|
|
|
|
export async function handler(event: {
|
|
queryStringParameters?: Record<string, string>;
|
|
requestContext?: { http?: { sourceIp?: string } };
|
|
}) {
|
|
const sourceIp = event.requestContext?.http?.sourceIp ?? "unknown";
|
|
const token = event.queryStringParameters?.token;
|
|
|
|
if (!token) {
|
|
console.log(JSON.stringify({ action: "unlock_attempt", 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: "unlock_attempt", status: "error", reason: "ssm_failure", sourceIp, error: String(err) }));
|
|
return { statusCode: 500, body: JSON.stringify({ error: "Internal error" }) };
|
|
}
|
|
|
|
if (!tokensMatch(token, secrets.authToken)) {
|
|
console.log(JSON.stringify({ action: "unlock_attempt", status: "rejected", reason: "invalid_token", sourceIp }));
|
|
return { statusCode: 403, body: JSON.stringify({ error: "Forbidden" }) };
|
|
}
|
|
|
|
const now = Date.now();
|
|
if (now - lastUnlockTime < COOLDOWN_MS) {
|
|
console.log(JSON.stringify({ action: "unlock_attempt", status: "cooldown", sourceIp }));
|
|
return { statusCode: 429, body: JSON.stringify({ message: "Cooldown active, try again shortly" }) };
|
|
}
|
|
|
|
const url = `https://api.elementssecure.com/v1/devices/${secrets.doorId}/commands/TemporaryUnlock/execute`;
|
|
|
|
try {
|
|
const response = await fetch(url, {
|
|
method: "POST",
|
|
headers: {
|
|
"api-key": secrets.apiKey,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const body = await response.text();
|
|
console.error(JSON.stringify({ action: "unlock_attempt", status: "elements_error", statusCode: response.status, body, sourceIp }));
|
|
return { statusCode: 502, body: JSON.stringify({ error: "Door system error" }) };
|
|
}
|
|
|
|
lastUnlockTime = now;
|
|
console.log(JSON.stringify({ action: "unlock_attempt", status: "success", sourceIp }));
|
|
return { statusCode: 200, body: JSON.stringify({ message: "Door unlocked" }) };
|
|
} catch (err) {
|
|
console.error(JSON.stringify({ action: "unlock_attempt", status: "error", reason: "elements_unreachable", sourceIp, error: String(err) }));
|
|
return { statusCode: 502, body: JSON.stringify({ error: "Door system unreachable" }) };
|
|
}
|
|
}
|