| 1 | package moves |
| 2 | |
| 3 | import ( |
| 4 | "crypto/hmac" |
| 5 | "crypto/sha256" |
| 6 | "encoding/base64" |
| 7 | "encoding/hex" |
| 8 | "errors" |
| 9 | "fmt" |
| 10 | "io" |
| 11 | "sort" |
| 12 | "strings" |
| 13 | |
| 14 | "golang.org/x/crypto/hkdf" |
| 15 | ) |
| 16 | |
| 17 | const RegionVar = "CARLOS_REGION" |
| 18 | |
| 19 | // secretsKeyLen is the length of CARLOS_SECRETS_KEY's raw bytes: a P-256 |
| 20 | // private scalar (internal/appconfig GenerateKey). |
| 21 | const secretsKeyLen = 32 |
| 22 | |
| 23 | var ( |
| 24 | // LayoutVars name a channel the flat layout always provides: present |
| 25 | // on both sides of a flat move, values not compared (they are paths |
| 26 | // on each box). |
| 27 | LayoutVars = map[string]bool{"CARLOS_CONTROL_SOCKET": true} |
| 28 | // AdvisoryVars follow a box fact (CARLOS_PURGE is set iff the box has |
| 29 | // a response cache, ExecBackend.agentOwnedVars): a presence difference |
| 30 | // is reported as a note, never a failure (round-4 finding 10). The |
| 31 | // spec's T3 table puts CARLOS_PURGE in the layout class; the plan |
| 32 | // deliberately makes it advisory, and the spec ledger records that. |
| 33 | AdvisoryVars = map[string]bool{"CARLOS_PURGE": true} |
| 34 | // BoxVars are the per-process variables of a box's own shell or |
| 35 | // systemd unit, and the box facts its unit's environment carries, |
| 36 | // never compared. The agent hands instances its own environment bar |
| 37 | // the CARLOS_ names, so every non-CARLOS_ variable of carlos-edge's |
| 38 | // process is here unless it is the same on every box. Read from the |
| 39 | // agent process on dev and flagship boxes, 2026-10-20: systemd 257 |
| 40 | // adds SYSTEMD_EXEC_PID and MEMORY_PRESSURE_*, and the units set |
| 41 | // AWS_REGION, STATUS_BOX_REGION, PLATFORM_* and, on a console box, |
| 42 | // CONSOLE_*. HOME, LANG, LOGNAME, PATH and USER are the same on every |
| 43 | // box and stay pinned. The rest of systemd's per-process set is here |
| 44 | // so a unit that gains one cannot fail every move. |
| 45 | BoxVars = map[string]bool{ |
| 46 | "INVOCATION_ID": true, "JOURNAL_STREAM": true, "HOSTNAME": true, "PWD": true, "OLDPWD": true, "SHLVL": true, "_": true, |
| 47 | "SYSTEMD_EXEC_PID": true, "MEMORY_PRESSURE_WATCH": true, "MEMORY_PRESSURE_WRITE": true, |
| 48 | "NOTIFY_SOCKET": true, "WATCHDOG_PID": true, "WATCHDOG_USEC": true, "MAINPID": true, |
| 49 | "LISTEN_PID": true, "LISTEN_FDS": true, "LISTEN_FDNAMES": true, |
| 50 | "RUNTIME_DIRECTORY": true, "STATE_DIRECTORY": true, "CACHE_DIRECTORY": true, "LOGS_DIRECTORY": true, |
| 51 | "CONFIGURATION_DIRECTORY": true, "CREDENTIALS_DIRECTORY": true, |
| 52 | "AWS_REGION": true, "STATUS_BOX_REGION": true, "PLATFORM_EDGE_IPS": true, "PLATFORM_APPS_DOMAIN": true, |
| 53 | "CONSOLE_DENY_DOMAINS": true, "CONSOLE_HOME_REGION": true, |
| 54 | } |
| 55 | // AgentOwnedVars are the variables the agent sets, overwrites or |
| 56 | // removes on every launch (activator ExecBackend.agentOwnedVars): the |
| 57 | // control socket, the purge channel, the box's region and the host |
| 58 | // row's name. A tenant's env file never decides them, and each box sets |
| 59 | // its own, so they are box-owned (BoxOwned). |
| 60 | // TestAgentOwnedVarsAreMovesBoxOwned (internal/activator) pins this set |
| 61 | // against agentOwnedVars. |
| 62 | AgentOwnedVars = map[string]bool{RegionVar: true, "CARLOS_CONTROL_SOCKET": true, "CARLOS_PURGE": true, "CARLOS_HOST": true} |
| 63 | ) |
| 64 | |
| 65 | // BoxOwned reports a variable whose value can be the box's rather than the |
| 66 | // instance's: an agent-owned variable, always the box's, or a box variable |
| 67 | // of the box's own process, the box's unless the instance's own layers |
| 68 | // supply it (Pinned). It is the one set every comparison of a move's |
| 69 | // environment across two boxes may leave out (home part 2, Task 2b): T3 |
| 70 | // tolerates these (Class is not "equal") unless an instance supplied |
| 71 | // them, and the env pin covers every variable Pinned keeps. |
| 72 | func BoxOwned(name string) bool { return AgentOwnedVars[name] || BoxVars[name] } |
| 73 | |
| 74 | // Pinned reports whether a variable is part of the move-portable env, the |
| 75 | // env the move's pin covers on the source and the target's checks hash |
| 76 | // (fix round 4, platform-1e's exclusion rule): a variable is left out only |
| 77 | // when it is box-owned (BoxOwned) and the instance's own layers did not |
| 78 | // supply the value that runs (instanceSupplied false). Everything else is |
| 79 | // pinned. The composer reports as instance-supplied only the names whose |
| 80 | // running value is the instance's (activator ExecBackend.LaunchLayers): an |
| 81 | // agent-owned variable never is, since the agent sets or removes it |
| 82 | // whatever the files say, and a box variable is when the instance's files |
| 83 | // set it, since the agent never overwrites one. |
| 84 | func Pinned(name string, instanceSupplied bool) bool { |
| 85 | return !BoxOwned(name) || instanceSupplied |
| 86 | } |
| 87 | |
| 88 | // ReportKeyFromSecrets derives the report's HMAC key from |
| 89 | // CARLOS_SECRETS_KEY (HKDF-SHA256), so the report reveals names and |
| 90 | // equality only and only to boxes holding the deployment's key (§7.3). |
| 91 | // The input must be the base64 of exactly 32 raw bytes; anything else is |
| 92 | // an error, and the error never carries the input. |
| 93 | func ReportKeyFromSecrets(secretsKeyB64 string) ([]byte, error) { |
| 94 | raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(secretsKeyB64)) |
| 95 | if err != nil { |
| 96 | return nil, fmt.Errorf("moves: secrets key: %w", err) |
| 97 | } |
| 98 | if len(raw) != secretsKeyLen { |
| 99 | return nil, errors.New("moves: secrets key: not a 32-byte key") |
| 100 | } |
| 101 | key := make([]byte, 32) |
| 102 | if _, err := io.ReadFull(hkdf.New(sha256.New, raw, nil, []byte("carlos-move-env-report-v1")), key); err != nil { |
| 103 | return nil, err |
| 104 | } |
| 105 | return key, nil |
| 106 | } |
| 107 | |
| 108 | type EnvReport struct { |
| 109 | V int `json:"v"` |
| 110 | Vars map[string]string `json:"vars"` |
| 111 | Digest string `json:"digest"` |
| 112 | // Instance names the box-owned variables (BoxOwned) whose running value |
| 113 | // the instance's own layers supplied, sorted: the box-owned variables |
| 114 | // the report pins and T3 compares. Empty means every box-owned variable |
| 115 | // in Vars is the box's. |
| 116 | Instance []string `json:"instance,omitempty"` |
| 117 | } |
| 118 | |
| 119 | // instanceSupplied reports whether the instance's own layers supplied the |
| 120 | // box-owned variable name in this report. |
| 121 | func (r EnvReport) instanceSupplied(name string) bool { |
| 122 | for _, n := range r.Instance { |
| 123 | if n == name { |
| 124 | return true |
| 125 | } |
| 126 | } |
| 127 | return false |
| 128 | } |
| 129 | |
| 130 | // ReportLaunch HMACs every value of a composed launch environment (bound to |
| 131 | // its name). instance is the names the instance's own layers supplied |
| 132 | // (activator ExecBackend.LaunchLayers); nil means none of the box |
| 133 | // variables were. Vars carries every variable, for T3's comparison by |
| 134 | // name; Instance the box variables the instance supplied; Digest the |
| 135 | // move-portable env, every variable Pinned keeps, so the source's pin and |
| 136 | // the target's composition of the same instance hash the same |
| 137 | // (PortableDigest). Raw values never appear in the result. |
| 138 | func ReportLaunch(key []byte, env map[string]string, instance map[string]bool) EnvReport { |
| 139 | r := EnvReport{V: EnvReportVersion, Vars: make(map[string]string, len(env))} |
| 140 | for k, v := range env { |
| 141 | r.Vars[k] = macVar(key, k, v) |
| 142 | if BoxOwned(k) && instance[k] { |
| 143 | r.Instance = append(r.Instance, k) |
| 144 | } |
| 145 | } |
| 146 | sort.Strings(r.Instance) |
| 147 | r.Digest = r.portableDigest() |
| 148 | return r |
| 149 | } |
| 150 | |
| 151 | // ReportEnv is ReportLaunch for an environment no instance layer supplied |
| 152 | // a box variable to. |
| 153 | func ReportEnv(key []byte, env map[string]string) EnvReport { |
| 154 | return ReportLaunch(key, env, nil) |
| 155 | } |
| 156 | |
| 157 | // PortableDigest is the digest of a composed launch environment's |
| 158 | // move-portable part: ReportLaunch's Digest, the move's env pin. |
| 159 | func PortableDigest(key []byte, env map[string]string, instance map[string]bool) string { |
| 160 | return ReportLaunch(key, env, instance).Digest |
| 161 | } |
| 162 | |
| 163 | // LocalDigest is the digest of a whole composed environment, every |
| 164 | // variable, box-owned or not: the digest of a comparison made on one box |
| 165 | // against a record that box made (the recovery's inputs), where the box's |
| 166 | // own variables are part of what runs and must not change either. |
| 167 | func LocalDigest(key []byte, env map[string]string) string { |
| 168 | vars := make(map[string]string, len(env)) |
| 169 | for k, v := range env { |
| 170 | vars[k] = macVar(key, k, v) |
| 171 | } |
| 172 | return "sha256:" + digestOf(vars, func(string) bool { return true }) |
| 173 | } |
| 174 | |
| 175 | func macVar(key []byte, name, value string) string { |
| 176 | mac := hmac.New(sha256.New, key) |
| 177 | mac.Write([]byte(name)) |
| 178 | mac.Write([]byte{0}) |
| 179 | mac.Write([]byte(value)) |
| 180 | return hex.EncodeToString(mac.Sum(nil)) |
| 181 | } |
| 182 | |
| 183 | // portableDigest hashes the reported variables Pinned keeps, in the v2 |
| 184 | // pin's form. |
| 185 | func (r EnvReport) portableDigest() string { |
| 186 | return envPinPrefix + digestOf(r.Vars, func(k string) bool { return Pinned(k, r.instanceSupplied(k)) }) |
| 187 | } |
| 188 | |
| 189 | // EnvReportVersion is the env report's and the env pin's contract |
| 190 | // (home part 2, Task 2b fix round 3). Version 2 names the instance's layer |
| 191 | // names (Instance) and pins a box variable only when the instance supplied |
| 192 | // it (Pinned). A version 1 report carries no layer names, so an absent |
| 193 | // Instance cannot tell an older producer, whose box variables were never |
| 194 | // pinned, from a newer one with none instance-set: a v2 participant |
| 195 | // refuses a v1 report at T3 and a v1 pin at the probe (CurrentEnvReport, |
| 196 | // CurrentEnvPin), and a v2 pin is in a form (envPinPrefix) no v1 |
| 197 | // digest, "sha256:<hex>", can equal. This is the report's version, not |
| 198 | // ProtocolVersion. |
| 199 | const EnvReportVersion = 2 |
| 200 | |
| 201 | // envPinPrefix marks a v2 env pin. |
| 202 | const envPinPrefix = "env2:sha256:" |
| 203 | |
| 204 | // CurrentEnvPin reports whether pin is a v2 env pin, the only form this |
| 205 | // release checks a launch against. |
| 206 | func CurrentEnvPin(pin string) bool { return strings.HasPrefix(pin, envPinPrefix) } |
| 207 | |
| 208 | // CurrentEnvReport reports whether r is a v2 env report: its version and |
| 209 | // its digest's form both say so. |
| 210 | func CurrentEnvReport(r EnvReport) bool { return r.V == EnvReportVersion && CurrentEnvPin(r.Digest) } |
| 211 | |
| 212 | func digestOf(vars map[string]string, keep func(string) bool) string { |
| 213 | names := make([]string, 0, len(vars)) |
| 214 | for k := range vars { |
| 215 | if keep(k) { |
| 216 | names = append(names, k) |
| 217 | } |
| 218 | } |
| 219 | sort.Strings(names) |
| 220 | h := sha256.New() |
| 221 | for _, k := range names { |
| 222 | fmt.Fprintf(h, "%s=%s\n", k, vars[k]) |
| 223 | } |
| 224 | return hex.EncodeToString(h.Sum(nil)) |
| 225 | } |
| 226 | |
| 227 | // Class is T3's variable class by name. Every BoxOwned variable has a |
| 228 | // class other than "equal", and only those do. The agent-owned variables |
| 229 | // without a rule of their own (CARLOS_HOST, the row's name) are box |
| 230 | // variables. CompareEnv compares a box variable an instance supplied as |
| 231 | // "equal" (classIn), as the pin covers it. |
| 232 | func Class(name string) string { |
| 233 | switch { |
| 234 | case name == RegionVar: |
| 235 | return "region" |
| 236 | case LayoutVars[name]: |
| 237 | return "layout" |
| 238 | case AdvisoryVars[name]: |
| 239 | return "advisory" |
| 240 | case BoxOwned(name): |
| 241 | return "box" |
| 242 | default: |
| 243 | return "equal" |
| 244 | } |
| 245 | } |
| 246 | |
| 247 | type EnvDiff struct { |
| 248 | Name, Kind string |
| 249 | } |
| 250 | |
| 251 | // classIn is Class in a comparison of two reports: a box-owned variable |
| 252 | // either side's instance supplied is the instance's, compared as "equal", |
| 253 | // as the pin covers it (Pinned). |
| 254 | func classIn(name string, source, target EnvReport) string { |
| 255 | if !BoxOwned(name) || !(source.instanceSupplied(name) || target.instanceSupplied(name)) { |
| 256 | return Class(name) |
| 257 | } |
| 258 | return "equal" |
| 259 | } |
| 260 | |
| 261 | // Note reports a diff T3 records without failing. |
| 262 | func (d EnvDiff) Note() bool { return d.Kind == "advisory-differs" } |
| 263 | |
| 264 | // CompareEnv is T3: region must differ, layout variables must be present |
| 265 | // iff the flat layout provides them (value not compared), advisory |
| 266 | // variables report a presence difference as a note (EnvDiff.Note), box |
| 267 | // variables are not compared, everything else must be equal by HMAC. T3 |
| 268 | // fails on any diff that is not a note. |
| 269 | // |
| 270 | // targetHome is a move into the home placement (the record's move names |
| 271 | // the home's code, ""). A home box sets no CARLOS_REGION (the agent adds it |
| 272 | // only when the box has a region), so for a home target the rule turns |
| 273 | // round: the target must not carry it, whatever the source carries, and a |
| 274 | // target that does is not the home box (region-present). For a named |
| 275 | // target the rule is unchanged: it must set its own, different from the |
| 276 | // source's. |
| 277 | func CompareEnv(source, target EnvReport, flatLayout, targetHome bool) []EnvDiff { |
| 278 | var diffs []EnvDiff |
| 279 | seen := map[string]bool{} |
| 280 | for name, s := range source.Vars { |
| 281 | seen[name] = true |
| 282 | tv, ok := target.Vars[name] |
| 283 | switch classIn(name, source, target) { |
| 284 | case "region": |
| 285 | if targetHome { |
| 286 | if ok { |
| 287 | diffs = append(diffs, EnvDiff{name, "region-present"}) // a home box sets no region |
| 288 | } |
| 289 | } else if !ok { |
| 290 | diffs = append(diffs, EnvDiff{name, "region-missing"}) // the target must set its own region |
| 291 | } else if tv == s { |
| 292 | diffs = append(diffs, EnvDiff{name, "region-same"}) |
| 293 | } |
| 294 | case "layout": |
| 295 | if flatLayout && !ok { |
| 296 | diffs = append(diffs, EnvDiff{name, "layout-missing"}) |
| 297 | } |
| 298 | if !flatLayout && ok { |
| 299 | diffs = append(diffs, EnvDiff{name, "layout-present"}) |
| 300 | } |
| 301 | case "advisory": |
| 302 | if !ok { |
| 303 | diffs = append(diffs, EnvDiff{name, "advisory-differs"}) |
| 304 | } |
| 305 | case "box": |
| 306 | default: |
| 307 | if !ok { |
| 308 | diffs = append(diffs, EnvDiff{name, "missing"}) |
| 309 | } else if tv != s { |
| 310 | diffs = append(diffs, EnvDiff{name, "differs"}) |
| 311 | } |
| 312 | } |
| 313 | } |
| 314 | for name := range target.Vars { |
| 315 | if seen[name] { |
| 316 | continue |
| 317 | } |
| 318 | switch classIn(name, source, target) { |
| 319 | case "region": |
| 320 | // a target-only region is a named target's own (a move out of |
| 321 | // home); on a home target it is a box that is not the home box |
| 322 | if targetHome { |
| 323 | diffs = append(diffs, EnvDiff{name, "region-present"}) |
| 324 | } |
| 325 | case "equal": |
| 326 | diffs = append(diffs, EnvDiff{name, "extra"}) |
| 327 | case "advisory": |
| 328 | diffs = append(diffs, EnvDiff{name, "advisory-differs"}) |
| 329 | case "layout": |
| 330 | // present iff the flat layout provides it: a target-only |
| 331 | // layout variable is wrong on a non-flat landing |
| 332 | if !flatLayout { |
| 333 | diffs = append(diffs, EnvDiff{name, "layout-present"}) |
| 334 | } |
| 335 | } |
| 336 | } |
| 337 | // layout names absent from BOTH reports are still checked: a flat |
| 338 | // landing must provide every layout variable the flat layout provides |
| 339 | for name := range LayoutVars { |
| 340 | if _, inSrc := source.Vars[name]; inSrc { |
| 341 | continue // handled above |
| 342 | } |
| 343 | if _, inTgt := target.Vars[name]; flatLayout && !inTgt { |
| 344 | diffs = append(diffs, EnvDiff{name, "layout-missing"}) |
| 345 | } |
| 346 | } |
| 347 | sort.Slice(diffs, func(i, j int) bool { return diffs[i].Name < diffs[j].Name }) |
| 348 | return diffs |
| 349 | } |
| 350 | |