feat: require paired browser receiver
This commit is contained in:
@@ -4,26 +4,30 @@
|
||||
package appsettings
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config represents the application settings stored in ~/.config/verstak/config.json.
|
||||
type Config struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
CurrentVaultPath string `json:"currentVaultPath"`
|
||||
RecentVaults []string `json:"recentVaults"`
|
||||
Theme string `json:"theme"`
|
||||
DevMode bool `json:"devMode"`
|
||||
UserPluginsDir string `json:"userPluginsDir"`
|
||||
Workbench WorkbenchPreferences `json:"workbench,omitempty"`
|
||||
Sync SyncSettings `json:"sync,omitempty"`
|
||||
WindowState *WindowState `json:"windowState,omitempty"`
|
||||
LastOpenedAt string `json:"lastOpenedAt"`
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
CurrentVaultPath string `json:"currentVaultPath"`
|
||||
RecentVaults []string `json:"recentVaults"`
|
||||
Theme string `json:"theme"`
|
||||
DevMode bool `json:"devMode"`
|
||||
UserPluginsDir string `json:"userPluginsDir"`
|
||||
Workbench WorkbenchPreferences `json:"workbench,omitempty"`
|
||||
Sync SyncSettings `json:"sync,omitempty"`
|
||||
BrowserReceiver BrowserReceiverSettings `json:"browserReceiver,omitempty"`
|
||||
WindowState *WindowState `json:"windowState,omitempty"`
|
||||
LastOpenedAt string `json:"lastOpenedAt"`
|
||||
}
|
||||
|
||||
type WorkbenchPreferences struct {
|
||||
@@ -44,6 +48,11 @@ type SyncSettings struct {
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
}
|
||||
|
||||
// BrowserReceiverSettings holds the installation-local browser capture pairing secret.
|
||||
type BrowserReceiverSettings struct {
|
||||
Token string `json:"token,omitempty"`
|
||||
}
|
||||
|
||||
// WindowState stores the last window position and size.
|
||||
type WindowState struct {
|
||||
Width int `json:"width"`
|
||||
@@ -205,6 +214,42 @@ func (m *Manager) UpdateSync(syncSettings SyncSettings) error {
|
||||
return m.saveLocked()
|
||||
}
|
||||
|
||||
// EnsureBrowserReceiverToken returns the persisted pairing token, creating it when absent.
|
||||
func (m *Manager) EnsureBrowserReceiverToken() (string, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.updateBrowserReceiverTokenLocked(false)
|
||||
}
|
||||
|
||||
// RotateBrowserReceiverToken replaces the persisted pairing token.
|
||||
func (m *Manager) RotateBrowserReceiverToken() (string, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.updateBrowserReceiverTokenLocked(true)
|
||||
}
|
||||
|
||||
func (m *Manager) updateBrowserReceiverTokenLocked(force bool) (string, error) {
|
||||
if m.config == nil {
|
||||
m.config = defaultConfig()
|
||||
}
|
||||
current := strings.TrimSpace(m.config.BrowserReceiver.Token)
|
||||
if current != "" && !force {
|
||||
return current, nil
|
||||
}
|
||||
|
||||
bytes := make([]byte, 32)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", fmt.Errorf("generate browser receiver token: %w", err)
|
||||
}
|
||||
token := base64.RawURLEncoding.EncodeToString(bytes)
|
||||
m.config.BrowserReceiver.Token = token
|
||||
if err := m.saveLocked(); err != nil {
|
||||
m.config.BrowserReceiver.Token = current
|
||||
return "", err
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// SetCurrentVault updates the current vault path and adds to recents.
|
||||
func (m *Manager) SetCurrentVault(path string) error {
|
||||
m.mu.Lock()
|
||||
@@ -262,6 +307,7 @@ func copyConfig(c *Config) *Config {
|
||||
UserPluginsDir: c.UserPluginsDir,
|
||||
Workbench: c.Workbench,
|
||||
Sync: c.Sync,
|
||||
BrowserReceiver: c.BrowserReceiver,
|
||||
LastOpenedAt: c.LastOpenedAt,
|
||||
}
|
||||
if c.WindowState != nil {
|
||||
|
||||
@@ -52,6 +52,43 @@ func TestLoad_CorruptConfig(t *testing.T) {
|
||||
// Just verify no panic
|
||||
}
|
||||
|
||||
func TestBrowserReceiverTokenPersistsAndRotates(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.json")
|
||||
|
||||
manager := NewManager(path)
|
||||
if err := manager.Load(); err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
firstToken, err := manager.EnsureBrowserReceiverToken()
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureBrowserReceiverToken: %v", err)
|
||||
}
|
||||
if firstToken == "" {
|
||||
t.Fatal("EnsureBrowserReceiverToken returned an empty token")
|
||||
}
|
||||
|
||||
reloaded := NewManager(path)
|
||||
if err := reloaded.Load(); err != nil {
|
||||
t.Fatalf("reload settings: %v", err)
|
||||
}
|
||||
persistedToken, err := reloaded.EnsureBrowserReceiverToken()
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureBrowserReceiverToken after reload: %v", err)
|
||||
}
|
||||
if persistedToken != firstToken {
|
||||
t.Fatalf("persisted token = %q, want %q", persistedToken, firstToken)
|
||||
}
|
||||
|
||||
rotatedToken, err := reloaded.RotateBrowserReceiverToken()
|
||||
if err != nil {
|
||||
t.Fatalf("RotateBrowserReceiverToken: %v", err)
|
||||
}
|
||||
if rotatedToken == "" || rotatedToken == firstToken {
|
||||
t.Fatalf("rotated token = %q, want new non-empty token", rotatedToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetCurrentVault(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.json")
|
||||
|
||||
@@ -14,14 +14,18 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/verstak/verstak-desktop/internal/core/events"
|
||||
)
|
||||
|
||||
const capturePath = "/api/browser-inbox/v1/captures"
|
||||
const DefaultAddr = "127.0.0.1:47731"
|
||||
const receiverTokenHeader = "X-Verstak-Receiver-Token"
|
||||
const (
|
||||
capturePath = "/api/browser-inbox/v1/captures"
|
||||
DefaultAddr = "127.0.0.1:47731"
|
||||
DefaultCaptureURL = "http://" + DefaultAddr + capturePath
|
||||
receiverTokenHeader = "X-Verstak-Receiver-Token"
|
||||
)
|
||||
|
||||
const (
|
||||
maxCaptureBodyBytes = 12 * 1024 * 1024
|
||||
@@ -44,6 +48,7 @@ const (
|
||||
type Receiver struct {
|
||||
bus *events.Bus
|
||||
workspaceProvider WorkspaceProvider
|
||||
optionsMu sync.RWMutex
|
||||
options Options
|
||||
}
|
||||
|
||||
@@ -112,6 +117,17 @@ func NewWithOptions(bus *events.Bus, options Options, providers ...WorkspaceProv
|
||||
return &Receiver{bus: bus, workspaceProvider: provider, options: options}
|
||||
}
|
||||
|
||||
// SetReceiverToken updates the active token without restarting the local server.
|
||||
func (r *Receiver) SetReceiverToken(token string) {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
r.optionsMu.Lock()
|
||||
defer r.optionsMu.Unlock()
|
||||
r.options.RequireToken = true
|
||||
r.options.ReceiverToken = strings.TrimSpace(token)
|
||||
}
|
||||
|
||||
func Start(addr string, receiver *Receiver) (*Server, error) {
|
||||
if receiver == nil {
|
||||
return nil, fmt.Errorf("receiver is required")
|
||||
@@ -213,10 +229,16 @@ func (r *Receiver) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
}
|
||||
|
||||
func (r *Receiver) validateReceiverToken(req *http.Request) error {
|
||||
if r == nil || !r.options.RequireToken {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
r.optionsMu.RLock()
|
||||
requireToken := r.options.RequireToken
|
||||
expected := strings.TrimSpace(r.options.ReceiverToken)
|
||||
r.optionsMu.RUnlock()
|
||||
if !requireToken {
|
||||
return nil
|
||||
}
|
||||
if expected == "" {
|
||||
return fmt.Errorf("receiver token required")
|
||||
}
|
||||
|
||||
@@ -281,6 +281,38 @@ func TestReceiverAcceptsPairedToken(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReceiverRotatesPairedToken(t *testing.T) {
|
||||
bus := events.NewBus()
|
||||
bus.Subscribe("browser.capture.page", func(event events.Event) {})
|
||||
receiver := NewWithOptions(bus, Options{RequireToken: true, ReceiverToken: "old-token"})
|
||||
body := `{
|
||||
"schemaVersion": 1,
|
||||
"captureId": "capture-rotated-token",
|
||||
"capturedAt": "2026-06-27T00:00:00.000Z",
|
||||
"kind": "page",
|
||||
"page": {"url": "https://example.com"}
|
||||
}`
|
||||
|
||||
request := func(token string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodPost, capturePath, bytes.NewBufferString(body))
|
||||
req.Header.Set(receiverTokenHeader, token)
|
||||
res := httptest.NewRecorder()
|
||||
receiver.ServeHTTP(res, req)
|
||||
return res
|
||||
}
|
||||
|
||||
if res := request("old-token"); res.Code != http.StatusAccepted {
|
||||
t.Fatalf("old token before rotation status = %d, want %d", res.Code, http.StatusAccepted)
|
||||
}
|
||||
receiver.SetReceiverToken("new-token")
|
||||
if res := request("old-token"); res.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("old token after rotation status = %d, want %d", res.Code, http.StatusUnauthorized)
|
||||
}
|
||||
if res := request("new-token"); res.Code != http.StatusAccepted {
|
||||
t.Fatalf("new token after rotation status = %d, want %d", res.Code, http.StatusAccepted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerStartsOnLocalAddressAndAcceptsCapture(t *testing.T) {
|
||||
bus := events.NewBus()
|
||||
bus.Subscribe("browser.capture.page", func(event events.Event) {})
|
||||
|
||||
@@ -50,6 +50,7 @@ func (r *Registry) registerDefaults() {
|
||||
{Name: "secrets.read", Description: "Read secrets from the secret store", Dangerous: true},
|
||||
{Name: "secrets.write", Description: "Write secrets to the secret store", Dangerous: true},
|
||||
{Name: "sync.participate", Description: "Participate in vault sync", Dangerous: true},
|
||||
{Name: "browser.receiver.manage", Description: "View and rotate the local browser receiver pairing token", Dangerous: true},
|
||||
}
|
||||
for _, e := range defaults {
|
||||
r.permissions[e.Name] = e
|
||||
|
||||
Reference in New Issue
Block a user