//go:build linux package provisioner import ( "context" "io" "net/mail" "os" "strconv" "time" "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 } } // Pollable nonblocking ownership lets Close interrupt a blocked Write. // This descriptor is explicitly transferred to the native command. if unix.SetNonblock(fd, true) != nil { 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 = output.write(ctx, 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 } // write keeps a stalled recipient from retaining the journal's process lock. // Closing a pollable pipe interrupts its wait without discarding the saved // request. The recipient may have a prefix, so a retry still uses exact bytes. func (o *requestOutput) write(ctx context.Context, raw []byte) error { if o == nil || o.file == nil || len(raw) == 0 || len(raw) > operator.BodyLimit { return ErrRefused } bounded, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() stop := context.AfterFunc(bounded, func() { o.Close() }) defer stop() e := writePrivateRequest(o.file, raw) if bounded.Err() != nil { return ErrUnavailable } return e }