mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 13:53:12 -06:00
It was generated once at pairing and never changed. On a fleet that makes it a
shared secret with no expiry: anyone who watches it typed once — an installer, a
contractor, someone filming a screen — keeps it for the life of the panel, and
the only way to take it back was to unpair and re-pair every affected display. A
customer asked whether it rotates, which was the right question.
POST /api/devices/:id/settings-pin takes { rotate: true } or { pin: "123456" },
and pushes the result to the panel over its socket immediately. The live push is
the part that matters: without it a new PIN would only take effect at the next
pairing, so an operator revoking a leaked PIN would believe access was closed
while the old one still opened the menu. The response reports whether the panel
actually took it, so an offline display is stated rather than assumed.
Validation is the security-relevant half and is pure and tested: six digits,
digits only, and a blocklist of the PINs people actually pick (repeats and
sequences) refused on explicit set and never produced by the generator. A PIN
that can be set to "0000" or left empty is a gate that is not there.
Generation uses crypto.randomInt rather than Math.random — this is a credential,
and a rotation requested BECAUSE a PIN leaked must not be predictable from
anything else. Leading zeros are padded, or roughly one PIN in ten would be five
digits and rejected by the on-device prompt.
Android applies it live via device:settings-pin instead of only at pairing. The
PIN is never written to a log on either side, and it stays out of device list
responses as before.
1084 pass; Android compiles.
Asked for by chris@chris-pc.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
81 lines
3.1 KiB
JavaScript
81 lines
3.1 KiB
JavaScript
'use strict';
|
|
|
|
// The on-device settings PIN was generated once at pairing and never changed.
|
|
//
|
|
// On a fleet that makes it a shared secret with no expiry: anyone who sees it typed once — an
|
|
// installer, a contractor, someone filming a screen — keeps it for the life of the panel, and the
|
|
// only way to take it back was to unpair and re-pair every display. A customer asked whether it
|
|
// rotates, which is the right question to ask.
|
|
//
|
|
// The validation is the security-relevant half: a PIN that can be set to "0000" or left empty is a
|
|
// gate that is not there. These tests exist so a future "let operators pick any PIN they like"
|
|
// change has to argue with them first.
|
|
|
|
const { test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const { generatePin, validatePin, PIN_LENGTH, WEAK } = require('../lib/settings-pin');
|
|
|
|
test('a generated PIN is exactly the length the on-device prompt expects', () => {
|
|
for (let i = 0; i < 50; i++) {
|
|
const p = generatePin();
|
|
assert.equal(p.length, PIN_LENGTH);
|
|
assert.match(p, /^[0-9]+$/);
|
|
}
|
|
});
|
|
|
|
test('generated PINs keep their leading zeros', () => {
|
|
// Generated as a number and padded: dropping the pad would emit 5-digit PINs ~10% of the time,
|
|
// which the device prompt then refuses.
|
|
const seen = new Set();
|
|
for (let i = 0; i < 400; i++) seen.add(generatePin().length);
|
|
assert.deepEqual([...seen], [PIN_LENGTH]);
|
|
});
|
|
|
|
test('the generator never emits a PIN it would refuse on input', () => {
|
|
for (let i = 0; i < 500; i++) assert.ok(!WEAK.has(generatePin()));
|
|
});
|
|
|
|
test('generated PINs are not all the same value', () => {
|
|
const seen = new Set();
|
|
for (let i = 0; i < 50; i++) seen.add(generatePin());
|
|
assert.ok(seen.size > 40, `expected variety, got ${seen.size} distinct in 50`);
|
|
});
|
|
|
|
test('THE GATE: obvious PINs are refused on an explicit set', () => {
|
|
for (const weak of ['000000', '111111', '123456', '654321']) {
|
|
const r = validatePin(weak);
|
|
assert.equal(r.ok, false, `${weak} must be refused`);
|
|
}
|
|
});
|
|
|
|
test('wrong length is refused — a 4-digit PIN would not open the 6-digit prompt', () => {
|
|
assert.equal(validatePin('1234').ok, false);
|
|
assert.equal(validatePin('12345678').ok, false);
|
|
});
|
|
|
|
test('non-digits are refused, including the ones that look like digits', () => {
|
|
assert.equal(validatePin('12 456').ok, false);
|
|
assert.equal(validatePin('abcdef').ok, false);
|
|
assert.equal(validatePin('12.456').ok, false);
|
|
assert.equal(validatePin('-12345').ok, false);
|
|
});
|
|
|
|
test('empty and missing are refused rather than silently clearing the gate', () => {
|
|
assert.equal(validatePin('').ok, false);
|
|
assert.equal(validatePin(null).ok, false);
|
|
assert.equal(validatePin(undefined).ok, false);
|
|
assert.equal(validatePin(' ').ok, false);
|
|
});
|
|
|
|
test('a good PIN is accepted and returned trimmed', () => {
|
|
const r = validatePin(' 204815 ');
|
|
assert.equal(r.ok, true);
|
|
assert.equal(r.pin, '204815');
|
|
});
|
|
|
|
test('a numeric PIN is accepted — the API may hand us a number, not a string', () => {
|
|
const r = validatePin(204815);
|
|
assert.equal(r.ok, true);
|
|
assert.equal(r.pin, '204815');
|
|
});
|