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:
co-authored by
Claude Opus 4.8
parent
a1519f6e89
commit
19a7d48feb
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'">
|
||||
|
||||
Reference in New Issue
Block a user