Harden Anker Solix OCPP control (token hashing, step-up, audit, TLS)

Security pass over the OCPP charger-control feature added in a1519f6, since
remotely actuating a physical charger is a real side effect.

Token hygiene:
  - Per-charger control tokens are stored as SHA-256 hashes + a last-4 hint,
    never plaintext. The token is shown once at generation; the status endpoint
    returns only the hint. Added a revoke endpoint that also drops any live
    session using the revoked token.

Step-up + confirmation:
  - Destructive actions (reset, unlock) require confirm:true AND a password
    re-authentication (verified against PocketBase). The Charging UI collects the
    password inline for reset.
  - Per user+charger rate limit (30/min) on control commands.

Transport + provenance:
  - OCPP_REQUIRE_TLS (default on) rejects plaintext ws:// charger connections;
    OCPP_PUBLIC_URL pins the advertised endpoint instead of trusting request
    headers.
  - Proxy-mode upstream URL is validated against a *.anker.com allowlist, so a
    spoofed ocpp-info response can't redirect the proxy.

Durable audit:
  - New control_audit PocketBase collection (added to setup-pocketbase.mjs);
    every control action, token generate/revoke and charger connect is persisted
    best-effort in addition to a structured log line.

Startup:
  - The control-token index is warmed from PocketBase on startup so a charger
    reconnecting after a restart resolves immediately.

Tests:
  - Unit tests for token hashing/eviction/revoke (no plaintext at rest),
    rate limiter, destructive-action classifier, upstream allowlist, TLS
    enforcement, and re-auth guards. A full-stack E2E (control_e2e_test.go)
    drives the real Handler with a stand-in PocketBase and a simulated charge
    point, proving step-up (400/401/200) and audit persistence end to end.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
tajniak81
2026-07-18 21:03:05 +02:00
co-authored by Claude Opus 4.8
parent a1519f6e89
commit 19a7d48feb
13 changed files with 883 additions and 44 deletions
+2
View File
@@ -247,6 +247,8 @@ export const api = {
getAnkerControl: (sn) => request(`/integrations/anker-solix/chargers/${encodeURIComponent(sn)}/control`),
ankerControlToken: (sn) =>
request(`/integrations/anker-solix/chargers/${encodeURIComponent(sn)}/control/token`, { method: "POST" }),
ankerControlRevoke: (sn) =>
request(`/integrations/anker-solix/chargers/${encodeURIComponent(sn)}/control/token`, { method: "DELETE" }),
ankerControlAction: (sn, action, body = {}) =>
request(`/integrations/anker-solix/chargers/${encodeURIComponent(sn)}/${action}`, {
method: "POST",
+5
View File
@@ -61,6 +61,8 @@
"applyLimit": "Apply limit",
"clearLimit": "Clear limit",
"reset": "Reset charger",
"resetConfirm": "Reboot this charger now? Any active charging session will be interrupted. Re-enter your password to confirm.",
"resetPassword": "Your account password",
"connectHint": "Enter your charger's serial and refresh. The charger must be connected to DriverVault's OCPP backend (set up in Settings → Integrations)."
},
"stations": {
@@ -253,6 +255,9 @@
"controlGenerate": "Generate token",
"controlEndpoint": "OCPP endpoint",
"controlToken": "Auth token",
"controlTokenOnce": "Copy this token now — it's shown only once and can't be retrieved later.",
"controlRevoke": "Revoke token",
"controlRevokeConfirm": "Revoke this charger's control token? It will disconnect and can't reconnect until you generate a new one.",
"controlConnected": "Connected to control backend",
"controlDisconnected": "Not connected"
},
+43 -1
View File
@@ -99,6 +99,30 @@ async function doAction(action, body) {
}
}
// Reset reboots the charger — a destructive action the server gates behind an
// explicit confirmation AND a password re-authentication (step-up). Reveal the
// inline password prompt; the actual call happens in confirmReset().
const resetPrompt = ref(false);
const resetPassword = ref("");
function askReset() {
ctlError.value = "";
resetPassword.value = "";
resetPrompt.value = true;
}
async function confirmReset() {
if (!resetPassword.value) return;
resetPrompt.value = false;
await doAction("reset", { hard: false, confirm: true, password: resetPassword.value });
resetPassword.value = "";
}
function cancelReset() {
resetPrompt.value = false;
resetPassword.value = "";
}
onMounted(async () => {
await loadCtlMode();
await refreshCtl();
@@ -215,9 +239,27 @@ onMounted(async () => {
</div>
</div>
<button class="dh-btn dh-btn-ghost mt-3 w-full" :disabled="ctlBusy === 'reset'" @click="doAction('reset', { hard: false })">
<button v-if="!resetPrompt" class="dh-btn dh-btn-ghost mt-3 w-full" :disabled="ctlBusy === 'reset'" @click="askReset">
{{ t("charging.control.reset") }}
</button>
<!-- Step-up: destructive reset requires re-entering the password. -->
<div v-else class="mt-3 rounded-control border border-danger/40 bg-danger-soft p-3">
<p class="text-xs font-medium text-danger">{{ t("charging.control.resetConfirm") }}</p>
<input
v-model="resetPassword"
type="password"
autocomplete="current-password"
class="dh-input mt-2"
:placeholder="t('charging.control.resetPassword')"
@keyup.enter="confirmReset"
/>
<div class="mt-2 flex gap-2">
<button class="dh-btn dh-btn-ghost grow" @click="cancelReset">{{ t("common.cancel") }}</button>
<button class="dh-btn dh-btn-danger grow" :disabled="!resetPassword || ctlBusy === 'reset'" @click="confirmReset">
{{ t("charging.control.reset") }}
</button>
</div>
</div>
</template>
<p v-else class="mt-3 text-xs text-muted">{{ t("charging.control.connectHint") }}</p>
<p v-if="ctlError" class="mt-2 text-sm text-danger">{{ ctlError }}</p>
+41 -4
View File
@@ -450,9 +450,10 @@ const ankerControlMode = computed(() => anker.value?.controlMode || "off");
// --- Anker Solix OCPP control (per-charger provisioning + connection status) ---
const ankerCtlSerial = ref("");
const ankerCtl = ref(null); // { endpoint, token, connected, status, ... }
const ankerCtl = ref(null); // { endpoint, hasToken, tokenHint, connected, status, ... }
const ankerCtlLoading = ref(false);
const ankerCtlError = ref("");
const ankerNewToken = ref(""); // freshly generated token, shown once
async function loadAnkerControl() {
const sn = ankerCtlSerial.value.trim();
@@ -472,14 +473,36 @@ async function generateAnkerToken() {
const sn = ankerCtlSerial.value.trim();
if (!sn) return;
ankerCtlError.value = "";
ankerNewToken.value = "";
try {
await api.ankerControlToken(sn);
// The token is returned exactly once — capture it here to show the operator.
const res = await api.ankerControlToken(sn);
ankerNewToken.value = res.token || "";
await loadAnkerControl();
} catch (e) {
ankerCtlError.value = e.message;
}
}
async function revokeAnkerToken() {
const sn = ankerCtlSerial.value.trim();
if (!sn) return;
if (!confirm(t("settings.integrations.controlRevokeConfirm"))) return;
ankerCtlError.value = "";
ankerNewToken.value = "";
try {
await api.ankerControlRevoke(sn);
await loadAnkerControl();
} catch (e) {
ankerCtlError.value = e.message;
}
}
// Clear the one-time token reveal whenever the operator switches charger.
watch(ankerCtlSerial, () => {
ankerNewToken.value = "";
});
function applyAnkerView(body) {
anker.value = body;
if (ankerScope.value === "org" && !body.canEditOrg) ankerScope.value = "user";
@@ -1134,6 +1157,20 @@ onBeforeUnmount(() => {
<button class="dh-btn dh-btn-primary" :disabled="!ankerCtlSerial.trim()" @click="generateAnkerToken">
{{ t("settings.integrations.controlGenerate") }}
</button>
<button
v-if="ankerCtl && ankerCtl.hasToken"
class="dh-btn dh-btn-ghost !text-danger"
:disabled="!ankerCtlSerial.trim()"
@click="revokeAnkerToken"
>
{{ t("settings.integrations.controlRevoke") }}
</button>
</div>
<!-- The token is shown exactly once, right after generation. -->
<div v-if="ankerNewToken" class="mt-3 rounded-control border border-warning/40 bg-warning-soft px-3 py-2">
<p class="text-xs font-semibold text-warning">{{ t("settings.integrations.controlTokenOnce") }}</p>
<code class="data mt-1 block break-all text-sm text-body">{{ ankerNewToken }}</code>
</div>
<div v-if="ankerCtl" class="mt-3 grid gap-2 text-sm">
@@ -1141,9 +1178,9 @@ onBeforeUnmount(() => {
<span class="text-muted">{{ t("settings.integrations.controlEndpoint") }}: </span>
<code class="data break-all text-body">{{ ankerCtl.endpoint }}</code>
</div>
<div v-if="ankerCtl.token">
<div v-if="ankerCtl.hasToken">
<span class="text-muted">{{ t("settings.integrations.controlToken") }}: </span>
<code class="data break-all text-body">{{ ankerCtl.token }}</code>
<code class="data text-body">••••{{ ankerCtl.tokenHint }}</code>
</div>
<div class="flex items-center gap-2">
<span class="dh-badge" :class="ankerCtl.connected ? 'dh-badge-success' : 'dh-badge-warning'">