// Message permalinks (issue #893): "Copy link" mints an opaque token for a // {thread_id, message_id} pair, sealed under a per-instance key that only this // server holds — never a thread or instance key, and never sent to any client. // A permalink exists to be pasted somewhere (another chat, an email, a synced // clipboard), so the reference has to be opaque to whoever sees the raw link // text, not just kept out of request logs. Sealing it under a key every thread // member holds would do that too, but as a static, offline capability with no // way to revoke it short of rotating the thread key. Sealing it under a key // only the server holds means opening a link is always a fresh, authenticated // round-trip that checks the caller's CURRENT thread membership — so leaving a // thread (or the space) actually revokes the ability to jump via an old link. // // The key only ever protects identifiers this server already stores in // plaintext (thread_id, message id) — never message content — so it's // addressing/auth material in the same category as the admin token, not a // decryption key for anything the E2EE rule protects. See the design // discussion on #893 for the full comparison against thread-key/instance-key // alternatives. package main import ( "crypto/aes" "crypto/cipher" "crypto/rand" "encoding/base64" "encoding/json" "net/http" "strconv" "strings" ) // ensureLinkKey returns this instance's message-link sealing key, generating // and persisting a fresh 256-bit one (config key "link_key") on first use. // Dedicated to this one purpose — not derived from or shared with any other // secret — so rotating it (or losing it, e.g. a fresh DB) only ever invalidates // outstanding permalinks, nothing else. func (db *DB) ensureLinkKey() ([]byte, error) { v, ok, err := db.getConfig("link_key") if err != nil { return nil, err } if ok { return base64.StdEncoding.DecodeString(v) } key := make([]byte, 32) if _, err := rand.Read(key); err != nil { return nil, err } if err := db.setConfig("link_key", base64.StdEncoding.EncodeToString(key)); err != nil { return nil, err } return key, nil } // sealLinkToken encrypts "threadID:messageID" under key with a fresh random // nonce (AES-256-GCM), same base64url(nonce||ciphertext) envelope used // elsewhere in this codebase for server-sealed tokens (home/stateless.go). func sealLinkToken(key []byte, threadID string, messageID int64) (string, error) { block, err := aes.NewCipher(key) if err != nil { return "", err } gcm, err := cipher.NewGCM(block) if err != nil { return "", err } nonce := make([]byte, gcm.NonceSize()) if _, err := rand.Read(nonce); err != nil { return "", err } pt := []byte(threadID + ":" + strconv.FormatInt(messageID, 10)) return base64.RawURLEncoding.EncodeToString(gcm.Seal(nonce, nonce, pt, nil)), nil } // openLinkToken reverses sealLinkToken. A tampered, expired-format, or // wrong-key token fails closed (GCM's tag, not the caller, decides "invalid"). func openLinkToken(key []byte, token string) (threadID string, messageID int64, ok bool) { raw, err := base64.RawURLEncoding.DecodeString(token) if err != nil { return "", 0, false } block, err := aes.NewCipher(key) if err != nil { return "", 0, false } gcm, err := cipher.NewGCM(block) if err != nil { return "", 0, false } if len(raw) < gcm.NonceSize() { return "", 0, false } pt, err := gcm.Open(nil, raw[:gcm.NonceSize()], raw[gcm.NonceSize():], nil) if err != nil { return "", 0, false } threadID, idStr, found := strings.Cut(string(pt), ":") if !found { return "", 0, false } id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { return "", 0, false } return threadID, id, true } // mintMessageLink mints the opaque token for a "Copy link" action. The caller // must already be a member of the thread the message lives in — same gate as // reading or reacting to it. func (s *Server) mintMessageLink(w http.ResponseWriter, r *http.Request) { token := r.URL.Query().Get("token") if !s.tokenOK(token) { httpError(w, http.StatusForbidden, "invalid invite") return } messageID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil || messageID <= 0 { httpError(w, http.StatusBadRequest, "bad message id") return } var body struct { ThreadID string `json:"thread_id"` } if json.NewDecoder(r.Body).Decode(&body) != nil { httpError(w, http.StatusBadRequest, "bad request") return } userID, _ := s.db.deviceTokenUser(token) if body.ThreadID == "" || !s.db.threadMember(body.ThreadID, userID) { httpError(w, http.StatusForbidden, "not a thread member") return } if !s.db.messageInThread(messageID, body.ThreadID) { httpError(w, http.StatusNotFound, "message not found") return } key, err := s.db.ensureLinkKey() if err != nil { httpError(w, http.StatusInternalServerError, "db error") return } tok, err := sealLinkToken(key, body.ThreadID, messageID) if err != nil { httpError(w, http.StatusInternalServerError, "seal error") return } writeJSON(w, http.StatusOK, map[string]string{"token": tok}) } // resolveMessageLink turns an opened "Copy link" token back into // {thread_id, message_id} — but only for a caller who is a member of that // thread RIGHT NOW. Every failure (tampered token, unknown thread, not a // member, message gone) returns the exact same generic response, so the // endpoint can never be used to probe thread membership or existence. func (s *Server) resolveMessageLink(w http.ResponseWriter, r *http.Request) { token := r.URL.Query().Get("token") if !s.tokenOK(token) { httpError(w, http.StatusForbidden, "invalid invite") return } var body struct { Token string `json:"token"` } notFound := func() { httpError(w, http.StatusNotFound, "link not found") } if json.NewDecoder(r.Body).Decode(&body) != nil || body.Token == "" { notFound() return } key, err := s.db.ensureLinkKey() if err != nil { httpError(w, http.StatusInternalServerError, "db error") return } threadID, messageID, ok := openLinkToken(key, body.Token) if !ok { notFound() return } userID, _ := s.db.deviceTokenUser(token) if !s.db.threadMember(threadID, userID) || !s.db.messageInThread(messageID, threadID) { notFound() return } writeJSON(w, http.StatusOK, map[string]any{"thread_id": threadID, "message_id": messageID}) }