carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package 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
13import (
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
27var (
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.
42const attestContext = "keymail-attest-v1"
43
44func 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.
51var (
52 replyBoxOnce sync.Once
53 replyBoxPub string
54 replyBoxErr error
55)
56
57func 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.
74func 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.
87const 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.
93func (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.
113func (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.
155func IsKeymailBlocked(err error) bool {
156 var ke *keymailError
157 return errors.As(err, &ke) && ke.Status == http.StatusForbidden && ke.Msg == "blocked"
158}
159
160func (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.
168func 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.
179var 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.
183func 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
192func (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