oficina / home Public

Constrain public CLI output and journal metadata

Paul Campbell pushed by paul@keymail.dev 2ee3f7456a99686064089121558acd9208f1f6a9 parent e24577a
5 files changed, +140 −6
  • cmd/oficina/main_linux.go +2 −2
  • internal/guardstest/logsinks_test.go +29 −1
  • internal/provisioner/cli_linux_test.go +74 −0
  • internal/provisioner/journal_linux.go +22 −3
  • internal/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)
+ }
+}