// Package instancekey is the one rule for the platform's per-instance key // (region HA L21, rastrillo #66): how an env file is parsed, which file's // CARLOS_INSTANCE_KEY a launched app receives, and the adoption record, a // writer-bound assertion from the app's helper that it seals with that // key. The activator mints and // delivers the key by these rules, the control socket records adoption by // them, and the relocation pinner checks its evidence by them, so the three // can never disagree about what the app was given. // // It imports only the standard library, so the pinner can use it without // importing the activator. package instancekey import ( "bytes" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "strings" ) // Var is the variable the key is delivered as. const Var = "CARLOS_INSTANCE_KEY" // AdoptionSuffix names the adoption record beside an instance's own env // file: .env's sibling .instance-key-adoption.json. It is an // installation artifact: moves retire it with .env (O7), and nested // it lives in the instance directory, retired with it. const AdoptionSuffix = ".instance-key-adoption.json" // AdoptionItem is the adoption record's name among a files manifest's // platform items, beside hostfiles' EnvItem ("host.env"). const AdoptionItem = "instance-key-adoption.json" // EnvReadLimit caps every read of an own env file (.env) or its // rendered configuration (.config.env). Both live in a directory a // nested row's tenant can write, and the agent reads them outside the // tenant's memory wall, so no read of them is unbounded. The activator's // reads and relocation's release flush and install read under this one // cap, so a file one accepts the other never refuses as too large. const EnvReadLimit = 1 << 20 // AdoptionReadLimit caps every read of the adoption record, as // EnvReadLimit caps the env file beside it: the adoption verb's writer and // relocation's release flush read the record under this one cap. const AdoptionReadLimit = 1 << 16 // ParseEnv parses an EnvironmentFile: KEY=VALUE lines, blank lines and # // comments skipped, the key and the value trimmed of surrounding // whitespace, the last of two lines for one key winning. It is exactly // what the activator hands a child, so a reader that wants to know what an // app received must parse with it. "export KEY=..." is the key "export // KEY", and a quoted value keeps its quotes. func ParseEnv(raw []byte) map[string]string { m := map[string]string{} for _, line := range strings.Split(string(raw), "\n") { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "#") { continue } if k, v, ok := strings.Cut(line, "="); ok { m[strings.TrimSpace(k)] = strings.TrimSpace(v) } } return m } // Layer is which env file supplied the effective key. type Layer int const ( // LayerNone: neither file supplies a non-empty key. LayerNone Layer = iota // LayerOwn: the instance's own .env, where the platform mints. LayerOwn // LayerConfig: the rendered .config.env, the app's configuration. LayerConfig ) // Effective is the key a launched app receives from its two env files, and // which file supplied it. The instance's own file wins, as in every other // variable (the activator's mergeEnv), but an empty value supplies no key: // an empty CARLOS_INSTANCE_KEY= in the own file never shadows a non-empty // configured key, so an app is never handed an empty key while a non-empty // one exists. func Effective(config, own map[string]string) (string, Layer) { if k := own[Var]; k != "" { return k, LayerOwn } if k := config[Var]; k != "" { return k, LayerConfig } return "", LayerNone } // Digest is the lowercase hex SHA-256 of key: what an app's helper sends // to say which key it resolved, and what the adoption record holds. The // record is published beside the env file that holds a platform key // itself, so the digest gives that file's reader nothing new. func Digest(key string) string { sum := sha256.Sum256([]byte(key)) return hex.EncodeToString(sum[:]) } // The two sources rastrillo's step 2 can have resolved. Both arrive as // $CARLOS_INSTANCE_KEY once the env files are merged. const ( SourcePlatform = "platform" // the instance's own .env, where the platform mints SourceConfig = "config" // the rendered .config.env, the app's configuration ) // SourceOf names the source of a layer, "" for LayerNone. func SourceOf(l Layer) string { switch l { case LayerOwn: return SourcePlatform case LayerConfig: return SourceConfig } return "" } // Binding is the writer an adoption belongs to: the host, its installation // (the claim holder's incarnation), and the writer session and generation // the process was admitted as. The agent takes all four from its own // launch state for the process, never from the request. type Binding struct { Host string `json:"host"` Incarnation string `json:"incarnation"` Session uint64 `json:"session"` Generation string `json:"generation"` } func (b Binding) valid() bool { return b.Host != "" && b.Incarnation != "" && b.Session != 0 && b.Generation != "" } // Adoption is the record the agent writes when a process reports that its // helper resolved the key at step 2: which writer, which source, which // key (by digest). It is a trusted-helper assertion, not independent // evidence: it stops accidental loss, and a tenant who forges one can lose // only its own data (instance-key plan ruling 19). type Adoption struct { V int `json:"v"` Var string `json:"var"` Binding Source string `json:"source"` SHA256 string `json:"sha256"` } // AdoptionRecord is the canonical bytes of the record for writer b, which // was handed the key with digest sha from source. func AdoptionRecord(b Binding, source, sha string) []byte { raw, _ := json.Marshal(Adoption{V: 2, Var: Var, Binding: b, Source: source, SHA256: sha}) return append(raw, '\n') } // ParseAdoption reads a record strictly: version 2, this variable, a // complete binding, a known source and a digest, in exactly the bytes // AdoptionRecord writes for them. Anything else is an error, never a // record: trailing data, a key in another case, a key given twice, or any // other spelling of the same fields (encoding/json alone accepts the // last three). func ParseAdoption(record []byte) (Adoption, error) { var a Adoption dec := json.NewDecoder(bytes.NewReader(record)) dec.DisallowUnknownFields() if err := dec.Decode(&a); err != nil { return Adoption{}, fmt.Errorf("instancekey: adoption record: %w", err) } if dec.More() || a.V != 2 || a.Var != Var || !a.Binding.valid() || (a.Source != SourcePlatform && a.Source != SourceConfig) || !ValidDigest(a.SHA256) { return Adoption{}, errors.New("instancekey: adoption record: not a version 2 record for " + Var) } if !bytes.Equal(record, AdoptionRecord(a.Binding, a.Source, a.SHA256)) { return Adoption{}, errors.New("instancekey: adoption record: not in the canonical form AdoptionRecord writes") } return a, nil } // Binds reports whether the record belongs to exactly writer w. A record // from another installation, another session or another generation (an // earlier release, a rollback, a reinstall) never vouches for w. func (a Adoption) Binds(w Binding) bool { return w.valid() && a.Binding == w } // VerifyDelivered checks that the env files a relocation target is about // to launch with deliver the key pinned by digest (O3'): the effective key // of the rendered configuration and the restored own env file, by // Effective's rule. A configuration changed since the pin, a key removed, // or a platform key where a configured one was pinned, is an error; the // install refuses rather than hand the app a different key. func VerifyDelivered(configEnv, ownEnv []byte, pinnedSHA string) error { key, _ := Effective(ParseEnv(configEnv), ParseEnv(ownEnv)) if key == "" || Digest(key) != pinnedSHA { return errors.New("instancekey: the env files would not deliver the pinned " + Var) } return nil } // ValidDigest reports whether s is a lowercase hex SHA-256. func ValidDigest(s string) bool { if len(s) != 64 { return false } for _, r := range s { if (r < '0' || r > '9') && (r < 'a' || r > 'f') { return false } } return true } // ParseHomeKeys parses the box's list of verified home keys for its replica // rows, the file /etc/carlos/replica-home-keys (the list the plan calls // CARLOS_REPLICA_HOME_KEYS; instance-key plan rulings 26, 27 and 30): // "replica-host=sha256" entries, each digest the home's effective key as // the pre-roll check computed it. Only the pre-roll check writes the file; // nothing refreshes it from a replica's own configuration. The file // separates entries with commas or newlines, but this splits on commas // only, so its reader turns newlines into commas first (the agent's // replicaHomeKeys). Whitespace around entries is ignored and "" is an // empty list. A malformed entry, or a host named twice, fails the whole // value: a list that may be wrong is not a list to verify keys against. // Its errors name the entry by position and the reason, never the entry's // text, which may hold a digest. func ParseHomeKeys(s string) (map[string]string, error) { out := map[string]string{} n := 0 for _, e := range strings.Split(s, ",") { e = strings.TrimSpace(e) if e == "" { continue } n++ host, sha, ok := strings.Cut(e, "=") host, sha = strings.TrimSpace(host), strings.TrimSpace(sha) switch { case !ok: return nil, fmt.Errorf("instancekey: replica home keys entry %d: no \"replica-host=\" before the digest", n) case host == "" || strings.ContainsAny(host, " /=") || !strings.Contains(host, "."): return nil, fmt.Errorf("instancekey: replica home keys entry %d: the host is empty or not a host name", n) case !ValidDigest(sha): return nil, fmt.Errorf("instancekey: replica home keys entry %d: the digest is not 64 lowercase hex characters", n) } if _, dup := out[host]; dup { return nil, fmt.Errorf("instancekey: replica home keys entry %d: its host is listed by an earlier entry", n) } out[host] = sha } return out, nil }