Files
gnoma/internal/plugin/pins.go
T
vikingowl dc438ea181 feat(plugin): trust-on-first-use manifest pinning
Plugins are now verified against ~/.config/gnoma/plugins.pins.toml at
load time. Each plugin's plugin.json bytes are hashed (SHA-256) and:

- recorded automatically on first load (TOFU) with a prominent warning
- compared on subsequent loads
- refused with a clear error if the hash drifted, without overwriting
  the pin so the user can review and re-enrol deliberately

Pin-store I/O failures degrade to load-without-pinning rather than
locking the user out of previously-trusted plugins.

Closes audit finding C2. See ADR-003 for the decision rationale and
docs/plugins-trust.md for the end-user trust model.
2026-05-19 16:44:09 +02:00

82 lines
2.0 KiB
Go

package plugin
import (
"errors"
"fmt"
"os"
"path/filepath"
"github.com/BurntSushi/toml"
)
// PinStore records the trusted SHA-256 of each enrolled plugin's manifest.
// Implementations must be safe for the single-process startup flow used by
// the loader; no concurrent Set calls are issued.
type PinStore interface {
Get(name string) (hash string, ok bool)
Set(name, hash string) error
}
// FilePinStore persists pins to a TOML file with a single [pins] table.
type FilePinStore struct {
path string
pins map[string]string
}
type pinsFile struct {
Pins map[string]string `toml:"pins"`
}
func NewFilePinStore(path string) (*FilePinStore, error) {
s := &FilePinStore{path: path, pins: make(map[string]string)}
data, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return s, nil
}
if err != nil {
return nil, fmt.Errorf("read pin file %q: %w", path, err)
}
var pf pinsFile
if _, err := toml.Decode(string(data), &pf); err != nil {
return nil, fmt.Errorf("decode pin file %q: %w", path, err)
}
if pf.Pins != nil {
s.pins = pf.Pins
}
return s, nil
}
func (s *FilePinStore) Get(name string) (string, bool) {
h, ok := s.pins[name]
return h, ok
}
func (s *FilePinStore) Set(name, hash string) error {
s.pins[name] = hash
if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
return fmt.Errorf("create pin dir: %w", err)
}
// Atomic write: temp + rename so a crash mid-write doesn't corrupt the file.
tmp, err := os.CreateTemp(filepath.Dir(s.path), "pins-*.tmp")
if err != nil {
return fmt.Errorf("create temp pin file: %w", err)
}
tmpPath := tmp.Name()
enc := toml.NewEncoder(tmp)
encErr := enc.Encode(pinsFile{Pins: s.pins})
closeErr := tmp.Close()
if encErr != nil {
_ = os.Remove(tmpPath)
return fmt.Errorf("encode pin file: %w", encErr)
}
if closeErr != nil {
_ = os.Remove(tmpPath)
return fmt.Errorf("close temp pin file: %w", closeErr)
}
if err := os.Rename(tmpPath, s.path); err != nil {
_ = os.Remove(tmpPath)
return fmt.Errorf("rename pin file: %w", err)
}
return nil
}