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" "time" ) 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") // ErrKeymailUnreachable: the lookup neither found nor ruled out a mailbox // (timeout, network error, 5xx). Unlike ErrNoKeymailMailbox it is not a // "no": a caller must not treat the address as non-Keymail on this alone. ErrKeymailUnreachable = errors.New("keymail: lookup unreachable") // 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 } // lookupFailure classifies a federation lookup error. Only Keymail's definite // answers (400 "could not determine target mailbox", 404 "no such mailbox", // 409 "inbox not yet claimed") mean there is no mailbox; anything else // (timeout, network error, 5xx, a bad body) means we do not know. The cause // stays reachable through errors.As. func lookupFailure(err error) error { var ke *keymailError if errors.As(err, &ke) { switch ke.Status { case http.StatusBadRequest, http.StatusNotFound, http.StatusConflict: return fmt.Errorf("%w: %w", ErrNoKeymailMailbox, err) } } return fmt.Errorf("%w: %w", ErrKeymailUnreachable, err) } // hasMailboxTimeout bounds one discovery lookup (spec 4.1), inside whatever // the caller's context allows. const hasMailboxTimeout = 5 * time.Second // HasMailbox asks Keymail's public federation lookup whether addr is a // Keymail mailbox: (true, nil) on 200, (false, nil) on the definite // 400/404/409 answers, and (false, an error wrapping ErrKeymailUnreachable) // when Keymail could not say. func (k *Keymail) HasMailbox(ctx context.Context, addr string) (bool, error) { ctx, cancel := context.WithTimeout(ctx, hasMailboxTimeout) defer cancel() var lr struct { BoxPub string `json:"box_pub"` } _, err := k.do(ctx, http.MethodGet, "/api/federation/lookup?addr="+url.QueryEscape(addr), "", nil, &lr) if err == nil { return true, nil } if lerr := lookupFailure(err); errors.Is(lerr, ErrNoKeymailMailbox) { return false, nil } else { return false, lerr } } // SendFederated delivers one sealed message to any Keymail mailbox as // console@. 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 "", lookupFailure(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 } // IsKeymailBlocked reports whether err is the recipient's mailbox refusing // the console because its owner blocked it (403 "blocked", at challenge or // deliver). That is the recipient's answer, not a delivery failure: no other // channel should go round it. func IsKeymailBlocked(err error) bool { var ke *keymailError return errors.As(err, &ke) && ke.Status == http.StatusForbidden && ke.Msg == "blocked" } 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" } // powSlot lets one proof of work run at a time, process-wide. Each one is // up to maxFederationPowBits of hashing on a core, and invites can arrive // together. var powSlot = make(chan struct{}, 1) // acquirePowSlot waits for powSlot, or gives up when ctx ends so a waiting // invite falls through to the next channel at its deadline. func acquirePowSlot(ctx context.Context) (release func(), err error) { select { case powSlot <- struct{}{}: return func() { <-powSlot }, nil case <-ctx.Done(): return nil, ctx.Err() } } 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)) releasePow, err := acquirePowSlot(ctx) if err != nil { return fmt.Errorf("keymail: proof of work: %w", err) } counter, err := powMine(ctx, nonce[:], signPubRaw, ch.Bits) releasePow() 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 }