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()) }