oficina / home Public

Wire private Home operator command to verified role transactions

Paul Campbell pushed by paul@keymail.dev 946a24ab82aa68c9d7bc2526770f8bc3deff29ee parent 790208e
19 files changed, +1198 −45
  • cmd/home/main.go +6 −0
  • implementation/operator-command/proposal.json +14 −0
  • internal/home/compatibility_linux.go +3 −22
  • internal/home/exchange_test.go +1 −1
  • internal/home/operator_command_linux.go +234 −0
  • internal/home/operator_command_linux_test.go +497 −0
  • internal/home/operator_command_other.go +7 −0
  • internal/home/operator_schema_linux.go +32 −0
  • internal/home/operator_schema_linux_test.go +100 −0
  • internal/home/roster_runtime.go +21 −10
  • internal/home/runtime.go +21 −1
  • internal/home/runtime_test.go +1 −1
  • internal/operator/command_linux.go +14 −2
  • internal/operator/engine.go +13 −7
  • internal/operator/engine_test.go +45 −0
  • internal/operator/inspect.go +12 −0
  • internal/operator/ledger.go +36 −0
  • internal/operator/protocol.go +1 −1
  • internal/operator/run_linux.go +140 −0

CI passed — run details

diff --git a/cmd/home/main.go b/cmd/home/main.go
index 3990902..0ea67a3 100644
--- a/cmd/home/main.go
+++ b/cmd/home/main.go
@@ -50,6 +50,12 @@ import (
)
func main() {
+ if len(os.Args) > 1 && os.Args[1] == "operator-v1" {
+ if len(os.Args) != 4 || os.Args[2] != "--db" {
+ os.Exit(2)
+ }
+ os.Exit(home.RunOperatorCommand(context.Background(), os.Args[3]))
+ }
logger := slog.Default()
// DBPath is set here, not required as a flag — see the package
diff --git a/implementation/operator-command/proposal.json b/implementation/operator-command/proposal.json
new file mode 100644
index 0000000..cd1fdda
--- /dev/null
+++ b/implementation/operator-command/proposal.json
@@ -0,0 +1,14 @@
+{
+ "status":"Implemented for review; no deploy, merge or live invocation",
+ "parents":["startup6289db0","binding790208e","seed898fe219","opened database4770bc6"],
+ "command":"home operator-v1 --db <existing absolute path>, dispatched before framework serving startup; fixed exit0/2/3; panic maps to3 without printing",
+ "material":"Closed HOME_OPERATOR_MATERIAL JSON (or existing mutually exclusive _BASE64), maximum16384 bytes. Required version1, role matching HOME_ROLE and pinned command scope, independently provisioned nonzero canonical32-byte std-base64 mac_key. Credential additionally requires owner_seed_key family keypair. Directory additionally requires owner_seed_public canonical rawurl P256 public, provisioning_key family keypair, 1-8 roster_keys with exact derived IDs. Unknown, missing, duplicate and cross-role fields refuse.",
+ "reuse":"Existing HOME_DEK, HOME_PEPPER credential only, assertion key/public-key map, directory roster runtime and shared family decoder. No SMTP, sign-in session, preview-origin loading, key generation or migration on command path. Existing NewOperatorPersonResolver and compiled canonical directory policy unchanged.",
+ "key_separation":"MAC distinct from DEK and credential pepper. Pairwise public-key separation across authorizer/owner/provisioner/browser/roster roles before opening DB. Current roster serving key must be in provisioning roster-key set.",
+ "binding":"Protected role/origin and signing-key separation checked before private startup/signature/database binding. Database exposed only to existing engine built on actual verified connector. Final closure precedes opaque private result release.",
+ "schema":"Reuse checker exact schema, ledger checksums and foreign-key/replication shape on the same connection inside BEGIN IMMEDIATE before mutation or replay. Ordinary serving boot applies existing additive role-specific operator migrations and refuses foreign/missing/changed ledger entries. Legacy schema remains unchanged. No command migrations.",
+ "inspection":"Preserve existing InspectChallenge and signature context. Explicit directory.inspect action pin permits verified read-only challenge after mutation-wrapper verification fails; both parsers validate distinct signed contexts and complete closed shapes. No unverified fallback. Existing Directory.Inspect then checks schema inside its transaction with query_only=ON. It writes no operator result/state rows. A signed mutation wrapper claiming directory.inspect reaches existing mutation dispatch and refuses.",
+ "output":"Require fixed fd1 write-only FIFO before DB open. Opaque result can write only to fd1 after successful checked DB closure, bounded by copied launch deadline and caller cancellation. Nonblocking writes handle backpressure; output/close/expiry errors return3. Partial bytes may exist only in dedicated private pipe and are not a successful result; worker must discard incomplete/nonzero-exit output. No empty-stdout claim after a partial pipe failure.",
+ "review_changes_from_fable_draft":"Kept existing inspection wire instead of replacing its protocol; no breaking NewEngine signature. Private schema callback is set only by RunCommand. Used full schema verification rather than ledger-only. Corrected impossible empty-stdout guarantee after partial output failure. Explicitly validate stdout pipe and honor launch deadline during publication.",
+ "remaining":["Expanded integrated role/key/schema/refusal/output tests and actual binary dispatch","Fable review of final implementation and config alignment","Full local and exact CI gates","Native platform worker authentication, owner workflow and checkpoint/resume integration","Deployed canary review before merge; concrete approved live maintenance request before provisioning"]
+}
diff --git a/internal/home/compatibility_linux.go b/internal/home/compatibility_linux.go
index 469074e..956c96f 100644
--- a/internal/home/compatibility_linux.go
+++ b/internal/home/compatibility_linux.go
@@ -265,33 +265,14 @@ func (b *compatibilityBudget) open(s *seal.Sealer, purpose string, ciphertext []
return nil
}
func checkCompatibilitySchema(ctx context.Context, c *sql.Conn, set *migrate.Set, profile []schemaObject) error {
- expected := map[string]string{}
- for _, m := range set.All() {
- if m.Fn != nil {
- return operator.ErrRefused
- }
- expected[m.ID] = migrate.Checksum(m.SQL)
- }
- rows, e := c.QueryContext(ctx, "SELECT id,checksum FROM rastrillo_migrations LIMIT 1001")
- if e != nil {
- return operator.ErrRefused
- }
- for rows.Next() {
- var id, sum string
- if rows.Scan(&id, &sum) != nil || expected[id] == "" || expected[id] != sum {
- rows.Close()
- return operator.ErrRefused
- }
- delete(expected, id)
- }
- if rows.Err() != nil || rows.Close() != nil || len(expected) != 0 {
- return operator.ErrRefused
+ if err := operator.VerifyLedger(ctx, c, set); err != nil {
+ return err
}
objects := map[string]schemaObject{}
for _, p := range profile {
objects[p.Type+"/"+p.Name] = p
}
- rows, e = c.QueryContext(ctx, "SELECT type,name,tbl_name,coalesce(sql,'') FROM sqlite_schema LIMIT 1001")
+ rows, e := c.QueryContext(ctx, "SELECT type,name,tbl_name,coalesce(sql,'') FROM sqlite_schema LIMIT 1001")
if e != nil {
return operator.ErrRefused
}
diff --git a/internal/home/exchange_test.go b/internal/home/exchange_test.go
index dbc9b79..d87aee9 100644
--- a/internal/home/exchange_test.go
+++ b/internal/home/exchange_test.go
@@ -50,7 +50,7 @@ func newExchangePair(t *testing.T) exchangePair {
t.Fatal(err)
}
t.Cleanup(func() { d.Close() })
- if _, err = migrate.Apply(context.Background(), d, directorySchema); err != nil {
+ if _, err = migrate.Apply(context.Background(), d, (Runtime{role: "directory"}).Schema()); err != nil {
t.Fatal(err)
}
dek := make([]byte, 32)
diff --git a/internal/home/operator_command_linux.go b/internal/home/operator_command_linux.go
new file mode 100644
index 0000000..155079b
--- /dev/null
+++ b/internal/home/operator_command_linux.go
@@ -0,0 +1,234 @@
+//go:build linux
+
+package home
+
+import (
+ "bytes"
+ "context"
+ "crypto/elliptic"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
+ "os"
+ "path/filepath"
+ "time"
+
+ "amadan.net/oficina/home/internal/home/directorydata"
+ "amadan.net/oficina/home/internal/home/seal"
+ "amadan.net/oficina/home/internal/operator"
+ "amadan.net/oficina/home/roster"
+ "amadan.net/rastrillo/rastrillo/assertion"
+ json "github.com/go-json-experiment/json"
+)
+
+type credentialOperatorMaterial struct {
+ Version int `json:"version"`
+ Role string `json:"role"`
+ MAC string `json:"mac_key"`
+ Owner string `json:"owner_seed_key"`
+}
+type directoryOperatorMaterial struct {
+ Version int `json:"version"`
+ Role string `json:"role"`
+ MAC string `json:"mac_key"`
+ Owner string `json:"owner_seed_public"`
+ Provisioning string `json:"provisioning_key"`
+ Roster []roster.Key `json:"roster_keys"`
+}
+
+// RunOperatorCommand has no serving startup, migration, mailer or logger.
+func RunOperatorCommand(ctx context.Context, path string) (code int) {
+ code = 3
+ defer func() {
+ if recover() != nil {
+ code = 3
+ }
+ }()
+ if ctx == nil || !filepath.IsAbs(path) || filepath.Clean(path) != path || path == "/" {
+ return 2
+ }
+ material, err := loadOperatorMaterial()
+ defer clear(material.MAC)
+ if err != nil {
+ return 2
+ }
+ ctx, cancel := context.WithTimeout(ctx, 10*time.Minute)
+ defer cancel()
+ result, err := operator.RunCommand(ctx, path, material)
+ if err == operator.ErrRefused {
+ return 2
+ }
+ if err != nil {
+ return 3
+ }
+ if result.Write(ctx) != nil {
+ return 3
+ }
+ return 0
+}
+
+func operatorMAC(raw string) ([]byte, error) {
+ key, err := base64.StdEncoding.Strict().DecodeString(raw)
+ if err != nil || len(key) != 32 || base64.StdEncoding.EncodeToString(key) != raw || bytes.Equal(key, make([]byte, 32)) {
+ return nil, operator.ErrRefused
+ }
+ return key, nil
+}
+func operatorPublic(raw string) ([]byte, error) {
+ key, err := base64.RawURLEncoding.Strict().DecodeString(raw)
+ if err != nil || len(key) != 65 || base64.RawURLEncoding.EncodeToString(key) != raw {
+ return nil, operator.ErrRefused
+ }
+ x, _ := elliptic.Unmarshal(elliptic.P256(), key)
+ if x == nil {
+ return nil, operator.ErrRefused
+ }
+ return key, nil
+}
+func distinctOperatorKeys(keys [][]byte) bool {
+ seen := map[string]bool{}
+ for _, key := range keys {
+ if len(key) == 0 || seen[string(key)] {
+ return false
+ }
+ seen[string(key)] = true
+ }
+ return true
+}
+
+func loadOperatorMaterial() (operator.CommandMaterial, error) {
+ var material operator.CommandMaterial
+ role := os.Getenv("HOME_ROLE")
+ if role != "credential" && role != "directory" {
+ return material, operator.ErrRefused
+ }
+ origins, err := LoadOrigins()
+ if err != nil {
+ return material, operator.ErrRefused
+ }
+ raw, err := exchangeJSON("HOME_OPERATOR_MATERIAL", 16384)
+ if err != nil || raw == "" {
+ return material, operator.ErrRefused
+ }
+ dek, err := key32("HOME_DEK")
+ if err != nil {
+ return material, operator.ErrRefused
+ }
+ defer clear(dek[:])
+ sealer, err := seal.New(dek[:])
+ if err != nil {
+ return material, operator.ErrRefused
+ }
+ check, err := operatorSchemaCheck(role)
+ if err != nil {
+ return material, err
+ }
+ material = operator.CommandMaterial{Role: role, Sealer: sealer, CheckSchema: check}
+ if role == "credential" {
+ var cfg credentialOperatorMaterial
+ if json.Unmarshal([]byte(raw), &cfg, json.RejectUnknownMembers(true)) != nil || cfg.Version != 1 || cfg.Role != role {
+ return material, operator.ErrRefused
+ }
+ material.MAC, err = operatorMAC(cfg.MAC)
+ if err != nil || bytes.Equal(material.MAC, dek[:]) {
+ return material, operator.ErrRefused
+ }
+ pepper, err := key32("HOME_PEPPER")
+ if err != nil || bytes.Equal(pepper[:], dek[:]) || bytes.Equal(pepper[:], material.MAC) {
+ return material, operator.ErrRefused
+ }
+ defer clear(pepper[:])
+ resolve, err := NewOperatorPersonResolver(pepper[:])
+ if err != nil {
+ return material, operator.ErrRefused
+ }
+ owner, err := decodeHomeKeypair(cfg.Owner)
+ if err != nil {
+ return material, operator.ErrRefused
+ }
+ browser, err := decodeHomeKeypair(os.Getenv("HOME_ASSERTION_KEY"))
+ if err != nil {
+ return material, operator.ErrRefused
+ }
+ material.OtherKeys = [][]byte{owner.SignPub(), browser.SignPub()}
+ material.Origin = origins.Credential
+ material.Build = func(engine *operator.Engine) (operator.CommandExecutor, error) {
+ return operator.NewCredential(engine, origins.Directory, owner, [][]byte{browser.SignPub()}, resolve)
+ }
+ } else {
+ var cfg directoryOperatorMaterial
+ if json.Unmarshal([]byte(raw), &cfg, json.RejectUnknownMembers(true)) != nil || cfg.Version != 1 || cfg.Role != role || len(cfg.Roster) == 0 || len(cfg.Roster) > 8 {
+ return material, operator.ErrRefused
+ }
+ material.MAC, err = operatorMAC(cfg.MAC)
+ if err != nil || bytes.Equal(material.MAC, dek[:]) {
+ return material, operator.ErrRefused
+ }
+ owner, err := operator.NewOwnerVerifier(cfg.Owner, origins.Credential, origins.Directory)
+ if err != nil {
+ return material, operator.ErrRefused
+ }
+ ownerPub, err := operatorPublic(cfg.Owner)
+ if err != nil {
+ return material, operator.ErrRefused
+ }
+ signer, err := decodeHomeKeypair(cfg.Provisioning)
+ if err != nil {
+ return material, operator.ErrRefused
+ }
+ browserRaw, err := exchangeJSON("HOME_ASSERTION_KEYS", 4096)
+ var encoded map[string]string
+ if err != nil || json.Unmarshal([]byte(browserRaw), &encoded) != nil || len(encoded) == 0 || len(encoded) > 8 {
+ return material, operator.ErrRefused
+ }
+ browser := map[string][]byte{}
+ material.OtherKeys = [][]byte{ownerPub, signer.SignPub()}
+ for id, value := range encoded {
+ key, err := base64.StdEncoding.Strict().DecodeString(value)
+ if err != nil || base64.StdEncoding.EncodeToString(key) != value {
+ return material, operator.ErrRefused
+ }
+ browser[id] = key
+ material.OtherKeys = append(material.OtherKeys, key)
+ }
+ if _, err := assertion.NewVerifier(origins.Credential, origins.Directory, browser); err != nil {
+ return material, operator.ErrRefused
+ }
+ rosterRuntime, err := loadRosterRuntime(role, browser)
+ if err != nil {
+ return material, operator.ErrRefused
+ }
+ ids := map[string]bool{}
+ matched := rosterRuntime.mode != "serve"
+ for _, key := range cfg.Roster {
+ pub, err := operatorPublic(key.Public)
+ sum := sha256.Sum256(pub)
+ if err != nil || key.ID != hex.EncodeToString(sum[:]) || ids[key.ID] {
+ return material, operator.ErrRefused
+ }
+ ids[key.ID] = true
+ material.OtherKeys = append(material.OtherKeys, pub)
+ if rosterRuntime.mode == "serve" && bytes.Equal(pub, rosterRuntime.signer.SignPub()) {
+ matched = true
+ }
+ }
+ if !matched {
+ return material, operator.ErrRefused
+ }
+ var apps []string
+ for _, app := range directoryApps() {
+ if app.integrated {
+ apps = append(apps, app.slug)
+ }
+ }
+ policy := directorydata.Config{Apps: apps, ValidateRoute: directoryRouteAllowed}
+ material.Origin = origins.Directory
+ material.Build = func(engine *operator.Engine) (operator.CommandExecutor, error) {
+ return operator.NewDirectory(engine, owner, signer, cfg.Roster, policy)
+ }
+ }
+ if !distinctOperatorKeys(material.OtherKeys) {
+ return material, operator.ErrRefused
+ }
+ return material, nil
+}
diff --git a/internal/home/operator_command_linux_test.go b/internal/home/operator_command_linux_test.go
new file mode 100644
index 0000000..b1f527e
--- /dev/null
+++ b/internal/home/operator_command_linux_test.go
@@ -0,0 +1,497 @@
+//go:build linux
+
+package home
+
+import (
+ "bytes"
+ "context"
+ "crypto/sha256"
+ "database/sql"
+ "encoding/base64"
+ "encoding/hex"
+ stdjson "encoding/json"
+ "errors"
+ "io"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "amadan.net/carlos/platform.git/launchctx"
+ "amadan.net/oficina/home/internal/operator"
+ family "amadan.net/rastrillo/rastrillo/crypto"
+ rdb "github.com/carlosframework/rastrillo/db"
+ "github.com/carlosframework/rastrillo/migrate"
+ "golang.org/x/sys/unix"
+)
+
+func TestOperatorCommandChild(t *testing.T) {
+ if os.Getenv("HOME_OPERATOR_CHILD") != "1" {
+ return
+ }
+ os.Exit(RunOperatorCommand(context.Background(), os.Getenv("HOME_OPERATOR_DATABASE")))
+}
+
+type commandFixture struct {
+ path string
+ env []string
+ pin operator.Pins
+ signer, owner *family.Keypair
+ imageHash string
+ outputMode string
+ executable string
+ args []string
+}
+
+func newCommandFixture(t *testing.T, role string) commandFixture {
+ t.Helper()
+ makeKey := func() *family.Keypair {
+ key, err := family.Generate()
+ if err != nil {
+ t.Fatal(err)
+ }
+ return key
+ }
+ encodeKey := func(key *family.Keypair) string {
+ raw, err := family.MarshalKeypair(key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return base64.StdEncoding.EncodeToString(raw)
+ }
+ signer, owner, browser, provisioning, page := makeKey(), makeKey(), makeKey(), makeKey(), makeKey()
+ path := filepath.Join(t.TempDir(), "home.db")
+ db, err := rdb.Open(path, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err = migrate.Apply(t.Context(), db, compatibilitySchema(role)); err != nil {
+ t.Fatal(err)
+ }
+ if _, err = db.Writer().Exec(replicationCompatibilityDDL); err != nil {
+ t.Fatal(err)
+ }
+ if err = db.Close(); err != nil {
+ t.Fatal(err)
+ }
+ credential, directory := "https://id.example.test", "https://home.example.test"
+ origin := credential
+ actions := []string{"credential.owner-seed"}
+ if role == "directory" {
+ origin = directory
+ actions = []string{"directory.inspect", "directory.initialize", "directory.seed-team", "directory.provision-consumer", "directory.verify-consumer", "directory.activate-consumer"}
+ }
+ pins := operator.Pins{Role: role, Origin: origin, Installation: opToken(1), Registry: opToken(2), Device: opToken(3), PublicKey: base64.RawURLEncoding.EncodeToString(signer.SignPub()), Actions: actions}
+ config := map[string]any{"version": 1, "account": "bds", "app": "home", "host": "home.example.test", "role": role, "origin": origin, "installation": pins.Installation, "registry": pins.Registry, "device": pins.Device, "public_key": pins.PublicKey, "key_id": rosterKeyID(signer), "actions": actions}
+ raw, err := stdjson.Marshal(config)
+ if err != nil {
+ t.Fatal(err)
+ }
+ env := []string{"HOME_OPERATOR_CHILD=1", "HOME_OPERATOR_DATABASE=" + path, "HOME_ROLE=" + role, "HOME_CREDENTIAL_ORIGIN=" + credential, "HOME_DIRECTORY_ORIGIN=" + directory, "HOME_OPERATOR_COMMAND=" + string(raw), "HOME_DEK=" + base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{7}, 32)), "GODEBUG=containermaxprocs=0,updatemaxprocs=0"}
+ material := map[string]any{"version": 1, "role": role, "mac_key": base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{8}, 32))}
+ if role == "credential" {
+ material["owner_seed_key"] = encodeKey(owner)
+ env = append(env, "HOME_PEPPER="+base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{9}, 32)), "HOME_ASSERTION_KEY="+encodeKey(browser))
+ } else {
+ material["owner_seed_public"] = base64.RawURLEncoding.EncodeToString(owner.SignPub())
+ material["provisioning_key"] = encodeKey(provisioning)
+ material["roster_keys"] = []map[string]string{{"id": rosterKeyID(page), "public": base64.RawURLEncoding.EncodeToString(page.SignPub())}}
+ keys, _ := stdjson.Marshal(map[string]string{rosterKeyID(browser): base64.StdEncoding.EncodeToString(browser.SignPub())})
+ env = append(env, "HOME_ASSERTION_KEYS="+string(keys))
+ }
+ raw, err = stdjson.Marshal(material)
+ if err != nil {
+ t.Fatal(err)
+ }
+ env = append(env, "HOME_OPERATOR_MATERIAL="+string(raw))
+ image, err := os.ReadFile("/proc/self/exe")
+ if err != nil {
+ t.Fatal(err)
+ }
+ hash := sha256.Sum256(image)
+ return commandFixture{path: path, env: env, pin: pins, signer: signer, owner: owner, imageHash: hex.EncodeToString(hash[:]), executable: "/proc/self/exe", args: []string{"-test.run=^TestOperatorCommandChild$"}}
+}
+func (f commandFixture) request(t *testing.T, action string, input any, sequence int64, previous string, op byte) []byte {
+ t.Helper()
+ now := time.Now().Unix()
+ auth := operator.Authorization{Version: 1, Purpose: "operator-input", Role: f.pin.Role, Origin: f.pin.Origin, Installation: f.pin.Installation, Registry: f.pin.Registry, Device: f.pin.Device, Sequence: sequence, Initial: sequence == 1, Previous: previous, JournalSequence: sequence, OperationID: opToken(op), Action: action, Issued: now, Expires: now + 300}
+ raw, err := operator.SignRequest(f.signer, auth, input)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return raw
+}
+func (f commandFixture) run(t *testing.T, input []byte) ([]byte, int, bool) {
+ t.Helper()
+ var st unix.Stat_t
+ if err := unix.Stat(f.path, &st); err != nil {
+ t.Fatal(err)
+ }
+ digest := strings.Repeat("a", 64)
+ sum := sha256.Sum256(input)
+ launch := launchctx.LaunchContext{Version: 1, Account: "bds", App: "home", Host: "home.example.test", RequestID: opToken(30), AttemptID: opToken(31), RequestDigest: digest, RecordIdentity: digest, ArtifactDigest: f.imageHash, EnvironmentEnvelopeDigest: digest, GuardGeneration: 1, InputSHA256: hex.EncodeToString(sum[:]), DatabasePath: f.path, DatabaseDevice: uint64(st.Dev), DatabaseInode: st.Ino, ExecutionDeadline: time.Now().Add(20 * time.Second).UTC().Format(time.RFC3339Nano)}
+ raw, err := launchctx.EncodeLaunch(launch)
+ if err != nil {
+ t.Fatal(err)
+ }
+ pin, err := os.Open(f.executable)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer pin.Close()
+ read, write, err := os.Pipe()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer read.Close()
+ defer write.Close()
+ watch, err := unix.InotifyInit1(unix.IN_NONBLOCK | unix.IN_CLOEXEC)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer unix.Close(watch)
+ if _, err = unix.InotifyAddWatch(watch, f.path, unix.IN_OPEN); err != nil {
+ t.Fatal(err)
+ }
+ ctx, cancel := context.WithTimeout(t.Context(), 25*time.Second)
+ defer cancel()
+ cmd := exec.CommandContext(ctx, f.executable, f.args...)
+ cmd.Dir = "/"
+ cmd.Env = f.env
+ cmd.Stdin = bytes.NewReader(input)
+ cmd.ExtraFiles = []*os.File{read, pin}
+ var out, stderr bytes.Buffer
+ cmd.Stdout = &out
+ if f.outputMode == "closed-pipe" {
+ reader, writer, err := os.Pipe()
+ if err != nil {
+ t.Fatal(err)
+ }
+ reader.Close()
+ defer writer.Close()
+ cmd.Stdout = writer
+ }
+ if f.outputMode == "regular-file" {
+ file, err := os.CreateTemp(t.TempDir(), "output")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer file.Close()
+ cmd.Stdout = file
+ }
+ cmd.Stderr = &stderr
+ if err = cmd.Start(); err != nil {
+ t.Fatal(err)
+ }
+ read.Close()
+ done := make(chan error, 1)
+ go func() { _, err := io.Copy(write, bytes.NewReader(raw)); write.Close(); done <- err }()
+ err = cmd.Wait()
+ writeErr := <-done
+ code := 0
+ if err != nil {
+ var exit *exec.ExitError
+ if !errors.As(err, &exit) {
+ t.Fatal(err)
+ }
+ code = exit.ExitCode()
+ }
+ if code == 0 && writeErr != nil {
+ t.Fatal(writeErr)
+ }
+ if stderr.Len() != 0 {
+ t.Fatal("command wrote stderr")
+ }
+ buf := make([]byte, 4096)
+ n, err := unix.Read(watch, buf)
+ if err != nil && !errors.Is(err, unix.EAGAIN) {
+ t.Fatal(err)
+ }
+ return out.Bytes(), code, n > 0
+}
+func commandRowCount(t *testing.T, path, table string) int {
+ t.Helper()
+ db, err := sql.Open("sqlite", "file:"+path+"?mode=ro")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer db.Close()
+ var n int
+ if err = db.QueryRow("SELECT count(*) FROM " + table).Scan(&n); err != nil {
+ t.Fatal(err)
+ }
+ return n
+}
+func TestOperatorCommandCredentialPrivateAtomicReplay(t *testing.T) {
+ f := newCommandFixture(t, "credential")
+ address := "operator-command-private@example.invalid"
+ raw := f.request(t, "credential.owner-seed", operator.OwnerInput{InputBase: operator.InputBase{Version: 1, Operation: "credential.owner-seed", OperationID: opToken(10)}, Directory: "https://home.example.test", TeamID: 1, TeamSQID: "Uk", Address: address}, 1, "", 10)
+ out, code, opened := f.run(t, raw)
+ if code != 0 || !opened {
+ t.Fatal("credential command failed", code, opened)
+ }
+ var result operator.Result
+ if stdjson.Unmarshal(out, &result) != nil {
+ t.Fatal("invalid result")
+ }
+ owner, err := operator.NewOwnerVerifier(base64.RawURLEncoding.EncodeToString(f.owner.SignPub()), f.pin.Origin, "https://home.example.test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ receipt, err := owner.Verify(result.Receipt, time.Now())
+ if err != nil || !ValidPersonRef(receipt.PersonRef) {
+ t.Fatal("owner receipt failed", err)
+ }
+ again, code, _ := f.run(t, raw)
+ if code != 0 || !bytes.Equal(out, again) {
+ t.Fatal("replay changed result", code)
+ }
+ if commandRowCount(t, f.path, "people") != 1 || commandRowCount(t, f.path, "credential_operator_results") != 1 {
+ t.Fatal("replay duplicated data")
+ }
+ for _, suffix := range []string{"", "-wal"} {
+ disk, err := os.ReadFile(f.path + suffix)
+ if err != nil && !errors.Is(err, os.ErrNotExist) {
+ t.Fatal(err)
+ }
+ if bytes.Contains(disk, []byte(address)) || bytes.Contains(disk, result.Receipt) {
+ t.Fatal("private values stored in plaintext")
+ }
+ }
+}
+func TestOperatorCommandRefusesBeforeDatabaseOpen(t *testing.T) {
+ for _, mode := range []string{"signer", "missing-material", "unknown-material", "wrong-role"} {
+ t.Run(mode, func(t *testing.T) {
+ f := newCommandFixture(t, "credential")
+ if mode == "signer" {
+ var err error
+ f.signer, err = family.Generate()
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
+ for i, value := range f.env {
+ if strings.HasPrefix(value, "HOME_OPERATOR_MATERIAL=") {
+ switch mode {
+ case "missing-material":
+ f.env[i] = "HOME_OPERATOR_MATERIAL="
+ case "unknown-material":
+ f.env[i] = strings.Replace(value, `"version":1`, `"version":1,"unknown":true`, 1)
+ }
+ }
+ if mode == "wrong-role" && strings.HasPrefix(value, "HOME_ROLE=") {
+ f.env[i] = "HOME_ROLE=directory"
+ }
+ }
+ raw := f.request(t, "credential.owner-seed", operator.OwnerInput{InputBase: operator.InputBase{Version: 1, Operation: "credential.owner-seed", OperationID: opToken(10)}, Directory: "https://home.example.test", TeamID: 1, TeamSQID: "Uk", Address: "synthetic@example.invalid"}, 1, "", 10)
+ out, code, opened := f.run(t, raw)
+ if code != 2 || len(out) != 0 || opened {
+ t.Fatal("refusal boundary failed", code, opened)
+ }
+ })
+ }
+}
+
+func TestOperatorCommandDirectoryInspectAndSeed(t *testing.T) {
+ f := newCommandFixture(t, "directory")
+ now := time.Now().Unix()
+ challenge := operator.InspectChallenge{Version: 1, Purpose: "operator-inspect", Role: "directory", Origin: f.pin.Origin, Installation: f.pin.Installation, Registry: f.pin.Registry, Device: f.pin.Device, Challenge: opToken(45), Issued: now, Expires: now + 300}
+ inspect, err := operator.SignInspect(f.signer, challenge)
+ if err != nil {
+ t.Fatal(err)
+ }
+ out, code, opened := f.run(t, inspect)
+ if code != 0 || !opened {
+ t.Fatal("inspect failed", code)
+ }
+ var observation operator.Inspection
+ if stdjson.Unmarshal(out, &observation) != nil || observation.Generation != nil || observation.TeamID != 1 || observation.TeamSQID != "Uk" {
+ t.Fatal("wrong initial inspection")
+ }
+ if commandRowCount(t, f.path, "directory_operator_state") != 0 || commandRowCount(t, f.path, "directory_operator_results") != 0 {
+ t.Fatal("inspect changed operator state")
+ }
+ generation := opToken(40)
+ initialize := f.request(t, "directory.initialize", operator.InitializeInput{InputBase: operator.InputBase{Version: 1, Operation: "directory.initialize", OperationID: opToken(10)}, Generation: generation, Baseline: "operator-attests-never-provisioned"}, 1, "", 10)
+ out, code, _ = f.run(t, initialize)
+ if code != 0 {
+ t.Fatal("initialize failed", code)
+ }
+ v, err := operator.NewVerifier(f.pin)
+ if err != nil {
+ t.Fatal(err)
+ }
+ request, err := v.Verify(initialize)
+ if err != nil {
+ t.Fatal(err)
+ }
+ receipt, err := operator.SignOwner(f.owner, operator.OwnerReceipt{Version: 1, Purpose: "owner-seed", OperationID: opToken(20), Issuer: "https://id.example.test", Directory: f.pin.Origin, TeamID: 1, TeamSQID: "Uk", PersonRef: "pr_aaaaaaaaaaaaaaaaaaaaaaaaaa", Role: "owner", Issued: now, Expires: now + 300})
+ if err != nil {
+ t.Fatal(err)
+ }
+ seed := f.request(t, "directory.seed-team", operator.SeedInput{InputBase: operator.InputBase{Version: 1, Operation: "directory.seed-team", OperationID: opToken(11)}, Generation: generation, TeamID: 1, TeamSQID: "Uk", Label: "test-command", Name: "private-command-team", Receipt: receipt}, 2, request.Head(), 11)
+ out, code, _ = f.run(t, seed)
+ if code != 0 {
+ t.Fatal("team seed failed", code)
+ }
+ again, code, _ := f.run(t, seed)
+ if code != 0 || !bytes.Equal(out, again) {
+ t.Fatal("team replay failed", code)
+ }
+ if commandRowCount(t, f.path, "teams") != 1 || commandRowCount(t, f.path, "memberships") != 1 || commandRowCount(t, f.path, "directory_operator_results") != 2 {
+ t.Fatal("wrong seed state")
+ }
+ out, code, _ = f.run(t, inspect)
+ if code != 0 || stdjson.Unmarshal(out, &observation) != nil || observation.Generation == nil || *observation.Generation != generation || observation.TeamID != 2 || observation.TeamSQID != "gb" {
+ t.Fatal("inspect after seed failed", code)
+ }
+ if commandRowCount(t, f.path, "directory_operator_results") != 2 {
+ t.Fatal("inspect persisted result")
+ }
+ for _, suffix := range []string{"", "-wal"} {
+ disk, err := os.ReadFile(f.path + suffix)
+ if err != nil && !errors.Is(err, os.ErrNotExist) {
+ t.Fatal(err)
+ }
+ if bytes.Contains(disk, []byte("private-command-team")) || bytes.Contains(disk, receipt) {
+ t.Fatal("private seed data stored in plaintext")
+ }
+ }
+}
+
+func TestOperatorCommandOutputFailureRecoversCommittedResult(t *testing.T) {
+ for _, mode := range []string{"closed-pipe", "regular-file"} {
+ t.Run(mode, func(t *testing.T) {
+ f := newCommandFixture(t, "credential")
+ f.outputMode = mode
+ raw := f.request(t, "credential.owner-seed", operator.OwnerInput{InputBase: operator.InputBase{Version: 1, Operation: "credential.owner-seed", OperationID: opToken(10)}, Directory: "https://home.example.test", TeamID: 1, TeamSQID: "Uk", Address: "output-private@example.invalid"}, 1, "", 10)
+ out, code, opened := f.run(t, raw)
+ want := 3
+ if mode == "regular-file" {
+ want = 2
+ }
+ if code != want || len(out) != 0 || opened != (mode == "closed-pipe") {
+ t.Fatal("output refusal failed", code, opened)
+ }
+ count := commandRowCount(t, f.path, "credential_operator_results")
+ if (mode == "closed-pipe" && count != 1) || (mode == "regular-file" && count != 0) {
+ t.Fatal("wrong commit state", count)
+ }
+ f.outputMode = ""
+ out, code, _ = f.run(t, raw)
+ if code != 0 || len(out) == 0 {
+ t.Fatal("recovery failed", code)
+ }
+ again, code, _ := f.run(t, raw)
+ if code != 0 || !bytes.Equal(out, again) || commandRowCount(t, f.path, "people") != 1 {
+ t.Fatal("recovery changed identity or result", code)
+ }
+ })
+ }
+}
+
+func TestOperatorCommandSchemaRefusalMakesNoIdentity(t *testing.T) {
+ for _, statement := range []string{"DELETE FROM rastrillo_migrations WHERE id=(SELECT id FROM rastrillo_migrations LIMIT 1)", "CREATE TRIGGER unexpected AFTER INSERT ON people BEGIN SELECT 1; END", "CREATE TABLE unexpected(value TEXT)"} {
+ t.Run(statement, func(t *testing.T) {
+ f := newCommandFixture(t, "credential")
+ db, err := sql.Open("sqlite", f.path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err = db.Exec(statement); err != nil {
+ t.Fatal(err)
+ }
+ if err = db.Close(); err != nil {
+ t.Fatal(err)
+ }
+ raw := f.request(t, "credential.owner-seed", operator.OwnerInput{InputBase: operator.InputBase{Version: 1, Operation: "credential.owner-seed", OperationID: opToken(10)}, Directory: "https://home.example.test", TeamID: 1, TeamSQID: "Uk", Address: "schema-private@example.invalid"}, 1, "", 10)
+ out, code, opened := f.run(t, raw)
+ if code != 2 || !opened || len(out) != 0 {
+ t.Fatal("schema refusal failed", code, opened)
+ }
+ if commandRowCount(t, f.path, "people") != 0 || commandRowCount(t, f.path, "credential_operator_results") != 0 {
+ t.Fatal("schema refusal wrote identity")
+ }
+ })
+ }
+}
+
+func TestOperatorCommandActualBinaryDispatch(t *testing.T) {
+ binary := filepath.Join(t.TempDir(), "home")
+ build := exec.CommandContext(t.Context(), "go", "build", "-o", binary, "../../cmd/home")
+ if out, err := build.CombinedOutput(); err != nil {
+ t.Fatalf("build failed: %v %s", err, out)
+ }
+ for _, args := range [][]string{{"operator-v1"}, {"operator-v1", "--db"}, {"operator-v1", "--db", "relative.db"}, {"operator-v1", "--db", "/tmp/example", "--extra"}} {
+ cmd := exec.CommandContext(t.Context(), binary, args...)
+ cmd.Env = []string{}
+ out, err := cmd.CombinedOutput()
+ var exited *exec.ExitError
+ if !errors.As(err, &exited) || exited.ExitCode() != 2 || len(out) != 0 {
+ t.Fatal("bad command argv was not silently refused", args, err)
+ }
+ }
+ f := newCommandFixture(t, "credential")
+ f.executable = binary
+ f.args = []string{"operator-v1", "--db", f.path}
+ image, err := os.ReadFile(binary)
+ if err != nil {
+ t.Fatal(err)
+ }
+ sum := sha256.Sum256(image)
+ f.imageHash = hex.EncodeToString(sum[:])
+ raw := f.request(t, "credential.owner-seed", operator.OwnerInput{InputBase: operator.InputBase{Version: 1, Operation: "credential.owner-seed", OperationID: opToken(10)}, Directory: "https://home.example.test", TeamID: 1, TeamSQID: "Uk", Address: "actual-binary-private@example.invalid"}, 1, "", 10)
+ out, code, opened := f.run(t, raw)
+ if code != 0 || !opened || len(out) == 0 || commandRowCount(t, f.path, "people") != 1 {
+ t.Fatal("real binary dispatch failed", code, opened)
+ }
+}
+
+func TestOperatorCommandKeyAndScopeCollisionsRefuseBeforeOpen(t *testing.T) {
+ for _, mode := range []string{"mac-dek", "zero-mac", "owner-browser", "owner-authorizer", "pinned-origin", "pinned-role"} {
+ t.Run(mode, func(t *testing.T) {
+ f := newCommandFixture(t, "credential")
+ vars := map[string]string{}
+ for _, value := range f.env {
+ name, value, _ := strings.Cut(value, "=")
+ vars[name] = value
+ }
+ var material, config map[string]any
+ if stdjson.Unmarshal([]byte(vars["HOME_OPERATOR_MATERIAL"]), &material) != nil || stdjson.Unmarshal([]byte(vars["HOME_OPERATOR_COMMAND"]), &config) != nil {
+ t.Fatal("fixture JSON")
+ }
+ switch mode {
+ case "mac-dek":
+ material["mac_key"] = vars["HOME_DEK"]
+ case "zero-mac":
+ material["mac_key"] = base64.StdEncoding.EncodeToString(make([]byte, 32))
+ case "owner-browser":
+ material["owner_seed_key"] = vars["HOME_ASSERTION_KEY"]
+ case "owner-authorizer":
+ config["public_key"] = base64.RawURLEncoding.EncodeToString(f.owner.SignPub())
+ config["key_id"] = rosterKeyID(f.owner)
+ f.signer = f.owner
+ case "pinned-origin":
+ config["origin"] = "https://other.example.test"
+ case "pinned-role":
+ config["role"] = "directory"
+ config["actions"] = []string{"directory.initialize"}
+ }
+ raw, _ := stdjson.Marshal(material)
+ vars["HOME_OPERATOR_MATERIAL"] = string(raw)
+ raw, _ = stdjson.Marshal(config)
+ vars["HOME_OPERATOR_COMMAND"] = string(raw)
+ f.env = nil
+ for name, value := range vars {
+ f.env = append(f.env, name+"="+value)
+ }
+ input := f.request(t, "credential.owner-seed", operator.OwnerInput{InputBase: operator.InputBase{Version: 1, Operation: "credential.owner-seed", OperationID: opToken(10)}, Directory: "https://home.example.test", TeamID: 1, TeamSQID: "Uk", Address: "collision-private@example.invalid"}, 1, "", 10)
+ out, code, opened := f.run(t, input)
+ if code != 2 || opened || len(out) != 0 {
+ t.Fatal("key/scope collision accepted", code, opened)
+ }
+ })
+ }
+}
diff --git a/internal/home/operator_command_other.go b/internal/home/operator_command_other.go
new file mode 100644
index 0000000..2a75cae
--- /dev/null
+++ b/internal/home/operator_command_other.go
@@ -0,0 +1,7 @@
+//go:build !linux
+
+package home
+
+import "context"
+
+func RunOperatorCommand(context.Context, string) int { return 3 }
diff --git a/internal/home/operator_schema_linux.go b/internal/home/operator_schema_linux.go
new file mode 100644
index 0000000..2f07a11
--- /dev/null
+++ b/internal/home/operator_schema_linux.go
@@ -0,0 +1,32 @@
+//go:build linux
+
+package home
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+
+ "amadan.net/oficina/home/internal/operator"
+)
+
+func operatorSchemaCheck(role string) (func(context.Context, *sql.Conn) error, error) {
+ set := compatibilitySchema(role)
+ if set == nil {
+ return nil, operator.ErrRefused
+ }
+ raw, err := compatibilityProfiles.ReadFile("compatibility_profiles/" + role + ".json")
+ if err != nil {
+ return nil, operator.ErrUnavailable
+ }
+ var profile []schemaObject
+ if json.Unmarshal(raw, &profile) != nil || len(profile) == 0 {
+ return nil, operator.ErrUnavailable
+ }
+ return func(ctx context.Context, conn *sql.Conn) error {
+ if conn == nil {
+ return operator.ErrRefused
+ }
+ return checkCompatibilitySchema(ctx, conn, set, profile)
+ }, nil
+}
diff --git a/internal/home/operator_schema_linux_test.go b/internal/home/operator_schema_linux_test.go
new file mode 100644
index 0000000..2d308cf
--- /dev/null
+++ b/internal/home/operator_schema_linux_test.go
@@ -0,0 +1,100 @@
+//go:build linux
+
+package home
+
+import (
+ "amadan.net/oficina/home/internal/operator"
+ "github.com/carlosframework/rastrillo/migrate"
+ "testing"
+)
+
+func TestOperatorSchemaMatchesSelectedRoleAndRejectsDrift(t *testing.T) {
+ for _, role := range []string{"credential", "directory"} {
+ for _, mode := range []string{"valid", "other-role", "missing-migration", "changed-checksum", "extra-trigger", "extra-table"} {
+ t.Run(role+"/"+mode, func(t *testing.T) {
+ check, err := operatorSchemaCheck(role)
+ if err != nil {
+ t.Fatal(err)
+ }
+ databaseRole := role
+ if mode == "other-role" {
+ if role == "credential" {
+ databaseRole = "directory"
+ } else {
+ databaseRole = "credential"
+ }
+ }
+ db := compatibilityFixture(t, databaseRole)
+ defer db.Close()
+ var sql string
+ switch mode {
+ case "missing-migration":
+ sql = "DELETE FROM rastrillo_migrations WHERE id=(SELECT id FROM rastrillo_migrations LIMIT 1)"
+ case "changed-checksum":
+ sql = "UPDATE rastrillo_migrations SET checksum='wrong' WHERE id=(SELECT id FROM rastrillo_migrations LIMIT 1)"
+ case "extra-trigger":
+ sql = "CREATE TRIGGER unexpected AFTER DELETE ON rastrillo_migrations BEGIN SELECT 1; END"
+ case "extra-table":
+ sql = "CREATE TABLE unexpected(value TEXT)"
+ }
+ if sql != "" {
+ if _, err := db.Exec(sql); err != nil {
+ t.Fatal(err)
+ }
+ }
+ conn, err := db.Conn(t.Context())
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer conn.Close()
+ if _, err := conn.ExecContext(t.Context(), "BEGIN IMMEDIATE"); err != nil {
+ t.Fatal(err)
+ }
+ err = check(t.Context(), conn)
+ if mode == "valid" && err != nil {
+ t.Fatal(err)
+ }
+ if mode != "valid" && err != operator.ErrRefused {
+ t.Fatal("schema drift accepted", err)
+ }
+ if _, err := conn.ExecContext(t.Context(), "ROLLBACK"); err != nil {
+ t.Fatal(err)
+ }
+ })
+ }
+ }
+ if _, err := operatorSchemaCheck(""); err != operator.ErrRefused {
+ t.Fatal("missing role accepted", err)
+ }
+}
+
+func TestRuntimeOperatorSchemasAreAdditiveAndRoleSpecific(t *testing.T) {
+ for _, role := range []string{"", "credential", "directory"} {
+ t.Run(role, func(t *testing.T) {
+ cfg := Runtime{role: role}
+ db, err := migrate.Replay(t.Context(), cfg.Schema().All())
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer db.Close()
+ tables := map[string]bool{}
+ rows, err := db.Query("SELECT name FROM sqlite_schema WHERE type='table'")
+ if err != nil {
+ t.Fatal(err)
+ }
+ for rows.Next() {
+ var name string
+ if rows.Scan(&name) != nil {
+ t.Fatal("schema read")
+ }
+ tables[name] = true
+ }
+ if rows.Err() != nil || rows.Close() != nil {
+ t.Fatal("schema scan")
+ }
+ if tables["credential_operator_results"] != (role == "credential") || tables["directory_operator_results"] != (role == "directory") || tables["people"] == (role == "directory") || tables["teams"] != (role == "directory") {
+ t.Fatal("role schema crossed boundary")
+ }
+ })
+ }
+}
diff --git a/internal/home/roster_runtime.go b/internal/home/roster_runtime.go
index 87a1b8a..86e91c2 100644
--- a/internal/home/roster_runtime.go
+++ b/internal/home/roster_runtime.go
@@ -72,34 +72,45 @@ func loadRosterRuntime(role string, credentialKeys map[string][]byte) (rosterRun
if mode != "serve" || !keySet || len(encoded) > 4096 {
return rosterRuntime{}, errExchangeConfig
}
+ key, err := decodeHomeKeypair(encoded)
+ if err != nil {
+ return rosterRuntime{}, err
+ }
+ cfg := rosterRuntime{mode: mode, signer: key, keyID: rosterKeyID(key)}
+ if !cfg.valid(role, credentialKeys) {
+ return rosterRuntime{}, errExchangeConfig
+ }
+ return cfg, nil
+}
+
+func decodeHomeKeypair(encoded string) (*family.Keypair, error) {
+ if len(encoded) == 0 || len(encoded) > 4096 {
+ return nil, errExchangeConfig
+ }
raw, err := base64.StdEncoding.Strict().DecodeString(encoded)
if err != nil || base64.StdEncoding.EncodeToString(raw) != encoded {
- return rosterRuntime{}, errExchangeConfig
+ return nil, errExchangeConfig
}
var fields struct {
Sign string `json:"sign_priv_d"`
Box string `json:"box_priv_d"`
}
if json.Unmarshal(raw, &fields, json.RejectUnknownMembers(true)) != nil {
- return rosterRuntime{}, errExchangeConfig
+ return nil, errExchangeConfig
}
for _, s := range []string{fields.Sign, fields.Box} {
b, e := hex.DecodeString(s)
if e != nil || len(b) != 32 || hex.EncodeToString(b) != s {
- return rosterRuntime{}, errExchangeConfig
+ return nil, errExchangeConfig
}
d := new(big.Int).SetBytes(b)
if d.Sign() <= 0 || d.Cmp(elliptic.P256().Params().N) >= 0 {
- return rosterRuntime{}, errExchangeConfig
+ return nil, errExchangeConfig
}
}
key, err := family.UnmarshalKeypair(raw)
if err != nil || !validRosterKey(key) {
- return rosterRuntime{}, errExchangeConfig
+ return nil, errExchangeConfig
}
- cfg := rosterRuntime{mode: mode, signer: key, keyID: rosterKeyID(key)}
- if !cfg.valid(role, credentialKeys) {
- return rosterRuntime{}, errExchangeConfig
- }
- return cfg, nil
+ return key, nil
}
diff --git a/internal/home/runtime.go b/internal/home/runtime.go
index 9bff597..ba89464 100644
--- a/internal/home/runtime.go
+++ b/internal/home/runtime.go
@@ -1,6 +1,7 @@
package home
import (
+ "context"
"crypto/elliptic"
"crypto/sha256"
"encoding/base64"
@@ -8,9 +9,11 @@ import (
"errors"
"log/slog"
"os"
+ "time"
"amadan.net/oficina/home/handoff"
"amadan.net/oficina/home/internal/home/directorydata"
+ "amadan.net/oficina/home/internal/operator"
"amadan.net/rastrillo/rastrillo/assertion"
assertioncrypto "amadan.net/rastrillo/rastrillo/crypto"
"github.com/carlosframework/rastrillo/db"
@@ -162,11 +165,28 @@ func consumerName(s string) bool {
}
func (cfg Runtime) Schema() *migrate.Set {
if cfg.role == "directory" {
- return directorySchema
+ return migrate.Merge(directorySchema, directorydata.OperatorSchema, operator.DirectorySchema)
+ }
+ if cfg.role == "credential" {
+ return migrate.Merge(BootSchema, operator.CredentialSchema)
}
return BootSchema
}
func NewRuntime(cfg Runtime, d *db.DB, logger *slog.Logger) (*App, error) {
+ if cfg.role == "credential" || cfg.role == "directory" {
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ conn, err := d.Writer().Conn(ctx)
+ if err != nil {
+ return nil, errExchangeConfig
+ }
+ ledgerErr := operator.VerifyLedger(ctx, conn, cfg.Schema())
+ closeErr := conn.Close()
+ if ledgerErr != nil || closeErr != nil {
+ return nil, errExchangeConfig
+ }
+ }
+
if !cfg.roster.valid(cfg.role, cfg.keys) || !cfg.validAppPreviews() {
return nil, errExchangeConfig
}
diff --git a/internal/home/runtime_test.go b/internal/home/runtime_test.go
index 7f2fefa..ee3baa6 100644
--- a/internal/home/runtime_test.go
+++ b/internal/home/runtime_test.go
@@ -57,7 +57,7 @@ func TestDirectoryRuntimeNeedsNoCredentialMaterial(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- if cfg.signer != nil || len(cfg.secrets.Pepper) != 0 || cfg.secrets.Mailer != nil || cfg.Schema() != directorySchema {
+ if cfg.signer != nil || len(cfg.secrets.Pepper) != 0 || cfg.secrets.Mailer != nil {
t.Fatal("directory holds credentials")
}
t.Setenv("HOME_ASSERTION_KEY", "present")
diff --git a/internal/operator/command_linux.go b/internal/operator/command_linux.go
index a632c4e..78f6217 100644
--- a/internal/operator/command_linux.go
+++ b/internal/operator/command_linux.go
@@ -51,6 +51,7 @@ type commandInvocation struct {
raw []byte
ctx context.Context
cancel context.CancelFunc
+ inspect bool
}
func openCommandInvocation(parent context.Context, database string) (*commandInvocation, error) {
@@ -58,6 +59,10 @@ func openCommandInvocation(parent context.Context, database string) (*commandInv
if err != nil {
return nil, ErrRefused
}
+ return openConfiguredInvocation(parent, database, c, verifier)
+}
+
+func openConfiguredInvocation(parent context.Context, database string, c commandConfig, verifier *Verifier) (*commandInvocation, error) {
launch, raw, err := operatorlaunch.ReadOperator(parent, database)
if err != nil {
return nil, ErrRefused
@@ -72,8 +77,15 @@ func openCommandInvocation(parent context.Context, database string) (*commandInv
return nil, ErrRefused
}
request, err := verifier.Verify(raw)
+ inspect := false
if err != nil {
- return nil, ErrRefused
+ if !verifier.actions["directory.inspect"] {
+ return nil, ErrRefused
+ }
+ if _, err := verifier.verifyInspect(raw, time.Now()); err != nil {
+ return nil, ErrRefused
+ }
+ inspect = true
}
defer func() {
if !keep {
@@ -97,7 +109,7 @@ func openCommandInvocation(parent context.Context, database string) (*commandInv
return nil, ErrRefused
}
keep = true
- return &commandInvocation{db, verifier, request, launch, raw, ctx, cancel}, nil
+ return &commandInvocation{db, verifier, request, launch, raw, ctx, cancel, inspect}, nil
}
func (i *commandInvocation) close() error {
diff --git a/internal/operator/engine.go b/internal/operator/engine.go
index 7311358..20986f4 100644
--- a/internal/operator/engine.go
+++ b/internal/operator/engine.go
@@ -39,19 +39,20 @@ type Sealer interface {
type Mutation func(context.Context, *sql.Conn, Request) ([]byte, error)
type Engine struct {
- db *sql.DB
- verifier *Verifier
- sealer Sealer
- macKey []byte
- prefix string
- now func() time.Time
+ db *sql.DB
+ verifier *Verifier
+ sealer Sealer
+ macKey []byte
+ prefix string
+ now func() time.Time
+ checkSchema func(context.Context, *sql.Conn) error
}
func NewEngine(db *sql.DB, v *Verifier, sealer Sealer, macKey []byte, now func() time.Time) (*Engine, error) {
if db == nil || v == nil || sealer == nil || len(macKey) != 32 || now == nil {
return nil, ErrRefused
}
- return &Engine{db, v, sealer, append([]byte(nil), macKey...), v.pins.Role + "_operator", now}, nil
+ return &Engine{db, v, sealer, append([]byte(nil), macKey...), v.pins.Role + "_operator", now, nil}, nil
}
func (e *Engine) purpose(a Authorization) string {
return "operator.result/v1/" + a.Origin + "/" + a.OperationID + "/" + a.Action
@@ -114,6 +115,11 @@ func (e *Engine) Execute(ctx context.Context, raw []byte, mutate Mutation) ([]by
}
}
}()
+ if e.checkSchema != nil {
+ if err := e.checkSchema(ctx, conn); err != nil {
+ return nil, err
+ }
+ }
var storedOrigin, storedInstallation, storedRegistry, storedDevice, storedHead, storedKey string
var storedSequence, storedCompleted int64
stateErr := conn.QueryRowContext(ctx, "SELECT origin,installation,registry,device,sequence,head,completed_at,key_id FROM "+e.prefix+"_state WHERE singleton=1").Scan(&storedOrigin, &storedInstallation, &storedRegistry, &storedDevice, &storedSequence, &storedHead, &storedCompleted, &storedKey)
diff --git a/internal/operator/engine_test.go b/internal/operator/engine_test.go
index d6f70b5..cb09c53 100644
--- a/internal/operator/engine_test.go
+++ b/internal/operator/engine_test.go
@@ -217,3 +217,48 @@ func TestEngineRefusesExpiredNewAndCorruptExistingResult(t *testing.T) {
t.Fatal("corrupt result", e)
}
}
+
+func TestEngineCommandSchemaCheckRunsInsideTransactionBeforeReplayOrMutation(t *testing.T) {
+ f := newEngineFixture(t)
+ ctx := t.Context()
+ var checked *sql.Conn
+ f.engine.checkSchema = func(ctx context.Context, conn *sql.Conn) error {
+ checked = conn
+ if _, err := conn.ExecContext(ctx, "BEGIN IMMEDIATE"); err == nil {
+ t.Fatal("schema check outside transaction")
+ }
+ return nil
+ }
+ mutate := func(ctx context.Context, conn *sql.Conn, r Request) ([]byte, error) {
+ if conn != checked {
+ t.Fatal("schema checked a different connection")
+ }
+ return insertOne(ctx, conn, r)
+ }
+ if _, err := f.engine.Execute(ctx, f.raw, mutate); err != nil {
+ t.Fatal(err)
+ }
+ f.engine.checkSchema = func(context.Context, *sql.Conn) error { return ErrRefused }
+ if out, err := f.engine.Execute(ctx, f.raw, mutate); err != ErrRefused || len(out) != 0 {
+ t.Fatal("replay bypassed schema refusal", err)
+ }
+ if countRows(t, f.db, "test_items") != 1 || countRows(t, f.db, "directory_operator_results") != 1 {
+ t.Fatal("schema refusal changed committed data")
+ }
+ fresh := newEngineFixture(t)
+ fresh.engine.checkSchema = func(ctx context.Context, conn *sql.Conn) error {
+ if _, err := conn.ExecContext(ctx, "INSERT INTO test_items VALUES(2)"); err != nil {
+ t.Fatal(err)
+ }
+ return ErrUnavailable
+ }
+ if out, err := fresh.engine.Execute(ctx, fresh.raw, func(context.Context, *sql.Conn, Request) ([]byte, error) {
+ t.Fatal("mutation ran after failed schema check")
+ return nil, nil
+ }); err != ErrUnavailable || len(out) != 0 {
+ t.Fatal("schema failure accepted", err)
+ }
+ if countRows(t, fresh.db, "test_items") != 0 || countRows(t, fresh.db, "directory_operator_state") != 0 || countRows(t, fresh.db, "directory_operator_results") != 0 {
+ t.Fatal("failed preflight transaction survived")
+ }
+}
diff --git a/internal/operator/inspect.go b/internal/operator/inspect.go
index 14acf01..37fc25a 100644
--- a/internal/operator/inspect.go
+++ b/internal/operator/inspect.go
@@ -89,6 +89,18 @@ func (d *Directory) Inspect(ctx context.Context, raw []byte) ([]byte, error) {
return nil, ErrUnavailable
}
defer c.ExecContext(context.Background(), "ROLLBACK")
+ if e.checkSchema != nil {
+ if _, err := c.ExecContext(ctx, "PRAGMA query_only=ON"); err != nil {
+ return nil, ErrUnavailable
+ }
+ var readonly int
+ if c.QueryRowContext(ctx, "PRAGMA query_only").Scan(&readonly) != nil || readonly != 1 {
+ return nil, ErrUnavailable
+ }
+ if err := e.checkSchema(ctx, c); err != nil {
+ return nil, err
+ }
+ }
var count int
if err = c.QueryRowContext(ctx, "SELECT count(*) FROM directory_operator_state WHERE singleton=1 AND (origin!=? OR installation!=? OR registry!=? OR device!=? OR key_id!=? OR completed_at>?)", e.verifier.pins.Origin, e.verifier.pins.Installation, e.verifier.pins.Registry, e.verifier.pins.Device, e.verifier.kid, e.now().Unix()).Scan(&count); err != nil {
return nil, ErrUnavailable
diff --git a/internal/operator/ledger.go b/internal/operator/ledger.go
new file mode 100644
index 0000000..57deba5
--- /dev/null
+++ b/internal/operator/ledger.go
@@ -0,0 +1,36 @@
+package operator
+
+import (
+ "context"
+ "database/sql"
+ "github.com/carlosframework/rastrillo/migrate"
+)
+
+func VerifyLedger(ctx context.Context, c *sql.Conn, set *migrate.Set) error {
+ if c == nil || set == nil {
+ return ErrRefused
+ }
+ expected := map[string]string{}
+ for _, m := range set.All() {
+ if m.Fn != nil {
+ return ErrRefused
+ }
+ expected[m.ID] = migrate.Checksum(m.SQL)
+ }
+ rows, e := c.QueryContext(ctx, "SELECT id,checksum FROM rastrillo_migrations LIMIT 1001")
+ if e != nil {
+ return ErrRefused
+ }
+ for rows.Next() {
+ var id, sum string
+ if rows.Scan(&id, &sum) != nil || expected[id] == "" || expected[id] != sum {
+ rows.Close()
+ return ErrRefused
+ }
+ delete(expected, id)
+ }
+ if rows.Err() != nil || rows.Close() != nil || len(expected) != 0 {
+ return ErrRefused
+ }
+ return nil
+}
diff --git a/internal/operator/protocol.go b/internal/operator/protocol.go
index 37e1703..c56c1d6 100644
--- a/internal/operator/protocol.go
+++ b/internal/operator/protocol.go
@@ -133,7 +133,7 @@ func allowedAction(role, a string) bool {
}
if role == "directory" {
switch a {
- case "directory.initialize", "directory.seed-team", "directory.provision-consumer", "directory.verify-consumer", "directory.activate-consumer":
+ case "directory.inspect", "directory.initialize", "directory.seed-team", "directory.provision-consumer", "directory.verify-consumer", "directory.activate-consumer":
return true
}
}
diff --git a/internal/operator/run_linux.go b/internal/operator/run_linux.go
new file mode 100644
index 0000000..a3bea2d
--- /dev/null
+++ b/internal/operator/run_linux.go
@@ -0,0 +1,140 @@
+//go:build linux
+
+package operator
+
+import (
+ "bytes"
+ "context"
+ "database/sql"
+ "errors"
+ "golang.org/x/sys/unix"
+ "os"
+ "time"
+)
+
+type CommandExecutor interface {
+ Execute(context.Context, []byte) ([]byte, error)
+}
+type CommandMaterial struct {
+ Role, Origin string
+ Sealer Sealer
+ MAC []byte
+ OtherKeys [][]byte
+ CheckSchema func(context.Context, *sql.Conn) error
+ Build func(*Engine) (CommandExecutor, error)
+}
+
+// RunCommand verifies configuration, private launch transport and signature
+// before exposing the bound database to the role executor.
+func RunCommand(ctx context.Context, database string, material CommandMaterial) (result *CommandResult, err error) {
+ var out []byte
+ if ctx == nil || material.CheckSchema == nil || material.Build == nil || material.Sealer == nil || len(material.MAC) != 32 || !commandOutputPipe() {
+ return nil, ErrRefused
+ }
+ cfg, verifier, err := parseCommandConfig(os.Getenv(commandConfigEnvironment))
+ if err != nil || material.Role != cfg.Role || material.Origin != cfg.Origin {
+ return nil, ErrRefused
+ }
+ for _, key := range material.OtherKeys {
+ if bytes.Equal(key, verifier.key) {
+ return nil, ErrRefused
+ }
+ }
+ invocation, err := openConfiguredInvocation(ctx, database, cfg, verifier)
+ if err != nil {
+ return nil, err
+ }
+ defer func() {
+ if closeErr := invocation.close(); closeErr != nil {
+ err = ErrUnavailable
+ }
+ if err != nil {
+ clear(out)
+ out = nil
+ result = nil
+ }
+ }()
+ engine, err := NewEngine(invocation.database.db, verifier, material.Sealer, material.MAC, time.Now)
+ if err != nil {
+ return nil, err
+ }
+ engine.checkSchema = material.CheckSchema
+ executor, err := material.Build(engine)
+ if err != nil || executor == nil {
+ return nil, ErrRefused
+ }
+ if invocation.inspect {
+ directory, ok := executor.(*Directory)
+ if !ok {
+ return nil, ErrRefused
+ }
+ out, err = directory.Inspect(invocation.ctx, invocation.raw)
+ } else {
+ out, err = executor.Execute(invocation.ctx, invocation.raw)
+ }
+ if err != nil {
+ return nil, err
+ }
+ if len(out) == 0 || len(out) > InputLimit {
+ return nil, ErrUnavailable
+ }
+ deadline, _ := invocation.ctx.Deadline()
+ return &CommandResult{out, deadline}, nil
+}
+
+type CommandResult struct {
+ raw []byte
+ deadline time.Time
+}
+
+func (r *CommandResult) Write(ctx context.Context) error {
+ if r == nil {
+ return ErrRefused
+ }
+ defer func() { clear(r.raw); r.raw = nil }()
+ if ctx == nil || len(r.raw) == 0 || !time.Now().Before(r.deadline) {
+ return ErrUnavailable
+ }
+ if !commandOutputPipe() {
+ return ErrUnavailable
+ }
+ if unix.SetNonblock(1, true) != nil {
+ return ErrUnavailable
+ }
+ written := 0
+ for written < len(r.raw) {
+ if ctx.Err() != nil || !time.Now().Before(r.deadline) {
+ return ErrUnavailable
+ }
+ n, err := unix.Write(1, r.raw[written:])
+ if n > 0 {
+ written += n
+ }
+ if errors.Is(err, unix.EINTR) {
+ continue
+ }
+ if errors.Is(err, unix.EAGAIN) {
+ timer := time.NewTimer(time.Millisecond)
+ select {
+ case <-ctx.Done():
+ timer.Stop()
+ return ErrUnavailable
+ case <-timer.C:
+ }
+ continue
+ }
+ if err != nil || n == 0 {
+ return ErrUnavailable
+ }
+ }
+ if unix.Close(1) != nil || ctx.Err() != nil || !time.Now().Before(r.deadline) {
+ return ErrUnavailable
+ }
+ return nil
+}
+
+func commandOutputPipe() bool {
+ var st unix.Stat_t
+ flags, err := unix.FcntlInt(1, unix.F_GETFL, 0)
+ return err == nil && flags&unix.O_ACCMODE == unix.O_WRONLY && unix.Fstat(1, &st) == nil && st.Mode&unix.S_IFMT == unix.S_IFIFO
+}