oficina / home Public

Persist native installation plans and authorization transitions

Paul Campbell pushed by paul@keymail.dev 382858f0058c973ddabf5e0db838b6d30de0111d parent a6057b1
4 files changed, +954 −4
  • internal/provisioner/cli_linux.go +21 −1
  • internal/provisioner/installations_linux.go +440 −0
  • internal/provisioner/installations_linux_test.go +481 −0
  • internal/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