oficina / home Public

Bind signed operator scope before opening the Home database

Paul Campbell pushed by paul@keymail.dev ad8762f3e05e89028a182693c09b40b95cae1ceb parent 6289db0
3 files changed, +356 −0
  • implementation/operator-binding/proposal.json +21 −0
  • internal/operator/command_linux.go +116 −0
  • internal/operator/command_linux_test.go +219 −0
diff --git a/implementation/operator-binding/proposal.json b/implementation/operator-binding/proposal.json
new file mode 100644
index 0000000..656c7c9
--- /dev/null
+++ b/implementation/operator-binding/proposal.json
@@ -0,0 +1,21 @@
+{
+ "status": "Proposed private adapter component; no command or HTTP mounting",
+ "parents": ["Home startup6289db0 with Fable approval", "Opened DB4770bc6", "Operator/provisioner898fe219"],
+ "configuration": {
+ "source": "HOME_OPERATOR_COMMAND from the worker's immutable protected app environment; never argv or signed-request fields",
+ "wire": "UTF-8 JSON at most8192 bytes; duplicate, unknown and missing fields refuse",
+ "fields": ["version=1", "account", "app", "host", "role", "origin", "installation", "registry", "device", "public_key", "key_id", "actions"],
+ "validation": "Shared route validators, existing NewVerifier role/origin/identity/key/action checks, and exact derived key ID match. No default, trust-on-first-input or key rotation by request."
+ },
+ "ordering": [
+ "Parse protected configuration and build the existing signature verifier before database access.",
+ "Consume reviewed private startup transport; preserve /dev/null reservations on fd0/3/4 until the database baseline.",
+ "Match launch account/app/host against protected configuration; verify exact signed Home request using pinned role/origin/actions/installation/registry/device/key.",
+ "Only then construct the opened-DB connector for context path/device/inode, bounded by the earlier caller/launch deadline.",
+ "Keep platform request/attempt IDs distinct from the signed Home operation ID; retain exact private signed bytes for the existing transactional engine.",
+ "Final cleanup checks pinned database identity, closes the database and clears retained private byte buffers; any uncertain cleanup/deadline yields unavailable, never a successful result."
+ ],
+ "replay": "Expired authentic requests remain eligible for the existing engine's exact committed-result recovery. The adapter does not grant freshness or execute a mutation. The engine separately enforces freshness before a new operation.",
+ "tests": "Real inherited-descriptor subprocesses plus Linux inotify IN_OPEN prove wrong signer, signed scope, configuration and transport scope refuse before any database open. Actual pinned modernc connection accepts the valid path; wrong inode is opened only for identity refusal. Closed config tests cover every missing field, duplicates, unknown fields, trailing data and key mismatch.",
+ "remaining": ["Fable approval of configuration alignment and component", "Final fixed command dispatch, role-specific key/sealer/owner resolver and schema checks", "Native worker authentication, owner approval, lease/fence/checkpoint and resume workflow", "Whole integration/canary validation before any live invocation"]
+}
diff --git a/internal/operator/command_linux.go b/internal/operator/command_linux.go
new file mode 100644
index 0000000..558bdbb
--- /dev/null
+++ b/internal/operator/command_linux.go
@@ -0,0 +1,116 @@
+//go:build linux
+
+package operator
+
+import (
+ "context"
+ "os"
+ "time"
+
+ "amadan.net/carlos/platform.git/launchctx"
+ "amadan.net/carlos/platform.git/launchctx/controlname"
+ "amadan.net/oficina/home/internal/operatorlaunch"
+)
+
+const commandConfigEnvironment = "HOME_OPERATOR_COMMAND"
+
+type commandConfig struct {
+ Version int `json:"version"`
+ Account string `json:"account"`
+ App string `json:"app"`
+ Host string `json:"host"`
+ Role string `json:"role"`
+ Origin string `json:"origin"`
+ Installation string `json:"installation"`
+ Registry string `json:"registry"`
+ Device string `json:"device"`
+ PublicKey string `json:"public_key"`
+ KeyID string `json:"key_id"`
+ Actions []string `json:"actions"`
+}
+
+func parseCommandConfig(raw string) (commandConfig, *Verifier, error) {
+ var c commandConfig
+ if len(raw) == 0 || len(raw) > 8192 || strict([]byte(raw), &c) != nil || c.Version != 1 || !controlname.ValidAccount(c.Account) || !controlname.ValidApp(c.App) || !controlname.ValidHostShape(c.Host) {
+ return commandConfig{}, nil, ErrRefused
+ }
+ v, err := NewVerifier(Pins{c.Role, c.Origin, c.Installation, c.Registry, c.Device, c.PublicKey, c.Actions})
+ if err != nil || c.KeyID != v.kid {
+ return commandConfig{}, nil, ErrRefused
+ }
+ return c, v, nil
+}
+
+// commandInvocation remains private to the unmounted command adapter. Nothing
+// in the HTTP app can supply a database, verifier or transport context to it.
+type commandInvocation struct {
+ database *boundDatabase
+ verifier *Verifier
+ request Request
+ launch launchctx.LaunchContext
+ raw []byte
+ ctx context.Context
+ cancel context.CancelFunc
+}
+
+func openCommandInvocation(parent context.Context, database string) (*commandInvocation, error) {
+ c, verifier, err := parseCommandConfig(os.Getenv(commandConfigEnvironment))
+ if err != nil {
+ return nil, ErrRefused
+ }
+ launch, raw, err := operatorlaunch.ReadOperator(parent, database)
+ if err != nil {
+ return nil, ErrRefused
+ }
+ keep := false
+ defer func() {
+ if !keep {
+ clear(raw)
+ }
+ }()
+ if launch.Account != c.Account || launch.App != c.App || launch.Host != c.Host {
+ return nil, ErrRefused
+ }
+ request, err := verifier.Verify(raw)
+ if err != nil {
+ return nil, ErrRefused
+ }
+ defer func() {
+ if !keep {
+ clear(request.input)
+ }
+ }()
+ deadline, err := time.Parse(time.RFC3339Nano, launch.ExecutionDeadline)
+ if err != nil || parent.Err() != nil {
+ return nil, ErrRefused
+ }
+ if callerDeadline, ok := parent.Deadline(); ok && callerDeadline.Before(deadline) {
+ deadline = callerDeadline
+ }
+ ctx, cancel := context.WithDeadline(parent, deadline)
+ db, err := newBoundDatabase(launch.DatabasePath, launch.DatabaseDevice, launch.DatabaseInode, deadline)
+ if err != nil {
+ cancel()
+ return nil, ErrRefused
+ }
+ keep = true
+ return &commandInvocation{db, verifier, request, launch, raw, ctx, cancel}, nil
+}
+
+func (i *commandInvocation) close() error {
+ if i == nil || i.database == nil {
+ return ErrRefused
+ }
+ defer i.cancel()
+ defer clear(i.raw)
+ defer clear(i.request.input)
+ i.database.connector.mu.Lock()
+ identityErr := i.database.connector.pathMatches()
+ i.database.connector.mu.Unlock()
+ closeErr := i.database.close()
+ i.database = nil
+ if identityErr != nil || closeErr != nil || i.ctx.Err() != nil {
+ return ErrUnavailable
+ }
+ return nil
+}
diff --git a/internal/operator/command_linux_test.go b/internal/operator/command_linux_test.go
new file mode 100644
index 0000000..0ec2aa0
--- /dev/null
+++ b/internal/operator/command_linux_test.go
@@ -0,0 +1,219 @@
+//go:build linux
+
+package operator
+
+import (
+ "bytes"
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "io"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "amadan.net/carlos/platform.git/launchctx"
+ family "amadan.net/rastrillo/rastrillo/crypto"
+ "golang.org/x/sys/unix"
+)
+
+func commandConfigFixture(v *Verifier) commandConfig {
+ p := v.pins
+ return commandConfig{1, "bds", "home", "home.example", p.Role, p.Origin, p.Installation, p.Registry, p.Device, p.PublicKey, v.kid, p.Actions}
+}
+
+func TestCommandConfigRequiresExactPins(t *testing.T) {
+ _, v, _ := fixture(t)
+ c := commandConfigFixture(v)
+ raw, err := json.Marshal(c)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, _, err := parseCommandConfig(string(raw)); err != nil {
+ t.Fatal(err)
+ }
+ var fields map[string]json.RawMessage
+ if json.Unmarshal(raw, &fields) != nil {
+ t.Fatal("fixture")
+ }
+ for key, value := range fields {
+ delete(fields, key)
+ missing, _ := json.Marshal(fields)
+ if _, _, err := parseCommandConfig(string(missing)); err == nil {
+ t.Fatal("missing pin accepted", key)
+ }
+ fields[key] = value
+ }
+ for _, bad := range []string{"", string(raw) + string(raw), strings.Repeat("x", 8193), strings.Replace(string(raw), `"version":1`, `"version":1,"version":1`, 1), strings.Replace(string(raw), `"version":1`, `"version":1,"extra":"x"`, 1), strings.Replace(string(raw), v.kid, strings.Repeat("a", 64), 1)} {
+ if _, _, err := parseCommandConfig(bad); err == nil {
+ t.Fatal("bad configuration accepted")
+ }
+ }
+}
+
+func TestCommandInvocationChild(t *testing.T) {
+ if os.Getenv("HOME_COMMAND_FIXTURE") == "" {
+ return
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
+ defer cancel()
+ i, err := openCommandInvocation(ctx, os.Getenv("HOME_COMMAND_DATABASE"))
+ accepted := err == nil
+ if accepted {
+ var count int
+ if i.request.Authorization().OperationID != token(4) || i.launch.RequestID != token(5) || i.launch.AttemptID != token(6) || i.database.db.QueryRowContext(i.ctx, "SELECT count(*) FROM changes").Scan(&count) != nil || count != 0 {
+ os.Exit(3)
+ }
+ if err := i.close(); err != nil {
+ os.Exit(4)
+ }
+ if i.close() != ErrRefused {
+ os.Exit(5)
+ }
+ } else if err != ErrRefused {
+ os.Exit(6)
+ }
+ json.NewEncoder(os.Stdout).Encode(map[string]bool{"accepted": accepted})
+ os.Exit(0)
+}
+
+func TestCommandInvocationBindsSignatureBeforeOpeningDatabase(t *testing.T) {
+ image, err := os.ReadFile("/proc/self/exe")
+ if err != nil {
+ t.Fatal(err)
+ }
+ imageHash := sha256.Sum256(image)
+ for _, mode := range []string{"valid", "expired-authentic", "signer", "origin", "registry", "role", "action", "config-key", "transport-account", "transport-app", "transport-host", "inode"} {
+ t.Run(mode, func(t *testing.T) {
+ k, v, a := fixture(t)
+ cfg := commandConfigFixture(v)
+ if mode == "expired-authentic" {
+ a.Issued, a.Expires = 100, 200
+ }
+ switch mode {
+ case "signer":
+ k, err = family.Generate()
+ if err != nil {
+ t.Fatal(err)
+ }
+ case "origin":
+ a.Origin = "https://other.example"
+ case "registry":
+ a.Registry = token(9)
+ case "role":
+ a.Role, a.Action = "credential", "credential.owner-seed"
+ case "action":
+ a.Action = "directory.seed-team"
+ case "config-key":
+ cfg.KeyID = strings.Repeat("a", 64)
+ }
+ input := signed(t, k, a)
+ inputHash := sha256.Sum256(input)
+ dir := t.TempDir()
+ database := filepath.Join(dir, "home.db")
+ seedOpenDB(t, database)
+ var st unix.Stat_t
+ if unix.Stat(database, &st) != nil {
+ t.Fatal("database stat")
+ }
+ path := filepath.Join(dir, "image")
+ if err := os.WriteFile(path, image, 0700); err != nil {
+ t.Fatal(err)
+ }
+ pin, err := os.Open(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer pin.Close()
+ digest := strings.Repeat("a", 64)
+ launch := launchctx.LaunchContext{Version: 1, Account: cfg.Account, App: cfg.App, Host: cfg.Host, RequestID: token(5), AttemptID: token(6), RequestDigest: digest, RecordIdentity: digest, ArtifactDigest: hex.EncodeToString(imageHash[:]), EnvironmentEnvelopeDigest: digest, GuardGeneration: 1, InputSHA256: hex.EncodeToString(inputHash[:]), DatabasePath: database, DatabaseDevice: uint64(st.Dev), DatabaseInode: st.Ino, ExecutionDeadline: time.Now().Add(10 * time.Second).UTC().Format(time.RFC3339Nano)}
+ switch mode {
+ case "transport-account":
+ launch.Account = "abc"
+ case "transport-app":
+ launch.App = "another"
+ case "transport-host":
+ launch.Host = "another.example"
+ case "inode":
+ launch.DatabaseInode++
+ }
+ raw, err := launchctx.EncodeLaunch(launch)
+ if err != nil {
+ t.Fatal(err)
+ }
+ config, err := json.Marshal(cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ inR, inW, err := os.Pipe()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer inR.Close()
+ defer inW.Close()
+ contextR, contextW, err := os.Pipe()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer contextR.Close()
+ defer contextW.Close()
+ watch, err := unix.InotifyInit1(unix.IN_CLOEXEC | unix.IN_NONBLOCK)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer unix.Close(watch)
+ if _, err := unix.InotifyAddWatch(watch, database, unix.IN_OPEN); err != nil {
+ t.Fatal(err)
+ }
+ ctx, cancel := context.WithTimeout(t.Context(), 8*time.Second)
+ defer cancel()
+ cmd := exec.CommandContext(ctx, path, "-test.run=^TestCommandInvocationChild$")
+ cmd.Dir = "/"
+ cmd.Env = []string{"HOME_COMMAND_FIXTURE=" + mode, "HOME_COMMAND_DATABASE=" + database, commandConfigEnvironment + "=" + string(config)}
+ cmd.Stdin = inR
+ cmd.ExtraFiles = []*os.File{contextR, pin}
+ var output bytes.Buffer
+ cmd.Stdout = &output
+ if err := cmd.Start(); err != nil {
+ t.Fatal(err)
+ }
+ inR.Close()
+ contextR.Close()
+ writes := make(chan struct{}, 2)
+ for _, item := range []struct {
+ fd *os.File
+ raw []byte
+ }{{inW, input}, {contextW, raw}} {
+ go func() {
+ io.Copy(item.fd, bytes.NewReader(item.raw))
+ item.fd.Close()
+ writes <- struct{}{}
+ }()
+ }
+ if err := cmd.Wait(); err != nil {
+ t.Fatal("child refused fixture", err)
+ }
+ <-writes
+ <-writes
+ var result struct{ Accepted bool }
+ want := mode == "valid" || mode == "expired-authentic"
+ if json.Unmarshal(output.Bytes(), &result) != nil || result.Accepted != want {
+ t.Fatal("wrong invocation result", output.String())
+ }
+ buf := make([]byte, 4096)
+ n, err := unix.Read(watch, buf)
+ opened := n > 0
+ if err != nil && !errors.Is(err, unix.EAGAIN) {
+ t.Fatal(err)
+ }
+ if opened != (want || mode == "inode") {
+ t.Fatal("database opened before signed scope validation", mode, opened)
+ }
+ })
+ }
+}