/omemo commands for transport and chats

This commit is contained in:
Bohdan Horbeshko 2026-07-30 00:09:22 -04:00
parent 942352a8df
commit aa7d31a4f7
10 changed files with 464 additions and 14 deletions

View file

@ -59,6 +59,16 @@ func (b *Backend) Encrypt(from e2ee.PeerID, to []e2ee.PeerID, plaintext []byte)
} }
for _, deviceID := range deviceIDs { for _, deviceID := range deviceIDs {
// A manually blocked device (see Backend.BlockDevice) never
// gets a copy, regardless of any session already established
// with it - TOFU trust itself is untouched, this is purely the
// manual override layered on top.
if blocked, err := store.IsDeviceBlocked(string(peer), deviceID); err != nil {
return e2ee.Envelope{}, fmt.Errorf("omemo: IsDeviceBlocked(%s:%d): %w", peer, deviceID, err)
} else if blocked {
continue
}
cipher, err := libsignal.NewSessionCipher(b.libctx, storeCtx, libsignal.Address{Name: string(peer), DeviceID: deviceID}, version.libsignalVersion()) cipher, err := libsignal.NewSessionCipher(b.libctx, storeCtx, libsignal.Address{Name: string(peer), DeviceID: deviceID}, version.libsignalVersion())
if err != nil { if err != nil {
return e2ee.Envelope{}, fmt.Errorf("omemo: NewSessionCipher(%s:%d): %w", peer, deviceID, err) return e2ee.Envelope{}, fmt.Errorf("omemo: NewSessionCipher(%s:%d): %w", peer, deviceID, err)
@ -116,6 +126,17 @@ func (b *Backend) Decrypt(from, to e2ee.PeerID, env e2ee.Envelope) ([]byte, erro
if err := b.ensureIdentity(store); err != nil { if err := b.ensureIdentity(store); err != nil {
return nil, fmt.Errorf("omemo: ensureIdentity: %w", err) return nil, fmt.Errorf("omemo: ensureIdentity: %w", err)
} }
// A manually blocked sender device (see Backend.BlockDevice) is
// rejected outright, regardless of any session already established
// with it - TOFU trust itself is untouched, this is purely the manual
// override layered on top.
if blocked, err := store.IsDeviceBlocked(string(from), wireEnv.SenderSID); err != nil {
return nil, fmt.Errorf("omemo: IsDeviceBlocked: %w", err)
} else if blocked {
return nil, fmt.Errorf("omemo: Decrypt: sender device %s:%d is blocked", from, wireEnv.SenderSID)
}
regID, err := store.GetLocalRegistrationID() regID, err := store.GetLocalRegistrationID()
if err != nil { if err != nil {
return nil, fmt.Errorf("omemo: GetLocalRegistrationID: %w", err) return nil, fmt.Errorf("omemo: GetLocalRegistrationID: %w", err)

View file

@ -486,14 +486,91 @@ func (b *Backend) Devices(owner, peer e2ee.PeerID) ([]e2ee.DeviceInfo, error) {
infos := make([]e2ee.DeviceInfo, 0, len(deviceIDs)) infos := make([]e2ee.DeviceInfo, 0, len(deviceIDs))
for _, id := range deviceIDs { for _, id := range deviceIDs {
// A stored session implies IsTrustedIdentity already accepted this // A stored session implies IsTrustedIdentity already accepted this
// device's key (TOFU) - finer-grained trust states (manually // device's key (TOFU, which always stays on) - Trusted here
// verified/revoked) are a documented future enhancement, not // reflects the separate manual block override layered on top (see
// needed for this phase. // BlockDevice), not TOFU's own verdict.
infos = append(infos, e2ee.DeviceInfo{ID: e2ee.DeviceID(strconv.FormatUint(uint64(id), 10)), Trusted: true}) blocked, err := store.IsDeviceBlocked(string(peer), id)
if err != nil {
return nil, fmt.Errorf("omemo: IsDeviceBlocked(%s:%d): %w", peer, id, err)
}
infos = append(infos, e2ee.DeviceInfo{ID: e2ee.DeviceID(strconv.FormatUint(uint64(id), 10)), Trusted: !blocked})
} }
return infos, nil return infos, nil
} }
// Override implements e2ee.Backend.
func (b *Backend) Override(owner e2ee.PeerID) (bool, bool, error) {
login, ownerJID, ok := e2ee.SplitOwnedPeer(owner)
if !ok {
return false, false, fmt.Errorf("omemo: Override: %q is not an OwnedPeer", owner)
}
b.mu.Lock()
defer b.mu.Unlock()
return b.db.Store(b.libctx, login, ownerJID).Override()
}
// SetOverride implements e2ee.Backend.
func (b *Backend) SetOverride(owner e2ee.PeerID, on bool) error {
login, ownerJID, ok := e2ee.SplitOwnedPeer(owner)
if !ok {
return fmt.Errorf("omemo: SetOverride: %q is not an OwnedPeer", owner)
}
b.mu.Lock()
defer b.mu.Unlock()
return b.db.Store(b.libctx, login, ownerJID).SetOverride(on)
}
// ClearOverride implements e2ee.Backend.
func (b *Backend) ClearOverride(owner e2ee.PeerID) error {
login, ownerJID, ok := e2ee.SplitOwnedPeer(owner)
if !ok {
return fmt.Errorf("omemo: ClearOverride: %q is not an OwnedPeer", owner)
}
b.mu.Lock()
defer b.mu.Unlock()
return b.db.Store(b.libctx, login, ownerJID).ClearOverride()
}
// BlockDevice implements e2ee.Backend.
func (b *Backend) BlockDevice(owner, peer e2ee.PeerID, device e2ee.DeviceID) error {
login, ownerJID, ok := e2ee.SplitOwnedPeer(owner)
if !ok {
return fmt.Errorf("omemo: BlockDevice: %q is not an OwnedPeer", owner)
}
deviceID, err := parseDeviceID(device)
if err != nil {
return err
}
b.mu.Lock()
defer b.mu.Unlock()
return b.db.Store(b.libctx, login, ownerJID).SetDeviceBlocked(string(peer), deviceID, true)
}
// UnblockDevice implements e2ee.Backend.
func (b *Backend) UnblockDevice(owner, peer e2ee.PeerID, device e2ee.DeviceID) error {
login, ownerJID, ok := e2ee.SplitOwnedPeer(owner)
if !ok {
return fmt.Errorf("omemo: UnblockDevice: %q is not an OwnedPeer", owner)
}
deviceID, err := parseDeviceID(device)
if err != nil {
return err
}
b.mu.Lock()
defer b.mu.Unlock()
return b.db.Store(b.libctx, login, ownerJID).SetDeviceBlocked(string(peer), deviceID, false)
}
func (b *Backend) Enabled(owner e2ee.PeerID) (bool, error) { func (b *Backend) Enabled(owner e2ee.PeerID) (bool, error) {
login, ownerJID, ok := e2ee.SplitOwnedPeer(owner) login, ownerJID, ok := e2ee.SplitOwnedPeer(owner)
if !ok { if !ok {

83
e2ee/policy.go Normal file
View file

@ -0,0 +1,83 @@
package e2ee
import "fmt"
// Mode is the account-wide OMEMO policy (see persistence.Session.OMEMO),
// combined with a chat's optional Override and its auto-upgrade sticky
// flag (Backend.Enabled) by ShouldEncrypt to decide whether to attempt
// outgoing encryption for that chat right now.
type Mode int
const (
// ModeAuto (the default) only encrypts once a chat has auto-upgraded
// (Backend.Enabled), i.e. its peer has already sent this chat an OMEMO
// message. This is the original, only behavior before Mode existed.
ModeAuto Mode = iota
// ModeOn always attempts to encrypt, regardless of whether the peer
// has ever shown OMEMO support. If no session can be established, the
// send fails outright rather than falling back to plaintext - the
// same OMEMOSendFailedBody convention already used at every Encrypt
// call site applies, it's just reached unconditionally now instead of
// only for already-active chats. Incoming plaintext is still accepted
// and processed as usual - this mode only ever affects outgoing.
ModeOn
// ModeOff never encrypts outgoing content, even for a chat that has
// already auto-upgraded - inbound OMEMO messages are still decrypted
// normally regardless of this mode (decryption never consults Mode at
// all, only the encrypt hooks do).
ModeOff
)
func (m Mode) String() string {
switch m {
case ModeOn:
return "on"
case ModeOff:
return "off"
default:
return "auto"
}
}
// ParseMode maps persistence.Session.OMEMO's stored string to a Mode - an
// empty string (an account that predates this field, or never touched it)
// is treated the same as "auto", matching Mode's zero value and default.
func ParseMode(s string) (Mode, error) {
switch s {
case "", "auto":
return ModeAuto, nil
case "on":
return ModeOn, nil
case "off":
return ModeOff, nil
default:
return ModeAuto, fmt.Errorf("e2ee: unknown mode %q (want on, auto, or off)", s)
}
}
// ShouldEncrypt decides whether owner's outgoing content should be
// encrypted right now, combining (in priority order): owner's explicit
// per-chat Override, if one has been set; otherwise mode's account-wide
// policy, consulting backend's auto-upgrade sticky flag (Enabled) only for
// ModeAuto. This is the single place that combination logic lives - every
// encrypt hook (SendMessageToGateway, sendMessagesReverse,
// updateMessageContent) calls this instead of checking Enabled directly,
// so a chat's effective behavior can never drift between call sites.
func ShouldEncrypt(backend Backend, owner PeerID, mode Mode) (bool, error) {
if override, isSet, err := backend.Override(owner); err != nil {
return false, err
} else if isSet {
return override, nil
}
switch mode {
case ModeOn:
return true, nil
case ModeOff:
return false, nil
default:
return backend.Enabled(owner)
}
}

View file

@ -30,15 +30,19 @@ var _ libsignal.Store = (*Store)(nil)
// //
// <login>/<owner>/enabled // <login>/<owner>/enabled
// <login>/<owner>/negotiatedversion // <login>/<owner>/negotiatedversion
// <login>/<owner>/override
// <login>/<owner>/identity/keypair // <login>/<owner>/identity/keypair
// <login>/<owner>/identity/registrationid // <login>/<owner>/identity/registrationid
// <login>/<owner>/identity/signedprekey/<id> // <login>/<owner>/identity/signedprekey/<id>
// <login>/<owner>/identity/prekey/<id> // <login>/<owner>/identity/prekey/<id>
// <login>/<owner>/identity/prekey/counter // <login>/<owner>/identity/prekey/counter
// <login>/<owner>/remote/<remote>/identity/<deviceId> // <login>/<owner>/remote/<remote>/identity/<deviceId>
// <login>/<owner>/remote/<remote>/trust/<deviceId> // <login>/<owner>/remote/<remote>/blocked/<deviceId>
// <login>/<owner>/remote/<remote>/session/<deviceId> // <login>/<owner>/remote/<remote>/session/<deviceId>
// <login>/<owner>/remote/<remote>/devicelist // <login>/<owner>/remote/<remote>/devicelist
//
// (identity/<deviceId> doubles as the TOFU trust record - see
// IsTrustedIdentity; there's no separate "trust" key.)
func (s *Store) enabledKey() []byte { func (s *Store) enabledKey() []byte {
return []byte(fmt.Sprintf("%s/%s/enabled", s.login, s.owner)) return []byte(fmt.Sprintf("%s/%s/enabled", s.login, s.owner))
@ -48,6 +52,10 @@ func (s *Store) negotiatedVersionKey() []byte {
return []byte(fmt.Sprintf("%s/%s/negotiatedversion", s.login, s.owner)) return []byte(fmt.Sprintf("%s/%s/negotiatedversion", s.login, s.owner))
} }
func (s *Store) overrideKey() []byte {
return []byte(fmt.Sprintf("%s/%s/override", s.login, s.owner))
}
func (s *Store) identityKeyPairKey() []byte { func (s *Store) identityKeyPairKey() []byte {
return []byte(fmt.Sprintf("%s/%s/identity/keypair", s.login, s.owner)) return []byte(fmt.Sprintf("%s/%s/identity/keypair", s.login, s.owner))
} }
@ -88,6 +96,10 @@ func (s *Store) remoteDeviceListKey(remote string) []byte {
return []byte(fmt.Sprintf("%s/%s/remote/%s/devicelist", s.login, s.owner, remote)) return []byte(fmt.Sprintf("%s/%s/remote/%s/devicelist", s.login, s.owner, remote))
} }
func (s *Store) blockedKey(remote string, deviceID uint32) []byte {
return []byte(fmt.Sprintf("%s/%s/remote/%s/blocked/%d", s.login, s.owner, remote, deviceID))
}
// --- generic byte get/set/delete/has, mirroring badger/ids.go's style --- // --- generic byte get/set/delete/has, mirroring badger/ids.go's style ---
func (s *Store) get(key []byte) ([]byte, error) { func (s *Store) get(key []byte) ([]byte, error) {
@ -418,6 +430,36 @@ func (s *Store) SetEnabled(enabled bool) error {
return s.set(s.enabledKey(), []byte{v}) return s.set(s.enabledKey(), []byte{v})
} }
// Override returns this chat's manual on/off override of the account-wide
// OMEMO mode (see e2ee.Mode/e2ee.ShouldEncrypt), and whether one has been
// set at all - isSet=false means "no override, follow the account-wide
// mode", not "forced off".
func (s *Store) Override() (on bool, isSet bool, err error) {
val, err := s.get(s.overrideKey())
if err != nil {
return false, false, err
}
if len(val) != 1 {
return false, false, nil
}
return val[0] == 1, true, nil
}
// SetOverride persists this chat's manual on/off override.
func (s *Store) SetOverride(on bool) error {
v := byte(0)
if on {
v = 1
}
return s.set(s.overrideKey(), []byte{v})
}
// ClearOverride removes this chat's manual override, reverting it to
// following the account-wide OMEMO mode.
func (s *Store) ClearOverride() error {
return s.delete(s.overrideKey())
}
// NegotiatedVersion returns the wire-format version last negotiated for // NegotiatedVersion returns the wire-format version last negotiated for
// this chat's outgoing content (see e2ee/omemo's Version enum - this // this chat's outgoing content (see e2ee/omemo's Version enum - this
// package stays version-agnostic and only stores the raw byte), and // package stays version-agnostic and only stores the raw byte), and
@ -456,3 +498,20 @@ func (s *Store) RemoteDeviceListCache(remote string) ([]byte, bool, error) {
func (s *Store) SaveRemoteDeviceListCache(remote string, doc []byte) error { func (s *Store) SaveRemoteDeviceListCache(remote string, doc []byte) error {
return s.set(s.remoteDeviceListKey(remote), doc) return s.set(s.remoteDeviceListKey(remote), doc)
} }
// IsDeviceBlocked reports whether remote's device deviceID has been
// manually blocked - a manual override layered on top of TOFU trust
// (IsTrustedIdentity), which always stays on regardless; this only ever
// narrows it further. Works independent of whether a session currently
// exists for that device.
func (s *Store) IsDeviceBlocked(remote string, deviceID uint32) (bool, error) {
return s.has(s.blockedKey(remote, deviceID))
}
// SetDeviceBlocked sets or clears remote's device deviceID's manual block.
func (s *Store) SetDeviceBlocked(remote string, deviceID uint32, blocked bool) error {
if !blocked {
return s.delete(s.blockedKey(remote, deviceID))
}
return s.set(s.blockedKey(remote, deviceID), []byte{1})
}

View file

@ -49,7 +49,11 @@ type DeviceListDoc struct{ Raw []byte }
type BundleDoc struct{ Raw []byte } type BundleDoc struct{ Raw []byte }
// DeviceInfo describes one known device of a peer, for trust-listing // DeviceInfo describes one known device of a peer, for trust-listing
// ad-hoc commands. // ad-hoc commands. Trusted is false only if the device has been manually
// blocked (see Backend.BlockDevice) - TOFU itself always trusts a
// never-before-seen device automatically, so a session existing at all
// already implies TOFU accepted it; Trusted here reflects the manual
// override layered on top, not TOFU's own verdict.
type DeviceInfo struct { type DeviceInfo struct {
ID DeviceID ID DeviceID
Trusted bool Trusted bool
@ -162,6 +166,25 @@ type Backend interface {
Enabled(owner PeerID) (bool, error) Enabled(owner PeerID) (bool, error)
SetEnabled(owner PeerID, enabled bool) error SetEnabled(owner PeerID, enabled bool) error
// Override, SetOverride, and ClearOverride manage owner's (an
// OwnedPeer) manual on/off override of the account-wide Mode (see
// ShouldEncrypt) - a per-chat "/omemo on"/"/omemo off" that wins
// regardless of the global setting, until cleared. isSet is false when
// no override has been set (follow the account-wide mode instead).
Override(owner PeerID) (on bool, isSet bool, err error)
SetOverride(owner PeerID, on bool) error
ClearOverride(owner PeerID) error
// BlockDevice and UnblockDevice manually override one of peer's
// devices' TOFU trust, as seen from owner's (an OwnedPeer) point of
// view - TOFU itself always stays on (a device's first-seen key is
// always trusted automatically), this only ever narrows it further. A
// blocked device's messages are rejected by Decrypt and skipped by
// Encrypt, regardless of any session already established with it - see
// Devices' Trusted field for the resulting per-device state.
BlockDevice(owner PeerID, peer PeerID, device DeviceID) error
UnblockDevice(owner PeerID, peer PeerID, device DeviceID) error
// Close flushes and releases whatever storage/resources this backend // Close flushes and releases whatever storage/resources this backend
// holds (e.g. the underlying Badger DB), for a clean process shutdown - // holds (e.g. the underlying Badger DB), for a clean process shutdown -
// mirrors gateway.IdsDB.Close()'s role for the id-mapping store. // mirrors gateway.IdsDB.Close()'s role for the id-mapping store.

View file

@ -50,6 +50,14 @@ type Session struct {
ignoredChatsMap map[int64]bool `yaml:"-"` ignoredChatsMap map[int64]bool `yaml:"-"`
IgnoreGroupDeletions bool `yaml:":ignoregroupdeletions"` IgnoreGroupDeletions bool `yaml:":ignoregroupdeletions"`
// OMEMO is the account-wide OMEMO mode: "on", "auto", or "off" (see
// e2ee.Mode/e2ee.ParseMode - kept as a plain string here rather than
// importing the e2ee package, to avoid coupling this generic,
// feature-agnostic session store to one specific feature). Empty
// (the zero value, e.g. for an account that predates this field)
// means "auto", matching e2ee.ModeAuto's own default.
OMEMO string `yaml:":omemo"`
} }
const ( const (
@ -70,6 +78,7 @@ var ConfigKeys = []string{
"receipts", "receipts",
"nativeedits", "nativeedits",
"ignoregroupdeletions", "ignoregroupdeletions",
"omemo",
} }
var Presets = map[string][][]string{ var Presets = map[string][][]string{
@ -199,6 +208,11 @@ func (s *Session) get(key string) (string, error) {
return fromBool(s.NativeEdits), nil return fromBool(s.NativeEdits), nil
case "ignoregroupdeletions": case "ignoregroupdeletions":
return fromBool(s.IgnoreGroupDeletions), nil return fromBool(s.IgnoreGroupDeletions), nil
case "omemo":
if s.OMEMO == "" {
return "auto", nil
}
return s.OMEMO, nil
} }
return "", errors.New("Unknown session property") return "", errors.New("Unknown session property")
@ -297,6 +311,13 @@ func (s *Session) Set(key string, value string) (string, error) {
} }
s.IgnoreGroupDeletions = b s.IgnoreGroupDeletions = b
return value, nil return value, nil
case "omemo":
switch value {
case "on", "auto", "off":
s.OMEMO = value
return value, nil
}
return "", errors.New("Invalid omemo mode (want on, auto, or off)")
} }
return "", errors.New("Unknown session property") return "", errors.New("Unknown session property")
@ -305,7 +326,7 @@ func (s *Session) Set(key string, value string) (string, error) {
// PropertyType determines the property type // PropertyType determines the property type
func PropertyType(key string) byte { func PropertyType(key string) byte {
switch key { switch key {
case "timezone": case "timezone", "omemo":
return PropertyTypeString return PropertyTypeString
case "keeponline", "rawmessages", "asciiarrows", "muc", "oobmode", "carbons", "hideids", case "keeponline", "rawmessages", "asciiarrows", "muc", "oobmode", "carbons", "hideids",
"receipts", "nativeedits", "ignoregroupdeletions": "receipts", "nativeedits", "ignoregroupdeletions":

View file

@ -70,6 +70,7 @@ var transportCommands = map[string]command{
"supergroup": command{1, []string{"title", "description"}, "create new supergroup «title» with «description»", true, nil, true}, "supergroup": command{1, []string{"title", "description"}, "create new supergroup «title» with «description»", true, nil, true},
"channel": command{1, []string{"title", "description"}, "create new channel «title» with «description»", true, nil, true}, "channel": command{1, []string{"title", "description"}, "create new channel «title» with «description»", true, nil, true},
"preset": command{1, []string{"modern|classic|pass"}, "apply a config preset", false, nil, false}, "preset": command{1, []string{"modern|classic|pass"}, "apply a config preset", false, nil, false},
"omemo": command{0, []string{"on|auto|off"}, "view or set the account-wide OMEMO encryption mode", false, nil, false},
"pass": command{0, []string{}, "proceed to next login stage", false, nil, false}, "pass": command{0, []string{}, "proceed to next login stage", false, nil, false},
"finish": command{0, []string{}, "skip post-login configuration", false, nil, false}, "finish": command{0, []string{}, "skip post-login configuration", false, nil, false},
} }
@ -88,6 +89,7 @@ var chatCommands = map[string]command{
"raw": command{1, []string{"message"}, "send a raw message not interpeted as a transport command (e.g. a bot command)", true, nil, true}, "raw": command{1, []string{"message"}, "send a raw message not interpeted as a transport command (e.g. a bot command)", true, nil, true},
"forward": command{2, []string{"message_id", "target_chat"}, "forwards a message", true, nil, true}, "forward": command{2, []string{"message_id", "target_chat"}, "forwards a message", true, nil, true},
"vcard": command{0, []string{}, "print vCard as text", true, nil, true}, "vcard": command{0, []string{}, "print vCard as text", true, nil, true},
"omemo": command{0, []string{"[on|off|clear|devices ...]"}, "view or override this chat's OMEMO state, or manage its peer's device trust (devices list|block <id>|accept <id>)", true, nil, false},
"add": command{1, []string{"@username"}, "add @username to your chat list", true, nil, true}, "add": command{1, []string{"@username"}, "add @username to your chat list", true, nil, true},
"join": command{1, []string{"https://t.me/invite_link"}, "join to chat via invite link or @publicname", true, nil, true}, "join": command{1, []string{"https://t.me/invite_link"}, "join to chat via invite link or @publicname", true, nil, true},
"group": command{1, []string{"title"}, "create groupchat «title» with current user", true, &notForGroups, true}, "group": command{1, []string{"title"}, "create groupchat «title» with current user", true, &notForGroups, true},
@ -126,6 +128,7 @@ var transportConfigurationOptions = map[string]configurationOption{
"receipts": configurationOption{"<bool>", "if enabled, XMPP read receipts are synced to Telegram, otherwise, messages are marked as read automatically (true/false)"}, "receipts": configurationOption{"<bool>", "if enabled, XMPP read receipts are synced to Telegram, otherwise, messages are marked as read automatically (true/false)"},
"nativeedits": configurationOption{"<bool>", "if possible, edit XMPP messages instead of showing Telegram edits as separate messages (true/false)"}, "nativeedits": configurationOption{"<bool>", "if possible, edit XMPP messages instead of showing Telegram edits as separate messages (true/false)"},
"ignoregroupdeletions": configurationOption{"<bool>", "suppress message deletion messages in group chats (true/false)"}, "ignoregroupdeletions": configurationOption{"<bool>", "suppress message deletion messages in group chats (true/false)"},
"omemo": configurationOption{"<on|auto|off>", "account-wide OMEMO encryption mode - see /omemo for a dedicated command and per-chat overrides"},
} }
type command struct { type command struct {
@ -572,6 +575,17 @@ func (c *Client) ProcessTransportCommand(cmdline string, resource string) (strin
cancelNextStage = true cancelNextStage = true
return "Invalid argument. Allowed ones are " + transportCommands["preset"].Arguments[0], false return "Invalid argument. Allowed ones are " + transportCommands["preset"].Arguments[0], false
} }
case "omemo":
if len(args) > 0 {
value, err := c.Session.Set("omemo", args[0])
if err != nil {
return err.Error(), false
}
gateway.DirtySessions = true
return fmt.Sprintf("omemo mode set to %s", value), true
}
value, _ := c.Session.Get("omemo")
return fmt.Sprintf("omemo mode is set to %s (per-chat overrides: use /omemo on|off|clear in a chat)", value), true
case "pass": case "pass":
switch c.loginStage { switch c.loginStage {
case LoginStagePreset: case LoginStagePreset:
@ -1180,6 +1194,9 @@ func (c *Client) ProcessChatCommand(chatID int64, cmdline string) (string, bool,
} }
return strings.Join(entries, "\n"), true, true return strings.Join(entries, "\n"), true, true
case "omemo":
response, success := c.cmdOmemoChat(chatID, args)
return response, true, success
case "help": case "help":
return c.helpString(CommandTypeChat, chatID), true, true return c.helpString(CommandTypeChat, chatID), true, true
default: default:

View file

@ -418,7 +418,13 @@ func (c *Client) updateMessageContent(update *client.UpdateMessageContent) {
if !isMUC { if !isMUC {
if backend, ok := gateway.E2EE.Backend(); ok { if backend, ok := gateway.E2EE.Backend(); ok {
owner := e2ee.OwnedPeer(c.Session.Login, gateway.CHATJID(update.ChatId, false)) owner := e2ee.OwnedPeer(c.Session.Login, gateway.CHATJID(update.ChatId, false))
if active, _ := backend.Enabled(owner); active { mode, modeErr := e2ee.ParseMode(c.Session.OMEMO)
if modeErr != nil {
log.Error(errors.Wrap(modeErr, "Invalid omemo mode"))
}
if active, err := e2ee.ShouldEncrypt(backend, owner, mode); err != nil {
log.Error(errors.Wrap(err, "Failed to evaluate OMEMO policy"))
} else if active {
peer := e2ee.PeerID(c.jid) peer := e2ee.PeerID(c.jid)
if err := c.ensureOMEMOSession(backend, owner, peer); err != nil { if err := c.ensureOMEMOSession(backend, owner, peer); err != nil {
log.Error(errors.Wrap(err, "Failed to establish OMEMO session")) log.Error(errors.Wrap(err, "Failed to establish OMEMO session"))

129
telegram/omemo_commands.go Normal file
View file

@ -0,0 +1,129 @@
package telegram
import (
"fmt"
"strings"
"dev.narayana.im/narayana/telegabber/e2ee"
"dev.narayana.im/narayana/telegabber/xmpp/gateway"
)
// cmdOmemoChat implements the chat-scoped "/omemo" command: with no
// arguments, reports this chat's current OMEMO state (account-wide mode,
// any override, and the resulting effective state); "on"/"off" forces this
// specific chat regardless of the account-wide mode (persistence.Session.OMEMO,
// via e2ee.Mode/e2ee.ShouldEncrypt); "clear" removes that override, reverting
// to following the account-wide mode; "devices ..." manages this chat's
// peer's device trust (see cmdOmemoDevices).
func (c *Client) cmdOmemoChat(chatID int64, args []string) (string, bool) {
backend, ok := gateway.E2EE.Backend()
if !ok {
return "OMEMO is not enabled on this transport", false
}
owner := e2ee.OwnedPeer(c.Session.Login, gateway.CHATJID(chatID, false))
if len(args) == 0 {
return c.omemoChatStatus(backend, owner)
}
switch args[0] {
case "on", "off":
if err := backend.SetOverride(owner, args[0] == "on"); err != nil {
return err.Error(), false
}
return fmt.Sprintf("OMEMO forced %s for this chat", args[0]), true
case "clear":
if err := backend.ClearOverride(owner); err != nil {
return err.Error(), false
}
return "OMEMO override cleared for this chat, now following the account-wide mode", true
case "devices":
return c.cmdOmemoDevices(backend, owner, args[1:])
default:
return "Usage: /omemo [on|off|clear|devices list|devices block <id>|devices accept <id>]", false
}
}
func (c *Client) omemoChatStatus(backend e2ee.Backend, owner e2ee.PeerID) (string, bool) {
mode, err := e2ee.ParseMode(c.Session.OMEMO)
if err != nil {
return err.Error(), false
}
override, isSet, err := backend.Override(owner)
if err != nil {
return err.Error(), false
}
active, err := e2ee.ShouldEncrypt(backend, owner, mode)
if err != nil {
return err.Error(), false
}
overrideText := "not set (following the account-wide mode)"
if isSet {
overrideText = fmt.Sprintf("forced %s", onOffString(override))
}
return fmt.Sprintf("Account-wide mode: %s\nThis chat's override: %s\nEffective right now: %s",
mode, overrideText, onOffString(active)), true
}
// cmdOmemoDevices lists, blocks, or accepts (unblocks) devices of owner's
// chat's real peer. TOFU trust itself always stays on (a never-before-seen
// device is always trusted automatically) - block/accept only manage a
// manual override layered on top of that, per Backend.BlockDevice's doc
// comment.
func (c *Client) cmdOmemoDevices(backend e2ee.Backend, owner e2ee.PeerID, args []string) (string, bool) {
peer := e2ee.PeerID(c.jid)
sub := "list"
if len(args) > 0 {
sub = args[0]
}
switch sub {
case "list":
devices, err := backend.Devices(owner, peer)
if err != nil {
return err.Error(), false
}
if len(devices) == 0 {
return "No known devices for this chat's peer yet", true
}
lines := make([]string, 0, len(devices))
for _, d := range devices {
lines = append(lines, fmt.Sprintf("%s: %s", d.ID, trustString(d.Trusted)))
}
return strings.Join(lines, "\n"), true
case "block", "accept":
if len(args) < 2 {
return notEnoughArguments, false
}
device := e2ee.DeviceID(args[1])
var err error
if sub == "block" {
err = backend.BlockDevice(owner, peer, device)
} else {
err = backend.UnblockDevice(owner, peer, device)
}
if err != nil {
return err.Error(), false
}
return fmt.Sprintf("Device %s %sed", device, sub), true
default:
return "Usage: /omemo devices [list|block <id>|accept <id>]", false
}
}
func onOffString(b bool) string {
if b {
return "on"
}
return "off"
}
func trustString(trusted bool) string {
if trusted {
return "trusted"
}
return "blocked"
}

View file

@ -2062,10 +2062,12 @@ func (c *Client) SendMessageToGateway(chatId int64, message *client.Message, id
// OMEMO encrypt hook - personal-chat pseudo-JIDs only (true MUC/XEP-0045 // OMEMO encrypt hook - personal-chat pseudo-JIDs only (true MUC/XEP-0045
// groupchat OMEMO is out of scope entirely, not deferred - see the plan // groupchat OMEMO is out of scope entirely, not deferred - see the plan
// doc), gated on the per-chat active flag set by the decrypt hook's // doc). Whether to encrypt is decided by e2ee.ShouldEncrypt, combining
// auto-upgrade trigger (xmpp/handlers.go). The account-wide config // this chat's optional /omemo on|off override, the account-wide
// toggle (persistence.Session.OMEMO) is a separate, not-yet-wired-up // persistence.Session.OMEMO mode, and (for the default "auto" mode) the
// piece (M4) meant to OR into this same gate. Encryption happens once // per-chat active flag set by the decrypt hook's auto-upgrade trigger
// (xmpp/handlers.go) - see ShouldEncrypt's own doc comment for the
// combination rules. Encryption happens once
// per logical message (not once per jids resource copy), addressed to // per logical message (not once per jids resource copy), addressed to
// every known device of the real user's bare JID - carbon-copy // every known device of the real user's bare JID - carbon-copy
// resources all share the same OMEMO device fan-out. OOB file links are // resources all share the same OMEMO device fan-out. OOB file links are
@ -2083,7 +2085,13 @@ func (c *Client) SendMessageToGateway(chatId int64, message *client.Message, id
if !isGroupchat && needsOMEMOEncryption { if !isGroupchat && needsOMEMOEncryption {
if backend, ok := gateway.E2EE.Backend(); ok { if backend, ok := gateway.E2EE.Backend(); ok {
owner := e2ee.OwnedPeer(c.Session.Login, gateway.CHATJID(chatId, false)) owner := e2ee.OwnedPeer(c.Session.Login, gateway.CHATJID(chatId, false))
if active, _ := backend.Enabled(owner); active { mode, modeErr := e2ee.ParseMode(c.Session.OMEMO)
if modeErr != nil {
log.Error(errors.Wrap(modeErr, "Invalid omemo mode"))
}
if active, err := e2ee.ShouldEncrypt(backend, owner, mode); err != nil {
log.Error(errors.Wrap(err, "Failed to evaluate OMEMO policy"))
} else if active {
peer := e2ee.PeerID(c.jid) peer := e2ee.PeerID(c.jid)
if err := c.ensureOMEMOSession(backend, owner, peer); err != nil { if err := c.ensureOMEMOSession(backend, owner, peer); err != nil {
log.Error(errors.Wrap(err, "Failed to establish OMEMO session")) log.Error(errors.Wrap(err, "Failed to establish OMEMO session"))
@ -3047,7 +3055,13 @@ func (c *Client) sendMessagesReverse(chatID int64, messages []*client.Message, p
if !isMUC && body != "" { if !isMUC && body != "" {
if backend, ok := gateway.E2EE.Backend(); ok { if backend, ok := gateway.E2EE.Backend(); ok {
owner := e2ee.OwnedPeer(c.Session.Login, gateway.CHATJID(chatID, false)) owner := e2ee.OwnedPeer(c.Session.Login, gateway.CHATJID(chatID, false))
if active, _ := backend.Enabled(owner); active { mode, modeErr := e2ee.ParseMode(c.Session.OMEMO)
if modeErr != nil {
log.Error(errors.Wrap(modeErr, "Invalid omemo mode"))
}
if active, err := e2ee.ShouldEncrypt(backend, owner, mode); err != nil {
log.Error(errors.Wrap(err, "Failed to evaluate OMEMO policy"))
} else if active {
peer := e2ee.PeerID(c.jid) peer := e2ee.PeerID(c.jid)
if err := c.ensureOMEMOSession(backend, owner, peer); err != nil { if err := c.ensureOMEMOSession(backend, owner, peer); err != nil {
log.Error(errors.Wrap(err, "Failed to establish OMEMO session")) log.Error(errors.Wrap(err, "Failed to establish OMEMO session"))