Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/driver-secrets-before-start.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"ftw": patch
---

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.
24 changes: 24 additions & 0 deletions go/cmd/ftw/driver_registry.go
Original file line number Diff line number Diff line change
@@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Key rotated credentials by stable device identity

When a managed OAuth driver rotates token A to B and the operator later renames the YAML driver, this key changes even though the device does not. Because the source config intentionally remains at A, the next initialization misses B and presents the now-invalidated token, disconnecting the driver; reusing an old name can conversely apply that credential to the wrong device. Store and migrate the override under a stable hardware/account identity rather than cfg.Name.

AGENTS.md reference: AGENTS.md:L38-L38

Useful? React with 👍 / 👎.

}
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
}
113 changes: 113 additions & 0 deletions go/cmd/ftw/driver_registry_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package main

import (
"context"
"fmt"
"os"
"path/filepath"
"testing"
"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 _, 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
// 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
`, scenario.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)
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()
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", 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")
}
}()
_, 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")
}
})
}
}
15 changes: 1 addition & 14 deletions go/cmd/ftw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down
77 changes: 75 additions & 2 deletions go/internal/driverrepo/manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -191,14 +192,27 @@ 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"))
if err != nil {
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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion go/internal/driverrepo/sourceful.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +595 to +596

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the beta trust identity across configuration changes

officialBetaRepository derives betaRepo.ID from the current configured repository IDs, so it can change between installation and restart. If a beta was installed under a suffixed ID because of a collision and that colliding repository is later removed, installed.RepoID matches neither the configured repositories nor the newly derived beta ID here, causing RuntimePolicy to return nil and the signed read-only artifact to run with the unrestricted legacy policy. The reverse change can bind an existing beta install to an unrelated configured repository and fail verification; recognize the immutable trust identity recorded at installation instead of only the current generated ID.

Useful? React with 👍 / 👎.

}
if repo == nil {
if cfg.Control != nil && cfg.Control.Enabled {
return nil, errors.New("control opt-in requires a configured Device Support trust root")
Expand Down Expand Up @@ -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
}

Expand Down
3 changes: 3 additions & 0 deletions go/internal/driverrepo/sourceful_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
25 changes: 25 additions & 0 deletions go/internal/drivers/control_v2.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
}
Expand Down
Loading
Loading