| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/ecdh" |
| 6 | "errors" |
| 7 | "log" |
| 8 | "os" |
| 9 | "strings" |
| 10 | "sync" |
| 11 | |
| 12 | "github.com/carlosframework/platform/internal/activator" |
| 13 | "github.com/carlosframework/platform/internal/appconfig" |
| 14 | "github.com/carlosframework/platform/internal/instancekey" |
| 15 | "github.com/carlosframework/platform/internal/registry" |
| 16 | "github.com/carlosframework/platform/internal/release" |
| 17 | ) |
| 18 | |
| 19 | // replicaHomeKeysPath is the box's list of verified home keys for its |
| 20 | // replica rows (the list the plan calls CARLOS_REPLICA_HOME_KEYS): |
| 21 | // "replica-host=sha256" entries, separated by commas or newlines. Only the |
| 22 | // pre-roll check writes it, as root, replacing it whole (write a temporary |
| 23 | // file, then rename it into place); the agent only reads it. |
| 24 | const replicaHomeKeysPath = "/etc/carlos/replica-home-keys" |
| 25 | |
| 26 | // instanceKeyReaders are the two readers every ExecBackend that launches in |
| 27 | // production is given: the box's verified home keys for its replica rows |
| 28 | // (rulings 26 and 30) and the bucket's configured key (rulings 5 and 27). |
| 29 | // The agent's backend and the claim path's backend (claimExecBackend) are |
| 30 | // wired from the same pair, so neither can gate a replica or mint a key |
| 31 | // by a rule the other lacks (final review I1). |
| 32 | type instanceKeyReaders struct { |
| 33 | HomeKeys func(host string) (sha256 string, ok bool) |
| 34 | Configured func(context.Context, registry.Route) (sha256 string, err error) |
| 35 | } |
| 36 | |
| 37 | // newInstanceKeyReaders builds the pair from a box: getenv for the secrets |
| 38 | // key and the flags, open for the deploy store, list for the home-keys |
| 39 | // file. |
| 40 | func newInstanceKeyReaders(getenv func(string) string, open func(context.Context) (release.Store, error), list func() ([]byte, error)) instanceKeyReaders { |
| 41 | return instanceKeyReaders{HomeKeys: replicaHomeKeys(list), Configured: configuredKey(getenv, open)} |
| 42 | } |
| 43 | |
| 44 | // productionInstanceKeyReaders reads this box: its environment, its deploy |
| 45 | // store and /etc/carlos/replica-home-keys. |
| 46 | func productionInstanceKeyReaders() instanceKeyReaders { |
| 47 | return newInstanceKeyReaders(os.Getenv, openDeployStore, func() ([]byte, error) { return os.ReadFile(replicaHomeKeysPath) }) |
| 48 | } |
| 49 | |
| 50 | // wire gives eb both readers. |
| 51 | func (k instanceKeyReaders) wire(eb *activator.ExecBackend) { |
| 52 | eb.ReplicaHomeKeys = k.HomeKeys |
| 53 | eb.ConfiguredKey = k.Configured |
| 54 | } |
| 55 | |
| 56 | // replicaHomeKeys is ExecBackend.ReplicaHomeKeys (instance-key plan rulings |
| 57 | // 26, 27 and 30): it reads the list again at every call, so an entry the |
| 58 | // procedure removes or replaces takes effect at the next replica launch, |
| 59 | // with no agent restart and no cached copy to go stale. Nothing refreshes |
| 60 | // the list from a replica's own configuration. An absent list verifies |
| 61 | // nothing. A list that cannot be read or parsed is logged by the error's |
| 62 | // position and reason, never its text, and verifies nothing, so every |
| 63 | // replica launch that would be handed a key is refused until it is fixed. |
| 64 | // Its log lines name the file by replicaHomeKeysPath, the list it reads in |
| 65 | // production, so an operator knows what to fix. |
| 66 | func replicaHomeKeys(read func() ([]byte, error)) func(host string) (string, bool) { |
| 67 | return func(host string) (string, bool) { |
| 68 | raw, err := read() |
| 69 | if errors.Is(err, os.ErrNotExist) { |
| 70 | return "", false |
| 71 | } |
| 72 | if err != nil { |
| 73 | log.Printf("agent: replica home keys: %s could not be read (no replica home key verifies): %v", replicaHomeKeysPath, err) |
| 74 | return "", false |
| 75 | } |
| 76 | keys, err := instancekey.ParseHomeKeys(strings.ReplaceAll(string(raw), "\n", ",")) |
| 77 | if err != nil { |
| 78 | log.Printf("agent: replica home keys: %s: %v (no replica home key verifies until it is fixed)", replicaHomeKeysPath, err) |
| 79 | return "", false |
| 80 | } |
| 81 | sha, ok := keys[host] |
| 82 | return sha, ok |
| 83 | } |
| 84 | } |
| 85 | |
| 86 | // configuredKey is ExecBackend.ConfiguredKey (rulings 5 and 27): the |
| 87 | // digest of the key rt's configuration in the bucket renders now, read |
| 88 | // through the deploy store with the box's secrets key. Which boxes have a |
| 89 | // configuration to read is decided as the config guard decides whether a |
| 90 | // box can provide a route's config at all (configguard.go): a box with no |
| 91 | // deployment store and CARLOS_NO_SEALED_CONFIG set has no configuration |
| 92 | // source and renders no key; one with no store and no such flag is an |
| 93 | // error; one with a store and no secrets key renders what it can, and a |
| 94 | // sealed value fails the render here as it fails the guard. The store is |
| 95 | // opened on first use and kept once open; a failed open is returned, so |
| 96 | // the mint is deferred or the replica refused, and is tried again at the |
| 97 | // next launch. |
| 98 | func configuredKey(getenv func(string) string, open func(context.Context) (release.Store, error)) func(context.Context, registry.Route) (string, error) { |
| 99 | var ( |
| 100 | mu sync.Mutex |
| 101 | store release.Store |
| 102 | priv *ecdh.PrivateKey |
| 103 | ) |
| 104 | return func(ctx context.Context, rt registry.Route) (string, error) { |
| 105 | mu.Lock() |
| 106 | if store == nil { |
| 107 | var k *ecdh.PrivateKey |
| 108 | if raw := getenv("CARLOS_SECRETS_KEY"); raw != "" { |
| 109 | var err error |
| 110 | k, err = appconfig.ParsePrivateKey(raw, "CARLOS_SECRETS_KEY") |
| 111 | if err != nil { |
| 112 | mu.Unlock() |
| 113 | return "", err |
| 114 | } |
| 115 | } |
| 116 | st, err := open(ctx) |
| 117 | if errors.Is(err, errNoDeployStore) && noSealedConfigFlag(getenv) { |
| 118 | mu.Unlock() |
| 119 | return "", nil // no configuration source: nothing is on its way |
| 120 | } |
| 121 | if err != nil { |
| 122 | mu.Unlock() |
| 123 | return "", err |
| 124 | } |
| 125 | store, priv = st, k |
| 126 | } |
| 127 | st, k := store, priv |
| 128 | mu.Unlock() |
| 129 | return appconfig.ConfiguredKeyDigest(ctx, st, k, rt) |
| 130 | } |
| 131 | } |
| 132 | |