oficina / home Public

Clone
git clone https://amadan.net/oficina/home

Plain git — no account needed to clone.

Download

Download this file

1//go:build linux
2
3package provisioner
4
5import (
6 "bytes"
7 "context"
8 "encoding/base64"
9 "strings"
10
11 "amadan.net/oficina/home/internal/operator"
12 family "amadan.net/rastrillo/rastrillo/crypto"
13 "github.com/go-json-experiment/json"
14)
15
16// InstallationPlan is local preparation. Persisting it does not enroll a Home
17// role, assert fresh owner approval, or permit a remote command.
18type InstallationPlan struct {
19 Version int `json:"version"`
20 Installation string `json:"installation"`
21 BaselineOperation string `json:"baseline_operation"`
22 Credential InstallationTarget `json:"credential"`
23 Directory InstallationTarget `json:"directory"`
24}
25type InstallationTarget struct {
26 Console string `json:"console"`
27 Account string `json:"account"`
28 App string `json:"app"`
29 Host string `json:"host"`
30 Origin string `json:"origin"`
31 Release string `json:"release"`
32 Artifact string `json:"artifact_digest"`
33 Environment string `json:"environment_digest"`
34 DataKey string `json:"data_key_fingerprint"`
35}
36type acceptedHead struct {
37 Sequence int64
38 Head string
39}
40type installationState struct {
41 Plan InstallationPlan
42 Generation string
43 Heads map[string]acceptedHead
44 Pending map[string]string
45 Operations map[string]*preparedOperation
46}
47type preparedOperation struct {
48 Request operator.Request
49 Raw []byte
50 Status string
51 Result []byte
52}
53type installationBook struct {
54 heads map[string]string
55 installations map[string]*installationState
56 events map[string][]byte
57 records map[string]journalRecord
58 results map[string][]byte
59}
60type localTransition struct {
61 Operation string `json:"operation"`
62 Kind string `json:"kind"`
63 ResultRecord string `json:"result_record"`
64 ResultHead string `json:"result_head"`
65}
66
67func validIdentifier(s string) bool {
68 if len(s) == 0 || len(s) > 128 {
69 return false
70 }
71 for _, c := range s {
72 if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || strings.ContainsRune("_-/.", c)) {
73 return false
74 }
75 }
76 return !strings.Contains(s, "..")
77}
78func validHex(s string, n int) bool {
79 if len(s) != n {
80 return false
81 }
82 for _, c := range s {
83 if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f') {
84 return false
85 }
86 }
87 return true
88}
89func (p InstallationPlan) valid() bool {
90 if p.Version != 1 || !operator.CanonicalToken(p.Installation) || !operator.CanonicalToken(p.BaselineOperation) {
91 return false
92 }
93 for _, t := range []InstallationTarget{p.Credential, p.Directory} {
94 if !operator.Origin(t.Console) || !operator.Origin(t.Origin) || !operator.Origin("https://"+t.Host) || !validIdentifier(t.Account) || !validIdentifier(t.App) || !validHex(t.Release, 40) || !validHex(t.Artifact, 64) || !validHex(t.Environment, 64) || !validHex(t.DataKey, 64) {
95 return false
96 }
97 }
98 return p.Credential.Origin != p.Directory.Origin && !(p.Credential.Console == p.Directory.Console && p.Credential.Host == p.Directory.Host)
99}
100func sameTarget(a, b InstallationTarget) bool {
101 return a.Console == b.Console && a.Host == b.Host
102}
103func (s *installationState) target(role string) (InstallationTarget, bool) {
104 switch role {
105 case "credential":
106 return s.Plan.Credential, true
107 case "directory":
108 return s.Plan.Directory, true
109 }
110 return InstallationTarget{}, false
111}
112func (j *journal) request(s *installationState, raw []byte, action string) (operator.Request, error) {
113 role := "directory"
114 if strings.HasPrefix(action, "credential.") {
115 role = "credential"
116 }
117 target, ok := s.target(role)
118 if !ok {
119 return operator.Request{}, ErrRefused
120 }
121 v, e := operator.NewVerifier(operator.Pins{Role: role, Origin: target.Origin, Installation: s.Plan.Installation, Registry: j.floor.identity.Registry, Device: j.floor.identity.Device, PublicKey: base64.RawURLEncoding.EncodeToString(j.key.SignPub()), Actions: []string{action}})
122 if e != nil {
123 return operator.Request{}, ErrRefused
124 }
125 r, e := v.Verify(raw)
126 if e != nil {
127 return operator.Request{}, ErrRefused
128 }
129 return r, nil
130}
131func (j *journal) bookLocked() (*installationBook, error) {
132 if len(j.locks) != 2 {
133 return nil, ErrRefused
134 }
135 if e := j.recover(); e != nil {
136 return nil, e
137 }
138 b := &installationBook{heads: map[string]string{}, installations: map[string]*installationState{}, events: map[string][]byte{}, records: map[string]journalRecord{}, results: map[string][]byte{}}
139 rows, e := j.db.Query("SELECT CASE WHEN length(signed_record)<=8192 THEN signed_record ELSE NULL END, CASE WHEN length(sealed_payload)<=98304 THEN sealed_payload ELSE NULL END FROM records ORDER BY sequence")
140 if e != nil {
141 return nil, ErrUnavailable
142 }
143 defer rows.Close()
144 for rows.Next() {
145 var raw, sealed []byte
146 if rows.Scan(&raw, &sealed) != nil {
147 return nil, ErrUnavailable
148 }
149 r, e := j.validate(raw, sealed)
150 if e != nil {
151 return nil, e
152 }
153 plain, e := family.Open(j.key, payloadContext+"/"+r.Operation, sealed)
154 if e != nil || len(plain) == 0 || len(plain) > 64<<10 {
155 return nil, ErrRefused
156 }
157 if e = j.replay(b, r, plain); e != nil {
158 return nil, e
159 }
160 b.events[r.Operation] = plain
161 b.records[r.Operation] = r
162 b.heads[r.Operation] = hash(raw)
163 }
164 if rows.Err() != nil {
165 return nil, ErrUnavailable
166 }
167 return b, nil
168}
169func (j *journal) replay(b *installationBook, r journalRecord, plain []byte) error {
170 s := b.installations[r.Installation]
171 if r.Action == "provisioner.enroll" {
172 var p InstallationPlan
173 if s != nil || r.Expected != "absent" || closedJSON(plain, &p) != nil || !p.valid() || p.Installation != r.Installation || p.BaselineOperation != r.Operation {
174 return ErrRefused
175 }
176 for _, old := range b.installations {
177 for _, a := range []InstallationTarget{old.Plan.Credential, old.Plan.Directory} {
178 if sameTarget(a, p.Credential) || sameTarget(a, p.Directory) {
179 return ErrRefused
180 }
181 }
182 }
183 b.installations[p.Installation] = &installationState{p, "", map[string]acceptedHead{}, map[string]string{}, map[string]*preparedOperation{}}
184 return nil
185 }
186 if s == nil {
187 return ErrRefused
188 }
189 if r.Expected == "prepared" && !strings.HasPrefix(r.Action, "provisioner.") {
190 req, e := j.request(s, plain, r.Action)
191 if e != nil {
192 return e
193 }
194 a := req.Authorization()
195 if a.Role == "directory" && ((s.Generation == "" && a.Action != "directory.initialize") || (s.Generation != "" && a.Action == "directory.initialize")) {
196 return ErrRefused
197 }
198 head := s.Heads[a.Role]
199 if a.OperationID != r.Operation || a.JournalSequence != r.Sequence || a.Sequence != head.Sequence+1 || a.Previous != head.Head || s.Pending[a.Role] != "" || s.Operations[a.OperationID] != nil || r.Created < a.Issued || r.Created >= a.Expires {
200 return ErrRefused
201 }
202 s.Operations[a.OperationID] = &preparedOperation{req, append([]byte(nil), plain...), "prepared", nil}
203 s.Pending[a.Role] = a.OperationID
204 return nil
205 }
206 if r.Action == "provisioner.result" {
207 if r.Expected != "in-flight" {
208 return ErrRefused
209 }
210 var out operator.Result
211 if decodeOperatorResult(plain, &out) != nil || out.Version != 1 || out.Outcome != "committed" {
212 return ErrRefused
213 }
214 pending := s.Operations[out.OperationID]
215 if pending == nil || (pending.Status != "in-flight" && pending.Status != "ambiguous") {
216 return ErrRefused
217 }
218 b.results[r.Operation] = append([]byte(nil), plain...)
219 return nil
220 }
221 var tr localTransition
222 if closedJSON(plain, &tr) != nil || !operator.CanonicalToken(tr.Operation) {
223 return ErrRefused
224 }
225 op := s.Operations[tr.Operation]
226 if op == nil {
227 return ErrRefused
228 }
229 a := op.Request.Authorization()
230 if r.Action == "provisioner.in-flight" {
231 if r.Expected != "prepared" || tr.Kind != "in-flight" || (tr.ResultRecord != "" || tr.ResultHead != "") || op.Status != "prepared" || r.Created >= a.Expires || r.Created < a.Issued {
232 return ErrRefused
233 }
234 op.Status = "in-flight"
235 return nil
236 }
237 if r.Action != "provisioner.outcome" || r.Expected != "in-flight" || (op.Status != "in-flight" && op.Status != "ambiguous") {
238 return ErrRefused
239 }
240 switch tr.Kind {
241 case "ambiguous":
242 if tr.ResultRecord != "" || tr.ResultHead != "" {
243 return ErrRefused
244 }
245 op.Status = "ambiguous"
246 case "refused":
247 if (tr.ResultRecord != "" || tr.ResultHead != "") || r.Created < a.Expires || op.Status == "ambiguous" {
248 return ErrRefused
249 }
250 op.Status = "refused"
251 delete(s.Pending, a.Role)
252 case "accepted":
253 result, ok := b.results[tr.ResultRecord]
254 rr := b.records[tr.ResultRecord]
255 if !ok || rr.Installation != r.Installation || rr.Action != "provisioner.result" || !operator.CanonicalToken(tr.ResultHead) || b.heads[tr.ResultRecord] != tr.ResultHead {
256 return ErrRefused
257 }
258 var out operator.Result
259 if decodeOperatorResult(result, &out) != nil || out.Version != 1 || out.OperationID != a.OperationID || out.Outcome != "committed" {
260 return ErrRefused
261 }
262 if a.Action == "directory.initialize" {
263 var input operator.InitializeInput
264 if operator.DecodeInput(op.Request, &input) != nil || s.Generation != "" || !operator.CanonicalToken(input.Generation) || out.Generation != input.Generation {
265 return ErrRefused
266 }
267 s.Generation = out.Generation
268 }
269 if a.Action != "directory.initialize" && out.Generation != "" && out.Generation != s.Generation {
270 return ErrRefused
271 }
272 s.Heads[a.Role] = acceptedHead{a.Sequence, op.Request.Head()}
273 delete(s.Pending, a.Role)
274 op.Status = "accepted"
275 op.Result = append([]byte(nil), result...)
276 default:
277 return ErrRefused
278 }
279 return nil
280}
281func (j *journal) enroll(ctx context.Context, p InstallationPlan) error {
282 if !p.valid() {
283 return ErrRefused
284 }
285 raw, e := canonicalJSON(p)
286 if e != nil {
287 return e
288 }
289 j.mu.Lock()
290 defer j.mu.Unlock()
291 b, e := j.bookLocked()
292 if e != nil {
293 return e
294 }
295 if old, ok := b.events[p.BaselineOperation]; ok {
296 if b.records[p.BaselineOperation].Action != "provisioner.enroll" || !bytes.Equal(old, raw) {
297 return ErrRefused
298 }
299 return nil
300 }
301 next, _, e := j.floor.current()
302 if e != nil {
303 return e
304 }
305 r := journalRecord{Installation: p.Installation, Operation: p.BaselineOperation, Action: "provisioner.enroll", Expected: "absent", Sequence: next.Sequence + 1, Created: j.now().Unix()}
306 if e = j.replay(b, r, raw); e != nil {
307 return e
308 }
309 _, e = j.appendAtLocked(ctx, p.Installation, p.BaselineOperation, r.Action, r.Expected, raw, r.Created)
310 return e
311}
312func (j *journal) prepare(ctx context.Context, id, op, action string, input any) ([]byte, error) {
313 j.mu.Lock()
314 defer j.mu.Unlock()
315 b, e := j.bookLocked()
316 if e != nil {
317 return nil, e
318 }
319 s := b.installations[id]
320 if s == nil || !operator.CanonicalToken(op) {
321 return nil, ErrRefused
322 }
323 if old := s.Operations[op]; old != nil {
324 a := old.Request.Authorization()
325 if a.Action != action {
326 return nil, ErrRefused
327 }
328 raw, e := operator.SignRequest(j.key, a, input)
329 if e != nil || !bytes.Equal(raw, old.Raw) {
330 // ECDSA signing may randomize the envelope. Compare verified exact input.
331 if e != nil {
332 return nil, ErrRefused
333 }
334 req, e := j.request(s, raw, action)
335 if e != nil || !bytes.Equal(req.Input(), old.Request.Input()) {
336 return nil, ErrRefused
337 }
338 }
339 return append([]byte(nil), old.Raw...), nil
340 }
341 role := "directory"
342 if strings.HasPrefix(action, "credential.") {
343 role = "credential"
344 }
345 target, ok := s.target(role)
346 if !ok || s.Pending[role] != "" {
347 return nil, ErrRefused
348 }
349 point, _, e := j.floor.current()
350 if e != nil {
351 return nil, e
352 }
353 head := s.Heads[role]
354 issued := j.now().Unix()
355 a := operator.Authorization{Version: 1, Purpose: "operator-input", Role: role, Origin: target.Origin, Installation: id, Registry: j.floor.identity.Registry, Device: j.floor.identity.Device, Sequence: head.Sequence + 1, Initial: head.Sequence == 0, Previous: head.Head, JournalSequence: point.Sequence + 1, OperationID: op, Action: action, Issued: issued, Expires: issued + 300}
356 raw, e := operator.SignRequest(j.key, a, input)
357 if e != nil {
358 return nil, ErrRefused
359 }
360 r := journalRecord{Installation: id, Operation: op, Action: action, Expected: "prepared", Sequence: point.Sequence + 1, Created: issued}
361 if e = j.replay(b, r, raw); e != nil {
362 return nil, e
363 }
364 if _, e = j.appendAtLocked(ctx, id, op, action, "prepared", raw, r.Created); e != nil {
365 return nil, e
366 }
367 return raw, nil
368}
369func (j *journal) markInFlight(ctx context.Context, id, event, op string) error {
370 tr := localTransition{Operation: op, Kind: "in-flight"}
371 return j.transition(ctx, id, event, "provisioner.in-flight", "prepared", tr)
372}
373
374// transition is internal journal machinery. Production outcome calls must be
375// supplied by the future authenticated console adapter; no CLI outcome path
376// or worker-authentication substitute is implemented here.
377func (j *journal) transition(ctx context.Context, id, event, action, expected string, tr localTransition) error {
378 raw, e := canonicalJSON(tr)
379 if e != nil {
380 return e
381 }
382 j.mu.Lock()
383 defer j.mu.Unlock()
384 b, e := j.bookLocked()
385 if e != nil {
386 return e
387 }
388 if old, ok := b.events[event]; ok {
389 r := b.records[event]
390 if r.Installation != id || r.Action != action || r.Expected != expected || !bytes.Equal(old, raw) {
391 return ErrRefused
392 }
393 return nil
394 }
395 r := journalRecord{Installation: id, Operation: event, Action: action, Expected: expected, Created: j.now().Unix()}
396 if e = j.replay(b, r, raw); e != nil {
397 return e
398 }
399 _, e = j.appendAtLocked(ctx, id, event, action, expected, raw, r.Created)
400 return e
401}
402
403type EnrollmentInfo struct {
404 RegistryInfo
405 Installation string `json:"installation"`
406 State string `json:"state"`
407}
408
409// EnrollPlan records private local preparation only. Remote enrollment and owner
410// approval remain separate steps with no dispatcher in this package.
411func EnrollPlan(statePath, floorPath string, password, raw []byte) (EnrollmentInfo, error) {
412 var p InstallationPlan
413 if len(raw) > 64<<10 || json.Unmarshal(raw, &p, json.RejectUnknownMembers(true)) != nil || !p.valid() {
414 return EnrollmentInfo{}, ErrRefused
415 }
416 j, e := existingJournal(statePath, floorPath, password)
417 if e != nil {
418 return EnrollmentInfo{}, e
419 }
420 defer closeExisting(j)
421 if e = j.enroll(context.Background(), p); e != nil {
422 return EnrollmentInfo{}, e
423 }
424 point, _, e := j.floor.current()
425 if e != nil {
426 return EnrollmentInfo{}, e
427 }
428 return EnrollmentInfo{RegistryInfo{j.floor.identity.Registry, j.floor.identity.Device, j.floor.identity.Public, point.Sequence}, p.Installation, "local-plan"}, nil
429}
430
431func decodeOperatorResult(raw []byte, out *operator.Result) error {
432 if len(raw) == 0 || len(raw) > operator.BodyLimit || json.Unmarshal(raw, out, json.RejectUnknownMembers(true)) != nil {
433 return ErrRefused
434 }
435 encoded, e := json.Marshal(out, json.Deterministic(true))
436 if e != nil || !bytes.Equal(encoded, raw) {
437 return ErrRefused
438 }
439 return nil
440}
441