Bind signed operator scope before opening the Home database
3 files changed,
+356
−0
implementation/operator-binding/proposal.json+21 −0internal/operator/command_linux.go+116 −0internal/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) |
| + } |
| + }) |
| + } |
| +} |