Persist native installation plans and authorization transitions
4 files changed,
+954
−4
internal/provisioner/cli_linux.go+21 −1internal/provisioner/installations_linux.go+440 −0internal/provisioner/installations_linux_test.go+481 −0internal/provisioner/journal_linux.go+12 −3
CI failed — run details
diff --git a/internal/provisioner/cli_linux.go b/internal/provisioner/cli_linux.go| index af8895f..915d9f9 100644 |
| --- a/internal/provisioner/cli_linux.go |
| +++ b/internal/provisioner/cli_linux.go |
| @@ -11,7 +11,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") || out == nil { |
| + if len(args) < 2 || args[0] != "provision" || (args[1] != "init" && args[1] != "backup" && args[1] != "restore" && args[1] != "enroll") || out == nil { |
| return ErrRefused |
| } |
| flags := flag.NewFlagSet("oficina provision init", flag.ContinueOnError) |
| @@ -21,9 +21,17 @@ func Run(args []string, out io.Writer) error { |
| output := flags.String("output", "", "") |
| input := flags.String("input", "", "") |
| passwordFD := flags.Int("password-fd", -1, "") |
| + planFD := flags.Int("plan-fd", -1, "") |
| if flags.Parse(args[2:]) != nil || flags.NArg() != 0 || *state == "" || *floor == "" { |
| return ErrRefused |
| } |
| + if args[1] == "enroll" { |
| + if *planFD < 3 || *planFD == *passwordFD || *input != "" || *output != "" { |
| + return ErrRefused |
| + } |
| + } else if *planFD != -1 { |
| + return ErrRefused |
| + } |
| password, e := privatePassword(*passwordFD) |
| if e != nil { |
| return e |
| @@ -31,6 +39,18 @@ func Run(args []string, out io.Writer) error { |
| defer clear(password) |
| var info any |
| switch args[1] { |
| + case "enroll": |
| + file := os.NewFile(uintptr(*planFD), "private-plan") |
| + if file == nil { |
| + return ErrRefused |
| + } |
| + raw, err := io.ReadAll(io.LimitReader(file, (64<<10)+1)) |
| + file.Close() |
| + if err != nil || len(raw) > 64<<10 { |
| + return ErrRefused |
| + } |
| + info, e = EnrollPlan(*state, *floor, password, raw) |
| + clear(raw) |
| case "init": |
| if *output != "" || *input != "" { |
| return ErrRefused |
diff --git a/internal/provisioner/installations_linux.go b/internal/provisioner/installations_linux.go| new file mode 100644 |
| index 0000000..d227fa6 |
| --- /dev/null |
| +++ b/internal/provisioner/installations_linux.go |
| @@ -0,0 +1,440 @@ |
| +//go:build linux |
| + |
| +package provisioner |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "encoding/base64" |
| + "strings" |
| + |
| + "amadan.net/oficina/home/internal/operator" |
| + family "amadan.net/rastrillo/rastrillo/crypto" |
| + "github.com/go-json-experiment/json" |
| +) |
| + |
| +// InstallationPlan is local preparation. Persisting it does not enroll a Home |
| +// role, assert fresh owner approval, or permit a remote command. |
| +type InstallationPlan struct { |
| + Version int `json:"version"` |
| + Installation string `json:"installation"` |
| + BaselineOperation string `json:"baseline_operation"` |
| + Credential InstallationTarget `json:"credential"` |
| + Directory InstallationTarget `json:"directory"` |
| +} |
| +type InstallationTarget struct { |
| + Console string `json:"console"` |
| + Account string `json:"account"` |
| + App string `json:"app"` |
| + Host string `json:"host"` |
| + Origin string `json:"origin"` |
| + Release string `json:"release"` |
| + Artifact string `json:"artifact_digest"` |
| + Environment string `json:"environment_digest"` |
| + DataKey string `json:"data_key_fingerprint"` |
| +} |
| +type acceptedHead struct { |
| + Sequence int64 |
| + Head string |
| +} |
| +type installationState struct { |
| + Plan InstallationPlan |
| + Generation string |
| + Heads map[string]acceptedHead |
| + Pending map[string]string |
| + Operations map[string]*preparedOperation |
| +} |
| +type preparedOperation struct { |
| + Request operator.Request |
| + Raw []byte |
| + Status string |
| + Result []byte |
| +} |
| +type installationBook struct { |
| + heads map[string]string |
| + installations map[string]*installationState |
| + events map[string][]byte |
| + records map[string]journalRecord |
| + results map[string][]byte |
| +} |
| +type localTransition struct { |
| + Operation string `json:"operation"` |
| + Kind string `json:"kind"` |
| + ResultRecord string `json:"result_record"` |
| + ResultHead string `json:"result_head"` |
| +} |
| + |
| +func validIdentifier(s string) bool { |
| + if len(s) == 0 || len(s) > 128 { |
| + return false |
| + } |
| + for _, c := range s { |
| + if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || strings.ContainsRune("_-/.", c)) { |
| + return false |
| + } |
| + } |
| + return !strings.Contains(s, "..") |
| +} |
| +func validHex(s string, n int) bool { |
| + if len(s) != n { |
| + return false |
| + } |
| + for _, c := range s { |
| + if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f') { |
| + return false |
| + } |
| + } |
| + return true |
| +} |
| +func (p InstallationPlan) valid() bool { |
| + if p.Version != 1 || !operator.CanonicalToken(p.Installation) || !operator.CanonicalToken(p.BaselineOperation) { |
| + return false |
| + } |
| + for _, t := range []InstallationTarget{p.Credential, p.Directory} { |
| + 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) { |
| + return false |
| + } |
| + } |
| + return p.Credential.Origin != p.Directory.Origin && !(p.Credential.Console == p.Directory.Console && p.Credential.Host == p.Directory.Host) |
| +} |
| +func sameTarget(a, b InstallationTarget) bool { |
| + return a.Console == b.Console && a.Host == b.Host |
| +} |
| +func (s *installationState) target(role string) (InstallationTarget, bool) { |
| + switch role { |
| + case "credential": |
| + return s.Plan.Credential, true |
| + case "directory": |
| + return s.Plan.Directory, true |
| + } |
| + return InstallationTarget{}, false |
| +} |
| +func (j *journal) request(s *installationState, raw []byte, action string) (operator.Request, error) { |
| + role := "directory" |
| + if strings.HasPrefix(action, "credential.") { |
| + role = "credential" |
| + } |
| + target, ok := s.target(role) |
| + if !ok { |
| + return operator.Request{}, ErrRefused |
| + } |
| + 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}}) |
| + if e != nil { |
| + return operator.Request{}, ErrRefused |
| + } |
| + r, e := v.Verify(raw) |
| + if e != nil { |
| + return operator.Request{}, ErrRefused |
| + } |
| + return r, nil |
| +} |
| +func (j *journal) bookLocked() (*installationBook, error) { |
| + if len(j.locks) != 2 { |
| + return nil, ErrRefused |
| + } |
| + if e := j.recover(); e != nil { |
| + return nil, e |
| + } |
| + b := &installationBook{heads: map[string]string{}, installations: map[string]*installationState{}, events: map[string][]byte{}, records: map[string]journalRecord{}, results: map[string][]byte{}} |
| + 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") |
| + if e != nil { |
| + return nil, ErrUnavailable |
| + } |
| + defer rows.Close() |
| + for rows.Next() { |
| + var raw, sealed []byte |
| + if rows.Scan(&raw, &sealed) != nil { |
| + return nil, ErrUnavailable |
| + } |
| + r, e := j.validate(raw, sealed) |
| + if e != nil { |
| + return nil, e |
| + } |
| + plain, e := family.Open(j.key, payloadContext+"/"+r.Operation, sealed) |
| + if e != nil || len(plain) == 0 || len(plain) > 64<<10 { |
| + return nil, ErrRefused |
| + } |
| + if e = j.replay(b, r, plain); e != nil { |
| + return nil, e |
| + } |
| + b.events[r.Operation] = plain |
| + b.records[r.Operation] = r |
| + b.heads[r.Operation] = hash(raw) |
| + } |
| + if rows.Err() != nil { |
| + return nil, ErrUnavailable |
| + } |
| + return b, nil |
| +} |
| +func (j *journal) replay(b *installationBook, r journalRecord, plain []byte) error { |
| + s := b.installations[r.Installation] |
| + if r.Action == "provisioner.enroll" { |
| + var p InstallationPlan |
| + if s != nil || r.Expected != "absent" || closedJSON(plain, &p) != nil || !p.valid() || p.Installation != r.Installation || p.BaselineOperation != r.Operation { |
| + return ErrRefused |
| + } |
| + for _, old := range b.installations { |
| + for _, a := range []InstallationTarget{old.Plan.Credential, old.Plan.Directory} { |
| + if sameTarget(a, p.Credential) || sameTarget(a, p.Directory) { |
| + return ErrRefused |
| + } |
| + } |
| + } |
| + b.installations[p.Installation] = &installationState{p, "", map[string]acceptedHead{}, map[string]string{}, map[string]*preparedOperation{}} |
| + return nil |
| + } |
| + if s == nil { |
| + return ErrRefused |
| + } |
| + if r.Expected == "prepared" && !strings.HasPrefix(r.Action, "provisioner.") { |
| + req, e := j.request(s, plain, r.Action) |
| + if e != nil { |
| + return e |
| + } |
| + a := req.Authorization() |
| + if a.Role == "directory" && ((s.Generation == "" && a.Action != "directory.initialize") || (s.Generation != "" && a.Action == "directory.initialize")) { |
| + return ErrRefused |
| + } |
| + head := s.Heads[a.Role] |
| + 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 { |
| + return ErrRefused |
| + } |
| + s.Operations[a.OperationID] = &preparedOperation{req, append([]byte(nil), plain...), "prepared", nil} |
| + s.Pending[a.Role] = a.OperationID |
| + return nil |
| + } |
| + if r.Action == "provisioner.result" { |
| + if r.Expected != "in-flight" { |
| + return ErrRefused |
| + } |
| + var out operator.Result |
| + if decodeOperatorResult(plain, &out) != nil || out.Version != 1 || out.Outcome != "committed" { |
| + return ErrRefused |
| + } |
| + pending := s.Operations[out.OperationID] |
| + if pending == nil || (pending.Status != "in-flight" && pending.Status != "ambiguous") { |
| + return ErrRefused |
| + } |
| + b.results[r.Operation] = append([]byte(nil), plain...) |
| + return nil |
| + } |
| + var tr localTransition |
| + if closedJSON(plain, &tr) != nil || !operator.CanonicalToken(tr.Operation) { |
| + return ErrRefused |
| + } |
| + op := s.Operations[tr.Operation] |
| + if op == nil { |
| + return ErrRefused |
| + } |
| + a := op.Request.Authorization() |
| + if r.Action == "provisioner.in-flight" { |
| + if r.Expected != "prepared" || tr.Kind != "in-flight" || (tr.ResultRecord != "" || tr.ResultHead != "") || op.Status != "prepared" || r.Created >= a.Expires || r.Created < a.Issued { |
| + return ErrRefused |
| + } |
| + op.Status = "in-flight" |
| + return nil |
| + } |
| + if r.Action != "provisioner.outcome" || r.Expected != "in-flight" || (op.Status != "in-flight" && op.Status != "ambiguous") { |
| + return ErrRefused |
| + } |
| + switch tr.Kind { |
| + case "ambiguous": |
| + if tr.ResultRecord != "" || tr.ResultHead != "" { |
| + return ErrRefused |
| + } |
| + op.Status = "ambiguous" |
| + case "refused": |
| + if (tr.ResultRecord != "" || tr.ResultHead != "") || r.Created < a.Expires || op.Status == "ambiguous" { |
| + return ErrRefused |
| + } |
| + op.Status = "refused" |
| + delete(s.Pending, a.Role) |
| + case "accepted": |
| + result, ok := b.results[tr.ResultRecord] |
| + rr := b.records[tr.ResultRecord] |
| + if !ok || rr.Installation != r.Installation || rr.Action != "provisioner.result" || !operator.CanonicalToken(tr.ResultHead) || b.heads[tr.ResultRecord] != tr.ResultHead { |
| + return ErrRefused |
| + } |
| + var out operator.Result |
| + if decodeOperatorResult(result, &out) != nil || out.Version != 1 || out.OperationID != a.OperationID || out.Outcome != "committed" { |
| + return ErrRefused |
| + } |
| + if a.Action == "directory.initialize" { |
| + var input operator.InitializeInput |
| + if operator.DecodeInput(op.Request, &input) != nil || s.Generation != "" || !operator.CanonicalToken(input.Generation) || out.Generation != input.Generation { |
| + return ErrRefused |
| + } |
| + s.Generation = out.Generation |
| + } |
| + if a.Action != "directory.initialize" && out.Generation != "" && out.Generation != s.Generation { |
| + return ErrRefused |
| + } |
| + s.Heads[a.Role] = acceptedHead{a.Sequence, op.Request.Head()} |
| + delete(s.Pending, a.Role) |
| + op.Status = "accepted" |
| + op.Result = append([]byte(nil), result...) |
| + default: |
| + return ErrRefused |
| + } |
| + return nil |
| +} |
| +func (j *journal) enroll(ctx context.Context, p InstallationPlan) error { |
| + if !p.valid() { |
| + return ErrRefused |
| + } |
| + raw, e := canonicalJSON(p) |
| + if e != nil { |
| + return e |
| + } |
| + j.mu.Lock() |
| + defer j.mu.Unlock() |
| + b, e := j.bookLocked() |
| + if e != nil { |
| + return e |
| + } |
| + if old, ok := b.events[p.BaselineOperation]; ok { |
| + if b.records[p.BaselineOperation].Action != "provisioner.enroll" || !bytes.Equal(old, raw) { |
| + return ErrRefused |
| + } |
| + return nil |
| + } |
| + next, _, e := j.floor.current() |
| + if e != nil { |
| + return e |
| + } |
| + r := journalRecord{Installation: p.Installation, Operation: p.BaselineOperation, Action: "provisioner.enroll", Expected: "absent", Sequence: next.Sequence + 1, Created: j.now().Unix()} |
| + if e = j.replay(b, r, raw); e != nil { |
| + return e |
| + } |
| + _, e = j.appendAtLocked(ctx, p.Installation, p.BaselineOperation, r.Action, r.Expected, raw, r.Created) |
| + return e |
| +} |
| +func (j *journal) prepare(ctx context.Context, id, op, action string, input any) ([]byte, error) { |
| + j.mu.Lock() |
| + defer j.mu.Unlock() |
| + b, e := j.bookLocked() |
| + if e != nil { |
| + return nil, e |
| + } |
| + s := b.installations[id] |
| + if s == nil || !operator.CanonicalToken(op) { |
| + return nil, ErrRefused |
| + } |
| + if old := s.Operations[op]; old != nil { |
| + a := old.Request.Authorization() |
| + if a.Action != action { |
| + return nil, ErrRefused |
| + } |
| + raw, e := operator.SignRequest(j.key, a, input) |
| + if e != nil || !bytes.Equal(raw, old.Raw) { |
| + // ECDSA signing may randomize the envelope. Compare verified exact input. |
| + if e != nil { |
| + return nil, ErrRefused |
| + } |
| + req, e := j.request(s, raw, action) |
| + if e != nil || !bytes.Equal(req.Input(), old.Request.Input()) { |
| + return nil, ErrRefused |
| + } |
| + } |
| + return append([]byte(nil), old.Raw...), nil |
| + } |
| + role := "directory" |
| + if strings.HasPrefix(action, "credential.") { |
| + role = "credential" |
| + } |
| + target, ok := s.target(role) |
| + if !ok || s.Pending[role] != "" { |
| + return nil, ErrRefused |
| + } |
| + point, _, e := j.floor.current() |
| + if e != nil { |
| + return nil, e |
| + } |
| + head := s.Heads[role] |
| + issued := j.now().Unix() |
| + 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} |
| + raw, e := operator.SignRequest(j.key, a, input) |
| + if e != nil { |
| + return nil, ErrRefused |
| + } |
| + r := journalRecord{Installation: id, Operation: op, Action: action, Expected: "prepared", Sequence: point.Sequence + 1, Created: issued} |
| + if e = j.replay(b, r, raw); e != nil { |
| + return nil, e |
| + } |
| + if _, e = j.appendAtLocked(ctx, id, op, action, "prepared", raw, r.Created); e != nil { |
| + return nil, e |
| + } |
| + return raw, nil |
| +} |
| +func (j *journal) markInFlight(ctx context.Context, id, event, op string) error { |
| + tr := localTransition{Operation: op, Kind: "in-flight"} |
| + return j.transition(ctx, id, event, "provisioner.in-flight", "prepared", tr) |
| +} |
| + |
| +// transition is internal journal machinery. Production outcome calls must be |
| +// supplied by the future authenticated console adapter; no CLI outcome path |
| +// or worker-authentication substitute is implemented here. |
| +func (j *journal) transition(ctx context.Context, id, event, action, expected string, tr localTransition) error { |
| + raw, e := canonicalJSON(tr) |
| + if e != nil { |
| + return e |
| + } |
| + j.mu.Lock() |
| + defer j.mu.Unlock() |
| + b, e := j.bookLocked() |
| + if e != nil { |
| + return e |
| + } |
| + if old, ok := b.events[event]; ok { |
| + r := b.records[event] |
| + if r.Installation != id || r.Action != action || r.Expected != expected || !bytes.Equal(old, raw) { |
| + return ErrRefused |
| + } |
| + return nil |
| + } |
| + r := journalRecord{Installation: id, Operation: event, Action: action, Expected: expected, Created: j.now().Unix()} |
| + if e = j.replay(b, r, raw); e != nil { |
| + return e |
| + } |
| + _, e = j.appendAtLocked(ctx, id, event, action, expected, raw, r.Created) |
| + return e |
| +} |
| + |
| +type EnrollmentInfo struct { |
| + RegistryInfo |
| + Installation string `json:"installation"` |
| + State string `json:"state"` |
| +} |
| + |
| +// EnrollPlan records private local preparation only. Remote enrollment and owner |
| +// approval remain separate steps with no dispatcher in this package. |
| +func EnrollPlan(statePath, floorPath string, password, raw []byte) (EnrollmentInfo, error) { |
| + var p InstallationPlan |
| + if len(raw) > 64<<10 || json.Unmarshal(raw, &p, json.RejectUnknownMembers(true)) != nil || !p.valid() { |
| + return EnrollmentInfo{}, ErrRefused |
| + } |
| + j, e := existingJournal(statePath, floorPath, password) |
| + if e != nil { |
| + return EnrollmentInfo{}, e |
| + } |
| + defer closeExisting(j) |
| + if e = j.enroll(context.Background(), p); e != nil { |
| + return EnrollmentInfo{}, e |
| + } |
| + point, _, e := j.floor.current() |
| + if e != nil { |
| + return EnrollmentInfo{}, e |
| + } |
| + return EnrollmentInfo{RegistryInfo{j.floor.identity.Registry, j.floor.identity.Device, j.floor.identity.Public, point.Sequence}, p.Installation, "local-plan"}, nil |
| +} |
| + |
| +func decodeOperatorResult(raw []byte, out *operator.Result) error { |
| + if len(raw) == 0 || len(raw) > operator.BodyLimit || json.Unmarshal(raw, out, json.RejectUnknownMembers(true)) != nil { |
| + return ErrRefused |
| + } |
| + encoded, e := json.Marshal(out, json.Deterministic(true)) |
| + if e != nil || !bytes.Equal(encoded, raw) { |
| + return ErrRefused |
| + } |
| + return nil |
| +} |
diff --git a/internal/provisioner/installations_linux_test.go b/internal/provisioner/installations_linux_test.go| new file mode 100644 |
| index 0000000..2ffb42a |
| --- /dev/null |
| +++ b/internal/provisioner/installations_linux_test.go |
| @@ -0,0 +1,481 @@ |
| +//go:build linux |
| + |
| +package provisioner |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "errors" |
| + "os" |
| + "os/exec" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "amadan.net/oficina/home/internal/operator" |
| + "github.com/go-json-experiment/json" |
| + "github.com/go-json-experiment/json/jsontext" |
| +) |
| + |
| +func planFixture() InstallationPlan { |
| + target := func(host, origin string) InstallationTarget { |
| + return InstallationTarget{Console: "https://console.example.test", Account: "synthetic-account", App: "oficina/home", Host: host, Origin: origin, Release: strings.Repeat("a", 40), Artifact: strings.Repeat("b", 64), Environment: strings.Repeat("c", 64), DataKey: strings.Repeat("d", 64)} |
| + } |
| + return InstallationPlan{1, testToken(20), testToken(21), target("credential.example.test", "https://id.example.test"), target("directory.example.test", "https://home.example.test")} |
| +} |
| +func stateFixture(t *testing.T) (*journal, InstallationPlan) { |
| + t.Helper() |
| + j := journalFixture(t) |
| + p := planFixture() |
| + j.now = func() time.Time { return time.Unix(2000000000, 0) } |
| + if e := j.enroll(context.Background(), p); e != nil { |
| + t.Fatal(e) |
| + } |
| + return j, p |
| +} |
| +func snapshot(t *testing.T, j *journal) *installationBook { |
| + t.Helper() |
| + j.mu.Lock() |
| + defer j.mu.Unlock() |
| + b, e := j.bookLocked() |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + return b |
| +} |
| +func initialization(op string) operator.InitializeInput { |
| + return operator.InitializeInput{InputBase: operator.InputBase{Version: 1, Operation: "directory.initialize", OperationID: op}, Generation: testToken(22), Baseline: "operator-attests-never-provisioned"} |
| +} |
| +func TestInstallationPreparationReplayAndIndependentRoles(t *testing.T) { |
| + j, p := stateFixture(t) |
| + ctx := context.Background() |
| + if e := j.enroll(ctx, p); e != nil { |
| + t.Fatal(e) |
| + } |
| + changed := p |
| + changed.Directory.Environment = strings.Repeat("e", 64) |
| + if e := j.enroll(ctx, changed); e != ErrRefused { |
| + t.Fatal("changed baseline", e) |
| + } |
| + raw, e := j.prepare(ctx, p.Installation, testToken(23), "directory.initialize", initialization(testToken(23))) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + before := snapshot(t, j) |
| + req := before.installations[p.Installation].Operations[testToken(23)].Request |
| + if req.Authorization().JournalSequence != 2 || req.Authorization().Sequence != 1 { |
| + t.Fatal("independent sequences") |
| + } |
| + if before.installations[p.Installation].Generation != "" || before.installations[p.Installation].Heads["directory"].Sequence != 0 { |
| + t.Fatal("preparation advanced authority") |
| + } |
| + j.now = func() time.Time { return time.Unix(2000001000, 0) } |
| + repeated, e := j.prepare(ctx, p.Installation, testToken(23), "directory.initialize", initialization(testToken(23))) |
| + if e != nil || !bytes.Equal(raw, repeated) { |
| + t.Fatal("exact expired prepared request changed", e) |
| + } |
| + changedInput := initialization(testToken(23)) |
| + changedInput.Generation = testToken(29) |
| + if _, e = j.prepare(ctx, p.Installation, testToken(23), "directory.initialize", changedInput); e != ErrRefused { |
| + t.Fatal("changed input", e) |
| + } |
| + if _, e = j.prepare(ctx, p.Installation, testToken(24), "directory.initialize", initialization(testToken(24))); e != ErrRefused { |
| + t.Fatal("replaced unresolved attempt", e) |
| + } |
| + if e = j.markInFlight(ctx, p.Installation, testToken(25), testToken(23)); e != ErrRefused { |
| + t.Fatal("expired dispatch", e) |
| + } |
| + credential, e := j.prepare(ctx, p.Installation, testToken(26), "credential.owner-seed", struct { |
| + Version int `json:"version"` |
| + }{1}) |
| + if e != nil || len(credential) == 0 { |
| + t.Fatal("independent credential role", e) |
| + } |
| + after := snapshot(t, j) |
| + if after.installations[p.Installation].Operations[testToken(26)].Request.Authorization().Sequence != 1 || len(after.installations[p.Installation].Heads) != 0 { |
| + t.Fatal("role advanced during preparation") |
| + } |
| +} |
| +func TestInstallationAmbiguityNeverReleasesSlot(t *testing.T) { |
| + j, p := stateFixture(t) |
| + ctx := context.Background() |
| + op := testToken(23) |
| + if _, e := j.prepare(ctx, p.Installation, op, "directory.initialize", initialization(op)); e != nil { |
| + t.Fatal(e) |
| + } |
| + if e := j.markInFlight(ctx, p.Installation, testToken(24), op); e != nil { |
| + t.Fatal(e) |
| + } |
| + if e := j.markInFlight(ctx, p.Installation, testToken(24), op); e != nil { |
| + t.Fatal("event replay", e) |
| + } |
| + if e := j.transition(ctx, p.Installation, testToken(25), "provisioner.outcome", "in-flight", localTransition{Operation: op, Kind: "ambiguous"}); e != nil { |
| + t.Fatal(e) |
| + } |
| + j.now = func() time.Time { return time.Unix(2000001000, 0) } |
| + if e := j.transition(ctx, p.Installation, testToken(26), "provisioner.outcome", "in-flight", localTransition{Operation: op, Kind: "refused"}); e != ErrRefused { |
| + t.Fatal("ambiguous became refused", e) |
| + } |
| + if _, e := j.prepare(ctx, p.Installation, testToken(27), "directory.initialize", initialization(testToken(27))); e != ErrRefused { |
| + t.Fatal("replaced ambiguous", e) |
| + } |
| +} |
| +func TestInstallationRefusalRequiresExpiryBeforeReplacement(t *testing.T) { |
| + j, p := stateFixture(t) |
| + ctx := context.Background() |
| + op := testToken(23) |
| + if _, e := j.prepare(ctx, p.Installation, op, "directory.initialize", initialization(op)); e != nil { |
| + t.Fatal(e) |
| + } |
| + if e := j.markInFlight(ctx, p.Installation, testToken(24), op); e != nil { |
| + t.Fatal(e) |
| + } |
| + tr := localTransition{Operation: op, Kind: "refused"} |
| + if e := j.transition(ctx, p.Installation, testToken(25), "provisioner.outcome", "in-flight", tr); e != ErrRefused { |
| + t.Fatal("fresh slot released", e) |
| + } |
| + j.now = func() time.Time { return time.Unix(2000000300, 0) } |
| + if e := j.transition(ctx, p.Installation, testToken(25), "provisioner.outcome", "in-flight", tr); e != nil { |
| + t.Fatal(e) |
| + } |
| + if _, e := j.prepare(ctx, p.Installation, testToken(26), "directory.initialize", initialization(testToken(26))); e != nil { |
| + t.Fatal(e) |
| + } |
| + s := snapshot(t, j).installations[p.Installation] |
| + if s.Operations[testToken(26)].Request.Authorization().Sequence != 1 || s.Heads["directory"].Sequence != 0 { |
| + t.Fatal("refusal consumed role sequence") |
| + } |
| +} |
| +func TestInstallationExactLargeResultDoesNotAdvanceUntilTransition(t *testing.T) { |
| + j, p := stateFixture(t) |
| + ctx := context.Background() |
| + op := testToken(23) |
| + if _, e := j.prepare(ctx, p.Installation, op, "directory.initialize", initialization(op)); e != nil { |
| + t.Fatal(e) |
| + } |
| + if e := j.markInFlight(ctx, p.Installation, testToken(24), op); e != nil { |
| + t.Fatal(e) |
| + } |
| + result := operator.Result{Version: 1, OperationID: op, Outcome: "committed", Generation: testToken(22), Receipt: jsontext.Value(`"x"`)} |
| + small, _ := json.Marshal(result, json.Deterministic(true)) |
| + result.Receipt = jsontext.Value(`"` + strings.Repeat("x", (64<<10)-len(small)+1) + `"`) |
| + exact, e := json.Marshal(result, json.Deterministic(true)) |
| + if e != nil || len(exact) != 64<<10 { |
| + t.Fatal("boundary", len(exact), e) |
| + } |
| + record, e := j.append(ctx, p.Installation, testToken(25), "provisioner.result", "in-flight", exact) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + s := snapshot(t, j).installations[p.Installation] |
| + if s.Generation != "" || s.Heads["directory"].Sequence != 0 { |
| + t.Fatal("orphan result advanced") |
| + } |
| + tr := localTransition{Operation: op, Kind: "accepted", ResultRecord: testToken(25), ResultHead: testToken(99)} |
| + if e = j.transition(ctx, p.Installation, testToken(26), "provisioner.outcome", "in-flight", tr); e != ErrRefused { |
| + t.Fatal("tampered result reference", e) |
| + } |
| + tr.ResultHead = hash(record) |
| + if e = j.transition(ctx, p.Installation, testToken(26), "provisioner.outcome", "in-flight", tr); e != nil { |
| + t.Fatal(e) |
| + } |
| + if e = j.transition(ctx, p.Installation, testToken(26), "provisioner.outcome", "in-flight", tr); e != nil { |
| + t.Fatal("outcome exact replay", e) |
| + } |
| + s = snapshot(t, j).installations[p.Installation] |
| + if s.Generation != testToken(22) || s.Heads["directory"].Sequence != 1 || !bytes.Equal(s.Operations[op].Result, exact) { |
| + t.Fatal("accepted result changed") |
| + } |
| + if _, e = j.prepare(ctx, p.Installation, testToken(27), "directory.seed-team", struct { |
| + Generation string `json:"expected_generation"` |
| + }{s.Generation}); e != nil { |
| + t.Fatal(e) |
| + } |
| + s = snapshot(t, j).installations[p.Installation] |
| + if s.Operations[testToken(27)].Request.Authorization().Previous != s.Heads["directory"].Head { |
| + t.Fatal("accepted chain not bound") |
| + } |
| +} |
| +func TestInstallationPreparationCrashBoundaries(t *testing.T) { |
| + for _, boundary := range []string{"prepared", "floor", "finalized"} { |
| + t.Run(boundary, func(t *testing.T) { |
| + j, p := stateFixture(t) |
| + ctx := context.Background() |
| + stop := errors.New("synthetic crash") |
| + j.boundary = func(stage string) error { |
| + if stage == boundary { |
| + return stop |
| + } |
| + return nil |
| + } |
| + if _, e := j.prepare(ctx, p.Installation, testToken(23), "directory.initialize", initialization(testToken(23))); e != stop { |
| + t.Fatal(e) |
| + } |
| + j.close() |
| + reopened, e := openJournal(j.dir, j.floor, j.key, false) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + defer reopened.close() |
| + reopened.now = func() time.Time { return time.Unix(2000001000, 0) } |
| + raw, e := reopened.prepare(ctx, p.Installation, testToken(23), "directory.initialize", initialization(testToken(23))) |
| + if e != nil || len(raw) == 0 { |
| + t.Fatal("recover signed bytes", e) |
| + } |
| + s := snapshot(t, reopened).installations[p.Installation] |
| + if len(s.Operations) != 1 || s.Heads["directory"].Sequence != 0 || s.Operations[testToken(23)].Request.Authorization().JournalSequence != 2 { |
| + t.Fatal("crash reserved twice") |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// These subprocesses exercise local custody and journal transitions only. They |
| +// do not impersonate a worker or manufacture an authenticated remote outcome. |
| +func TestNativeEnrollmentCLIAndProcessDeath(t *testing.T) { |
| + if mode := os.Getenv("OFICINA_STATE_CHILD"); mode != "" { |
| + state, floor := os.Getenv("OFICINA_STATE_PATH"), os.Getenv("OFICINA_FLOOR_PATH") |
| + if mode == "enroll" { |
| + if e := Run([]string{"provision", "enroll", "--state", state, "--floor-dir", floor, "--password-fd", "3", "--plan-fd", "4"}, os.Stdout); e != nil { |
| + os.Exit(1) |
| + } |
| + os.Exit(0) |
| + } |
| + password, e := privatePassword(3) |
| + if e != nil { |
| + os.Exit(2) |
| + } |
| + j, e := existingJournal(state, floor, password) |
| + clear(password) |
| + if e != nil { |
| + os.Exit(3) |
| + } |
| + defer closeExisting(j) |
| + j.boundary = func(stage string) error { |
| + if stage == mode { |
| + os.Exit(72) |
| + } |
| + return nil |
| + } |
| + _, e = j.prepare(context.Background(), testToken(20), testToken(23), "directory.initialize", initialization(testToken(23))) |
| + if e != nil { |
| + os.Exit(4) |
| + } |
| + os.Exit(5) |
| + } |
| + for _, boundary := range []string{"prepared", "floor", "finalized"} { |
| + t.Run(boundary, func(t *testing.T) { |
| + state, floor := persistentDirectory(t), persistentDirectory(t) |
| + password := []byte("synthetic-state-private-password") |
| + if _, e := Initialize(state, floor, password); e != nil { |
| + t.Fatal(e) |
| + } |
| + plan, _ := canonicalJSON(planFixture()) |
| + run := func(mode string) ([]byte, error) { |
| + r, w, e := os.Pipe() |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + pr, pw, e := os.Pipe() |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + defer r.Close() |
| + defer pr.Close() |
| + cmd := exec.Command(os.Args[0], "-test.run=^TestNativeEnrollmentCLIAndProcessDeath$") |
| + cmd.Env = append(os.Environ(), "OFICINA_STATE_CHILD="+mode, "OFICINA_STATE_PATH="+state, "OFICINA_FLOOR_PATH="+floor) |
| + cmd.ExtraFiles = []*os.File{r, pr} |
| + var out bytes.Buffer |
| + cmd.Stdout = &out |
| + cmd.Stderr = &out |
| + if e = cmd.Start(); e != nil { |
| + t.Fatal(e) |
| + } |
| + r.Close() |
| + pr.Close() |
| + _, e = w.Write(password) |
| + w.Close() |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + if mode == "enroll" { |
| + if _, e = pw.Write(plan); e != nil { |
| + t.Fatal(e) |
| + } |
| + } |
| + pw.Close() |
| + e = cmd.Wait() |
| + if bytes.Contains(out.Bytes(), password) || bytes.Contains(out.Bytes(), []byte(planFixture().Credential.Host)) { |
| + t.Fatal("private output") |
| + } |
| + return out.Bytes(), e |
| + } |
| + first, e := run("enroll") |
| + if e != nil { |
| + t.Fatal("enroll CLI", e) |
| + } |
| + second, e := run("enroll") |
| + if e != nil || !bytes.Equal(first, second) { |
| + t.Fatal("plan replay", e) |
| + } |
| + j, e := existingJournal(state, floor, password) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + if _, e = run("enroll"); e == nil { |
| + t.Fatal("second process bypassed registry lock") |
| + } |
| + closeExisting(j) |
| + _, e = run(boundary) |
| + var exit *exec.ExitError |
| + if !errors.As(e, &exit) || exit.ExitCode() != 72 { |
| + t.Fatal("process death boundary", e) |
| + } |
| + reopened, e := existingJournal(state, floor, password) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + defer closeExisting(reopened) |
| + reopened.now = func() time.Time { return time.Now().Add(time.Hour) } |
| + raw, e := reopened.prepare(context.Background(), testToken(20), testToken(23), "directory.initialize", initialization(testToken(23))) |
| + if e != nil || len(raw) == 0 { |
| + t.Fatal("recover after process death", e) |
| + } |
| + s := snapshot(t, reopened).installations[testToken(20)] |
| + if len(s.Operations) != 1 || s.Heads["directory"].Sequence != 0 { |
| + t.Fatal("process crash advanced authority") |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestInstallationResultAndAcceptanceCrashBoundaries(t *testing.T) { |
| + for _, phase := range []string{"result", "acceptance"} { |
| + for _, boundary := range []string{"prepared", "floor", "finalized"} { |
| + t.Run(phase+"/"+boundary, func(t *testing.T) { |
| + j, p := stateFixture(t) |
| + ctx := context.Background() |
| + op := testToken(23) |
| + if _, e := j.prepare(ctx, p.Installation, op, "directory.initialize", initialization(op)); e != nil { |
| + t.Fatal(e) |
| + } |
| + if e := j.markInFlight(ctx, p.Installation, testToken(24), op); e != nil { |
| + t.Fatal(e) |
| + } |
| + result, _ := json.Marshal(operator.Result{Version: 1, OperationID: op, Outcome: "committed", Generation: testToken(22)}, json.Deterministic(true)) |
| + stop := errors.New("synthetic interruption") |
| + inject := func() { |
| + j.boundary = func(stage string) error { |
| + if stage == boundary { |
| + return stop |
| + } |
| + return nil |
| + } |
| + } |
| + if phase == "result" { |
| + inject() |
| + } |
| + record, e := j.append(ctx, p.Installation, testToken(25), "provisioner.result", "in-flight", result) |
| + if phase == "result" && e != stop || phase == "acceptance" && e != nil { |
| + t.Fatal(e) |
| + } |
| + if phase == "acceptance" { |
| + inject() |
| + if e = j.transition(ctx, p.Installation, testToken(26), "provisioner.outcome", "in-flight", localTransition{Operation: op, Kind: "accepted", ResultRecord: testToken(25), ResultHead: hash(record)}); e != stop { |
| + t.Fatal(e) |
| + } |
| + } |
| + j.close() |
| + reopened, e := openJournal(j.dir, j.floor, j.key, false) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + defer reopened.close() |
| + b := snapshot(t, reopened) |
| + s := b.installations[p.Installation] |
| + if phase == "result" && (s.Generation != "" || s.Heads["directory"].Sequence != 0) { |
| + t.Fatal("orphan accepted") |
| + } |
| + if phase == "acceptance" && (s.Generation != testToken(22) || s.Heads["directory"].Sequence != 1) { |
| + t.Fatal("accepted event lost") |
| + } |
| + tr := localTransition{Operation: op, Kind: "accepted", ResultRecord: testToken(25), ResultHead: b.heads[testToken(25)]} |
| + if phase == "result" { |
| + missing := tr |
| + missing.ResultRecord = testToken(30) |
| + if e = reopened.transition(ctx, p.Installation, testToken(26), "provisioner.outcome", "in-flight", missing); e != ErrRefused { |
| + t.Fatal("missing result accepted", e) |
| + } |
| + } |
| + if e = reopened.transition(ctx, p.Installation, testToken(26), "provisioner.outcome", "in-flight", tr); e != nil { |
| + t.Fatal("exact acceptance recovery", e) |
| + } |
| + if snapshot(t, reopened).installations[p.Installation].Heads["directory"].Sequence != 1 { |
| + t.Fatal("accepted twice") |
| + } |
| + }) |
| + } |
| + } |
| +} |
| +func TestInstallationRestoredOldJournalCannotForgetAcceptedHead(t *testing.T) { |
| + j, p := stateFixture(t) |
| + ctx := context.Background() |
| + op := testToken(23) |
| + if _, e := j.prepare(ctx, p.Installation, op, "directory.initialize", initialization(op)); e != nil { |
| + t.Fatal(e) |
| + } |
| + if _, e := j.db.Exec("PRAGMA wal_checkpoint(TRUNCATE)"); e != nil { |
| + t.Fatal(e) |
| + } |
| + path := j.dir.file.Name() + "/registry.db" |
| + old, e := os.ReadFile(path) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + if e = j.markInFlight(ctx, p.Installation, testToken(24), op); e != nil { |
| + t.Fatal(e) |
| + } |
| + result, _ := json.Marshal(operator.Result{Version: 1, OperationID: op, Outcome: "committed", Generation: testToken(22)}, json.Deterministic(true)) |
| + record, e := j.append(ctx, p.Installation, testToken(25), "provisioner.result", "in-flight", result) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + if e = j.transition(ctx, p.Installation, testToken(26), "provisioner.outcome", "in-flight", localTransition{Operation: op, Kind: "accepted", ResultRecord: testToken(25), ResultHead: hash(record)}); e != nil { |
| + t.Fatal(e) |
| + } |
| + j.close() |
| + if e = os.WriteFile(path, old, 0600); e != nil { |
| + t.Fatal(e) |
| + } |
| + reopened, e := openJournal(j.dir, j.floor, j.key, false) |
| + if reopened != nil { |
| + reopened.close() |
| + } |
| + if e != ErrRefused { |
| + t.Fatal("old snapshot reset role authority", e) |
| + } |
| +} |
| + |
| +func TestInstallationPrivatePlanAndRequestStayEncrypted(t *testing.T) { |
| + j, p := stateFixture(t) |
| + input := operator.OwnerInput{InputBase: operator.InputBase{Version: 1, Operation: "credential.owner-seed", OperationID: testToken(23)}, Directory: p.Directory.Origin, TeamID: 1, TeamSQID: "Uk", Address: "native-state-private-32@example.invalid", ExistingOnly: true} |
| + if _, e := j.prepare(context.Background(), p.Installation, testToken(23), "credential.owner-seed", input); e != nil { |
| + t.Fatal(e) |
| + } |
| + for _, suffix := range []string{"", "-wal", "-shm"} { |
| + raw, e := os.ReadFile(j.dir.file.Name() + "/registry.db" + suffix) |
| + if e != nil && !os.IsNotExist(e) { |
| + t.Fatal(e) |
| + } |
| + for _, canary := range []string{input.Address, p.Directory.Host, p.Credential.Host, p.Directory.Environment, p.Directory.Origin} { |
| + if bytes.Contains(raw, []byte(canary)) { |
| + t.Fatal("private journal plaintext") |
| + } |
| + } |
| + } |
| + for _, action := range []string{"accept", "refuse", "complete", "mark-stopped"} { |
| + if e := Run([]string{"provision", action}, &bytes.Buffer{}); e != ErrRefused { |
| + t.Fatal("CLI manufactured completion", e) |
| + } |
| + } |
| +} |
diff --git a/internal/provisioner/journal_linux.go b/internal/provisioner/journal_linux.go| index 10efdc5..cd2634d 100644 |
| --- a/internal/provisioner/journal_linux.go |
| +++ b/internal/provisioner/journal_linux.go |
| @@ -187,14 +187,14 @@ func (j *journal) validate(raw, ciphertext []byte) (journalRecord, error) { |
| } |
| func recordAction(action string) bool { |
| switch action { |
| - case "credential.owner-seed", "directory.initialize", "directory.seed-team", "directory.provision-consumer", "directory.verify-consumer", "directory.activate-consumer": |
| + case "provisioner.enroll", "provisioner.in-flight", "provisioner.result", "provisioner.outcome", "credential.owner-seed", "directory.initialize", "directory.seed-team", "directory.provision-consumer", "directory.verify-consumer", "directory.activate-consumer": |
| return true |
| } |
| return false |
| } |
| func recordExpectation(expected string) bool { |
| switch expected { |
| - case "absent", "unseeded", "empty", "pending", "live": |
| + case "absent", "unseeded", "empty", "pending", "live", "prepared", "in-flight", "outcome": |
| return true |
| } |
| return false |
| @@ -308,6 +308,15 @@ func (j *journal) append(ctx context.Context, installation, operation, action, e |
| } |
| j.mu.Lock() |
| defer j.mu.Unlock() |
| + return j.appendLocked(ctx, installation, operation, action, expected, payload) |
| +} |
| +func (j *journal) appendLocked(ctx context.Context, installation, operation, action, expected string, payload []byte) ([]byte, error) { |
| + return j.appendAtLocked(ctx, installation, operation, action, expected, payload, j.now().Unix()) |
| +} |
| +func (j *journal) appendAtLocked(ctx context.Context, installation, operation, action, expected string, payload []byte, created int64) ([]byte, error) { |
| + if j == nil || len(payload) == 0 || len(payload) > 64<<10 { |
| + return nil, ErrRefused |
| + } |
| if len(j.locks) != 2 { |
| return nil, ErrRefused |
| } |
| @@ -341,7 +350,7 @@ func (j *journal) append(ctx context.Context, installation, operation, action, e |
| if e != nil { |
| return nil, ErrUnavailable |
| } |
| - r := journalRecord{1, j.floor.identity, installation, old.Sequence + 1, old.Head, operation, action, expected, j.now().Unix(), hash(sealed)} |
| + r := journalRecord{1, j.floor.identity, installation, old.Sequence + 1, old.Head, operation, action, expected, created, hash(sealed)} |
| body, e := canonicalJSON(r) |
| if e != nil { |
| return nil, e |