oficina / home Public

Prepare exact Home requests through private native output

Paul Campbell pushed by paul@keymail.dev caa16b588c36cbd9c11bac237464d2f18b6bfe8c parent 382858f
3 files changed, +516 −4
  • internal/provisioner/cli_linux.go +21 −4
  • internal/provisioner/prepare_linux.go +171 −0
  • internal/provisioner/prepare_linux_test.go +324 −0

CI passed — run details

diff --git a/internal/provisioner/cli_linux.go b/internal/provisioner/cli_linux.go
index 915d9f9..bf24170 100644
--- a/internal/provisioner/cli_linux.go
+++ b/internal/provisioner/cli_linux.go
@@ -3,6 +3,7 @@
package provisioner
import (
+ "context"
"flag"
"io"
"os"
@@ -11,7 +12,7 @@ import (
)
func Run(args []string, out io.Writer) error {
- if len(args) < 2 || args[0] != "provision" || (args[1] != "init" && args[1] != "backup" && args[1] != "restore" && args[1] != "enroll") || out == nil {
+ if len(args) < 2 || args[0] != "provision" || (args[1] != "init" && args[1] != "backup" && args[1] != "restore" && args[1] != "enroll" && args[1] != "prepare") || out == nil {
return ErrRefused
}
flags := flag.NewFlagSet("oficina provision init", flag.ContinueOnError)
@@ -22,16 +23,28 @@ func Run(args []string, out io.Writer) error {
input := flags.String("input", "", "")
passwordFD := flags.Int("password-fd", -1, "")
planFD := flags.Int("plan-fd", -1, "")
+ requestFD := flags.Int("request-fd", -1, "")
if flags.Parse(args[2:]) != nil || flags.NArg() != 0 || *state == "" || *floor == "" {
return ErrRefused
}
- if args[1] == "enroll" {
+ if args[1] == "enroll" || args[1] == "prepare" {
if *planFD < 3 || *planFD == *passwordFD || *input != "" || *output != "" {
return ErrRefused
}
} else if *planFD != -1 {
return ErrRefused
}
+ var privateOutput *requestOutput
+ if args[1] == "prepare" {
+ var err error
+ privateOutput, err = privateRequestOutput(*requestFD, *planFD, *passwordFD)
+ if err != nil {
+ return err
+ }
+ defer privateOutput.Close()
+ } else if *requestFD != -1 {
+ return ErrRefused
+ }
password, e := privatePassword(*passwordFD)
if e != nil {
return e
@@ -39,7 +52,7 @@ func Run(args []string, out io.Writer) error {
defer clear(password)
var info any
switch args[1] {
- case "enroll":
+ case "enroll", "prepare":
file := os.NewFile(uintptr(*planFD), "private-plan")
if file == nil {
return ErrRefused
@@ -49,7 +62,11 @@ func Run(args []string, out io.Writer) error {
if err != nil || len(raw) > 64<<10 {
return ErrRefused
}
- info, e = EnrollPlan(*state, *floor, password, raw)
+ if args[1] == "enroll" {
+ info, e = EnrollPlan(*state, *floor, password, raw)
+ } else {
+ info, e = prepareRequest(context.Background(), *state, *floor, password, raw, privateOutput)
+ }
clear(raw)
case "init":
if *output != "" || *input != "" {
diff --git a/internal/provisioner/prepare_linux.go b/internal/provisioner/prepare_linux.go
new file mode 100644
index 0000000..1e45a93
--- /dev/null
+++ b/internal/provisioner/prepare_linux.go
@@ -0,0 +1,171 @@
+//go:build linux
+
+package provisioner
+
+import (
+ "context"
+ "io"
+ "net/mail"
+ "os"
+ "strconv"
+
+ "amadan.net/oficina/home/internal/operator"
+ "github.com/go-json-experiment/json"
+ "github.com/go-json-experiment/json/jsontext"
+ "golang.org/x/sys/unix"
+)
+
+type OperationPlan struct {
+ Version int `json:"version"`
+ Installation string `json:"installation"`
+ Role string `json:"role"`
+ Operation string `json:"operation_id"`
+ Action string `json:"action"`
+ Input jsontext.Value `json:"input"`
+}
+type PreparationInfo struct {
+ Installation string `json:"installation"`
+ Operation string `json:"operation_id"`
+ Role string `json:"role"`
+ Sequence int64 `json:"sequence"`
+ State string `json:"state"`
+}
+
+// privateRequestOutput accepts an explicitly inherited anonymous write pipe.
+// Regular files, sockets, terminals and aliases of standard/input descriptors
+// are refused before any credential is unlocked or request is prepared.
+type requestOutput struct{ file *os.File }
+
+func (o *requestOutput) Close() error { return o.file.Close() }
+func privateRequestOutput(fd int, excluded ...int) (*requestOutput, error) {
+ if fd < 3 {
+ return nil, ErrRefused
+ }
+ var s unix.Stat_t
+ if unix.Fstat(fd, &s) != nil || s.Mode&unix.S_IFMT != unix.S_IFIFO {
+ return nil, ErrRefused
+ }
+ flags, e := unix.FcntlInt(uintptr(fd), unix.F_GETFL, 0)
+ if e != nil || flags&unix.O_ACCMODE != unix.O_WRONLY {
+ return nil, ErrRefused
+ }
+ name, e := os.Readlink("/proc/self/fd/" + strconv.Itoa(fd))
+ if e != nil || name != "pipe:["+strconv.FormatUint(s.Ino, 10)+"]" {
+ return nil, ErrRefused
+ }
+ for _, other := range append([]int{0, 1, 2}, excluded...) {
+ if other < 0 {
+ continue
+ }
+ var x unix.Stat_t
+ if unix.Fstat(other, &x) != nil || (x.Dev == s.Dev && x.Ino == s.Ino) {
+ return nil, ErrRefused
+ }
+ }
+ file := os.NewFile(uintptr(fd), "private-request")
+ if file == nil {
+ return nil, ErrRefused
+ }
+ return &requestOutput{file}, nil
+}
+func decodeOperationPlan(raw []byte) (OperationPlan, any, error) {
+ var p OperationPlan
+ if len(raw) == 0 || len(raw) > operator.BodyLimit || json.Unmarshal(raw, &p, json.RejectUnknownMembers(true)) != nil || p.Version != 1 || !operator.CanonicalToken(p.Installation) || !operator.CanonicalToken(p.Operation) || len(p.Input) == 0 || len(p.Input) > operator.InputLimit {
+ return p, nil, ErrRefused
+ }
+ var input any
+ var base *operator.InputBase
+ switch p.Action {
+ case "directory.initialize":
+ in := new(operator.InitializeInput)
+ input = in
+ base = &in.InputBase
+ case "credential.owner-seed":
+ in := new(operator.OwnerInput)
+ input = in
+ base = &in.InputBase
+ case "directory.seed-team":
+ in := new(operator.SeedInput)
+ input = in
+ base = &in.InputBase
+ case "directory.provision-consumer":
+ in := new(operator.ProvisionInput)
+ input = in
+ base = &in.InputBase
+ default:
+ // Verification and activation await checked record/evidence collectors.
+ return p, nil, ErrRefused
+ }
+ role := "directory"
+ if p.Action == "credential.owner-seed" {
+ role = "credential"
+ }
+ if p.Role != role || json.Unmarshal(p.Input, input, json.RejectUnknownMembers(true)) != nil || base.Version != 1 || base.Operation != p.Action || base.OperationID != p.Operation {
+ return p, nil, ErrRefused
+ }
+ return p, input, nil
+}
+func preparationScope(s *installationState, input any) bool {
+ if s == nil {
+ return false
+ }
+ switch in := input.(type) {
+ case *operator.InitializeInput:
+ return in.ExpectedGeneration == nil && operator.CanonicalToken(in.Generation) && in.Baseline == "operator-attests-never-provisioned"
+ case *operator.OwnerInput:
+ sqid, e := operator.TeamSQID(in.TeamID)
+ address, parseErr := mail.ParseAddress(in.Address)
+ return e == nil && parseErr == nil && address.Address == in.Address && address.Name == "" && in.ExistingOnly && in.TeamSQID == sqid && in.Directory == s.Plan.Directory.Origin && len(in.Address) > 0 && len(in.Address) <= 320
+ case *operator.SeedInput:
+ return in.Generation == s.Generation && s.Generation != ""
+ case *operator.ProvisionInput:
+ return in.Generation == s.Generation && s.Generation != ""
+ }
+ return false
+}
+func prepareRequest(ctx context.Context, statePath, floorPath string, password, plan []byte, output *requestOutput) (PreparationInfo, error) {
+ p, input, e := decodeOperationPlan(plan)
+ if e != nil || output == nil {
+ return PreparationInfo{}, ErrRefused
+ }
+ j, e := existingJournal(statePath, floorPath, password)
+ if e != nil {
+ return PreparationInfo{}, e
+ }
+ defer closeExisting(j)
+ j.mu.Lock()
+ b, e := j.bookLocked()
+ j.mu.Unlock()
+ if e != nil {
+ return PreparationInfo{}, e
+ }
+ s := b.installations[p.Installation]
+ if !preparationScope(s, input) {
+ return PreparationInfo{}, ErrRefused
+ }
+ raw, e := j.prepare(ctx, p.Installation, p.Operation, p.Action, input)
+ if e != nil {
+ return PreparationInfo{}, e
+ }
+ defer clear(raw)
+ req, e := j.request(s, raw, p.Action)
+ if e != nil {
+ return PreparationInfo{}, e
+ }
+ // The record and independent floor are durable before output. Failure cannot
+ // release its role slot or cause the next call to sign a replacement.
+ if e = writePrivateRequest(output.file, raw); e != nil {
+ return PreparationInfo{}, e
+ }
+ return PreparationInfo{p.Installation, p.Operation, p.Role, req.Authorization().Sequence, "local-request"}, nil
+}
+func writePrivateRequest(output io.Writer, raw []byte) error {
+ if output == nil || len(raw) == 0 || len(raw) > operator.BodyLimit {
+ return ErrRefused
+ }
+ n, e := output.Write(raw)
+ if e != nil || n != len(raw) {
+ return ErrUnavailable
+ }
+ return nil
+}
diff --git a/internal/provisioner/prepare_linux_test.go b/internal/provisioner/prepare_linux_test.go
new file mode 100644
index 0000000..06ca17f
--- /dev/null
+++ b/internal/provisioner/prepare_linux_test.go
@@ -0,0 +1,324 @@
+//go:build linux
+
+package provisioner
+
+import (
+ "bytes"
+ "io"
+ "os"
+ "os/exec"
+ "strconv"
+ "testing"
+
+ "amadan.net/oficina/home/internal/operator"
+ "github.com/go-json-experiment/json"
+ "github.com/go-json-experiment/json/jsontext"
+ "golang.org/x/sys/unix"
+)
+
+func TestPrivateRequestDescriptorsSubprocess(t *testing.T) {
+ if mode := os.Getenv("OFICINA_DESCRIPTOR_CHILD"); mode != "" {
+ var output *requestOutput
+ var e error
+ if mode == "input-alias" {
+ output, e = privateRequestOutput(3, 4)
+ } else {
+ output, e = privateRequestOutput(3)
+ }
+ if mode != "valid" {
+ if output != nil {
+ output.Close()
+ }
+ if e != ErrRefused {
+ os.Exit(2)
+ }
+ os.Exit(0)
+ }
+ if e != nil {
+ os.Exit(3)
+ }
+ if e = writePrivateRequest(output.file, []byte("synthetic-private-output")); e != nil {
+ os.Exit(4)
+ }
+ output.Close()
+ os.Exit(0)
+ }
+ for _, mode := range []string{"valid", "stdout-alias", "stderr-alias", "stdin-alias", "input-alias", "read-only", "regular", "socket", "named-pipe"} {
+ t.Run(mode, func(t *testing.T) {
+ reader, writer, e := os.Pipe()
+ if e != nil {
+ t.Fatal(e)
+ }
+ defer reader.Close()
+ defer writer.Close()
+ inherited := writer
+ var extra *os.File
+ switch mode {
+ case "read-only":
+ inherited = reader
+ case "regular":
+ extra, e = os.CreateTemp(t.TempDir(), "private")
+ if e != nil {
+ t.Fatal(e)
+ }
+ defer extra.Close()
+ inherited = extra
+ case "socket":
+ pair, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ extra = os.NewFile(uintptr(pair[0]), "socket")
+ defer extra.Close()
+ defer unix.Close(pair[1])
+ inherited = extra
+ case "named-pipe":
+ path := t.TempDir() + "/pipe"
+ if e = unix.Mkfifo(path, 0600); e != nil {
+ t.Fatal(e)
+ }
+ holder, err := unix.Open(path, unix.O_RDWR|unix.O_NONBLOCK, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer unix.Close(holder)
+ fd, err := unix.Open(path, unix.O_WRONLY|unix.O_NONBLOCK, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ extra = os.NewFile(uintptr(fd), path)
+ defer extra.Close()
+ inherited = extra
+ }
+ cmd := exec.Command(os.Args[0], "-test.run=^TestPrivateRequestDescriptorsSubprocess$")
+ cmd.Env = append(os.Environ(), "OFICINA_DESCRIPTOR_CHILD="+mode)
+ cmd.ExtraFiles = []*os.File{inherited}
+ if mode == "input-alias" {
+ cmd.ExtraFiles = append(cmd.ExtraFiles, reader)
+ }
+ var public bytes.Buffer
+ cmd.Stdout = &public
+ cmd.Stderr = &public
+ if mode == "stdout-alias" {
+ cmd.Stdout = writer
+ }
+ if mode == "stderr-alias" {
+ cmd.Stderr = writer
+ }
+ if mode == "stdin-alias" {
+ cmd.Stdin = reader
+ }
+ if e = cmd.Start(); e != nil {
+ t.Fatal(e)
+ }
+ writer.Close()
+ private, e := io.ReadAll(reader)
+ if e != nil {
+ t.Fatal(e)
+ }
+ if e = cmd.Wait(); e != nil {
+ t.Fatalf("descriptor %s: %v %s", mode, e, public.Bytes())
+ }
+ if public.Len() != 0 {
+ t.Fatal("private output reached standard streams")
+ }
+ if mode == "valid" && !bytes.Equal(private, []byte("synthetic-private-output")) {
+ t.Fatal("private pipe output")
+ }
+ if mode != "valid" && len(private) != 0 {
+ t.Fatal("refused output wrote bytes")
+ }
+ })
+ }
+}
+func ownerOperationPlan() OperationPlan {
+ input := operator.OwnerInput{InputBase: operator.InputBase{Version: 1, Operation: "credential.owner-seed", OperationID: testToken(23)}, Directory: planFixture().Directory.Origin, TeamID: 1, TeamSQID: "Uk", Address: "native-prepare-private-83@example.invalid", ExistingOnly: true}
+ raw, _ := json.Marshal(input, json.Deterministic(true))
+ return OperationPlan{1, testToken(20), "credential", testToken(23), "credential.owner-seed", raw}
+}
+func TestNativePreparationPrivateReplayAndFailedOutput(t *testing.T) {
+ if os.Getenv("OFICINA_PREPARE_CHILD") == "1" {
+ args := []string{"provision", "prepare", "--state", os.Getenv("OFICINA_STATE_PATH"), "--floor-dir", os.Getenv("OFICINA_FLOOR_PATH"), "--password-fd", "3", "--plan-fd", "4", "--request-fd", "5"}
+ if e := Run(args, os.Stdout); e != nil {
+ os.Exit(3)
+ }
+ os.Exit(0)
+ }
+ state, floor := persistentDirectory(t), persistentDirectory(t)
+ password := []byte("synthetic-prepare-private-password")
+ if _, e := Initialize(state, floor, password); e != nil {
+ t.Fatal(e)
+ }
+ enrollment, _ := canonicalJSON(planFixture())
+ if _, e := EnrollPlan(state, floor, password, enrollment); e != nil {
+ t.Fatal(e)
+ }
+ p := ownerOperationPlan()
+ plan, _ := json.Marshal(p, json.Deterministic(true))
+ run := func(raw []byte, broken bool) ([]byte, []byte, error) {
+ pr, pw, e := os.Pipe()
+ if e != nil {
+ t.Fatal(e)
+ }
+ defer pr.Close()
+ ir, iw, e := os.Pipe()
+ if e != nil {
+ t.Fatal(e)
+ }
+ defer ir.Close()
+ rr, rw, e := os.Pipe()
+ if e != nil {
+ t.Fatal(e)
+ }
+ defer rr.Close()
+ if broken {
+ rr.Close()
+ }
+ cmd := exec.Command(os.Args[0], "-test.run=^TestNativePreparationPrivateReplayAndFailedOutput$")
+ cmd.Env = append(os.Environ(), "OFICINA_PREPARE_CHILD=1", "OFICINA_STATE_PATH="+state, "OFICINA_FLOOR_PATH="+floor)
+ cmd.ExtraFiles = []*os.File{pr, ir, rw}
+ var public, stderr bytes.Buffer
+ cmd.Stdout = &public
+ cmd.Stderr = &stderr
+ if e = cmd.Start(); e != nil {
+ t.Fatal(e)
+ }
+ pr.Close()
+ ir.Close()
+ rw.Close()
+ pw.Write(password)
+ pw.Close()
+ iw.Write(raw)
+ iw.Close()
+ var request []byte
+ if !broken {
+ request, e = io.ReadAll(rr)
+ if e != nil {
+ t.Fatal(e)
+ }
+ }
+ e = cmd.Wait()
+ for _, stream := range [][]byte{public.Bytes(), stderr.Bytes()} {
+ if bytes.Contains(stream, []byte("native-prepare-private-83@example.invalid")) || bytes.Contains(stream, password) || bytes.Contains(stream, []byte("signed_authorization")) {
+ t.Fatal("private request leaked")
+ }
+ }
+ return request, public.Bytes(), e
+ }
+ if _, public, e := run(plan, true); e == nil || len(public) != 0 {
+ t.Fatal("failed private output reported success", e)
+ }
+ j, e := existingJournal(state, floor, password)
+ if e != nil {
+ t.Fatal(e)
+ }
+ before := snapshot(t, j).installations[testToken(20)]
+ saved := append([]byte(nil), before.Operations[testToken(23)].Raw...)
+ if before.Operations[testToken(23)].Status != "prepared" || before.Heads["credential"].Sequence != 0 {
+ t.Fatal("output failure advanced state")
+ }
+ closeExisting(j)
+ request, public, e := run(plan, false)
+ if e != nil || !bytes.Equal(request, saved) {
+ t.Fatal("exact failed-output resume", e)
+ }
+ var info PreparationInfo
+ if json.Unmarshal(public, &info, json.RejectUnknownMembers(true)) != nil || info.State != "local-request" || info.Sequence != 1 {
+ t.Fatal("public preparation status")
+ }
+ again, _, e := run(plan, false)
+ if e != nil || !bytes.Equal(again, request) {
+ t.Fatal("cross-process replay changed bytes", e)
+ }
+ padded := append(append([]byte(nil), plan...), bytes.Repeat([]byte(" "), operator.BodyLimit-len(plan))...)
+ if len(padded) != operator.BodyLimit {
+ t.Fatal("test boundary")
+ }
+ exact, _, e := run(padded, false)
+ if e != nil || !bytes.Equal(exact, request) {
+ t.Fatal("64KiB local plan boundary", e)
+ }
+ if _, public, e = run(append(padded, ' '), false); e == nil || len(public) != 0 {
+ t.Fatal("oversized plan accepted", e)
+ }
+ wrong := p
+ wrong.Role = "directory"
+ raw, _ := json.Marshal(wrong)
+ if _, public, e = run(raw, false); e == nil || len(public) != 0 {
+ t.Fatal("foreign role accepted")
+ }
+ wrong = p
+ wrong.Installation = testToken(99)
+ raw, _ = json.Marshal(wrong)
+ if _, public, e = run(raw, false); e == nil || len(public) != 0 {
+ t.Fatal("foreign installation accepted")
+ }
+ j, e = existingJournal(state, floor, password)
+ if e != nil {
+ t.Fatal(e)
+ }
+ defer closeExisting(j)
+ after := snapshot(t, j).installations[testToken(20)]
+ if len(after.Operations) != 1 || after.Heads["credential"].Sequence != 0 || after.Operations[testToken(23)].Status != "prepared" {
+ t.Fatal("local request implied execution")
+ }
+}
+
+type shortPrivateWriter struct{}
+
+func (shortPrivateWriter) Write(p []byte) (int, error) { return len(p) - 1, nil }
+func TestPrivatePreparationBoundsAndCollectorRefusal(t *testing.T) {
+ for _, size := range []int{0, 1, operator.BodyLimit, operator.BodyLimit + 1} {
+ raw := bytes.Repeat([]byte("x"), size)
+ var out bytes.Buffer
+ e := writePrivateRequest(&out, raw)
+ if size == 0 || size > operator.BodyLimit {
+ if e != ErrRefused || out.Len() != 0 {
+ t.Fatal(size, e)
+ }
+ } else if e != nil || !bytes.Equal(raw, out.Bytes()) {
+ t.Fatal(size, e)
+ }
+ }
+ if e := writePrivateRequest(shortPrivateWriter{}, []byte("private")); e != ErrUnavailable {
+ t.Fatal("short output")
+ }
+ for _, action := range []string{"directory.verify-consumer", "directory.activate-consumer"} {
+ p := ownerOperationPlan()
+ p.Role = "directory"
+ p.Action = action
+ p.Input = jsontext.Value(`{}`)
+ raw, _ := json.Marshal(p)
+ if _, _, e := decodeOperationPlan(raw); e != ErrRefused {
+ t.Fatal("missing collector signed", action)
+ }
+ }
+ for _, fd := range []int{-1, 0, 1, 2, 99999} {
+ if out, e := privateRequestOutput(fd); out != nil || e != ErrRefused {
+ t.Fatal("unsafe descriptor", strconv.Itoa(fd), e)
+ }
+ }
+ p := ownerOperationPlan()
+ input := new(operator.OwnerInput)
+ if json.Unmarshal(p.Input, input) != nil {
+ t.Fatal("fixture")
+ }
+ input.ExistingOnly = false
+ if preparationScope(&installationState{Plan: planFixture()}, input) {
+ t.Fatal("unbacked person creation preparation")
+ }
+}
+
+func TestPreparationClosedPlanRefusesUnknownAndDuplicateFields(t *testing.T) {
+ p := ownerOperationPlan()
+ raw, _ := json.Marshal(p, json.Deterministic(true))
+ for _, bad := range [][]byte{
+ append([]byte{}, append(raw, raw...)...),
+ bytes.Replace(raw, []byte(`"version":1`), []byte(`"version":1,"version":1`), 1),
+ bytes.Replace(raw, []byte(`"version":1`), []byte(`"version":1,"unknown":"private@example.invalid"`), 1),
+ } {
+ if _, _, e := decodeOperationPlan(bad); e != ErrRefused {
+ t.Fatal("non-closed plan", e)
+ }
+ }
+}