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:
1 parent
a1519f6e89
commit
19a7d48feb
13 files changed
+883
-44
No files matched your search
@@ -3,6 +3,7 @@ package api
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -30,39 +31,43 @@ type ankerControlBinding struct {
|
||||
Serial string
|
||||
}
|
||||
|
||||
// controlIndex maps an OCPP control token to its owning user + charger. It is an
|
||||
// in-memory cache, seeded when a token is (re)generated and rebuilt from
|
||||
// PocketBase the first time an unknown token connects (or lazily at startup).
|
||||
// controlIndex maps a control token's SHA-256 hash to its owning user + charger.
|
||||
// Only hashes are held (in memory and at rest), so a leak of the index or the
|
||||
// stored settings never yields a usable charger credential. It is seeded when a
|
||||
// token is (re)generated and rebuilt from PocketBase the first time an unknown
|
||||
// token connects (or lazily at startup).
|
||||
type controlIndex struct {
|
||||
mu sync.RWMutex
|
||||
byToken map[string]ankerControlBinding
|
||||
built bool
|
||||
mu sync.RWMutex
|
||||
byHash map[string]ankerControlBinding
|
||||
built bool
|
||||
}
|
||||
|
||||
func newControlIndex() *controlIndex {
|
||||
return &controlIndex{byToken: map[string]ankerControlBinding{}}
|
||||
return &controlIndex{byHash: map[string]ankerControlBinding{}}
|
||||
}
|
||||
|
||||
// lookup resolves the presented plaintext token by hashing it and matching a
|
||||
// stored hash — so the plaintext is never compared or retained.
|
||||
func (ci *controlIndex) lookup(token string) (ankerControlBinding, bool) {
|
||||
ci.mu.RLock()
|
||||
defer ci.mu.RUnlock()
|
||||
b, ok := ci.byToken[token]
|
||||
b, ok := ci.byHash[hashToken(token)]
|
||||
return b, ok
|
||||
}
|
||||
|
||||
// setUser replaces all of a user's token entries with the given charger set, so
|
||||
// a regenerated token invalidates the previous one.
|
||||
// a regenerated or revoked token immediately stops resolving.
|
||||
func (ci *controlIndex) setUser(userID string, chargers map[string]ankerChargerBinding) {
|
||||
ci.mu.Lock()
|
||||
defer ci.mu.Unlock()
|
||||
for tok, b := range ci.byToken {
|
||||
for hash, b := range ci.byHash {
|
||||
if b.UserID == userID {
|
||||
delete(ci.byToken, tok)
|
||||
delete(ci.byHash, hash)
|
||||
}
|
||||
}
|
||||
for serial, cb := range chargers {
|
||||
if cb.Token != "" {
|
||||
ci.byToken[cb.Token] = ankerControlBinding{UserID: userID, Serial: serial}
|
||||
if cb.TokenHash != "" {
|
||||
ci.byHash[cb.TokenHash] = ankerControlBinding{UserID: userID, Serial: serial}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -103,6 +108,111 @@ func (s *Server) ensureControlIndex(ctx context.Context) {
|
||||
s.control.mu.Unlock()
|
||||
}
|
||||
|
||||
// ---- rate limiting -----------------------------------------------------------
|
||||
|
||||
// rateLimiter is a simple fixed-window limiter keyed by an arbitrary string
|
||||
// (here user id + charger serial). It bounds how fast control commands can be
|
||||
// issued so a valid session can't hammer Start/Stop/Reset at a physical charger.
|
||||
//
|
||||
// It is intentionally in-memory and resets on restart: the window is one minute,
|
||||
// so a restart clears at most a sub-minute budget, and persisting per-key
|
||||
// counters would add write amplification for negligible security value.
|
||||
type rateLimiter struct {
|
||||
mu sync.Mutex
|
||||
window time.Duration
|
||||
max int
|
||||
hits map[string]*rlEntry
|
||||
}
|
||||
|
||||
type rlEntry struct {
|
||||
start time.Time
|
||||
count int
|
||||
}
|
||||
|
||||
func newRateLimiter(max int, window time.Duration) *rateLimiter {
|
||||
return &rateLimiter{window: window, max: max, hits: map[string]*rlEntry{}}
|
||||
}
|
||||
|
||||
func (rl *rateLimiter) allow(key string) bool {
|
||||
rl.mu.Lock()
|
||||
defer rl.mu.Unlock()
|
||||
now := time.Now()
|
||||
e := rl.hits[key]
|
||||
if e == nil || now.Sub(e.start) > rl.window {
|
||||
rl.hits[key] = &rlEntry{start: now, count: 1}
|
||||
return true
|
||||
}
|
||||
if e.count >= rl.max {
|
||||
return false
|
||||
}
|
||||
e.count++
|
||||
return true
|
||||
}
|
||||
|
||||
// ---- audit -------------------------------------------------------------------
|
||||
|
||||
// auditControl emits a single structured audit line for a control-plane event.
|
||||
// Control commands move a physical actuator, so every one records who did what
|
||||
// to which charger, with the outcome. (Durable persistence to PocketBase is a
|
||||
// planned follow-up; this greppable line is the first-cut trail.)
|
||||
func (s *Server) auditControl(who *callerIdentity, serial, action string, params map[string]any, result string, actErr error) {
|
||||
userID, orgID := "", ""
|
||||
if who != nil {
|
||||
userID, orgID = who.ID, who.OrgID
|
||||
}
|
||||
|
||||
entry := map[string]any{
|
||||
"audit": "anker-control",
|
||||
"ts": time.Now().UTC().Format(time.RFC3339),
|
||||
"serial": serial,
|
||||
"action": action,
|
||||
"result": result,
|
||||
"userId": userID,
|
||||
}
|
||||
if orgID != "" {
|
||||
entry["orgId"] = orgID
|
||||
}
|
||||
if len(params) > 0 {
|
||||
entry["params"] = params
|
||||
}
|
||||
if actErr != nil {
|
||||
entry["error"] = actErr.Error()
|
||||
}
|
||||
b, _ := json.Marshal(entry)
|
||||
log.Printf("AUDIT %s", b)
|
||||
|
||||
// Durable, best-effort: persist to the control_audit collection if present.
|
||||
// The structured log line above is the fallback when it is not, so a failure
|
||||
// here never blocks or fails the control action.
|
||||
if !s.pb.Configured() {
|
||||
return
|
||||
}
|
||||
rec := map[string]any{
|
||||
"user_id": userID,
|
||||
"org_id": orgID,
|
||||
"serial": serial,
|
||||
"action": action,
|
||||
"result": result,
|
||||
}
|
||||
auditParams := params
|
||||
if actErr != nil {
|
||||
auditParams = map[string]any{"error": actErr.Error()}
|
||||
for k, v := range params {
|
||||
auditParams[k] = v
|
||||
}
|
||||
}
|
||||
if len(auditParams) > 0 {
|
||||
rec["params"] = auditParams
|
||||
}
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := s.pb.Create(ctx, colControlAudit, rec, nil); err != nil {
|
||||
log.Printf("audit: could not persist control event (serial=%s action=%s): %v", serial, action, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// ---- OCPP WebSocket endpoint -------------------------------------------------
|
||||
|
||||
// handleOCPPConnect is the WebSocket endpoint a charger dials out to. It
|
||||
@@ -111,6 +221,17 @@ func (s *Server) ensureControlIndex(ctx context.Context) {
|
||||
// registers the session (own) or bridges it to Anker's cloud (proxy).
|
||||
func (s *Server) handleOCPPConnect(w http.ResponseWriter, r *http.Request) {
|
||||
serial := r.PathValue("serial")
|
||||
|
||||
// A plaintext ws:// connection would carry the charger's Basic-auth token in
|
||||
// the clear, so reject it unless TLS was explicitly made optional (dev).
|
||||
s.mu.RLock()
|
||||
requireTLS := s.cfg.OCPPRequireTLS
|
||||
s.mu.RUnlock()
|
||||
if requireTLS && !requestIsSecure(r) {
|
||||
writeError(w, http.StatusForbidden, "OCPP connections must use TLS (wss)")
|
||||
return
|
||||
}
|
||||
|
||||
user, token, ok := r.BasicAuth()
|
||||
if !ok || token == "" {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="ocpp"`)
|
||||
@@ -165,7 +286,9 @@ func (s *Server) handleOCPPConnect(w http.ResponseWriter, r *http.Request) {
|
||||
if _, err := s.ocpp.Accept(serial, ocpp.ModeOwn, conn, nil); err != nil {
|
||||
log.Printf("ocpp: accept own %s: %v", serial, err)
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
s.auditControl(who, serial, "ocpp.connect.own", nil, "accepted", nil)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -179,7 +302,9 @@ func (s *Server) handleOCPPConnect(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("ocpp: accept proxy %s: %v", serial, err)
|
||||
_ = conn.Close()
|
||||
_ = up.Close()
|
||||
return
|
||||
}
|
||||
s.auditControl(who, serial, "ocpp.connect.proxy", nil, "accepted", nil)
|
||||
}
|
||||
|
||||
// callerForUser builds a callerIdentity (and returns the pluginSettings blob) for
|
||||
@@ -223,6 +348,9 @@ func (s *Server) ankerProxyUpstream(ctx context.Context, res ankerResolution, se
|
||||
if u == "" {
|
||||
return "", "", errors.New("no OCPP endpoint URL in the Anker response")
|
||||
}
|
||||
if !ankerUpstreamAllowed(u) {
|
||||
return "", "", errors.New("Anker returned an OCPP endpoint on an untrusted host")
|
||||
}
|
||||
return u, "", nil
|
||||
}
|
||||
|
||||
@@ -247,8 +375,8 @@ func (s *Server) handleAnkerControlStatus(w http.ResponseWriter, r *http.Request
|
||||
"controlMode": res.eff.ControlMode,
|
||||
"available": res.available && res.orgEnabled && res.enabled,
|
||||
"endpoint": s.ocppEndpoint(r, sn),
|
||||
"hasToken": binding.Token != "",
|
||||
"token": binding.Token, // shown to the owner for charger provisioning
|
||||
"hasToken": binding.TokenHash != "",
|
||||
"tokenHint": binding.TokenHint, // last-4 only; the token is shown once at generation
|
||||
}
|
||||
if sess, ok := s.ocpp.SessionFor(sn); ok {
|
||||
body["connected"] = true
|
||||
@@ -284,7 +412,11 @@ func (s *Server) handleAnkerControlToken(w http.ResponseWriter, r *http.Request)
|
||||
if as.ControlChargers == nil {
|
||||
as.ControlChargers = map[string]ankerChargerBinding{}
|
||||
}
|
||||
as.ControlChargers[sn] = ankerChargerBinding{Token: token, AddedAt: time.Now().UTC().Format(time.RFC3339)}
|
||||
as.ControlChargers[sn] = ankerChargerBinding{
|
||||
TokenHash: hashToken(token),
|
||||
TokenHint: tokenHint(token),
|
||||
AddedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
updated = as.ControlChargers
|
||||
})
|
||||
if err := s.pb.Update(r.Context(), s.usersCollection(), who.ID,
|
||||
@@ -293,6 +425,9 @@ func (s *Server) handleAnkerControlToken(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
s.control.setUser(who.ID, updated)
|
||||
s.auditControl(who, sn, "token.generate", nil, "ok", nil)
|
||||
// The plaintext token is returned exactly once — it is never stored, and the
|
||||
// status endpoint only ever exposes the last-4 hint afterwards.
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"serial": sn,
|
||||
"token": token,
|
||||
@@ -300,14 +435,58 @@ func (s *Server) handleAnkerControlToken(w http.ResponseWriter, r *http.Request)
|
||||
})
|
||||
}
|
||||
|
||||
// handleAnkerControlRevoke deletes a charger's control token, so it can no longer
|
||||
// connect until a new token is generated.
|
||||
func (s *Server) handleAnkerControlRevoke(w http.ResponseWriter, r *http.Request) {
|
||||
who := caller(r)
|
||||
if who == nil {
|
||||
writeError(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
if !s.pb.Configured() {
|
||||
writeError(w, http.StatusServiceUnavailable, "integration settings not configured on the server")
|
||||
return
|
||||
}
|
||||
sn := r.PathValue("sn")
|
||||
userRaw := s.userPluginSettings(r.Context(), who.ID)
|
||||
|
||||
var updated map[string]ankerChargerBinding
|
||||
newDoc := mergeAnker(userRaw, func(as *ankerStored) {
|
||||
delete(as.ControlChargers, sn)
|
||||
updated = as.ControlChargers
|
||||
})
|
||||
if err := s.pb.Update(r.Context(), s.usersCollection(), who.ID,
|
||||
map[string]any{"pluginSettings": newDoc}, nil); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
s.control.setUser(who.ID, updated)
|
||||
// Drop any live session for this charger so an already-connected charger using
|
||||
// the revoked token is disconnected too.
|
||||
if sess, ok := s.ocpp.SessionFor(sn); ok {
|
||||
sess.Close()
|
||||
}
|
||||
s.auditControl(who, sn, "token.revoke", nil, "ok", nil)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"serial": sn, "revoked": true})
|
||||
}
|
||||
|
||||
// handleAnkerControlAction issues one OCPP command to a connected charger.
|
||||
func (s *Server) handleAnkerControlAction(w http.ResponseWriter, r *http.Request) {
|
||||
sess, ok := s.ankerControlSession(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
who := caller(r)
|
||||
sn := r.PathValue("sn")
|
||||
action := r.PathValue("action")
|
||||
|
||||
// Rate limit per user+charger so a valid session can't hammer the actuator.
|
||||
if !s.ctlRL.allow(who.ID + "|" + sn) {
|
||||
s.auditControl(who, sn, action, nil, "rate-limited", nil)
|
||||
writeError(w, http.StatusTooManyRequests, "too many control commands; please slow down")
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
IdTag string `json:"idTag"`
|
||||
ConnectorID int `json:"connectorId"`
|
||||
@@ -318,11 +497,30 @@ func (s *Server) handleAnkerControlAction(w http.ResponseWriter, r *http.Request
|
||||
RequestedMessage string `json:"requestedMessage"`
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
Confirm bool `json:"confirm"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if r.Body != nil {
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
}
|
||||
|
||||
// Destructive actions (reboot, cable unlock) demand an explicit confirmation
|
||||
// AND a fresh re-authentication (the caller re-enters their password), so they
|
||||
// can't fire by accident, from a replayed benign request, or from a session
|
||||
// left open on an unattended device.
|
||||
if isDestructiveAction(action) {
|
||||
if !body.Confirm {
|
||||
s.auditControl(who, sn, action, nil, "unconfirmed", nil)
|
||||
writeError(w, http.StatusBadRequest, "this action is destructive; resend with confirm:true")
|
||||
return
|
||||
}
|
||||
if !s.reauthenticate(r.Context(), who, body.Password) {
|
||||
s.auditControl(who, sn, action, nil, "reauth-failed", nil)
|
||||
writeError(w, http.StatusUnauthorized, "re-enter your password to confirm this action")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
var (
|
||||
status string
|
||||
@@ -362,6 +560,14 @@ func (s *Server) handleAnkerControlAction(w http.ResponseWriter, r *http.Request
|
||||
writeError(w, http.StatusBadRequest, "unknown control action: "+action)
|
||||
return
|
||||
}
|
||||
|
||||
// Audit the outcome (actor, charger, action, the meaningful params, result).
|
||||
outcome := status
|
||||
if err != nil {
|
||||
outcome = "error"
|
||||
}
|
||||
s.auditControl(who, sn, action, controlAuditParams(action, body.ConnectorID, body.Amps, body.Hard, body.Operative), outcome, err)
|
||||
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()})
|
||||
return
|
||||
@@ -373,6 +579,50 @@ func (s *Server) handleAnkerControlAction(w http.ResponseWriter, r *http.Request
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// isDestructiveAction reports whether a control action reboots the charger or
|
||||
// releases the cable lock — actions that require an explicit confirm:true and a
|
||||
// password re-authentication.
|
||||
func isDestructiveAction(action string) bool {
|
||||
return action == "reset" || action == "unlock"
|
||||
}
|
||||
|
||||
// reauthenticate verifies the caller's password against PocketBase (a sudo-style
|
||||
// step-up). It returns false on any failure — missing password, unknown email,
|
||||
// unconfigured service account, or wrong password.
|
||||
func (s *Server) reauthenticate(ctx context.Context, who *callerIdentity, password string) bool {
|
||||
if who == nil || who.Email == "" || password == "" || !s.pb.Configured() {
|
||||
return false
|
||||
}
|
||||
_, status, err := s.pb.LoginWithPassword(ctx, s.usersCollection(), who.Email, password)
|
||||
return err == nil && status == http.StatusOK
|
||||
}
|
||||
|
||||
// controlAuditParams collects the parameters worth recording for an audited
|
||||
// control action (none are sensitive).
|
||||
func controlAuditParams(action string, connectorID int, amps float64, hard bool, operative *bool) map[string]any {
|
||||
p := map[string]any{}
|
||||
switch action {
|
||||
case "limit":
|
||||
p["amps"] = amps
|
||||
p["connectorId"] = connectorID
|
||||
case "reset":
|
||||
p["hard"] = hard
|
||||
case "availability":
|
||||
p["connectorId"] = connectorID
|
||||
if operative != nil {
|
||||
p["operative"] = *operative
|
||||
}
|
||||
case "start", "stop", "unlock", "clear-limit":
|
||||
if connectorID > 0 {
|
||||
p["connectorId"] = connectorID
|
||||
}
|
||||
}
|
||||
if len(p) == 0 {
|
||||
return nil
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// ankerControlSession applies the full gate (cascade + control mode + a live
|
||||
// session the caller actually owns) and returns the charger's session.
|
||||
func (s *Server) ankerControlSession(w http.ResponseWriter, r *http.Request) (*ocpp.Session, bool) {
|
||||
@@ -402,7 +652,7 @@ func (s *Server) ankerControlSession(w http.ResponseWriter, r *http.Request) (*o
|
||||
}
|
||||
// The caller must own this charger (have a token bound to it), so a serial
|
||||
// alone can't be used to reach someone else's charger.
|
||||
if ankerBindingFor(userRaw, sn).Token == "" {
|
||||
if ankerBindingFor(userRaw, sn).TokenHash == "" {
|
||||
writeError(w, http.StatusNotFound, "no control token for this charger; generate one first")
|
||||
return nil, false
|
||||
}
|
||||
@@ -416,15 +666,48 @@ func (s *Server) ankerControlSession(w http.ResponseWriter, r *http.Request) (*o
|
||||
|
||||
// ---- helpers -----------------------------------------------------------------
|
||||
|
||||
// ocppEndpoint builds the ws(s):// URL the operator points the charger at.
|
||||
// ocppEndpoint builds the ws(s):// URL the operator points the charger at. When
|
||||
// OCPP_PUBLIC_URL is configured it is used verbatim (scheme normalized to
|
||||
// ws/wss), which is safer than trusting request headers; otherwise it is derived
|
||||
// from the request.
|
||||
func (s *Server) ocppEndpoint(r *http.Request, sn string) string {
|
||||
s.mu.RLock()
|
||||
publicURL := s.cfg.OCPPPublicURL
|
||||
s.mu.RUnlock()
|
||||
if publicURL != "" {
|
||||
base := publicURL
|
||||
if rest, ok := strings.CutPrefix(base, "https://"); ok {
|
||||
base = "wss://" + rest
|
||||
} else if rest, ok := strings.CutPrefix(base, "http://"); ok {
|
||||
base = "ws://" + rest
|
||||
}
|
||||
return strings.TrimRight(base, "/") + "/ocpp/" + url.PathEscape(sn)
|
||||
}
|
||||
scheme := "ws"
|
||||
if r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") {
|
||||
if requestIsSecure(r) {
|
||||
scheme = "wss"
|
||||
}
|
||||
return scheme + "://" + r.Host + "/ocpp/" + url.PathEscape(sn)
|
||||
}
|
||||
|
||||
// requestIsSecure reports whether a request arrived over TLS, either directly or
|
||||
// via a TLS-terminating reverse proxy (X-Forwarded-Proto). The header is only
|
||||
// trustworthy behind such a proxy — the deployment model here (see the plan).
|
||||
func requestIsSecure(r *http.Request) bool {
|
||||
return r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
|
||||
}
|
||||
|
||||
// ankerUpstreamAllowed reports whether a proxy-mode upstream URL points at an
|
||||
// Anker host, so a spoofed ocpp-info response can't steer the proxy elsewhere.
|
||||
func ankerUpstreamAllowed(raw string) bool {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
host := strings.ToLower(u.Hostname())
|
||||
return host == "anker.com" || strings.HasSuffix(host, ".anker.com")
|
||||
}
|
||||
|
||||
// ankerBindingFor reads one charger's stored control binding from a user's
|
||||
// pluginSettings blob.
|
||||
func ankerBindingFor(userRaw json.RawMessage, sn string) ankerChargerBinding {
|
||||
@@ -441,6 +724,22 @@ func genControlToken() string {
|
||||
return hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
// hashToken returns the lowercase hex SHA-256 of a control token. Only this hash
|
||||
// is ever stored or indexed.
|
||||
func hashToken(token string) string {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// tokenHint returns the last 4 characters of a token, shown in the UI so an owner
|
||||
// can tell which token is installed without exposing it.
|
||||
func tokenHint(token string) string {
|
||||
if len(token) <= 4 {
|
||||
return token
|
||||
}
|
||||
return token[len(token)-4:]
|
||||
}
|
||||
|
||||
// extractWSURL walks an arbitrary JSON value and returns the first ws:// or
|
||||
// wss:// string it finds.
|
||||
func extractWSURL(raw json.RawMessage) string {
|
||||
|
||||
Reference in new issue
Block a user