feat: require paired browser receiver

This commit is contained in:
mirivlad 2026-07-10 03:37:12 +08:00
parent 89c261824f
commit 0f293640f9
11 changed files with 316 additions and 26 deletions

View File

@ -439,6 +439,21 @@ export function createPluginAPI(pluginId) {
}
},
browserReceiver: {
pairing: function() {
assertActive('browserReceiver.pairing');
return callBackend(pluginId, 'browserReceiver.pairing', function() {
return App.PluginBrowserReceiverPairing(pluginId);
});
},
rotateToken: function() {
assertActive('browserReceiver.rotateToken');
return callBackend(pluginId, 'browserReceiver.rotateToken', function() {
return App.PluginRotateBrowserReceiverToken(pluginId);
});
}
},
workbench: {
openResource: async function(request) {
assertActive('workbench.openResource');

View File

@ -94,6 +94,10 @@ export function PluginSecretsUnlock(arg1:string,arg2:string):Promise<string>;
export function PluginSecretsWrite(arg1:string,arg2:Record<string, any>):Promise<Record<string, any>|string>;
export function PluginBrowserReceiverPairing(arg1:string):Promise<Record<string, string>|string>;
export function PluginRotateBrowserReceiverToken(arg1:string):Promise<Record<string, string>|string>;
export function PluginSyncConfigure(arg1:string,arg2:string,arg3:string,arg4:string):Promise<string>;
export function PluginSyncDisconnect(arg1:string):Promise<string>;

View File

@ -174,6 +174,14 @@ export function PluginSecretsWrite(arg1, arg2) {
return window['go']['api']['App']['PluginSecretsWrite'](arg1, arg2);
}
export function PluginBrowserReceiverPairing(arg1) {
return window['go']['api']['App']['PluginBrowserReceiverPairing'](arg1);
}
export function PluginRotateBrowserReceiverToken(arg1) {
return window['go']['api']['App']['PluginRotateBrowserReceiverToken'](arg1);
}
export function PluginSyncConfigure(arg1, arg2, arg3, arg4) {
return window['go']['api']['App']['PluginSyncConfigure'](arg1, arg2, arg3, arg4);
}

View File

@ -15,6 +15,7 @@ import (
"github.com/wailsapp/wails/v2/pkg/runtime"
"github.com/verstak/verstak-desktop/internal/core/appsettings"
"github.com/verstak/verstak-desktop/internal/core/browserreceiver"
"github.com/verstak/verstak-desktop/internal/core/capability"
"github.com/verstak/verstak-desktop/internal/core/contribution"
"github.com/verstak/verstak-desktop/internal/core/events"
@ -62,6 +63,7 @@ type App struct {
workbench *coreworkbench.Router
workspace *workspace.Manager
syncSvc *syncsvc.Service
browserReceiver *browserreceiver.Receiver
secretsSession *coresecrets.VaultSession
fileWatcher *filewatcher.Service
debug bool
@ -87,6 +89,7 @@ func NewApp(
pluginStateMgr *pluginstate.Manager,
workspaceMgr *workspace.Manager,
syncService *syncsvc.Service,
browserReceiverService *browserreceiver.Receiver,
debugEnabled bool,
) *App {
app := &App{
@ -104,6 +107,7 @@ func NewApp(
workbench: coreworkbench.NewRouter(workbenchPrefsFromSettings(appSettingsMgr)),
workspace: workspaceMgr,
syncSvc: syncService,
browserReceiver: browserReceiverService,
fileWatcher: filewatcher.NewService(bus, 0),
debug: debugEnabled,
activityEvents: make(map[string]bool),
@ -2105,6 +2109,62 @@ func (a *App) GetPluginAssetContent(pluginID, assetPath string) (string, string)
return string(data), ""
}
// ─── Browser Receiver API ──────────────────────────────────
func (a *App) requirePluginBrowserReceiverAccess(pluginID string) error {
_, err := a.requirePluginAccess(pluginID, "browser.receiver.manage")
return err
}
func (a *App) browserReceiverPairing() (map[string]string, error) {
if a.browserReceiver == nil {
return nil, fmt.Errorf("browser receiver is unavailable")
}
if a.appSettings == nil {
return nil, fmt.Errorf("app settings not initialized")
}
token := strings.TrimSpace(a.appSettings.Get().BrowserReceiver.Token)
if token == "" {
return nil, fmt.Errorf("browser receiver token is unavailable")
}
return map[string]string{
"receiverUrl": browserreceiver.DefaultCaptureURL,
"receiverToken": token,
}, nil
}
// PluginBrowserReceiverPairing returns the local receiver settings for authorized plugins.
func (a *App) PluginBrowserReceiverPairing(pluginID string) (map[string]string, string) {
if err := a.requirePluginBrowserReceiverAccess(pluginID); err != nil {
return nil, err.Error()
}
pairing, err := a.browserReceiverPairing()
if err != nil {
return nil, err.Error()
}
return pairing, ""
}
// PluginRotateBrowserReceiverToken invalidates previous extension pairings.
func (a *App) PluginRotateBrowserReceiverToken(pluginID string) (map[string]string, string) {
if err := a.requirePluginBrowserReceiverAccess(pluginID); err != nil {
return nil, err.Error()
}
if _, err := a.browserReceiverPairing(); err != nil {
return nil, err.Error()
}
token, err := a.appSettings.RotateBrowserReceiverToken()
if err != nil {
return nil, err.Error()
}
a.browserReceiver.SetReceiverToken(token)
pairing, err := a.browserReceiverPairing()
if err != nil {
return nil, err.Error()
}
return pairing, ""
}
// ─── Sync API ──────────────────────────────────────────────
func (a *App) requirePluginSyncAccess(pluginID string, remote bool) error {

View File

@ -13,6 +13,7 @@ import (
"time"
"github.com/verstak/verstak-desktop/internal/core/appsettings"
"github.com/verstak/verstak-desktop/internal/core/browserreceiver"
"github.com/verstak/verstak-desktop/internal/core/capability"
"github.com/verstak/verstak-desktop/internal/core/contribution"
"github.com/verstak/verstak-desktop/internal/core/events"
@ -2000,6 +2001,54 @@ func TestPluginSyncBridgeRequiresDeclaredPermissions(t *testing.T) {
}
}
func TestPluginBrowserReceiverPairingRequiresPermissionAndRotatesToken(t *testing.T) {
app := newBridgeTestApp(t)
app.appSettings = appsettings.NewManager(filepath.Join(t.TempDir(), "config.json"))
if err := app.appSettings.Load(); err != nil {
t.Fatalf("settings Load: %v", err)
}
initialToken, err := app.appSettings.EnsureBrowserReceiverToken()
if err != nil {
t.Fatalf("EnsureBrowserReceiverToken: %v", err)
}
app.browserReceiver = browserreceiver.NewWithOptions(app.eventBus, browserreceiver.Options{
RequireToken: true,
ReceiverToken: initialToken,
})
app.plugins = append(app.plugins, plugin.Plugin{
Manifest: plugin.Manifest{
ID: "browser.local",
Name: "Browser Local",
Version: "1.0.0",
Permissions: []string{"browser.receiver.manage"},
},
Status: plugin.StatusLoaded,
Enabled: true,
})
pairing, errStr := app.PluginBrowserReceiverPairing("browser.local")
if errStr != "" {
t.Fatalf("PluginBrowserReceiverPairing: %s", errStr)
}
if pairing["receiverToken"] != initialToken {
t.Fatalf("pairing token = %q, want %q", pairing["receiverToken"], initialToken)
}
if pairing["receiverUrl"] != browserreceiver.DefaultCaptureURL {
t.Fatalf("pairing receiver URL = %q, want %q", pairing["receiverUrl"], browserreceiver.DefaultCaptureURL)
}
if _, errStr := app.PluginBrowserReceiverPairing("no.storage"); !strings.Contains(errStr, "browser.receiver.manage") {
t.Fatalf("missing permission error = %q", errStr)
}
rotated, errStr := app.PluginRotateBrowserReceiverToken("browser.local")
if errStr != "" {
t.Fatalf("PluginRotateBrowserReceiverToken: %s", errStr)
}
if rotated["receiverToken"] == "" || rotated["receiverToken"] == initialToken {
t.Fatalf("rotated pairing token = %q, want new non-empty token", rotated["receiverToken"])
}
}
func TestPluginSyncStatusReportsPersistedError(t *testing.T) {
app := newBridgeTestApp(t)
app.plugins = append(app.plugins,

View File

@ -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 {

View File

@ -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")

View File

@ -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")
}

View File

@ -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) {})

View File

@ -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

40
main.go
View File

@ -251,20 +251,36 @@ func main() {
if vaultService.GetVaultStatus() == vault.StatusOpen {
syncService = syncsvc.NewService(vaultService.GetVaultPath(), "")
}
app := api.NewApp(capRegistry, contribRegistry, permRegistry, eventBus, plugins, vaultService, storageService, filesService, appSettingsMgr, pluginStateMgr, workspaceMgr, syncService, debugEnabled)
browserReceiver := browserreceiver.New(eventBus, func() string {
current := app.GetCurrentWorkspace()
if root, ok := current["rootPath"].(string); ok {
return root
}
return ""
})
browserReceiverServer, err := browserreceiver.Start(browserreceiver.DefaultAddr, browserReceiver)
receiverToken, err := appSettingsMgr.EnsureBrowserReceiverToken()
if err != nil {
log.Printf("[browserreceiver] local receiver disabled: %v", err)
} else {
defer browserReceiverServer.Close()
log.Printf("[browserreceiver] local receiver listening at %s", browserReceiverServer.URL())
}
var app *api.App
var browserReceiver *browserreceiver.Receiver
if receiverToken != "" {
browserReceiver = browserreceiver.NewWithOptions(eventBus, browserreceiver.Options{
RequireToken: true,
ReceiverToken: receiverToken,
}, func() string {
if app == nil {
return ""
}
current := app.GetCurrentWorkspace()
if root, ok := current["rootPath"].(string); ok {
return root
}
return ""
})
}
app = api.NewApp(capRegistry, contribRegistry, permRegistry, eventBus, plugins, vaultService, storageService, filesService, appSettingsMgr, pluginStateMgr, workspaceMgr, syncService, browserReceiver, debugEnabled)
if browserReceiver != nil {
browserReceiverServer, err := browserreceiver.Start(browserreceiver.DefaultAddr, browserReceiver)
if err != nil {
log.Printf("[browserreceiver] local receiver disabled: %v", err)
} else {
defer browserReceiverServer.Close()
log.Printf("[browserreceiver] paired local receiver listening at %s", browserReceiverServer.URL())
}
}
// ─── Wails App ───────────────────────────────────────────