package console import ( "context" "crypto/aes" "crypto/cipher" "crypto/ecdh" "crypto/ecdsa" "crypto/elliptic" "crypto/hkdf" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/json" "errors" "math/big" "net/http" "net/http/httptest" "net/url" "strings" "sync" "testing" "time" ) // ---- a fake keymail, faithful to oauth.go / appapi.go on the wires the // console touches. Shared with server_test.go. type sentMessage struct { ID, From, To, Subject, Body, Kind string } type fakeKeymail struct { t *testing.T srv *httptest.Server mu sync.Mutex address string // the user identity minted at exchange expectCode string // the single code the fake will honor challenge string // PKCE challenge captured from the authorize URL token string // access token minted at exchange signPub string // sign_pub pinned at exchange origin string // client_id seen at exchange boxPriv *ecdh.PrivateKey send401 bool // simulate a revoked grant on /api/app/send revoked []string sends []sentMessage exchanges int // Step-up (stepup.go). reauth mirrors /.well-known/keymail's advertised // capability; authTime is what the next exchange reports (zero = "this // deployment proved no gesture", the fail-closed case); stepUp records // whether the last exchange replayed the step-up redirect_uri. reauth bool authTime time.Time stepUp bool // Federation (federation.go / relay.go proxyAPI). fedSends is every // envelope accepted over /api/federation/deliver (Kind "federation"). // powBits is the price this mailbox charges a sender it doesn't know yet. // noMailbox marks local parts with no claimed mailbox (404 at lookup). // blocked marks senders this mailbox has blocked. senders is the // registered-sender table: from -> the sign_pub it proved. registrations // counts successful /api/federation/register calls. challenges is the // outstanding challenge-token table (the real Keymail seals the token // instead of keeping a map, but the wire is the same). fedSends []sentMessage powBits int noMailbox map[string]bool blocked map[string]bool senders map[string]string registrations int challenges map[string]fakeChallenge // lookupStatus makes the lookup for one full address answer that HTTP // status (400/404/409/500...) instead of its usual 200; lookupStall makes // it hold the answer for that long (or until the caller gives up). lookupStatus map[string]int lookupStall map[string]time.Duration // lookups counts every federation lookup the fake answered or was asked. lookups int // deliverStatus, when set, makes /api/federation/deliver answer that // status (an ordinary post-lookup failure) before looking at the envelope. deliverStatus int } // sentBodies is every message body the fake has accepted — how a test proves // a link was delivered into a Keymail inbox rather than emailed. func (f *fakeKeymail) sentBodies() []string { f.mu.Lock() defer f.mu.Unlock() out := make([]string, 0, len(f.sends)) for _, s := range f.sends { out = append(out, s.Body) } return out } func newFakeKeymail(t *testing.T, address string) *fakeKeymail { t.Helper() boxPriv, err := ecdh.P256().GenerateKey(rand.Reader) if err != nil { t.Fatal(err) } // reauth defaults on: the deployments this console talks to are relays, and // a test that wants the fail-closed path turns it off explicitly. f := &fakeKeymail{ t: t, address: address, boxPriv: boxPriv, reauth: true, powBits: 8, noMailbox: map[string]bool{}, blocked: map[string]bool{}, lookupStatus: map[string]int{}, lookupStall: map[string]time.Duration{}, senders: map[string]string{}, } mux := http.NewServeMux() mux.HandleFunc("GET /.well-known/keymail", f.handleWellKnown) mux.HandleFunc("POST /api/oauth/token", f.handleToken) mux.HandleFunc("POST /api/oauth/revoke", f.handleRevoke) mux.HandleFunc("GET /api/federation/lookup", f.handleLookup) mux.HandleFunc("POST /api/app/send", f.handleSend) mux.HandleFunc("POST /api/federation/challenge", f.handleChallenge) mux.HandleFunc("POST /api/federation/register", f.handleRegister) mux.HandleFunc("POST /api/federation/deliver", f.handleDeliver) f.srv = httptest.NewServer(mux) t.Cleanup(f.srv.Close) return f } func jsonErr(w http.ResponseWriter, status int, msg string) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) json.NewEncoder(w).Encode(map[string]string{"error": msg}) } func (f *fakeKeymail) handleToken(w http.ResponseWriter, r *http.Request) { f.mu.Lock() defer f.mu.Unlock() var req struct { Code string `json:"code"` CodeVerifier string `json:"code_verifier"` ClientID string `json:"client_id"` RedirectURI string `json:"redirect_uri"` SignPub string `json:"sign_pub"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { jsonErr(w, http.StatusBadRequest, "bad json") return } if f.expectCode == "" || req.Code != f.expectCode { jsonErr(w, http.StatusBadRequest, "code invalid, used, or expired") return } f.expectCode = "" // single-use, burn-then-check manners if f.challenge != "" && ChallengeS256(req.CodeVerifier) != f.challenge { jsonErr(w, http.StatusBadRequest, "PKCE verification failed") return } switch req.RedirectURI { case req.ClientID + "/auth/callback": f.stepUp = false case req.ClientID + "/auth/stepup/callback": f.stepUp = true default: jsonErr(w, http.StatusBadRequest, "client_id or redirect_uri does not match the authorization") return } // Scope "identify send" requires the app's envelope-signing key: a // base64 raw uncompressed P-256 point (keymail parseECDSAPub). raw, err := base64.StdEncoding.DecodeString(req.SignPub) if err != nil || len(raw) != 65 || raw[0] != 4 { jsonErr(w, http.StatusBadRequest, "sign_pub (the app's envelope-signing key) is required to send") return } f.signPub = req.SignPub f.origin = req.ClientID f.token = "user.tok-" + f.address f.exchanges++ out := map[string]any{ "access_token": f.token, "token_type": "bearer", "scope": "identify send", "address": f.address, } // auth_time is OMITTED when no gesture was proved — keymail never invents // one, and the console must treat its absence as a refusal. if !f.authTime.IsZero() { out["auth_time"] = f.authTime.Unix() } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(out) } // handleWellKnown is the capability probe the step-up gate makes before it // sends anyone anywhere. func (f *fakeKeymail) handleWellKnown(w http.ResponseWriter, r *http.Request) { f.mu.Lock() defer f.mu.Unlock() w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ "version": "keymail1", "host": "keymail.dev", "pow_bits": 8, "reauth": f.reauth, }) } func (f *fakeKeymail) handleRevoke(w http.ResponseWriter, r *http.Request) { f.mu.Lock() defer f.mu.Unlock() var req struct { Token string `json:"token"` } json.NewDecoder(r.Body).Decode(&req) f.revoked = append(f.revoked, req.Token) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) } // handleLookup routes like the relay (keymail/relay.go proxyAPI) and prices // like the instance (keymail/federation.go handleLookup): a host other than // keymail.dev never resolves (the relay only owns that one host), and an // unclaimed local part 404s as "no such mailbox" one level up from "no such // inbox" — the fake collapses the two hops into one handler. func (f *fakeKeymail) handleLookup(w http.ResponseWriter, r *http.Request) { addr := r.URL.Query().Get("addr") f.mu.Lock() f.lookups++ stall, status := f.lookupStall[addr], f.lookupStatus[addr] f.mu.Unlock() if stall > 0 { select { case <-time.After(stall): case <-r.Context().Done(): return } } if status != 0 && status != http.StatusOK { jsonErr(w, status, http.StatusText(status)) return } f.mu.Lock() defer f.mu.Unlock() // A forced 200 resolves whatever the host (a Keymail-served custom // domain), so it skips the keymail.dev-only routing. if status != http.StatusOK { local, host, ok := strings.Cut(addr, "@") if !ok || host != "keymail.dev" { jsonErr(w, http.StatusBadRequest, "could not determine target mailbox") return } if f.noMailbox[local] { jsonErr(w, http.StatusNotFound, "no such mailbox") return } } bits := f.powBits if _, known := f.senders[r.URL.Query().Get("from")]; known { bits = 0 // keymail prices a known sender at 0 whatever key it brings } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ "addr": addr, "sign_pub": "", "box_pub": base64.StdEncoding.EncodeToString(f.boxPriv.PublicKey().Bytes()), "pow_bits": bits, }) } // fakeChallenge is the outstanding-challenge record keyed by token. The real // Keymail seals the challenge instead of keeping a map (federation.go // sealChallenge/openChallenge), but the wire — and what register checks — is // the same. type fakeChallenge struct { to, from, signPub string bits int } func (f *fakeKeymail) handleChallenge(w http.ResponseWriter, r *http.Request) { f.mu.Lock() defer f.mu.Unlock() var raw struct { To string `json:"to"` From string `json:"from"` SignPub string `json:"sign_pub"` } if err := json.NewDecoder(r.Body).Decode(&raw); err != nil { jsonErr(w, http.StatusBadRequest, "bad json") return } if f.blocked[raw.From] { jsonErr(w, http.StatusForbidden, "blocked") return } bits := f.powBits if f.senders[raw.From] == raw.SignPub { bits = 0 } tok := make([]byte, 16) rand.Read(tok) token := base64.RawURLEncoding.EncodeToString(tok) if f.challenges == nil { f.challenges = map[string]fakeChallenge{} } f.challenges[token] = fakeChallenge{to: raw.To, from: raw.From, signPub: raw.SignPub, bits: bits} w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{"challenge": token, "bits": bits, "expires_at": time.Now().Add(time.Minute).Unix()}) } func (f *fakeKeymail) handleRegister(w http.ResponseWriter, r *http.Request) { f.mu.Lock() defer f.mu.Unlock() var req struct { To string `json:"to"` Challenge string `json:"challenge"` Counter uint64 `json:"counter"` From string `json:"from"` SignPub string `json:"sign_pub"` BoxPub string `json:"box_pub"` Attestation string `json:"attestation"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { jsonErr(w, http.StatusBadRequest, "bad json") return } c, ok := f.challenges[req.Challenge] if !ok { jsonErr(w, http.StatusBadRequest, "bad challenge") return } delete(f.challenges, req.Challenge) if c.from != req.From || c.signPub != req.SignPub { jsonErr(w, http.StatusBadRequest, "challenge does not match this sender") return } nonce := sha256.Sum256([]byte(req.Challenge)) signPubRaw, _ := base64.StdEncoding.DecodeString(req.SignPub) if refLeadingZeros(refHash(nonce[:], signPubRaw, req.Counter)) < c.bits { jsonErr(w, http.StatusForbidden, "proof of work does not meet difficulty") return } attest := []byte("keymail-attest-v1\n" + req.From + "\n" + req.SignPub + "\n" + req.BoxPub) if !testVerify(req.SignPub, attest, req.Attestation) { jsonErr(w, http.StatusForbidden, "bad key attestation") return } f.senders[req.From] = req.SignPub f.registrations++ w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"status": "proven"}) } func (f *fakeKeymail) handleDeliver(w http.ResponseWriter, r *http.Request) { f.mu.Lock() defer f.mu.Unlock() var req struct { Envelope envelope `json:"envelope"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { jsonErr(w, http.StatusBadRequest, "bad json") return } e := req.Envelope if f.deliverStatus != 0 { jsonErr(w, f.deliverStatus, http.StatusText(f.deliverStatus)) return } if f.blocked[e.From] { jsonErr(w, http.StatusForbidden, "blocked") return } reg, known := f.senders[e.From] if !known { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusForbidden) json.NewEncoder(w).Encode(map[string]any{"error": "unknown_sender", "pow_bits": f.powBits}) return } if reg != e.SignPub { jsonErr(w, http.StatusForbidden, "signing key does not match registered key") return } if e.V != "keymail1" || e.ID == "" || e.Date == "" || !testVerify(e.SignPub, envelopeMsg(e.ID, e.From, e.To, e.Date, e.Blob), e.Sig) { jsonErr(w, http.StatusBadRequest, "bad envelope signature") return } plain, err := testOpen(f.boxPriv, e.Blob) if err != nil { jsonErr(w, http.StatusBadRequest, "blob does not open") return } var p struct{ Subject, Body string } json.Unmarshal(plain, &p) f.fedSends = append(f.fedSends, sentMessage{ID: e.ID, From: e.From, To: e.To, Subject: p.Subject, Body: p.Body, Kind: "federation"}) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"status": "delivered"}) } // fedBodies is every message body the fake has accepted over federation — // how a test proves an invite reached someone who never granted the // console anything. func (f *fakeKeymail) fedBodies() []string { f.mu.Lock() defer f.mu.Unlock() out := make([]string, 0, len(f.fedSends)) for _, s := range f.fedSends { out = append(out, s.Body) } return out } func (f *fakeKeymail) handleSend(w http.ResponseWriter, r *http.Request) { f.mu.Lock() defer f.mu.Unlock() if f.send401 || r.Header.Get("Authorization") != "Bearer "+f.token || f.token == "" { jsonErr(w, http.StatusUnauthorized, "bad, expired or revoked app token") return } var req struct { Envelope envelope `json:"envelope"` Kind string `json:"kind"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { jsonErr(w, http.StatusBadRequest, "bad json") return } e := req.Envelope if e.To == "" { jsonErr(w, http.StatusForbidden, "envelope needs a recipient") return } // The grant authorizes exactly one recipient: the user who approved it // (keymail appapi.go handleAppSend). This fake used to accept anyone, // which is how invites shipped broken. grantUser := strings.TrimPrefix(f.token, "user.tok-") if e.To != grantUser { jsonErr(w, http.StatusForbidden, "envelope to must be "+grantUser) return } if host := OriginHost(f.origin); !strings.HasSuffix(e.From, "@"+host) { jsonErr(w, http.StatusForbidden, "envelope from must be an address on "+host) return } if e.SignPub != f.signPub { jsonErr(w, http.StatusForbidden, "signing key does not match this grant") return } if e.V != "keymail1" || e.ID == "" || e.Date == "" || !testVerify(e.SignPub, envelopeMsg(e.ID, e.From, e.To, e.Date, e.Blob), e.Sig) { jsonErr(w, http.StatusBadRequest, "bad envelope signature") return } plain, err := testOpen(f.boxPriv, e.Blob) if err != nil { jsonErr(w, http.StatusBadRequest, "blob does not open: "+err.Error()) return } var payload struct{ Subject, Body string } if err := json.Unmarshal(plain, &payload); err != nil { jsonErr(w, http.StatusBadRequest, "payload is not subject/body json") return } f.sends = append(f.sends, sentMessage{ ID: e.ID, From: e.From, To: e.To, Subject: payload.Subject, Body: payload.Body, Kind: req.Kind, }) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"status": "delivered", "folder": "apps"}) } // testVerify mirrors keymail crypto.go verifySig: raw r||s ECDSA P-256 over // sha256(msg), key a base64 raw uncompressed point. func testVerify(b64pub string, msg []byte, b64sig string) bool { raw, err := base64.StdEncoding.DecodeString(b64pub) if err != nil || len(raw) != 65 || raw[0] != 4 { return false } pub := &ecdsa.PublicKey{ Curve: elliptic.P256(), X: new(big.Int).SetBytes(raw[1:33]), Y: new(big.Int).SetBytes(raw[33:65]), } sig, err := base64.StdEncoding.DecodeString(b64sig) if err != nil || len(sig) != 64 { return false } h := sha256.Sum256(msg) return ecdsa.Verify(pub, h[:], new(big.Int).SetBytes(sig[:32]), new(big.Int).SetBytes(sig[32:])) } // testOpen mirrors keymail crypto.go open: the inverse of sealToBox. func testOpen(boxPriv *ecdh.PrivateKey, blobB64 string) ([]byte, error) { blob, err := base64.StdEncoding.DecodeString(blobB64) if err != nil || len(blob) < 65+12+16 { return nil, errors.New("bad blob") } ephPub, err := ecdh.P256().NewPublicKey(blob[:65]) if err != nil { return nil, err } shared, err := boxPriv.ECDH(ephPub) if err != nil { return nil, err } key, err := hkdf.Key(sha256.New, shared, nil, envelopeInfo, 32) if err != nil { return nil, err } block, err := aes.NewCipher(key) if err != nil { return nil, err } gcm, err := cipher.NewGCM(block) if err != nil { return nil, err } return gcm.Open(nil, blob[65:77], blob[77:], nil) } func testSigningKey(t *testing.T) *ecdsa.PrivateKey { t.Helper() k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { t.Fatal(err) } return k } // ---- tests ------------------------------------------------------------------ func TestAuthorizeURL(t *testing.T) { km := NewKeymail("https://keymail.dev/", "https://platform.carlosframework.com") km.SetSigningKey(testSigningKey(t)) u, err := url.Parse(km.AuthorizeURL("state123", "chal456")) if err != nil { t.Fatal(err) } if u.Scheme != "https" || u.Host != "keymail.dev" || u.Path != "/oauth/authorize" { t.Fatalf("authorize URL = %v", u) } q := u.Query() for k, want := range map[string]string{ "client_id": "https://platform.carlosframework.com", "redirect_uri": "https://platform.carlosframework.com/auth/callback", "scope": "identify send", "state": "state123", "code_challenge": "chal456", "code_challenge_method": "S256", } { if got := q.Get(k); got != want { t.Errorf("%s = %q, want %q", k, got, want) } } } func TestAuthorizeURLScopeWithoutKey(t *testing.T) { km := NewKeymail("https://keymail.dev", "https://platform.carlosframework.com") u, _ := url.Parse(km.AuthorizeURL("s", "c")) if got := u.Query().Get("scope"); got != "identify" { t.Fatalf("scope without signing key = %q, want identify", got) } } func TestExchangeRoundTrip(t *testing.T) { fk := newFakeKeymail(t, "paul@keymail.dev") km := NewKeymail(fk.srv.URL, "https://platform.carlosframework.com") km.SetSigningKey(testSigningKey(t)) verifier, err := NewVerifier() if err != nil { t.Fatal(err) } if n := len(verifier); n < 43 || n > 128 { t.Fatalf("verifier length %d outside RFC 7636 window", n) } fk.challenge = ChallengeS256(verifier) fk.expectCode = "code-1" addr, token, err := km.Exchange(context.Background(), "code-1", verifier) if err != nil { t.Fatal(err) } if addr != "paul@keymail.dev" { t.Fatalf("address = %q", addr) } if token == "" || token != fk.token { t.Fatalf("token = %q, fake minted %q", token, fk.token) } if fk.signPub != km.SignPub() { t.Fatalf("fake pinned sign_pub %q, client's is %q", fk.signPub, km.SignPub()) } } func TestExchangePKCEMismatch(t *testing.T) { fk := newFakeKeymail(t, "paul@keymail.dev") km := NewKeymail(fk.srv.URL, "https://platform.carlosframework.com") km.SetSigningKey(testSigningKey(t)) fk.challenge = ChallengeS256("the-real-verifier") fk.expectCode = "code-1" _, _, err := km.Exchange(context.Background(), "code-1", "a-different-verifier") if err == nil || !strings.Contains(err.Error(), "PKCE") { t.Fatalf("err = %v, want PKCE failure surfaced", err) } } func TestExchangeBadCode(t *testing.T) { fk := newFakeKeymail(t, "paul@keymail.dev") km := NewKeymail(fk.srv.URL, "https://platform.carlosframework.com") km.SetSigningKey(testSigningKey(t)) fk.expectCode = "code-1" _, _, err := km.Exchange(context.Background(), "guessed", "v") if err == nil || !strings.Contains(err.Error(), "code invalid") { t.Fatalf("err = %v, want code-invalid error", err) } } func TestRevoke(t *testing.T) { fk := newFakeKeymail(t, "paul@keymail.dev") km := NewKeymail(fk.srv.URL, "https://platform.carlosframework.com") if err := km.Revoke(context.Background(), "user.tok"); err != nil { t.Fatal(err) } if len(fk.revoked) != 1 || fk.revoked[0] != "user.tok" { t.Fatalf("revoked = %v", fk.revoked) } } func TestSendAppSealedRoundTrip(t *testing.T) { fk := newFakeKeymail(t, "paul@keymail.dev") km := NewKeymail(fk.srv.URL, "http://127.0.0.1:8090") km.SetSigningKey(testSigningKey(t)) verifier, _ := NewVerifier() fk.challenge = ChallengeS256(verifier) fk.expectCode = "code-1" _, token, err := km.Exchange(context.Background(), "code-1", verifier) if err != nil { t.Fatal(err) } id, err := km.SendApp(context.Background(), token, "paul@keymail.dev", "Sign in", "click https://example.com/auth/confirm?token=abc") if err != nil { t.Fatal(err) } if id == "" { t.Fatal("no envelope id") } if len(fk.sends) != 1 { t.Fatalf("fake got %d sends", len(fk.sends)) } got := fk.sends[0] if got.ID != id || got.To != "paul@keymail.dev" || got.From != "console@127.0.0.1:8090" { t.Fatalf("send = %+v", got) } if got.Subject != "Sign in" || !strings.Contains(got.Body, "confirm?token=abc") { t.Fatalf("sealed payload did not round-trip: %+v", got) } if got.Kind != "app" { t.Fatalf("kind = %q, want app", got.Kind) } } func TestSendAppRevokedGrant(t *testing.T) { fk := newFakeKeymail(t, "paul@keymail.dev") km := NewKeymail(fk.srv.URL, "http://127.0.0.1:8090") km.SetSigningKey(testSigningKey(t)) verifier, _ := NewVerifier() fk.challenge = ChallengeS256(verifier) fk.expectCode = "code-1" _, token, err := km.Exchange(context.Background(), "code-1", verifier) if err != nil { t.Fatal(err) } fk.send401 = true if _, err := km.SendApp(context.Background(), token, "paul@keymail.dev", "s", "b"); !errors.Is(err, ErrGrantRevoked) { t.Fatalf("err = %v, want ErrGrantRevoked", err) } } func TestSendAppWithoutKey(t *testing.T) { km := NewKeymail("https://keymail.dev", "https://platform.carlosframework.com") if _, err := km.SendApp(context.Background(), "tok", "paul@keymail.dev", "s", "b"); err == nil { t.Fatal("SendApp without a signing key should refuse") } }