carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

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.
12package instancekey
13
14import (
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.
25const 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.
31const 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").
35const 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.
43const 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.
48const 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.
56func 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.
71type Layer int
72
73const (
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.
88func 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.
102func 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.
109const (
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.
115func 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.
129type Binding struct {
130 Host string `json:"host"`
131 Incarnation string `json:"incarnation"`
132 Session uint64 `json:"session"`
133 Generation string `json:"generation"`
134}
135
136func (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).
145type 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.
155func 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).
166func 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.
185func (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.
195func 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.
204func 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.
229func 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