| 1 | //go:build linux |
| 2 | |
| 3 | package home |
| 4 | |
| 5 | import ( |
| 6 | "bytes" |
| 7 | "context" |
| 8 | "crypto/sha256" |
| 9 | "database/sql" |
| 10 | "encoding/base64" |
| 11 | "encoding/hex" |
| 12 | "encoding/json" |
| 13 | "io" |
| 14 | "os" |
| 15 | "os/exec" |
| 16 | "path/filepath" |
| 17 | "testing" |
| 18 | "time" |
| 19 | |
| 20 | "amadan.net/oficina/home/internal/home/seal" |
| 21 | "amadan.net/oficina/home/internal/operator" |
| 22 | "amadan.net/rastrillo/rastrillo/migrate" |
| 23 | "golang.org/x/sys/unix" |
| 24 | ) |
| 25 | |
| 26 | const replicationCompatibilityDDL = "CREATE TABLE IF NOT EXISTS _litestream_seq (id INTEGER PRIMARY KEY, seq INTEGER); CREATE TABLE IF NOT EXISTS _litestream_lock (id INTEGER);" |
| 27 | |
| 28 | func compatibilityFixture(t *testing.T, role string) *sql.DB { |
| 29 | t.Helper() |
| 30 | set := compatibilitySchema(role) |
| 31 | d, e := migrate.Replay(context.Background(), set.All()) |
| 32 | if e != nil { |
| 33 | t.Fatal(e) |
| 34 | } |
| 35 | for _, q := range []string{migrate.LedgerDDL, replicationCompatibilityDDL} { |
| 36 | if _, e = d.Exec(q); e != nil { |
| 37 | t.Fatal(e) |
| 38 | } |
| 39 | } |
| 40 | for _, m := range set.All() { |
| 41 | if m.Fn != nil { |
| 42 | t.Fatal("unsupported Go migration") |
| 43 | } |
| 44 | if _, e = d.Exec("INSERT INTO rastrillo_migrations VALUES(?,?,?)", m.ID, "2026-01-01T00:00:00Z", migrate.Checksum(m.SQL)); e != nil { |
| 45 | t.Fatal(e) |
| 46 | } |
| 47 | } |
| 48 | return d |
| 49 | } |
| 50 | func readCompatibilityProfile(t *testing.T, d *sql.DB) []schemaObject { |
| 51 | t.Helper() |
| 52 | rows, e := d.Query("SELECT type,name,tbl_name,coalesce(sql,'') FROM sqlite_schema ORDER BY type,name") |
| 53 | if e != nil { |
| 54 | t.Fatal(e) |
| 55 | } |
| 56 | defer rows.Close() |
| 57 | var objects []schemaObject |
| 58 | for rows.Next() { |
| 59 | var p schemaObject |
| 60 | if e = rows.Scan(&p.Type, &p.Name, &p.Table, &p.SQL); e != nil { |
| 61 | t.Fatal(e) |
| 62 | } |
| 63 | objects = append(objects, p) |
| 64 | } |
| 65 | if rows.Err() != nil { |
| 66 | t.Fatal(rows.Err()) |
| 67 | } |
| 68 | return objects |
| 69 | } |
| 70 | func TestCompatibilityFrozenProfiles(t *testing.T) { |
| 71 | for _, role := range []string{"credential", "directory"} { |
| 72 | t.Run(role, func(t *testing.T) { |
| 73 | d := compatibilityFixture(t, role) |
| 74 | defer d.Close() |
| 75 | raw, e := json.MarshalIndent(readCompatibilityProfile(t, d), "", " ") |
| 76 | if e != nil { |
| 77 | t.Fatal(e) |
| 78 | } |
| 79 | raw = append(raw, '\n') |
| 80 | path := "compatibility_profiles/" + role + ".json" |
| 81 | if os.Getenv("OFICINA_GENERATE_COMPATIBILITY_PROFILE") == "1" { |
| 82 | if e = os.WriteFile(path, raw, 0600); e != nil { |
| 83 | t.Fatal(e) |
| 84 | } |
| 85 | return |
| 86 | } |
| 87 | expected, e := compatibilityProfiles.ReadFile(path) |
| 88 | if e != nil || !bytes.Equal(raw, expected) { |
| 89 | t.Fatal("frozen selected-release schema changed; review and regenerate") |
| 90 | } |
| 91 | }) |
| 92 | } |
| 93 | } |
| 94 | func compatibilityTestConfig(role string) compatibilityConfig { |
| 95 | 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} |
| 96 | if role == "credential" { |
| 97 | cfg.pepper = bytes.Repeat([]byte{3}, 32) |
| 98 | cfg.signin = bytes.Repeat([]byte{4}, 32) |
| 99 | } else { |
| 100 | cfg.origin = "https://home.example.test" |
| 101 | } |
| 102 | for name, key := range map[string][]byte{"dek": cfg.dek, "mac": cfg.mac, "pepper": cfg.pepper, "signin": cfg.signin} { |
| 103 | if len(key) == 0 { |
| 104 | continue |
| 105 | } |
| 106 | h := sha256.Sum256(key) |
| 107 | cfg.expectedKeys[name] = hex.EncodeToString(h[:]) |
| 108 | } |
| 109 | cfg.state = compatibilityState{cfg.origin, "installation", "registry", "device", cfg.generation, "key", 1, 1} |
| 110 | cfg.verify = func(context.Context, operator.ReadonlyExpectation) error { return nil } |
| 111 | return cfg |
| 112 | } |
| 113 | func seedCompatibilityData(t *testing.T, d *sql.DB, cfg compatibilityConfig) { |
| 114 | t.Helper() |
| 115 | must := func(q string, args ...any) { |
| 116 | t.Helper() |
| 117 | if _, e := d.Exec(q, args...); e != nil { |
| 118 | t.Fatal(e) |
| 119 | } |
| 120 | } |
| 121 | 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) |
| 122 | s, _ := seal.New(cfg.dek) |
| 123 | result, _ := s.Seal("operator.result/v1/"+cfg.origin+"/"+cfg.generation+"/owner-seed", []byte(`{"ok":true}`)) |
| 124 | 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) |
| 125 | if cfg.role == "credential" { |
| 126 | h := sha256.Sum256([]byte("synthetic")) |
| 127 | must("INSERT INTO people VALUES(?,?,?)", cfg.witness, hex.EncodeToString(h[:]), "2026-01-01T00:00:00Z") |
| 128 | sealed, _ := s.Seal("signin.address", []byte("synthetic@example.invalid")) |
| 129 | must("INSERT INTO signin_links VALUES(?,?,?,?)", "hash", sealed, "signin", "2020-01-01T00:00:00Z") |
| 130 | } else { |
| 131 | sealed, _ := s.Seal("directory.team-name/1", []byte("Synthetic")) |
| 132 | must("INSERT INTO teams(id,sqid,label,sealed_name,created_at) VALUES(1,'abc','synthetic',?,1)", sealed) |
| 133 | must("INSERT INTO memberships VALUES(1,?,'owner')", cfg.witness) |
| 134 | must("INSERT INTO roster_generation VALUES(1,?)", cfg.generation) |
| 135 | hash := hex.EncodeToString(bytes.Repeat([]byte{6}, 32)) |
| 136 | sealed, _ = s.Seal("handoff.path."+hash, []byte("oficina-handoff-fresh-v1:/account")) |
| 137 | must("INSERT INTO oficina_handoff_pending VALUES(?,?,?,1,2)", hash, hash, sealed) |
| 138 | sealed, _ = s.Seal("handoff.token."+hash, []byte("expired-synthetic-token")) |
| 139 | must("INSERT INTO oficina_handoff_inbox VALUES(?,?,?,1,2)", hash, hash, sealed) |
| 140 | } |
| 141 | } |
| 142 | func TestCompatibilityDatabaseSubprocess(t *testing.T) { |
| 143 | if os.Getenv("OFICINA_COMPATIBILITY_PROBE") == "1" { |
| 144 | var in struct { |
| 145 | Expected operator.ReadonlyExpectation |
| 146 | Role, Mode string |
| 147 | } |
| 148 | if json.NewDecoder(io.LimitReader(os.Stdin, 16384)).Decode(&in) != nil { |
| 149 | t.Fatal("input") |
| 150 | } |
| 151 | cfg := compatibilityTestConfig(in.Role) |
| 152 | switch in.Mode { |
| 153 | case "missing-proof": |
| 154 | cfg.verify = nil |
| 155 | case "missing-key": |
| 156 | delete(cfg.expectedKeys, "dek") |
| 157 | case "wrong-key": |
| 158 | cfg.dek = bytes.Repeat([]byte{9}, 32) |
| 159 | case "wrong-key-enrolled": |
| 160 | cfg.dek = bytes.Repeat([]byte{9}, 32) |
| 161 | sum := sha256.Sum256(cfg.dek) |
| 162 | cfg.expectedKeys["dek"] = hex.EncodeToString(sum[:]) |
| 163 | case "wrong-state": |
| 164 | cfg.state.Sequence++ |
| 165 | case "wrong-witness": |
| 166 | cfg.witness = "pr_bbbbbbbbbbbbbbbbbbbbbbbbbb" |
| 167 | } |
| 168 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) |
| 169 | defer cancel() |
| 170 | e := inspectCompatibilityDatabase(ctx, in.Expected, cfg) |
| 171 | if in.Mode == "valid" { |
| 172 | if e != nil { |
| 173 | t.Fatal(e) |
| 174 | } |
| 175 | } else if e == nil { |
| 176 | t.Fatal("unsafe success", in.Mode) |
| 177 | } |
| 178 | return |
| 179 | } |
| 180 | for _, role := range []string{"credential", "directory"} { |
| 181 | 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"} { |
| 182 | t.Run(role+"/"+mode, func(t *testing.T) { |
| 183 | cfg := compatibilityTestConfig(role) |
| 184 | d := compatibilityFixture(t, role) |
| 185 | seedCompatibilityData(t, d, cfg) |
| 186 | q := "" |
| 187 | switch mode { |
| 188 | case "oversized-ciphertext": |
| 189 | if role == "credential" { |
| 190 | q = "UPDATE signin_links SET sealed_address=zeroblob(65536)" |
| 191 | } else { |
| 192 | q = "UPDATE oficina_handoff_inbox SET sealed_token=zeroblob(65536)" |
| 193 | } |
| 194 | case "operator-ciphertext": |
| 195 | q = "UPDATE " + role + "_operator_results SET sealed_result=zeroblob(40)" |
| 196 | case "path-ciphertext": |
| 197 | if role == "directory" { |
| 198 | q = "UPDATE oficina_handoff_pending SET sealed_path=zeroblob(40)" |
| 199 | } else { |
| 200 | q = "UPDATE signin_links SET sealed_address=zeroblob(40)" |
| 201 | } |
| 202 | case "schema": |
| 203 | q = "CREATE TRIGGER unexpected AFTER DELETE ON " + role + "_operator_state BEGIN SELECT 1; END" |
| 204 | case "ledger": |
| 205 | q = "UPDATE rastrillo_migrations SET checksum='bad'" |
| 206 | case "replication": |
| 207 | q = "INSERT INTO _litestream_lock VALUES(1)" |
| 208 | case "empty-state": |
| 209 | q = "DELETE FROM " + role + "_operator_state" |
| 210 | case "ciphertext": |
| 211 | if role == "credential" { |
| 212 | q = "UPDATE signin_links SET sealed_address=zeroblob(40)" |
| 213 | } else { |
| 214 | q = "UPDATE oficina_handoff_inbox SET sealed_token=zeroblob(40)" |
| 215 | } |
| 216 | case "foreign-key": |
| 217 | if role == "directory" { |
| 218 | q = "INSERT INTO memberships VALUES(999,'pr_bbbbbbbbbbbbbbbbbbbbbbbbbb','member')" |
| 219 | } else { |
| 220 | q = "UPDATE people SET person_ref='broken'" |
| 221 | } |
| 222 | } |
| 223 | if q != "" { |
| 224 | if _, e := d.Exec(q); e != nil { |
| 225 | t.Fatal(e) |
| 226 | } |
| 227 | } |
| 228 | path := filepath.Join(t.TempDir(), "role.db") |
| 229 | if _, e := d.Exec("VACUUM INTO ?", path); e != nil { |
| 230 | t.Fatal(e) |
| 231 | } |
| 232 | if e := d.Close(); e != nil { |
| 233 | t.Fatal(e) |
| 234 | } |
| 235 | raw, e := os.ReadFile(path) |
| 236 | if e != nil { |
| 237 | t.Fatal(e) |
| 238 | } |
| 239 | h := sha256.Sum256(raw) |
| 240 | var st unix.Stat_t |
| 241 | if unix.Stat(path, &st) != nil { |
| 242 | t.Fatal("stat") |
| 243 | } |
| 244 | expected := operator.ReadonlyExpectation{Path: path, Device: uint64(st.Dev), Inode: st.Ino, Length: int64(len(raw)), SHA256: hex.EncodeToString(h[:])} |
| 245 | input, _ := json.Marshal(struct { |
| 246 | Expected operator.ReadonlyExpectation |
| 247 | Role, Mode string |
| 248 | }{expected, role, mode}) |
| 249 | cmd := exec.Command(os.Args[0], "-test.run=^TestCompatibilityDatabaseSubprocess$") |
| 250 | cmd.Env = append(os.Environ(), "OFICINA_COMPATIBILITY_PROBE=1") |
| 251 | cmd.Stdin = bytes.NewReader(input) |
| 252 | if out, e := cmd.CombinedOutput(); e != nil { |
| 253 | t.Fatalf("child: %v %s", e, out) |
| 254 | } |
| 255 | after, e := os.ReadFile(path) |
| 256 | if e != nil || !bytes.Equal(raw, after) { |
| 257 | t.Fatal("database changed") |
| 258 | } |
| 259 | }) |
| 260 | } |
| 261 | } |
| 262 | } |
| 263 | |