Skip to content

Commit 1f72ff7

Browse files
bwp91claude
andcommitted
test: cover constant-time pincode comparison
Adds five HAPServer tests verifying the fix from 12aea01: the insecure PUT /characteristics and POST /resource paths must compare the authorization header against the configured pincode using crypto.timingSafeEqual with a length check first. Coverage: - missing authorization → CONNECTION_AUTHORIZATION_REQUIRED - wrong-length authorization → rejected (without the length guard timingSafeEqual would throw RangeError) - same-length wrong pincode → rejected - correct pincode → authorized - /resource endpoint also rejects wrong-length auth Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 517e33b commit 1f72ff7

2 files changed

Lines changed: 120 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ All notable changes to `@homebridge/hap-nodejs` will be documented in this file.
1818
- test: cover aid.iid format validation
1919
- test: cover camera stream start TLV parsing guards
2020
- test: cover error argument in pairing debug logs
21+
- test: cover constant-time pincode comparison
2122

2223
### Homebridge Dependencies
2324

src/lib/HAPServer.spec.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,125 @@ describe("HAPServer", () => {
329329
});
330330
});
331331

332+
describe("constant-time pincode comparison (fix 12aea013)", () => {
333+
test("PUT /characteristics with no authorization header should be rejected", async () => {
334+
server = new HAPServer(accessoryInfoUnpaired);
335+
server.allowInsecureRequest = true;
336+
const [port] = await bindServer(server);
337+
338+
try {
339+
await axios.put(
340+
`http://localhost:${port}/characteristics`,
341+
{ characteristics: [{ aid: 1, iid: 9, value: true }] },
342+
{ httpAgent },
343+
);
344+
fail("Expected CONNECTION_AUTHORIZATION_REQUIRED response");
345+
} catch (error) {
346+
expect(error).toBeInstanceOf(AxiosError);
347+
expect(error.response?.status).toBe(HAPPairingHTTPCode.CONNECTION_AUTHORIZATION_REQUIRED);
348+
expect(error.response?.data).toEqual({ status: HAPStatus.INSUFFICIENT_PRIVILEGES });
349+
}
350+
});
351+
352+
test("PUT /characteristics with wrong-length authorization should not crash timingSafeEqual", async () => {
353+
server = new HAPServer(accessoryInfoUnpaired);
354+
server.allowInsecureRequest = true;
355+
const [port] = await bindServer(server);
356+
357+
// 5 bytes — different length to the 11-byte pincode. Without the
358+
// length guard added in 12aea013, crypto.timingSafeEqual throws
359+
// RangeError and the connection would be killed.
360+
try {
361+
await axios.put(
362+
`http://localhost:${port}/characteristics`,
363+
{ characteristics: [{ aid: 1, iid: 9, value: true }] },
364+
{ httpAgent, headers: { authorization: "short" } },
365+
);
366+
fail("Expected CONNECTION_AUTHORIZATION_REQUIRED response");
367+
} catch (error) {
368+
expect(error).toBeInstanceOf(AxiosError);
369+
expect(error.response?.status).toBe(HAPPairingHTTPCode.CONNECTION_AUTHORIZATION_REQUIRED);
370+
}
371+
});
372+
373+
test("PUT /characteristics with same-length wrong pincode should be rejected", async () => {
374+
// use a pincode without leading whitespace — node's HTTP parser trims
375+
// header values, so the fixture's " 031-45-154" can't round-trip and
376+
// any wrong value with the same leading space hits the wrong-length
377+
// branch on the server, not the constant-time same-length comparison.
378+
const info = AccessoryInfo.create(serverUsername);
379+
// @ts-expect-error: private access
380+
info.setupID = Accessory._generateSetupID();
381+
info.displayName = "Outlet";
382+
info.category = 7;
383+
info.pincode = "031-45-154";
384+
385+
server = new HAPServer(info);
386+
server.allowInsecureRequest = true;
387+
const [port] = await bindServer(server);
388+
389+
const wrongPincode = "999-99-999"; // same length as "031-45-154", different content
390+
try {
391+
await axios.put(
392+
`http://localhost:${port}/characteristics`,
393+
{ characteristics: [{ aid: 1, iid: 9, value: true }] },
394+
{ httpAgent, headers: { authorization: wrongPincode } },
395+
);
396+
fail("Expected CONNECTION_AUTHORIZATION_REQUIRED response");
397+
} catch (error) {
398+
expect(error).toBeInstanceOf(AxiosError);
399+
expect(error.response?.status).toBe(HAPPairingHTTPCode.CONNECTION_AUTHORIZATION_REQUIRED);
400+
}
401+
});
402+
403+
test("PUT /characteristics with correct pincode should be authorized", async () => {
404+
// use a pincode without leading whitespace — HTTP header values get trimmed
405+
// by node's parser so the global test fixture's " 031-45-154" can't round-trip.
406+
const info = AccessoryInfo.create(serverUsername);
407+
// @ts-expect-error: private access
408+
info.setupID = Accessory._generateSetupID();
409+
info.displayName = "Outlet";
410+
info.category = 7;
411+
info.pincode = "031-45-154";
412+
413+
server = new HAPServer(info);
414+
server.allowInsecureRequest = true;
415+
const [port] = await bindServer(server);
416+
417+
server.on(HAPServerEventTypes.SET_CHARACTERISTICS, (connection, writeRequest, callback) => {
418+
callback(undefined, { characteristics: writeRequest.characteristics.map(c => ({ aid: c.aid, iid: c.iid, status: HAPStatus.SUCCESS })) });
419+
});
420+
421+
const response = await axios.put(
422+
`http://localhost:${port}/characteristics`,
423+
{ characteristics: [{ aid: 1, iid: 9, value: true }] },
424+
{ httpAgent, headers: { authorization: info.pincode } },
425+
);
426+
// a successful insecure PUT returns 204 NO_CONTENT (or 200 if there's a response body)
427+
expect(response.status).toBeGreaterThanOrEqual(200);
428+
expect(response.status).toBeLessThan(300);
429+
});
430+
431+
test("POST /resource with wrong-length authorization should not crash timingSafeEqual", async () => {
432+
server = new HAPServer(accessoryInfoUnpaired);
433+
server.allowInsecureRequest = true;
434+
const [port] = await bindServer(server);
435+
436+
try {
437+
await axios.post(
438+
`http://localhost:${port}/resource`,
439+
{ "resource-type": "image", "image-width": 100, "image-height": 100 },
440+
{ httpAgent, headers: { authorization: "short" } },
441+
);
442+
fail("Expected CONNECTION_AUTHORIZATION_REQUIRED response");
443+
} catch (error) {
444+
expect(error).toBeInstanceOf(AxiosError);
445+
expect(error.response?.status).toBe(HAPPairingHTTPCode.CONNECTION_AUTHORIZATION_REQUIRED);
446+
expect(error.response?.data).toEqual({ status: HAPStatus.INSUFFICIENT_PRIVILEGES });
447+
}
448+
});
449+
});
450+
332451
describe("error argument in pairing debug logs (fix f12ed233)", () => {
333452
let originalEnable: string;
334453
let originalLog: (...args: unknown[]) => void;

0 commit comments

Comments
 (0)