Federated via graft
10 kB
302 lines
1package admin
2
3import (
4 "crypto/aes"
5 "crypto/cipher"
6 "crypto/hmac"
7 "crypto/rand"
8 "crypto/sha256"
9 "encoding/base64"
10 "encoding/hex"
11 "fmt"
12 "html/template"
13 "math/big"
14 "net/http"
15 "strings"
16 "time"
17
18 qrcode "github.com/skip2/go-qrcode"
19)
20
21const (
22 shareTTL = 15 * time.Minute
23 maxShareAttempts = 5
24 maxSecretBytes = 8000
25)
26
27// newShareID returns 16 random bytes, base64url-encoded (no padding) —
28// the only copy of it that ever exists in reversible form is the one
29// handed to whoever creates the share; the database only ever sees its
30// hash (see hashShareID).
31func newShareID() (string, error) {
32 b := make([]byte, 16)
33 if _, err := rand.Read(b); err != nil {
34 return "", err
35 }
36 return base64.RawURLEncoding.EncodeToString(b), nil
37}
38
39func hashShareID(id string) string {
40 sum := sha256.Sum256([]byte(id))
41 return hex.EncodeToString(sum[:])
42}
43
44// newPasscode returns a uniformly random 6-digit code, zero-padded.
45func newPasscode() (string, error) {
46 n, err := rand.Int(rand.Reader, big.NewInt(1_000_000))
47 if err != nil {
48 return "", err
49 }
50 return fmt.Sprintf("%06d", n.Int64()), nil
51}
52
53// shareKey derives the AES-256 key for one share from its id and
54// passcode together — never from either alone. A database dump gives an
55// attacker id_hash and ciphertext but never the plaintext id, so it's
56// insufficient by itself; an attacker who only has the link (the
57// plaintext id) still needs the passcode, which is never stored at all —
58// GCM's authentication tag is what tells a wrong-passcode attempt apart
59// from a right one, not a stored comparison value.
60func shareKey(id, passcode string) []byte {
61 mac := hmac.New(sha256.New, []byte(id))
62 mac.Write([]byte("graft-share-v1|" + passcode))
63 return mac.Sum(nil)
64}
65
66func encryptShare(id, passcode, plaintext string) (nonceB64, ciphertextB64 string, err error) {
67 block, err := aes.NewCipher(shareKey(id, passcode))
68 if err != nil {
69 return "", "", err
70 }
71 gcm, err := cipher.NewGCM(block)
72 if err != nil {
73 return "", "", err
74 }
75 nonce := make([]byte, gcm.NonceSize())
76 if _, err := rand.Read(nonce); err != nil {
77 return "", "", err
78 }
79 ct := gcm.Seal(nil, nonce, []byte(plaintext), nil)
80 return base64.StdEncoding.EncodeToString(nonce), base64.StdEncoding.EncodeToString(ct), nil
81}
82
83func decryptShare(id, passcode, nonceB64, ciphertextB64 string) (string, error) {
84 block, err := aes.NewCipher(shareKey(id, passcode))
85 if err != nil {
86 return "", err
87 }
88 gcm, err := cipher.NewGCM(block)
89 if err != nil {
90 return "", err
91 }
92 nonce, err := base64.StdEncoding.DecodeString(nonceB64)
93 if err != nil {
94 return "", err
95 }
96 ct, err := base64.StdEncoding.DecodeString(ciphertextB64)
97 if err != nil {
98 return "", err
99 }
100 pt, err := gcm.Open(nil, nonce, ct, nil) // fails (wrong passcode) is the expected common case, not a real error
101 if err != nil {
102 return "", errWrongPasscode
103 }
104 return string(pt), nil
105}
106
107var errWrongPasscode = fmt.Errorf("wrong passcode")
108
109func (h *Handler) shareBaseURL(r *http.Request) string {
110 if h.cfg.PublicHost != "" {
111 return "https://" + h.cfg.PublicHost
112 }
113 return "https://" + r.Host
114}
115
116var shareNewTmpl = template.Must(template.New("share-new").Parse(`
117<h1>Share a secret, once</h1>
118<p class="lead">Encrypts the text below. Returns a one-time link and a separate 6-digit passcode. Send them over two different channels. Destroyed on first successful claim, after 5 wrong passcodes, or after 15 minutes.</p>
119{{if .Error}}<div class="notice bad">{{.Error}}</div>{{end}}
120<div class="card">
121 <form method="POST" action="/share/new">
122 <label for="secret">Secret</label>
123 <textarea id="secret" name="secret" required maxlength="8000" placeholder="the token, pasted as-is"></textarea>
124 <div class="hint">Stored encrypted. The passcode required to decrypt it is never stored.</div>
125 <button type="submit">Create one-time link</button>
126 </form>
127</div>
128`))
129
130var shareCreatedTmpl = template.Must(template.New("share-created").Parse(`
131<h1>Share created</h1>
132<div class="notice good">Link and passcode shown together only this once. Send them over two separate channels.</div>
133<div class="card">
134 <label>One-time link</label>
135 <div class="secret-box"><a href="{{.URL}}">{{.URL}}</a></div>
136 <label>Passcode</label>
137 <div class="passcode">{{.Passcode}}</div>
138 <label>QR code (link only — passcode still entered by hand)</label>
139 <div style="text-align:center;margin-top:.5rem;"><img src="/share/{{.ID}}/qr.png" width="200" height="200" alt="QR code for the one-time link"></div>
140 <div class="hint">Expires in 15 minutes if unclaimed. One claim only.</div>
141</div>
142`))
143
144var shareClaimTmpl = template.Must(template.New("share-claim").Parse(`
145<h1>Claim a shared secret</h1>
146<p class="lead">Enter the 6-digit passcode. Limited attempts before the secret is destroyed.</p>
147{{if .Error}}<div class="notice bad">{{.Error}}</div>{{end}}
148<div class="card">
149 <form method="POST" action="/share/{{.ID}}">
150 <label for="passcode">Passcode</label>
151 <input type="text" id="passcode" name="passcode" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" required autofocus>
152 <button type="submit">Reveal</button>
153 </form>
154</div>
155`))
156
157var shareRevealedTmpl = template.Must(template.New("share-revealed").Parse(`
158<h1>Secret</h1>
159<div class="notice good">Shown once. Copy it now.</div>
160<div class="card">
161 <div class="secret-box">{{.Secret}}</div>
162</div>
163`))
164
165func (h *Handler) shareNew(w http.ResponseWriter, r *http.Request) {
166 switch r.Method {
167 case http.MethodGet:
168 render(w, "share a secret", "ONE-TIME SHARE", renderFragment(shareNewTmpl, map[string]string{}))
169 case http.MethodPost:
170 if err := r.ParseForm(); err != nil {
171 render(w, "share a secret", "ONE-TIME SHARE", renderFragment(shareNewTmpl, map[string]string{"Error": "couldn't read the form"}))
172 return
173 }
174 secret := strings.TrimSpace(r.FormValue("secret"))
175 if secret == "" {
176 render(w, "share a secret", "ONE-TIME SHARE", renderFragment(shareNewTmpl, map[string]string{"Error": "the secret can't be empty"}))
177 return
178 }
179 if len(secret) > maxSecretBytes {
180 render(w, "share a secret", "ONE-TIME SHARE", renderFragment(shareNewTmpl, map[string]string{"Error": "that's too long for a one-time share"}))
181 return
182 }
183 id, err := newShareID()
184 if err != nil {
185 http.Error(w, "internal error", http.StatusInternalServerError)
186 return
187 }
188 passcode, err := newPasscode()
189 if err != nil {
190 http.Error(w, "internal error", http.StatusInternalServerError)
191 return
192 }
193 nonce, ct, err := encryptShare(id, passcode, secret)
194 if err != nil {
195 http.Error(w, "internal error", http.StatusInternalServerError)
196 return
197 }
198 if err := h.cfg.Store.CreateTokenShare(hashShareID(id), nonce, ct, time.Now().Add(shareTTL)); err != nil {
199 http.Error(w, "internal error", http.StatusInternalServerError)
200 return
201 }
202 render(w, "share created", "ONE-TIME SHARE", renderFragment(shareCreatedTmpl, map[string]string{
203 "URL": h.shareBaseURL(r) + "/share/" + id,
204 "Passcode": passcode,
205 "ID": id,
206 }))
207 default:
208 http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
209 }
210}
211
212func (h *Handler) shareClaim(w http.ResponseWriter, r *http.Request) {
213 path := strings.TrimPrefix(r.URL.Path, "/share/")
214 parts := strings.Split(path, "/")
215 id := parts[0]
216 if id == "" {
217 http.NotFound(w, r)
218 return
219 }
220 if len(parts) == 2 && parts[1] == "qr.png" {
221 h.serveShareQR(w, r, id)
222 return
223 }
224 if len(parts) != 1 {
225 http.NotFound(w, r)
226 return
227 }
228
229 switch r.Method {
230 case http.MethodGet:
231 render(w, "claim a secret", "ONE-TIME SHARE", renderFragment(shareClaimTmpl, map[string]string{"ID": id}))
232 case http.MethodPost:
233 h.claimShare(w, r, id)
234 default:
235 http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
236 }
237}
238
239func (h *Handler) serveShareQR(w http.ResponseWriter, r *http.Request, id string) {
240 // The QR encodes only the claim link, never the passcode — scanning
241 // it is a convenience for reaching the page in person or on a call,
242 // not a second channel in itself; the passcode is still meant to be
243 // told separately.
244 png, err := qrcode.Encode(h.shareBaseURL(r)+"/share/"+id, qrcode.Medium, 256)
245 if err != nil {
246 http.Error(w, "could not generate QR code", http.StatusInternalServerError)
247 return
248 }
249 w.Header().Set("Content-Type", "image/png")
250 w.Header().Set("Cache-Control", "no-store")
251 _, _ = w.Write(png)
252}
253
254func (h *Handler) claimShare(w http.ResponseWriter, r *http.Request, id string) {
255 ip := clientIP(r)
256 if !h.shareLimiter.allowed(ip) {
257 render(w, "claim a secret", "ONE-TIME SHARE", renderFragment(shareClaimTmpl, map[string]string{
258 "ID": id, "Error": "too many attempts from your address — try again later",
259 }))
260 return
261 }
262 if err := r.ParseForm(); err != nil {
263 render(w, "claim a secret", "ONE-TIME SHARE", renderFragment(shareClaimTmpl, map[string]string{"ID": id, "Error": "couldn't read the form"}))
264 return
265 }
266 passcode := strings.TrimSpace(r.FormValue("passcode"))
267
268 idHash := hashShareID(id)
269 share, err := h.cfg.Store.GetTokenShare(idHash)
270 if err != nil {
271 http.Error(w, "internal error", http.StatusInternalServerError)
272 return
273 }
274 if share == nil {
275 render(w, "claim a secret", "ONE-TIME SHARE", renderFragment(shareClaimTmpl, map[string]string{
276 "ID": id, "Error": "this link is invalid, already claimed, or expired",
277 }))
278 return
279 }
280
281 secret, err := decryptShare(id, passcode, share.Nonce, share.Ciphertext)
282 if err != nil {
283 h.shareLimiter.recordFailure(ip)
284 attempts, aerr := h.cfg.Store.IncrementTokenShareAttempts(idHash)
285 if aerr == nil && attempts >= maxShareAttempts {
286 _ = h.cfg.Store.DeleteTokenShare(idHash)
287 render(w, "claim a secret", "ONE-TIME SHARE", renderFragment(shareClaimTmpl, map[string]string{
288 "ID": id, "Error": "too many wrong passcodes — this secret has been destroyed",
289 }))
290 return
291 }
292 render(w, "claim a secret", "ONE-TIME SHARE", renderFragment(shareClaimTmpl, map[string]string{
293 "ID": id, "Error": "wrong passcode",
294 }))
295 return
296 }
297
298 // One-time by design: destroy it the moment it's successfully read,
299 // before rendering the response that reveals it.
300 _ = h.cfg.Store.DeleteTokenShare(idHash)
301 render(w, "secret", "ONE-TIME SHARE", renderFragment(shareRevealedTmpl, map[string]string{"Secret": secret}))
302}