From d5f68e1bd9c98ce95a6c93015abbd3c752cccbd3 Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Sat, 17 Oct 2026 16:16:19 +0200 Subject: [PATCH] Server 2.1.0: pair cars by QR code instead of typing the token Modelled on the OAuth device authorization grant (RFC 8628) and on only when APPROVAL_PASSWORD is set (at least 12 characters): - POST /v1/pair/start opens a request for an installation and returns a short user code and the /pair URL for the car's QR code; POST /v1/pair/poll returns pending, the car's own token (once), denied or expired. Requests live 10 minutes, in memory. - GET/POST /pair is a script-free approval page for the phone (strict CSP, no framing). Five wrong passwords lock approvals for a minute. - Each car gets its own 43-character token; only its SHA-256 is stored (measurement "device"), and the device is persisted before the car can collect the token. Tokens are loaded at startup, so a restart keeps paired cars working; the server refuses to start if they cannot be loaded. - GET /v1/devices and POST /v1/devices/{id}/revoke, authenticated with the approval password, list and revoke cars. - The shared INGEST_TOKEN keeps working. 31 tests (race detector clean), govulncheck clean. Tested end to end against InfluxDB 2.7.12: pairing, wrong password, single collection, persistence across restarts, revocation (also across a restart), and that InfluxDB holds only the hash. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki --- server/README.md | 27 +++- server/api.go | 21 ++- server/api_test.go | 25 +++- server/main.go | 30 +++- server/pairing.go | 311 +++++++++++++++++++++++++++++++++++++++++ server/pairing_http.go | 294 ++++++++++++++++++++++++++++++++++++++ server/pairing_test.go | 234 +++++++++++++++++++++++++++++++ server/store.go | 38 +++++ 8 files changed, 970 insertions(+), 10 deletions(-) create mode 100644 server/pairing.go create mode 100644 server/pairing_http.go create mode 100644 server/pairing_test.go diff --git a/server/README.md b/server/README.md index 8dfba01..647d2cd 100644 --- a/server/README.md +++ b/server/README.md @@ -22,13 +22,16 @@ Environment only; the server refuses to start when a required value is missing. | `INGEST_TOKEN` | required, at least 24 characters: `openssl rand -hex 32` | | `ABRP_API_KEY`, `ABRP_USER_TOKEN` | optional; both needed to forward | | `ABRP_MIN_INTERVAL_SECONDS` | default 5 | +| `APPROVAL_PASSWORD` | optional, at least 12 characters; switches pairing on (see below) | +| `PUBLIC_URL` | the address a phone uses, for the pairing QR code; derived from the request if empty | | `LISTEN_ADDR` | default `:8080` | The ABRP credentials never reach the car. ## Endpoints -All but `/v1/health` need `Authorization: Bearer `. +The ingest endpoints accept `Authorization: Bearer ` with either the shared `INGEST_TOKEN` +or a paired car's own token. - `POST /v1/telemetry` — `{ installationId, samples: [...] }`, up to 500 samples in ABRP's `tlm` field names and units (`utc` in epoch seconds; flags as 0/1 or booleans). Unknown fields are @@ -41,6 +44,27 @@ All but `/v1/health` need `Authorization: Bearer `. A bad request is a 400 naming the field; a database failure is a 500 without detail. +## Pairing a car by QR code + +So nobody types a token in the car. Modelled on the OAuth device authorization grant (RFC 8628); +on only when `APPROVAL_PASSWORD` is set. + +1. The car calls `POST /v1/pair/start` `{installationId}` and shows the returned + `verificationUriComplete` as a QR code, plus the short `userCode` (e.g. `K7F-3QX`). +2. The phone opens `/pair?code=…`, the owner checks the code and approves with the approval + password. Five wrong passwords lock approvals for a minute. +3. The car polls `POST /v1/pair/poll` `{deviceCode}`: 202 pending, 200 with its own token + (handed out once), 403 denied, 410 expired (after 10 minutes) or unknown. + +Each car gets its own 43-character token; only its SHA-256 is stored (measurement `device` in +the same bucket), and the device is persisted before the car can collect the token, so a +restart never loses a paired car. Manage cars with the approval password: + +```sh +curl -H "Authorization: Bearer $APPROVAL_PASSWORD" https://car.stinnesbeck.com/v1/devices +curl -X POST -H "Authorization: Bearer $APPROVAL_PASSWORD" https://car.stinnesbeck.com/v1/devices//revoke +``` + ## Storage One bucket, `installationId` tag on every point: @@ -51,6 +75,7 @@ One bucket, `installationId` tag on every point: | `consumption_chunk` | `recordedAt` | `date` | km, kwh, hours, socDrop, sohPercent | | `charging_session` | `startedAt` | `sessionId` | endedAtMs, duration, energy, cost, start/end SOC | | `charging_curve` | point time | `sessionId` | powerKw, socPercent | +| `device` | paired at | `deviceId` | tokenHash, installationId, revoked | A point with the same measurement, tags and timestamp replaces the earlier one, so the car's retries are idempotent: a cached chunk is re-sent with its original `recordedAt`. Days and the diff --git a/server/api.go b/server/api.go index 89b8923..ce1390e 100644 --- a/server/api.go +++ b/server/api.go @@ -17,10 +17,17 @@ import ( // PUT /v1/charging-sessions/{sessionId} one finished DC charging session // GET /v1/consumption/history/{installationId} the car reads its own totals back // GET /v1/health unauthenticated liveness probe +// +// A car authenticates with the shared INGEST_TOKEN or with its own token from pairing +// (pairing_http.go). type API struct { Store Store IngestToken string Forwarder *Forwarder + Pairing *Pairing + // PublicURL is the server's address as a phone reaches it, for the pairing QR code; + // derived from the request when empty. + PublicURL string } const maxBodyBytes = 512 * 1024 @@ -34,18 +41,24 @@ func (a *API) Routes() http.Handler { mux.HandleFunc("PUT /v1/consumption/daily", a.ingest(a.consumption)) mux.HandleFunc("PUT /v1/charging-sessions/{sessionId}", a.ingest(a.chargingSession)) mux.HandleFunc("GET /v1/consumption/history/{installationId}", a.ingest(a.history)) + a.pairingRoutes(mux) return mux } -// hasIngestToken compares in constant time. main refuses to start with a token under 24 -// characters, so an empty or short configured token can never match here. +// hasIngestToken accepts the shared token (compared in constant time; main refuses to start +// with one under 24 characters, so an empty or short one never matches) or a paired car's +// own, unrevoked token. func (a *API) hasIngestToken(r *http.Request) bool { header := r.Header.Get("Authorization") supplied, ok := strings.CutPrefix(header, "Bearer ") - if !ok || len(a.IngestToken) < minIngestTokenLength { + if !ok || supplied == "" { return false } - return subtle.ConstantTimeCompare([]byte(supplied), []byte(a.IngestToken)) == 1 + if len(a.IngestToken) >= minIngestTokenLength && + subtle.ConstantTimeCompare([]byte(supplied), []byte(a.IngestToken)) == 1 { + return true + } + return a.Pairing.Authorized(supplied) } type handler func(w http.ResponseWriter, r *http.Request, body []byte) error diff --git a/server/api_test.go b/server/api_test.go index c466fde..c3832ae 100644 --- a/server/api_test.go +++ b/server/api_test.go @@ -16,6 +16,7 @@ type fakeStore struct { telemetry []TelemetrySample chunks []ConsumptionChunk sessions []ChargingSession + devices []Device fail error } @@ -46,17 +47,37 @@ func (f *fakeStore) WriteChargingSession(_ context.Context, s ChargingSession) e return f.fail } +func (f *fakeStore) SaveDevice(_ context.Context, d Device) error { + f.mu.Lock() + defer f.mu.Unlock() + if f.fail != nil { + return f.fail + } + f.devices = append(f.devices, d) + return nil +} + +func (f *fakeStore) LoadDevices(context.Context) ([]Device, error) { + f.mu.Lock() + defer f.mu.Unlock() + return f.devices, f.fail +} + func (f *fakeStore) ReadConsumptionChunks(context.Context, string) ([]ConsumptionChunk, error) { f.mu.Lock() defer f.mu.Unlock() return f.chunks, f.fail } -const testToken = "ingest-test-token-0123456789abcdef" +const ( + testToken = "ingest-test-token-0123456789abcdef" + testApprovalPassword = "approval-password-123" +) func newTestAPI() (*API, *fakeStore) { store := &fakeStore{} - return &API{Store: store, IngestToken: testToken, Forwarder: &Forwarder{}}, store + return &API{Store: store, IngestToken: testToken, Forwarder: &Forwarder{}, + Pairing: NewPairing(testApprovalPassword, nil), PublicURL: "https://car.example.com"}, store } func do(t *testing.T, h http.Handler, method, path, token, body string) *httptest.ResponseRecorder { diff --git a/server/main.go b/server/main.go index 18ca0b6..6bc6eb7 100644 --- a/server/main.go +++ b/server/main.go @@ -16,7 +16,10 @@ import ( "time" ) -const minIngestTokenLength = 24 +const ( + minIngestTokenLength = 24 + minApprovalPasswordLength = 12 +) type config struct { listenAddr string @@ -28,6 +31,8 @@ type config struct { abrpAPIKey string abrpUserToken string abrpMinInterval time.Duration + approvalPass string + publicURL string } // loadConfig reads the environment and fails closed: a missing InfluxDB setting or a @@ -42,6 +47,8 @@ func loadConfig(getenv func(string) string) (config, error) { ingestToken: getenv("INGEST_TOKEN"), abrpAPIKey: getenv("ABRP_API_KEY"), abrpUserToken: getenv("ABRP_USER_TOKEN"), + approvalPass: getenv("APPROVAL_PASSWORD"), + publicURL: getenv("PUBLIC_URL"), } if c.listenAddr == "" { c.listenAddr = ":8080" @@ -57,6 +64,11 @@ func loadConfig(getenv func(string) string) (config, error) { if len(c.ingestToken) < minIngestTokenLength { return config{}, fmt.Errorf("INGEST_TOKEN must be at least %d characters", minIngestTokenLength) } + // Pairing is optional; when it is on, its password guards token issuance and must not + // be guessable. + if c.approvalPass != "" && len(c.approvalPass) < minApprovalPasswordLength { + return config{}, fmt.Errorf("APPROVAL_PASSWORD must be at least %d characters", minApprovalPasswordLength) + } c.abrpMinInterval = 5 * time.Second if raw := getenv("ABRP_MIN_INTERVAL_SECONDS"); raw != "" { seconds, err := strconv.ParseFloat(raw, 64) @@ -84,7 +96,18 @@ func main() { MinInterval: cfg.abrpMinInterval, Client: &http.Client{Timeout: 10 * time.Second}, } - api := &API{Store: store, IngestToken: cfg.ingestToken, Forwarder: forwarder} + // Paired cars' tokens must be known before the first request; starting without them + // would answer every paired car with 401, so a failed load stops the start instead. + loadCtx, cancelLoad := context.WithTimeout(context.Background(), 30*time.Second) + devices, err := store.LoadDevices(loadCtx) + cancelLoad() + if err != nil { + slog.Error("paired devices could not be loaded", "error", err) + os.Exit(1) + } + pairing := NewPairing(cfg.approvalPass, devices) + api := &API{Store: store, IngestToken: cfg.ingestToken, Forwarder: forwarder, + Pairing: pairing, PublicURL: cfg.publicURL} srv := &http.Server{ Addr: cfg.listenAddr, @@ -98,7 +121,8 @@ func main() { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() go func() { - slog.Info("listening", "addr", cfg.listenAddr, "bucket", cfg.influxBucket, "abrp", forwarder.configured()) + slog.Info("listening", "addr", cfg.listenAddr, "bucket", cfg.influxBucket, + "abrp", forwarder.configured(), "pairing", pairing.Enabled(), "pairedDevices", len(devices)) if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { slog.Error("server failed", "error", err) os.Exit(1) diff --git a/server/pairing.go b/server/pairing.go new file mode 100644 index 0000000..f67438c --- /dev/null +++ b/server/pairing.go @@ -0,0 +1,311 @@ +package main + +import ( + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/hex" + "errors" + "strings" + "sync" + "time" +) + +// Pairing lets a car get its own ingest token without anyone typing it: the car asks for a +// pairing request and shows its code as a QR code, a phone opens the approval page, the +// owner signs in with the approval password and approves, and the car — polling meanwhile — +// receives a token once. Modelled on the OAuth device authorization grant (RFC 8628). +// +// Pending requests live in memory only: they last ten minutes, and the server runs as one +// replica. Approved devices are persisted (see Device) so their tokens survive a restart. + +const ( + pairingTTL = 10 * time.Minute + pairingInterval = 5 * time.Second + maxPendingPairing = 50 + // After this many wrong approval passwords in a window, approvals pause for the window. + maxFailedLogins = 5 + failedLoginsWindow = time.Minute + // Unambiguous characters only: no 0/O, 1/I/L, so a code reads right off a car screen. + userCodeAlphabet = "ABCDEFGHJKMNPQRSTUVWXYZ23456789" +) + +var ( + errUnknownCode = errors.New("unknown or expired code") + errNotPending = errors.New("request already decided") + errTooManyFailed = errors.New("too many wrong passwords, try again in a minute") + errWrongPassword = errors.New("wrong password") + errTooMany = errors.New("too many pending pairing requests") +) + +// Device is a paired car. Only the SHA-256 of its token is kept. +type Device struct { + ID string + InstallationID string + TokenHash string + CreatedAt time.Time + Revoked bool +} + +type pairingState int + +const ( + pairingPending pairingState = iota + pairingApproved + pairingDenied +) + +type pairingRequest struct { + deviceCode string + userCode string + installationID string + expires time.Time + state pairingState + token string // set on approval, handed out once + deviceID string +} + +// Pairing holds pending requests and the known devices. The clock is injectable for tests. +type Pairing struct { + ApprovalPassword string + Now func() time.Time + + mu sync.Mutex + byDevice map[string]*pairingRequest + byUser map[string]*pairingRequest + devices map[string]Device // by token hash + failures []time.Time +} + +func NewPairing(password string, devices []Device) *Pairing { + p := &Pairing{ + ApprovalPassword: password, + Now: time.Now, + byDevice: map[string]*pairingRequest{}, + byUser: map[string]*pairingRequest{}, + devices: map[string]Device{}, + } + for _, d := range devices { + p.devices[d.TokenHash] = d + } + return p +} + +func (p *Pairing) Enabled() bool { return p != nil && p.ApprovalPassword != "" } + +func randomString(n int) string { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + panic(err) // crypto/rand does not fail on supported platforms + } + return base64.RawURLEncoding.EncodeToString(b) +} + +func newUserCode() string { + b := make([]byte, 6) + if _, err := rand.Read(b); err != nil { + panic(err) + } + code := make([]byte, 0, 7) + for i, v := range b { + if i == 3 { + code = append(code, '-') + } + // 256 % 31 leaves a slight bias; irrelevant for a 10-minute code that also needs + // the approval password. + code = append(code, userCodeAlphabet[int(v)%len(userCodeAlphabet)]) + } + return string(code) +} + +// normalizeUserCode accepts what a person types: any case, with or without the dash. +func normalizeUserCode(code string) string { + code = strings.ToUpper(strings.TrimSpace(code)) + code = strings.ReplaceAll(code, "-", "") + code = strings.ReplaceAll(code, " ", "") + if len(code) != 6 { + return "" + } + return code[:3] + "-" + code[3:] +} + +func hashToken(token string) string { + sum := sha256.Sum256([]byte(token)) + return hex.EncodeToString(sum[:]) +} + +func (p *Pairing) expireLocked(now time.Time) { + for code, r := range p.byDevice { + if now.After(r.expires) { + delete(p.byDevice, code) + delete(p.byUser, r.userCode) + } + } +} + +// Start opens a pairing request for a car. +func (p *Pairing) Start(installationID string) (*pairingRequest, error) { + p.mu.Lock() + defer p.mu.Unlock() + now := p.Now() + p.expireLocked(now) + if len(p.byDevice) >= maxPendingPairing { + return nil, errTooMany + } + r := &pairingRequest{ + deviceCode: randomString(32), + installationID: installationID, + expires: now.Add(pairingTTL), + } + for { + r.userCode = newUserCode() + if _, taken := p.byUser[r.userCode]; !taken { + break + } + } + p.byDevice[r.deviceCode] = r + p.byUser[r.userCode] = r + out := *r + return &out, nil +} + +// Lookup returns the pending request for a user code, for the approval page. +func (p *Pairing) Lookup(userCode string) (pairingRequest, error) { + p.mu.Lock() + defer p.mu.Unlock() + p.expireLocked(p.Now()) + r, ok := p.byUser[normalizeUserCode(userCode)] + if !ok { + return pairingRequest{}, errUnknownCode + } + return *r, nil +} + +// checkPassword counts failures and refuses everything while too many are recent. +func (p *Pairing) checkPasswordLocked(password string, now time.Time) error { + recent := p.failures[:0] + for _, t := range p.failures { + if now.Sub(t) < failedLoginsWindow { + recent = append(recent, t) + } + } + p.failures = recent + if len(p.failures) >= maxFailedLogins { + return errTooManyFailed + } + if subtle.ConstantTimeCompare([]byte(password), []byte(p.ApprovalPassword)) != 1 { + p.failures = append(p.failures, now) + return errWrongPassword + } + return nil +} + +// Decide approves or denies a pending request. On approval it returns the new device, which +// the caller must persist before the car can collect the token. +func (p *Pairing) Decide(userCode, password string, approve bool) (Device, error) { + p.mu.Lock() + defer p.mu.Unlock() + now := p.Now() + if err := p.checkPasswordLocked(password, now); err != nil { + return Device{}, err + } + p.expireLocked(now) + r, ok := p.byUser[normalizeUserCode(userCode)] + if !ok { + return Device{}, errUnknownCode + } + if r.state != pairingPending { + return Device{}, errNotPending + } + if !approve { + r.state = pairingDenied + return Device{}, nil + } + token := randomString(32) // 43 characters + d := Device{ + ID: randomString(9), + InstallationID: r.installationID, + TokenHash: hashToken(token), + CreatedAt: now, + } + r.state = pairingApproved + r.token = token + r.deviceID = d.ID + p.devices[d.TokenHash] = d + return d, nil +} + +// Forget undoes an approval whose device could not be persisted, so no token is handed out +// that would stop working at the next restart. +func (p *Pairing) Forget(userCode string, d Device) { + p.mu.Lock() + defer p.mu.Unlock() + delete(p.devices, d.TokenHash) + if r, ok := p.byUser[normalizeUserCode(userCode)]; ok { + r.state = pairingPending + r.token = "" + r.deviceID = "" + } +} + +// Poll is what the car calls: pending, the token (exactly once), denied, or unknown. +func (p *Pairing) Poll(deviceCode string) (state pairingState, token, deviceID string, err error) { + p.mu.Lock() + defer p.mu.Unlock() + p.expireLocked(p.Now()) + r, ok := p.byDevice[deviceCode] + if !ok { + return 0, "", "", errUnknownCode + } + switch r.state { + case pairingApproved, pairingDenied: + // Decided: hand the outcome out once, then forget the request. + delete(p.byDevice, r.deviceCode) + delete(p.byUser, r.userCode) + } + return r.state, r.token, r.deviceID, nil +} + +// Authorized reports whether a token belongs to a paired, unrevoked device. +func (p *Pairing) Authorized(token string) bool { + if p == nil || token == "" { + return false + } + p.mu.Lock() + defer p.mu.Unlock() + d, ok := p.devices[hashToken(token)] + return ok && !d.Revoked +} + +func (p *Pairing) Devices() []Device { + p.mu.Lock() + defer p.mu.Unlock() + out := make([]Device, 0, len(p.devices)) + for _, d := range p.devices { + out = append(out, d) + } + return out +} + +// Revoke marks a device revoked and returns it for persisting. +func (p *Pairing) Revoke(id string) (Device, bool) { + p.mu.Lock() + defer p.mu.Unlock() + for hash, d := range p.devices { + if d.ID == id { + d.Revoked = true + p.devices[hash] = d + return d, true + } + } + return Device{}, false +} + +// CheckAdmin verifies the approval password for the device-management API, with the same +// failure counting as the approval page. +func (p *Pairing) CheckAdmin(password string) error { + p.mu.Lock() + defer p.mu.Unlock() + return p.checkPasswordLocked(password, p.Now()) +} diff --git a/server/pairing_http.go b/server/pairing_http.go new file mode 100644 index 0000000..f8341f1 --- /dev/null +++ b/server/pairing_http.go @@ -0,0 +1,294 @@ +package main + +import ( + "errors" + "html/template" + "io" + "log/slog" + "net/http" + "net/url" + "strings" + "time" +) + +// HTTP side of pairing. +// +// POST /v1/pair/start car: open a request {installationId} → codes and URL +// POST /v1/pair/poll car: collect the outcome {deviceCode} → 202 / 200 token / 403 / 410 +// GET /pair[?code=XXX-XXX] phone: approval page +// POST /pair phone: approve or deny (needs the approval password) +// GET /v1/devices list paired cars Bearer +// POST /v1/devices/{id}/revoke revoke a car's token Bearer +// +// Start and poll need no token — the car does not have one yet. What they hand out is +// worthless until a person who knows the approval password approves it. + +func (a *API) pairingRoutes(mux *http.ServeMux) { + mux.HandleFunc("POST /v1/pair/start", a.pairStart) + mux.HandleFunc("POST /v1/pair/poll", a.pairPoll) + mux.HandleFunc("GET /pair", a.pairPage) + mux.HandleFunc("POST /pair", a.pairDecide) + mux.HandleFunc("GET /v1/devices", a.listDevices) + mux.HandleFunc("POST /v1/devices/{id}/revoke", a.revokeDevice) +} + +func (a *API) pairingDisabled(w http.ResponseWriter) bool { + if a.Pairing.Enabled() { + return false + } + writeJSON(w, http.StatusNotFound, map[string]string{"error": "pairing is not enabled"}) + return true +} + +func readSmallJSON(r *http.Request) (object, error) { + body, err := io.ReadAll(io.LimitReader(r.Body, 4097)) + if err != nil || len(body) > 4096 { + return nil, invalid("body is too large") + } + return decodeObject(body) +} + +// publicURL is where the phone should go: PUBLIC_URL when set, otherwise what the request +// came in on (behind Traefik, the forwarded scheme and host). +func (a *API) publicURL(r *http.Request) string { + if a.PublicURL != "" { + return strings.TrimRight(a.PublicURL, "/") + } + scheme := "http" + if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" { + scheme = "https" + } + host := r.Header.Get("X-Forwarded-Host") + if host == "" { + host = r.Host + } + return scheme + "://" + host +} + +func (a *API) pairStart(w http.ResponseWriter, r *http.Request) { + if a.pairingDisabled(w) { + return + } + o, err := readSmallJSON(r) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + id, err := validUUID(o["installationId"], "installationId") + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + req, err := a.Pairing.Start(id) + if err != nil { + writeJSON(w, http.StatusTooManyRequests, map[string]string{"error": err.Error()}) + return + } + base := a.publicURL(r) + "/pair" + writeJSON(w, http.StatusCreated, map[string]any{ + "deviceCode": req.deviceCode, + "userCode": req.userCode, + "verificationUri": base, + "verificationUriComplete": base + "?code=" + url.QueryEscape(req.userCode), + "expiresIn": int(pairingTTL / time.Second), + "interval": int(pairingInterval / time.Second), + }) +} + +func (a *API) pairPoll(w http.ResponseWriter, r *http.Request) { + if a.pairingDisabled(w) { + return + } + o, err := readSmallJSON(r) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + code, _ := o["deviceCode"].(string) + state, token, deviceID, err := a.Pairing.Poll(code) + switch { + case err != nil: + writeJSON(w, http.StatusGone, map[string]string{"error": "expired_token"}) + case state == pairingPending: + writeJSON(w, http.StatusAccepted, map[string]string{"status": "authorization_pending"}) + case state == pairingDenied: + writeJSON(w, http.StatusForbidden, map[string]string{"error": "access_denied"}) + default: + writeJSON(w, http.StatusOK, map[string]string{"token": token, "deviceId": deviceID}) + } +} + +// ---------- Approval page ---------- + +type pageData struct { + Code string + Message string + Error string + Done bool +} + +var pairTemplate = template.Must(template.New("pair").Parse(` + +Pair a car — Nils' MG4 +
+

Pair a car

+{{if .Done}} +

{{.Message}}

+{{else}} +

Check that this code matches the one on the car's screen, then approve with the approval password.

+
+{{if .Code}}
{{.Code}}
+{{else}} +{{end}} + + + +{{if .Error}}

{{.Error}}

{{end}} +
+
+
+{{end}} +
`)) + +func renderPage(w http.ResponseWriter, status int, data pageData) { + h := w.Header() + h.Set("Content-Type", "text/html; charset=utf-8") + // No scripts at all, no framing, forms only back to this page. + h.Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'") + h.Set("X-Content-Type-Options", "nosniff") + h.Set("Referrer-Policy", "no-referrer") + h.Set("Cache-Control", "no-store") + w.WriteHeader(status) + if err := pairTemplate.Execute(w, data); err != nil { + slog.Warn("pair page not rendered", "error", err) + } +} + +func (a *API) pairPage(w http.ResponseWriter, r *http.Request) { + if !a.Pairing.Enabled() { + http.NotFound(w, r) + return + } + data := pageData{} + if raw := r.URL.Query().Get("code"); raw != "" { + if req, err := a.Pairing.Lookup(raw); err == nil && req.state == pairingPending { + data.Code = req.userCode + } else { + data.Error = "This code is unknown or has expired. Start pairing again on the car." + } + } + renderPage(w, http.StatusOK, data) +} + +func (a *API) pairDecide(w http.ResponseWriter, r *http.Request) { + if !a.Pairing.Enabled() { + http.NotFound(w, r) + return + } + r.Body = http.MaxBytesReader(w, r.Body, 4096) + if err := r.ParseForm(); err != nil { + renderPage(w, http.StatusBadRequest, pageData{Error: "The form could not be read."}) + return + } + code := normalizeUserCode(r.PostForm.Get("code")) + approve := r.PostForm.Get("action") != "deny" + device, err := a.Pairing.Decide(code, r.PostForm.Get("password"), approve) + switch { + case errors.Is(err, errWrongPassword): + renderPage(w, http.StatusUnauthorized, pageData{Code: code, Error: "Wrong password."}) + return + case errors.Is(err, errTooManyFailed): + renderPage(w, http.StatusTooManyRequests, pageData{Code: code, Error: err.Error() + "."}) + return + case errors.Is(err, errUnknownCode), errors.Is(err, errNotPending): + renderPage(w, http.StatusNotFound, pageData{Error: "This code is unknown, expired or already used. Start pairing again on the car."}) + return + case err != nil: + renderPage(w, http.StatusInternalServerError, pageData{Code: code, Error: "Something went wrong."}) + return + } + if !approve { + renderPage(w, http.StatusOK, pageData{Done: true, Message: "Denied. The car was not paired."}) + return + } + // Persist before the car can collect the token: a token that vanished at the next + // restart would look like a pairing that worked. + if err := a.Store.SaveDevice(r.Context(), device); err != nil { + a.Pairing.Forget(code, device) + slog.Error("device not saved", "error", err) + renderPage(w, http.StatusInternalServerError, pageData{Code: code, Error: "The car could not be saved. Try again."}) + return + } + slog.Info("car paired", "device", device.ID, "installation", device.InstallationID) + renderPage(w, http.StatusOK, pageData{Done: true, Message: "Approved. The car finishes pairing on its own within a few seconds — you can close this page."}) +} + +// ---------- Device management ---------- + +func (a *API) adminAuthorized(w http.ResponseWriter, r *http.Request) bool { + if a.pairingDisabled(w) { + return false + } + supplied, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ") + if !ok { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"}) + return false + } + switch err := a.Pairing.CheckAdmin(supplied); { + case errors.Is(err, errTooManyFailed): + writeJSON(w, http.StatusTooManyRequests, map[string]string{"error": err.Error()}) + return false + case err != nil: + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"}) + return false + } + return true +} + +type deviceJSON struct { + ID string `json:"id"` + InstallationID string `json:"installationId"` + CreatedAt time.Time `json:"createdAt"` + Revoked bool `json:"revoked"` +} + +func (a *API) listDevices(w http.ResponseWriter, r *http.Request) { + if !a.adminAuthorized(w, r) { + return + } + out := []deviceJSON{} + for _, d := range a.Pairing.Devices() { + out = append(out, deviceJSON{d.ID, d.InstallationID, d.CreatedAt, d.Revoked}) + } + writeJSON(w, http.StatusOK, map[string]any{"devices": out}) +} + +func (a *API) revokeDevice(w http.ResponseWriter, r *http.Request) { + if !a.adminAuthorized(w, r) { + return + } + d, ok := a.Pairing.Revoke(r.PathValue("id")) + if !ok { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such device"}) + return + } + if err := a.Store.SaveDevice(r.Context(), d); err != nil { + slog.Error("revocation not saved", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "revoked until restart only; saving failed"}) + return + } + slog.Info("car revoked", "device", d.ID) + w.WriteHeader(http.StatusNoContent) +} diff --git a/server/pairing_test.go b/server/pairing_test.go new file mode 100644 index 0000000..fce9b53 --- /dev/null +++ b/server/pairing_test.go @@ -0,0 +1,234 @@ +package main + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "regexp" + "strings" + "testing" + "time" +) + +func startPairing(t *testing.T, h http.Handler) (deviceCode, userCode, uriComplete string) { + t.Helper() + rec := do(t, h, "POST", "/v1/pair/start", "", `{"installationId":"`+testInstallation+`"}`) + if rec.Code != 201 { + t.Fatalf("start: %d %s", rec.Code, rec.Body) + } + var got map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + return got["deviceCode"].(string), got["userCode"].(string), got["verificationUriComplete"].(string) +} + +func poll(t *testing.T, h http.Handler, deviceCode string) (int, map[string]string) { + t.Helper() + rec := do(t, h, "POST", "/v1/pair/poll", "", `{"deviceCode":"`+deviceCode+`"}`) + var got map[string]string + _ = json.Unmarshal(rec.Body.Bytes(), &got) + return rec.Code, got +} + +func submit(t *testing.T, h http.Handler, code, password, action string) *httptest.ResponseRecorder { + t.Helper() + form := url.Values{"code": {code}, "password": {password}, "action": {action}} + req := httptest.NewRequest("POST", "/pair", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec +} + +func TestPairingHappyPath(t *testing.T) { + api, store := newTestAPI() + h := api.Routes() + deviceCode, userCode, uri := startPairing(t, h) + if !regexpUserCode.MatchString(userCode) { + t.Fatalf("user code %q", userCode) + } + if uri != "https://car.example.com/pair?code="+userCode { + t.Fatalf("verification URI %q", uri) + } + if status, _ := poll(t, h, deviceCode); status != 202 { + t.Fatalf("before approval: want 202, got %d", status) + } + + // The phone opens the link: the page shows the code and asks for the password. + page := do(t, h, "GET", "/pair?code="+strings.ToLower(strings.ReplaceAll(userCode, "-", "")), "", "") + if page.Code != 200 || !strings.Contains(page.Body.String(), userCode) { + t.Fatalf("page: %d", page.Code) + } + if csp := page.Header().Get("Content-Security-Policy"); !strings.Contains(csp, "frame-ancestors 'none'") { + t.Fatalf("CSP %q", csp) + } + + if rec := submit(t, h, userCode, testApprovalPassword, "approve"); rec.Code != 200 || !strings.Contains(rec.Body.String(), "Approved") { + t.Fatalf("approve: %d %s", rec.Code, rec.Body) + } + if len(store.devices) != 1 || store.devices[0].InstallationID != testInstallation { + t.Fatalf("device not persisted: %+v", store.devices) + } + status, got := poll(t, h, deviceCode) + if status != 200 || len(got["token"]) < minIngestTokenLength || got["deviceId"] == "" { + t.Fatalf("after approval: %d %v", status, got) + } + // Only the hash is stored, never the token. + if store.devices[0].TokenHash == got["token"] || store.devices[0].TokenHash != hashToken(got["token"]) { + t.Fatal("stored token hash is wrong") + } + // The token is handed out once. + if status, _ := poll(t, h, deviceCode); status != 410 { + t.Fatalf("second collection: want 410, got %d", status) + } + // And it works on the ingest endpoints. + rec := do(t, h, "POST", "/v1/telemetry", got["token"], string(telemetryBody(`{"utc":1790000000,"soc":80}`))) + if rec.Code != 204 { + t.Fatalf("paired token on telemetry: %d %s", rec.Code, rec.Body) + } +} + +func TestPairingWrongPasswordAndLockout(t *testing.T) { + api, store := newTestAPI() + h := api.Routes() + deviceCode, userCode, _ := startPairing(t, h) + for i := 0; i < maxFailedLogins; i++ { + if rec := submit(t, h, userCode, "wrong", "approve"); rec.Code != 401 { + t.Fatalf("attempt %d: want 401, got %d", i, rec.Code) + } + } + // Locked now — even the right password waits. + if rec := submit(t, h, userCode, testApprovalPassword, "approve"); rec.Code != 429 { + t.Fatalf("locked: want 429, got %d", rec.Code) + } + if status, _ := poll(t, h, deviceCode); status != 202 || len(store.devices) != 0 { + t.Fatal("a locked-out attempt changed the request") + } + // After the window the right password works again. + api.Pairing.Now = func() time.Time { return time.Now().Add(failedLoginsWindow + time.Second) } + if rec := submit(t, h, userCode, testApprovalPassword, "approve"); rec.Code != 200 { + t.Fatalf("after the window: want 200, got %d %s", rec.Code, rec.Body) + } +} + +func TestPairingDenyAndExpiry(t *testing.T) { + api, _ := newTestAPI() + h := api.Routes() + deviceCode, userCode, _ := startPairing(t, h) + if rec := submit(t, h, userCode, testApprovalPassword, "deny"); rec.Code != 200 || !strings.Contains(rec.Body.String(), "Denied") { + t.Fatalf("deny: %d", rec.Code) + } + if status, _ := poll(t, h, deviceCode); status != 403 { + t.Fatalf("denied: want 403, got %d", status) + } + if rec := submit(t, h, userCode, testApprovalPassword, "approve"); rec.Code != 404 { + t.Fatalf("a decided code was approvable: %d", rec.Code) + } + + deviceCode, userCode, _ = startPairing(t, h) + api.Pairing.Now = func() time.Time { return time.Now().Add(pairingTTL + time.Second) } + if status, _ := poll(t, h, deviceCode); status != 410 { + t.Fatalf("expired: want 410, got %d", status) + } + if rec := submit(t, h, userCode, testApprovalPassword, "approve"); rec.Code != 404 { + t.Fatalf("an expired code was approvable: %d", rec.Code) + } +} + +func TestPairingNotPersistedMeansNoToken(t *testing.T) { + api, store := newTestAPI() + h := api.Routes() + deviceCode, userCode, _ := startPairing(t, h) + store.fail = errors.New("influx down") + if rec := submit(t, h, userCode, testApprovalPassword, "approve"); rec.Code != 500 { + t.Fatalf("want 500, got %d", rec.Code) + } + if status, _ := poll(t, h, deviceCode); status != 202 { + t.Fatalf("a token was handed out that was never saved: %d", status) + } + store.fail = nil + if rec := submit(t, h, userCode, testApprovalPassword, "approve"); rec.Code != 200 { + t.Fatalf("retry: %d", rec.Code) + } + if status, _ := poll(t, h, deviceCode); status != 200 { + t.Fatalf("after retry: %d", status) + } +} + +func TestRevokedTokenIsRejected(t *testing.T) { + api, store := newTestAPI() + h := api.Routes() + deviceCode, userCode, _ := startPairing(t, h) + submit(t, h, userCode, testApprovalPassword, "approve") + _, got := poll(t, h, deviceCode) + token := got["token"] + + if rec := do(t, h, "GET", "/v1/devices", "wrong-password", ""); rec.Code != 401 { + t.Fatalf("devices without the password: %d", rec.Code) + } + if rec := do(t, h, "GET", "/v1/devices", token, ""); rec.Code != 401 { + t.Fatal("a car token opened the device list") + } + rec := do(t, h, "GET", "/v1/devices", testApprovalPassword, "") + if rec.Code != 200 || !strings.Contains(rec.Body.String(), got["deviceId"]) || strings.Contains(rec.Body.String(), store.devices[0].TokenHash) { + t.Fatalf("list: %d %s", rec.Code, rec.Body) + } + if rec := do(t, h, "POST", "/v1/devices/"+got["deviceId"]+"/revoke", testApprovalPassword, ""); rec.Code != 204 { + t.Fatalf("revoke: %d", rec.Code) + } + if last := store.devices[len(store.devices)-1]; !last.Revoked { + t.Fatal("revocation not persisted") + } + if rec := do(t, h, "POST", "/v1/telemetry", token, string(telemetryBody(`{"utc":1790000000}`))); rec.Code != 401 { + t.Fatalf("revoked token accepted: %d", rec.Code) + } + // The shared ingest token is unaffected. + if rec := do(t, h, "POST", "/v1/telemetry", testToken, string(telemetryBody(`{"utc":1790000000,"soc":1}`))); rec.Code != 204 { + t.Fatalf("shared token: %d", rec.Code) + } +} + +func TestPairedDevicesSurviveARestart(t *testing.T) { + devices := []Device{{ID: "d1", TokenHash: hashToken("restored-token-0123456789abcdef"), CreatedAt: time.Now()}, + {ID: "d2", TokenHash: hashToken("revoked-token-0123456789abcdef"), CreatedAt: time.Now(), Revoked: true}} + p := NewPairing(testApprovalPassword, devices) + if !p.Authorized("restored-token-0123456789abcdef") || p.Authorized("revoked-token-0123456789abcdef") { + t.Fatal("restored devices not honoured") + } +} + +func TestPairingOffWithoutAPassword(t *testing.T) { + api, _ := newTestAPI() + api.Pairing = NewPairing("", nil) + h := api.Routes() + if rec := do(t, h, "POST", "/v1/pair/start", "", `{"installationId":"`+testInstallation+`"}`); rec.Code != 404 { + t.Fatalf("start: %d", rec.Code) + } + if rec := do(t, h, "GET", "/pair", "", ""); rec.Code != 404 { + t.Fatalf("page: %d", rec.Code) + } + if rec := do(t, h, "GET", "/v1/devices", "", ""); rec.Code != 404 { + t.Fatalf("devices: %d", rec.Code) + } +} + +func TestUserCodesNormalise(t *testing.T) { + for in, want := range map[string]string{"k7f-3qx": "K7F-3QX", " K7F3QX ": "K7F-3QX", "k7f 3qx": "K7F-3QX", "K7F-3Q": "", "": ""} { + if got := normalizeUserCode(in); got != want { + t.Errorf("%q: got %q, want %q", in, got, want) + } + } +} + +func TestApprovalPasswordMustBeLongEnough(t *testing.T) { + env := map[string]string{"INFLUX_URL": "u", "INFLUX_TOKEN": "t", "INFLUX_ORG": "o", + "INGEST_TOKEN": "0123456789abcdef01234567", "APPROVAL_PASSWORD": "short"} + if _, err := loadConfig(func(k string) string { return env[k] }); err == nil { + t.Fatal("short approval password accepted") + } +} + +var regexpUserCode = regexp.MustCompile(`^[` + userCodeAlphabet + `]{3}-[` + userCodeAlphabet + `]{3}$`) diff --git a/server/store.go b/server/store.go index 9f49601..0dab1a8 100644 --- a/server/store.go +++ b/server/store.go @@ -17,6 +17,8 @@ type Store interface { WriteConsumptionChunk(ctx context.Context, chunk ConsumptionChunk) error WriteChargingSession(ctx context.Context, session ChargingSession) error ReadConsumptionChunks(ctx context.Context, installationID string) ([]ConsumptionChunk, error) + SaveDevice(ctx context.Context, d Device) error + LoadDevices(ctx context.Context) ([]Device, error) } // InfluxStore keeps everything in one InfluxDB 2 bucket; every point carries an @@ -26,6 +28,7 @@ type Store interface { // consumption_chunk time = recordedAt tags: date km, kwh, hours, socDrop, sohPercent // charging_session time = startedAt tags: sessionId summary fields // charging_curve time = point time tags: sessionId powerKw, socPercent +// device time = createdAt tags: deviceId tokenHash, installationId, revoked // // Idempotency comes from InfluxDB itself: a point with the same measurement, tags and // timestamp replaces the earlier one. The car re-sends a cached chunk with its original @@ -160,3 +163,38 @@ func floatValue(v any) float64 { f, _ := v.(float64) return f } + +// SaveDevice writes a paired car. Re-saving it (a revocation) overwrites the same point, +// because measurement, tag and timestamp stay the same. +func (s *InfluxStore) SaveDevice(ctx context.Context, d Device) error { + return s.write.WritePoint(ctx, influxdb2.NewPoint("device", + map[string]string{"deviceId": d.ID}, + map[string]any{"tokenHash": d.TokenHash, "installationId": d.InstallationID, "revoked": d.Revoked}, + d.CreatedAt)) +} + +func (s *InfluxStore) LoadDevices(ctx context.Context) ([]Device, error) { + query := fmt.Sprintf(`from(bucket: %s) + |> range(start: 0) + |> filter(fn: (r) => r._measurement == "device") + |> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value")`, fluxString(s.bucket)) + result, err := s.query.Query(ctx, query) + if err != nil { + return nil, err + } + defer result.Close() + var devices []Device + for result.Next() { + r := result.Record() + id, _ := r.ValueByKey("deviceId").(string) + hash, _ := r.ValueByKey("tokenHash").(string) + installation, _ := r.ValueByKey("installationId").(string) + revoked, _ := r.ValueByKey("revoked").(bool) + if id == "" || hash == "" { + continue + } + devices = append(devices, Device{ID: id, InstallationID: installation, TokenHash: hash, + CreatedAt: r.Time(), Revoked: revoked}) + } + return devices, result.Err() +}