diff --git a/fst/config.go b/fst/config.go index 171c29f..ed0ac4c 100644 --- a/fst/config.go +++ b/fst/config.go @@ -7,7 +7,7 @@ import ( const Tmp = "/.fortify" -// Config is used to seal an *App +// Config is used to seal an app type Config struct { // application ID ID string `json:"id"` diff --git a/internal/app/app.go b/internal/app/app.go index 0da8148..30f893a 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -3,7 +3,6 @@ package app import ( "context" "sync" - "sync/atomic" "git.gensokyo.uk/security/fortify/fst" "git.gensokyo.uk/security/fortify/internal/linux" @@ -30,9 +29,6 @@ type RunState struct { } type app struct { - // single-use config reference - ct *appCt - // application unique identifier id *fst.ID // operating system interface @@ -74,24 +70,3 @@ func New(os linux.System) (App, error) { a.os = os return a, fst.NewAppID(a.id) } - -// appCt ensures its wrapped val is only accessed once -type appCt struct { - val *fst.Config - done *atomic.Bool -} - -func (a *appCt) Unwrap() *fst.Config { - if !a.done.Load() { - defer a.done.Store(true) - return a.val - } - panic("attempted to access config reference twice") -} - -func newAppCt(config *fst.Config) (ct *appCt) { - ct = new(appCt) - ct.done = new(atomic.Bool) - ct.val = config - return ct -} diff --git a/internal/app/seal.go b/internal/app/seal.go index 35cd316..bd0522a 100644 --- a/internal/app/seal.go +++ b/internal/app/seal.go @@ -1,8 +1,11 @@ package app import ( + "bytes" + "encoding/gob" "errors" "fmt" + "io" "io/fs" "path" "regexp" @@ -47,6 +50,8 @@ type appSeal struct { // pass-through enablement tracking from config et system.Enablements + // initial config gob encoding buffer + ct io.WriterTo // pass-through seccomp config from config scmp *fst.SyscallConfig // wayland socket direct access @@ -87,6 +92,14 @@ func (a *app) Seal(config *fst.Config) error { // create seal seal := new(appSeal) + // encode initial configuration for state tracking + ct := new(bytes.Buffer) + if err := gob.NewEncoder(ct).Encode(config); err != nil { + return fmsg.WrapErrorSuffix(err, + "cannot encode initial config:") + } + seal.ct = ct + // fetch system constants seal.Paths = a.os.Paths() @@ -261,6 +274,5 @@ func (a *app) Seal(config *fst.Config) error { // seal app and release lock a.seal = seal - a.ct = newAppCt(config) return nil } diff --git a/internal/app/start.go b/internal/app/start.go index 1825d9a..87bb6fd 100644 --- a/internal/app/start.go +++ b/internal/app/start.go @@ -89,16 +89,15 @@ func (a *app) Run(ctx context.Context, rs *RunState) error { // shim accepted setup payload, create process state sd := state.State{ - ID: *a.id, - PID: a.shim.Unwrap().Process.Pid, - Config: a.ct.Unwrap(), - Time: *startTime, + ID: *a.id, + PID: a.shim.Unwrap().Process.Pid, + Time: *startTime, } // register process state var err0 = new(StateStoreError) err0.Inner, err0.DoErr = a.seal.store.Do(a.seal.sys.user.aid, func(c state.Cursor) { - err0.InnerErr = c.Save(&sd) + err0.InnerErr = c.Save(&sd, a.seal.ct) }) a.seal.sys.saveState = true if err = err0.equiv("cannot save process state:"); err != nil { diff --git a/internal/state/multi.go b/internal/state/multi.go index fd92847..bb3764f 100644 --- a/internal/state/multi.go +++ b/internal/state/multi.go @@ -1,9 +1,11 @@ package state import ( + "encoding/binary" "encoding/gob" "errors" "fmt" + "io" "io/fs" "os" "path" @@ -208,12 +210,11 @@ func (b *multiBackend) load(decode bool) (Entries, error) { s := new(State) r[*id] = s - // append regardless, but only parse if required, used to implement Len + // append regardless, but only parse if required, implements Len if decode { - if err = gob.NewDecoder(f).Decode(s); err != nil { + if err = b.decodeState(f, s); err != nil { return err } - if s.ID != *id { return fmt.Errorf("state entry %s has unexpected id %s", id, &s.ID) } @@ -229,18 +230,65 @@ func (b *multiBackend) load(decode bool) (Entries, error) { return r, nil } +// state file consists of an eight byte header, followed by concatenated gobs +// of [fst.Config] and [State], if [State.Config] is not nil or offset < 0, +// the first gob is skipped +func (b *multiBackend) decodeState(r io.ReadSeeker, state *State) error { + offset := make([]byte, 8) + if l, err := r.Read(offset); err != nil { + if errors.Is(err, io.EOF) { + return fmt.Errorf("state file too short: %d bytes", l) + } + return err + } + + // decode volatile state first + var skipConfig bool + { + o := int64(binary.LittleEndian.Uint64(offset)) + skipConfig = o < 0 + + if !skipConfig { + if l, err := r.Seek(o, io.SeekCurrent); err != nil { + return err + } else if l != 8+o { + return fmt.Errorf("invalid seek offset %d", l) + } + } + } + if err := gob.NewDecoder(r).Decode(state); err != nil { + return err + } + + // decode sealed config + if state.Config == nil { + // config must be provided either as part of volatile state, + // or in the config segment + if skipConfig { + return ErrNoConfig + } + + state.Config = new(fst.Config) + if _, err := r.Seek(8, io.SeekStart); err != nil { + return err + } + return gob.NewDecoder(r).Decode(state.Config) + } else { + return nil + } +} + // Save writes process state to filesystem -func (b *multiBackend) Save(state *State) error { +func (b *multiBackend) Save(state *State, configWriter io.WriterTo) error { b.lock.Lock() defer b.lock.Unlock() - if state.Config == nil { - return errors.New("state does not contain config") + if configWriter == nil && state.Config == nil { + return ErrNoConfig } statePath := b.filename(&state.ID) - // create and open state data file if f, err := os.OpenFile(statePath, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0600); err != nil { return err } else { @@ -250,11 +298,43 @@ func (b *multiBackend) Save(state *State) error { panic("state file closed prematurely") } }() - // encode into state file - return gob.NewEncoder(f).Encode(state) + return b.encodeState(f, state, configWriter) } } +func (b *multiBackend) encodeState(w io.WriteSeeker, state *State, configWriter io.WriterTo) error { + offset := make([]byte, 8) + + // skip header bytes + if _, err := w.Seek(8, io.SeekStart); err != nil { + return err + } + + if configWriter != nil { + // write config gob and encode header + if l, err := configWriter.WriteTo(w); err != nil { + return err + } else { + binary.LittleEndian.PutUint64(offset, uint64(l)) + } + } else { + // offset == -1 indicates absence of config gob + binary.LittleEndian.PutUint64(offset, 0xffffffffffffffff) + } + + // encode volatile state + if err := gob.NewEncoder(w).Encode(state); err != nil { + return err + } + + // write header + if _, err := w.Seek(0, io.SeekStart); err != nil { + return err + } + _, err := w.Write(offset) + return err +} + func (b *multiBackend) Destroy(id fst.ID) error { b.lock.Lock() defer b.lock.Unlock() diff --git a/internal/state/state.go b/internal/state/state.go index e820847..6e76d51 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -1,11 +1,15 @@ package state import ( + "errors" + "io" "time" "git.gensokyo.uk/security/fortify/fst" ) +var ErrNoConfig = errors.New("state does not contain config") + type Entries map[fst.ID]*State type Store interface { @@ -24,13 +28,13 @@ type Store interface { // Cursor provides access to the store type Cursor interface { - Save(state *State) error + Save(state *State, configWriter io.WriterTo) error Destroy(id fst.ID) error Load() (Entries, error) Len() (int, error) } -// State is the on-disk format for a fortified process's state information +// State is a fortify process's state type State struct { // fortify instance id ID fst.ID `json:"instance"` @@ -40,5 +44,5 @@ type State struct { Config *fst.Config `json:"config"` // process start time - Time time.Time + Time time.Time `json:"time"` } diff --git a/internal/state/state_test.go b/internal/state/state_test.go index b19c64b..ee2a88c 100644 --- a/internal/state/state_test.go +++ b/internal/state/state_test.go @@ -1,6 +1,9 @@ package state_test import ( + "bytes" + "encoding/gob" + "io" "math/rand/v2" "reflect" "slices" @@ -28,9 +31,12 @@ func testStore(t *testing.T, s state.Store) { tl ) - var tc [tl]state.State + var tc [tl]struct { + state state.State + ct bytes.Buffer + } for i := 0; i < tl; i++ { - makeState(t, &tc[i]) + makeState(t, &tc[i].state, &tc[i].ct) } do := func(aid int, f func(c state.Cursor)) { @@ -41,7 +47,7 @@ func testStore(t *testing.T, s state.Store) { insert := func(i, aid int) { do(aid, func(c state.Cursor) { - if err := c.Save(&tc[i]); err != nil { + if err := c.Save(&tc[i].state, &tc[i].ct); err != nil { t.Fatalf("Save(&tc[%v]): error = %v", i, err) } }) @@ -51,15 +57,17 @@ func testStore(t *testing.T, s state.Store) { do(aid, func(c state.Cursor) { if entries, err := c.Load(); err != nil { t.Fatalf("Load: error = %v", err) - } else if got, ok := entries[tc[i].ID]; !ok { + } else if got, ok := entries[tc[i].state.ID]; !ok { t.Fatalf("Load: entry %s missing", - &tc[i].ID) + &tc[i].state.ID) } else { - got.Time = tc[i].Time - if !reflect.DeepEqual(got, &tc[i]) { + got.Time = tc[i].state.Time + tc[i].state.Config = fst.Template() + if !reflect.DeepEqual(got, &tc[i].state) { t.Fatalf("Load: entry %s got %#v, want %#v", - &tc[i].ID, got, &tc[i]) + &tc[i].state.ID, got, &tc[i].state) } + tc[i].state.Config = nil } }) } @@ -104,7 +112,7 @@ func testStore(t *testing.T, s state.Store) { t.Run("clear aid 1", func(t *testing.T) { do(1, func(c state.Cursor) { - if err := c.Destroy(tc[insertEntryOtherApp].ID); err != nil { + if err := c.Destroy(tc[insertEntryOtherApp].state.ID); err != nil { t.Fatalf("Destroy: error = %v", err) } }) @@ -124,11 +132,13 @@ func testStore(t *testing.T, s state.Store) { }) } -func makeState(t *testing.T, s *state.State) { +func makeState(t *testing.T, s *state.State, ct io.Writer) { if err := fst.NewAppID(&s.ID); err != nil { t.Fatalf("cannot create dummy state: %v", err) } - s.Config = fst.Template() + if err := gob.NewEncoder(ct).Encode(fst.Template()); err != nil { + t.Fatalf("cannot encode dummy config: %v", err) + } s.PID = rand.Int() s.Time = time.Now() }