| 1 | package home |
| 2 | |
| 3 | import ( |
| 4 | "crypto/subtle" |
| 5 | "encoding/base64" |
| 6 | "errors" |
| 7 | "fmt" |
| 8 | "log/slog" |
| 9 | "os" |
| 10 | "strings" |
| 11 | |
| 12 | "amadan.net/rastrillo/rastrillo/mail" |
| 13 | |
| 14 | "amadan.net/oficina/home/internal/home/seal" |
| 15 | ) |
| 16 | |
| 17 | // Secrets is everything the home needs from platform config, already |
| 18 | // validated. Building it is the only place these variable names appear. |
| 19 | // |
| 20 | // Every field here is a refusal rather than a default. The failure they |
| 21 | // guard against is the same one each time: an app that boots, answers |
| 22 | // /healthz perfectly well, and is broken or leaking in a way nobody |
| 23 | // notices until it matters. A generated DEK makes every previously |
| 24 | // sealed value unreadable; a generated pepper orphans every person row, |
| 25 | // because the blind index is the only way back from an address to a |
| 26 | // person. Neither is recoverable, and both look like data loss long |
| 27 | // after the boot that caused them. |
| 28 | type Secrets struct { |
| 29 | // Sealer seals addresses, team display names and queue payloads at |
| 30 | // rest. From HOME_DEK. |
| 31 | Sealer *seal.Sealer |
| 32 | |
| 33 | // Pepper keys the blind index — HMAC(pepper, lowercased address). |
| 34 | // From HOME_PEPPER. |
| 35 | Pepper []byte |
| 36 | |
| 37 | // SigninKey seals signin.Flow's pending blob. It must come from |
| 38 | // config and never from an instance data key: sign-in runs before |
| 39 | // any session exists. From HOME_SIGNIN_KEY. |
| 40 | // |
| 41 | // signin.Flow.Key is a [32]byte and Flow refuses a zero one with |
| 42 | // ErrNoKey, so the size is the package's requirement, not ours. |
| 43 | SigninKey [32]byte |
| 44 | |
| 45 | // Mailer delivers magic links. |
| 46 | Mailer mail.Sender |
| 47 | } |
| 48 | |
| 49 | // LoadSealer reads HOME_DEK and returns the process Sealer. |
| 50 | // |
| 51 | // It refuses to boot rather than generating a key. A generated key would |
| 52 | // let the app start with every previously sealed link unreadable, which |
| 53 | // looks like data loss and is discovered late. |
| 54 | func LoadSealer() (*seal.Sealer, error) { |
| 55 | dek, err := key32("HOME_DEK") |
| 56 | if err != nil { |
| 57 | return nil, err |
| 58 | } |
| 59 | return seal.New(dek[:]) |
| 60 | } |
| 61 | |
| 62 | // key32 reads a base64 environment variable that must decode to exactly |
| 63 | // 32 bytes, naming the variable in every error. |
| 64 | // |
| 65 | // Exactly, not "at least": a short value is the interesting case, and |
| 66 | // accepting one would let a truncated secret — a copy-paste that lost |
| 67 | // its trailing characters — key real ciphertext with silently less |
| 68 | // entropy than intended. |
| 69 | func key32(name string) ([32]byte, error) { |
| 70 | var out [32]byte |
| 71 | raw := strings.TrimSpace(os.Getenv(name)) |
| 72 | if raw == "" { |
| 73 | return out, fmt.Errorf("%s is not set", name) |
| 74 | } |
| 75 | b, err := base64.StdEncoding.DecodeString(raw) |
| 76 | if err != nil { |
| 77 | return out, fmt.Errorf("%s is not valid base64: %w", name, err) |
| 78 | } |
| 79 | if len(b) != 32 { |
| 80 | return out, fmt.Errorf("%s decodes to %d bytes, want exactly 32", name, len(b)) |
| 81 | } |
| 82 | // An all-zero key is the canonical placeholder — the value a |
| 83 | // generated config, a truncated copy-paste or a `head -c 32 |
| 84 | // /dev/zero` produces — and it is well-formed, so every other check |
| 85 | // here passes it. |
| 86 | // |
| 87 | // The consequence, stated accurately: signin.Flow reads Key only on |
| 88 | // the keymail path (beginKeymail and CompleteKeymail), so a zero |
| 89 | // HOME_SIGNIN_KEY breaks sign-in for addresses at a keymail domain |
| 90 | // and leaves the magic link working. Not "every sign-in", which an |
| 91 | // earlier version of this comment claimed. A silently half-broken |
| 92 | // sign-in is if anything a worse failure than a wholly broken one, |
| 93 | // and this loader is the only place the refusal can happen at boot. |
| 94 | if subtle.ConstantTimeCompare(b, make([]byte, 32)) == 1 { |
| 95 | return out, fmt.Errorf("%s is 32 zero bytes, which is a placeholder rather than a key", name) |
| 96 | } |
| 97 | copy(out[:], b) |
| 98 | return out, nil |
| 99 | } |
| 100 | |
| 101 | // LoadSecrets reads the four secrets and the three settings whose |
| 102 | // absence is silent, and refuses to boot on any of them. |
| 103 | // |
| 104 | // HOME_DEK seals link state, team names and queue payloads |
| 105 | // HOME_PEPPER the blind index |
| 106 | // HOME_SIGNIN_KEY seals signin.Flow's Keymail pending blob |
| 107 | // CARLOS_SMTP_PASS the mailer's credential |
| 108 | // |
| 109 | // dbPath is passed in rather than read from the environment because |
| 110 | // rastrillo.Resolve owns it, and it is checked here so that every boot |
| 111 | // refusal lives in one function an operator can read. |
| 112 | // |
| 113 | // On the platform it is never empty: cmd/home sets Options.DBPath to a |
| 114 | // relative "home.db", which Resolve joins to $STATE_DIRECTORY, and a |
| 115 | // -db flag overrides it only if one is passed. This check therefore |
| 116 | // guards a caller that constructs its own Options without one — a test, |
| 117 | // a future command — rather than the deployed boot path. |
| 118 | func LoadSecrets(dbPath string, logger *slog.Logger) (Secrets, error) { |
| 119 | if logger == nil { |
| 120 | logger = slog.Default() |
| 121 | } |
| 122 | |
| 123 | // SQLite treats an empty filename as a private temporary database: |
| 124 | // the app comes up, works, and loses every person on restart. There |
| 125 | // is no warning for this anywhere below us, which is why it is |
| 126 | // checked here. |
| 127 | if strings.TrimSpace(dbPath) == "" { |
| 128 | return Secrets{}, errors.New("no database path: set Options.DBPath or pass -db, or SQLite opens a private temporary database and every person is lost on restart") |
| 129 | } |
| 130 | |
| 131 | var s Secrets |
| 132 | var err error |
| 133 | |
| 134 | // Through LoadSealer rather than beside it, so HOME_DEK is read in |
| 135 | // one place. Two readers of the same variable is how one of them |
| 136 | // ends up with a check the other does not have. |
| 137 | s.Sealer, err = LoadSealer() |
| 138 | if err != nil { |
| 139 | return Secrets{}, err |
| 140 | } |
| 141 | |
| 142 | pepper, err := key32("HOME_PEPPER") |
| 143 | if err != nil { |
| 144 | return Secrets{}, err |
| 145 | } |
| 146 | s.Pepper = pepper[:] |
| 147 | |
| 148 | s.SigninKey, err = key32("HOME_SIGNIN_KEY") |
| 149 | if err != nil { |
| 150 | return Secrets{}, err |
| 151 | } |
| 152 | |
| 153 | // mail.FromEnv falls back to mail.Logged with a warning when HOST or |
| 154 | // FROM is absent, and Logged's Send logs "to", to — an address into |
| 155 | // platform-held logs on every single sign-in. A warning is not |
| 156 | // enough: that log is storage we cannot grep or redact, so this is |
| 157 | // the one fallback we refuse instead of accept. |
| 158 | // |
| 159 | // CARLOS_SMTP_PASS is a different failure and is refused for a |
| 160 | // different reason, so the message below distinguishes them. It |
| 161 | // leaks nothing: with HOST and FROM set, FromEnv returns the SMTP |
| 162 | // sender whether or not PASS is present. What a missing PASS costs |
| 163 | // is delivery — every link fails at send time on an app that |
| 164 | // otherwise looks healthy. (It is inert without CARLOS_SMTP_USER, |
| 165 | // which `carlos email enable` delivers alongside it; refusing on |
| 166 | // PASS is a bet that the platform sets both or neither.) |
| 167 | // |
| 168 | // Deliberately no loopback relaxation. Sheets' LoadAuthEnv allows |
| 169 | // one for local development, keyed on its instance origin being an |
| 170 | // http loopback host. The home has no origin variable at this step, |
| 171 | // so the equivalent carve-out would have to invent one, and a |
| 172 | // carve-out that turns off the leak guard is not a thing to invent |
| 173 | // in passing. Local development without SMTP is deferred until |
| 174 | // there is somewhere to put it. |
| 175 | var leaking, undeliverable []string |
| 176 | for _, name := range []string{"CARLOS_SMTP_HOST", "CARLOS_SMTP_FROM"} { |
| 177 | if strings.TrimSpace(os.Getenv(name)) == "" { |
| 178 | leaking = append(leaking, name) |
| 179 | } |
| 180 | } |
| 181 | if strings.TrimSpace(os.Getenv("CARLOS_SMTP_PASS")) == "" { |
| 182 | undeliverable = append(undeliverable, "CARLOS_SMTP_PASS") |
| 183 | } |
| 184 | if len(leaking) > 0 { |
| 185 | return Secrets{}, fmt.Errorf( |
| 186 | "mail is not configured: %s not set, so magic links would be written to the log instead of emailed. Run `carlos email enable --app home`", |
| 187 | strings.Join(leaking, ", ")) |
| 188 | } |
| 189 | if len(undeliverable) > 0 { |
| 190 | return Secrets{}, fmt.Errorf( |
| 191 | "mail is not configured: %s not set, so every magic link would fail at send time. Run `carlos email enable --app home`", |
| 192 | strings.Join(undeliverable, ", ")) |
| 193 | } |
| 194 | s.Mailer = mail.FromEnv("CARLOS", logger) |
| 195 | |
| 196 | return s, nil |
| 197 | } |
| 198 | |