console: deliver to any Keymail mailbox by federation
Keymail.SendFederated delivers through the federation protocol (lookup -> optional challenge/proof-of-work/register -> deliver) instead of the app API, which only ever reached the grant's own user. do() now returns a typed keymailError so SendFederated can tell "unknown sender" and "signing key does not match registered key" apart from a dead transport and retry through registration; sealEnvelope is shared with SendApp so the two wires can't drift. The fake keymail gains the three federation verbs and prices lookups the way federation.go/relay.go do, so keymailfed_test.go exercises registration, free re-delivery from a known sender, a rotated key re-registering, a price above the console's cap, an unclaimed or non-Keymail address, and a blocked sender.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4 files changed,
+497
−24
internal/console/keymail.go+44 −21internal/console/keymail_test.go+187 −3internal/console/keymailfed.go+156 −0internal/console/keymailfed_test.go+110 −0
diff --git a/internal/console/keymail.go b/internal/console/keymail.go| index d149e4b8c..f85ab0f6c 100644 |
| --- a/internal/console/keymail.go |
| +++ b/internal/console/keymail.go |
| @@ -38,6 +38,19 @@ import ( |
| // 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 |
| @@ -229,6 +242,34 @@ const ( |
| 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 |
| @@ -249,28 +290,10 @@ func (k *Keymail) SendApp(ctx context.Context, token, to, subject, body string) |
| if _, err := k.do(ctx, http.MethodGet, lu, "", nil, &lr); err != nil { |
| return "", fmt.Errorf("keymail: lookup %s: %w", to, err) |
| } |
| - payload, err := json.Marshal(map[string]string{"subject": subject, "body": body}) |
| - if err != nil { |
| - return "", err |
| - } |
| - blob, err := sealToBox(lr.BoxPub, payload) |
| - if err != nil { |
| - return "", fmt.Errorf("keymail: seal to %s: %w", to, err) |
| - } |
| - idb := make([]byte, 16) |
| - if _, err := rand.Read(idb); err != nil { |
| - return "", err |
| - } |
| - id := hex.EncodeToString(idb) |
| - date := time.Now().UTC().Format(time.RFC3339) |
| - sig, err := signRaw(k.signKey, envelopeMsg(id, from, to, date, blob)) |
| + env, err := k.sealEnvelope(from, to, lr.BoxPub, subject, body) |
| if err != nil { |
| return "", err |
| } |
| - env := envelope{ |
| - V: protocolVersion, ID: id, From: from, To: to, Date: date, |
| - SignPub: k.SignPub(), Blob: blob, Sig: sig, |
| - } |
| status, err := k.do(ctx, http.MethodPost, "/api/app/send", token, |
| map[string]any{"envelope": env, "kind": "app"}, nil) |
| if status == http.StatusUnauthorized { |
| @@ -279,7 +302,7 @@ func (k *Keymail) SendApp(ctx context.Context, token, to, subject, body string) |
| if err != nil { |
| return "", err |
| } |
| - return id, nil |
| + return env.ID, nil |
| } |
| // OriginHost strips the scheme off an origin: the host (with port, if any) |
| @@ -330,7 +353,7 @@ func (k *Keymail) do(ctx context.Context, method, path, bearer string, in, out a |
| if json.Unmarshal(raw, &e) == nil && e.Error != "" { |
| msg = e.Error |
| } |
| - return res.StatusCode, fmt.Errorf("keymail: %s %s: %s", method, path, msg) |
| + return res.StatusCode, &keymailError{Status: res.StatusCode, Method: method, Path: path, Msg: msg} |
| } |
| if out != nil { |
| if err := json.Unmarshal(raw, out); err != nil { |
diff --git a/internal/console/keymail_test.go b/internal/console/keymail_test.go| index 444d2d4cd..afcaae90b 100644 |
| --- a/internal/console/keymail_test.go |
| +++ b/internal/console/keymail_test.go |
| @@ -54,6 +54,23 @@ type fakeKeymail struct { |
| 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 |
| } |
| // sentBodies is every message body the fake has accepted — how a test proves |
| @@ -76,13 +93,22 @@ func newFakeKeymail(t *testing.T, address string) *fakeKeymail { |
| } |
| // 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} |
| + f := &fakeKeymail{ |
| + t: t, address: address, boxPriv: boxPriv, reauth: true, |
| + powBits: 8, |
| + noMailbox: map[string]bool{}, |
| + blocked: map[string]bool{}, |
| + 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 |
| @@ -175,18 +201,176 @@ func (f *fakeKeymail) handleRevoke(w http.ResponseWriter, r *http.Request) { |
| 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) { |
| f.mu.Lock() |
| defer f.mu.Unlock() |
| + addr := r.URL.Query().Get("addr") |
| + 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": r.URL.Query().Get("addr"), |
| + "addr": addr, |
| "sign_pub": "", |
| "box_pub": base64.StdEncoding.EncodeToString(f.boxPriv.PublicKey().Bytes()), |
| - "pow_bits": 8, |
| + "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.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() |
diff --git a/internal/console/keymailfed.go b/internal/console/keymailfed.go| new file mode 100644 |
| index 000000000..338b32497 |
| --- /dev/null |
| +++ b/internal/console/keymailfed.go |
| @@ -0,0 +1,156 @@ |
| +package console |
| + |
| +// Delivery to someone who never granted the console anything: Keymail's |
| +// federation protocol (keymail federation.go), the same one a Keymail |
| +// instance uses to write to another. An unknown sender pays a proof of work |
| +// to register its signing key with the recipient's mailbox, then delivers |
| +// signed, sealed envelopes into the main inbox. |
| +// |
| +// The app API (SendApp) cannot do this: a grant delivers only to the user |
| +// who approved it (keymail appapi.go handleAppSend), which is why invites |
| +// sent through it never arrived. |
| + |
| +import ( |
| + "context" |
| + "crypto/ecdh" |
| + "crypto/rand" |
| + "crypto/sha256" |
| + "encoding/base64" |
| + "errors" |
| + "fmt" |
| + "net/http" |
| + "net/url" |
| + "sync" |
| +) |
| + |
| +var ( |
| + // ErrNoKeymailMailbox: Keymail has no mailbox for the address (not a |
| + // Keymail host, unknown local part, or a mailbox not yet claimed). The |
| + // caller should try another channel. |
| + ErrNoKeymailMailbox = errors.New("keymail: no mailbox for that address") |
| + // ErrFederationPriceTooHigh: the mailbox asks more proof of work than |
| + // maxFederationPowBits. |
| + ErrFederationPriceTooHigh = errors.New("keymail: the recipient's proof-of-work price is above the console's cap") |
| +) |
| + |
| +// attestContext is keymail crypto.go's attestation domain separator. |
| +const attestContext = "keymail-attest-v1" |
| + |
| +func attestationMsg(addr, signPub, boxPub string) []byte { |
| + return []byte(attestContext + "\n" + addr + "\n" + signPub + "\n" + boxPub) |
| +} |
| + |
| +// replyBox is the box key registration records for replies. Nobody replies |
| +// to an invite and the console serves no Keymail endpoints, so the key is |
| +// per process and its private half is dropped. |
| +var ( |
| + replyBoxOnce sync.Once |
| + replyBoxPub string |
| + replyBoxErr error |
| +) |
| + |
| +func federationReplyBox() (string, error) { |
| + replyBoxOnce.Do(func() { |
| + k, err := ecdh.P256().GenerateKey(rand.Reader) |
| + if err != nil { |
| + replyBoxErr = err |
| + return |
| + } |
| + replyBoxPub = base64.StdEncoding.EncodeToString(k.PublicKey().Bytes()) |
| + }) |
| + return replyBoxPub, replyBoxErr |
| +} |
| + |
| +// SendFederated delivers one sealed message to any Keymail mailbox as |
| +// console@<origin host>. It registers the console's key with the mailbox |
| +// first when Keymail asks for it. |
| +func (k *Keymail) SendFederated(ctx context.Context, to, subject, body string) (string, error) { |
| + if k.signKey == nil { |
| + return "", errors.New("keymail: SendFederated requires a signing key") |
| + } |
| + from := "console@" + OriginHost(k.origin) |
| + var lr struct { |
| + BoxPub string `json:"box_pub"` |
| + PowBits int `json:"pow_bits"` |
| + } |
| + 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("%w: %v", ErrNoKeymailMailbox, err) |
| + } |
| + if lr.PowBits > maxFederationPowBits { |
| + return "", ErrFederationPriceTooHigh |
| + } |
| + if lr.PowBits > 0 { |
| + if err := k.registerSender(ctx, to, from); err != nil { |
| + return "", err |
| + } |
| + } |
| + env, err := k.sealEnvelope(from, to, lr.BoxPub, subject, body) |
| + if err != nil { |
| + return "", err |
| + } |
| + err = k.deliver(ctx, env) |
| + if needsRegistration(err) { |
| + if rerr := k.registerSender(ctx, to, from); rerr != nil { |
| + return "", rerr |
| + } |
| + err = k.deliver(ctx, env) |
| + } |
| + if err != nil { |
| + return "", err |
| + } |
| + return env.ID, nil |
| +} |
| + |
| +func (k *Keymail) deliver(ctx context.Context, env envelope) error { |
| + _, err := k.do(ctx, http.MethodPost, "/api/federation/deliver", "", map[string]any{"envelope": env}, nil) |
| + return err |
| +} |
| + |
| +// needsRegistration: the mailbox does not know this sender, or knows it |
| +// under a different key (the console's key was rotated). Both are cured by |
| +// registering, which upserts the key. |
| +func needsRegistration(err error) bool { |
| + var ke *keymailError |
| + if !errors.As(err, &ke) || ke.Status != http.StatusForbidden { |
| + return false |
| + } |
| + return ke.Msg == "unknown_sender" || ke.Msg == "signing key does not match registered key" |
| +} |
| + |
| +func (k *Keymail) registerSender(ctx context.Context, to, from string) error { |
| + signPub := k.SignPub() |
| + var ch struct { |
| + Challenge string `json:"challenge"` |
| + Bits int `json:"bits"` |
| + } |
| + if _, err := k.do(ctx, http.MethodPost, "/api/federation/challenge", "", |
| + map[string]string{"to": to, "from": from, "sign_pub": signPub}, &ch); err != nil { |
| + return err |
| + } |
| + if ch.Bits > maxFederationPowBits { |
| + return ErrFederationPriceTooHigh |
| + } |
| + signPubRaw, err := base64.StdEncoding.DecodeString(signPub) |
| + if err != nil { |
| + return err |
| + } |
| + nonce := sha256.Sum256([]byte(ch.Challenge)) |
| + counter, err := powMine(ctx, nonce[:], signPubRaw, ch.Bits) |
| + if err != nil { |
| + return fmt.Errorf("keymail: proof of work: %w", err) |
| + } |
| + boxPub, err := federationReplyBox() |
| + if err != nil { |
| + return err |
| + } |
| + att, err := signRaw(k.signKey, attestationMsg(from, signPub, boxPub)) |
| + if err != nil { |
| + return err |
| + } |
| + _, err = k.do(ctx, http.MethodPost, "/api/federation/register", "", map[string]any{ |
| + "to": to, "challenge": ch.Challenge, "counter": counter, "from": from, |
| + "sign_pub": signPub, "box_pub": boxPub, "attestation": att, |
| + }, nil) |
| + return err |
| +} |
diff --git a/internal/console/keymailfed_test.go b/internal/console/keymailfed_test.go| new file mode 100644 |
| index 000000000..7a919b4a6 |
| --- /dev/null |
| +++ b/internal/console/keymailfed_test.go |
| @@ -0,0 +1,110 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "crypto/ecdsa" |
| + "crypto/elliptic" |
| + "crypto/rand" |
| + "errors" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +func fedClient(t *testing.T, fk *fakeKeymail) *Keymail { |
| + t.Helper() |
| + k := NewKeymail(fk.srv.URL, "https://console.example.test") |
| + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + k.SetSigningKey(key) |
| + return k |
| +} |
| + |
| +func TestSendFederatedUnknownSenderRegistersThenDelivers(t *testing.T) { |
| + fk := newFakeKeymail(t, "paul@keymail.dev") |
| + k := fedClient(t, fk) |
| + id, err := k.SendFederated(context.Background(), "bill@keymail.dev", "subj", "the link") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if fk.registrations != 1 { |
| + t.Fatalf("registrations = %d, want 1", fk.registrations) |
| + } |
| + if len(fk.fedSends) != 1 || fk.fedSends[0].ID != id || fk.fedSends[0].Body != "the link" || |
| + fk.fedSends[0].From != "console@console.example.test" || fk.fedSends[0].To != "bill@keymail.dev" { |
| + t.Fatalf("fedSends = %+v", fk.fedSends) |
| + } |
| +} |
| + |
| +func TestSendFederatedKnownSenderPaysNothing(t *testing.T) { |
| + fk := newFakeKeymail(t, "paul@keymail.dev") |
| + k := fedClient(t, fk) |
| + ctx := context.Background() |
| + if _, err := k.SendFederated(ctx, "bill@keymail.dev", "s", "one"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := k.SendFederated(ctx, "bill@keymail.dev", "s", "two"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if fk.registrations != 1 { |
| + t.Fatalf("registrations = %d after two sends, want 1", fk.registrations) |
| + } |
| +} |
| + |
| +// Review Focus 1: the console's key changed since it registered. |
| +func TestSendFederatedReRegistersARotatedKey(t *testing.T) { |
| + fk := newFakeKeymail(t, "paul@keymail.dev") |
| + fk.senders["console@console.example.test"] = "an-old-key" |
| + k := fedClient(t, fk) |
| + if _, err := k.SendFederated(context.Background(), "bill@keymail.dev", "s", "b"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if fk.registrations != 1 || len(fk.fedSends) != 1 { |
| + t.Fatalf("registrations=%d fedSends=%d, want 1 and 1", fk.registrations, len(fk.fedSends)) |
| + } |
| +} |
| + |
| +func TestSendFederatedRefusesAPriceAboveTheCap(t *testing.T) { |
| + fk := newFakeKeymail(t, "paul@keymail.dev") |
| + fk.powBits = maxFederationPowBits + 1 |
| + k := fedClient(t, fk) |
| + _, err := k.SendFederated(context.Background(), "bill@keymail.dev", "s", "b") |
| + if !errors.Is(err, ErrFederationPriceTooHigh) { |
| + t.Fatalf("err = %v, want ErrFederationPriceTooHigh", err) |
| + } |
| + if fk.registrations != 0 { |
| + t.Fatal("registered despite refusing the price") |
| + } |
| +} |
| + |
| +// Review Focus 2 and the non-Keymail host. |
| +func TestSendFederatedNoMailbox(t *testing.T) { |
| + fk := newFakeKeymail(t, "paul@keymail.dev") |
| + fk.noMailbox["ghost"] = true |
| + k := fedClient(t, fk) |
| + for _, to := range []string{"ghost@keymail.dev", "bill@gmail.com"} { |
| + _, err := k.SendFederated(context.Background(), to, "s", "b") |
| + if !errors.Is(err, ErrNoKeymailMailbox) { |
| + t.Errorf("SendFederated(%s) = %v, want ErrNoKeymailMailbox", to, err) |
| + } |
| + } |
| +} |
| + |
| +func TestSendFederatedBlocked(t *testing.T) { |
| + fk := newFakeKeymail(t, "paul@keymail.dev") |
| + fk.blocked["console@console.example.test"] = true |
| + k := fedClient(t, fk) |
| + _, err := k.SendFederated(context.Background(), "bill@keymail.dev", "s", "b") |
| + if err == nil || !strings.Contains(err.Error(), "blocked") { |
| + t.Fatalf("err = %v, want a blocked error", err) |
| + } |
| +} |
| + |
| +func TestSendFederatedWithoutKey(t *testing.T) { |
| + fk := newFakeKeymail(t, "paul@keymail.dev") |
| + k := NewKeymail(fk.srv.URL, "https://console.example.test") |
| + if _, err := k.SendFederated(context.Background(), "bill@keymail.dev", "s", "b"); err == nil { |
| + t.Fatal("SendFederated without a signing key succeeded") |
| + } |
| +} |