Files
BlackBean/pigo/internal/remotecontrol/token.go
T
2026-08-14 23:41:57 +08:00

131 lines
4.1 KiB
Go

// Package remotecontrol implements the /remote-control feature: an in-process
// web server that lets a phone on the same LAN mirror the CLI session, inject
// prompts, and approve risky tool calls (see tasks/spec-remote-control.md).
//
// This file (node #438) provides the auth substrate: a one-time, TTL-bound
// pairing token that a paired browser exchanges for an opaque session
// credential. All state is in-memory and process-scoped; nothing is persisted
// or logged, and Clear() wipes it on shutdown.
package remotecontrol
import (
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"sync"
"time"
)
// tokenBytes is the entropy of both pairing tokens and session credentials.
// 32 bytes = 256 bits, hex-encoded to 64 characters in the URL/cookie.
const tokenBytes = 32
// pairingToken is a single-use link credential handed to the user (embedded in
// the printed /pair?t=... URL). It is consumed on the first successful pairing
// and rejected thereafter or once expired.
type pairingToken struct {
expiresAt time.Time
used bool
}
// TokenStore holds the outstanding pairing tokens and issued session
// credentials for one remote-control session. It is safe for concurrent use by
// the HTTP handlers. All values are cryptographically random secrets; the store
// keeps only their hex string form and never logs them.
type TokenStore struct {
mu sync.Mutex
pairing map[string]*pairingToken
sessions map[string]struct{}
// now is the clock, injectable so tests can force expiry without sleeping.
now func() time.Time
}
// NewTokenStore returns an empty store using the wall clock.
func NewTokenStore() *TokenStore {
return &TokenStore{
pairing: make(map[string]*pairingToken),
sessions: make(map[string]struct{}),
now: time.Now,
}
}
// randHex returns n cryptographically random bytes, hex-encoded. crypto/rand
// never returns a short read without an error, so a nil error guarantees a full
// buffer.
func randHex(n int) (string, error) {
buf := make([]byte, n)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}
// NewPairing mints a fresh one-time pairing token that expires after ttl and
// returns its hex value for embedding in the pairing URL.
func (s *TokenStore) NewPairing(ttl time.Duration) (string, error) {
value, err := randHex(tokenBytes)
if err != nil {
return "", err
}
s.mu.Lock()
defer s.mu.Unlock()
s.pairing[value] = &pairingToken{expiresAt: s.now().Add(ttl)}
return value, nil
}
// ConsumePairing validates a pairing token and, on success, marks it used so it
// can never be redeemed again. It returns false if the token is unknown,
// already used, or expired. The single-use marking happens under the lock, so
// two concurrent /pair requests with the same token cannot both succeed.
func (s *TokenStore) ConsumePairing(value string) bool {
s.mu.Lock()
defer s.mu.Unlock()
tok, ok := s.pairing[value]
if !ok || tok.used || s.now().After(tok.expiresAt) {
return false
}
tok.used = true
return true
}
// IssueSession creates and stores a new opaque session credential (set as the
// pigo_rc cookie after pairing) and returns its hex value.
func (s *TokenStore) IssueSession() (string, error) {
cred, err := randHex(tokenBytes)
if err != nil {
return "", err
}
s.mu.Lock()
defer s.mu.Unlock()
s.sessions[cred] = struct{}{}
return cred, nil
}
// ValidateSession reports whether cred matches a currently-issued session
// credential. Comparison is constant-time to avoid leaking the credential
// through response timing; an empty cred is always rejected.
func (s *TokenStore) ValidateSession(cred string) bool {
if cred == "" {
return false
}
want := []byte(cred)
s.mu.Lock()
defer s.mu.Unlock()
var matched bool
for issued := range s.sessions {
if subtle.ConstantTimeCompare([]byte(issued), want) == 1 {
matched = true
}
}
return matched
}
// Clear wipes all pairing tokens and session credentials. It is called on
// server shutdown so no secret outlives the remote-control session.
func (s *TokenStore) Clear() {
s.mu.Lock()
defer s.mu.Unlock()
s.pairing = make(map[string]*pairingToken)
s.sessions = make(map[string]struct{})
}