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:
tajniak81andClaude Opus 4.8 committed 2026-07-18 21:03:05 +02:00
1 parent a1519f6e89
commit 19a7d48feb
13 files changed
+883 -44

No files matched your search

+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'">