| 1 | // Message permalinks (issue #893): "Copy link" mints an opaque token for a |
| 2 | // {thread_id, message_id} pair, sealed under a per-instance key that only this |
| 3 | // server holds — never a thread or instance key, and never sent to any client. |
| 4 | // A permalink exists to be pasted somewhere (another chat, an email, a synced |
| 5 | // clipboard), so the reference has to be opaque to whoever sees the raw link |
| 6 | // text, not just kept out of request logs. Sealing it under a key every thread |
| 7 | // member holds would do that too, but as a static, offline capability with no |
| 8 | // way to revoke it short of rotating the thread key. Sealing it under a key |
| 9 | // only the server holds means opening a link is always a fresh, authenticated |
| 10 | // round-trip that checks the caller's CURRENT thread membership — so leaving a |
| 11 | // thread (or the space) actually revokes the ability to jump via an old link. |
| 12 | // |
| 13 | // The key only ever protects identifiers this server already stores in |
| 14 | // plaintext (thread_id, message id) — never message content — so it's |
| 15 | // addressing/auth material in the same category as the admin token, not a |
| 16 | // decryption key for anything the E2EE rule protects. See the design |
| 17 | // discussion on #893 for the full comparison against thread-key/instance-key |
| 18 | // alternatives. |
| 19 | package main |
| 20 | |
| 21 | import ( |
| 22 | "crypto/aes" |
| 23 | "crypto/cipher" |
| 24 | "crypto/rand" |
| 25 | "encoding/base64" |
| 26 | "encoding/json" |
| 27 | "net/http" |
| 28 | "strconv" |
| 29 | "strings" |
| 30 | ) |
| 31 | |
| 32 | // ensureLinkKey returns this instance's message-link sealing key, generating |
| 33 | // and persisting a fresh 256-bit one (config key "link_key") on first use. |
| 34 | // Dedicated to this one purpose — not derived from or shared with any other |
| 35 | // secret — so rotating it (or losing it, e.g. a fresh DB) only ever invalidates |
| 36 | // outstanding permalinks, nothing else. |
| 37 | func (db *DB) ensureLinkKey() ([]byte, error) { |
| 38 | v, ok, err := db.getConfig("link_key") |
| 39 | if err != nil { |
| 40 | return nil, err |
| 41 | } |
| 42 | if ok { |
| 43 | return base64.StdEncoding.DecodeString(v) |
| 44 | } |
| 45 | key := make([]byte, 32) |
| 46 | if _, err := rand.Read(key); err != nil { |
| 47 | return nil, err |
| 48 | } |
| 49 | if err := db.setConfig("link_key", base64.StdEncoding.EncodeToString(key)); err != nil { |
| 50 | return nil, err |
| 51 | } |
| 52 | return key, nil |
| 53 | } |
| 54 | |
| 55 | // sealLinkToken encrypts "threadID:messageID" under key with a fresh random |
| 56 | // nonce (AES-256-GCM), same base64url(nonce||ciphertext) envelope used |
| 57 | // elsewhere in this codebase for server-sealed tokens (home/stateless.go). |
| 58 | func sealLinkToken(key []byte, threadID string, messageID int64) (string, error) { |
| 59 | block, err := aes.NewCipher(key) |
| 60 | if err != nil { |
| 61 | return "", err |
| 62 | } |
| 63 | gcm, err := cipher.NewGCM(block) |
| 64 | if err != nil { |
| 65 | return "", err |
| 66 | } |
| 67 | nonce := make([]byte, gcm.NonceSize()) |
| 68 | if _, err := rand.Read(nonce); err != nil { |
| 69 | return "", err |
| 70 | } |
| 71 | pt := []byte(threadID + ":" + strconv.FormatInt(messageID, 10)) |
| 72 | return base64.RawURLEncoding.EncodeToString(gcm.Seal(nonce, nonce, pt, nil)), nil |
| 73 | } |
| 74 | |
| 75 | // openLinkToken reverses sealLinkToken. A tampered, expired-format, or |
| 76 | // wrong-key token fails closed (GCM's tag, not the caller, decides "invalid"). |
| 77 | func openLinkToken(key []byte, token string) (threadID string, messageID int64, ok bool) { |
| 78 | raw, err := base64.RawURLEncoding.DecodeString(token) |
| 79 | if err != nil { |
| 80 | return "", 0, false |
| 81 | } |
| 82 | block, err := aes.NewCipher(key) |
| 83 | if err != nil { |
| 84 | return "", 0, false |
| 85 | } |
| 86 | gcm, err := cipher.NewGCM(block) |
| 87 | if err != nil { |
| 88 | return "", 0, false |
| 89 | } |
| 90 | if len(raw) < gcm.NonceSize() { |
| 91 | return "", 0, false |
| 92 | } |
| 93 | pt, err := gcm.Open(nil, raw[:gcm.NonceSize()], raw[gcm.NonceSize():], nil) |
| 94 | if err != nil { |
| 95 | return "", 0, false |
| 96 | } |
| 97 | threadID, idStr, found := strings.Cut(string(pt), ":") |
| 98 | if !found { |
| 99 | return "", 0, false |
| 100 | } |
| 101 | id, err := strconv.ParseInt(idStr, 10, 64) |
| 102 | if err != nil { |
| 103 | return "", 0, false |
| 104 | } |
| 105 | return threadID, id, true |
| 106 | } |
| 107 | |
| 108 | // mintMessageLink mints the opaque token for a "Copy link" action. The caller |
| 109 | // must already be a member of the thread the message lives in — same gate as |
| 110 | // reading or reacting to it. |
| 111 | func (s *Server) mintMessageLink(w http.ResponseWriter, r *http.Request) { |
| 112 | token := r.URL.Query().Get("token") |
| 113 | if !s.tokenOK(token) { |
| 114 | httpError(w, http.StatusForbidden, "invalid invite") |
| 115 | return |
| 116 | } |
| 117 | messageID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) |
| 118 | if err != nil || messageID <= 0 { |
| 119 | httpError(w, http.StatusBadRequest, "bad message id") |
| 120 | return |
| 121 | } |
| 122 | var body struct { |
| 123 | ThreadID string `json:"thread_id"` |
| 124 | } |
| 125 | if json.NewDecoder(r.Body).Decode(&body) != nil { |
| 126 | httpError(w, http.StatusBadRequest, "bad request") |
| 127 | return |
| 128 | } |
| 129 | userID, _ := s.db.deviceTokenUser(token) |
| 130 | if body.ThreadID == "" || !s.db.threadMember(body.ThreadID, userID) { |
| 131 | httpError(w, http.StatusForbidden, "not a thread member") |
| 132 | return |
| 133 | } |
| 134 | if !s.db.messageInThread(messageID, body.ThreadID) { |
| 135 | httpError(w, http.StatusNotFound, "message not found") |
| 136 | return |
| 137 | } |
| 138 | key, err := s.db.ensureLinkKey() |
| 139 | if err != nil { |
| 140 | httpError(w, http.StatusInternalServerError, "db error") |
| 141 | return |
| 142 | } |
| 143 | tok, err := sealLinkToken(key, body.ThreadID, messageID) |
| 144 | if err != nil { |
| 145 | httpError(w, http.StatusInternalServerError, "seal error") |
| 146 | return |
| 147 | } |
| 148 | writeJSON(w, http.StatusOK, map[string]string{"token": tok}) |
| 149 | } |
| 150 | |
| 151 | // resolveMessageLink turns an opened "Copy link" token back into |
| 152 | // {thread_id, message_id} — but only for a caller who is a member of that |
| 153 | // thread RIGHT NOW. Every failure (tampered token, unknown thread, not a |
| 154 | // member, message gone) returns the exact same generic response, so the |
| 155 | // endpoint can never be used to probe thread membership or existence. |
| 156 | func (s *Server) resolveMessageLink(w http.ResponseWriter, r *http.Request) { |
| 157 | token := r.URL.Query().Get("token") |
| 158 | if !s.tokenOK(token) { |
| 159 | httpError(w, http.StatusForbidden, "invalid invite") |
| 160 | return |
| 161 | } |
| 162 | var body struct { |
| 163 | Token string `json:"token"` |
| 164 | } |
| 165 | notFound := func() { httpError(w, http.StatusNotFound, "link not found") } |
| 166 | if json.NewDecoder(r.Body).Decode(&body) != nil || body.Token == "" { |
| 167 | notFound() |
| 168 | return |
| 169 | } |
| 170 | key, err := s.db.ensureLinkKey() |
| 171 | if err != nil { |
| 172 | httpError(w, http.StatusInternalServerError, "db error") |
| 173 | return |
| 174 | } |
| 175 | threadID, messageID, ok := openLinkToken(key, body.Token) |
| 176 | if !ok { |
| 177 | notFound() |
| 178 | return |
| 179 | } |
| 180 | userID, _ := s.db.deviceTokenUser(token) |
| 181 | if !s.db.threadMember(threadID, userID) || !s.db.messageInThread(messageID, threadID) { |
| 182 | notFound() |
| 183 | return |
| 184 | } |
| 185 | writeJSON(w, http.StatusOK, map[string]any{"thread_id": threadID, "message_id": messageID}) |
| 186 | } |
| 187 | |