oficina / home Public

Verify private Home operator startup before database access

Paul Campbell pushed by paul@keymail.dev 6143a83feca3453a19fa4c26fde80befe45b526c parent c5cf9bd
8 files changed, +363 −2
  • Makefile +2 −2
  • go.mod +1 −0
  • go.sum +2 −0
  • implementation/operator-startup/proposal.json +16 −0
  • internal/operatorlaunch/errors.go +7 −0
  • internal/operatorlaunch/read_linux.go +152 −0
  • internal/operatorlaunch/read_linux_test.go +170 −0
  • internal/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
+}