Federated via graft
0

Configure Feed

Select the types of activity you want to include in your feed.

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">Paste a token or anything else sensitive below. You'll get back a one-time link and a separate 6-digit passcode — hand the link and the passcode to the recipient over two <em>different</em> channels (a link over chat, the passcode read aloud on a call, say), so intercepting one alone is useless. The secret is encrypted, shown to the recipient exactly once, and then destroyed — including if they enter the wrong passcode five times.</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">Never stored in reversible form without a passcode only the recipient will have.</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">This is the only time both the link and passcode are shown together. Send them to the recipient now, 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 (opens the link — the recipient still enters the passcode 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 never claimed. Claimed exactly once — a second visit shows nothing.</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 you were given separately from this link. It can only be tried a handful of times 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. It's already deleted — copy it now, this page won't show it again on refresh.</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}