Constrain public CLI output and journal metadata
5 files changed,
+140
−6
cmd/oficina/main_linux.go+2 −2internal/guardstest/logsinks_test.go+29 −1internal/provisioner/cli_linux_test.go+74 −0internal/provisioner/journal_linux.go+22 −3internal/provisioner/journal_linux_test.go+13 −0
diff --git a/cmd/oficina/main_linux.go b/cmd/oficina/main_linux.go| index 17672e1..2c7a4f2 100644 |
| --- a/cmd/oficina/main_linux.go |
| +++ b/cmd/oficina/main_linux.go |
| @@ -3,7 +3,7 @@ |
| package main |
| import ( |
| - "fmt" |
| + "log/slog" |
| "os" |
| "amadan.net/oficina/home/internal/provisioner" |
| @@ -11,7 +11,7 @@ import ( |
| func main() { |
| if err := provisioner.Run(os.Args[1:], os.Stdout); err != nil { |
| - fmt.Fprintln(os.Stderr, err) |
| + slog.Error("provisioner", "err", err) |
| os.Exit(1) |
| } |
| } |
diff --git a/internal/guardstest/logsinks_test.go b/internal/guardstest/logsinks_test.go| index b55dafc..f6da0cd 100644 |
| --- a/internal/guardstest/logsinks_test.go |
| +++ b/internal/guardstest/logsinks_test.go |
| @@ -98,6 +98,34 @@ func TestNoLogSinkOutsideSlog(t *testing.T) { |
| local[name] = ipath |
| } |
| + // The separate native CLI returns the approved structured public result. |
| + // Permit only that argument, not other raw writes in the same file. |
| + structuredResult := map[*ast.SelectorExpr]bool{} |
| + if rel == "cmd/oficina/main_linux.go" { |
| + ast.Inspect(f, func(n ast.Node) bool { |
| + call, ok := n.(*ast.CallExpr) |
| + if !ok || len(call.Args) != 2 { |
| + return true |
| + } |
| + fun, ok := call.Fun.(*ast.SelectorExpr) |
| + if !ok || fun.Sel.Name != "Run" { |
| + return true |
| + } |
| + pkg, ok := fun.X.(*ast.Ident) |
| + if !ok || local[pkg.Name] != "amadan.net/oficina/home/internal/provisioner" { |
| + return true |
| + } |
| + sink, ok := call.Args[1].(*ast.SelectorExpr) |
| + if !ok || sink.Sel.Name != "Stdout" { |
| + return true |
| + } |
| + owner, ok := sink.X.(*ast.Ident) |
| + if ok && local[owner.Name] == "os" { |
| + structuredResult[sink] = true |
| + } |
| + return true |
| + }) |
| + } |
| ast.Inspect(f, func(n ast.Node) bool { |
| sel, ok := n.(*ast.SelectorExpr) |
| if !ok { |
| @@ -111,7 +139,7 @@ func TestNoLogSinkOutsideSlog(t *testing.T) { |
| if !isPackage { |
| return true |
| } |
| - if why, bad := forbiddenSelectors[ipath][sel.Sel.Name]; bad { |
| + if why, bad := forbiddenSelectors[ipath][sel.Sel.Name]; bad && !structuredResult[sel] { |
| t.Errorf("%s:%d: %s.%s (%s) — %s", |
| rel, fset.Position(sel.Pos()).Line, ident.Name, sel.Sel.Name, ipath, why) |
| } |
diff --git a/internal/provisioner/cli_linux_test.go b/internal/provisioner/cli_linux_test.go| new file mode 100644 |
| index 0000000..879f0c9 |
| --- /dev/null |
| +++ b/internal/provisioner/cli_linux_test.go |
| @@ -0,0 +1,74 @@ |
| +//go:build linux |
| + |
| +package provisioner |
| + |
| +import ( |
| + "bytes" |
| + "fmt" |
| + "io" |
| + "log/slog" |
| + "os" |
| + "os/exec" |
| + "testing" |
| + |
| + "github.com/go-json-experiment/json" |
| +) |
| + |
| +func TestCLIPrivateOutput(t *testing.T) { |
| + if os.Getenv("OFICINA_CLI_CHILD") == "1" { |
| + args := []string{"provision", "init", "--state", os.Getenv("OFICINA_CLI_STATE"), "--floor-dir", os.Getenv("OFICINA_CLI_FLOOR"), "--password-fd", "3"} |
| + if os.Getenv("OFICINA_CLI_INVALID") == "1" { |
| + args = append(args, "--unknown", "canary-8f3a@example.invalid") |
| + } |
| + if e := Run(args, os.Stdout); e != nil { |
| + slog.Error("provisioner", "err", e) |
| + os.Exit(1) |
| + } |
| + return |
| + } |
| + state, floor := persistentDirectory(t), persistentDirectory(t) |
| + run := func(invalid bool) ([]byte, []byte, error) { |
| + reader, writer, e := os.Pipe() |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + cmd := exec.Command(os.Args[0], "-test.run=^TestCLIPrivateOutput$") |
| + cmd.ExtraFiles = []*os.File{reader} |
| + cmd.Env = append(os.Environ(), "OFICINA_CLI_CHILD=1", "OFICINA_CLI_STATE="+state, "OFICINA_CLI_FLOOR="+floor) |
| + if invalid { |
| + cmd.Env = append(cmd.Env, "OFICINA_CLI_INVALID=1") |
| + } |
| + var out, errout bytes.Buffer |
| + cmd.Stdout = &out |
| + cmd.Stderr = &errout |
| + if e = cmd.Start(); e != nil { |
| + t.Fatal(e) |
| + } |
| + reader.Close() |
| + io.WriteString(writer, "synthetic-private-password") |
| + writer.Close() |
| + e = cmd.Wait() |
| + return out.Bytes(), errout.Bytes(), e |
| + } |
| + out, errout, e := run(false) |
| + if e != nil { |
| + t.Fatalf("child failed: %v %s", e, errout) |
| + } |
| + // The Go test runner adds PASS after the helper returns. Keep the actual Run |
| + // result separate while still observing the complete process streams. |
| + line, _, ok := bytes.Cut(out, []byte("\n")) |
| + if !ok { |
| + t.Fatal("missing structured output") |
| + } |
| + var got RegistryInfo |
| + if json.Unmarshal(line, &got, json.RejectUnknownMembers(true)) != nil || got.Sequence != 0 || len(got.PublicKey) != 87 || len(got.Registry) != 43 || len(got.Device) != 43 { |
| + t.Fatal("unexpected public result") |
| + } |
| + if len(errout) != 0 || bytes.Contains(out, []byte("synthetic-private-password")) { |
| + t.Fatal("private CLI output") |
| + } |
| + out, errout, e = run(true) |
| + if e == nil || len(out) != 0 || bytes.Contains(errout, []byte("canary-8f3a@example.invalid")) || bytes.Contains(errout, []byte("synthetic-private-password")) || !bytes.Contains(errout, []byte(ErrRefused.Error())) { |
| + t.Fatal(fmt.Sprint("invalid input privacy: ", e)) |
| + } |
| +} |
diff --git a/internal/provisioner/journal_linux.go b/internal/provisioner/journal_linux.go| index a696842..10efdc5 100644 |
| --- a/internal/provisioner/journal_linux.go |
| +++ b/internal/provisioner/journal_linux.go |
| @@ -175,7 +175,7 @@ func (j *journal) validate(raw, ciphertext []byte) (journalRecord, error) { |
| r := signed.Record |
| _, installation := tokenBytes(r.Installation, 32) |
| _, operation := tokenBytes(r.Operation, 32) |
| - if r.Version != 1 || r.Identity != j.floor.identity || !installation || !operation || r.Sequence <= 0 || r.Sequence > maxRecords || r.Created <= 0 || r.Action == "" || len(r.Action) > 64 || len(r.Expected) > 64 || r.CiphertextDigest != hash(ciphertext) { |
| + if r.Version != 1 || r.Identity != j.floor.identity || !installation || !operation || r.Sequence <= 0 || r.Sequence > maxRecords || r.Created <= 0 || !recordAction(r.Action) || !recordExpectation(r.Expected) || r.CiphertextDigest != hash(ciphertext) { |
| return journalRecord{}, ErrRefused |
| } |
| body, e := canonicalJSON(r) |
| @@ -185,6 +185,21 @@ func (j *journal) validate(raw, ciphertext []byte) (journalRecord, error) { |
| } |
| return r, nil |
| } |
| +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": |
| + return true |
| + } |
| + return false |
| +} |
| +func recordExpectation(expected string) bool { |
| + switch expected { |
| + case "absent", "unseeded", "empty", "pending", "live": |
| + return true |
| + } |
| + return false |
| +} |
| + |
| func (j *journal) recover() error { |
| var id floorIdentity |
| var accepted floorPoint |
| @@ -194,7 +209,11 @@ func (j *journal) recover() error { |
| if id != j.floor.identity { |
| return ErrRefused |
| } |
| - rows, e := j.db.Query("SELECT sequence,operation,signed_record,sealed_payload,state FROM records ORDER BY sequence") |
| + rows, e := j.db.Query(`SELECT sequence, |
| + CASE WHEN length(operation)=43 THEN operation ELSE '' END, |
| + CASE WHEN typeof(signed_record)='blob' AND length(signed_record)<=8192 THEN signed_record ELSE NULL END, |
| + CASE WHEN typeof(sealed_payload)='blob' AND length(sealed_payload)<=98304 THEN sealed_payload ELSE NULL END, |
| + CASE WHEN length(state)<=9 THEN state ELSE '' END FROM records ORDER BY sequence`) |
| if e != nil { |
| return ErrUnavailable |
| } |
| @@ -296,7 +315,7 @@ func (j *journal) append(ctx context.Context, installation, operation, action, e |
| return nil, e |
| } |
| var oldRaw, oldSealed []byte |
| - e := j.db.QueryRowContext(ctx, "SELECT signed_record,sealed_payload FROM records WHERE operation=?", operation).Scan(&oldRaw, &oldSealed) |
| + e := j.db.QueryRowContext(ctx, "SELECT CASE WHEN typeof(signed_record)='blob' AND length(signed_record)<=8192 THEN signed_record ELSE NULL END, CASE WHEN typeof(sealed_payload)='blob' AND length(sealed_payload)<=98304 THEN sealed_payload ELSE NULL END FROM records WHERE operation=?", operation).Scan(&oldRaw, &oldSealed) |
| if e == nil { |
| r, e := j.validate(oldRaw, oldSealed) |
| if e != nil || r.Installation != installation || r.Action != action || r.Expected != expected { |
diff --git a/internal/provisioner/journal_linux_test.go b/internal/provisioner/journal_linux_test.go| index 452d547..b5010ad 100644 |
| --- a/internal/provisioner/journal_linux_test.go |
| +++ b/internal/provisioner/journal_linux_test.go |
| @@ -191,3 +191,16 @@ func TestJournalProcessDeathRecovery(t *testing.T) { |
| }) |
| } |
| } |
| + |
| +func TestJournalPublicMetadataRejectsPrivateOrArbitraryText(t *testing.T) { |
| + j := journalFixture(t) |
| + for _, pair := range [][2]string{{"owner@example.invalid", "empty"}, {"directory.seed-team", "owner@example.invalid"}, {"arbitrary-sql", "empty"}} { |
| + if _, e := j.append(context.Background(), testToken(5), testToken(6), pair[0], pair[1], []byte("sealed")); e != ErrRefused { |
| + t.Fatal("arbitrary public journal metadata", e) |
| + } |
| + } |
| + var n int |
| + if e := j.db.QueryRow("SELECT count(*) FROM records").Scan(&n); e != nil || n != 0 { |
| + t.Fatal("rejected metadata persisted", e) |
| + } |
| +} |