| 1 | //go:build linux |
| 2 | |
| 3 | package provisioner |
| 4 | |
| 5 | import ( |
| 6 | "context" |
| 7 | "io" |
| 8 | "net/mail" |
| 9 | "os" |
| 10 | "strconv" |
| 11 | "time" |
| 12 | |
| 13 | "amadan.net/oficina/home/internal/operator" |
| 14 | "github.com/go-json-experiment/json" |
| 15 | "github.com/go-json-experiment/json/jsontext" |
| 16 | "golang.org/x/sys/unix" |
| 17 | ) |
| 18 | |
| 19 | type OperationPlan struct { |
| 20 | Version int `json:"version"` |
| 21 | Installation string `json:"installation"` |
| 22 | Role string `json:"role"` |
| 23 | Operation string `json:"operation_id"` |
| 24 | Action string `json:"action"` |
| 25 | Input jsontext.Value `json:"input"` |
| 26 | } |
| 27 | type PreparationInfo struct { |
| 28 | Installation string `json:"installation"` |
| 29 | Operation string `json:"operation_id"` |
| 30 | Role string `json:"role"` |
| 31 | Sequence int64 `json:"sequence"` |
| 32 | State string `json:"state"` |
| 33 | } |
| 34 | |
| 35 | // privateRequestOutput accepts an explicitly inherited anonymous write pipe. |
| 36 | // Regular files, sockets, terminals and aliases of standard/input descriptors |
| 37 | // are refused before any credential is unlocked or request is prepared. |
| 38 | type requestOutput struct{ file *os.File } |
| 39 | |
| 40 | func (o *requestOutput) Close() error { return o.file.Close() } |
| 41 | func privateRequestOutput(fd int, excluded ...int) (*requestOutput, error) { |
| 42 | if fd < 3 { |
| 43 | return nil, ErrRefused |
| 44 | } |
| 45 | var s unix.Stat_t |
| 46 | if unix.Fstat(fd, &s) != nil || s.Mode&unix.S_IFMT != unix.S_IFIFO { |
| 47 | return nil, ErrRefused |
| 48 | } |
| 49 | flags, e := unix.FcntlInt(uintptr(fd), unix.F_GETFL, 0) |
| 50 | if e != nil || flags&unix.O_ACCMODE != unix.O_WRONLY { |
| 51 | return nil, ErrRefused |
| 52 | } |
| 53 | name, e := os.Readlink("/proc/self/fd/" + strconv.Itoa(fd)) |
| 54 | if e != nil || name != "pipe:["+strconv.FormatUint(s.Ino, 10)+"]" { |
| 55 | return nil, ErrRefused |
| 56 | } |
| 57 | for _, other := range append([]int{0, 1, 2}, excluded...) { |
| 58 | if other < 0 { |
| 59 | continue |
| 60 | } |
| 61 | var x unix.Stat_t |
| 62 | if unix.Fstat(other, &x) != nil || (x.Dev == s.Dev && x.Ino == s.Ino) { |
| 63 | return nil, ErrRefused |
| 64 | } |
| 65 | } |
| 66 | // Pollable nonblocking ownership lets Close interrupt a blocked Write. |
| 67 | // This descriptor is explicitly transferred to the native command. |
| 68 | if unix.SetNonblock(fd, true) != nil { |
| 69 | return nil, ErrRefused |
| 70 | } |
| 71 | file := os.NewFile(uintptr(fd), "private-request") |
| 72 | if file == nil { |
| 73 | return nil, ErrRefused |
| 74 | } |
| 75 | return &requestOutput{file}, nil |
| 76 | } |
| 77 | func decodeOperationPlan(raw []byte) (OperationPlan, any, error) { |
| 78 | var p OperationPlan |
| 79 | 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 { |
| 80 | return p, nil, ErrRefused |
| 81 | } |
| 82 | var input any |
| 83 | var base *operator.InputBase |
| 84 | switch p.Action { |
| 85 | case "directory.initialize": |
| 86 | in := new(operator.InitializeInput) |
| 87 | input = in |
| 88 | base = &in.InputBase |
| 89 | case "credential.owner-seed": |
| 90 | in := new(operator.OwnerInput) |
| 91 | input = in |
| 92 | base = &in.InputBase |
| 93 | case "directory.seed-team": |
| 94 | in := new(operator.SeedInput) |
| 95 | input = in |
| 96 | base = &in.InputBase |
| 97 | case "directory.provision-consumer": |
| 98 | in := new(operator.ProvisionInput) |
| 99 | input = in |
| 100 | base = &in.InputBase |
| 101 | default: |
| 102 | // Verification and activation await checked record/evidence collectors. |
| 103 | return p, nil, ErrRefused |
| 104 | } |
| 105 | role := "directory" |
| 106 | if p.Action == "credential.owner-seed" { |
| 107 | role = "credential" |
| 108 | } |
| 109 | if p.Role != role || json.Unmarshal(p.Input, input, json.RejectUnknownMembers(true)) != nil || base.Version != 1 || base.Operation != p.Action || base.OperationID != p.Operation { |
| 110 | return p, nil, ErrRefused |
| 111 | } |
| 112 | return p, input, nil |
| 113 | } |
| 114 | func preparationScope(s *installationState, input any) bool { |
| 115 | if s == nil { |
| 116 | return false |
| 117 | } |
| 118 | switch in := input.(type) { |
| 119 | case *operator.InitializeInput: |
| 120 | return in.ExpectedGeneration == nil && operator.CanonicalToken(in.Generation) && in.Baseline == "operator-attests-never-provisioned" |
| 121 | case *operator.OwnerInput: |
| 122 | sqid, e := operator.TeamSQID(in.TeamID) |
| 123 | address, parseErr := mail.ParseAddress(in.Address) |
| 124 | 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 |
| 125 | case *operator.SeedInput: |
| 126 | return in.Generation == s.Generation && s.Generation != "" |
| 127 | case *operator.ProvisionInput: |
| 128 | return in.Generation == s.Generation && s.Generation != "" |
| 129 | } |
| 130 | return false |
| 131 | } |
| 132 | func prepareRequest(ctx context.Context, statePath, floorPath string, password, plan []byte, output *requestOutput) (PreparationInfo, error) { |
| 133 | p, input, e := decodeOperationPlan(plan) |
| 134 | if e != nil || output == nil { |
| 135 | return PreparationInfo{}, ErrRefused |
| 136 | } |
| 137 | j, e := existingJournal(statePath, floorPath, password) |
| 138 | if e != nil { |
| 139 | return PreparationInfo{}, e |
| 140 | } |
| 141 | defer closeExisting(j) |
| 142 | j.mu.Lock() |
| 143 | b, e := j.bookLocked() |
| 144 | j.mu.Unlock() |
| 145 | if e != nil { |
| 146 | return PreparationInfo{}, e |
| 147 | } |
| 148 | s := b.installations[p.Installation] |
| 149 | if !preparationScope(s, input) { |
| 150 | return PreparationInfo{}, ErrRefused |
| 151 | } |
| 152 | raw, e := j.prepare(ctx, p.Installation, p.Operation, p.Action, input) |
| 153 | if e != nil { |
| 154 | return PreparationInfo{}, e |
| 155 | } |
| 156 | defer clear(raw) |
| 157 | req, e := j.request(s, raw, p.Action) |
| 158 | if e != nil { |
| 159 | return PreparationInfo{}, e |
| 160 | } |
| 161 | // The record and independent floor are durable before output. Failure cannot |
| 162 | // release its role slot or cause the next call to sign a replacement. |
| 163 | if e = output.write(ctx, raw); e != nil { |
| 164 | return PreparationInfo{}, e |
| 165 | } |
| 166 | return PreparationInfo{p.Installation, p.Operation, p.Role, req.Authorization().Sequence, "local-request"}, nil |
| 167 | } |
| 168 | func writePrivateRequest(output io.Writer, raw []byte) error { |
| 169 | if output == nil || len(raw) == 0 || len(raw) > operator.BodyLimit { |
| 170 | return ErrRefused |
| 171 | } |
| 172 | n, e := output.Write(raw) |
| 173 | if e != nil || n != len(raw) { |
| 174 | return ErrUnavailable |
| 175 | } |
| 176 | return nil |
| 177 | } |
| 178 | |
| 179 | // write keeps a stalled recipient from retaining the journal's process lock. |
| 180 | // Closing a pollable pipe interrupts its wait without discarding the saved |
| 181 | // request. The recipient may have a prefix, so a retry still uses exact bytes. |
| 182 | func (o *requestOutput) write(ctx context.Context, raw []byte) error { |
| 183 | if o == nil || o.file == nil || len(raw) == 0 || len(raw) > operator.BodyLimit { |
| 184 | return ErrRefused |
| 185 | } |
| 186 | bounded, cancel := context.WithTimeout(ctx, 30*time.Second) |
| 187 | defer cancel() |
| 188 | stop := context.AfterFunc(bounded, func() { o.Close() }) |
| 189 | defer stop() |
| 190 | e := writePrivateRequest(o.file, raw) |
| 191 | if bounded.Err() != nil { |
| 192 | return ErrUnavailable |
| 193 | } |
| 194 | return e |
| 195 | } |
| 196 | |