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}},