// Sign in with Keymail — a hand-written client for keymail's JSON-bodied // OAuth 2.1 (authorization code + PKCE S256, no client registration: // client_id IS the console's origin) and its app API (sealed sends into the // user's Apps inbox). // // Wire contract implemented from the keymail source of truth: // keymail oauth.go (handleOAuthToken, handleOAuthRevoke), appapi.go // (handleAppSend), federation.go (envelope), crypto.go (seal / sign / // key encodings), docs/apps.md. All bodies are JSON, never form-encoded — // off-the-shelf oauth2 libraries do not speak this, hence by hand. package console import ( "bytes" "context" "crypto/aes" "crypto/cipher" "crypto/ecdh" "crypto/ecdsa" "crypto/hkdf" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strconv" "strings" "time" ) // ErrGrantRevoked reports a send refused with 401: the user revoked the // console's grant in Keymail (or the token is otherwise dead), so the only // road back is the OAuth ceremony, which rotates the grant. var ErrGrantRevoked = errors.New("keymail: grant revoked or invalid") // keymailError is a non-200 answer from Keymail. Error() keeps the text // do() has always produced; Status and Msg let callers classify without // matching strings. type keymailError struct { Status int Method, Path string Msg string // Keymail's {"error": ...}, or the HTTP status line } func (e *keymailError) Error() string { return fmt.Sprintf("keymail: %s %s: %s", e.Method, e.Path, e.Msg) } // Keymail is a client bound to one keymail base URL and one console origin. type Keymail struct { base string // keymail base URL, no trailing slash origin string // the console's origin — its OAuth client_id signKey *ecdsa.PrivateKey httpc *http.Client } // NewKeymail builds a client. origin is the console's https origin (its // client_id); baseURL the keymail deployment (https://keymail.dev). func NewKeymail(baseURL, origin string) *Keymail { return &Keymail{ base: strings.TrimSuffix(baseURL, "/"), origin: strings.TrimSuffix(origin, "/"), httpc: &http.Client{Timeout: 15 * time.Second}, } } // Host is the hostname of the configured Keymail deployment (keymail.dev for // https://keymail.dev), lowercased, no port. An address at this host is a // Keymail address whatever a lookup says. func (k *Keymail) Host() string { u, err := url.Parse(k.base) if err != nil { return "" } return strings.ToLower(u.Hostname()) } // SetSigningKey installs the console's long-lived envelope-signing key // (ECDSA P-256). With a key set the client asks for scope "identify send" // and pins the key into the grant at exchange (keymail requires sign_pub // whenever the grant can deliver mail); without one it asks for bare // "identify" and cannot SendApp. func (k *Keymail) SetSigningKey(priv *ecdsa.PrivateKey) { k.signKey = priv } // SignPub is the base64 raw uncompressed P-256 point keymail pins in the // grant ("" when no key is set). func (k *Keymail) SignPub() string { if k.signKey == nil { return "" } pub, err := k.signKey.PublicKey.ECDH() if err != nil { return "" } return base64.StdEncoding.EncodeToString(pub.Bytes()) } func (k *Keymail) scope() string { if k.signKey != nil { return "identify send" } return "identify" } // redirectURI is fixed by the console's route table. func (k *Keymail) redirectURI() string { return k.origin + "/auth/callback" } // stepUpRedirectURI is where a re-authentication lands. A separate route from // the sign-in callback on purpose: a code minted for a step-up must not be // spendable as a sign-in, and a sign-in must never be able to satisfy a // step-up — the two flows never share a landing pad or a state cookie. func (k *Keymail) stepUpRedirectURI() string { return k.origin + "/auth/stepup/callback" } // AuthorizeURL is where the browser goes to approve the console. func (k *Keymail) AuthorizeURL(state, challenge string) string { return k.authorizeURL(state, challenge, k.redirectURI(), 0) } // StepUpURL is the same ceremony with one extra demand: a FRESH authenticator // gesture, however live the user's Keymail session is. `prompt=login` is the // ask; `max_age` is the same ask spelled for an IdP that only understands // seconds, and both are sent because the tighter of the two wins at Keymail. // The redirect lands on the step-up callback, not the sign-in one — a step-up // must never be mistakable for a sign-in, in either direction. func (k *Keymail) StepUpURL(state, challenge string, window time.Duration) string { return k.authorizeURL(state, challenge, k.stepUpRedirectURI(), window) } func (k *Keymail) authorizeURL(state, challenge, redirect string, window time.Duration) string { q := url.Values{ "client_id": {k.origin}, "redirect_uri": {redirect}, "scope": {k.scope()}, "state": {state}, "code_challenge": {challenge}, "code_challenge_method": {"S256"}, } if window > 0 { q.Set("prompt", "login") q.Set("max_age", strconv.Itoa(int(window.Seconds()))) } return k.base + "/oauth/authorize?" + q.Encode() } // SupportsStepUp asks the deployment whether it can re-authenticate anyone at // all: `/.well-known/keymail` advertises `reauth` where step-up exists (only // the relay shape has an identity service behind it). An error here is NOT a // "probably yes" — callers must treat both a false and an error as "cannot", // because the alternative is walking a human through a ceremony that was never // going to produce proof and then deciding what to do about it afterwards. func (k *Keymail) SupportsStepUp(ctx context.Context) (bool, error) { var out struct { Reauth bool `json:"reauth"` } if _, err := k.do(ctx, http.MethodGet, "/.well-known/keymail", "", nil, &out); err != nil { return false, err } return out.Reauth, nil } // Grant is one completed ceremony: who the user is, the console's access // token, and — for a step-up — when the authenticator gesture behind it // happened. type Grant struct { Address string Token string // AuthTime is zero when Keymail proved no gesture. Zero is the only honest // representation of "it did not say", and the caller MUST treat it as a // refusal rather than substituting now: a fabricated freshness is worse // than no freshness, because it looks like proof. AuthTime time.Time } // Exchange trades the callback's code (plus the PKCE verifier) for the // user's address and the console's access token, server-side. func (k *Keymail) Exchange(ctx context.Context, code, verifier string) (address, token string, err error) { g, err := k.ExchangeGrant(ctx, code, verifier, false) return g.Address, g.Token, err } // ExchangeGrant is Exchange with the step-up answer attached. stepUp selects // which redirect_uri is replayed — Keymail re-checks it against the one the // authorization was minted for, so a code from the sign-in flow cannot be // spent here and vice versa. func (k *Keymail) ExchangeGrant(ctx context.Context, code, verifier string, stepUp bool) (Grant, error) { redirect := k.redirectURI() if stepUp { redirect = k.stepUpRedirectURI() } req := map[string]string{ "code": code, "code_verifier": verifier, "client_id": k.origin, "redirect_uri": redirect, } if sp := k.SignPub(); sp != "" { req["sign_pub"] = sp } var out struct { AccessToken string `json:"access_token"` TokenType string `json:"token_type"` Scope string `json:"scope"` Address string `json:"address"` AuthTime int64 `json:"auth_time"` } if _, err := k.do(ctx, http.MethodPost, "/api/oauth/token", "", req, &out); err != nil { return Grant{}, err } if !strings.EqualFold(out.TokenType, "bearer") { return Grant{}, fmt.Errorf("keymail: token exchange returned token_type %q, want bearer", out.TokenType) } if out.AccessToken == "" || out.Address == "" { return Grant{}, errors.New("keymail: token exchange returned an empty token or address") } g := Grant{Address: out.Address, Token: out.AccessToken} if out.AuthTime > 0 { g.AuthTime = time.Unix(out.AuthTime, 0) } return g, nil } // Revoke retires an access token (RFC 7009 manners server-side: always 200, // revealing nothing). Callers treat failures as best-effort. func (k *Keymail) Revoke(ctx context.Context, token string) error { _, err := k.do(ctx, http.MethodPost, "/api/oauth/revoke", "", map[string]string{"token": token}, nil) return err } // ---- app send (the returning-sign-in confirmation channel) ------------------ // envelope is keymail's message-in-flight shape (federation.go): plaintext // routing headers, subject+body sealed to the recipient's box key in blob, // sig the sender's ECDSA signature over headers+blob. type envelope struct { V string `json:"v"` ID string `json:"id"` From string `json:"from"` To string `json:"to"` Date string `json:"date"` SignPub string `json:"sign_pub"` Blob string `json:"blob"` Sig string `json:"sig"` } const ( protocolVersion = "keymail1" envelopeInfo = "keymail-envelope-v1" envSigContext = "keymail-env-v1" ) // sealEnvelope seals {subject, body} to the recipient's box key and signs // the envelope with the console's key. SendApp and SendFederated share it, // so the two wires cannot drift apart. func (k *Keymail) sealEnvelope(from, to, boxPub, subject, body string) (envelope, error) { payload, err := json.Marshal(map[string]string{"subject": subject, "body": body}) if err != nil { return envelope{}, err } blob, err := sealToBox(boxPub, payload) if err != nil { return envelope{}, fmt.Errorf("keymail: seal to %s: %w", to, err) } idb := make([]byte, 16) if _, err := rand.Read(idb); err != nil { return envelope{}, err } id := hex.EncodeToString(idb) date := time.Now().UTC().Format(time.RFC3339) sig, err := signRaw(k.signKey, envelopeMsg(id, from, to, date, blob)) if err != nil { return envelope{}, err } return envelope{ V: protocolVersion, ID: id, From: from, To: to, Date: date, SignPub: k.SignPub(), Blob: blob, Sig: sig, }, nil } // SendApp delivers one sealed message (kind "app" → the user's Apps inbox) // to the granting user, using the stored grant token. It looks up the // recipient's box key, seals {subject, body} to it, signs the envelope with // the console's pinned key, and posts it. Returns the envelope id (the // sender mints it, so a deep link is knowable up front). A 401 comes back // as ErrGrantRevoked. func (k *Keymail) SendApp(ctx context.Context, token, to, subject, body string) (msgID string, err error) { if k.signKey == nil { return "", errors.New("keymail: SendApp requires a signing key") } // From is pinned server-side to the console's own origin host — the // console writes as itself, never as anyone the user might trust. from := "console@" + OriginHost(k.origin) var lr struct { BoxPub string `json:"box_pub"` } lu := "/api/federation/lookup?addr=" + url.QueryEscape(to) + "&from=" + url.QueryEscape(from) if _, err := k.do(ctx, http.MethodGet, lu, "", nil, &lr); err != nil { return "", fmt.Errorf("keymail: lookup %s: %w", to, err) } env, err := k.sealEnvelope(from, to, lr.BoxPub, subject, body) if err != nil { return "", err } status, err := k.do(ctx, http.MethodPost, "/api/app/send", token, map[string]any{"envelope": env, "kind": "app"}, nil) if status == http.StatusUnauthorized { return "", ErrGrantRevoked } if err != nil { return "", err } return env.ID, nil } // OriginHost strips the scheme off an origin: the host (with port, if any) // that keymail treats as the app's identity. func OriginHost(origin string) string { h := strings.TrimPrefix(origin, "https://") return strings.TrimPrefix(h, "http://") } // ---- HTTP plumbing ---------------------------------------------------------- // do runs one JSON round-trip. Non-200 answers become an error carrying the // server's {"error": ...} text; the status is returned either way so callers // can classify (401 → revoked grant). func (k *Keymail) do(ctx context.Context, method, path, bearer string, in, out any) (int, error) { var body io.Reader if in != nil { raw, err := json.Marshal(in) if err != nil { return 0, err } body = bytes.NewReader(raw) } req, err := http.NewRequestWithContext(ctx, method, k.base+path, body) if err != nil { return 0, err } if in != nil { req.Header.Set("Content-Type", "application/json") } if bearer != "" { req.Header.Set("Authorization", "Bearer "+bearer) } res, err := k.httpc.Do(req) if err != nil { return 0, err } defer res.Body.Close() raw, err := io.ReadAll(io.LimitReader(res.Body, 1<<20)) if err != nil { return res.StatusCode, err } if res.StatusCode != http.StatusOK { var e struct { Error string `json:"error"` } msg := res.Status if json.Unmarshal(raw, &e) == nil && e.Error != "" { msg = e.Error } return res.StatusCode, &keymailError{Status: res.StatusCode, Method: method, Path: path, Msg: msg} } if out != nil { if err := json.Unmarshal(raw, out); err != nil { return res.StatusCode, fmt.Errorf("keymail: %s %s: bad response body: %v", method, path, err) } } return res.StatusCode, nil } // ---- PKCE ------------------------------------------------------------------- // NewVerifier mints a PKCE code verifier (43 base64url chars — inside the // RFC 7636 window). Also serves as the state generator. func NewVerifier() (string, error) { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { return "", err } return base64.RawURLEncoding.EncodeToString(b), nil } // ChallengeS256 derives the S256 code challenge for a verifier. func ChallengeS256(verifier string) string { sum := sha256.Sum256([]byte(verifier)) return base64.RawURLEncoding.EncodeToString(sum[:]) } // ---- envelope crypto (ported from keymail crypto.go, byte-compatible) ------- // envelopeMsg is what a sender signs per message: routing headers and the // ciphertext, so tampering with any of them breaks the signature. func envelopeMsg(id, from, to, date, blob string) []byte { return []byte(envSigContext + "\n" + id + "\n" + from + "\n" + to + "\n" + date + "\n" + blob) } // signRaw produces a WebCrypto-compatible raw r||s ECDSA P-256 signature. func signRaw(priv *ecdsa.PrivateKey, msg []byte) (string, error) { h := sha256.Sum256(msg) r, s, err := ecdsa.Sign(rand.Reader, priv, h[:]) if err != nil { return "", err } sig := make([]byte, 64) r.FillBytes(sig[:32]) s.FillBytes(sig[32:]) return base64.StdEncoding.EncodeToString(sig), nil } // sealToBox encrypts payload to a recipient box key: ephemeral ECDH P-256 → // HKDF-SHA256("keymail-envelope-v1") → AES-GCM-256, blob = ephPub(65) || // iv(12) || ciphertext, base64 (std). func sealToBox(recipientBoxPubB64 string, payload []byte) (string, error) { raw, err := base64.StdEncoding.DecodeString(recipientBoxPubB64) if err != nil { return "", errors.New("bad recipient key encoding") } pub, err := ecdh.P256().NewPublicKey(raw) if err != nil { return "", fmt.Errorf("bad recipient key: %w", err) } eph, err := ecdh.P256().GenerateKey(rand.Reader) if err != nil { return "", err } shared, err := eph.ECDH(pub) if err != nil { return "", err } key, err := hkdf.Key(sha256.New, shared, nil, envelopeInfo, 32) if err != nil { return "", err } block, err := aes.NewCipher(key) if err != nil { return "", err } gcm, err := cipher.NewGCM(block) if err != nil { return "", err } iv := make([]byte, 12) if _, err := rand.Read(iv); err != nil { return "", err } ct := gcm.Seal(nil, iv, payload, nil) blob := append(append(eph.PublicKey().Bytes(), iv...), ct...) return base64.StdEncoding.EncodeToString(blob), nil }