Verify private Home operator startup before database access
8 files changed,
+363
−2
Makefile+2 −2go.mod+1 −0go.sum+2 −0implementation/operator-startup/proposal.json+16 −0internal/operatorlaunch/errors.go+7 −0internal/operatorlaunch/read_linux.go+152 −0internal/operatorlaunch/read_linux_test.go+170 −0internal/operatorlaunch/read_other.go+13 −0
CI cancelled — superseded by a newer push — run details
diff --git a/Makefile b/Makefile| index e3638a0..8943f10 100644 |
| --- a/Makefile |
| +++ b/Makefile |
| @@ -1,7 +1,7 @@ |
| export GOPRIVATE := $(GOPRIVATE),amadan.net/oficina/estilo.git |
| private_comma := , |
| -export GOPRIVATE := $(if $(GOPRIVATE),$(GOPRIVATE)$(private_comma))amadan.net/oficina/estilo.git |
| +export GOPRIVATE := $(if $(GOPRIVATE),$(GOPRIVATE)$(private_comma))amadan.net/oficina/estilo.git,amadan.net/carlos/platform.git |
| .PHONY: build test test-vet test-fmt test-build test-go test-race |
| @@ -62,7 +62,7 @@ test-go: |
| # CGO_ENABLED. Nothing it builds is shipped, so it does not violate the |
| # cgo-free binary rule, which is about what gets deployed. |
| test-race: |
| - go test -race -count=1 ./internal/home/ ./internal/operator/ ./internal/provisioner/ ./internal/home/directorydata/ |
| + go test -race -count=1 ./internal/home/ ./internal/operator/ ./internal/operatorlaunch/ ./internal/provisioner/ ./internal/home/directorydata/ |
| cd handoff && CGO_ENABLED=1 go test -race -count=1 ./... |
| cd roster && CGO_ENABLED=1 go test -race -count=1 ./... |
| cd connect && CGO_ENABLED=1 go test -race -count=1 ./... |
diff --git a/go.mod b/go.mod| index 24f70e1..0cc4a06 100644 |
| --- a/go.mod |
| +++ b/go.mod |
| @@ -20,6 +20,7 @@ require ( |
| ) |
| require ( |
| + amadan.net/carlos/platform.git/launchctx v0.0.0-20260911135504-fb577f96f7d1 // indirect |
| filippo.io/hpke v0.4.0 // indirect |
| github.com/dustin/go-humanize v1.0.1 // indirect |
| github.com/google/uuid v1.6.0 // indirect |
diff --git a/go.sum b/go.sum| index acc35d2..16adee9 100644 |
| --- a/go.sum |
| +++ b/go.sum |
| @@ -1,3 +1,5 @@ |
| +amadan.net/carlos/platform.git/launchctx v0.0.0-20260911135504-fb577f96f7d1 h1:RxwIRojWcVtwSdNAjtdz7y1bxHLh3GUgqT1TrXVKpyM= |
| +amadan.net/carlos/platform.git/launchctx v0.0.0-20260911135504-fb577f96f7d1/go.mod h1:6fBmMWgtESLFXZURzu/HyRXM0vyLuo4+XGGdKuZ+wao= |
| amadan.net/oficina/estilo.git v0.0.0-20260908001419-672a447da335 h1:YogZKYINrPNXRfPedXxOIDbGCMY4ff7yAdohYphEWMg= |
| amadan.net/oficina/estilo.git v0.0.0-20260908001419-672a447da335/go.mod h1:0R45/vCUAT+kpS7S6K/ZxBdE8f7YxFN4HT3HyDJ3nPY= |
| amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489 h1:RPKtyry/71i06o8L+gjAaUyexbmG56kO1YPs601URAM= |
diff --git a/implementation/operator-startup/proposal.json b/implementation/operator-startup/proposal.json| new file mode 100644 |
| index 0000000..42b1c58 |
| --- /dev/null |
| +++ b/implementation/operator-startup/proposal.json |
| @@ -0,0 +1,16 @@ |
| +{ |
| + "status": "Proposed startup component; no command mounting or database integration", |
| + "parent_contracts": ["home-operator-open.md at approved 4770bc6", "platform maintenance-launch.md at approved 8eb788c9", "home-provisioner.md at approved 898fe219"], |
| + "alignment": "The prior opened-DB contract listed fd0/1/2/3. The reviewed platform launch adds fd4 as a startup-only readonly artifact pin. Home verifies fd4 against /proc/self/exe and closes both executable descriptors with checked errors before any opened-DB baseline. fd4 never becomes an exception in the database descriptor inventory.", |
| + "implementation": "internal/operatorlaunch.ReadOperator checks mutable-v1 metadata using the shared exact-pinned launchctx module. No HTTP or command entrypoint invokes it yet.", |
| + "ordering": [ |
| + "Read only the private FIFO at fd3 under a five-second startup bound and caller cancellation; cap at 8192 plus one byte, require EOF, close with checked error, reject malformed or checker-mode context.", |
| + "Require byte-exact database selector equality. Apply the earlier of startup timeout, caller deadline and context execution deadline.", |
| + "Require fd4 readonly regular identity equal to opened /proc/self/exe, stream the actual executable SHA256 and compare artifact_digest, and check all executable descriptor closures.", |
| + "Read only private FIFO stdin with 65536 plus one byte bound and EOF; match exact input_sha256 and check stdin closure. A failed startup returns only a fixed error and no private result bytes.", |
| + "The future command adapter must next validate the signed Home request using pinned role/origin/action/configuration, then construct the existing opened-DB connector. Startup metadata alone grants no mutation or worker authority. No database is opened by this component." |
| + ], |
| + "boundary": "Native Linux, fixed nonserving trusted-worker process. The worker owns actual owner approval, lifecycle fence, continuous lease, verified release/environment and strict containment. Neither synthetic inherited descriptors nor metadata establish those facts.", |
| + "verification": ["Actual test executable with inherited private pipes and readonly artifact", "Wrong context, oversized input/context, wrong selector/hash/image/flags, wrong mode and expired deadline refuse", "Stalled context and stdin terminate under startup deadline", "fd0/fd3/fd4 are closed before return; database sentinel remains unchanged"], |
| + "remaining_before_mount": ["Fable review of this alignment and source", "Fixed pinned configuration schema and signed-request binding in final command adapter", "Modernc package-init and opened-DB baseline call-graph review", "Native full worker plus consumer integration", "Real owner-approved preview provisioning and restore drill"] |
| +} |
diff --git a/internal/operatorlaunch/errors.go b/internal/operatorlaunch/errors.go| new file mode 100644 |
| index 0000000..0eb040c |
| --- /dev/null |
| +++ b/internal/operatorlaunch/errors.go |
| @@ -0,0 +1,7 @@ |
| +// Package operatorlaunch checks private startup transport before database access. |
| +// Its output still requires signed Home authorization and pinned configuration. |
| +package operatorlaunch |
| + |
| +import "errors" |
| + |
| +var ErrRefused = errors.New("operator launch: refused") |
diff --git a/internal/operatorlaunch/read_linux.go b/internal/operatorlaunch/read_linux.go| new file mode 100644 |
| index 0000000..e117993 |
| --- /dev/null |
| +++ b/internal/operatorlaunch/read_linux.go |
| @@ -0,0 +1,152 @@ |
| +//go:build linux |
| + |
| +package operatorlaunch |
| + |
| +import ( |
| + "context" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "errors" |
| + "time" |
| + |
| + "amadan.net/carlos/platform.git/launchctx" |
| + "golang.org/x/sys/unix" |
| +) |
| + |
| +const startupTimeout = 5 * time.Second |
| +const inputLimit = 64 << 10 |
| + |
| +// ReadOperator consumes and closes fd0, fd3 and fd4 in a nonserving process. |
| +// It never opens a database or establishes owner, fence or lease authority. |
| +func ReadOperator(parent context.Context, database string) (launch launchctx.LaunchContext, input []byte, err error) { |
| + if parent == nil { |
| + return launchctx.LaunchContext{}, nil, ErrRefused |
| + } |
| + owned := map[int]bool{0: true, 3: true, 4: true} |
| + closeFD := func(fd int) error { |
| + delete(owned, fd) |
| + return unix.Close(fd) |
| + } |
| + defer func() { |
| + for fd := range owned { |
| + if closeFD(fd) != nil { |
| + err = ErrRefused |
| + } |
| + } |
| + if err != nil { |
| + clear(input) |
| + input = nil |
| + launch = launchctx.LaunchContext{} |
| + err = ErrRefused |
| + } |
| + }() |
| + ctx, cancel := context.WithTimeout(parent, startupTimeout) |
| + defer cancel() |
| + raw, e := readPipe(ctx, 3, launchctx.MaxLaunchBytes) |
| + if e != nil { |
| + return launchctx.LaunchContext{}, nil, ErrRefused |
| + } |
| + defer clear(raw) |
| + if closeFD(3) != nil { |
| + return launchctx.LaunchContext{}, nil, ErrRefused |
| + } |
| + launch, e = launchctx.DecodeOperator(raw) |
| + if e != nil || launch.DatabasePath != database { |
| + return launchctx.LaunchContext{}, nil, ErrRefused |
| + } |
| + deadline, e := time.Parse(time.RFC3339Nano, launch.ExecutionDeadline) |
| + if e != nil || !time.Now().Before(deadline) { |
| + return launchctx.LaunchContext{}, nil, ErrRefused |
| + } |
| + ctx, cancelLaunch := context.WithDeadline(ctx, deadline) |
| + defer cancelLaunch() |
| + if checkExecutable(ctx, 4, launch.ArtifactDigest) != nil || closeFD(4) != nil { |
| + return launchctx.LaunchContext{}, nil, ErrRefused |
| + } |
| + input, e = readPipe(ctx, 0, inputLimit) |
| + if e != nil || closeFD(0) != nil || ctx.Err() != nil { |
| + return launchctx.LaunchContext{}, input, ErrRefused |
| + } |
| + sum := sha256.Sum256(input) |
| + if hex.EncodeToString(sum[:]) != launch.InputSHA256 { |
| + return launchctx.LaunchContext{}, input, ErrRefused |
| + } |
| + return launch, input, nil |
| +} |
| + |
| +func readPipe(ctx context.Context, fd, limit int) ([]byte, error) { |
| + var st unix.Stat_t |
| + flags, e := unix.FcntlInt(uintptr(fd), unix.F_GETFL, 0) |
| + if e != nil || flags&unix.O_ACCMODE != unix.O_RDONLY || unix.Fstat(fd, &st) != nil || st.Mode&unix.S_IFMT != unix.S_IFIFO || unix.SetNonblock(fd, true) != nil { |
| + return nil, ErrRefused |
| + } |
| + out := make([]byte, 0, limit+1) |
| + buf := make([]byte, 4096) |
| + defer clear(buf) |
| + for ctx.Err() == nil { |
| + n, e := unix.Read(fd, buf[:min(len(buf), limit+1-len(out))]) |
| + if n > 0 { |
| + out = append(out, buf[:n]...) |
| + if len(out) > limit { |
| + break |
| + } |
| + } |
| + if e == nil && n == 0 { |
| + return out, nil |
| + } |
| + if errors.Is(e, unix.EINTR) { |
| + continue |
| + } |
| + if errors.Is(e, unix.EAGAIN) { |
| + select { |
| + case <-ctx.Done(): |
| + case <-time.After(time.Millisecond): |
| + } |
| + continue |
| + } |
| + if e != nil { |
| + break |
| + } |
| + } |
| + clear(out) |
| + return nil, ErrRefused |
| +} |
| + |
| +func checkExecutable(ctx context.Context, artifact int, digest string) (err error) { |
| + flags, e := unix.FcntlInt(uintptr(artifact), unix.F_GETFL, 0) |
| + var pin unix.Stat_t |
| + if e != nil || flags&unix.O_ACCMODE != unix.O_RDONLY || unix.Fstat(artifact, &pin) != nil || pin.Mode&unix.S_IFMT != unix.S_IFREG { |
| + return ErrRefused |
| + } |
| + self, e := unix.Open("/proc/self/exe", unix.O_RDONLY|unix.O_CLOEXEC, 0) |
| + if e != nil { |
| + return ErrRefused |
| + } |
| + defer func() { |
| + if unix.Close(self) != nil { |
| + err = ErrRefused |
| + } |
| + }() |
| + var st unix.Stat_t |
| + if unix.Fstat(self, &st) != nil || st.Dev != pin.Dev || st.Ino != pin.Ino || st.Size <= 0 || st.Size != pin.Size { |
| + return ErrRefused |
| + } |
| + hash := sha256.New() |
| + buf := make([]byte, 64<<10) |
| + for offset := int64(0); offset < st.Size; { |
| + if ctx.Err() != nil { |
| + return ErrRefused |
| + } |
| + n, e := unix.Pread(self, buf[:min(int64(len(buf)), st.Size-offset)], offset) |
| + if e != nil || n <= 0 { |
| + return ErrRefused |
| + } |
| + hash.Write(buf[:n]) |
| + offset += int64(n) |
| + } |
| + var after unix.Stat_t |
| + if ctx.Err() != nil || unix.Fstat(self, &after) != nil || after.Size != st.Size || hex.EncodeToString(hash.Sum(nil)) != digest { |
| + return ErrRefused |
| + } |
| + return nil |
| +} |
diff --git a/internal/operatorlaunch/read_linux_test.go b/internal/operatorlaunch/read_linux_test.go| new file mode 100644 |
| index 0000000..adc8991 |
| --- /dev/null |
| +++ b/internal/operatorlaunch/read_linux_test.go |
| @@ -0,0 +1,170 @@ |
| +//go:build linux |
| + |
| +package operatorlaunch |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "crypto/sha256" |
| + "encoding/base64" |
| + "encoding/hex" |
| + "encoding/json" |
| + "errors" |
| + "io" |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "amadan.net/carlos/platform.git/launchctx" |
| + "golang.org/x/sys/unix" |
| +) |
| + |
| +func TestStartupChild(t *testing.T) { |
| + if os.Getenv("HOME_STARTUP_FIXTURE") != "1" { |
| + return |
| + } |
| + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) |
| + defer cancel() |
| + c, raw, err := ReadOperator(ctx, os.Getenv("HOME_STARTUP_DATABASE")) |
| + closed := true |
| + for _, fd := range []uintptr{0, 3, 4} { |
| + if _, e := unix.FcntlInt(fd, unix.F_GETFD, 0); !errors.Is(e, unix.EBADF) { |
| + closed = false |
| + } |
| + } |
| + if err != nil && (err != ErrRefused || len(raw) != 0 || c.Version != 0) { |
| + os.Exit(3) |
| + } |
| + json.NewEncoder(os.Stdout).Encode(map[string]any{"accepted": err == nil, "closed": closed, "input_bytes": len(raw)}) |
| + clear(raw) |
| + os.Exit(0) |
| +} |
| + |
| +func TestStartupTransportInSubprocess(t *testing.T) { |
| + image, e := os.ReadFile("/proc/self/exe") |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + imageSum := sha256.Sum256(image) |
| + for _, mode := range []string{"valid", "context", "context-overflow", "input", "input-overflow", "selector", "artifact-hash", "artifact-identity", "artifact-flags", "expired", "checker", "stalled-context", "stalled-input"} { |
| + t.Run(mode, func(t *testing.T) { |
| + dir := t.TempDir() |
| + path := filepath.Join(dir, "image") |
| + if e := os.WriteFile(path, image, 0700); e != nil { |
| + t.Fatal(e) |
| + } |
| + database := filepath.Join(dir, "home.db") |
| + marker := []byte("untouched database sentinel") |
| + if e := os.WriteFile(database, marker, 0600); e != nil { |
| + t.Fatal(e) |
| + } |
| + input := []byte(`{"private":"` + strings.Repeat("x", 60000) + `"}`) |
| + inputSum := sha256.Sum256(input) |
| + id := base64.RawURLEncoding.EncodeToString(make([]byte, 32)) |
| + digest := strings.Repeat("a", 64) |
| + c := launchctx.LaunchContext{Version: 1, Account: "bds", App: "home", Host: "home.example", RequestID: id, AttemptID: id, RequestDigest: digest, RecordIdentity: digest, ArtifactDigest: hex.EncodeToString(imageSum[:]), EnvironmentEnvelopeDigest: digest, GuardGeneration: 1, InputSHA256: hex.EncodeToString(inputSum[:]), DatabasePath: database, DatabaseDevice: 1, DatabaseInode: 1, ExecutionDeadline: time.Now().Add(10 * time.Second).UTC().Format(time.RFC3339Nano)} |
| + selector := database |
| + artifactPath, artifactFlags := path, os.O_RDONLY |
| + switch mode { |
| + case "input": |
| + input[20] = 'y' |
| + case "input-overflow": |
| + input = bytes.Repeat([]byte("x"), inputLimit+1) |
| + case "selector": |
| + selector += ".other" |
| + case "artifact-hash": |
| + c.ArtifactDigest = digest |
| + case "artifact-identity", "artifact-flags": |
| + artifactPath = filepath.Join(dir, "other") |
| + if e := os.WriteFile(artifactPath, image, 0600); e != nil { |
| + t.Fatal(e) |
| + } |
| + if mode == "artifact-flags" { |
| + artifactFlags = os.O_RDWR |
| + } |
| + case "expired": |
| + c.ExecutionDeadline = time.Now().Add(-time.Second).UTC().Format(time.RFC3339Nano) |
| + } |
| + raw, e := launchctx.EncodeLaunch(c) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + switch mode { |
| + case "context": |
| + raw = []byte(`{"version":1}`) |
| + case "context-overflow": |
| + raw = bytes.Repeat([]byte(" "), launchctx.MaxLaunchBytes+1) |
| + case "checker": |
| + raw = bytes.Replace(raw, []byte(`"version":1`), []byte(`"version":2`), 1) |
| + } |
| + pin, e := os.OpenFile(artifactPath, artifactFlags, 0) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + defer pin.Close() |
| + inR, inW, e := os.Pipe() |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + defer inR.Close() |
| + defer inW.Close() |
| + contextR, contextW, e := os.Pipe() |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + defer contextR.Close() |
| + defer contextW.Close() |
| + ctx, cancel := context.WithTimeout(t.Context(), 8*time.Second) |
| + defer cancel() |
| + cmd := exec.CommandContext(ctx, path, "-test.run=^TestStartupChild$") |
| + cmd.Dir = "/" |
| + cmd.Env = []string{"HOME_STARTUP_FIXTURE=1", "HOME_STARTUP_DATABASE=" + selector, "GODEBUG=containermaxprocs=0,updatemaxprocs=0"} |
| + cmd.Stdin = inR |
| + cmd.ExtraFiles = []*os.File{contextR, pin} |
| + var output bytes.Buffer |
| + cmd.Stdout = &output |
| + if e := cmd.Start(); e != nil { |
| + t.Fatal(e) |
| + } |
| + inR.Close() |
| + contextR.Close() |
| + writes := make(chan struct{}, 2) |
| + for _, item := range []struct { |
| + fd *os.File |
| + raw []byte |
| + stall bool |
| + }{{inW, input, mode == "stalled-input"}, {contextW, raw, mode == "stalled-context"}} { |
| + go func() { |
| + if !item.stall { |
| + io.Copy(item.fd, bytes.NewReader(item.raw)) |
| + item.fd.Close() |
| + } |
| + writes <- struct{}{} |
| + }() |
| + } |
| + if e := cmd.Wait(); e != nil { |
| + t.Fatal("startup child failed", e) |
| + } |
| + <-writes |
| + <-writes |
| + var result struct { |
| + Accepted bool `json:"accepted"` |
| + Closed bool `json:"closed"` |
| + InputBytes int `json:"input_bytes"` |
| + } |
| + if json.Unmarshal(output.Bytes(), &result) != nil || !result.Closed || result.Accepted != (mode == "valid") { |
| + t.Fatal("wrong startup result", output.String()) |
| + } |
| + if mode == "valid" && result.InputBytes != len(input) { |
| + t.Fatal("private input changed") |
| + } |
| + unchanged, e := os.ReadFile(database) |
| + if e != nil || !bytes.Equal(unchanged, marker) { |
| + t.Fatal("startup touched database") |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/operatorlaunch/read_other.go b/internal/operatorlaunch/read_other.go| new file mode 100644 |
| index 0000000..ff10047 |
| --- /dev/null |
| +++ b/internal/operatorlaunch/read_other.go |
| @@ -0,0 +1,13 @@ |
| +//go:build !linux |
| + |
| +package operatorlaunch |
| + |
| +import ( |
| + "context" |
| + |
| + "amadan.net/carlos/platform.git/launchctx" |
| +) |
| + |
| +func ReadOperator(context.Context, string) (launchctx.LaunchContext, []byte, error) { |
| + return launchctx.LaunchContext{}, nil, ErrRefused |
| +} |