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")
|
||||
|
||||
Reference in New Issue
Block a user