diff --git a/.changeset/preserve-beta-driver-trust.md b/.changeset/preserve-beta-driver-trust.md new file mode 100644 index 000000000..49d23a435 --- /dev/null +++ b/.changeset/preserve-beta-driver-trust.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Keep official driver signature checks and read-only limits after repository names change. Use the trust recorded at installation and verify the saved manifest again; stop a managed driver if its trusted source cannot be found. diff --git a/go/internal/driverrepo/beta_runtime_policy_test.go b/go/internal/driverrepo/beta_runtime_policy_test.go new file mode 100644 index 000000000..cb9da8e7f --- /dev/null +++ b/go/internal/driverrepo/beta_runtime_policy_test.go @@ -0,0 +1,165 @@ +package driverrepo + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/srcfl/ftw/go/internal/config" + "github.com/srcfl/ftw/go/internal/state" +) + +type betaPolicyFixture struct { + manager *Manager + installed state.DriverRepoInstall + driver config.Driver + newManager func([]string) *Manager +} + +func installBetaPolicyFixture(t *testing.T, repositoryIDs []string) betaPolicyFixture { + t.Helper() + public, private, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + fixture := &signedFixture{private: private} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/manifest.json": + _, _ = w.Write(fixture.envelope(t)) + case "/demo.lua": + _, _ = w.Write(fixture.driver) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + fixture.setVersion(server.URL, "1.2.2") + fixture.manifest.Repository = "https://github.com/srcfl/device-drivers" + entry := &fixture.manifest.Drivers[0] + entry.ReadOnly, entry.Metadata.ReadOnly = true, true + entry.Permissions = []string{"http.get", "http.post"} + entry.Metadata.AuthPostPath = "/oauth/token" + entry.Metadata.ConfigSecrets = []string{"refresh_token"} + dir := t.TempDir() + st, err := state.Open(filepath.Join(dir, "state.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + newManager := func(ids []string) *Manager { + cfg := &config.DeviceRepository{} + for _, id := range ids { + cfg.Repositories = append(cfg.Repositories, config.DriverRepositorySource{ + ID: id, ManifestURL: "https://unrelated.invalid/manifest.json", AllowUnsigned: true, + }) + } + m := New(cfg, dir, st) + // Keep the production alias calculation, replacing only the fixture + // transport and signing key. No production private key is available. + m.betaRepo.ManifestURL = server.URL + "/manifest.json" + m.betaRepo.AllowInsecure = true + m.betaRepo.TrustedKeys = map[string]string{"test": base64.StdEncoding.EncodeToString(public)} + return m + } + m := newManager(repositoryIDs) + installed, err := m.InstallChannel(context.Background(), "beta", "demo", "1.2.2") + if err != nil { + t.Fatal(err) + } + if installed.FTWSigned { + t.Fatal("fixture key was incorrectly recorded as FTW's production key") + } + // Model the durable provenance that the production installer records for + // its pinned key. Signature verification still runs with the fixture key. + installed.FTWSigned = true + installed, err = st.ActivateDriverRepoInstall(installed) + if err != nil { + t.Fatal(err) + } + return betaPolicyFixture{m, installed, config.Driver{Name: "demo", Lua: filepath.Join(m.ActiveDir(), "demo.lua")}, newManager} +} + +func TestOfficialBetaPolicySurvivesRepositoryAliasChanges(t *testing.T) { + beta := config.DefaultDriverRepositoryBetaID + for _, tc := range []struct { + name string + before, after []string + }{ + {"collision_removed", []string{beta}, nil}, + {"collision_added", nil, []string{beta}}, + {"multiple_collisions_removed", []string{beta, beta + "-channel"}, nil}, + } { + t.Run(tc.name, func(t *testing.T) { + f := installBetaPolicyFixture(t, tc.before) + reloaded := f.newManager(tc.after) + if reloaded.betaRepo.ID == f.installed.RepoID { + t.Fatal("test did not change the generated beta alias") + } + // The current repository owning this alias may already have loaded + // different, even unsigned metadata into the shared memory cache. + unrelated := f.manager.manifests[f.installed.RepoID] + unrelated.Drivers = append([]ManifestDriver(nil), unrelated.Drivers...) + unrelated.Drivers[0].ReadOnly = false + unrelated.Drivers[0].Metadata.ReadOnly = false + unrelated.Drivers[0].ControlEnabled = true + reloaded.manifests[f.installed.RepoID] = unrelated + policy, err := reloaded.RuntimePolicy(f.driver) + if err != nil || policy == nil || !policy.IsReadOnly() || policy.ArtifactSHA256 != f.installed.SHA256 || + policy.AuthPostPath != "/oauth/token" || len(policy.ConfigSecrets) != 1 || policy.ConfigSecrets[0] != "refresh_token" { + t.Fatalf("beta policy after alias change = %+v, %v", policy, err) + } + }) + } +} + +func TestOfficialBetaPolicyRejectsReplacedOrMissingManifest(t *testing.T) { + for _, mode := range []string{"missing", "other_signer", "changed_payload"} { + t.Run(mode, func(t *testing.T) { + f := installBetaPolicyFixture(t, nil) + cache := filepath.Join(f.manager.root, "cache", f.installed.RepoID+".json") + if mode == "missing" { + if err := os.Remove(cache); err != nil { + t.Fatal(err) + } + } else { + raw, err := os.ReadFile(cache) + if err != nil { + t.Fatal(err) + } + var envelope ManifestEnvelope + if err := json.Unmarshal(raw, &envelope); err != nil { + t.Fatal(err) + } + if mode == "other_signer" { + _, other, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + envelope.Signature = base64.StdEncoding.EncodeToString(ed25519.Sign(other, envelope.Payload)) + } else { + envelope.Payload = json.RawMessage(`{"schema_version":1}`) + } + raw, err = json.Marshal(envelope) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(cache, raw, 0600); err != nil { + t.Fatal(err) + } + } + // Even an earlier valid in-memory manifest cannot bypass a fresh + // check against the recorded official trust identity. + if policy, err := f.manager.RuntimePolicy(f.driver); err == nil || policy != nil { + t.Fatalf("%s manifest yielded runtime policy %+v, %v", mode, policy, err) + } + }) + } +} diff --git a/go/internal/driverrepo/manager_test.go b/go/internal/driverrepo/manager_test.go index 17428177f..8edebe4c2 100644 --- a/go/internal/driverrepo/manager_test.go +++ b/go/internal/driverrepo/manager_test.go @@ -270,7 +270,7 @@ func TestOfficialBetaChannelInstallsOneSignedDriver(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 { + if policy, err := unknown.RuntimePolicy(driverCfg); err == nil || policy != nil { t.Fatalf("unknown installed repository gained a policy: %+v, %v", policy, err) } }) diff --git a/go/internal/driverrepo/sourceful.go b/go/internal/driverrepo/sourceful.go index 5eae6df11..fe1f5f2d3 100644 --- a/go/internal/driverrepo/sourceful.go +++ b/go/internal/driverrepo/sourceful.go @@ -584,10 +584,18 @@ func (m *Manager) RuntimePolicy(cfg config.Driver) (*drivers.RuntimePolicy, erro return nil, nil } var repo *config.DriverRepositorySource - for i := range m.cfg.Repositories { - if m.cfg.Repositories[i].ID == installed.RepoID { - repo = &m.cfg.Repositories[i] - break + if installed.FTWSigned && installed.RepoURL == "https://github.com/srcfl/device-drivers" { + // The installer recorded this trust identity before config aliases + // could change. Keep the historical ID only to locate its cache. + pinned := m.betaRepo + pinned.ID = installed.RepoID + repo = &pinned + } else { + for i := range m.cfg.Repositories { + if m.cfg.Repositories[i].ID == installed.RepoID { + repo = &m.cfg.Repositories[i] + break + } } } // InstallChannel uses this pinned trust source without adding it to the @@ -596,10 +604,7 @@ func (m *Manager) RuntimePolicy(cfg config.Driver) (*drivers.RuntimePolicy, erro 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") - } - return nil, nil + return nil, errors.New("active managed driver has no trusted repository source") } if repositoryFormat(*repo) != config.DriverRepositoryFormatSourcefulIndexV1 { return m.directManifestRuntimePolicy(cfg, *repo, installed) @@ -698,7 +703,23 @@ func (m *Manager) directManifestRuntimePolicy( if cfg.Control != nil && cfg.Control.Enabled { return nil, errors.New("control opt-in requires a signed Device Support control package") } - manifest, err := m.manifestFor(repo) + var manifest Manifest + var err error + if installed.FTWSigned && installed.RepoURL == "https://github.com/srcfl/device-drivers" { + // The in-memory cache is keyed only by a configurable repository ID. + // Another source may now own that ID. Reverify the saved envelope + // against the pinned official key before applying its runtime policy. + var raw []byte + raw, err = readLimitedFile(filepath.Join(m.root, "cache", safeSegment(installed.RepoID)+".json"), maxManifestBytes) + if err == nil { + manifest, _, err = verifyManifest(raw, repo) + } + if err == nil { + err = validateManifest(manifest, repo.AllowInsecure) + } + } else { + manifest, err = m.manifestFor(repo) + } if err != nil { if installed.RepoURL != "https://github.com/srcfl/device-drivers" { return nil, nil