From 48013fd6aa2a8c25ca9d3a2d733ddd17e0330aab Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 7 Sep 2026 19:27:54 +0200 Subject: [PATCH 1/2] fix(drivers): restore rotated secrets before startup --- .changeset/driver-secrets-before-start.md | 5 ++ go/cmd/ftw/driver_registry.go | 24 ++++++ go/cmd/ftw/driver_registry_test.go | 98 +++++++++++++++++++++++ go/cmd/ftw/main.go | 15 +--- go/internal/drivers/registry.go | 7 +- 5 files changed, 131 insertions(+), 18 deletions(-) create mode 100644 .changeset/driver-secrets-before-start.md create mode 100644 go/cmd/ftw/driver_registry.go create mode 100644 go/cmd/ftw/driver_registry_test.go diff --git a/.changeset/driver-secrets-before-start.md b/.changeset/driver-secrets-before-start.md new file mode 100644 index 00000000..5e8fc71f --- /dev/null +++ b/.changeset/driver-secrets-before-start.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Load rotated driver tokens before startup so OAuth connections such as myUplink keep working after a Core restart or update. Enable token persistence before drivers initialize or begin polling. diff --git a/go/cmd/ftw/driver_registry.go b/go/cmd/ftw/driver_registry.go new file mode 100644 index 00000000..148c603a --- /dev/null +++ b/go/cmd/ftw/driver_registry.go @@ -0,0 +1,24 @@ +package main + +import ( + "github.com/srcfl/ftw/go/internal/drivers" + "github.com/srcfl/ftw/go/internal/state" + "github.com/srcfl/ftw/go/internal/telemetry" +) + +func newDriverRegistry(tel *telemetry.Store, st *state.Store) *drivers.Registry { + reg := drivers.NewRegistry(tel) + // Install both callbacks before Add can initialize or poll any driver. + // Rotations keep their own KV rows so they do not apply the whole config + // or restart the driver that just refreshed its credential. + driverSecretKey := func(driverName, key string) string { + return "driver_secret:" + driverName + ":" + key + } + reg.SecretPersister = func(driverName, key, value string) error { + return st.SaveConfig(driverSecretKey(driverName, key), value) + } + reg.SecretOverride = func(driverName, key string) (string, bool) { + return st.LoadConfig(driverSecretKey(driverName, key)) + } + return reg +} diff --git a/go/cmd/ftw/driver_registry_test.go b/go/cmd/ftw/driver_registry_test.go new file mode 100644 index 00000000..d57ea354 --- /dev/null +++ b/go/cmd/ftw/driver_registry_test.go @@ -0,0 +1,98 @@ +package main + +import ( + "context" + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/config" + "github.com/srcfl/ftw/go/internal/state" + "github.com/srcfl/ftw/go/internal/telemetry" +) + +func TestDriverRegistryRotatedSecretSurvivesStartup(t *testing.T) { + for _, phase := range []string{"init", "first_poll"} { + t.Run(phase, func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "oauth.lua") + // Synthetic tokens only. A remains in the config document after the + // driver rotates to B, just as it does after real OAuth consent. + source := fmt.Sprintf(` +local token +local rotated = false +local function rotate() + if token == "synthetic-A" and not rotated then + local ok = host.persist_secret("refresh_token", "synthetic-B") + host.emit_metric("persist_ok", ok and 1 or 0) + rotated = true + end +end +function driver_init(config) + token = config.refresh_token + host.emit_metric("started_with_B", token == "synthetic-B" and 1 or 0) + host.set_poll_interval(10) + if %q == "init" then rotate() end +end +function driver_poll() rotate() return 60000 end +function driver_command() end +function driver_default_mode() end +`, phase) + if err := os.WriteFile(path, []byte(source), 0600); err != nil { + t.Fatal(err) + } + cfg := config.Driver{Name: "oauth-test", Lua: path, Config: map[string]any{"refresh_token": "synthetic-A"}} + dbPath := filepath.Join(dir, "state.db") + start := func() (*state.Store, *telemetry.Store, func()) { + t.Helper() + st, err := state.Open(dbPath) + if err != nil { + t.Fatal(err) + } + tel := telemetry.NewStore() + reg := newDriverRegistry(tel, st) + stop := func() { reg.ShutdownAll(); st.Close() } + if err := reg.Add(context.Background(), cfg); err != nil { + stop() + t.Fatal(err) + } + return st, tel, stop + } + metric := func(tel *telemetry.Store, key string) float64 { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if value, _, ok := tel.LatestMetric(cfg.Name, key); ok { + return value + } + time.Sleep(time.Millisecond) + } + t.Fatalf("driver did not emit %s", key) + return 0 + } + st, tel, stop := start() + func() { + defer stop() + if got := metric(tel, "started_with_B"); got != 0 { + t.Errorf("first start did not use config token A") + } + if got := metric(tel, "persist_ok"); got != 1 { + t.Errorf("secret persistence during %s failed", phase) + } + if got, ok := st.LoadConfig("driver_secret:oauth-test:refresh_token"); !ok || got != "synthetic-B" { + t.Errorf("rotated token B was not stored") + } + }() + _, tel, stop = start() + defer stop() + if got := metric(tel, "started_with_B"); got != 1 { + t.Error("restart used stale config token A instead of persisted token B") + } + if cfg.Config["refresh_token"] != "synthetic-A" { + t.Error("rotation changed the source config") + } + }) + } +} diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index a5243f54..824f06ee 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -615,7 +615,7 @@ func main() { if cfg.DeviceRepository != nil && cfg.DeviceRepository.Enabled { go driverRepositoryRefreshLoop(ctx, driverRepository, cfg.DeviceRepository.RefreshIntervalH) } - reg := drivers.NewRegistry(tel) + reg := newDriverRegistry(tel, st) reg.SetTroubleshootingMode(cfg.Site.TroubleshootingMode) reg.RuntimePolicyResolver = driverRepository.RuntimePolicy reg.CommandResultSink = func(driverName string, result drivers.DriverCommandResultV1) { @@ -693,19 +693,6 @@ func main() { cfgMu := &sync.RWMutex{} modelsMu := &sync.Mutex{} - // Rotated driver tokens keep their own KV rows. Rotation must not apply the - // whole config or restart a driver that just refreshed its credential. - // SecretOverride supplies the newest token when the driver next starts. - driverSecretKey := func(driverName, key string) string { - return "driver_secret:" + driverName + ":" + key - } - reg.SecretPersister = func(driverName, key, value string) error { - return st.SaveConfig(driverSecretKey(driverName, key), value) - } - reg.SecretOverride = func(driverName, key string) (string, bool) { - return st.LoadConfig(driverSecretKey(driverName, key)) - } - // Pre-declare services that the hot-reload Applier needs to touch. // The Applier closure captures these by reference; they're assigned // further down when their packages are wired, and the Applier only diff --git a/go/internal/drivers/registry.go b/go/internal/drivers/registry.go index 7f0810c1..f997de83 100644 --- a/go/internal/drivers/registry.go +++ b/go/internal/drivers/registry.go @@ -490,10 +490,9 @@ func (r *Registry) add(ctx context.Context, cfg config.Driver, startupDefault bo env := NewHostEnv(cfg.Name, r.tel) env.BatteryCapacityWh = cfg.BatteryCapacityWh env.BatteryTelemetryOnly = cfg.BatteryTelemetryOnly - // Wire durable secret write-back (rotated OAuth tokens). The closure - // reads r.SecretPersister lazily at call time so main.go may set it - // either before or after the initial Add loop; persists only ever - // happen at runtime poll, long after wiring completes. + // Wire secret write-back (rotated OAuth tokens). The host must install + // SecretPersister and SecretOverride before Add: init may persist a + // secret, and the poll loop starts before Add returns. driverName := cfg.Name env.PersistSecret = func(key, value string) error { if r.SecretPersister == nil { From 8c14a8af0bb2627c3ce2a41adb18a875ab27924a Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 7 Sep 2026 19:44:10 +0200 Subject: [PATCH 2/2] fix(drivers): preserve signed OAuth credentials within auth scope --- .changeset/driver-secrets-before-start.md | 4 +- go/cmd/ftw/driver_registry_test.go | 23 +++- go/internal/driverrepo/manager_test.go | 77 +++++++++++- go/internal/driverrepo/sourceful.go | 8 +- go/internal/driverrepo/sourceful_test.go | 3 + go/internal/drivers/control_v2.go | 25 ++++ go/internal/drivers/host.go | 13 ++- go/internal/drivers/lua.go | 23 +++- go/internal/drivers/lua_persist_test.go | 83 +++++++++++++ .../drivers/read_only_auth_post_test.go | 110 +++++++++++++++++- 10 files changed, 349 insertions(+), 20 deletions(-) diff --git a/.changeset/driver-secrets-before-start.md b/.changeset/driver-secrets-before-start.md index 5e8fc71f..0ae8928b 100644 --- a/.changeset/driver-secrets-before-start.md +++ b/.changeset/driver-secrets-before-start.md @@ -2,4 +2,6 @@ "ftw": patch --- -Load rotated driver tokens before startup so OAuth connections such as myUplink keep working after a Core restart or update. Enable token persistence before drivers initialize or begin polling. +Restore and save rotated OAuth tokens before drivers start so myUplink can stay connected across Core restarts and updates. + +Apply signed OAuth rules to managed drivers, including official beta installs. Allow token exchange only at the declared path, block redirects, and let each driver save only its declared secret keys with bounded keys and values. diff --git a/go/cmd/ftw/driver_registry_test.go b/go/cmd/ftw/driver_registry_test.go index d57ea354..9f876b8c 100644 --- a/go/cmd/ftw/driver_registry_test.go +++ b/go/cmd/ftw/driver_registry_test.go @@ -9,13 +9,17 @@ import ( "time" "github.com/srcfl/ftw/go/internal/config" + "github.com/srcfl/ftw/go/internal/drivers" "github.com/srcfl/ftw/go/internal/state" "github.com/srcfl/ftw/go/internal/telemetry" ) func TestDriverRegistryRotatedSecretSurvivesStartup(t *testing.T) { - for _, phase := range []string{"init", "first_poll"} { - t.Run(phase, func(t *testing.T) { + for _, scenario := range []struct { + name, phase string + managed bool + }{{"init", "init", false}, {"first_poll", "first_poll", false}, {"managed_init", "init", true}, {"managed_first_poll", "first_poll", true}} { + t.Run(scenario.name, func(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "oauth.lua") // Synthetic tokens only. A remains in the config document after the @@ -39,7 +43,7 @@ end function driver_poll() rotate() return 60000 end function driver_command() end function driver_default_mode() end -`, phase) +`, scenario.phase) if err := os.WriteFile(path, []byte(source), 0600); err != nil { t.Fatal(err) } @@ -53,6 +57,17 @@ function driver_default_mode() end } tel := telemetry.NewStore() reg := newDriverRegistry(tel, st) + if scenario.managed { + reg.RuntimePolicyResolver = func(config.Driver) (*drivers.RuntimePolicy, error) { + return &drivers.RuntimePolicy{ + PackageID: "com.sourceful.driver.myuplink", Version: "1.2.2", + ArtifactSHA256: fmt.Sprintf("%064x", 1), RuntimeABI: "gopher-lua-source-v1", + HostAPIProfile: "sourceful.host/ftw-core/v1", ReadOnly: true, + Permissions: map[string]bool{"http.get": true, "http.post": true}, AuthPostPath: "/oauth/token", + ConfigSecrets: []string{"refresh_token"}, + }, nil + } + } stop := func() { reg.ShutdownAll(); st.Close() } if err := reg.Add(context.Background(), cfg); err != nil { stop() @@ -79,7 +94,7 @@ function driver_default_mode() end t.Errorf("first start did not use config token A") } if got := metric(tel, "persist_ok"); got != 1 { - t.Errorf("secret persistence during %s failed", phase) + t.Errorf("secret persistence during %s failed", scenario.phase) } if got, ok := st.LoadConfig("driver_secret:oauth-test:refresh_token"); !ok || got != "synthetic-B" { t.Errorf("rotated token B was not stored") diff --git a/go/internal/driverrepo/manager_test.go b/go/internal/driverrepo/manager_test.go index f159a3d6..17428177 100644 --- a/go/internal/driverrepo/manager_test.go +++ b/go/internal/driverrepo/manager_test.go @@ -19,6 +19,7 @@ import ( "github.com/srcfl/ftw/go/internal/components" "github.com/srcfl/ftw/go/internal/config" + "github.com/srcfl/ftw/go/internal/drivers" "github.com/srcfl/ftw/go/internal/state" ) @@ -191,6 +192,14 @@ func TestOfficialBetaChannelInstallsOneSignedDriver(t *testing.T) { })) defer server.Close() fixture.setVersion(server.URL, "1.1.0-beta.1") + fixture.mu.Lock() + fixture.manifest.Repository = "https://github.com/srcfl/device-drivers" + fixture.manifest.Drivers[0].ReadOnly = true + fixture.manifest.Drivers[0].Permissions = []string{"http.get", "http.post"} + fixture.manifest.Drivers[0].Metadata.ReadOnly = true + fixture.manifest.Drivers[0].Metadata.AuthPostPath = "/oauth/token" + fixture.manifest.Drivers[0].Metadata.ConfigSecrets = []string{"client_secret", "refresh_token"} + fixture.mu.Unlock() dir := t.TempDir() store, err := state.Open(filepath.Join(dir, "state.db")) @@ -198,7 +207,12 @@ func TestOfficialBetaChannelInstallsOneSignedDriver(t *testing.T) { t.Fatal(err) } defer store.Close() - manager := New(nil, dir, store) + // The box config lists only stable; InstallChannel owns its separate, + // already trusted beta source. + configured := &config.DeviceRepository{Repositories: []config.DriverRepositorySource{{ + ID: config.DefaultDriverRepositoryID, ManifestURL: config.DefaultDriverRepositoryManifestURL, + }}} + manager := New(configured, dir, store) manager.betaRepo = config.DriverRepositorySource{ ID: config.DefaultDriverRepositoryBetaID, Name: config.DefaultDriverRepositoryBetaName, @@ -233,6 +247,52 @@ func TestOfficialBetaChannelInstallsOneSignedDriver(t *testing.T) { !strings.Contains(err.Error(), "unsupported driver channel") { t.Fatalf("unsupported channel error = %v", err) } + for _, repo := range manager.cfg.Repositories { + if repo.ID == manager.betaRepo.ID { + t.Fatal("beta leaked into configured repositories") + } + } + driverCfg := config.Driver{Name: "demo", Lua: filepath.Join(manager.ActiveDir(), "demo.lua")} + checkPolicy := func(m *Manager) { + t.Helper() + policy, err := m.RuntimePolicy(driverCfg) + if err != nil || policy == nil || !policy.IsReadOnly() || policy.AuthPostPath != "/oauth/token" || + len(policy.ConfigSecrets) != 2 || policy.ConfigSecrets[1] != "refresh_token" { + t.Fatalf("installed beta OAuth policy = %+v, %v", policy, err) + } + } + checkPolicy(manager) + // A new process must reconstruct the same policy from the signed cache. + reloaded := New(configured, dir, store) + reloaded.betaRepo = manager.betaRepo + checkPolicy(reloaded) + t.Run("unknown_repository", func(t *testing.T) { + unknown := New(configured, dir, store) + unknown.betaRepo = manager.betaRepo + unknown.betaRepo.ID = "other-beta-source" + if policy, err := unknown.RuntimePolicy(driverCfg); err != nil || policy != nil { + t.Fatalf("unknown installed repository gained a policy: %+v, %v", policy, err) + } + }) + t.Run("invalid_signature", func(t *testing.T) { + var envelope ManifestEnvelope + if err := json.Unmarshal(fixture.envelope(t), &envelope); err != nil { + t.Fatal(err) + } + envelope.Signature = base64.StdEncoding.EncodeToString(make([]byte, ed25519.SignatureSize)) + raw, err := json.Marshal(envelope) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(manager.root, "cache", manager.betaRepo.ID+".json"), raw, 0600); err != nil { + t.Fatal(err) + } + invalid := New(configured, dir, store) + invalid.betaRepo = manager.betaRepo + if policy, err := invalid.RuntimePolicy(driverCfg); err == nil || policy != nil { + t.Fatalf("invalid beta signature gained a policy: %+v, %v", policy, err) + } + }) } func TestOfficialBetaChannelDoesNotShareConfiguredRepositoryState(t *testing.T) { @@ -270,8 +330,10 @@ func TestDirectManifestBindsReadOnlyRuntimePolicy(t *testing.T) { fixture.mu.Lock() fixture.manifest.Repository = "https://github.com/srcfl/device-drivers" fixture.manifest.Drivers[0].ReadOnly = true - fixture.manifest.Drivers[0].Permissions = []string{"http.get"} + fixture.manifest.Drivers[0].Permissions = []string{"http.get", "http.post"} fixture.manifest.Drivers[0].Metadata.ReadOnly = true + fixture.manifest.Drivers[0].Metadata.AuthPostPath = "/oauth/token" + fixture.manifest.Drivers[0].Metadata.ConfigSecrets = []string{"client_secret", "refresh_token"} fixture.mu.Unlock() dir := t.TempDir() @@ -302,6 +364,17 @@ func TestDirectManifestBindsReadOnlyRuntimePolicy(t *testing.T) { policy.PackageID != "com.sourceful.driver.demo" { t.Fatalf("direct runtime identity = %+v", policy) } + if policy.AuthPostPath != "/oauth/token" || !policy.Permissions["http.post"] || len(policy.ConfigSecrets) != 2 || policy.ConfigSecrets[1] != "refresh_token" { + t.Fatalf("signed OAuth secret policy = %+v", policy) + } + // These are the policy fields in signed myUplink 1.2.2. Exercise the + // actual Lua constructor as well: inspecting a policy alone misses its + // startup validation, which previously rejected the auth POST grant. + luaDriver, err := drivers.NewLuaDriverWithPolicy(filepath.Join(manager.ActiveDir(), "demo.lua"), drivers.NewHostEnv("demo", nil), policy) + if err != nil { + t.Fatal(err) + } + luaDriver.Cleanup() // read_only and control_enabled are two spellings of one fact. A driver // that may control while claiming to be read-only reads as safe to diff --git a/go/internal/driverrepo/sourceful.go b/go/internal/driverrepo/sourceful.go index adfd07a2..5eae6df1 100644 --- a/go/internal/driverrepo/sourceful.go +++ b/go/internal/driverrepo/sourceful.go @@ -590,6 +590,11 @@ func (m *Manager) RuntimePolicy(cfg config.Driver) (*drivers.RuntimePolicy, erro break } } + // InstallChannel uses this pinned trust source without adding it to the + // stable config list. Bind only its exact recorded repository identity. + if repo == nil && m.betaRepo.ID != "" && installed.RepoID == m.betaRepo.ID { + repo = &m.betaRepo + } if repo == nil { if cfg.Control != nil && cfg.Control.Enabled { return nil, errors.New("control opt-in requires a configured Device Support trust root") @@ -761,7 +766,8 @@ func (m *Manager) directManifestRuntimePolicy( // Only a read-only driver can have one, and only the path the signed // manifest names. An unsigned or absent value leaves it empty, which // is the same as having no exemption at all. - AuthPostPath: matched.Metadata.AuthPostPath, + AuthPostPath: matched.Metadata.AuthPostPath, + ConfigSecrets: append([]string(nil), matched.Metadata.ConfigSecrets...), }, nil } diff --git a/go/internal/driverrepo/sourceful_test.go b/go/internal/driverrepo/sourceful_test.go index 7d8b119f..e8009eb7 100644 --- a/go/internal/driverrepo/sourceful_test.go +++ b/go/internal/driverrepo/sourceful_test.go @@ -324,6 +324,9 @@ func TestSourcefulIndexPackageInstallAndOfflineCache(t *testing.T) { if err != nil || policy == nil || !policy.IsReadOnly() || !policy.Permissions["modbus.read"] || policy.Permissions["modbus.write"] { t.Fatalf("signed read-only runtime policy = %+v, %v", policy, err) } + if len(policy.ConfigSecrets) != 0 || policy.AuthPostPath != "" { + t.Fatalf("package without signed OAuth metadata received secret grants: %+v", policy) + } // A failed refresh cannot replace the last-good in-memory or on-disk view. fixture.mu.Lock() diff --git a/go/internal/drivers/control_v2.go b/go/internal/drivers/control_v2.go index ad7724c8..6d69558d 100644 --- a/go/internal/drivers/control_v2.go +++ b/go/internal/drivers/control_v2.go @@ -19,6 +19,9 @@ const ( var controlTokenRE = regexp.MustCompile(`^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$`) var controlHashRE = regexp.MustCompile(`^[0-9a-f]{64}$`) +var persistSecretKeyRE = regexp.MustCompile(`^[a-z][a-z0-9_]{0,63}$`) + +func validPersistSecretKey(key string) bool { return persistSecretKeyRE.MatchString(key) } // RuntimePolicy is the verified, signed package policy bound to one managed // artifact. SiteEnabled becomes true only when the local config pins the same @@ -42,6 +45,24 @@ type RuntimePolicy struct { // init or poll -- the phases allowWrite refuses. Empty for every driver // that does not declare one, which is all of them by default. AuthPostPath string + // ConfigSecrets comes from verified signed metadata. A read-only OAuth + // driver may persist only these keys in its own secret namespace. + ConfigSecrets []string +} + +func (p *RuntimePolicy) allowsSecretPersistence(key string) bool { + if p == nil { + return true + } + if !p.IsReadOnly() || p.AuthPostPath == "" || !p.Permissions["http.get"] { + return false + } + for _, allowed := range p.ConfigSecrets { + if key == allowed { + return true + } + } + return false } type RuntimeCommand struct { @@ -82,6 +103,10 @@ func (p *RuntimePolicy) validate() error { } switch permission { case "http.get", "modbus.read", "mqtt.subscribe", "serial.read": + case "http.post": + if p.AuthPostPath == "" || !p.Permissions["http.get"] { + return errors.New("read-only HTTP POST requires a declared auth path and http.get") + } default: return fmt.Errorf("read-only runtime has write-capable permission %q", permission) } diff --git a/go/internal/drivers/host.go b/go/internal/drivers/host.go index 274535a2..4b4d99b8 100644 --- a/go/internal/drivers/host.go +++ b/go/internal/drivers/host.go @@ -169,12 +169,12 @@ type HostEnv struct { // in driver_init; read once by the registry's run loop. WarmupS float64 - // PersistSecret, when non-nil, lets a driver durably write a config - // secret (e.g. a rotated OAuth refresh_token) back into its own - // config block so it survives a restart. nil → host.persist_secret + // PersistSecret, when non-nil, lets a driver write a secret (e.g. a + // rotated OAuth refresh_token) into its own persisted KV namespace. + // nil → host.persist_secret // returns ok=false + an error. Wired by the Registry to a per-driver - // closure (see registry.go SecretPersister). Keep the value small: - // it is round-tripped through config.yaml as a plain string. + // closure (see registry.go SecretPersister). The host limits each value + // to 1 MiB and checks signed secret-key grants for managed drivers. PersistSecret func(key, value string) error writePhase string writeDeadline time.Time @@ -305,6 +305,9 @@ func (h *HostEnv) allowWrite(permission string) error { if h.RuntimePolicy == nil { return nil } + if h.RuntimePolicy.IsReadOnly() { + return fmt.Errorf("%s: read-only driver cannot write", permission) + } h.mu.Lock() defer h.mu.Unlock() if !h.RuntimePolicy.allows(permission) { diff --git a/go/internal/drivers/lua.go b/go/internal/drivers/lua.go index 5c7906e6..d60f146a 100644 --- a/go/internal/drivers/lua.go +++ b/go/internal/drivers/lua.go @@ -969,17 +969,24 @@ func registerHost(L *lua.LState, env *HostEnv) { })) // host.persist_secret(key, value) -> ok, err - // Durably writes a config secret back into the driver's own config - // block (e.g. a rotated OAuth refresh_token) so it survives restarts. + // Writes a secret into the driver's own persisted KV namespace. + // Managed read-only OAuth drivers need a signed config_secrets entry. + // Keys use at most 64 lowercase letters, digits or underscores; values + // are capped at 1 MiB, matching the host's HTTP response limit. // Returns ok=false + an error string when the capability isn't wired. host.RawSetString("persist_secret", L.NewFunction(func(L *lua.LState) int { key := L.CheckString(1) val := L.CheckString(2) - if env.RuntimePolicy != nil || env.PersistSecret == nil { + if env.PersistSecret == nil || !env.RuntimePolicy.allowsSecretPersistence(key) { L.Push(lua.LBool(false)) L.Push(lua.LString("persist_secret: capability not granted")) return 2 } + if !validPersistSecretKey(key) || len(val) > 1<<20 { + L.Push(lua.LBool(false)) + L.Push(lua.LString("persist_secret: invalid key or value exceeds 1 MiB")) + return 2 + } if err := env.PersistSecret(key, val); err != nil { L.Push(lua.LBool(false)) L.Push(lua.LString(err.Error())) @@ -1389,6 +1396,11 @@ func registerHost(L *lua.LState, env *HostEnv) { if len(via) > 0 && via[0].Method == "PATCH" { return fmt.Errorf("redirect not followed for PATCH (a redirected write cannot be verified)") } + // The managed read-only OAuth exception authorizes one exact path. + // A 307/308 must not carry its POST body to a device write endpoint. + if len(via) > 0 && via[0].Method == "POST" && env.allowAuthPost(via[0].URL.String()) { + return fmt.Errorf("redirect not followed for managed OAuth POST") + } if ok, reason := hostAllowed(req.URL.String()); !ok { return fmt.Errorf("redirect blocked: %s", reason) } @@ -1758,11 +1770,14 @@ func registerHost(L *lua.LState, env *HostEnv) { // Do not leave these functions reachable in a v2 VM even when local // YAML happens to configure those legacy capabilities. for _, name := range []string{ - "persist_secret", "ws_open", "ws_send", "ws_messages", "ws_is_open", "ws_close", + "ws_open", "ws_send", "ws_messages", "ws_is_open", "ws_close", "tcp_open", "tcp_recv", "tcp_is_open", "tcp_close", } { host.RawSetString(name, lua.LNil) } + if !env.RuntimePolicy.IsReadOnly() { + host.RawSetString("persist_secret", lua.LNil) + } } L.SetGlobal("host", host) } diff --git a/go/internal/drivers/lua_persist_test.go b/go/internal/drivers/lua_persist_test.go index ebdfc3ad..8137336b 100644 --- a/go/internal/drivers/lua_persist_test.go +++ b/go/internal/drivers/lua_persist_test.go @@ -2,13 +2,96 @@ package drivers import ( "context" + "fmt" "os" "path/filepath" + "strings" "testing" "github.com/srcfl/ftw/go/internal/telemetry" ) +func TestManagedPersistSecretScope(t *testing.T) { + for _, tc := range []struct { + name, key string + valueBytes int + change func(*RuntimePolicy) + unwired, allowed bool + }{ + {name: "declared", key: "refresh_token", allowed: true}, + {name: "undeclared", key: "other_token"}, + {name: "other_namespace", key: "other:refresh_token"}, + {name: "listed_colon", key: "other:refresh_token", change: func(p *RuntimePolicy) { p.ConfigSecrets = []string{"other:refresh_token"} }}, + {name: "listed_path", key: "../refresh_token", change: func(p *RuntimePolicy) { p.ConfigSecrets = []string{"../refresh_token"} }}, + {name: "listed_long_key", key: strings.Repeat("a", 65), change: func(p *RuntimePolicy) { p.ConfigSecrets = []string{strings.Repeat("a", 65)} }}, + {name: "no_auth_path", key: "refresh_token", change: func(p *RuntimePolicy) { p.AuthPostPath = "" }}, + {name: "no_http_get", key: "refresh_token", change: func(p *RuntimePolicy) { p.Permissions = nil }}, + {name: "not_read_only", key: "refresh_token", change: func(p *RuntimePolicy) { p.ReadOnly = false }}, + {name: "broad_http_write", key: "refresh_token", change: func(p *RuntimePolicy) { p.Permissions["http.patch"] = true }}, + {name: "unwired", key: "refresh_token", unwired: true}, + {name: "at_value_limit", key: "refresh_token", valueBytes: 1 << 20, allowed: true}, + {name: "above_value_limit", key: "refresh_token", valueBytes: (1 << 20) + 1}, + } { + t.Run(tc.name, func(t *testing.T) { + policy := &RuntimePolicy{ + PackageID: "com.sourceful.driver.myuplink", Version: "1.2.2", + ArtifactSHA256: strings.Repeat("a", 64), RuntimeABI: "gopher-lua-source-v1", + HostAPIProfile: "sourceful.host/ftw-core/v1", ReadOnly: true, + Permissions: map[string]bool{"http.get": true, "http.post": true}, AuthPostPath: "/oauth/token", + ConfigSecrets: []string{"refresh_token"}, + } + if tc.change != nil { + tc.change(policy) + } + tel := telemetry.NewStore() + env := NewHostEnv("managed", tel) + calls := 0 + if !tc.unwired { + env.PersistSecret = func(key, value string) error { + calls++ + if key != tc.key || len(value) != tc.valueBytes { + t.Error("callback arguments changed") + } + return nil + } + } + path := filepath.Join(t.TempDir(), "managed.lua") + source := fmt.Sprintf(`function driver_init() +local ok, err = host.persist_secret(%q, string.rep("x", %d)) +host.emit_metric("persist_ok", ok and 1 or 0) +if not ok and not err then error("missing denial reason") end +end`, tc.key, tc.valueBytes) + if err := os.WriteFile(path, []byte(source), 0600); err != nil { + t.Fatal(err) + } + d, err := NewLuaDriverWithPolicy(path, env, policy) + if err != nil { + if tc.name == "not_read_only" || tc.name == "broad_http_write" { + return + } + if tc.name == "no_auth_path" || tc.name == "no_http_get" { + return + } + t.Fatal(err) + } + defer d.Cleanup() + if err := d.Init(context.Background(), nil); err != nil { + t.Fatal(err) + } + want := 0 + if tc.allowed { + want = 1 + } + if calls != want { + t.Fatalf("persistence calls = %d, want %d", calls, want) + } + if value, _, ok := tel.LatestMetric("managed", "persist_ok"); !ok || value != float64(want) { + t.Fatalf("persist result = %v, present=%v, want %d", value, ok, want) + } + }) + } +} + // TestHostPersistSecret verifies a driver can durably write a config // secret (e.g. a rotated OAuth refresh_token) back through the // host.persist_secret capability, and that the (key, value) pair reaches diff --git a/go/internal/drivers/read_only_auth_post_test.go b/go/internal/drivers/read_only_auth_post_test.go index 9f0e4dfa..e95fa0b1 100644 --- a/go/internal/drivers/read_only_auth_post_test.go +++ b/go/internal/drivers/read_only_auth_post_test.go @@ -1,8 +1,17 @@ package drivers import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" "strings" + "sync/atomic" "testing" + "time" + + "github.com/srcfl/ftw/go/internal/telemetry" ) // A read-only driver that reads a vendor cloud cannot read anything until it @@ -16,9 +25,104 @@ func readOnlyAuthPostPolicy(path string) *RuntimePolicy { PackageID: "com.sourceful.driver.myuplink", Version: "1.2.0", ArtifactSHA256: strings.Repeat("a", 64), - ReadOnly: true, - Permissions: map[string]bool{"http.get": true, "http.post": true}, - AuthPostPath: path, + RuntimeABI: "gopher-lua-source-v1", HostAPIProfile: "sourceful.host/ftw-core/v1", + ReadOnly: true, + Permissions: map[string]bool{"http.get": true, "http.post": true}, + AuthPostPath: path, + } +} + +func TestManagedReadOnlyOAuthHTTPBoundary(t *testing.T) { + var requests atomic.Int32 + var deviceRequests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + if r.Method != "POST" || r.URL.Path != "/oauth/token" { + deviceRequests.Add(1) + } + if r.URL.Path == "/oauth/token" { + switch r.URL.Query().Get("redirect") { + case "307": + w.Header().Set("Location", "/v2/devices/1/points") + w.WriteHeader(http.StatusTemporaryRedirect) + return + case "308": + w.Header().Set("Location", "/v2/devices/1/points") + w.WriteHeader(http.StatusPermanentRedirect) + return + } + } + _, _ = w.Write([]byte(`{"access_token":"synthetic"}`)) + })) + defer server.Close() + path := filepath.Join(t.TempDir(), "oauth.lua") + if err := os.WriteFile(path, []byte(`function driver_init(config) +local body, err = host.http_post(config.url, "synthetic") +host.emit_metric("post_ok", body and 1 or 0) +if not body and not err then error("missing denial reason") end +end`), 0600); err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + name, url string + noPermission, allowed bool + }{ + {"auth", server.URL + "/oauth/token", false, true}, + {"auth_query", server.URL + "/oauth/token?x=1", false, true}, + {"device_write", server.URL + "/v2/devices/1/points", false, false}, + {"path_traversal", server.URL + "/oauth/token/../device", false, false}, + {"path_suffix", server.URL + "/oauth/token/extra", false, false}, + {"other_host", "http://not-allowed.invalid/oauth/token", false, false}, + {"invalid_url", ":bad", false, false}, + {"missing_post_permission", server.URL + "/oauth/token", true, false}, + {"redirect_307", server.URL + "/oauth/token?redirect=307", false, false}, + {"redirect_308", server.URL + "/oauth/token?redirect=308", false, false}, + } { + t.Run(tc.name, func(t *testing.T) { + policy := readOnlyAuthPostPolicy("/oauth/token") + if tc.noPermission { + delete(policy.Permissions, "http.post") + } + tel := telemetry.NewStore() + env := NewHostEnv("oauth", tel).WithHTTP().WithHTTPAllowedHosts([]string{strings.TrimPrefix(server.URL, "http://")}) + d, err := NewLuaDriverWithPolicy(path, env, policy) + if err != nil { + t.Fatal(err) + } + defer d.Cleanup() + before := requests.Load() + if err := d.Init(context.Background(), map[string]any{"url": tc.url}); err != nil { + t.Fatal(err) + } + want := 0 + if tc.allowed { + want = 1 + } + if value, _, ok := tel.LatestMetric("oauth", "post_ok"); !ok || value != float64(want) { + t.Fatalf("HTTP result = %v, present=%v, want %d", value, ok, want) + } + wantRequests := want + if strings.HasPrefix(tc.name, "redirect_") { + wantRequests = 1 + } + if got := requests.Load() - before; got != int32(wantRequests) { + t.Fatalf("HTTP requests = %d, want %d", got, wantRequests) + } + if deviceRequests.Load() != 0 { + t.Fatal("OAuth exception reached a device write path") + } + // A write scope must not turn a read-only OAuth grant into a device + // write grant, even when local HTTP write capability is configured. + env.WithHTTPAllowWrite() + env.writePhase = "command" + env.writeDeadline = time.Now().Add(time.Minute) + if err := env.allowWrite("http.post"); err == nil { + t.Fatal("read-only POST escaped through command write scope") + } + if env.writeAttempts != 0 { + t.Fatal("auth or rejected device POST spent the write budget") + } + }) } }