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 // Pending requests per client address, and in all. A car needs one; a client that opens // more only crowds out its own, and the total bounds memory. maxPendingPerClient = 3 maxPendingPairing = 1000 // After this many wrong approval passwords from one client address in a window, that // address waits for the window. Other addresses are not affected, so a stranger guessing // cannot lock the owner out. The global cap only bounds guessing spread over many // addresses; it is far above anything a person types. maxFailedLogins = 5 maxFailedLoginsGlobal = 100 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 // pairingSaving: approved, and the device is being persisted. The car still sees // pending, so it cannot collect a token whose device might not be saved. pairingSaving pairingApproved pairingDenied ) type pairingRequest struct { deviceCode string userCode string installationID string client string // address that opened the request 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 // Wrong passwords: per client address, and all of them together. failures map[string][]time.Time globalFailures []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{}, failures: map[string][]time.Time{}, } 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 { // A request being saved is finished by Commit or Forget, not expired under them. if now.After(r.expires) && r.state != pairingSaving { delete(p.byDevice, code) delete(p.byUser, r.userCode) } } } func (p *Pairing) dropLocked(r *pairingRequest) { delete(p.byDevice, r.deviceCode) delete(p.byUser, r.userCode) } // Start opens a pairing request for a car. A new request replaces the same installation's // undecided one; a client already holding maxPendingPerClient requests gets errTooMany. func (p *Pairing) Start(installationID, client string) (*pairingRequest, error) { p.mu.Lock() defer p.mu.Unlock() now := p.Now() p.expireLocked(now) perClient := 0 for _, r := range p.byDevice { if r.state == pairingPending && strings.EqualFold(r.installationID, installationID) { p.dropLocked(r) continue } if r.client == client { perClient++ } } if perClient >= maxPendingPerClient || len(p.byDevice) >= maxPendingPairing { return nil, errTooMany } r := &pairingRequest{ deviceCode: randomString(32), installationID: installationID, client: client, 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 } func recentOnly(times []time.Time, now time.Time) []time.Time { recent := times[:0] for _, t := range times { if now.Sub(t) < failedLoginsWindow { recent = append(recent, t) } } return recent } // checkPasswordLocked counts failures per client address and refuses a client while it // has too many recent ones (or while too many arrive from everywhere at once). func (p *Pairing) checkPasswordLocked(password, client string, now time.Time) error { for c, times := range p.failures { if recent := recentOnly(times, now); len(recent) == 0 { delete(p.failures, c) } else { p.failures[c] = recent } } p.globalFailures = recentOnly(p.globalFailures, now) if len(p.failures[client]) >= maxFailedLogins || len(p.globalFailures) >= maxFailedLoginsGlobal { return errTooManyFailed } if subtle.ConstantTimeCompare([]byte(password), []byte(p.ApprovalPassword)) != 1 { p.failures[client] = append(p.failures[client], now) p.globalFailures = append(p.globalFailures, now) return errWrongPassword } return nil } // Decide approves or denies a pending request. On approval it returns the new device, which // the caller must persist and then Commit (or Forget, if saving failed): until Commit the // car keeps seeing pending and the token is not accepted anywhere. func (p *Pairing) Decide(userCode, password, client string, approve bool) (Device, error) { p.mu.Lock() defer p.mu.Unlock() now := p.Now() if err := p.checkPasswordLocked(password, client, 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 = pairingSaving r.token = token r.deviceID = d.ID return d, nil } // Commit finishes an approval once its device is persisted: the token now works, and the // car collects it at its next poll. func (p *Pairing) Commit(userCode string, d Device) { p.mu.Lock() defer p.mu.Unlock() p.devices[d.TokenHash] = d if r, ok := p.byUser[normalizeUserCode(userCode)]; ok && r.deviceID == d.ID { r.state = pairingApproved } } // Forget undoes an approval whose device could not be persisted, so no token is handed out // that would stop working at the next restart. The request is pending again. func (p *Pairing) Forget(userCode string, d Device) { p.mu.Lock() defer p.mu.Unlock() if r, ok := p.byUser[normalizeUserCode(userCode)]; ok && r.deviceID == d.ID { 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 pairingSaving: return pairingPending, "", "", nil case pairingApproved, pairingDenied: // Decided: hand the outcome out once, then forget the request. p.dropLocked(r) } return r.state, r.token, r.deviceID, nil } // Authorized returns the paired, unrevoked device a token belongs to. func (p *Pairing) Authorized(token string) (Device, bool) { if p == nil || token == "" { return Device{}, false } p.mu.Lock() defer p.mu.Unlock() d, ok := p.devices[hashToken(token)] if !ok || d.Revoked { return Device{}, false } return d, true } 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, client string) error { p.mu.Lock() defer p.mu.Unlock() return p.checkPasswordLocked(password, client, p.Now()) }