eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

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.
19package main
20
21import (
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.
37func (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).
58func 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").
77func 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.
111func (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.
156func (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