oficina / home Public

Clone
git clone https://amadan.net/oficina/home

Plain git — no account needed to clone.

Download

Download this file

1package home
2
3import (
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.
28type 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.
54func 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.
69func 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.
118func 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