| 1 | //go:build linux |
| 2 | |
| 3 | package home |
| 4 | |
| 5 | import ( |
| 6 | "context" |
| 7 | "crypto/sha256" |
| 8 | "database/sql" |
| 9 | "embed" |
| 10 | "encoding/base64" |
| 11 | "encoding/hex" |
| 12 | "encoding/json" |
| 13 | "strconv" |
| 14 | "strings" |
| 15 | "unicode" |
| 16 | "unicode/utf8" |
| 17 | |
| 18 | "amadan.net/oficina/home/handoff" |
| 19 | "amadan.net/oficina/home/internal/home/directorydata" |
| 20 | "amadan.net/oficina/home/internal/home/seal" |
| 21 | "amadan.net/oficina/home/internal/operator" |
| 22 | "amadan.net/rastrillo/rastrillo/migrate" |
| 23 | ) |
| 24 | |
| 25 | // This component has no command or serving entry point. Only the future verified |
| 26 | // launch/enrollment adapter may supply these expectations. Synthetic tests are |
| 27 | // currently the sole caller; a non-nil callback is not platform authorization. |
| 28 | type compatibilityConfig struct { |
| 29 | role, origin string |
| 30 | dek, pepper, signin, mac []byte |
| 31 | expectedKeys map[string]string |
| 32 | state compatibilityState |
| 33 | witness, generation string |
| 34 | team int64 |
| 35 | verify func(context.Context, operator.ReadonlyExpectation) error |
| 36 | } |
| 37 | type compatibilityState struct { |
| 38 | Origin, Installation, Registry, Device, Head, KeyID string |
| 39 | Sequence, Completed int64 |
| 40 | } |
| 41 | type schemaObject struct{ Type, Name, Table, SQL string } |
| 42 | |
| 43 | //go:embed compatibility_profiles/*.json |
| 44 | var compatibilityProfiles embed.FS |
| 45 | |
| 46 | func compatibilitySchema(role string) *migrate.Set { |
| 47 | if role == "credential" { |
| 48 | return migrate.Merge(BootSchema, operator.CredentialSchema) |
| 49 | } |
| 50 | if role == "directory" { |
| 51 | return migrate.Merge(directorySchema, directorydata.OperatorSchema, operator.DirectorySchema) |
| 52 | } |
| 53 | return nil |
| 54 | } |
| 55 | |
| 56 | // inspectCompatibilityDatabase implements only the database portion of the |
| 57 | // approved checker. Config-facts, launch parsing and enrollment are not mounted. |
| 58 | // It conservatively accepts initialized operator state only; a reviewed caller |
| 59 | // for the separately enrolled empty baseline does not exist yet. |
| 60 | func inspectCompatibilityDatabase(ctx context.Context, expected operator.ReadonlyExpectation, cfg compatibilityConfig) error { |
| 61 | schema := compatibilitySchema(cfg.role) |
| 62 | 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 == "" { |
| 63 | return operator.ErrRefused |
| 64 | } |
| 65 | keys := map[string][]byte{"dek": cfg.dek, "mac": cfg.mac} |
| 66 | if cfg.role == "credential" { |
| 67 | keys["pepper"] = cfg.pepper |
| 68 | keys["signin"] = cfg.signin |
| 69 | } else if len(cfg.pepper) != 0 || len(cfg.signin) != 0 { |
| 70 | return operator.ErrRefused |
| 71 | } |
| 72 | if len(cfg.expectedKeys) != len(keys) { |
| 73 | return operator.ErrRefused |
| 74 | } |
| 75 | for name, key := range keys { |
| 76 | if len(key) != 32 { |
| 77 | return operator.ErrRefused |
| 78 | } |
| 79 | h := sha256.Sum256(key) |
| 80 | if cfg.expectedKeys[name] != hex.EncodeToString(h[:]) { |
| 81 | return operator.ErrRefused |
| 82 | } |
| 83 | } |
| 84 | sealer, e := seal.New(cfg.dek) |
| 85 | if e != nil { |
| 86 | return operator.ErrRefused |
| 87 | } |
| 88 | raw, e := compatibilityProfiles.ReadFile("compatibility_profiles/" + cfg.role + ".json") |
| 89 | if e != nil { |
| 90 | return operator.ErrRefused |
| 91 | } |
| 92 | var profile []schemaObject |
| 93 | if json.Unmarshal(raw, &profile) != nil || len(profile) == 0 { |
| 94 | return operator.ErrRefused |
| 95 | } |
| 96 | return operator.InspectReadonly(ctx, expected, cfg.verify, func(ctx context.Context, c *sql.Conn) error { |
| 97 | if e := checkCompatibilitySchema(ctx, c, schema, profile); e != nil { |
| 98 | return e |
| 99 | } |
| 100 | prefix := cfg.role + "_operator" |
| 101 | var state compatibilityState |
| 102 | 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 { |
| 103 | return operator.ErrRefused |
| 104 | } |
| 105 | budget := compatibilityBudget{} |
| 106 | if cfg.role == "credential" { |
| 107 | var ref string |
| 108 | if c.QueryRowContext(ctx, "SELECT person_ref FROM people WHERE person_ref=?", cfg.witness).Scan(&ref) != nil { |
| 109 | return operator.ErrRefused |
| 110 | } |
| 111 | if e := budget.scan(ctx, c, "SELECT person_ref,blind_index FROM people", func(rows *sql.Rows) error { |
| 112 | var ref, index string |
| 113 | if rows.Scan(&ref, &index) != nil || !ValidPersonRef(ref) || !hexHash(index) { |
| 114 | return operator.ErrRefused |
| 115 | } |
| 116 | return nil |
| 117 | }); e != nil { |
| 118 | return e |
| 119 | } |
| 120 | } else { |
| 121 | var generation, role string |
| 122 | if !canonicalHash(cfg.generation) || cfg.team <= 0 || c.QueryRowContext(ctx, "SELECT generation FROM roster_generation WHERE singleton=1").Scan(&generation) != nil || generation != cfg.generation { |
| 123 | return operator.ErrRefused |
| 124 | } |
| 125 | if c.QueryRowContext(ctx, "SELECT role FROM memberships WHERE team_id=? AND person_ref=?", cfg.team, cfg.witness).Scan(&role) != nil || role != "owner" { |
| 126 | return operator.ErrRefused |
| 127 | } |
| 128 | } |
| 129 | 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 { |
| 130 | var id, action string |
| 131 | var ciphertext []byte |
| 132 | if rows.Scan(&id, &action, &ciphertext) != nil || !canonicalHash(id) || len(action) == 0 || len(action) > 64 { |
| 133 | return operator.ErrRefused |
| 134 | } |
| 135 | return budget.open(sealer, "operator.result/v1/"+state.Origin+"/"+id+"/"+action, ciphertext, 49180, func(raw []byte) bool { return len(raw) > 0 && json.Valid(raw) }) |
| 136 | }); e != nil { |
| 137 | return e |
| 138 | } |
| 139 | if cfg.role == "credential" { |
| 140 | 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 { |
| 141 | var ciphertext []byte |
| 142 | if rows.Scan(&ciphertext) != nil { |
| 143 | return operator.ErrRefused |
| 144 | } |
| 145 | return budget.open(sealer, "signin.address", ciphertext, 4096, func(raw []byte) bool { return len(raw) > 0 && utf8.Valid(raw) }) |
| 146 | }) |
| 147 | } |
| 148 | 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 { |
| 149 | var id, epoch int64 |
| 150 | var sqid, label string |
| 151 | var ciphertext []byte |
| 152 | if rows.Scan(&id, &sqid, &label, &epoch, &ciphertext) != nil || id <= 0 || epoch <= 0 || !directorydata.ValidLabel(label) || len(sqid) == 0 || len(sqid) > 64 { |
| 153 | return operator.ErrRefused |
| 154 | } |
| 155 | for _, r := range sqid { |
| 156 | if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9') { |
| 157 | return operator.ErrRefused |
| 158 | } |
| 159 | } |
| 160 | return budget.open(sealer, "directory.team-name/"+strconv.FormatInt(id, 10), ciphertext, 284, func(raw []byte) bool { |
| 161 | if len(raw) > 256 || !utf8.Valid(raw) || strings.TrimSpace(string(raw)) == "" { |
| 162 | return false |
| 163 | } |
| 164 | for _, r := range string(raw) { |
| 165 | if unicode.IsControl(r) { |
| 166 | return false |
| 167 | } |
| 168 | } |
| 169 | return true |
| 170 | }) |
| 171 | }); e != nil { |
| 172 | return e |
| 173 | } |
| 174 | if e := budget.scan(ctx, c, "SELECT person_ref,role FROM memberships", func(rows *sql.Rows) error { |
| 175 | var ref, role string |
| 176 | if rows.Scan(&ref, &role) != nil || !ValidPersonRef(ref) || (role != "owner" && role != "member") { |
| 177 | return operator.ErrRefused |
| 178 | } |
| 179 | return nil |
| 180 | }); e != nil { |
| 181 | return e |
| 182 | } |
| 183 | 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 { |
| 184 | var route directorydata.Route |
| 185 | if rows.Scan(&route.TeamID, &route.TeamSQID, &route.Label, &route.App, &route.Origin, &route.ConsumerID) != nil || !directoryRouteAllowed(route) || !consumerName(route.ConsumerID) { |
| 186 | return operator.ErrRefused |
| 187 | } |
| 188 | return nil |
| 189 | }); e != nil { |
| 190 | return e |
| 191 | } |
| 192 | for _, query := range []string{ |
| 193 | "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", |
| 194 | "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", |
| 195 | } { |
| 196 | var bad int |
| 197 | if c.QueryRowContext(ctx, query).Scan(&bad) != nil || bad != 0 { |
| 198 | return operator.ErrRefused |
| 199 | } |
| 200 | } |
| 201 | for _, item := range []struct { |
| 202 | query, purpose string |
| 203 | limit int |
| 204 | path bool |
| 205 | }{ |
| 206 | {"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}, |
| 207 | {"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}, |
| 208 | } { |
| 209 | if e := budget.scan(ctx, c, item.query, func(rows *sql.Rows) error { |
| 210 | var hash string |
| 211 | var ciphertext []byte |
| 212 | if rows.Scan(&hash, &ciphertext) != nil || !hexHash(hash) { |
| 213 | return operator.ErrRefused |
| 214 | } |
| 215 | return budget.open(sealer, item.purpose+hash, ciphertext, item.limit, func(raw []byte) bool { |
| 216 | if !item.path { |
| 217 | return len(raw) > 0 && len(raw) <= 12288 && utf8.Valid(raw) |
| 218 | } |
| 219 | path := strings.TrimPrefix(string(raw), "oficina-handoff-fresh-v1:") |
| 220 | return handoff.SafePath(path) |
| 221 | }) |
| 222 | }); e != nil { |
| 223 | return e |
| 224 | } |
| 225 | } |
| 226 | return nil |
| 227 | }) |
| 228 | } |
| 229 | |
| 230 | type compatibilityBudget struct{ rows, bytes int } |
| 231 | |
| 232 | func (b *compatibilityBudget) scan(ctx context.Context, c *sql.Conn, query string, visit func(*sql.Rows) error) error { |
| 233 | rows, e := c.QueryContext(ctx, query+" LIMIT 100001") |
| 234 | if e != nil { |
| 235 | return operator.ErrUnavailable |
| 236 | } |
| 237 | defer rows.Close() |
| 238 | for rows.Next() { |
| 239 | b.rows++ |
| 240 | if b.rows > 100000 || ctx.Err() != nil { |
| 241 | return operator.ErrUnavailable |
| 242 | } |
| 243 | if e = visit(rows); e != nil { |
| 244 | return e |
| 245 | } |
| 246 | } |
| 247 | if rows.Err() != nil { |
| 248 | return operator.ErrUnavailable |
| 249 | } |
| 250 | return rows.Close() |
| 251 | } |
| 252 | func (b *compatibilityBudget) open(s *seal.Sealer, purpose string, ciphertext []byte, max int, valid func([]byte) bool) error { |
| 253 | b.bytes += len(ciphertext) |
| 254 | if len(ciphertext) < 29 || len(ciphertext) > max || b.bytes > 64<<20 { |
| 255 | return operator.ErrRefused |
| 256 | } |
| 257 | raw, e := s.Open(purpose, ciphertext) |
| 258 | if e != nil { |
| 259 | return operator.ErrRefused |
| 260 | } |
| 261 | defer clear(raw) |
| 262 | if !valid(raw) { |
| 263 | return operator.ErrRefused |
| 264 | } |
| 265 | return nil |
| 266 | } |
| 267 | func checkCompatibilitySchema(ctx context.Context, c *sql.Conn, set *migrate.Set, profile []schemaObject) error { |
| 268 | if err := operator.VerifyLedger(ctx, c, set); err != nil { |
| 269 | return err |
| 270 | } |
| 271 | objects := map[string]schemaObject{} |
| 272 | for _, p := range profile { |
| 273 | objects[p.Type+"/"+p.Name] = p |
| 274 | } |
| 275 | rows, e := c.QueryContext(ctx, "SELECT type,name,tbl_name,coalesce(sql,'') FROM sqlite_schema LIMIT 1001") |
| 276 | if e != nil { |
| 277 | return operator.ErrRefused |
| 278 | } |
| 279 | for rows.Next() { |
| 280 | var p schemaObject |
| 281 | if rows.Scan(&p.Type, &p.Name, &p.Table, &p.SQL) != nil { |
| 282 | rows.Close() |
| 283 | return operator.ErrRefused |
| 284 | } |
| 285 | key := p.Type + "/" + p.Name |
| 286 | if objects[key] != p { |
| 287 | rows.Close() |
| 288 | return operator.ErrRefused |
| 289 | } |
| 290 | delete(objects, key) |
| 291 | } |
| 292 | if rows.Err() != nil || rows.Close() != nil || len(objects) != 0 { |
| 293 | return operator.ErrRefused |
| 294 | } |
| 295 | total := 0 |
| 296 | for _, p := range profile { |
| 297 | if p.Type != "table" { |
| 298 | continue |
| 299 | } |
| 300 | var count int |
| 301 | query := "SELECT count(*) FROM (SELECT 1 FROM \"" + strings.ReplaceAll(p.Name, "\"", "\"\"") + "\" LIMIT 100001)" |
| 302 | if c.QueryRowContext(ctx, query).Scan(&count) != nil { |
| 303 | return operator.ErrRefused |
| 304 | } |
| 305 | total += count |
| 306 | if total > 100000 { |
| 307 | return operator.ErrUnavailable |
| 308 | } |
| 309 | } |
| 310 | rows, e = c.QueryContext(ctx, "PRAGMA foreign_key_check") |
| 311 | if e != nil { |
| 312 | return operator.ErrRefused |
| 313 | } |
| 314 | bad := rows.Next() |
| 315 | e = rows.Err() |
| 316 | closeErr := rows.Close() |
| 317 | if bad || e != nil || closeErr != nil { |
| 318 | return operator.ErrRefused |
| 319 | } |
| 320 | var n int |
| 321 | if c.QueryRowContext(ctx, "SELECT count(*) FROM (SELECT 1 FROM _litestream_lock LIMIT 1)").Scan(&n) != nil || n != 0 { |
| 322 | return operator.ErrRefused |
| 323 | } |
| 324 | if c.QueryRowContext(ctx, "SELECT count(*) FROM (SELECT 1 FROM _litestream_seq LIMIT 2)").Scan(&n) != nil || n > 1 { |
| 325 | return operator.ErrRefused |
| 326 | } |
| 327 | if n == 1 { |
| 328 | var valid int |
| 329 | 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 { |
| 330 | return operator.ErrRefused |
| 331 | } |
| 332 | } |
| 333 | return nil |
| 334 | } |
| 335 | |
| 336 | func canonicalHash(s string) bool { |
| 337 | raw, e := base64.RawURLEncoding.DecodeString(s) |
| 338 | return e == nil && len(raw) == 32 && base64.RawURLEncoding.EncodeToString(raw) == s |
| 339 | } |
| 340 | func hexHash(s string) bool { |
| 341 | raw, e := hex.DecodeString(s) |
| 342 | return e == nil && len(raw) == 32 && hex.EncodeToString(raw) == s |
| 343 | } |
| 344 | |