Merge branch 'feat/checker-component' into main
Merge reviewed unmounted Home compatibility component
6 files changed,
+1723
−0
internal/home/compatibility_linux.go+362 −0internal/home/compatibility_linux_test.go+262 −0internal/home/compatibility_profiles/credential.json+158 −0internal/home/compatibility_profiles/directory.json+350 −0internal/operator/readonly_linux.go+430 −0internal/operator/readonly_linux_test.go+161 −0
CI passed — run details
diff --git a/internal/home/compatibility_linux.go b/internal/home/compatibility_linux.go| new file mode 100644 |
| index 0000000..469074e |
| --- /dev/null |
| +++ b/internal/home/compatibility_linux.go |
| @@ -0,0 +1,362 @@ |
| +//go:build linux |
| + |
| +package home |
| + |
| +import ( |
| + "context" |
| + "crypto/sha256" |
| + "database/sql" |
| + "embed" |
| + "encoding/base64" |
| + "encoding/hex" |
| + "encoding/json" |
| + "strconv" |
| + "strings" |
| + "unicode" |
| + "unicode/utf8" |
| + |
| + "amadan.net/oficina/home/handoff" |
| + "amadan.net/oficina/home/internal/home/directorydata" |
| + "amadan.net/oficina/home/internal/home/seal" |
| + "amadan.net/oficina/home/internal/operator" |
| + "github.com/carlosframework/rastrillo/migrate" |
| +) |
| + |
| +// This component has no command or serving entry point. Only the future verified |
| +// launch/enrollment adapter may supply these expectations. Synthetic tests are |
| +// currently the sole caller; a non-nil callback is not platform authorization. |
| +type compatibilityConfig struct { |
| + role, origin string |
| + dek, pepper, signin, mac []byte |
| + expectedKeys map[string]string |
| + state compatibilityState |
| + witness, generation string |
| + team int64 |
| + verify func(context.Context, operator.ReadonlyExpectation) error |
| +} |
| +type compatibilityState struct { |
| + Origin, Installation, Registry, Device, Head, KeyID string |
| + Sequence, Completed int64 |
| +} |
| +type schemaObject struct{ Type, Name, Table, SQL string } |
| + |
| +//go:embed compatibility_profiles/*.json |
| +var compatibilityProfiles embed.FS |
| + |
| +func compatibilitySchema(role string) *migrate.Set { |
| + if role == "credential" { |
| + return migrate.Merge(BootSchema, operator.CredentialSchema) |
| + } |
| + if role == "directory" { |
| + return migrate.Merge(directorySchema, directorydata.OperatorSchema, operator.DirectorySchema) |
| + } |
| + return nil |
| +} |
| + |
| +// inspectCompatibilityDatabase implements only the database portion of the |
| +// approved checker. Config-facts, launch parsing and enrollment are not mounted. |
| +// It conservatively accepts initialized operator state only; a reviewed caller |
| +// for the separately enrolled empty baseline does not exist yet. |
| +func inspectCompatibilityDatabase(ctx context.Context, expected operator.ReadonlyExpectation, cfg compatibilityConfig) error { |
| + schema := compatibilitySchema(cfg.role) |
| + if schema == nil || !ValidPersonRef(cfg.witness) || !directorydata.ValidOrigin(cfg.origin) || cfg.verify == nil || cfg.state.Origin != cfg.origin || cfg.state.Sequence <= 0 || cfg.state.Completed <= 0 || !canonicalHash(cfg.state.Head) || cfg.state.Installation == "" || cfg.state.Registry == "" || cfg.state.Device == "" || cfg.state.KeyID == "" { |
| + return operator.ErrRefused |
| + } |
| + keys := map[string][]byte{"dek": cfg.dek, "mac": cfg.mac} |
| + if cfg.role == "credential" { |
| + keys["pepper"] = cfg.pepper |
| + keys["signin"] = cfg.signin |
| + } else if len(cfg.pepper) != 0 || len(cfg.signin) != 0 { |
| + return operator.ErrRefused |
| + } |
| + if len(cfg.expectedKeys) != len(keys) { |
| + return operator.ErrRefused |
| + } |
| + for name, key := range keys { |
| + if len(key) != 32 { |
| + return operator.ErrRefused |
| + } |
| + h := sha256.Sum256(key) |
| + if cfg.expectedKeys[name] != hex.EncodeToString(h[:]) { |
| + return operator.ErrRefused |
| + } |
| + } |
| + sealer, e := seal.New(cfg.dek) |
| + if e != nil { |
| + return operator.ErrRefused |
| + } |
| + raw, e := compatibilityProfiles.ReadFile("compatibility_profiles/" + cfg.role + ".json") |
| + if e != nil { |
| + return operator.ErrRefused |
| + } |
| + var profile []schemaObject |
| + if json.Unmarshal(raw, &profile) != nil || len(profile) == 0 { |
| + return operator.ErrRefused |
| + } |
| + return operator.InspectReadonly(ctx, expected, cfg.verify, func(ctx context.Context, c *sql.Conn) error { |
| + if e := checkCompatibilitySchema(ctx, c, schema, profile); e != nil { |
| + return e |
| + } |
| + prefix := cfg.role + "_operator" |
| + var state compatibilityState |
| + if c.QueryRowContext(ctx, "SELECT origin,installation,registry,device,sequence,head,completed_at,key_id FROM "+prefix+"_state WHERE singleton=1").Scan(&state.Origin, &state.Installation, &state.Registry, &state.Device, &state.Sequence, &state.Head, &state.Completed, &state.KeyID) != nil || state != cfg.state { |
| + return operator.ErrRefused |
| + } |
| + budget := compatibilityBudget{} |
| + if cfg.role == "credential" { |
| + var ref string |
| + if c.QueryRowContext(ctx, "SELECT person_ref FROM people WHERE person_ref=?", cfg.witness).Scan(&ref) != nil { |
| + return operator.ErrRefused |
| + } |
| + if e := budget.scan(ctx, c, "SELECT person_ref,blind_index FROM people", func(rows *sql.Rows) error { |
| + var ref, index string |
| + if rows.Scan(&ref, &index) != nil || !ValidPersonRef(ref) || !hexHash(index) { |
| + return operator.ErrRefused |
| + } |
| + return nil |
| + }); e != nil { |
| + return e |
| + } |
| + } else { |
| + var generation, role string |
| + if !canonicalHash(cfg.generation) || cfg.team <= 0 || c.QueryRowContext(ctx, "SELECT generation FROM roster_generation WHERE singleton=1").Scan(&generation) != nil || generation != cfg.generation { |
| + return operator.ErrRefused |
| + } |
| + if c.QueryRowContext(ctx, "SELECT role FROM memberships WHERE team_id=? AND person_ref=?", cfg.team, cfg.witness).Scan(&role) != nil || role != "owner" { |
| + return operator.ErrRefused |
| + } |
| + } |
| + if e := budget.scan(ctx, c, "SELECT operation_id,action,CASE WHEN typeof(sealed_result)='blob' AND length(sealed_result)<=49180 THEN sealed_result END FROM "+prefix+"_results", func(rows *sql.Rows) error { |
| + var id, action string |
| + var ciphertext []byte |
| + if rows.Scan(&id, &action, &ciphertext) != nil || !canonicalHash(id) || len(action) == 0 || len(action) > 64 { |
| + return operator.ErrRefused |
| + } |
| + return budget.open(sealer, "operator.result/v1/"+state.Origin+"/"+id+"/"+action, ciphertext, 49180, func(raw []byte) bool { return len(raw) > 0 && json.Valid(raw) }) |
| + }); e != nil { |
| + return e |
| + } |
| + if cfg.role == "credential" { |
| + return budget.scan(ctx, c, "SELECT CASE WHEN typeof(sealed_address)='blob' AND length(sealed_address)<=4096 THEN sealed_address END FROM signin_links", func(rows *sql.Rows) error { |
| + var ciphertext []byte |
| + if rows.Scan(&ciphertext) != nil { |
| + return operator.ErrRefused |
| + } |
| + return budget.open(sealer, "signin.address", ciphertext, 4096, func(raw []byte) bool { return len(raw) > 0 && utf8.Valid(raw) }) |
| + }) |
| + } |
| + if e := budget.scan(ctx, c, "SELECT id,sqid,label,roster_epoch,CASE WHEN typeof(sealed_name)='blob' AND length(sealed_name)<=284 THEN sealed_name END FROM teams", func(rows *sql.Rows) error { |
| + var id, epoch int64 |
| + var sqid, label string |
| + var ciphertext []byte |
| + if rows.Scan(&id, &sqid, &label, &epoch, &ciphertext) != nil || id <= 0 || epoch <= 0 || !directorydata.ValidLabel(label) || len(sqid) == 0 || len(sqid) > 64 { |
| + return operator.ErrRefused |
| + } |
| + for _, r := range sqid { |
| + if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9') { |
| + return operator.ErrRefused |
| + } |
| + } |
| + return budget.open(sealer, "directory.team-name/"+strconv.FormatInt(id, 10), ciphertext, 284, func(raw []byte) bool { |
| + if len(raw) > 256 || !utf8.Valid(raw) || strings.TrimSpace(string(raw)) == "" { |
| + return false |
| + } |
| + for _, r := range string(raw) { |
| + if unicode.IsControl(r) { |
| + return false |
| + } |
| + } |
| + return true |
| + }) |
| + }); e != nil { |
| + return e |
| + } |
| + if e := budget.scan(ctx, c, "SELECT person_ref,role FROM memberships", func(rows *sql.Rows) error { |
| + var ref, role string |
| + if rows.Scan(&ref, &role) != nil || !ValidPersonRef(ref) || (role != "owner" && role != "member") { |
| + return operator.ErrRefused |
| + } |
| + return nil |
| + }); e != nil { |
| + return e |
| + } |
| + if e := budget.scan(ctx, c, "SELECT t.id,t.sqid,t.label,i.app,i.origin,i.consumer_id FROM team_instances i JOIN teams t ON t.id=i.team_id", func(rows *sql.Rows) error { |
| + var route directorydata.Route |
| + if rows.Scan(&route.TeamID, &route.TeamSQID, &route.Label, &route.App, &route.Origin, &route.ConsumerID) != nil || !directoryRouteAllowed(route) || !consumerName(route.ConsumerID) { |
| + return operator.ErrRefused |
| + } |
| + return nil |
| + }); e != nil { |
| + return e |
| + } |
| + for _, query := range []string{ |
| + "SELECT count(*) FROM roster_consumers r JOIN team_instances i ON i.consumer_id=r.consumer_id WHERE r.team_id!=i.team_id OR r.app!=i.app OR r.origin!=i.origin", |
| + "SELECT count(*) FROM directory_operator_consumer_records r JOIN roster_operator_receipts p ON p.operation_id=r.operation_id WHERE r.generation!=p.generation OR r.sequence!=p.sequence OR r.record_digest!=p.record_digest OR r.consumer_id!=p.consumer_id", |
| + } { |
| + var bad int |
| + if c.QueryRowContext(ctx, query).Scan(&bad) != nil || bad != 0 { |
| + return operator.ErrRefused |
| + } |
| + } |
| + for _, item := range []struct { |
| + query, purpose string |
| + limit int |
| + path bool |
| + }{ |
| + {"SELECT secret_hash,CASE WHEN typeof(sealed_path)='blob' AND length(sealed_path)<=8192 THEN sealed_path END FROM oficina_handoff_pending", "handoff.path.", 8192, true}, |
| + {"SELECT handle_hash,CASE WHEN typeof(sealed_token)='blob' AND length(sealed_token)<=16384 THEN sealed_token END FROM oficina_handoff_inbox", "handoff.token.", 16384, false}, |
| + } { |
| + if e := budget.scan(ctx, c, item.query, func(rows *sql.Rows) error { |
| + var hash string |
| + var ciphertext []byte |
| + if rows.Scan(&hash, &ciphertext) != nil || !hexHash(hash) { |
| + return operator.ErrRefused |
| + } |
| + return budget.open(sealer, item.purpose+hash, ciphertext, item.limit, func(raw []byte) bool { |
| + if !item.path { |
| + return len(raw) > 0 && len(raw) <= 12288 && utf8.Valid(raw) |
| + } |
| + path := strings.TrimPrefix(string(raw), "oficina-handoff-fresh-v1:") |
| + return handoff.SafePath(path) |
| + }) |
| + }); e != nil { |
| + return e |
| + } |
| + } |
| + return nil |
| + }) |
| +} |
| + |
| +type compatibilityBudget struct{ rows, bytes int } |
| + |
| +func (b *compatibilityBudget) scan(ctx context.Context, c *sql.Conn, query string, visit func(*sql.Rows) error) error { |
| + rows, e := c.QueryContext(ctx, query+" LIMIT 100001") |
| + if e != nil { |
| + return operator.ErrUnavailable |
| + } |
| + defer rows.Close() |
| + for rows.Next() { |
| + b.rows++ |
| + if b.rows > 100000 || ctx.Err() != nil { |
| + return operator.ErrUnavailable |
| + } |
| + if e = visit(rows); e != nil { |
| + return e |
| + } |
| + } |
| + if rows.Err() != nil { |
| + return operator.ErrUnavailable |
| + } |
| + return rows.Close() |
| +} |
| +func (b *compatibilityBudget) open(s *seal.Sealer, purpose string, ciphertext []byte, max int, valid func([]byte) bool) error { |
| + b.bytes += len(ciphertext) |
| + if len(ciphertext) < 29 || len(ciphertext) > max || b.bytes > 64<<20 { |
| + return operator.ErrRefused |
| + } |
| + raw, e := s.Open(purpose, ciphertext) |
| + if e != nil { |
| + return operator.ErrRefused |
| + } |
| + defer clear(raw) |
| + if !valid(raw) { |
| + return operator.ErrRefused |
| + } |
| + 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 |
| + } |
| + 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") |
| + if e != nil { |
| + return operator.ErrRefused |
| + } |
| + for rows.Next() { |
| + var p schemaObject |
| + if rows.Scan(&p.Type, &p.Name, &p.Table, &p.SQL) != nil { |
| + rows.Close() |
| + return operator.ErrRefused |
| + } |
| + key := p.Type + "/" + p.Name |
| + if objects[key] != p { |
| + rows.Close() |
| + return operator.ErrRefused |
| + } |
| + delete(objects, key) |
| + } |
| + if rows.Err() != nil || rows.Close() != nil || len(objects) != 0 { |
| + return operator.ErrRefused |
| + } |
| + total := 0 |
| + for _, p := range profile { |
| + if p.Type != "table" { |
| + continue |
| + } |
| + var count int |
| + query := "SELECT count(*) FROM (SELECT 1 FROM \"" + strings.ReplaceAll(p.Name, "\"", "\"\"") + "\" LIMIT 100001)" |
| + if c.QueryRowContext(ctx, query).Scan(&count) != nil { |
| + return operator.ErrRefused |
| + } |
| + total += count |
| + if total > 100000 { |
| + return operator.ErrUnavailable |
| + } |
| + } |
| + rows, e = c.QueryContext(ctx, "PRAGMA foreign_key_check") |
| + if e != nil { |
| + return operator.ErrRefused |
| + } |
| + bad := rows.Next() |
| + e = rows.Err() |
| + closeErr := rows.Close() |
| + if bad || e != nil || closeErr != nil { |
| + return operator.ErrRefused |
| + } |
| + var n int |
| + if c.QueryRowContext(ctx, "SELECT count(*) FROM (SELECT 1 FROM _litestream_lock LIMIT 1)").Scan(&n) != nil || n != 0 { |
| + return operator.ErrRefused |
| + } |
| + if c.QueryRowContext(ctx, "SELECT count(*) FROM (SELECT 1 FROM _litestream_seq LIMIT 2)").Scan(&n) != nil || n > 1 { |
| + return operator.ErrRefused |
| + } |
| + if n == 1 { |
| + var valid int |
| + if c.QueryRowContext(ctx, "SELECT typeof(id)='integer' AND id=1 AND typeof(seq)='integer' AND seq>=1 FROM _litestream_seq").Scan(&valid) != nil || valid != 1 { |
| + return operator.ErrRefused |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +func canonicalHash(s string) bool { |
| + raw, e := base64.RawURLEncoding.DecodeString(s) |
| + return e == nil && len(raw) == 32 && base64.RawURLEncoding.EncodeToString(raw) == s |
| +} |
| +func hexHash(s string) bool { |
| + raw, e := hex.DecodeString(s) |
| + return e == nil && len(raw) == 32 && hex.EncodeToString(raw) == s |
| +} |
diff --git a/internal/home/compatibility_linux_test.go b/internal/home/compatibility_linux_test.go| new file mode 100644 |
| index 0000000..51b011a |
| --- /dev/null |
| +++ b/internal/home/compatibility_linux_test.go |
| @@ -0,0 +1,262 @@ |
| +//go:build linux |
| + |
| +package home |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "crypto/sha256" |
| + "database/sql" |
| + "encoding/base64" |
| + "encoding/hex" |
| + "encoding/json" |
| + "io" |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| + "testing" |
| + "time" |
| + |
| + "amadan.net/oficina/home/internal/home/seal" |
| + "amadan.net/oficina/home/internal/operator" |
| + "github.com/carlosframework/rastrillo/migrate" |
| + "golang.org/x/sys/unix" |
| +) |
| + |
| +const replicationCompatibilityDDL = "CREATE TABLE IF NOT EXISTS _litestream_seq (id INTEGER PRIMARY KEY, seq INTEGER); CREATE TABLE IF NOT EXISTS _litestream_lock (id INTEGER);" |
| + |
| +func compatibilityFixture(t *testing.T, role string) *sql.DB { |
| + t.Helper() |
| + set := compatibilitySchema(role) |
| + d, e := migrate.Replay(context.Background(), set.All()) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + for _, q := range []string{migrate.LedgerDDL, replicationCompatibilityDDL} { |
| + if _, e = d.Exec(q); e != nil { |
| + t.Fatal(e) |
| + } |
| + } |
| + for _, m := range set.All() { |
| + if m.Fn != nil { |
| + t.Fatal("unsupported Go migration") |
| + } |
| + if _, e = d.Exec("INSERT INTO rastrillo_migrations VALUES(?,?,?)", m.ID, "2026-01-01T00:00:00Z", migrate.Checksum(m.SQL)); e != nil { |
| + t.Fatal(e) |
| + } |
| + } |
| + return d |
| +} |
| +func readCompatibilityProfile(t *testing.T, d *sql.DB) []schemaObject { |
| + t.Helper() |
| + rows, e := d.Query("SELECT type,name,tbl_name,coalesce(sql,'') FROM sqlite_schema ORDER BY type,name") |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + defer rows.Close() |
| + var objects []schemaObject |
| + for rows.Next() { |
| + var p schemaObject |
| + if e = rows.Scan(&p.Type, &p.Name, &p.Table, &p.SQL); e != nil { |
| + t.Fatal(e) |
| + } |
| + objects = append(objects, p) |
| + } |
| + if rows.Err() != nil { |
| + t.Fatal(rows.Err()) |
| + } |
| + return objects |
| +} |
| +func TestCompatibilityFrozenProfiles(t *testing.T) { |
| + for _, role := range []string{"credential", "directory"} { |
| + t.Run(role, func(t *testing.T) { |
| + d := compatibilityFixture(t, role) |
| + defer d.Close() |
| + raw, e := json.MarshalIndent(readCompatibilityProfile(t, d), "", " ") |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + raw = append(raw, '\n') |
| + path := "compatibility_profiles/" + role + ".json" |
| + if os.Getenv("OFICINA_GENERATE_COMPATIBILITY_PROFILE") == "1" { |
| + if e = os.WriteFile(path, raw, 0600); e != nil { |
| + t.Fatal(e) |
| + } |
| + return |
| + } |
| + expected, e := compatibilityProfiles.ReadFile(path) |
| + if e != nil || !bytes.Equal(raw, expected) { |
| + t.Fatal("frozen selected-release schema changed; review and regenerate") |
| + } |
| + }) |
| + } |
| +} |
| +func compatibilityTestConfig(role string) compatibilityConfig { |
| + cfg := compatibilityConfig{role: role, origin: "https://id.example.test", dek: bytes.Repeat([]byte{1}, 32), mac: bytes.Repeat([]byte{2}, 32), expectedKeys: map[string]string{}, witness: "pr_aaaaaaaaaaaaaaaaaaaaaaaaaa", generation: base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{5}, 32)), team: 1} |
| + if role == "credential" { |
| + cfg.pepper = bytes.Repeat([]byte{3}, 32) |
| + cfg.signin = bytes.Repeat([]byte{4}, 32) |
| + } else { |
| + cfg.origin = "https://home.example.test" |
| + } |
| + for name, key := range map[string][]byte{"dek": cfg.dek, "mac": cfg.mac, "pepper": cfg.pepper, "signin": cfg.signin} { |
| + if len(key) == 0 { |
| + continue |
| + } |
| + h := sha256.Sum256(key) |
| + cfg.expectedKeys[name] = hex.EncodeToString(h[:]) |
| + } |
| + cfg.state = compatibilityState{cfg.origin, "installation", "registry", "device", cfg.generation, "key", 1, 1} |
| + cfg.verify = func(context.Context, operator.ReadonlyExpectation) error { return nil } |
| + return cfg |
| +} |
| +func seedCompatibilityData(t *testing.T, d *sql.DB, cfg compatibilityConfig) { |
| + t.Helper() |
| + must := func(q string, args ...any) { |
| + t.Helper() |
| + if _, e := d.Exec(q, args...); e != nil { |
| + t.Fatal(e) |
| + } |
| + } |
| + must("INSERT INTO "+cfg.role+"_operator_state(singleton,origin,installation,registry,device,sequence,head,completed_at,key_id) VALUES(1,?,?,?,?,?,?,?,?)", cfg.origin, cfg.state.Installation, cfg.state.Registry, cfg.state.Device, cfg.state.Sequence, cfg.state.Head, cfg.state.Completed, cfg.state.KeyID) |
| + s, _ := seal.New(cfg.dek) |
| + result, _ := s.Seal("operator.result/v1/"+cfg.origin+"/"+cfg.generation+"/owner-seed", []byte(`{"ok":true}`)) |
| + must("INSERT INTO "+cfg.role+"_operator_results(operation_id,action,sequence,authorization_head,request_mac,sealed_result,completed_at,operator_ref) VALUES(?,?,1,?,?,?,1,'test')", cfg.generation, "owner-seed", cfg.generation, bytes.Repeat([]byte{7}, 32), result) |
| + if cfg.role == "credential" { |
| + h := sha256.Sum256([]byte("synthetic")) |
| + must("INSERT INTO people VALUES(?,?,?)", cfg.witness, hex.EncodeToString(h[:]), "2026-01-01T00:00:00Z") |
| + sealed, _ := s.Seal("signin.address", []byte("synthetic@example.invalid")) |
| + must("INSERT INTO signin_links VALUES(?,?,?,?)", "hash", sealed, "signin", "2020-01-01T00:00:00Z") |
| + } else { |
| + sealed, _ := s.Seal("directory.team-name/1", []byte("Synthetic")) |
| + must("INSERT INTO teams(id,sqid,label,sealed_name,created_at) VALUES(1,'abc','synthetic',?,1)", sealed) |
| + must("INSERT INTO memberships VALUES(1,?,'owner')", cfg.witness) |
| + must("INSERT INTO roster_generation VALUES(1,?)", cfg.generation) |
| + hash := hex.EncodeToString(bytes.Repeat([]byte{6}, 32)) |
| + sealed, _ = s.Seal("handoff.path."+hash, []byte("oficina-handoff-fresh-v1:/account")) |
| + must("INSERT INTO oficina_handoff_pending VALUES(?,?,?,1,2)", hash, hash, sealed) |
| + sealed, _ = s.Seal("handoff.token."+hash, []byte("expired-synthetic-token")) |
| + must("INSERT INTO oficina_handoff_inbox VALUES(?,?,?,1,2)", hash, hash, sealed) |
| + } |
| +} |
| +func TestCompatibilityDatabaseSubprocess(t *testing.T) { |
| + if os.Getenv("OFICINA_COMPATIBILITY_PROBE") == "1" { |
| + var in struct { |
| + Expected operator.ReadonlyExpectation |
| + Role, Mode string |
| + } |
| + if json.NewDecoder(io.LimitReader(os.Stdin, 16384)).Decode(&in) != nil { |
| + t.Fatal("input") |
| + } |
| + cfg := compatibilityTestConfig(in.Role) |
| + switch in.Mode { |
| + case "missing-proof": |
| + cfg.verify = nil |
| + case "missing-key": |
| + delete(cfg.expectedKeys, "dek") |
| + case "wrong-key": |
| + cfg.dek = bytes.Repeat([]byte{9}, 32) |
| + case "wrong-key-enrolled": |
| + cfg.dek = bytes.Repeat([]byte{9}, 32) |
| + sum := sha256.Sum256(cfg.dek) |
| + cfg.expectedKeys["dek"] = hex.EncodeToString(sum[:]) |
| + case "wrong-state": |
| + cfg.state.Sequence++ |
| + case "wrong-witness": |
| + cfg.witness = "pr_bbbbbbbbbbbbbbbbbbbbbbbbbb" |
| + } |
| + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) |
| + defer cancel() |
| + e := inspectCompatibilityDatabase(ctx, in.Expected, cfg) |
| + if in.Mode == "valid" { |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + } else if e == nil { |
| + t.Fatal("unsafe success", in.Mode) |
| + } |
| + return |
| + } |
| + for _, role := range []string{"credential", "directory"} { |
| + for _, mode := range []string{"valid", "oversized-ciphertext", "wrong-key-enrolled", "operator-ciphertext", "path-ciphertext", "missing-proof", "missing-key", "wrong-key", "wrong-state", "wrong-witness", "schema", "ledger", "ciphertext", "replication", "foreign-key", "empty-state"} { |
| + t.Run(role+"/"+mode, func(t *testing.T) { |
| + cfg := compatibilityTestConfig(role) |
| + d := compatibilityFixture(t, role) |
| + seedCompatibilityData(t, d, cfg) |
| + q := "" |
| + switch mode { |
| + case "oversized-ciphertext": |
| + if role == "credential" { |
| + q = "UPDATE signin_links SET sealed_address=zeroblob(65536)" |
| + } else { |
| + q = "UPDATE oficina_handoff_inbox SET sealed_token=zeroblob(65536)" |
| + } |
| + case "operator-ciphertext": |
| + q = "UPDATE " + role + "_operator_results SET sealed_result=zeroblob(40)" |
| + case "path-ciphertext": |
| + if role == "directory" { |
| + q = "UPDATE oficina_handoff_pending SET sealed_path=zeroblob(40)" |
| + } else { |
| + q = "UPDATE signin_links SET sealed_address=zeroblob(40)" |
| + } |
| + case "schema": |
| + q = "CREATE TRIGGER unexpected AFTER DELETE ON " + role + "_operator_state BEGIN SELECT 1; END" |
| + case "ledger": |
| + q = "UPDATE rastrillo_migrations SET checksum='bad'" |
| + case "replication": |
| + q = "INSERT INTO _litestream_lock VALUES(1)" |
| + case "empty-state": |
| + q = "DELETE FROM " + role + "_operator_state" |
| + case "ciphertext": |
| + if role == "credential" { |
| + q = "UPDATE signin_links SET sealed_address=zeroblob(40)" |
| + } else { |
| + q = "UPDATE oficina_handoff_inbox SET sealed_token=zeroblob(40)" |
| + } |
| + case "foreign-key": |
| + if role == "directory" { |
| + q = "INSERT INTO memberships VALUES(999,'pr_bbbbbbbbbbbbbbbbbbbbbbbbbb','member')" |
| + } else { |
| + q = "UPDATE people SET person_ref='broken'" |
| + } |
| + } |
| + if q != "" { |
| + if _, e := d.Exec(q); e != nil { |
| + t.Fatal(e) |
| + } |
| + } |
| + path := filepath.Join(t.TempDir(), "role.db") |
| + if _, e := d.Exec("VACUUM INTO ?", path); e != nil { |
| + t.Fatal(e) |
| + } |
| + if e := d.Close(); e != nil { |
| + t.Fatal(e) |
| + } |
| + raw, e := os.ReadFile(path) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + h := sha256.Sum256(raw) |
| + var st unix.Stat_t |
| + if unix.Stat(path, &st) != nil { |
| + t.Fatal("stat") |
| + } |
| + expected := operator.ReadonlyExpectation{Path: path, Device: uint64(st.Dev), Inode: st.Ino, Length: int64(len(raw)), SHA256: hex.EncodeToString(h[:])} |
| + input, _ := json.Marshal(struct { |
| + Expected operator.ReadonlyExpectation |
| + Role, Mode string |
| + }{expected, role, mode}) |
| + cmd := exec.Command(os.Args[0], "-test.run=^TestCompatibilityDatabaseSubprocess$") |
| + cmd.Env = append(os.Environ(), "OFICINA_COMPATIBILITY_PROBE=1") |
| + cmd.Stdin = bytes.NewReader(input) |
| + if out, e := cmd.CombinedOutput(); e != nil { |
| + t.Fatalf("child: %v %s", e, out) |
| + } |
| + after, e := os.ReadFile(path) |
| + if e != nil || !bytes.Equal(raw, after) { |
| + t.Fatal("database changed") |
| + } |
| + }) |
| + } |
| + } |
| +} |
diff --git a/internal/home/compatibility_profiles/credential.json b/internal/home/compatibility_profiles/credential.json| new file mode 100644 |
| index 0000000..04d1f0b |
| --- /dev/null |
| +++ b/internal/home/compatibility_profiles/credential.json |
| @@ -0,0 +1,158 @@ |
| +[ |
| + { |
| + "Type": "index", |
| + "Name": "idx_signin_links_expires_at", |
| + "Table": "signin_links", |
| + "SQL": "CREATE INDEX idx_signin_links_expires_at ON signin_links(expires_at)" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "passkey_credentials_subject", |
| + "Table": "passkey_credentials", |
| + "SQL": "CREATE INDEX passkey_credentials_subject\n ON passkey_credentials (subject)" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "passkey_recovery_codes_subject", |
| + "Table": "passkey_recovery_codes", |
| + "SQL": "CREATE INDEX passkey_recovery_codes_subject\n ON passkey_recovery_codes (subject)" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_credential_operator_results_1", |
| + "Table": "credential_operator_results", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_credential_operator_results_2", |
| + "Table": "credential_operator_results", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_passkey_challenges_1", |
| + "Table": "passkey_challenges", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_passkey_credentials_1", |
| + "Table": "passkey_credentials", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_passkey_pending_1", |
| + "Table": "passkey_pending", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_passkey_recovery_codes_1", |
| + "Table": "passkey_recovery_codes", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_people_1", |
| + "Table": "people", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_people_2", |
| + "Table": "people", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_rastrillo_migrations_1", |
| + "Table": "rastrillo_migrations", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_sessions_1", |
| + "Table": "sessions", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_signin_links_1", |
| + "Table": "signin_links", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "_litestream_lock", |
| + "Table": "_litestream_lock", |
| + "SQL": "CREATE TABLE _litestream_lock (id INTEGER)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "_litestream_seq", |
| + "Table": "_litestream_seq", |
| + "SQL": "CREATE TABLE _litestream_seq (id INTEGER PRIMARY KEY, seq INTEGER)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "credential_operator_results", |
| + "Table": "credential_operator_results", |
| + "SQL": "CREATE TABLE credential_operator_results (\n operation_id TEXT PRIMARY KEY CHECK(length(operation_id)=43),\n action TEXT NOT NULL,\n sequence INTEGER NOT NULL UNIQUE CHECK(typeof(sequence)='integer' AND sequence\u003e0),\n authorization_head TEXT NOT NULL CHECK(length(authorization_head)=43),\n request_mac BLOB NOT NULL CHECK(typeof(request_mac)='blob' AND length(request_mac)=32),\n sealed_result BLOB NOT NULL CHECK(typeof(sealed_result)='blob' AND length(sealed_result) BETWEEN 29 AND 49180),\n completed_at INTEGER NOT NULL CHECK(typeof(completed_at)='integer' AND completed_at\u003e0)\n, operator_ref TEXT NOT NULL DEFAULT '')" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "credential_operator_state", |
| + "Table": "credential_operator_state", |
| + "SQL": "CREATE TABLE credential_operator_state (\n singleton INTEGER PRIMARY KEY CHECK(singleton=1),\n origin TEXT NOT NULL,\n installation TEXT NOT NULL,\n registry TEXT NOT NULL,\n device TEXT NOT NULL,\n sequence INTEGER NOT NULL CHECK(typeof(sequence)='integer' AND sequence\u003e0),\n head TEXT NOT NULL CHECK(length(head)=43),\n completed_at INTEGER NOT NULL CHECK(typeof(completed_at)='integer' AND completed_at\u003e0)\n, key_id TEXT NOT NULL DEFAULT '')" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "passkey_challenges", |
| + "Table": "passkey_challenges", |
| + "SQL": "CREATE TABLE passkey_challenges (\n challenge TEXT PRIMARY KEY,\n subject TEXT NOT NULL,\n purpose TEXT NOT NULL,\n expires_at TEXT NOT NULL\n)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "passkey_credentials", |
| + "Table": "passkey_credentials", |
| + "SQL": "CREATE TABLE passkey_credentials (\n id TEXT PRIMARY KEY,\n subject TEXT NOT NULL,\n public_key BLOB NOT NULL,\n sign_count INTEGER NOT NULL DEFAULT 0,\n created_at TEXT NOT NULL\n)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "passkey_pending", |
| + "Table": "passkey_pending", |
| + "SQL": "CREATE TABLE passkey_pending (\n token_hash TEXT PRIMARY KEY,\n subject TEXT NOT NULL,\n method TEXT NOT NULL DEFAULT '',\n return_to TEXT NOT NULL DEFAULT '',\n expires_at TEXT NOT NULL\n)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "passkey_recovery_codes", |
| + "Table": "passkey_recovery_codes", |
| + "SQL": "CREATE TABLE passkey_recovery_codes (\n code_hash TEXT PRIMARY KEY,\n subject TEXT NOT NULL,\n created_at TEXT NOT NULL\n)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "people", |
| + "Table": "people", |
| + "SQL": "CREATE TABLE people (\n person_ref TEXT PRIMARY KEY,\n blind_index TEXT NOT NULL UNIQUE, -- UNIQUE already indexes it; no second index\n created_at TEXT NOT NULL\n)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "rastrillo_migrations", |
| + "Table": "rastrillo_migrations", |
| + "SQL": "CREATE TABLE rastrillo_migrations (\n id TEXT PRIMARY KEY,\n applied_at TEXT NOT NULL,\n checksum TEXT NOT NULL\n)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "sessions", |
| + "Table": "sessions", |
| + "SQL": "CREATE TABLE sessions (\n token_hash TEXT PRIMARY KEY,\n subject TEXT NOT NULL,\n method TEXT NOT NULL DEFAULT '',\n auth_time TEXT NOT NULL DEFAULT '',\n created_at TEXT NOT NULL,\n expires_at TEXT NOT NULL\n)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "signin_links", |
| + "Table": "signin_links", |
| + "SQL": "CREATE TABLE signin_links (\n token_hash TEXT PRIMARY KEY,\n sealed_address BLOB NOT NULL,\n purpose TEXT NOT NULL,\n expires_at TEXT NOT NULL\n)" |
| + } |
| +] |
diff --git a/internal/home/compatibility_profiles/directory.json b/internal/home/compatibility_profiles/directory.json| new file mode 100644 |
| index 0000000..a7b5c02 |
| --- /dev/null |
| +++ b/internal/home/compatibility_profiles/directory.json |
| @@ -0,0 +1,350 @@ |
| +[ |
| + { |
| + "Type": "index", |
| + "Name": "memberships_person", |
| + "Table": "memberships", |
| + "SQL": "CREATE INDEX memberships_person ON memberships(person_ref, team_id)" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "oficina_handoff_inbox_expiry", |
| + "Table": "oficina_handoff_inbox", |
| + "SQL": "CREATE INDEX oficina_handoff_inbox_expiry ON oficina_handoff_inbox(expires_at)" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "oficina_handoff_pending_expiry", |
| + "Table": "oficina_handoff_pending", |
| + "SQL": "CREATE INDEX oficina_handoff_pending_expiry ON oficina_handoff_pending(expires_at)" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "oficina_handoff_replay_expiry", |
| + "Table": "oficina_handoff_replay", |
| + "SQL": "CREATE INDEX oficina_handoff_replay_expiry ON oficina_handoff_replay(expires_at)" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_directory_operator_consumer_records_1", |
| + "Table": "directory_operator_consumer_records", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_directory_operator_consumer_records_2", |
| + "Table": "directory_operator_consumer_records", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_directory_operator_owner_receipts_1", |
| + "Table": "directory_operator_owner_receipts", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_directory_operator_owner_receipts_2", |
| + "Table": "directory_operator_owner_receipts", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_directory_operator_owner_receipts_3", |
| + "Table": "directory_operator_owner_receipts", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_directory_operator_results_1", |
| + "Table": "directory_operator_results", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_directory_operator_results_2", |
| + "Table": "directory_operator_results", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_memberships_1", |
| + "Table": "memberships", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_oficina_handoff_inbox_1", |
| + "Table": "oficina_handoff_inbox", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_oficina_handoff_inbox_2", |
| + "Table": "oficina_handoff_inbox", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_oficina_handoff_pending_1", |
| + "Table": "oficina_handoff_pending", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_oficina_handoff_pending_2", |
| + "Table": "oficina_handoff_pending", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_oficina_handoff_replay_1", |
| + "Table": "oficina_handoff_replay", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_rastrillo_migrations_1", |
| + "Table": "rastrillo_migrations", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_roster_consumers_1", |
| + "Table": "roster_consumers", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_roster_operator_receipts_1", |
| + "Table": "roster_operator_receipts", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_roster_tokens_1", |
| + "Table": "roster_tokens", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_roster_tokens_2", |
| + "Table": "roster_tokens", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_sessions_1", |
| + "Table": "sessions", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_team_instances_1", |
| + "Table": "team_instances", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_team_instances_2", |
| + "Table": "team_instances", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_team_instances_3", |
| + "Table": "team_instances", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_teams_1", |
| + "Table": "teams", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_teams_2", |
| + "Table": "teams", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_verification_active_1", |
| + "Table": "verification_active", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_verification_active_2", |
| + "Table": "verification_active", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_verification_attempts_1", |
| + "Table": "verification_attempts", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_verification_outcomes_1", |
| + "Table": "verification_outcomes", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "_litestream_lock", |
| + "Table": "_litestream_lock", |
| + "SQL": "CREATE TABLE _litestream_lock (id INTEGER)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "_litestream_seq", |
| + "Table": "_litestream_seq", |
| + "SQL": "CREATE TABLE _litestream_seq (id INTEGER PRIMARY KEY, seq INTEGER)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "directory_operator_consumer_records", |
| + "Table": "directory_operator_consumer_records", |
| + "SQL": "CREATE TABLE directory_operator_consumer_records (\n consumer_id TEXT PRIMARY KEY REFERENCES roster_consumers(consumer_id),\n operation_id TEXT NOT NULL UNIQUE REFERENCES roster_operator_receipts(operation_id),\n sequence INTEGER NOT NULL CHECK(typeof(sequence)='integer' AND sequence\u003e0),\n generation TEXT NOT NULL CHECK(length(generation)=43),\n record_digest BLOB NOT NULL CHECK(typeof(record_digest)='blob' AND length(record_digest)=32)\n)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "directory_operator_owner_receipts", |
| + "Table": "directory_operator_owner_receipts", |
| + "SQL": "CREATE TABLE directory_operator_owner_receipts (\n issuer TEXT NOT NULL,\n owner_operation_id TEXT NOT NULL CHECK(length(owner_operation_id)=43),\n directory_operation_id TEXT NOT NULL UNIQUE REFERENCES directory_operator_results(operation_id) DEFERRABLE INITIALLY DEFERRED,\n team_id INTEGER NOT NULL UNIQUE REFERENCES teams(id),\n PRIMARY KEY(issuer,owner_operation_id)\n)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "directory_operator_results", |
| + "Table": "directory_operator_results", |
| + "SQL": "CREATE TABLE directory_operator_results (\n operation_id TEXT PRIMARY KEY CHECK(length(operation_id)=43),\n action TEXT NOT NULL,\n sequence INTEGER NOT NULL UNIQUE CHECK(typeof(sequence)='integer' AND sequence\u003e0),\n authorization_head TEXT NOT NULL CHECK(length(authorization_head)=43),\n request_mac BLOB NOT NULL CHECK(typeof(request_mac)='blob' AND length(request_mac)=32),\n sealed_result BLOB NOT NULL CHECK(typeof(sealed_result)='blob' AND length(sealed_result) BETWEEN 29 AND 49180),\n completed_at INTEGER NOT NULL CHECK(typeof(completed_at)='integer' AND completed_at\u003e0)\n, operator_ref TEXT NOT NULL DEFAULT '')" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "directory_operator_state", |
| + "Table": "directory_operator_state", |
| + "SQL": "CREATE TABLE directory_operator_state (\n singleton INTEGER PRIMARY KEY CHECK(singleton=1),\n origin TEXT NOT NULL,\n installation TEXT NOT NULL,\n registry TEXT NOT NULL,\n device TEXT NOT NULL,\n sequence INTEGER NOT NULL CHECK(typeof(sequence)='integer' AND sequence\u003e0),\n head TEXT NOT NULL CHECK(length(head)=43),\n completed_at INTEGER NOT NULL CHECK(typeof(completed_at)='integer' AND completed_at\u003e0)\n, key_id TEXT NOT NULL DEFAULT '')" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "memberships", |
| + "Table": "memberships", |
| + "SQL": "CREATE TABLE memberships (\n team_id INTEGER NOT NULL REFERENCES teams(id) ON DELETE RESTRICT,\n person_ref TEXT NOT NULL CHECK(length(person_ref) = 29 AND substr(person_ref,1,3) = 'pr_' AND substr(person_ref,4) NOT GLOB '*[^a-z2-7]*'),\n role TEXT NOT NULL CHECK(role IN ('owner', 'member')),\n PRIMARY KEY(team_id, person_ref)\n)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "oficina_handoff_clock", |
| + "Table": "oficina_handoff_clock", |
| + "SQL": "CREATE TABLE oficina_handoff_clock (\n id INTEGER PRIMARY KEY CHECK (id = 1),\n observed INTEGER NOT NULL\n)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "oficina_handoff_inbox", |
| + "Table": "oficina_handoff_inbox", |
| + "SQL": "CREATE TABLE oficina_handoff_inbox (\n handle_hash TEXT PRIMARY KEY,\n jti_hash TEXT NOT NULL UNIQUE,\n sealed_token BLOB NOT NULL,\n created_at INTEGER NOT NULL,\n expires_at INTEGER NOT NULL\n)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "oficina_handoff_pending", |
| + "Table": "oficina_handoff_pending", |
| + "SQL": "CREATE TABLE oficina_handoff_pending (\n secret_hash TEXT PRIMARY KEY,\n nonce_hash TEXT NOT NULL UNIQUE,\n sealed_path BLOB NOT NULL,\n created_at INTEGER NOT NULL,\n expires_at INTEGER NOT NULL\n)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "oficina_handoff_replay", |
| + "Table": "oficina_handoff_replay", |
| + "SQL": "CREATE TABLE oficina_handoff_replay (\n jti_hash TEXT PRIMARY KEY,\n expires_at INTEGER NOT NULL\n)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "rastrillo_migrations", |
| + "Table": "rastrillo_migrations", |
| + "SQL": "CREATE TABLE rastrillo_migrations (\n id TEXT PRIMARY KEY,\n applied_at TEXT NOT NULL,\n checksum TEXT NOT NULL\n)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "roster_consumers", |
| + "Table": "roster_consumers", |
| + "SQL": "CREATE TABLE roster_consumers (\n consumer_id TEXT PRIMARY KEY REFERENCES team_instances(consumer_id) ON DELETE RESTRICT,\n team_id INTEGER NOT NULL REFERENCES teams(id) ON DELETE RESTRICT,\n app TEXT NOT NULL,\n origin TEXT NOT NULL,\n enabled INTEGER NOT NULL CHECK(enabled IN (0,1)),\n FOREIGN KEY(team_id,app) REFERENCES team_instances(team_id,app) ON DELETE RESTRICT\n)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "roster_generation", |
| + "Table": "roster_generation", |
| + "SQL": "CREATE TABLE roster_generation (\n singleton INTEGER PRIMARY KEY CHECK(singleton = 1),\n generation TEXT NOT NULL CHECK(length(generation) = 43 AND generation NOT GLOB '*[^a-zA-Z0-9_-]*')\n)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "roster_operator_receipts", |
| + "Table": "roster_operator_receipts", |
| + "SQL": "CREATE TABLE roster_operator_receipts (\n operation_id TEXT PRIMARY KEY CHECK(length(operation_id) BETWEEN 1 AND 128 AND operation_id NOT GLOB '*[^a-zA-Z0-9_-]*'),\n sequence INTEGER NOT NULL CHECK(typeof(sequence) = 'integer' AND sequence \u003e 0),\n operation TEXT NOT NULL CHECK(operation IN ('bootstrap','provision','rotate','disable','restore')),\n consumer_id TEXT CHECK(consumer_id IS NULL OR (length(consumer_id) BETWEEN 1 AND 128 AND consumer_id NOT GLOB '*[^a-zA-Z0-9_-]*')),\n generation TEXT NOT NULL CHECK(length(generation) = 43 AND generation NOT GLOB '*[^a-zA-Z0-9_-]*'),\n record_digest BLOB NOT NULL CHECK(typeof(record_digest) = 'blob' AND length(record_digest) = 32),\n completed_at INTEGER NOT NULL CHECK(typeof(completed_at) = 'integer' AND completed_at \u003e 0)\n)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "roster_tokens", |
| + "Table": "roster_tokens", |
| + "SQL": "CREATE TABLE roster_tokens (\n token_hash BLOB PRIMARY KEY CHECK(typeof(token_hash) = 'blob' AND length(token_hash) = 32),\n consumer_id TEXT NOT NULL REFERENCES roster_consumers(consumer_id) ON DELETE RESTRICT,\n slot TEXT NOT NULL CHECK(slot IN ('current','previous')),\n issued_at INTEGER NOT NULL CHECK(typeof(issued_at) = 'integer' AND issued_at \u003e 0),\n expires_at INTEGER,\n UNIQUE(consumer_id,slot),\n CHECK((slot = 'current' AND expires_at IS NULL) OR\n (slot = 'previous' AND typeof(expires_at) = 'integer' AND expires_at \u003e issued_at AND expires_at - issued_at \u003c= 300))\n)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "sessions", |
| + "Table": "sessions", |
| + "SQL": "CREATE TABLE sessions (\n token_hash TEXT PRIMARY KEY,\n subject TEXT NOT NULL,\n method TEXT NOT NULL DEFAULT '',\n auth_time TEXT NOT NULL DEFAULT '',\n created_at TEXT NOT NULL,\n expires_at TEXT NOT NULL\n)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "sqlite_sequence", |
| + "Table": "sqlite_sequence", |
| + "SQL": "CREATE TABLE sqlite_sequence(name,seq)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "team_instances", |
| + "Table": "team_instances", |
| + "SQL": "CREATE TABLE team_instances (\n team_id INTEGER NOT NULL REFERENCES teams(id) ON DELETE RESTRICT,\n app TEXT NOT NULL,\n origin TEXT NOT NULL UNIQUE,\n consumer_id TEXT NOT NULL UNIQUE,\n state TEXT NOT NULL CHECK(state IN ('pending', 'live', 'disabled')),\n PRIMARY KEY(team_id, app)\n)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "teams", |
| + "Table": "teams", |
| + "SQL": "CREATE TABLE teams (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n sqid TEXT NOT NULL UNIQUE CHECK(length(sqid) BETWEEN 1 AND 64 AND sqid NOT GLOB '*[^a-zA-Z0-9]*'),\n label TEXT NOT NULL UNIQUE CHECK(length(label) BETWEEN 1 AND 63 AND label NOT GLOB '*[^a-z0-9-]*' AND substr(label,1,1) != '-' AND substr(label,-1,1) != '-'),\n sealed_name BLOB NOT NULL CHECK(typeof(sealed_name) = 'blob' AND length(sealed_name) BETWEEN 29 AND 284),\n created_at INTEGER NOT NULL CHECK(created_at \u003e 0),\n roster_epoch INTEGER NOT NULL DEFAULT 1 CHECK(typeof(roster_epoch) = 'integer' AND roster_epoch \u003e 0)\n)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "verification_active", |
| + "Table": "verification_active", |
| + "SQL": "CREATE TABLE verification_active (\n consumer_id TEXT PRIMARY KEY REFERENCES roster_consumers(consumer_id),\n operation_id TEXT NOT NULL UNIQUE REFERENCES verification_attempts(operation_id)\n)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "verification_attempts", |
| + "Table": "verification_attempts", |
| + "SQL": "CREATE TABLE verification_attempts (\n operation_id TEXT PRIMARY KEY CHECK(length(operation_id)=43),\n consumer_id TEXT NOT NULL REFERENCES roster_consumers(consumer_id),\n team_id INTEGER NOT NULL REFERENCES teams(id),\n app TEXT NOT NULL,\n origin TEXT NOT NULL,\n generation TEXT NOT NULL CHECK(length(generation)=43),\n record_sequence INTEGER NOT NULL CHECK(typeof(record_sequence)='integer' AND record_sequence\u003e0),\n record_digest BLOB NOT NULL CHECK(typeof(record_digest)='blob' AND length(record_digest)=32),\n expected_epoch INTEGER NOT NULL CHECK(typeof(expected_epoch)='integer' AND expected_epoch\u003e0),\n owner_ref TEXT NOT NULL CHECK(length(owner_ref)=29),\n issued_at INTEGER NOT NULL CHECK(typeof(issued_at)='integer' AND issued_at\u003e0),\n expires_at INTEGER NOT NULL CHECK(typeof(expires_at)='integer' AND expires_at\u003eissued_at AND expires_at-issued_at\u003c=600),\n authorization_digest BLOB NOT NULL CHECK(typeof(authorization_digest)='blob' AND length(authorization_digest)=32)\n)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "verification_outcomes", |
| + "Table": "verification_outcomes", |
| + "SQL": "CREATE TABLE verification_outcomes (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n attempt_id TEXT NOT NULL REFERENCES verification_attempts(operation_id),\n operator_operation_id TEXT NOT NULL CHECK(length(operator_operation_id)=43),\n kind TEXT NOT NULL CHECK(kind IN ('opened','expired','activated')),\n completed_at INTEGER NOT NULL CHECK(typeof(completed_at)='integer' AND completed_at\u003e0),\n UNIQUE(attempt_id,operator_operation_id,kind)\n)" |
| + }, |
| + { |
| + "Type": "trigger", |
| + "Name": "memberships_epoch_delete", |
| + "Table": "memberships", |
| + "SQL": "CREATE TRIGGER memberships_epoch_delete AFTER DELETE ON memberships BEGIN\n SELECT CASE WHEN (SELECT roster_epoch FROM teams WHERE id = OLD.team_id) = 9223372036854775807 THEN RAISE(ABORT,'roster epoch exhausted') END;\n UPDATE teams SET roster_epoch = roster_epoch + 1 WHERE id = OLD.team_id;\nEND" |
| + }, |
| + { |
| + "Type": "trigger", |
| + "Name": "memberships_epoch_insert", |
| + "Table": "memberships", |
| + "SQL": "CREATE TRIGGER memberships_epoch_insert AFTER INSERT ON memberships BEGIN\n SELECT CASE WHEN (SELECT roster_epoch FROM teams WHERE id = NEW.team_id) = 9223372036854775807 THEN RAISE(ABORT,'roster epoch exhausted') END;\n UPDATE teams SET roster_epoch = roster_epoch + 1 WHERE id = NEW.team_id;\nEND" |
| + }, |
| + { |
| + "Type": "trigger", |
| + "Name": "memberships_epoch_update", |
| + "Table": "memberships", |
| + "SQL": "CREATE TRIGGER memberships_epoch_update AFTER UPDATE ON memberships BEGIN\n SELECT CASE WHEN EXISTS(SELECT 1 FROM teams WHERE id IN (OLD.team_id,NEW.team_id) AND roster_epoch = 9223372036854775807) THEN RAISE(ABORT,'roster epoch exhausted') END;\n UPDATE teams SET roster_epoch = roster_epoch + 1 WHERE id IN (OLD.team_id,NEW.team_id);\nEND" |
| + } |
| +] |
diff --git a/internal/operator/readonly_linux.go b/internal/operator/readonly_linux.go| new file mode 100644 |
| index 0000000..8ade850 |
| --- /dev/null |
| +++ b/internal/operator/readonly_linux.go |
| @@ -0,0 +1,430 @@ |
| +//go:build linux |
| + |
| +package operator |
| + |
| +import ( |
| + "context" |
| + "crypto/sha256" |
| + "database/sql" |
| + "database/sql/driver" |
| + "encoding/hex" |
| + "io" |
| + "net/url" |
| + "os" |
| + "sync" |
| + "time" |
| + |
| + "golang.org/x/sys/unix" |
| + "modernc.org/sqlite" |
| +) |
| + |
| +// ReadonlyExpectation is an observation required by the unmounted checker. |
| +// It is not launch authority. Verify must check the independently enrolled and |
| +// platform-verified handoff; deriving expectations from this database is invalid. |
| +type ReadonlyExpectation struct { |
| + Path string |
| + Device, Inode uint64 |
| + Length int64 |
| + SHA256 string |
| +} |
| + |
| +// InspectReadonly runs only in the dedicated non-serving checker process, after |
| +// its launch descriptors are closed. Verify owns launch/enrollment verification; |
| +// no production implementation exists yet. The callback must not open files, |
| +// start goroutines, retain the connection, or perform network or serving work. |
| +// Success is withheld until driver closure and the final exact byte comparison. |
| +func InspectReadonly(ctx context.Context, expected ReadonlyExpectation, verify func(context.Context, ReadonlyExpectation) error, inspect func(context.Context, *sql.Conn) error) error { |
| + return inspectReadonly(ctx, expected, verify, inspect, nil) |
| +} |
| + |
| +func inspectReadonly(ctx context.Context, expected ReadonlyExpectation, verify func(context.Context, ReadonlyExpectation) error, inspect func(context.Context, *sql.Conn) error, hook func(string)) (err error) { |
| + deadline, bounded := ctx.Deadline() |
| + if !bounded || !deadline.After(time.Now()) || verify == nil || inspect == nil || expected.Length <= 0 || expected.Length > 256<<20 || len(expected.SHA256) != 64 { |
| + return ErrRefused |
| + } |
| + hash, e := hex.DecodeString(expected.SHA256) |
| + if e != nil || hex.EncodeToString(hash) != expected.SHA256 || expected.Device == 0 || expected.Inode == 0 { |
| + return ErrRefused |
| + } |
| + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) |
| + defer cancel() |
| + deadline, _ = ctx.Deadline() |
| + if verify(ctx, expected) != nil { |
| + return ErrRefused |
| + } |
| + pin, id, e := pinExisting(expected.Path) |
| + if e != nil { |
| + return e |
| + } |
| + defer func() { |
| + if pin.Close() != nil { |
| + err = ErrUnavailable |
| + } |
| + }() |
| + if id != (fileIdentity{expected.Device, expected.Inode}) { |
| + return ErrRefused |
| + } |
| + c := &readonlyConnector{path: expected.Path, pin: pin, identity: id, deadline: deadline, sqlite: &sqlite.Driver{}, hook: hook} |
| + if c.pathMatches() != nil || hashReadonly(ctx, pin, expected) != nil { |
| + return ErrRefused |
| + } |
| + d := sql.OpenDB(c) |
| + d.SetMaxOpenConns(1) |
| + d.SetMaxIdleConns(1) |
| + defer func() { |
| + if d.Close() != nil { |
| + err = ErrUnavailable |
| + } |
| + }() |
| + conn, e := d.Conn(ctx) |
| + if e != nil { |
| + return ErrUnavailable |
| + } |
| + defer conn.Close() |
| + if _, e = conn.ExecContext(ctx, "PRAGMA query_only=ON"); e != nil { |
| + return ErrUnavailable |
| + } |
| + if _, e = conn.ExecContext(ctx, "PRAGMA temp_store=MEMORY"); e != nil { |
| + return ErrUnavailable |
| + } |
| + var temp int |
| + if conn.QueryRowContext(ctx, "PRAGMA temp_store").Scan(&temp) != nil || temp != 2 { |
| + return ErrRefused |
| + } |
| + if _, e = conn.ExecContext(ctx, "BEGIN"); e != nil { |
| + return ErrUnavailable |
| + } |
| + e = inspect(ctx, conn) |
| + if _, end := conn.ExecContext(ctx, "ROLLBACK"); end != nil { |
| + return ErrUnavailable |
| + } |
| + if e != nil { |
| + if e == ErrUnavailable { |
| + return ErrUnavailable |
| + } |
| + return ErrRefused |
| + } |
| + if conn.Close() != nil || d.Close() != nil { |
| + return ErrUnavailable |
| + } |
| + if c.pathMatches() != nil || hashReadonly(ctx, pin, expected) != nil || verify(ctx, expected) != nil { |
| + return ErrRefused |
| + } |
| + return nil |
| +} |
| + |
| +func hashReadonly(ctx context.Context, pin *os.File, expected ReadonlyExpectation) error { |
| + info, e := pin.Stat() |
| + if e != nil || info.Size() != expected.Length { |
| + return ErrRefused |
| + } |
| + h := sha256.New() |
| + buf := make([]byte, 64<<10) |
| + for off := int64(0); off < expected.Length; { |
| + if ctx.Err() != nil { |
| + return ErrUnavailable |
| + } |
| + size := int64(len(buf)) |
| + if expected.Length-off < size { |
| + size = expected.Length - off |
| + } |
| + n, e := pin.ReadAt(buf[:size], off) |
| + if e != nil || n != int(size) { |
| + return ErrUnavailable |
| + } |
| + h.Write(buf[:n]) |
| + off += int64(n) |
| + } |
| + if hex.EncodeToString(h.Sum(nil)) != expected.SHA256 { |
| + return ErrRefused |
| + } |
| + info, e = pin.Stat() |
| + if e != nil || info.Size() != expected.Length { |
| + return ErrRefused |
| + } |
| + return nil |
| +} |
| + |
| +type readonlyConnector struct { |
| + mu sync.Mutex |
| + path string |
| + pin *os.File |
| + identity fileIdentity |
| + deadline time.Time |
| + sqlite *sqlite.Driver |
| + active bool |
| + hook func(string) |
| +} |
| +type readonlyConnection struct { |
| + driver.Conn |
| + owner *readonlyConnector |
| + mainFD int |
| + baseline map[int]descriptor |
| + closed bool |
| +} |
| + |
| +func (c *readonlyConnector) Driver() driver.Driver { return c.sqlite } |
| +func (c *readonlyConnector) checkpoint(stage string) { |
| + if c.hook != nil { |
| + c.hook(stage) |
| + } |
| +} |
| +func (c *readonlyConnector) pathMatches() error { |
| + if !time.Now().Before(c.deadline) { |
| + return ErrRefused |
| + } |
| + var s unix.Stat_t |
| + if unix.Fstat(int(c.pin.Fd()), &s) != nil || (fileIdentity{uint64(s.Dev), s.Ino}) != c.identity || s.Nlink != 1 { |
| + return ErrRefused |
| + } |
| + id, e := statExisting(c.path) |
| + if e != nil || id != c.identity { |
| + return ErrRefused |
| + } |
| + for _, suffix := range []string{"-wal", "-shm", "-journal"} { |
| + _, e := os.Lstat(c.path + suffix) |
| + if !os.IsNotExist(e) { |
| + return ErrRefused |
| + } |
| + } |
| + return nil |
| +} |
| +func (c *readonlyConnector) Connect(ctx context.Context) (driver.Conn, error) { |
| + c.mu.Lock() |
| + defer c.mu.Unlock() |
| + if c.active || ctx.Err() != nil || c.pathMatches() != nil { |
| + return nil, ErrRefused |
| + } |
| + baseline, e := descriptors() |
| + if e != nil { |
| + return nil, e |
| + } |
| + for _, d := range baseline { |
| + if writable(d.flags) { |
| + return nil, ErrRefused |
| + } |
| + } |
| + c.checkpoint("before-driver") |
| + u := url.URL{Scheme: "file", Path: c.path} |
| + q := u.Query() |
| + q.Set("mode", "ro") |
| + q.Set("immutable", "1") |
| + u.RawQuery = q.Encode() |
| + native, e := c.sqlite.Open(u.String()) |
| + if e != nil { |
| + return nil, ErrUnavailable |
| + } |
| + keep := false |
| + defer func() { |
| + if !keep { |
| + native.Close() |
| + } |
| + }() |
| + c.checkpoint("after-open") |
| + all, e := descriptors() |
| + if e != nil { |
| + return nil, e |
| + } |
| + main := -1 |
| + for fd, d := range all { |
| + if old, ok := baseline[fd]; ok { |
| + if old != d { |
| + return nil, ErrRefused |
| + } |
| + continue |
| + } |
| + if d.identity != c.identity || d.flags&unix.O_ACCMODE != unix.O_RDONLY || d.links != 1 || main != -1 { |
| + return nil, ErrRefused |
| + } |
| + main = fd |
| + } |
| + if main < 0 { |
| + return nil, ErrRefused |
| + } |
| + bound := &readonlyConnection{Conn: native, owner: c, mainFD: main, baseline: baseline} |
| + if bound.check() != nil { |
| + return nil, ErrRefused |
| + } |
| + query, ok := native.(driver.QueryerContext) |
| + if !ok { |
| + return nil, ErrRefused |
| + } |
| + rows, e := query.QueryContext(ctx, "PRAGMA database_list", nil) |
| + if e != nil { |
| + return nil, ErrUnavailable |
| + } |
| + values := make([]driver.Value, 3) |
| + if rows.Next(values) != nil || values[1] != "main" || values[2] != c.path { |
| + rows.Close() |
| + return nil, ErrRefused |
| + } |
| + if rows.Next(values) != io.EOF { |
| + rows.Close() |
| + return nil, ErrRefused |
| + } |
| + if rows.Close() != nil || bound.check() != nil { |
| + return nil, ErrUnavailable |
| + } |
| + c.active = true |
| + keep = true |
| + return bound, nil |
| +} |
| +func (c *readonlyConnection) check() error { |
| + if c.closed || c.owner.pathMatches() != nil { |
| + return ErrRefused |
| + } |
| + all, e := descriptors() |
| + if e != nil { |
| + return e |
| + } |
| + if len(all) != len(c.baseline)+1 { |
| + return ErrRefused |
| + } |
| + for fd, old := range c.baseline { |
| + if all[fd] != old { |
| + return ErrRefused |
| + } |
| + } |
| + main, ok := all[c.mainFD] |
| + if !ok || main.identity != c.owner.identity || main.flags&unix.O_ACCMODE != unix.O_RDONLY || main.links != 1 { |
| + return ErrRefused |
| + } |
| + return nil |
| +} |
| +func (c *readonlyConnection) Close() error { |
| + c.owner.mu.Lock() |
| + defer c.owner.mu.Unlock() |
| + if c.closed { |
| + return nil |
| + } |
| + c.closed = true |
| + e := c.Conn.Close() |
| + c.owner.active = false |
| + return e |
| +} |
| +func (c *readonlyConnection) Prepare(string) (driver.Stmt, error) { return nil, ErrRefused } |
| +func (c *readonlyConnection) PrepareContext(context.Context, string) (driver.Stmt, error) { |
| + return nil, ErrRefused |
| +} |
| +func (c *readonlyConnection) Begin() (driver.Tx, error) { |
| + return c.BeginTx(context.Background(), driver.TxOptions{}) |
| +} |
| +func (c *readonlyConnection) BeginTx(ctx context.Context, opts driver.TxOptions) (driver.Tx, error) { |
| + if c.check() != nil { |
| + return nil, ErrRefused |
| + } |
| + x, ok := c.Conn.(driver.ConnBeginTx) |
| + if !ok { |
| + return nil, ErrRefused |
| + } |
| + tx, e := x.BeginTx(ctx, opts) |
| + if e != nil { |
| + return nil, e |
| + } |
| + if c.check() != nil { |
| + tx.Rollback() |
| + return nil, ErrUnavailable |
| + } |
| + return &readonlyTransaction{tx, c}, nil |
| +} |
| +func (c *readonlyConnection) ExecContext(ctx context.Context, query string, args []driver.NamedValue) (driver.Result, error) { |
| + if query != "ROLLBACK" && c.check() != nil { |
| + return nil, ErrRefused |
| + } |
| + x, ok := c.Conn.(driver.ExecerContext) |
| + if !ok { |
| + return nil, ErrRefused |
| + } |
| + result, e := x.ExecContext(ctx, query, args) |
| + if e != nil { |
| + return nil, e |
| + } |
| + c.owner.checkpoint("after-exec") |
| + if query != "ROLLBACK" && c.check() != nil { |
| + return nil, ErrUnavailable |
| + } |
| + return result, nil |
| +} |
| +func (c *readonlyConnection) QueryContext(ctx context.Context, query string, args []driver.NamedValue) (driver.Rows, error) { |
| + if c.check() != nil { |
| + return nil, ErrRefused |
| + } |
| + x, ok := c.Conn.(driver.QueryerContext) |
| + if !ok { |
| + return nil, ErrRefused |
| + } |
| + rows, e := x.QueryContext(ctx, query, args) |
| + if e != nil { |
| + return nil, e |
| + } |
| + if c.check() != nil { |
| + rows.Close() |
| + return nil, ErrUnavailable |
| + } |
| + return &readonlyRows{rows, c}, nil |
| +} |
| +func (c *readonlyConnection) Ping(ctx context.Context) error { |
| + if c.check() != nil { |
| + return ErrRefused |
| + } |
| + p, ok := c.Conn.(driver.Pinger) |
| + if !ok { |
| + return ErrRefused |
| + } |
| + if e := p.Ping(ctx); e != nil { |
| + return e |
| + } |
| + if c.check() != nil { |
| + return ErrUnavailable |
| + } |
| + return nil |
| +} |
| +func (c *readonlyConnection) ResetSession(ctx context.Context) error { |
| + if c.check() != nil { |
| + return ErrRefused |
| + } |
| + if r, ok := c.Conn.(driver.SessionResetter); ok { |
| + return r.ResetSession(ctx) |
| + } |
| + return nil |
| +} |
| +func (c *readonlyConnection) IsValid() bool { return c.check() == nil } |
| + |
| +type readonlyRows struct { |
| + driver.Rows |
| + c *readonlyConnection |
| +} |
| + |
| +func (r *readonlyRows) Next(values []driver.Value) error { |
| + if r.c.check() != nil { |
| + return ErrRefused |
| + } |
| + e := r.Rows.Next(values) |
| + if e != nil { |
| + return e |
| + } |
| + if r.c.check() != nil { |
| + return ErrUnavailable |
| + } |
| + return nil |
| +} |
| + |
| +type readonlyTransaction struct { |
| + driver.Tx |
| + c *readonlyConnection |
| +} |
| + |
| +func (t *readonlyTransaction) Commit() error { |
| + if t.c.check() != nil { |
| + return ErrRefused |
| + } |
| + e := t.Tx.Commit() |
| + t.c.owner.checkpoint("after-commit") |
| + if e != nil { |
| + return e |
| + } |
| + if t.c.check() != nil { |
| + return ErrUnavailable |
| + } |
| + return nil |
| +} |
diff --git a/internal/operator/readonly_linux_test.go b/internal/operator/readonly_linux_test.go| new file mode 100644 |
| index 0000000..de0a190 |
| --- /dev/null |
| +++ b/internal/operator/readonly_linux_test.go |
| @@ -0,0 +1,161 @@ |
| +//go:build linux |
| + |
| +package operator |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "crypto/sha256" |
| + "database/sql" |
| + "encoding/hex" |
| + "encoding/json" |
| + "io" |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func TestReadonlySubprocess(t *testing.T) { |
| + if os.Getenv("OFICINA_READONLY_PROBE") == "1" { |
| + var input struct { |
| + Expected ReadonlyExpectation |
| + Mode string |
| + } |
| + if json.NewDecoder(io.LimitReader(os.Stdin, 16384)).Decode(&input) != nil { |
| + t.Fatal("input") |
| + } |
| + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) |
| + defer cancel() |
| + calls := 0 |
| + proof := func(context.Context, ReadonlyExpectation) error { |
| + calls++ |
| + if input.Mode == "proof-revoked" && calls > 1 { |
| + return ErrRefused |
| + } |
| + return nil |
| + } |
| + if input.Mode == "missing-proof" { |
| + proof = nil |
| + } |
| + if input.Mode == "wrong-hash" { |
| + input.Expected.SHA256 = string(bytes.Repeat([]byte("0"), 64)) |
| + } |
| + if input.Mode == "wrong-inode" { |
| + input.Expected.Inode++ |
| + } |
| + if input.Mode == "expired" { |
| + cancel() |
| + } |
| + if input.Mode == "unbounded" { |
| + ctx = context.Background() |
| + } |
| + var extra *os.File |
| + defer func() { |
| + if extra != nil { |
| + extra.Close() |
| + } |
| + }() |
| + inspect := func(ctx context.Context, c *sql.Conn) error { |
| + if input.Mode == "unavailable" { |
| + return ErrUnavailable |
| + } |
| + if input.Mode == "spill" { |
| + var e error |
| + extra, e = os.Create(input.Expected.Path + ".spill") |
| + if e != nil { |
| + return e |
| + } |
| + } |
| + var count int |
| + if e := c.QueryRowContext(ctx, "SELECT count(*) FROM changes").Scan(&count); e != nil { |
| + return e |
| + } |
| + if count != 0 { |
| + return ErrRefused |
| + } |
| + if input.Mode == "write" { |
| + _, e := c.ExecContext(ctx, "INSERT INTO changes VALUES(1)") |
| + return e |
| + } |
| + return nil |
| + } |
| + lastStage := "before-inspection" |
| + hook := func(stage string) { |
| + lastStage = stage |
| + if input.Mode == "replace-restore" { |
| + if stage == "before-driver" { |
| + if os.Rename(input.Expected.Path, input.Expected.Path+".saved") != nil || os.Rename(input.Expected.Path+".other", input.Expected.Path) != nil { |
| + t.Fatal("swap") |
| + } |
| + } |
| + if stage == "after-open" { |
| + if os.Rename(input.Expected.Path, input.Expected.Path+".other") != nil || os.Rename(input.Expected.Path+".saved", input.Expected.Path) != nil { |
| + t.Fatal("restore") |
| + } |
| + } |
| + } |
| + if input.Mode == "change-after-read" && stage == "after-exec" { |
| + // Alter an unused SQLite header byte through an external process: no |
| + // extra descriptor is admitted into the checker process. |
| + cmd := exec.Command("python3", "-c", "import sys; f=open(sys.argv[1],'r+b'); f.seek(72); f.write(b'\\x01'); f.close()", input.Expected.Path) |
| + if e := cmd.Run(); e != nil { |
| + t.Fatal(e) |
| + } |
| + } |
| + } |
| + err := inspectReadonly(ctx, input.Expected, proof, inspect, hook) |
| + if input.Mode == "unavailable" && err != ErrUnavailable { |
| + t.Fatalf("lost unavailable outcome: %v", err) |
| + } |
| + if input.Mode == "valid" { |
| + if err != nil { |
| + t.Fatalf("inspection refused after %s: %v", lastStage, err) |
| + } |
| + } else if err == nil { |
| + t.Fatal("unsafe success", input.Mode) |
| + } |
| + return |
| + } |
| + for _, mode := range []string{"valid", "unavailable", "proof-revoked", "replace-restore", "missing-proof", "wrong-hash", "wrong-inode", "expired", "unbounded", "wal", "shm", "journal", "spill", "write", "change-after-read"} { |
| + t.Run(mode, func(t *testing.T) { |
| + path := filepath.Join(t.TempDir(), "test.db") |
| + seedOpenDB(t, path) |
| + if mode == "replace-restore" { |
| + seedOpenDB(t, path+".other") |
| + } |
| + raw, e := os.ReadFile(path) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + h := sha256.Sum256(raw) |
| + id, e := statExisting(path) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + expected := ReadonlyExpectation{path, id.device, id.inode, int64(len(raw)), hex.EncodeToString(h[:])} |
| + if mode == "wal" || mode == "shm" || mode == "journal" { |
| + if e := os.WriteFile(path+"-"+mode, []byte("retained"), 0600); e != nil { |
| + t.Fatal(e) |
| + } |
| + } |
| + input, _ := json.Marshal(struct { |
| + Expected ReadonlyExpectation |
| + Mode string |
| + }{expected, mode}) |
| + cmd := exec.Command(os.Args[0], "-test.run=^TestReadonlySubprocess$") |
| + cmd.Env = append(os.Environ(), "OFICINA_READONLY_PROBE=1") |
| + cmd.Stdin = bytes.NewReader(input) |
| + if out, e := cmd.CombinedOutput(); e != nil { |
| + t.Fatalf("child: %v %s", e, out) |
| + } |
| + if mode != "change-after-read" { |
| + after, e := os.ReadFile(path) |
| + if e != nil || !bytes.Equal(raw, after) { |
| + t.Fatal("database bytes changed") |
| + } |
| + } |
| + }) |
| + } |
| +} |