| 1 | package console |
| 2 | |
| 3 | // Delivery to someone who never granted the console anything: Keymail's |
| 4 | // federation protocol (keymail federation.go), the same one a Keymail |
| 5 | // instance uses to write to another. An unknown sender pays a proof of work |
| 6 | // to register its signing key with the recipient's mailbox, then delivers |
| 7 | // signed, sealed envelopes into the main inbox. |
| 8 | // |
| 9 | // The app API (SendApp) cannot do this: a grant delivers only to the user |
| 10 | // who approved it (keymail appapi.go handleAppSend), which is why invites |
| 11 | // sent through it never arrived. |
| 12 | |
| 13 | import ( |
| 14 | "context" |
| 15 | "crypto/ecdh" |
| 16 | "crypto/rand" |
| 17 | "crypto/sha256" |
| 18 | "encoding/base64" |
| 19 | "errors" |
| 20 | "fmt" |
| 21 | "net/http" |
| 22 | "net/url" |
| 23 | "sync" |
| 24 | "time" |
| 25 | ) |
| 26 | |
| 27 | var ( |
| 28 | // ErrNoKeymailMailbox: Keymail has no mailbox for the address (not a |
| 29 | // Keymail host, unknown local part, or a mailbox not yet claimed). The |
| 30 | // caller should try another channel. |
| 31 | ErrNoKeymailMailbox = errors.New("keymail: no mailbox for that address") |
| 32 | // ErrKeymailUnreachable: the lookup neither found nor ruled out a mailbox |
| 33 | // (timeout, network error, 5xx). Unlike ErrNoKeymailMailbox it is not a |
| 34 | // "no": a caller must not treat the address as non-Keymail on this alone. |
| 35 | ErrKeymailUnreachable = errors.New("keymail: lookup unreachable") |
| 36 | // ErrFederationPriceTooHigh: the mailbox asks more proof of work than |
| 37 | // maxFederationPowBits. |
| 38 | ErrFederationPriceTooHigh = errors.New("keymail: the recipient's proof-of-work price is above the console's cap") |
| 39 | ) |
| 40 | |
| 41 | // attestContext is keymail crypto.go's attestation domain separator. |
| 42 | const attestContext = "keymail-attest-v1" |
| 43 | |
| 44 | func attestationMsg(addr, signPub, boxPub string) []byte { |
| 45 | return []byte(attestContext + "\n" + addr + "\n" + signPub + "\n" + boxPub) |
| 46 | } |
| 47 | |
| 48 | // replyBox is the box key registration records for replies. Nobody replies |
| 49 | // to an invite and the console serves no Keymail endpoints, so the key is |
| 50 | // per process and its private half is dropped. |
| 51 | var ( |
| 52 | replyBoxOnce sync.Once |
| 53 | replyBoxPub string |
| 54 | replyBoxErr error |
| 55 | ) |
| 56 | |
| 57 | func federationReplyBox() (string, error) { |
| 58 | replyBoxOnce.Do(func() { |
| 59 | k, err := ecdh.P256().GenerateKey(rand.Reader) |
| 60 | if err != nil { |
| 61 | replyBoxErr = err |
| 62 | return |
| 63 | } |
| 64 | replyBoxPub = base64.StdEncoding.EncodeToString(k.PublicKey().Bytes()) |
| 65 | }) |
| 66 | return replyBoxPub, replyBoxErr |
| 67 | } |
| 68 | |
| 69 | // lookupFailure classifies a federation lookup error. Only Keymail's definite |
| 70 | // answers (400 "could not determine target mailbox", 404 "no such mailbox", |
| 71 | // 409 "inbox not yet claimed") mean there is no mailbox; anything else |
| 72 | // (timeout, network error, 5xx, a bad body) means we do not know. The cause |
| 73 | // stays reachable through errors.As. |
| 74 | func lookupFailure(err error) error { |
| 75 | var ke *keymailError |
| 76 | if errors.As(err, &ke) { |
| 77 | switch ke.Status { |
| 78 | case http.StatusBadRequest, http.StatusNotFound, http.StatusConflict: |
| 79 | return fmt.Errorf("%w: %w", ErrNoKeymailMailbox, err) |
| 80 | } |
| 81 | } |
| 82 | return fmt.Errorf("%w: %w", ErrKeymailUnreachable, err) |
| 83 | } |
| 84 | |
| 85 | // hasMailboxTimeout bounds one discovery lookup (spec 4.1), inside whatever |
| 86 | // the caller's context allows. |
| 87 | const hasMailboxTimeout = 5 * time.Second |
| 88 | |
| 89 | // HasMailbox asks Keymail's public federation lookup whether addr is a |
| 90 | // Keymail mailbox: (true, nil) on 200, (false, nil) on the definite |
| 91 | // 400/404/409 answers, and (false, an error wrapping ErrKeymailUnreachable) |
| 92 | // when Keymail could not say. |
| 93 | func (k *Keymail) HasMailbox(ctx context.Context, addr string) (bool, error) { |
| 94 | ctx, cancel := context.WithTimeout(ctx, hasMailboxTimeout) |
| 95 | defer cancel() |
| 96 | var lr struct { |
| 97 | BoxPub string `json:"box_pub"` |
| 98 | } |
| 99 | _, err := k.do(ctx, http.MethodGet, "/api/federation/lookup?addr="+url.QueryEscape(addr), "", nil, &lr) |
| 100 | if err == nil { |
| 101 | return true, nil |
| 102 | } |
| 103 | if lerr := lookupFailure(err); errors.Is(lerr, ErrNoKeymailMailbox) { |
| 104 | return false, nil |
| 105 | } else { |
| 106 | return false, lerr |
| 107 | } |
| 108 | } |
| 109 | |
| 110 | // SendFederated delivers one sealed message to any Keymail mailbox as |
| 111 | // console@<origin host>. It registers the console's key with the mailbox |
| 112 | // first when Keymail asks for it. |
| 113 | func (k *Keymail) SendFederated(ctx context.Context, to, subject, body string) (string, error) { |
| 114 | if k.signKey == nil { |
| 115 | return "", errors.New("keymail: SendFederated requires a signing key") |
| 116 | } |
| 117 | from := "console@" + OriginHost(k.origin) |
| 118 | var lr struct { |
| 119 | BoxPub string `json:"box_pub"` |
| 120 | PowBits int `json:"pow_bits"` |
| 121 | } |
| 122 | lu := "/api/federation/lookup?addr=" + url.QueryEscape(to) + "&from=" + url.QueryEscape(from) |
| 123 | if _, err := k.do(ctx, http.MethodGet, lu, "", nil, &lr); err != nil { |
| 124 | return "", lookupFailure(err) |
| 125 | } |
| 126 | if lr.PowBits > maxFederationPowBits { |
| 127 | return "", ErrFederationPriceTooHigh |
| 128 | } |
| 129 | if lr.PowBits > 0 { |
| 130 | if err := k.registerSender(ctx, to, from); err != nil { |
| 131 | return "", err |
| 132 | } |
| 133 | } |
| 134 | env, err := k.sealEnvelope(from, to, lr.BoxPub, subject, body) |
| 135 | if err != nil { |
| 136 | return "", err |
| 137 | } |
| 138 | err = k.deliver(ctx, env) |
| 139 | if needsRegistration(err) { |
| 140 | if rerr := k.registerSender(ctx, to, from); rerr != nil { |
| 141 | return "", rerr |
| 142 | } |
| 143 | err = k.deliver(ctx, env) |
| 144 | } |
| 145 | if err != nil { |
| 146 | return "", err |
| 147 | } |
| 148 | return env.ID, nil |
| 149 | } |
| 150 | |
| 151 | // IsKeymailBlocked reports whether err is the recipient's mailbox refusing |
| 152 | // the console because its owner blocked it (403 "blocked", at challenge or |
| 153 | // deliver). That is the recipient's answer, not a delivery failure: no other |
| 154 | // channel should go round it. |
| 155 | func IsKeymailBlocked(err error) bool { |
| 156 | var ke *keymailError |
| 157 | return errors.As(err, &ke) && ke.Status == http.StatusForbidden && ke.Msg == "blocked" |
| 158 | } |
| 159 | |
| 160 | func (k *Keymail) deliver(ctx context.Context, env envelope) error { |
| 161 | _, err := k.do(ctx, http.MethodPost, "/api/federation/deliver", "", map[string]any{"envelope": env}, nil) |
| 162 | return err |
| 163 | } |
| 164 | |
| 165 | // needsRegistration: the mailbox does not know this sender, or knows it |
| 166 | // under a different key (the console's key was rotated). Both are cured by |
| 167 | // registering, which upserts the key. |
| 168 | func needsRegistration(err error) bool { |
| 169 | var ke *keymailError |
| 170 | if !errors.As(err, &ke) || ke.Status != http.StatusForbidden { |
| 171 | return false |
| 172 | } |
| 173 | return ke.Msg == "unknown_sender" || ke.Msg == "signing key does not match registered key" |
| 174 | } |
| 175 | |
| 176 | // powSlot lets one proof of work run at a time, process-wide. Each one is |
| 177 | // up to maxFederationPowBits of hashing on a core, and invites can arrive |
| 178 | // together. |
| 179 | var powSlot = make(chan struct{}, 1) |
| 180 | |
| 181 | // acquirePowSlot waits for powSlot, or gives up when ctx ends so a waiting |
| 182 | // invite falls through to the next channel at its deadline. |
| 183 | func acquirePowSlot(ctx context.Context) (release func(), err error) { |
| 184 | select { |
| 185 | case powSlot <- struct{}{}: |
| 186 | return func() { <-powSlot }, nil |
| 187 | case <-ctx.Done(): |
| 188 | return nil, ctx.Err() |
| 189 | } |
| 190 | } |
| 191 | |
| 192 | func (k *Keymail) registerSender(ctx context.Context, to, from string) error { |
| 193 | signPub := k.SignPub() |
| 194 | var ch struct { |
| 195 | Challenge string `json:"challenge"` |
| 196 | Bits int `json:"bits"` |
| 197 | } |
| 198 | if _, err := k.do(ctx, http.MethodPost, "/api/federation/challenge", "", |
| 199 | map[string]string{"to": to, "from": from, "sign_pub": signPub}, &ch); err != nil { |
| 200 | return err |
| 201 | } |
| 202 | if ch.Bits > maxFederationPowBits { |
| 203 | return ErrFederationPriceTooHigh |
| 204 | } |
| 205 | signPubRaw, err := base64.StdEncoding.DecodeString(signPub) |
| 206 | if err != nil { |
| 207 | return err |
| 208 | } |
| 209 | nonce := sha256.Sum256([]byte(ch.Challenge)) |
| 210 | releasePow, err := acquirePowSlot(ctx) |
| 211 | if err != nil { |
| 212 | return fmt.Errorf("keymail: proof of work: %w", err) |
| 213 | } |
| 214 | counter, err := powMine(ctx, nonce[:], signPubRaw, ch.Bits) |
| 215 | releasePow() |
| 216 | if err != nil { |
| 217 | return fmt.Errorf("keymail: proof of work: %w", err) |
| 218 | } |
| 219 | boxPub, err := federationReplyBox() |
| 220 | if err != nil { |
| 221 | return err |
| 222 | } |
| 223 | att, err := signRaw(k.signKey, attestationMsg(from, signPub, boxPub)) |
| 224 | if err != nil { |
| 225 | return err |
| 226 | } |
| 227 | _, err = k.do(ctx, http.MethodPost, "/api/federation/register", "", map[string]any{ |
| 228 | "to": to, "challenge": ch.Challenge, "counter": counter, "from": from, |
| 229 | "sign_pub": signPub, "box_pub": boxPub, "attestation": att, |
| 230 | }, nil) |
| 231 | return err |
| 232 | } |
| 233 | |