| 1 | // Package instancekey is the one rule for the platform's per-instance key |
| 2 | // (region HA L21, rastrillo #66): how an env file is parsed, which file's |
| 3 | // CARLOS_INSTANCE_KEY a launched app receives, and the adoption record, a |
| 4 | // writer-bound assertion from the app's helper that it seals with that |
| 5 | // key. The activator mints and |
| 6 | // delivers the key by these rules, the control socket records adoption by |
| 7 | // them, and the relocation pinner checks its evidence by them, so the three |
| 8 | // can never disagree about what the app was given. |
| 9 | // |
| 10 | // It imports only the standard library, so the pinner can use it without |
| 11 | // importing the activator. |
| 12 | package instancekey |
| 13 | |
| 14 | import ( |
| 15 | "bytes" |
| 16 | "crypto/sha256" |
| 17 | "encoding/hex" |
| 18 | "encoding/json" |
| 19 | "errors" |
| 20 | "fmt" |
| 21 | "strings" |
| 22 | ) |
| 23 | |
| 24 | // Var is the variable the key is delivered as. |
| 25 | const Var = "CARLOS_INSTANCE_KEY" |
| 26 | |
| 27 | // AdoptionSuffix names the adoption record beside an instance's own env |
| 28 | // file: <host>.env's sibling <host>.instance-key-adoption.json. It is an |
| 29 | // installation artifact: moves retire it with <host>.env (O7), and nested |
| 30 | // it lives in the instance directory, retired with it. |
| 31 | const AdoptionSuffix = ".instance-key-adoption.json" |
| 32 | |
| 33 | // AdoptionItem is the adoption record's name among a files manifest's |
| 34 | // platform items, beside hostfiles' EnvItem ("host.env"). |
| 35 | const AdoptionItem = "instance-key-adoption.json" |
| 36 | |
| 37 | // EnvReadLimit caps every read of an own env file (<host>.env) or its |
| 38 | // rendered configuration (<host>.config.env). Both live in a directory a |
| 39 | // nested row's tenant can write, and the agent reads them outside the |
| 40 | // tenant's memory wall, so no read of them is unbounded. The activator's |
| 41 | // reads and relocation's release flush and install read under this one |
| 42 | // cap, so a file one accepts the other never refuses as too large. |
| 43 | const EnvReadLimit = 1 << 20 |
| 44 | |
| 45 | // AdoptionReadLimit caps every read of the adoption record, as |
| 46 | // EnvReadLimit caps the env file beside it: the adoption verb's writer and |
| 47 | // relocation's release flush read the record under this one cap. |
| 48 | const AdoptionReadLimit = 1 << 16 |
| 49 | |
| 50 | // ParseEnv parses an EnvironmentFile: KEY=VALUE lines, blank lines and # |
| 51 | // comments skipped, the key and the value trimmed of surrounding |
| 52 | // whitespace, the last of two lines for one key winning. It is exactly |
| 53 | // what the activator hands a child, so a reader that wants to know what an |
| 54 | // app received must parse with it. "export KEY=..." is the key "export |
| 55 | // KEY", and a quoted value keeps its quotes. |
| 56 | func ParseEnv(raw []byte) map[string]string { |
| 57 | m := map[string]string{} |
| 58 | for _, line := range strings.Split(string(raw), "\n") { |
| 59 | line = strings.TrimSpace(line) |
| 60 | if line == "" || strings.HasPrefix(line, "#") { |
| 61 | continue |
| 62 | } |
| 63 | if k, v, ok := strings.Cut(line, "="); ok { |
| 64 | m[strings.TrimSpace(k)] = strings.TrimSpace(v) |
| 65 | } |
| 66 | } |
| 67 | return m |
| 68 | } |
| 69 | |
| 70 | // Layer is which env file supplied the effective key. |
| 71 | type Layer int |
| 72 | |
| 73 | const ( |
| 74 | // LayerNone: neither file supplies a non-empty key. |
| 75 | LayerNone Layer = iota |
| 76 | // LayerOwn: the instance's own <host>.env, where the platform mints. |
| 77 | LayerOwn |
| 78 | // LayerConfig: the rendered <host>.config.env, the app's configuration. |
| 79 | LayerConfig |
| 80 | ) |
| 81 | |
| 82 | // Effective is the key a launched app receives from its two env files, and |
| 83 | // which file supplied it. The instance's own file wins, as in every other |
| 84 | // variable (the activator's mergeEnv), but an empty value supplies no key: |
| 85 | // an empty CARLOS_INSTANCE_KEY= in the own file never shadows a non-empty |
| 86 | // configured key, so an app is never handed an empty key while a non-empty |
| 87 | // one exists. |
| 88 | func Effective(config, own map[string]string) (string, Layer) { |
| 89 | if k := own[Var]; k != "" { |
| 90 | return k, LayerOwn |
| 91 | } |
| 92 | if k := config[Var]; k != "" { |
| 93 | return k, LayerConfig |
| 94 | } |
| 95 | return "", LayerNone |
| 96 | } |
| 97 | |
| 98 | // Digest is the lowercase hex SHA-256 of key: what an app's helper sends |
| 99 | // to say which key it resolved, and what the adoption record holds. The |
| 100 | // record is published beside the env file that holds a platform key |
| 101 | // itself, so the digest gives that file's reader nothing new. |
| 102 | func Digest(key string) string { |
| 103 | sum := sha256.Sum256([]byte(key)) |
| 104 | return hex.EncodeToString(sum[:]) |
| 105 | } |
| 106 | |
| 107 | // The two sources rastrillo's step 2 can have resolved. Both arrive as |
| 108 | // $CARLOS_INSTANCE_KEY once the env files are merged. |
| 109 | const ( |
| 110 | SourcePlatform = "platform" // the instance's own <host>.env, where the platform mints |
| 111 | SourceConfig = "config" // the rendered <host>.config.env, the app's configuration |
| 112 | ) |
| 113 | |
| 114 | // SourceOf names the source of a layer, "" for LayerNone. |
| 115 | func SourceOf(l Layer) string { |
| 116 | switch l { |
| 117 | case LayerOwn: |
| 118 | return SourcePlatform |
| 119 | case LayerConfig: |
| 120 | return SourceConfig |
| 121 | } |
| 122 | return "" |
| 123 | } |
| 124 | |
| 125 | // Binding is the writer an adoption belongs to: the host, its installation |
| 126 | // (the claim holder's incarnation), and the writer session and generation |
| 127 | // the process was admitted as. The agent takes all four from its own |
| 128 | // launch state for the process, never from the request. |
| 129 | type Binding struct { |
| 130 | Host string `json:"host"` |
| 131 | Incarnation string `json:"incarnation"` |
| 132 | Session uint64 `json:"session"` |
| 133 | Generation string `json:"generation"` |
| 134 | } |
| 135 | |
| 136 | func (b Binding) valid() bool { |
| 137 | return b.Host != "" && b.Incarnation != "" && b.Session != 0 && b.Generation != "" |
| 138 | } |
| 139 | |
| 140 | // Adoption is the record the agent writes when a process reports that its |
| 141 | // helper resolved the key at step 2: which writer, which source, which |
| 142 | // key (by digest). It is a trusted-helper assertion, not independent |
| 143 | // evidence: it stops accidental loss, and a tenant who forges one can lose |
| 144 | // only its own data (instance-key plan ruling 19). |
| 145 | type Adoption struct { |
| 146 | V int `json:"v"` |
| 147 | Var string `json:"var"` |
| 148 | Binding |
| 149 | Source string `json:"source"` |
| 150 | SHA256 string `json:"sha256"` |
| 151 | } |
| 152 | |
| 153 | // AdoptionRecord is the canonical bytes of the record for writer b, which |
| 154 | // was handed the key with digest sha from source. |
| 155 | func AdoptionRecord(b Binding, source, sha string) []byte { |
| 156 | raw, _ := json.Marshal(Adoption{V: 2, Var: Var, Binding: b, Source: source, SHA256: sha}) |
| 157 | return append(raw, '\n') |
| 158 | } |
| 159 | |
| 160 | // ParseAdoption reads a record strictly: version 2, this variable, a |
| 161 | // complete binding, a known source and a digest, in exactly the bytes |
| 162 | // AdoptionRecord writes for them. Anything else is an error, never a |
| 163 | // record: trailing data, a key in another case, a key given twice, or any |
| 164 | // other spelling of the same fields (encoding/json alone accepts the |
| 165 | // last three). |
| 166 | func ParseAdoption(record []byte) (Adoption, error) { |
| 167 | var a Adoption |
| 168 | dec := json.NewDecoder(bytes.NewReader(record)) |
| 169 | dec.DisallowUnknownFields() |
| 170 | if err := dec.Decode(&a); err != nil { |
| 171 | return Adoption{}, fmt.Errorf("instancekey: adoption record: %w", err) |
| 172 | } |
| 173 | if dec.More() || a.V != 2 || a.Var != Var || !a.Binding.valid() || (a.Source != SourcePlatform && a.Source != SourceConfig) || !ValidDigest(a.SHA256) { |
| 174 | return Adoption{}, errors.New("instancekey: adoption record: not a version 2 record for " + Var) |
| 175 | } |
| 176 | if !bytes.Equal(record, AdoptionRecord(a.Binding, a.Source, a.SHA256)) { |
| 177 | return Adoption{}, errors.New("instancekey: adoption record: not in the canonical form AdoptionRecord writes") |
| 178 | } |
| 179 | return a, nil |
| 180 | } |
| 181 | |
| 182 | // Binds reports whether the record belongs to exactly writer w. A record |
| 183 | // from another installation, another session or another generation (an |
| 184 | // earlier release, a rollback, a reinstall) never vouches for w. |
| 185 | func (a Adoption) Binds(w Binding) bool { |
| 186 | return w.valid() && a.Binding == w |
| 187 | } |
| 188 | |
| 189 | // VerifyDelivered checks that the env files a relocation target is about |
| 190 | // to launch with deliver the key pinned by digest (O3'): the effective key |
| 191 | // of the rendered configuration and the restored own env file, by |
| 192 | // Effective's rule. A configuration changed since the pin, a key removed, |
| 193 | // or a platform key where a configured one was pinned, is an error; the |
| 194 | // install refuses rather than hand the app a different key. |
| 195 | func VerifyDelivered(configEnv, ownEnv []byte, pinnedSHA string) error { |
| 196 | key, _ := Effective(ParseEnv(configEnv), ParseEnv(ownEnv)) |
| 197 | if key == "" || Digest(key) != pinnedSHA { |
| 198 | return errors.New("instancekey: the env files would not deliver the pinned " + Var) |
| 199 | } |
| 200 | return nil |
| 201 | } |
| 202 | |
| 203 | // ValidDigest reports whether s is a lowercase hex SHA-256. |
| 204 | func ValidDigest(s string) bool { |
| 205 | if len(s) != 64 { |
| 206 | return false |
| 207 | } |
| 208 | for _, r := range s { |
| 209 | if (r < '0' || r > '9') && (r < 'a' || r > 'f') { |
| 210 | return false |
| 211 | } |
| 212 | } |
| 213 | return true |
| 214 | } |
| 215 | |
| 216 | // ParseHomeKeys parses the box's list of verified home keys for its replica |
| 217 | // rows, the file /etc/carlos/replica-home-keys (the list the plan calls |
| 218 | // CARLOS_REPLICA_HOME_KEYS; instance-key plan rulings 26, 27 and 30): |
| 219 | // "replica-host=sha256" entries, each digest the home's effective key as |
| 220 | // the pre-roll check computed it. Only the pre-roll check writes the file; |
| 221 | // nothing refreshes it from a replica's own configuration. The file |
| 222 | // separates entries with commas or newlines, but this splits on commas |
| 223 | // only, so its reader turns newlines into commas first (the agent's |
| 224 | // replicaHomeKeys). Whitespace around entries is ignored and "" is an |
| 225 | // empty list. A malformed entry, or a host named twice, fails the whole |
| 226 | // value: a list that may be wrong is not a list to verify keys against. |
| 227 | // Its errors name the entry by position and the reason, never the entry's |
| 228 | // text, which may hold a digest. |
| 229 | func ParseHomeKeys(s string) (map[string]string, error) { |
| 230 | out := map[string]string{} |
| 231 | n := 0 |
| 232 | for _, e := range strings.Split(s, ",") { |
| 233 | e = strings.TrimSpace(e) |
| 234 | if e == "" { |
| 235 | continue |
| 236 | } |
| 237 | n++ |
| 238 | host, sha, ok := strings.Cut(e, "=") |
| 239 | host, sha = strings.TrimSpace(host), strings.TrimSpace(sha) |
| 240 | switch { |
| 241 | case !ok: |
| 242 | return nil, fmt.Errorf("instancekey: replica home keys entry %d: no \"replica-host=\" before the digest", n) |
| 243 | case host == "" || strings.ContainsAny(host, " /=") || !strings.Contains(host, "."): |
| 244 | return nil, fmt.Errorf("instancekey: replica home keys entry %d: the host is empty or not a host name", n) |
| 245 | case !ValidDigest(sha): |
| 246 | return nil, fmt.Errorf("instancekey: replica home keys entry %d: the digest is not 64 lowercase hex characters", n) |
| 247 | } |
| 248 | if _, dup := out[host]; dup { |
| 249 | return nil, fmt.Errorf("instancekey: replica home keys entry %d: its host is listed by an earlier entry", n) |
| 250 | } |
| 251 | out[host] = sha |
| 252 | } |
| 253 | return out, nil |
| 254 | } |
| 255 | |