Make an admin's refusal stick, and let a revoke actually deny

Three holes in the enrollment path, all of the same shape: a decision the admin
made did not survive contact with a PC that simply asked again.

REJECTION WAS UNDONE BY A NEW NONCE. A rejected request re-armed itself as a
fresh cycle when the PC came back with a different nonce, and with the shared
key and auto-enroll on it was then issued the credential the admin had just
refused. Rejection now binds to the endpoint identity - hostname plus BIOS
serial - not to the nonce, so the PC stays refused however often it reinstalls.
An admin clears it deliberately, through the approve path that already exists; a
genuinely different PC is untouched.

THE PENDING QUEUE COULD BE FILLED UNTIL IT REFUSED EVERY NEW PC. /enroll takes
no auth by design - a bay that has never spoken to the server has nothing to
authenticate with - so anything that can reach the server could sit at the cap
and lock out real enrollments fleet-wide. Rows nobody has touched for seven days
now age out before the cap refuses anyone, and the count of what went is
audited. Per-identity dedup and the cap itself were already there and are left
alone.

REVOKE DID NOT STOP A LIVE AGENT. It fell back to the shared key and the server
admitted it again, so revoking a compromised PC changed nothing while that key
still worked. plugin.endpoints.revokeddeniessharedkey makes a revoked host
refused on the shared key too.

That switch DEFAULTS OFF, and that is the important part. Turning it on would
silence any bay revoked for a key rotation, and would break the documented
revoke-then-rollback path to an older agent - on an air-gapped site, mid
migration, that is a fleet outage. A never-enrolled bay is never affected either
way: with no credential row at all the shared key still works, which is the
whole un-migrated fleet.

The checkbox sits next to the other two enrollment switches and says what
turning it on costs, because a setting that can stop the floor should not be
reachable only through the API.
This commit is contained in:
cproudlock
2026-09-18 13:29:51 -04:00
parent fe4f44f855
commit 3f80425f30
6 changed files with 585 additions and 28 deletions

View File

@@ -229,6 +229,27 @@
on record matches. Anything else waits below for an admin.
</span>
</label>
<label class="checkbox-row">
<input
type="checkbox"
class="toggle-revokeddenies"
:checked="enrollmentSettings.revokeddeniessharedkey"
:disabled="!isadmin || busy"
@change="onRevokedDeniesToggle($event)"
/>
<span>
<strong>A revoked PC loses the shared key too.</strong> (setting
<code>plugin.endpoints.revokeddeniessharedkey</code>) Off by
default, which is what the fleet does today: a PC whose credential
was revoked falls back to the shared key and keeps reporting, so a
credential rotation does not strand it. Turn this on and a revoke
becomes an eviction - that PC reports nothing and receives no
commands until it enrolls again - and the documented rollback of
revoke plus block re-enrollment onto an older agent that only knows
the shared key stops working. Only safe once every PC here is
enrolled.
</span>
</label>
<label class="number-row">
<span>
<strong>Pending request limit.</strong> How many requests may wait
@@ -670,6 +691,9 @@ const DEFAULT_PENDING_CAP = 500
const enrollmentSettings = ref({
sharedkeyallowed: true,
autoenroll: true,
// Matches the seeded default (plugin.endpoints.revokeddeniessharedkey is
// false), so the box renders unchecked before the first read lands.
revokeddeniessharedkey: false,
maxpendingenrollments: DEFAULT_PENDING_CAP
})
// Bound with v-model rather than :value so the field holds what the operator
@@ -697,6 +721,8 @@ async function loadEnrollment({ quiet = false } = {}) {
enrollmentSettings.value = {
sharedkeyallowed: data.settings?.sharedkeyallowed !== false,
autoenroll: data.settings?.autoenroll !== false,
// Opt-in, so anything but an explicit true reads as off.
revokeddeniessharedkey: data.settings?.revokeddeniessharedkey === true,
maxpendingenrollments: Number.isFinite(Number(data.settings?.maxpendingenrollments))
? Number(data.settings.maxpendingenrollments)
: DEFAULT_PENDING_CAP
@@ -771,6 +797,26 @@ function onAutoEnrollToggle(event) {
})
}
function onRevokedDeniesToggle(event) {
const turningOn = event.target.checked
if (turningOn) {
// Typed confirm, like the shared-key switch: this one silences PCs that
// are reporting right now, and the revoke-plus-rollback recipe in the
// runbook stops working the moment it is on.
const answer = prompt(
'Deny the shared key to revoked PCs?\n\n'
+ 'Any PC whose credential was revoked stops reporting and stops '
+ 'receiving commands until it enrolls again. The revoke + block '
+ 're-enrollment rollback onto an older agent no longer works.\n\n'
+ 'Type DENY to confirm.')
if (!answer || answer.trim().toUpperCase() !== 'DENY') {
event.target.checked = false
return
}
}
return saveSettings({ revokeddeniessharedkey: turningOn })
}
function onPendingCapChange() {
const wanted = Math.trunc(Number(pendingCapInput.value))
if (!Number.isFinite(wanted) || wanted < 0) {