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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
This commit is contained in:
@@ -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())
|
||||
}
|
||||
Reference in New Issue
Block a user