From 711e674ab1e82065b2f9154b4a83776a10e3f253 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 9 Oct 2026 12:06:18 +0200 Subject: [PATCH] telegram: take a tap only on a message showing the words this channel put there, and say where an approval from Telegram means something The review of 2026-10-09 (M4): another holder of the token can change a message's words and leave its buttons, so the operator taps Release under words that say something else. Each message's words are now kept as a digest, per chat and message, and a tap on a message whose words differ, or that this channel never showed, is refused on the phone and never said to the router. The README says, as the operator directed (H5), that an approval from Telegram is meaningful only where agents run under their own account without sudo, without the operator's X display, and with Telegram Desktop's data readable by the operator alone; until then, approve from the phone. --- modules/telegram/README.md | 22 ++++++ modules/telegram/cmd/telegram/channel.go | 50 ++++++++++++-- modules/telegram/cmd/telegram/channel_test.go | 69 ++++++++++++++++++- modules/telegram/cmd/telegram/main.go | 18 +++++ 4 files changed, 153 insertions(+), 6 deletions(-) diff --git a/modules/telegram/README.md b/modules/telegram/README.md index f1902efe..2df1db71 100644 --- a/modules/telegram/README.md +++ b/modules/telegram/README.md @@ -42,6 +42,28 @@ watcher's. - **The router believes it proves its sender only so placed**, and only while the controller's self-check finds no agent able to become root without a person on its machine or the router's (`agent-root`). - **If an agent holds root on the machine, none of this holds**: root reads the token and every credential. +- **A tap counts only on a message showing the words it put there.** It keeps a digest of what each message + shows; a tap on one whose words differ — changed by another holder of the token — or on one it never showed + is refused on the phone and never said to the router. + +## Where an approval from Telegram means something (the operator's direction of 2026-10-09) + +An approval from Telegram proves only that the linked account tapped. It is meaningful where nothing working +for the operator can tap in their name, which on a machine where agents run needs all three of: + +1. **The agents run under an account of their own, without sudo** (novox/hq ADR 0266), so they cannot become + root or the operator. +2. **That account has no access to the operator's X display** — no Xauthority cookie for it. X11 has no + isolation between the clients of one display: a program on it reads every key typed and can click any + window, the Telegram client's buttons among them. +3. **The operator's Telegram Desktop data is readable by the operator alone** (`~/.local/share/TelegramDesktop/tdata`, + 0700). Found 0755 with its files 0644: any account on the machine can copy the session and be the operator + on Telegram from elsewhere. + +Until agents have their own account on a machine, the operator's Telegram client on that machine is not +counted as safe: **approve from the phone.** No module of the catalogue installs Telegram Desktop today, so +nothing of the mesh keeps `tdata` 0700 yet; the operator sets it by hand (`chmod -R go-rwx` on it) until one +does. ## Where it runs diff --git a/modules/telegram/cmd/telegram/channel.go b/modules/telegram/cmd/telegram/channel.go index c278f3d8..3f7fba54 100644 --- a/modules/telegram/cmd/telegram/channel.go +++ b/modules/telegram/cmd/telegram/channel.go @@ -13,13 +13,16 @@ package main // text is a success (D6). A message that cannot be delivered after three tries is said as failed. // - **What arrives** is long-polled: outbound only. A tapped button is acknowledged at once (the phone's // spinner stops) and said as a `choice` with the sender the service authenticated; only a private chat -// with the account itself counts. `/start` is said as a `link`; six digits are asked as a `code` proof by +// with the account itself counts, and only on a message showing the words this channel put there (another +// holder of the token may have changed them). `/start` is said as a `link`; six digits are asked as a `code` proof by // request and reply, never in an event, and deleted from the chat. Anything else is answered with one // fixed line. // - **Standing** is said every five minutes and whenever it changes: whether the token works, whether // the updates arrive, and that an edit here notifies nobody. import ( + "crypto/sha256" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -66,6 +69,26 @@ type Store interface { SetOffset(int64) error Placed(handle string) (*Placed, error) Place(handle string, p Placed) error + // Shown is the digest of the words a message in a chat shows as this channel last put them there (wordsDigest), + // empty for one it never showed; Show keeps it. + Shown(chat, id int64) (string, error) + Show(chat, id int64, digest string) error +} + +// wordsDigest is what a tap's message is compared by: the words as Telegram gives them back — its line +// endings and outer spaces aside — so a message whose words another holder of the token changed is told +// from the one this channel showed (the review of 2026-10-09). +func wordsDigest(words string) string { + w := strings.TrimSpace(strings.ReplaceAll(words, "\r\n", "\n")) + sum := sha256.Sum256([]byte(w)) + return hex.EncodeToString(sum[:]) +} + +// keepShown keeps what a message now shows; a message whose words could not be kept answers no tap. +func (c *Channel) keepShown(chat, id int64, words string) { + if err := c.Store.Show(chat, id, wordsDigest(words)); err != nil { + c.Logf("[telegram] what a message shows could not be kept, so its buttons will be refused: %v", err) + } } // Channel is the channel's whole working. @@ -190,6 +213,9 @@ func (c *Channel) Take(subject string, body json.RawMessage) error { switch { case place != nil: err = c.Bot.Edit(place.Chat, place.ID, words, buttons) + if err == nil || notModified(err) { + c.keepShown(place.Chat, place.ID, words) + } if gone(err) { place, err = nil, c.newMessage(m.Handle, chat, words, buttons, silent, &id) } @@ -225,6 +251,7 @@ func (c *Channel) newMessage(handle string, chat int64, words string, buttons [] return err } *id = got + c.keepShown(chat, got, words) if err := c.Store.Place(handle, Placed{Chat: chat, ID: got}); err != nil { c.Logf("[telegram] a message was sent and where it is could not be kept: an edit of it will be a new message: %v", err) } @@ -261,14 +288,21 @@ func (c *Channel) Handle(u Update) { switch { case u.Callback != nil: cb := u.Callback - // At once, whatever comes of it: the phone shows a spinner until then. - if err := c.Bot.AnswerTap(cb.ID, ""); err != nil { - c.Logf("[telegram] a tap could not be acknowledged: %v", err) - } if cb.Message == nil || !private(&cb.From, cb.Message.Chat) { + c.answerTap(cb.ID, "") c.Logf("[telegram] a tap outside a private chat was dropped") return } + // Only on a message showing exactly what this channel put there: one whose words another holder of + // the token changed, or one it never showed, answers nothing (the review of 2026-10-09). + if want, err := c.Store.Shown(cb.Message.Chat.ID, cb.Message.MessageID); err != nil || want == "" || + want != wordsDigest(cb.Message.Text) { + c.answerTap(cb.ID, "That answer was not taken: this message does not show what the mesh put there. Nothing was done.") + c.Logf("[telegram] a tap on a message not showing what this channel put there was refused") + return + } + // At once, whatever comes of it: the phone shows a spinner until then. + c.answerTap(cb.ID, "") id := "tg-tap-" + cb.ID c.remember(id, cb.Message.Chat.ID) ch := asks.Choice{ID: id, Ticket: cb.Data, Sender: senderOf(cb.From), At: now} @@ -311,6 +345,12 @@ func (c *Channel) Handle(u Update) { } } +func (c *Channel) answerTap(id, words string) { + if err := c.Bot.AnswerTap(id, words); err != nil { + c.Logf("[telegram] a tap could not be answered: %v", err) + } +} + func (c *Channel) remember(id string, chat int64) { c.mu.Lock() defer c.mu.Unlock() diff --git a/modules/telegram/cmd/telegram/channel_test.go b/modules/telegram/cmd/telegram/channel_test.go index 818f42a8..5d6437cc 100644 --- a/modules/telegram/cmd/telegram/channel_test.go +++ b/modules/telegram/cmd/telegram/channel_test.go @@ -88,6 +88,16 @@ func jsonInt(n int64) string { b, _ := json.Marshal(n); return string(b) } type memStore struct { offset int64 placed map[string]Placed + shown map[[2]int64]string +} + +func (m *memStore) Shown(chat, id int64) (string, error) { return m.shown[[2]int64{chat, id}], nil } +func (m *memStore) Show(chat, id int64, digest string) error { + if m.shown == nil { + m.shown = map[[2]int64]string{} + } + m.shown[[2]int64{chat, id}] = digest + return nil } func (m *memStore) Offset() (int64, error) { return m.offset, nil } @@ -261,8 +271,9 @@ func TestARefusalIsPermanentARateLimitIsWaitedOutAndNeitherCarriesTheToken(t *te func TestATapIsAcknowledgedAtOnceAndSaidWithItsAuthenticatedSender(t *testing.T) { c, f, s, _ := newChannel(t, goodToken) + shown := showQuestion(t, c, f) c.Handle(Update{UpdateID: 1, Callback: &Callback{ID: "cb1", Data: "tkt", From: User{ID: 42, FirstName: "Jo"}, - Message: &Msg{MessageID: 7, Chat: Chat{ID: 42, Type: "private"}}}}) + Message: &Msg{MessageID: shown, Chat: Chat{ID: 42, Type: "private"}, Text: "Release it?\nThe delivery is held."}}}) if len(f.of("answerCallbackQuery")) != 1 { t.Error("the tap was not acknowledged") } @@ -363,3 +374,59 @@ func TestPollTakesTheUpdatesAndKeepsTheOffsetAndSaysAConflict(t *testing.T) { t.Error("ready while the updates do not arrive") } } + +// showQuestion shows one question with a button to the linked account, and answers its message's id. +func showQuestion(t *testing.T, c *Channel, f *fakeAPI) int64 { + t.Helper() + subject, body := work(asks.Show, asks.Message{Handle: "q1", Title: "Release it?", Body: "The delivery is held.", + To: "42", Buttons: []asks.Button{{Label: "Release", Ticket: "tkt"}}}) + if err := c.Take(subject, body); err != nil { + t.Fatal(err) + } + sent := f.of("sendMessage") + if len(sent) == 0 { + t.Fatal("the question was not sent") + } + f.mu.Lock() + id := f.next + f.mu.Unlock() + return id +} + +// A tap is said only on a message showing exactly what this channel put there (the review of 2026-10-09): a +// message whose words were changed by another holder of the token — "Release" under other words — or one +// this channel never showed, is refused on the phone and never reaches the router. +func TestATapOnAMessageShowingOtherWordsIsRefused(t *testing.T) { + c, f, s, st := newChannel(t, goodToken) + shown := showQuestion(t, c, f) + tap := func(id string, msg int64, words string) { + c.Handle(Update{UpdateID: 1, Callback: &Callback{ID: id, Data: "tkt", From: User{ID: 42, FirstName: "Jo"}, + Message: &Msg{MessageID: msg, Chat: Chat{ID: 42, Type: "private"}, Text: words}}}) + } + tap("cb-other", shown, "Silence it for a week?\nNothing changes.") + tap("cb-unknown", shown+100, "Release it?\nThe delivery is held.") + if got := s.of(asks.IntakeSubject(asks.Chosen, Kind)); len(got) != 0 { + t.Fatalf("a tap on other words was said: %s", got) + } + answers := f.of("answerCallbackQuery") + if len(answers) != 2 || !strings.Contains(answers[0].Body["text"].(string), "not") { + t.Errorf("the phone was not told: %v", answers) + } + // The same words, as Telegram gives them back (its line endings, its outer spaces), are taken. + tap("cb-same", shown, " Release it?\r\nThe delivery is held. ") + if got := s.of(asks.IntakeSubject(asks.Chosen, Kind)); len(got) != 1 { + t.Fatalf("the tap on the words shown was not said: %s", got) + } + // An edit moves what was shown: the old words are then refused. + subject, body := work(asks.Edit, asks.Message{Handle: "q1", Title: "Released", Body: "by you", To: "42"}) + if err := c.Take(subject, body); err != nil { + t.Fatal(err) + } + tap("cb-old", shown, "Release it?\nThe delivery is held.") + if got := s.of(asks.IntakeSubject(asks.Chosen, Kind)); len(got) != 1 { + t.Errorf("a tap on words since replaced was said") + } + if d, _ := st.Shown(42, shown); d == "" { + t.Error("what an edit showed was not kept") + } +} diff --git a/modules/telegram/cmd/telegram/main.go b/modules/telegram/cmd/telegram/main.go index fc94243c..2883072d 100644 --- a/modules/telegram/cmd/telegram/main.go +++ b/modules/telegram/cmd/telegram/main.go @@ -49,6 +49,24 @@ func (busStore) Place(handle string, p Placed) error { return err } +// shownKey is where what a message shows is kept, beside the router's handles in the same state. +func shownKey(chat, id int64) string { return fmt.Sprintf("shown-words.%d.%d", chat, id) } + +func (busStore) Shown(chat, id int64) (string, error) { + e, err := stdio.State("messages").Get(shownKey(chat, id)) + if err != nil || e == nil { + return "", err + } + var w struct { + Digest string `json:"digest"` + } + return w.Digest, json.Unmarshal(e.Value, &w) +} +func (busStore) Show(chat, id int64, digest string) error { + _, err := stdio.State("messages").Put(shownKey(chat, id), map[string]string{"digest": digest}) + return err +} + func main() { c := &Channel{ Bot: &Bot{TokenFile: os.Getenv("MESH_TELEGRAM_TOKEN_FILE"), Client: &http.Client{Timeout: PollWait + 15*time.Second}},