carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Sign in with Keymail — a hand-written client for keymail's JSON-bodied
2// OAuth 2.1 (authorization code + PKCE S256, no client registration:
3// client_id IS the console's origin) and its app API (sealed sends into the
4// user's Apps inbox).
5//
6// Wire contract implemented from the keymail source of truth:
7// keymail oauth.go (handleOAuthToken, handleOAuthRevoke), appapi.go
8// (handleAppSend), federation.go (envelope), crypto.go (seal / sign /
9// key encodings), docs/apps.md. All bodies are JSON, never form-encoded —
10// off-the-shelf oauth2 libraries do not speak this, hence by hand.
11package console
12
13import (
14 "bytes"
15 "context"
16 "crypto/aes"
17 "crypto/cipher"
18 "crypto/ecdh"
19 "crypto/ecdsa"
20 "crypto/hkdf"
21 "crypto/rand"
22 "crypto/sha256"
23 "encoding/base64"
24 "encoding/hex"
25 "encoding/json"
26 "errors"
27 "fmt"
28 "io"
29 "net/http"
30 "net/url"
31 "strconv"
32 "strings"
33 "time"
34)
35
36// ErrGrantRevoked reports a send refused with 401: the user revoked the
37// console's grant in Keymail (or the token is otherwise dead), so the only
38// road back is the OAuth ceremony, which rotates the grant.
39var ErrGrantRevoked = errors.New("keymail: grant revoked or invalid")
40
41// keymailError is a non-200 answer from Keymail. Error() keeps the text
42// do() has always produced; Status and Msg let callers classify without
43// matching strings.
44type keymailError struct {
45 Status int
46 Method, Path string
47 Msg string // Keymail's {"error": ...}, or the HTTP status line
48}
49
50func (e *keymailError) Error() string {
51 return fmt.Sprintf("keymail: %s %s: %s", e.Method, e.Path, e.Msg)
52}
53
54// Keymail is a client bound to one keymail base URL and one console origin.
55type Keymail struct {
56 base string // keymail base URL, no trailing slash
57 origin string // the console's origin — its OAuth client_id
58 signKey *ecdsa.PrivateKey
59 httpc *http.Client
60}
61
62// NewKeymail builds a client. origin is the console's https origin (its
63// client_id); baseURL the keymail deployment (https://keymail.dev).
64func NewKeymail(baseURL, origin string) *Keymail {
65 return &Keymail{
66 base: strings.TrimSuffix(baseURL, "/"),
67 origin: strings.TrimSuffix(origin, "/"),
68 httpc: &http.Client{Timeout: 15 * time.Second},
69 }
70}
71
72// Host is the hostname of the configured Keymail deployment (keymail.dev for
73// https://keymail.dev), lowercased, no port. An address at this host is a
74// Keymail address whatever a lookup says.
75func (k *Keymail) Host() string {
76 u, err := url.Parse(k.base)
77 if err != nil {
78 return ""
79 }
80 return strings.ToLower(u.Hostname())
81}
82
83// SetSigningKey installs the console's long-lived envelope-signing key
84// (ECDSA P-256). With a key set the client asks for scope "identify send"
85// and pins the key into the grant at exchange (keymail requires sign_pub
86// whenever the grant can deliver mail); without one it asks for bare
87// "identify" and cannot SendApp.
88func (k *Keymail) SetSigningKey(priv *ecdsa.PrivateKey) { k.signKey = priv }
89
90// SignPub is the base64 raw uncompressed P-256 point keymail pins in the
91// grant ("" when no key is set).
92func (k *Keymail) SignPub() string {
93 if k.signKey == nil {
94 return ""
95 }
96 pub, err := k.signKey.PublicKey.ECDH()
97 if err != nil {
98 return ""
99 }
100 return base64.StdEncoding.EncodeToString(pub.Bytes())
101}
102
103func (k *Keymail) scope() string {
104 if k.signKey != nil {
105 return "identify send"
106 }
107 return "identify"
108}
109
110// redirectURI is fixed by the console's route table.
111func (k *Keymail) redirectURI() string { return k.origin + "/auth/callback" }
112
113// stepUpRedirectURI is where a re-authentication lands. A separate route from
114// the sign-in callback on purpose: a code minted for a step-up must not be
115// spendable as a sign-in, and a sign-in must never be able to satisfy a
116// step-up — the two flows never share a landing pad or a state cookie.
117func (k *Keymail) stepUpRedirectURI() string { return k.origin + "/auth/stepup/callback" }
118
119// AuthorizeURL is where the browser goes to approve the console.
120func (k *Keymail) AuthorizeURL(state, challenge string) string {
121 return k.authorizeURL(state, challenge, k.redirectURI(), 0)
122}
123
124// StepUpURL is the same ceremony with one extra demand: a FRESH authenticator
125// gesture, however live the user's Keymail session is. `prompt=login` is the
126// ask; `max_age` is the same ask spelled for an IdP that only understands
127// seconds, and both are sent because the tighter of the two wins at Keymail.
128// The redirect lands on the step-up callback, not the sign-in one — a step-up
129// must never be mistakable for a sign-in, in either direction.
130func (k *Keymail) StepUpURL(state, challenge string, window time.Duration) string {
131 return k.authorizeURL(state, challenge, k.stepUpRedirectURI(), window)
132}
133
134func (k *Keymail) authorizeURL(state, challenge, redirect string, window time.Duration) string {
135 q := url.Values{
136 "client_id": {k.origin},
137 "redirect_uri": {redirect},
138 "scope": {k.scope()},
139 "state": {state},
140 "code_challenge": {challenge},
141 "code_challenge_method": {"S256"},
142 }
143 if window > 0 {
144 q.Set("prompt", "login")
145 q.Set("max_age", strconv.Itoa(int(window.Seconds())))
146 }
147 return k.base + "/oauth/authorize?" + q.Encode()
148}
149
150// SupportsStepUp asks the deployment whether it can re-authenticate anyone at
151// all: `/.well-known/keymail` advertises `reauth` where step-up exists (only
152// the relay shape has an identity service behind it). An error here is NOT a
153// "probably yes" — callers must treat both a false and an error as "cannot",
154// because the alternative is walking a human through a ceremony that was never
155// going to produce proof and then deciding what to do about it afterwards.
156func (k *Keymail) SupportsStepUp(ctx context.Context) (bool, error) {
157 var out struct {
158 Reauth bool `json:"reauth"`
159 }
160 if _, err := k.do(ctx, http.MethodGet, "/.well-known/keymail", "", nil, &out); err != nil {
161 return false, err
162 }
163 return out.Reauth, nil
164}
165
166// Grant is one completed ceremony: who the user is, the console's access
167// token, and — for a step-up — when the authenticator gesture behind it
168// happened.
169type Grant struct {
170 Address string
171 Token string
172 // AuthTime is zero when Keymail proved no gesture. Zero is the only honest
173 // representation of "it did not say", and the caller MUST treat it as a
174 // refusal rather than substituting now: a fabricated freshness is worse
175 // than no freshness, because it looks like proof.
176 AuthTime time.Time
177}
178
179// Exchange trades the callback's code (plus the PKCE verifier) for the
180// user's address and the console's access token, server-side.
181func (k *Keymail) Exchange(ctx context.Context, code, verifier string) (address, token string, err error) {
182 g, err := k.ExchangeGrant(ctx, code, verifier, false)
183 return g.Address, g.Token, err
184}
185
186// ExchangeGrant is Exchange with the step-up answer attached. stepUp selects
187// which redirect_uri is replayed — Keymail re-checks it against the one the
188// authorization was minted for, so a code from the sign-in flow cannot be
189// spent here and vice versa.
190func (k *Keymail) ExchangeGrant(ctx context.Context, code, verifier string, stepUp bool) (Grant, error) {
191 redirect := k.redirectURI()
192 if stepUp {
193 redirect = k.stepUpRedirectURI()
194 }
195 req := map[string]string{
196 "code": code,
197 "code_verifier": verifier,
198 "client_id": k.origin,
199 "redirect_uri": redirect,
200 }
201 if sp := k.SignPub(); sp != "" {
202 req["sign_pub"] = sp
203 }
204 var out struct {
205 AccessToken string `json:"access_token"`
206 TokenType string `json:"token_type"`
207 Scope string `json:"scope"`
208 Address string `json:"address"`
209 AuthTime int64 `json:"auth_time"`
210 }
211 if _, err := k.do(ctx, http.MethodPost, "/api/oauth/token", "", req, &out); err != nil {
212 return Grant{}, err
213 }
214 if !strings.EqualFold(out.TokenType, "bearer") {
215 return Grant{}, fmt.Errorf("keymail: token exchange returned token_type %q, want bearer", out.TokenType)
216 }
217 if out.AccessToken == "" || out.Address == "" {
218 return Grant{}, errors.New("keymail: token exchange returned an empty token or address")
219 }
220 g := Grant{Address: out.Address, Token: out.AccessToken}
221 if out.AuthTime > 0 {
222 g.AuthTime = time.Unix(out.AuthTime, 0)
223 }
224 return g, nil
225}
226
227// Revoke retires an access token (RFC 7009 manners server-side: always 200,
228// revealing nothing). Callers treat failures as best-effort.
229func (k *Keymail) Revoke(ctx context.Context, token string) error {
230 _, err := k.do(ctx, http.MethodPost, "/api/oauth/revoke", "", map[string]string{"token": token}, nil)
231 return err
232}
233
234// ---- app send (the returning-sign-in confirmation channel) ------------------
235
236// envelope is keymail's message-in-flight shape (federation.go): plaintext
237// routing headers, subject+body sealed to the recipient's box key in blob,
238// sig the sender's ECDSA signature over headers+blob.
239type envelope struct {
240 V string `json:"v"`
241 ID string `json:"id"`
242 From string `json:"from"`
243 To string `json:"to"`
244 Date string `json:"date"`
245 SignPub string `json:"sign_pub"`
246 Blob string `json:"blob"`
247 Sig string `json:"sig"`
248}
249
250const (
251 protocolVersion = "keymail1"
252 envelopeInfo = "keymail-envelope-v1"
253 envSigContext = "keymail-env-v1"
254)
255
256// sealEnvelope seals {subject, body} to the recipient's box key and signs
257// the envelope with the console's key. SendApp and SendFederated share it,
258// so the two wires cannot drift apart.
259func (k *Keymail) sealEnvelope(from, to, boxPub, subject, body string) (envelope, error) {
260 payload, err := json.Marshal(map[string]string{"subject": subject, "body": body})
261 if err != nil {
262 return envelope{}, err
263 }
264 blob, err := sealToBox(boxPub, payload)
265 if err != nil {
266 return envelope{}, fmt.Errorf("keymail: seal to %s: %w", to, err)
267 }
268 idb := make([]byte, 16)
269 if _, err := rand.Read(idb); err != nil {
270 return envelope{}, err
271 }
272 id := hex.EncodeToString(idb)
273 date := time.Now().UTC().Format(time.RFC3339)
274 sig, err := signRaw(k.signKey, envelopeMsg(id, from, to, date, blob))
275 if err != nil {
276 return envelope{}, err
277 }
278 return envelope{
279 V: protocolVersion, ID: id, From: from, To: to, Date: date,
280 SignPub: k.SignPub(), Blob: blob, Sig: sig,
281 }, nil
282}
283
284// SendApp delivers one sealed message (kind "app" → the user's Apps inbox)
285// to the granting user, using the stored grant token. It looks up the
286// recipient's box key, seals {subject, body} to it, signs the envelope with
287// the console's pinned key, and posts it. Returns the envelope id (the
288// sender mints it, so a deep link is knowable up front). A 401 comes back
289// as ErrGrantRevoked.
290func (k *Keymail) SendApp(ctx context.Context, token, to, subject, body string) (msgID string, err error) {
291 if k.signKey == nil {
292 return "", errors.New("keymail: SendApp requires a signing key")
293 }
294 // From is pinned server-side to the console's own origin host — the
295 // console writes as itself, never as anyone the user might trust.
296 from := "console@" + OriginHost(k.origin)
297 var lr struct {
298 BoxPub string `json:"box_pub"`
299 }
300 lu := "/api/federation/lookup?addr=" + url.QueryEscape(to) + "&from=" + url.QueryEscape(from)
301 if _, err := k.do(ctx, http.MethodGet, lu, "", nil, &lr); err != nil {
302 return "", fmt.Errorf("keymail: lookup %s: %w", to, err)
303 }
304 env, err := k.sealEnvelope(from, to, lr.BoxPub, subject, body)
305 if err != nil {
306 return "", err
307 }
308 status, err := k.do(ctx, http.MethodPost, "/api/app/send", token,
309 map[string]any{"envelope": env, "kind": "app"}, nil)
310 if status == http.StatusUnauthorized {
311 return "", ErrGrantRevoked
312 }
313 if err != nil {
314 return "", err
315 }
316 return env.ID, nil
317}
318
319// OriginHost strips the scheme off an origin: the host (with port, if any)
320// that keymail treats as the app's identity.
321func OriginHost(origin string) string {
322 h := strings.TrimPrefix(origin, "https://")
323 return strings.TrimPrefix(h, "http://")
324}
325
326// ---- HTTP plumbing ----------------------------------------------------------
327
328// do runs one JSON round-trip. Non-200 answers become an error carrying the
329// server's {"error": ...} text; the status is returned either way so callers
330// can classify (401 → revoked grant).
331func (k *Keymail) do(ctx context.Context, method, path, bearer string, in, out any) (int, error) {
332 var body io.Reader
333 if in != nil {
334 raw, err := json.Marshal(in)
335 if err != nil {
336 return 0, err
337 }
338 body = bytes.NewReader(raw)
339 }
340 req, err := http.NewRequestWithContext(ctx, method, k.base+path, body)
341 if err != nil {
342 return 0, err
343 }
344 if in != nil {
345 req.Header.Set("Content-Type", "application/json")
346 }
347 if bearer != "" {
348 req.Header.Set("Authorization", "Bearer "+bearer)
349 }
350 res, err := k.httpc.Do(req)
351 if err != nil {
352 return 0, err
353 }
354 defer res.Body.Close()
355 raw, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))
356 if err != nil {
357 return res.StatusCode, err
358 }
359 if res.StatusCode != http.StatusOK {
360 var e struct {
361 Error string `json:"error"`
362 }
363 msg := res.Status
364 if json.Unmarshal(raw, &e) == nil && e.Error != "" {
365 msg = e.Error
366 }
367 return res.StatusCode, &keymailError{Status: res.StatusCode, Method: method, Path: path, Msg: msg}
368 }
369 if out != nil {
370 if err := json.Unmarshal(raw, out); err != nil {
371 return res.StatusCode, fmt.Errorf("keymail: %s %s: bad response body: %v", method, path, err)
372 }
373 }
374 return res.StatusCode, nil
375}
376
377// ---- PKCE -------------------------------------------------------------------
378
379// NewVerifier mints a PKCE code verifier (43 base64url chars — inside the
380// RFC 7636 window). Also serves as the state generator.
381func NewVerifier() (string, error) {
382 b := make([]byte, 32)
383 if _, err := rand.Read(b); err != nil {
384 return "", err
385 }
386 return base64.RawURLEncoding.EncodeToString(b), nil
387}
388
389// ChallengeS256 derives the S256 code challenge for a verifier.
390func ChallengeS256(verifier string) string {
391 sum := sha256.Sum256([]byte(verifier))
392 return base64.RawURLEncoding.EncodeToString(sum[:])
393}
394
395// ---- envelope crypto (ported from keymail crypto.go, byte-compatible) -------
396
397// envelopeMsg is what a sender signs per message: routing headers and the
398// ciphertext, so tampering with any of them breaks the signature.
399func envelopeMsg(id, from, to, date, blob string) []byte {
400 return []byte(envSigContext + "\n" + id + "\n" + from + "\n" + to + "\n" + date + "\n" + blob)
401}
402
403// signRaw produces a WebCrypto-compatible raw r||s ECDSA P-256 signature.
404func signRaw(priv *ecdsa.PrivateKey, msg []byte) (string, error) {
405 h := sha256.Sum256(msg)
406 r, s, err := ecdsa.Sign(rand.Reader, priv, h[:])
407 if err != nil {
408 return "", err
409 }
410 sig := make([]byte, 64)
411 r.FillBytes(sig[:32])
412 s.FillBytes(sig[32:])
413 return base64.StdEncoding.EncodeToString(sig), nil
414}
415
416// sealToBox encrypts payload to a recipient box key: ephemeral ECDH P-256 →
417// HKDF-SHA256("keymail-envelope-v1") → AES-GCM-256, blob = ephPub(65) ||
418// iv(12) || ciphertext, base64 (std).
419func sealToBox(recipientBoxPubB64 string, payload []byte) (string, error) {
420 raw, err := base64.StdEncoding.DecodeString(recipientBoxPubB64)
421 if err != nil {
422 return "", errors.New("bad recipient key encoding")
423 }
424 pub, err := ecdh.P256().NewPublicKey(raw)
425 if err != nil {
426 return "", fmt.Errorf("bad recipient key: %w", err)
427 }
428 eph, err := ecdh.P256().GenerateKey(rand.Reader)
429 if err != nil {
430 return "", err
431 }
432 shared, err := eph.ECDH(pub)
433 if err != nil {
434 return "", err
435 }
436 key, err := hkdf.Key(sha256.New, shared, nil, envelopeInfo, 32)
437 if err != nil {
438 return "", err
439 }
440 block, err := aes.NewCipher(key)
441 if err != nil {
442 return "", err
443 }
444 gcm, err := cipher.NewGCM(block)
445 if err != nil {
446 return "", err
447 }
448 iv := make([]byte, 12)
449 if _, err := rand.Read(iv); err != nil {
450 return "", err
451 }
452 ct := gcm.Seal(nil, iv, payload, nil)
453 blob := append(append(eph.PublicKey().Bytes(), iv...), ct...)
454 return base64.StdEncoding.EncodeToString(blob), nil
455}
456