Wire private Home operator command to verified role transactions
19 files changed,
+1198
−45
cmd/home/main.go+6 −0implementation/operator-command/proposal.json+14 −0internal/home/compatibility_linux.go+3 −22internal/home/exchange_test.go+1 −1internal/home/operator_command_linux.go+234 −0internal/home/operator_command_linux_test.go+497 −0internal/home/operator_command_other.go+7 −0internal/home/operator_schema_linux.go+32 −0internal/home/operator_schema_linux_test.go+100 −0internal/home/roster_runtime.go+21 −10internal/home/runtime.go+21 −1internal/home/runtime_test.go+1 −1internal/operator/command_linux.go+14 −2internal/operator/engine.go+13 −7internal/operator/engine_test.go+45 −0internal/operator/inspect.go+12 −0internal/operator/ledger.go+36 −0internal/operator/protocol.go+1 −1internal/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 |
| +} |