carlos / platform Public

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>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 94dc1eed314c353f7a9b82b9d3f570bdcbac997a parent b3dfbe3
4 files changed, +497 −24
  • internal/console/keymail.go +44 −21
  • internal/console/keymail_test.go +187 −3
  • internal/console/keymailfed.go +156 −0
  • internal/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")
+ }
+}