Wire OMEMO message handling

This commit is contained in:
Bohdan Horbeshko 2026-07-28 23:14:32 -04:00
parent 6a7cd095e9
commit 09e0c793bf
23 changed files with 1368 additions and 175 deletions

View file

@ -7,7 +7,9 @@ MAKEOPTS := "-j4"
# GOTAGS=signal_legacy builds the OMEMO cgo bindings against the pre-fork
# libsignal-protocol-c instead of libomemo-c (e.g. on Debian bullseye, which
# only packages the former) - legacy/siacs OMEMO only, no modern OMEMO 1/2.
# only packages the former) - Omemo0 (eu.siacs.conversations.axolotl) only,
# no Omemo1/Omemo2 (both need protocol v4, which vanilla libsignal-protocol-c
# doesn't have).
GOTAGS :=
all:
@ -15,7 +17,7 @@ all:
go build -ldflags "-X main.commit=${COMMIT}" -tags "${GOTAGS}" -o release/telegabber
test:
go test -tags "${GOTAGS}" -v ./config ./ ./telegram ./xmpp ./xmpp/gateway ./persistence ./telegram/formatter ./badger ./e2ee/...
go test -tags "${GOTAGS}" -v ./config ./ ./telegram ./xmpp ./xmpp/gateway ./xmpp/extensions ./persistence ./telegram/formatter ./badger ./e2ee/...
lint:
$(GOPATH)/bin/golint ./...

View file

@ -26,3 +26,19 @@
:port: 5347
:password: 'password'
:db: 'session.dat'
# Optional. OMEMO (XEP-0384) end-to-end encryption for personal-chat
# pseudo-JIDs - off by default. When present but :enabled is left out
# or false, telegabber behaves exactly as if this whole block didn't
# exist. Every OMEMO generation this build supports (omemo0/omemo1/
# omemo2) is always published/served/accepted at once - there's no
# version to pick here; which one is actually used for a given chat is
# negotiated automatically (omemo0 by default, upgrading once that
# chat's peer is seen using a newer one). See README.md for the
# account-level (:omemo) and per-chat opt-in triggers on top of this
# deploy-time master switch.
#:e2ee:
# :enabled: true
# :backend: 'omemo'
# :db: 'e2ee'
# :encryption_passphrase: 'change-me' # empty disables encryption-at-rest

View file

@ -17,12 +17,29 @@ type Config struct {
// XMPPConfig is for :xmpp: subtree
type XMPPConfig struct {
Loglevel string `yaml:":loglevel"`
Jid string `yaml:":jid"`
Host string `yaml:":host"`
Port string `yaml:":port"`
Password string `yaml:":password"`
Db string `yaml:":db"`
Loglevel string `yaml:":loglevel"`
Jid string `yaml:":jid"`
Host string `yaml:":host"`
Port string `yaml:":port"`
Password string `yaml:":password"`
Db string `yaml:":db"`
E2EE E2EEConfig `yaml:":e2ee"`
}
// E2EEConfig is for :xmpp: :e2ee: subtree - end-to-end encryption (OMEMO),
// off by default (the zero value has Enabled: false, so an absent :e2ee
// block behaves exactly as if the feature didn't exist). There's no
// version selector: every OMEMO generation (omemo0/omemo1/omemo2) this
// build's crypto library supports is always published/served/accepted at
// once - which one is actually used for a given chat's outgoing content is
// negotiated per chat (defaulting to omemo0, then tracking whatever
// version that chat's peer is seen using - see e2ee/omemo.Backend), not a
// deploy-time choice.
type E2EEConfig struct {
Enabled bool `yaml:":enabled"`
Backend string `yaml:":backend"`
Db string `yaml:":db"`
EncryptionPassphrase string `yaml:":encryption_passphrase"`
}
// TelegramConfig is for :telegram: subtree

View file

@ -94,6 +94,23 @@
},
":db": {
"$ref": "#/definitions/non-empty-string"
},
":e2ee": {
"type": "object",
"properties": {
":enabled": {
"type": "boolean"
},
":backend": {
"type": "string"
},
":db": {
"type": "string"
},
":encryption_passphrase": {
"type": "string"
}
}
}
}
}

129
e2ee/fetch.go Normal file
View file

@ -0,0 +1,129 @@
package e2ee
import (
"context"
"encoding/xml"
"fmt"
"time"
"gosrc.io/xmpp"
"gosrc.io/xmpp/stanza"
)
// FetchTimeout bounds how long a single PEP items request may take before
// giving up - telegabber is a background bridge relaying Telegram traffic,
// so a slow or unresponsive remote pubsub service must not stall the
// outbound message path indefinitely.
const FetchTimeout = 15 * time.Second
// FetchAndIngestPeer fetches peer's device-list and every not-yet-known
// device's bundle over PEP - using the already-established
// xmpp.Component.SendIQ IQ-result routing, not a new transport mechanism -
// and ingests them into backend under owner's identity. It is the
// generic, backend-agnostic counterpart to the xmpp package's PEP-serving
// IQ handlers (handleGetOMEMODeviceListIq/handleGetOMEMOBundleIq): driven
// entirely by whatever PEP node names backend itself reports via
// DeviceListNode/BundleNode, it has no OMEMO-specific knowledge of its own,
// so it works unchanged for any future Backend implementation.
func FetchAndIngestPeer(component *xmpp.Component, backend Backend, owner PeerID, peer PeerID) error {
_, ownerBareJID, ok := SplitOwnedPeer(owner)
if !ok {
return fmt.Errorf("e2ee: FetchAndIngestPeer: %q is not an OwnedPeer", owner)
}
// Fetch under owner's currently negotiated variant (DefaultVariant for
// a brand new chat) - that's what Encrypt will actually produce, so
// there's no benefit to establishing a session under a different one.
variant, err := backend.NegotiatedVariant(owner)
if err != nil {
return fmt.Errorf("e2ee: NegotiatedVariant: %w", err)
}
deviceListItem, err := fetchOneItem(component, ownerBareJID, string(peer), backend.DeviceListNode(variant))
if err != nil {
return fmt.Errorf("e2ee: fetch device list for %s: %w", peer, err)
}
raw, err := xml.Marshal(deviceListItem)
if err != nil {
return fmt.Errorf("e2ee: marshal device list for %s: %w", peer, err)
}
deviceIDs, err := backend.IngestRemoteDeviceList(owner, peer, DeviceListDoc{Raw: raw})
if err != nil {
return fmt.Errorf("e2ee: IngestRemoteDeviceList(%s): %w", peer, err)
}
// Only devices we don't already have a session for need their bundle
// fetched - re-processing an existing device's bundle would rebuild its
// session from scratch, discarding live ratchet state for no reason.
known := map[DeviceID]bool{}
if existing, err := backend.Devices(owner, peer); err == nil {
for _, d := range existing {
known[d.ID] = true
}
}
var lastErr error
fetchedAny := false
for _, id := range deviceIDs {
if known[id] {
continue
}
bundleItem, err := fetchOneItem(component, ownerBareJID, string(peer), backend.BundleNode(variant, id))
if err != nil {
lastErr = fmt.Errorf("e2ee: fetch bundle for %s:%s: %w", peer, id, err)
continue
}
bundleRaw, err := xml.Marshal(bundleItem)
if err != nil {
lastErr = fmt.Errorf("e2ee: marshal bundle for %s:%s: %w", peer, id, err)
continue
}
if err := backend.IngestRemoteBundle(owner, peer, id, BundleDoc{Raw: bundleRaw}); err != nil {
lastErr = fmt.Errorf("e2ee: IngestRemoteBundle(%s:%s): %w", peer, id, err)
continue
}
fetchedAny = true
}
if !fetchedAny && lastErr != nil {
return lastErr
}
return nil
}
// fetchOneItem requests the single most recent item of node from jid's PEP
// service, sent as if from ownerBareJID (the bridged chat's pseudo-JID doing
// the fetching), and returns its payload node.
func fetchOneItem(component *xmpp.Component, ownerBareJID, jid, node string) (*stanza.Node, error) {
iq, err := stanza.NewItemsRequest(jid, node, 1)
if err != nil {
return nil, err
}
iq.Attrs.From = ownerBareJID
ctx, cancel := context.WithTimeout(context.Background(), FetchTimeout)
defer cancel()
ch, err := component.SendIQ(ctx, iq)
if err != nil {
return nil, err
}
select {
case result := <-ch:
if result.Type == stanza.IQTypeError {
return nil, fmt.Errorf("e2ee: %s returned an error for node %s", jid, node)
}
pubsub, ok := result.Payload.(*stanza.PubSubGeneric)
if !ok || pubsub.Items == nil || len(pubsub.Items.List) == 0 {
return nil, fmt.Errorf("e2ee: %s returned no items for node %s", jid, node)
}
item := pubsub.Items.List[0]
if item.Any == nil {
return nil, fmt.Errorf("e2ee: %s returned an empty item for node %s", jid, node)
}
return item.Any, nil
case <-ctx.Done():
return nil, fmt.Errorf("e2ee: timed out fetching node %s from %s", node, jid)
}
}

View file

@ -1,32 +1,24 @@
package e2ee
import "fmt"
// Manager holds the process-wide selected E2EE backend (or none, if E2EE
// is disabled), reachable from both the xmpp and telegram packages without
// either importing the other's e2ee-specific glue.
// either importing the other's e2ee-specific glue (see gateway.E2EE, set by
// xmpp.NewComponent, which both packages already import).
type Manager struct {
backend Backend
}
// NewManager selects a backend by name from the registry. An empty name
// means E2EE is disabled: the returned Manager's Backend() always reports
// ok=false, and every call site is expected to treat that as "behave
// exactly as if this feature didn't exist".
func NewManager(name string) (*Manager, error) {
if name == "" {
return &Manager{}, nil
}
b, ok := Get(name)
if !ok {
return nil, fmt.Errorf("e2ee: unknown backend %q (forgot to import its package for its init() side effect?)", name)
}
return &Manager{backend: b}, nil
// NewManager wraps backend for process-wide access. Pass nil to represent
// E2EE being disabled - every call site is expected to treat that the same
// as "behave exactly as if this feature didn't exist" (see Backend's ok
// return).
func NewManager(backend Backend) *Manager {
return &Manager{backend: backend}
}
// Backend returns the selected backend. ok is false if m is nil or no
// backend was selected (E2EE disabled) - callers should treat a nil
// Manager the same as one built via NewManager("").
// Backend returns the selected backend. ok is false if m is nil or backend
// was nil (E2EE disabled) - callers should treat a nil Manager the same as
// one built via NewManager(nil).
func (m *Manager) Backend() (backend Backend, ok bool) {
if m == nil || m.backend == nil {
return nil, false

View file

@ -10,7 +10,7 @@ import (
"dev.narayana.im/narayana/telegabber/e2ee/store/badgerstore"
)
func newTestBackend(t *testing.T, version omemo.Version) *omemo.Backend {
func newTestBackend(t *testing.T) *omemo.Backend {
t.Helper()
ctx, err := libsignal.NewContext()
@ -25,102 +25,143 @@ func newTestBackend(t *testing.T, version omemo.Version) *omemo.Backend {
}
t.Cleanup(func() { db.Close() })
return omemo.New(ctx, db, version)
return omemo.New(ctx, db)
}
// TestBackendRoundTrip exercises the full e2ee.Backend interface, not just
// the underlying libsignal primitives (already covered by libsignal's own
// round-trip test): EnsureIdentity, publishing a device-list/bundle,
// ingesting them on the other side, and Encrypt/Decrypt in both
// directions. This simulates two independent parties - "gateway" (the
// bridged chat pseudo-JID's owned identity) and "client" (the real XMPP
// user's own device) - each with their own Backend/store, exchanging
// PublishedIdentity/PublishedBundle results directly in place of the real
// PEP fetch (e2ee/fetch.go's actual IQ round trip isn't built yet).
// ingesting them on the other side, Encrypt/Decrypt in both directions, and
// per-chat version negotiation (defaults to Omemo0, then tracks whatever
// variant the chat's very first session actually gets established at - see
// e2ee.Backend.NegotiatedVariant's doc comment). Each variant is tested with
// its own fresh pair of identities/sessions rather than trying to "upgrade"
// one shared chat mid-test - see testBackendRoundTripAtVariant's own doc
// comment for why that's the only valid way to exercise a non-default
// variant with this crypto library.
func TestBackendRoundTrip(t *testing.T) {
const gatewayLogin = "telegram-login-1"
const gatewayBareJID = "12345@transport.example"
const realUserBareJID = "alice@real.example"
t.Run("omemo0 (default)", func(t *testing.T) {
testBackendRoundTripAtVariant(t, "telegram-login-1", "12345@transport.example", "alice@real.example", e2ee.Variant(omemo.Omemo0.String()))
})
for _, tc := range []struct {
name string
version omemo.Version
}{
{"omemo0", omemo.Omemo0},
{"omemo1", omemo.Omemo1},
{"omemo2", omemo.Omemo2},
} {
t.Run(tc.name, func(t *testing.T) {
if tc.version != omemo.Omemo0 && !libsignal.ProtocolV4Supported {
t.Skip("protocol v4 (omemo1/omemo2) not supported by this build (signal_legacy tag)")
}
t.Run("omemo2 (fresh chat established directly at a non-default variant)", func(t *testing.T) {
if !libsignal.ProtocolV4Supported {
t.Skip("protocol v4 (omemo1/omemo2) not supported by this build (signal_legacy tag)")
}
testBackendRoundTripAtVariant(t, "telegram-login-2", "67890@transport.example", "bob@real.example", e2ee.Variant(omemo.Omemo2.String()))
})
}
gateway := newTestBackend(t, tc.version)
client := newTestBackend(t, tc.version)
// testBackendRoundTripAtVariant simulates two independent parties -
// "gateway" (the bridged chat pseudo-JID's owned identity) and "client"
// (the real XMPP user's own device) - exchanging PublishedIdentity/
// PublishedBundle results directly in place of the real PEP fetch
// (e2ee/fetch.go's actual IQ round trip isn't exercised here, only
// Backend's own contract), with their very first session established at
// variant.
//
// This can only test a variant from a chat's FIRST-EVER message, not by
// switching an EXISTING session to a different variant mid-test: reading
// libomemo-c's session_builder.c and ratchet.c directly shows that
// session_builder_process_pre_key_bundle/process_pre_key_signal_message
// always archive the OLD session state and have the new one INHERIT its
// version (session_record_archive_current_state calls
// session_state_set_session_version(new_state, session_record_get_version(record))
// using the OLD record's version) whenever a session record already
// exists for that address - only a genuinely fresh (never-before-seen)
// address gets its version set from what's actually requested
// (signal_protocol_session_load_session's "record didn't exist yet"
// branch calls session_record_set_version with the requested version).
// Concretely: encrypting/decrypting with a NEW protocol version against an
// address that already has a session silently keeps using the OLD
// session's version regardless of what's requested - this is what
// produced a "SessionCipher.Decrypt: ... invalid protobuf" failure the
// first time this test tried exactly that. See the matching note on
// e2ee.Backend.NegotiatedVariant: telegabber does not yet support
// re-negotiating an EXISTING chat to a different variant (that would
// require explicitly deleting the old session first) - only the
// auto-upgrade-from-nothing case (this test) is implemented.
func testBackendRoundTripAtVariant(t *testing.T, gatewayLogin, gatewayBareJID, realUserBareJID string, variant e2ee.Variant) {
t.Helper()
gatewayOwned := e2ee.OwnedPeer(gatewayLogin, gatewayBareJID)
clientOwned := e2ee.OwnedPeer("n/a", realUserBareJID)
realUser := e2ee.PeerID(realUserBareJID) // how the gateway refers to the real user
gatewayAsPeer := e2ee.PeerID(gatewayBareJID) // how the client refers to the gateway
gateway := newTestBackend(t)
client := newTestBackend(t)
if err := gateway.EnsureIdentity(gatewayOwned); err != nil {
t.Fatalf("gateway.EnsureIdentity: %v", err)
}
if err := client.EnsureIdentity(clientOwned); err != nil {
t.Fatalf("client.EnsureIdentity: %v", err)
}
gatewayOwned := e2ee.OwnedPeer(gatewayLogin, gatewayBareJID)
clientOwned := e2ee.OwnedPeer("n/a", realUserBareJID)
realUser := e2ee.PeerID(realUserBareJID) // how the gateway refers to the real user
gatewayAsPeer := e2ee.PeerID(gatewayBareJID) // how the client refers to the gateway
// Gateway "fetches" the client's device list + bundle.
clientDeviceList, err := client.PublishedIdentity(clientOwned)
if err != nil {
t.Fatalf("client.PublishedIdentity: %v", err)
}
if err := gateway.IngestRemoteDeviceList(gatewayOwned, realUser, clientDeviceList); err != nil {
t.Fatalf("gateway.IngestRemoteDeviceList: %v", err)
}
clientBundle, err := client.PublishedBundle(clientOwned, omemo.OwnDeviceID)
if err != nil {
t.Fatalf("client.PublishedBundle: %v", err)
}
if err := gateway.IngestRemoteBundle(gatewayOwned, realUser, omemo.OwnDeviceID, clientBundle); err != nil {
t.Fatalf("gateway.IngestRemoteBundle: %v", err)
}
if err := gateway.EnsureIdentity(gatewayOwned); err != nil {
t.Fatalf("gateway.EnsureIdentity: %v", err)
}
if err := client.EnsureIdentity(clientOwned); err != nil {
t.Fatalf("client.EnsureIdentity: %v", err)
}
// Gateway encrypts a message originating from Telegram.
plaintext1 := []byte("hello from telegram")
env1, err := gateway.Encrypt(gatewayOwned, []e2ee.PeerID{realUser}, plaintext1)
if err != nil {
t.Fatalf("gateway.Encrypt: %v", err)
}
if v, err := gateway.NegotiatedVariant(gatewayOwned); err != nil || v != gateway.DefaultVariant() {
t.Fatalf("expected a fresh chat to default to %v, got %v (err %v)", gateway.DefaultVariant(), v, err)
}
// Client decrypts it - this establishes its side of the
// session as a side effect, with no prior bundle fetch needed
// on its end (matching the prekey-message responder flow
// already validated at the libsignal level).
got1, err := client.Decrypt(gatewayAsPeer, clientOwned, env1)
if err != nil {
t.Fatalf("client.Decrypt: %v", err)
}
if !bytes.Equal(got1, plaintext1) {
t.Fatalf("round trip mismatch: got %q, want %q", got1, plaintext1)
}
// Gateway "fetches" the client's device list + bundle, under variant.
clientDeviceList, err := client.PublishedIdentity(clientOwned, variant)
if err != nil {
t.Fatalf("client.PublishedIdentity: %v", err)
}
gotDeviceIDs, err := gateway.IngestRemoteDeviceList(gatewayOwned, realUser, clientDeviceList)
if err != nil {
t.Fatalf("gateway.IngestRemoteDeviceList: %v", err)
}
if len(gotDeviceIDs) != 1 || gotDeviceIDs[0] != omemo.OwnDeviceID {
t.Fatalf("expected device list to contain only %v, got %v", omemo.OwnDeviceID, gotDeviceIDs)
}
clientBundle, err := client.PublishedBundle(clientOwned, omemo.OwnDeviceID, variant)
if err != nil {
t.Fatalf("client.PublishedBundle: %v", err)
}
if err := gateway.IngestRemoteBundle(gatewayOwned, realUser, omemo.OwnDeviceID, clientBundle); err != nil {
t.Fatalf("gateway.IngestRemoteBundle: %v", err)
}
if v, err := gateway.NegotiatedVariant(gatewayOwned); err != nil || v != variant {
t.Fatalf("expected gateway to have negotiated %v after ingesting its bundle, got %v (err %v)", variant, v, err)
}
// Client replies. It needs no explicit IngestRemoteBundle call
// for the gateway's device - the session established during
// the decrypt above already covers it.
plaintext2 := []byte("hi telegram")
env2, err := client.Encrypt(clientOwned, []e2ee.PeerID{gatewayAsPeer}, plaintext2)
if err != nil {
t.Fatalf("client.Encrypt: %v", err)
}
// Gateway encrypts a message originating from Telegram.
plaintext1 := []byte("hello from telegram")
env1, err := gateway.Encrypt(gatewayOwned, []e2ee.PeerID{realUser}, plaintext1)
if err != nil {
t.Fatalf("gateway.Encrypt: %v", err)
}
got2, err := gateway.Decrypt(realUser, gatewayOwned, env2)
if err != nil {
t.Fatalf("gateway.Decrypt: %v", err)
}
if !bytes.Equal(got2, plaintext2) {
t.Fatalf("round trip mismatch: got %q, want %q", got2, plaintext2)
}
})
// Client decrypts it - this establishes its side of the session as a
// side effect, with no prior bundle fetch needed on its end (matching
// the prekey-message responder flow already validated at the libsignal
// level), and records variant as negotiated too.
got1, err := client.Decrypt(gatewayAsPeer, clientOwned, env1)
if err != nil {
t.Fatalf("client.Decrypt: %v", err)
}
if !bytes.Equal(got1, plaintext1) {
t.Fatalf("round trip mismatch: got %q, want %q", got1, plaintext1)
}
if v, err := client.NegotiatedVariant(clientOwned); err != nil || v != variant {
t.Fatalf("expected client to have negotiated %v after decrypting its first message, got %v (err %v)", variant, v, err)
}
// Client replies. It needs no explicit IngestRemoteBundle call for the
// gateway's device - the session established during the decrypt above
// already covers it.
plaintext2 := []byte("hi telegram")
env2, err := client.Encrypt(clientOwned, []e2ee.PeerID{gatewayAsPeer}, plaintext2)
if err != nil {
t.Fatalf("client.Encrypt: %v", err)
}
got2, err := gateway.Decrypt(realUser, gatewayOwned, env2)
if err != nil {
t.Fatalf("gateway.Decrypt: %v", err)
}
if !bytes.Equal(got2, plaintext2) {
t.Fatalf("round trip mismatch: got %q, want %q", got2, plaintext2)
}
}

View file

@ -69,6 +69,7 @@ type xepPreKeysWrap struct {
// the three bundle formats, in the raw (still base64-decoded) bytes
// libsignal expects.
type parsedBundle struct {
Version Version // which of the three OMEMO versions this bundle was actually fetched/published under - determines both which libsignal protocol version (V3 vs V4) a session built from it must use, AND (Omemo1 vs Omemo2 specifically) what to record as the chat's negotiated version, since that's a property of the bundle actually fetched, not of this backend's own prior state (see IngestRemoteBundle)
SignedPreKeyID uint32
SignedPreKeyPublic []byte
SignedPreKeySignature []byte
@ -109,6 +110,7 @@ func decodeOmemo0Bundle(b omemo0Bundle) (*parsedBundle, error) {
}
result := &parsedBundle{
Version: Omemo0,
SignedPreKeyID: b.SignedPreKeyPublic.ID,
SignedPreKeyPublic: spk,
SignedPreKeySignature: sig,
@ -138,7 +140,12 @@ func decodeXepBundle(b xepBundle) (*parsedBundle, error) {
return nil, fmt.Errorf("omemo: bundle ik: %w", err)
}
version := Omemo2
if b.XMLName.Space == omemo1NS {
version = Omemo1
}
result := &parsedBundle{
Version: version,
SignedPreKeyID: b.SPK.ID,
SignedPreKeyPublic: spk,
SignedPreKeySignature: sig,

View file

@ -19,9 +19,16 @@ func (b *Backend) Encrypt(from e2ee.PeerID, to []e2ee.PeerID, plaintext []byte)
store := b.db.Store(b.libctx, login, owner)
// Which variant to actually encrypt as is negotiated per chat, not
// fixed on the backend - see negotiatedVersion's doc comment.
version, err := b.negotiatedVersion(store)
if err != nil {
return e2ee.Envelope{}, fmt.Errorf("omemo: negotiatedVersion: %w", err)
}
// Outer content encryption happens once per message, regardless of how
// many recipient devices it ends up going to - see envelope.go.
payload, err := EncryptOuter(b.version, plaintext, owner)
payload, err := EncryptOuter(version, plaintext, owner)
if err != nil {
return e2ee.Envelope{}, fmt.Errorf("omemo: EncryptOuter: %w", err)
}
@ -33,7 +40,7 @@ func (b *Backend) Encrypt(from e2ee.PeerID, to []e2ee.PeerID, plaintext []byte)
defer storeCtx.Close()
env := &WireEnvelope{
Version: b.version,
Version: version,
SenderSID: ownDeviceIDNum,
IV: payload.IV,
Payload: payload.Ciphertext,
@ -49,7 +56,7 @@ func (b *Backend) Encrypt(from e2ee.PeerID, to []e2ee.PeerID, plaintext []byte)
}
for _, deviceID := range deviceIDs {
cipher, err := libsignal.NewSessionCipher(b.libctx, storeCtx, libsignal.Address{Name: string(peer), DeviceID: deviceID}, b.version.libsignalVersion())
cipher, err := libsignal.NewSessionCipher(b.libctx, storeCtx, libsignal.Address{Name: string(peer), DeviceID: deviceID}, version.libsignalVersion())
if err != nil {
return e2ee.Envelope{}, fmt.Errorf("omemo: NewSessionCipher(%s:%d): %w", peer, deviceID, err)
}
@ -115,12 +122,11 @@ func (b *Backend) Decrypt(from, to e2ee.PeerID, env e2ee.Envelope) ([]byte, erro
defer storeCtx.Close()
// The cipher's configured version must match what the SENDER actually
// used (wireEnv.Version, derived from which XML namespace their
// stanza used), not this backend's own outgoing preference (b.version)
// - a remote peer's message version isn't guaranteed to match ours,
// and this is the one case telegabber's own M1 round-trip test never
// exercised (both sides there always shared the same configured
// version), so this is deliberately conservative rather than assumed.
// used (wireEnv.Version, derived from which XML namespace their stanza
// used) - a remote peer's message version isn't guaranteed to match
// whatever we've last negotiated for outgoing content (see
// negotiatedVersion), so this is deliberately read straight from the
// envelope rather than assumed.
cipher, err := libsignal.NewSessionCipher(b.libctx, storeCtx, libsignal.Address{Name: string(from), DeviceID: wireEnv.SenderSID}, wireEnv.Version.libsignalVersion())
if err != nil {
return nil, fmt.Errorf("omemo: NewSessionCipher: %w", err)
@ -145,5 +151,19 @@ func (b *Backend) Decrypt(from, to e2ee.PeerID, env e2ee.Envelope) ([]byte, erro
if err != nil {
return nil, fmt.Errorf("omemo: DecryptOuter: %w", err)
}
// A successful decrypt is the negotiation signal: "to"'s outgoing
// content should now use whatever variant this message actually used.
// This only actually changes anything the first time a session is
// established for "from" (a regular message on an existing session can
// only ever arrive at wireEnv.Version already matching that session's
// own stored version - libomemo-c rejects anything else outright - so
// this is a harmless no-op then); see the KNOWN LIMITATION on
// e2ee.Backend.NegotiatedVariant's doc comment for why an
// already-established chat can't actually be moved to a different
// variant later.
if err := store.SetNegotiatedVersion(byte(wireEnv.Version)); err != nil {
return nil, fmt.Errorf("omemo: SetNegotiatedVersion: %w", err)
}
return plaintext, nil
}

View file

@ -34,11 +34,17 @@ const ownDeviceIDNum uint32 = 1
// need to reference "the" device of an owned identity (e.g. PublishedBundle).
var OwnDeviceID = e2ee.DeviceID(strconv.FormatUint(uint64(ownDeviceIDNum), 10))
// Backend is the OMEMO (XEP-0384) implementation of e2ee.Backend.
// Backend is the OMEMO (XEP-0384) implementation of e2ee.Backend. It always
// supports every Version this build is capable of (see Variants) - a
// single identity/prekey set serves all of them simultaneously (XEP-0384's
// signed prekeys carry both a legacy and an OMEMO signature precisely so
// this works, see libsignal.SignedPreKeyInfo) - rather than being
// configured for one fixed version. Which version is actually used for a
// given chat's outgoing content is negotiated per chat (see
// NegotiatedVariant/negotiatedVersion), not chosen at construction time.
type Backend struct {
libctx *libsignal.Context
db *badgerstore.DB
version Version // which version this backend produces for new outgoing content; incoming messages of any of the three versions are always accepted, decoded per-message
libctx *libsignal.Context
db *badgerstore.DB
// libsignal is not internally thread-safe (see its package doc) - one
// mutex serializes every call into it, across every identity this
@ -48,34 +54,134 @@ type Backend struct {
mu sync.Mutex
}
// New creates an OMEMO backend backed by db, producing new outgoing
// content in the given version (Omemo0, Omemo1, or Omemo2 - see version.go).
func New(libctx *libsignal.Context, db *badgerstore.DB, version Version) *Backend {
return &Backend{libctx: libctx, db: db, version: version}
// New creates an OMEMO backend backed by db.
func New(libctx *libsignal.Context, db *badgerstore.DB) *Backend {
return &Backend{libctx: libctx, db: db}
}
var _ e2ee.Backend = (*Backend)(nil)
func (b *Backend) Name() string { return Name }
// Close implements e2ee.Backend.
func (b *Backend) Close() error { return b.db.Close() }
func (b *Backend) Namespaces() e2ee.Namespaces {
switch b.version {
case Omemo0:
return e2ee.Namespaces{
Disco: []string{omemo0NS + ".devicelist+notify"},
EME: omemo0NS,
}
case Omemo1:
return e2ee.Namespaces{
Disco: []string{omemo1NS + ":devices+notify"},
EME: omemo1NS,
}
default:
return e2ee.Namespaces{
Disco: []string{omemo2NS + ":devices+notify"},
EME: omemo2NS,
}
disco := []string{omemo0NS + ".devicelist+notify"}
if libsignal.ProtocolV4Supported {
disco = append(disco, omemo1NS+":devices+notify", omemo2NS+":devices+notify")
}
return e2ee.Namespaces{Disco: disco}
}
// Variants implements e2ee.Backend. Omemo1/Omemo2 (both libsignal.ProtocolVersionV4)
// are only listed when this build's crypto library actually supports
// protocol v4 (see libsignal.ProtocolV4Supported - false for the
// signal_legacy build tag, vanilla libsignal-protocol-c).
func (b *Backend) Variants() []e2ee.Variant {
variants := []e2ee.Variant{e2ee.Variant(Omemo0.String())}
if libsignal.ProtocolV4Supported {
variants = append(variants, e2ee.Variant(Omemo1.String()), e2ee.Variant(Omemo2.String()))
}
return variants
}
// DefaultVariant implements e2ee.Backend: Omemo0, the most widely deployed
// version, is always what a brand new chat starts out using.
func (b *Backend) DefaultVariant() e2ee.Variant {
return e2ee.Variant(Omemo0.String())
}
// NegotiatedVariant implements e2ee.Backend.
//
// KNOWN LIMITATION: this only ever reflects whatever variant a chat's
// session was ESTABLISHED at (via IngestRemoteBundle, or reactively via
// Decrypt processing a peer's first PreKeyMessage) - there is currently no
// way to re-negotiate an EXISTING chat to a different variant later.
// Reading libomemo-c's session_builder.c/ratchet.c directly shows that
// session_builder_process_pre_key_bundle/process_pre_key_signal_message
// always archive the OLD session state and have the replacement INHERIT
// its version (session_record_archive_current_state literally copies
// session_record_get_version(record) onto the fresh state) whenever a
// session record already exists for that address - only a genuinely fresh
// (never-before-seen) address gets its version set from what's actually
// requested. So encrypting/decrypting with a new variant against an
// address that already has a session silently keeps using the OLD
// session's version regardless of what's asked for - it does not, and
// cannot, upgrade in place. A future enhancement wanting real
// re-negotiation would need to explicitly delete the existing session
// (Store.DeleteSession/DeleteAllSessions) before re-ingesting a bundle
// under the new variant.
func (b *Backend) NegotiatedVariant(owner e2ee.PeerID) (e2ee.Variant, error) {
login, ownerJID, ok := e2ee.SplitOwnedPeer(owner)
if !ok {
return "", fmt.Errorf("omemo: NegotiatedVariant: %q is not an OwnedPeer", owner)
}
b.mu.Lock()
defer b.mu.Unlock()
version, err := b.negotiatedVersion(b.db.Store(b.libctx, login, ownerJID))
if err != nil {
return "", err
}
return e2ee.Variant(version.String()), nil
}
// negotiatedVersion is NegotiatedVariant's internal counterpart, returning
// a Version directly for Encrypt's own use - store must already be scoped
// to the right (login, owner) and b.mu must already be held.
func (b *Backend) negotiatedVersion(store *badgerstore.Store) (Version, error) {
raw, ok, err := store.NegotiatedVersion()
if err != nil {
return 0, err
}
if !ok {
return Omemo0, nil
}
return Version(raw), nil
}
// DeviceListNode implements e2ee.Backend. Node names follow each version's
// own established convention: Omemo0 uses siacs' original
// "<ns>.devicelist"; Omemo1/Omemo2 use XEP-0384's "<ns>:devices" (confirmed
// against the archived 0.4.0 spec text directly, not assumed).
func (b *Backend) DeviceListNode(variant e2ee.Variant) string {
version, err := ParseVersion(string(variant))
if err != nil {
return ""
}
switch version {
case Omemo0:
return omemo0NS + ".devicelist"
case Omemo1:
return omemo1NS + ":devices"
default:
return omemo2NS + ":devices"
}
}
// BundleNode implements e2ee.Backend. Omemo0 addresses each device's bundle
// under its own node ("<ns>.bundles:<device>"); Omemo1/Omemo2 instead use
// one shared node per XEP-0384 0.4.0's bundle-storage redesign ("Each
// bundle MUST be stored in a se[p]arate item. The item id MUST be set to
// the device id.") - confirmed against the archived 0.4.0 spec text, not
// assumed. Either way, e2ee/fetch.go only ever needs the most recent item
// on whatever node this returns, so callers don't need to know which shape
// applies.
func (b *Backend) BundleNode(variant e2ee.Variant, device e2ee.DeviceID) string {
version, err := ParseVersion(string(variant))
if err != nil {
return ""
}
if version == Omemo0 {
return fmt.Sprintf("%s.bundles:%s", omemo0NS, device)
}
ns := omemo1NS
if version == Omemo2 {
ns = omemo2NS
}
return ns + ":bundles"
}
func (b *Backend) EnsureIdentity(peer e2ee.PeerID) error {
@ -138,11 +244,15 @@ func (b *Backend) EnsureIdentity(peer e2ee.PeerID) error {
return nil
}
func (b *Backend) PublishedIdentity(peer e2ee.PeerID) (e2ee.DeviceListDoc, error) {
func (b *Backend) PublishedIdentity(peer e2ee.PeerID, variant e2ee.Variant) (e2ee.DeviceListDoc, error) {
login, owner, ok := e2ee.SplitOwnedPeer(peer)
if !ok {
return e2ee.DeviceListDoc{}, fmt.Errorf("omemo: PublishedIdentity: %q is not an OwnedPeer", peer)
}
version, err := ParseVersion(string(variant))
if err != nil {
return e2ee.DeviceListDoc{}, fmt.Errorf("omemo: PublishedIdentity: %w", err)
}
b.mu.Lock()
defer b.mu.Unlock()
@ -154,14 +264,14 @@ func (b *Backend) PublishedIdentity(peer e2ee.PeerID) (e2ee.DeviceListDoc, error
return e2ee.DeviceListDoc{}, errors.New("omemo: PublishedIdentity: EnsureIdentity was not called")
}
raw, err := encodeDeviceList(b.version, ownDeviceIDNum)
raw, err := encodeDeviceList(version, ownDeviceIDNum)
if err != nil {
return e2ee.DeviceListDoc{}, err
}
return e2ee.DeviceListDoc{Raw: raw}, nil
}
func (b *Backend) PublishedBundle(peer e2ee.PeerID, device e2ee.DeviceID) (e2ee.BundleDoc, error) {
func (b *Backend) PublishedBundle(peer e2ee.PeerID, device e2ee.DeviceID, variant e2ee.Variant) (e2ee.BundleDoc, error) {
login, owner, ok := e2ee.SplitOwnedPeer(peer)
if !ok {
return e2ee.BundleDoc{}, fmt.Errorf("omemo: PublishedBundle: %q is not an OwnedPeer", peer)
@ -169,6 +279,10 @@ func (b *Backend) PublishedBundle(peer e2ee.PeerID, device e2ee.DeviceID) (e2ee.
if device != OwnDeviceID {
return e2ee.BundleDoc{}, fmt.Errorf("omemo: PublishedBundle: unknown device %q", device)
}
version, err := ParseVersion(string(variant))
if err != nil {
return e2ee.BundleDoc{}, fmt.Errorf("omemo: PublishedBundle: %w", err)
}
b.mu.Lock()
defer b.mu.Unlock()
@ -195,12 +309,11 @@ func (b *Backend) PublishedBundle(peer e2ee.PeerID, device e2ee.DeviceID) (e2ee.
}
// See the rule documented on libsignal.SignedPreKeyInfo: the signature
// must match the serialization form the verifier will re-derive, which
// depends on the recipient's protocol version (Omemo0 -> legacy form,
// Omemo1/Omemo2 -> OMEMO form, since both share libsignal.ProtocolVersionV4)
// - but since we only publish one bundle version at a time (this
// backend's own configured version), that's the one to use here too.
// depends on the requested variant (Omemo0 -> legacy form, Omemo1/Omemo2
// -> OMEMO form, since both share libsignal.ProtocolVersionV4) - the
// same signed prekey serves every variant, just re-signed per request.
signature := spkInfo.Signature
if b.version != Omemo0 {
if version != Omemo0 {
signature = spkInfo.SignatureOMEMO
}
@ -224,27 +337,36 @@ func (b *Backend) PublishedBundle(peer e2ee.PeerID, device e2ee.DeviceID) (e2ee.
preKeys = append(preKeys, *info)
}
raw, err := encodeBundle(b.version, identityPublic, spkInfo.ID, spkInfo.PublicKey, signature, preKeys)
raw, err := encodeBundle(version, identityPublic, spkInfo.ID, spkInfo.PublicKey, signature, preKeys)
if err != nil {
return e2ee.BundleDoc{}, err
}
return e2ee.BundleDoc{Raw: raw}, nil
}
func (b *Backend) IngestRemoteDeviceList(owner, peer e2ee.PeerID, doc e2ee.DeviceListDoc) error {
func (b *Backend) IngestRemoteDeviceList(owner, peer e2ee.PeerID, doc e2ee.DeviceListDoc) ([]e2ee.DeviceID, error) {
login, ownerJID, ok := e2ee.SplitOwnedPeer(owner)
if !ok {
return fmt.Errorf("omemo: IngestRemoteDeviceList: %q is not an OwnedPeer", owner)
return nil, fmt.Errorf("omemo: IngestRemoteDeviceList: %q is not an OwnedPeer", owner)
}
if _, err := parseDeviceList(doc.Raw); err != nil {
return fmt.Errorf("omemo: parseDeviceList: %w", err)
ids, err := parseDeviceList(doc.Raw)
if err != nil {
return nil, fmt.Errorf("omemo: parseDeviceList: %w", err)
}
b.mu.Lock()
defer b.mu.Unlock()
store := b.db.Store(b.libctx, login, ownerJID)
return store.SaveRemoteDeviceListCache(string(peer), doc.Raw)
if err := store.SaveRemoteDeviceListCache(string(peer), doc.Raw); err != nil {
return nil, err
}
deviceIDs := make([]e2ee.DeviceID, len(ids))
for i, id := range ids {
deviceIDs[i] = e2ee.DeviceID(strconv.FormatUint(uint64(id), 10))
}
return deviceIDs, nil
}
func (b *Backend) IngestRemoteBundle(owner, peer e2ee.PeerID, device e2ee.DeviceID, doc e2ee.BundleDoc) error {
@ -282,7 +404,13 @@ func (b *Backend) IngestRemoteBundle(owner, peer e2ee.PeerID, device e2ee.Device
}
defer storeCtx.Close()
builder, err := libsignal.NewSessionBuilder(b.libctx, storeCtx, libsignal.Address{Name: string(peer), DeviceID: deviceID}, b.version.libsignalVersion())
// The session's protocol version must match what the fetched bundle
// itself actually is (parsed.Version), derived from doc.Raw's own shape
// rather than assumed from whatever variant fetch.go asked for -
// picking the wrong one here fails bundle signature verification with
// SG_ERR_INVALID_KEY (the same class of bug already hit and fixed once
// for PublishedBundle/Decrypt - see their comments).
builder, err := libsignal.NewSessionBuilder(b.libctx, storeCtx, libsignal.Address{Name: string(peer), DeviceID: deviceID}, parsed.Version.libsignalVersion())
if err != nil {
return fmt.Errorf("omemo: NewSessionBuilder: %w", err)
}
@ -304,6 +432,22 @@ func (b *Backend) IngestRemoteBundle(owner, peer e2ee.PeerID, device e2ee.Device
if err != nil {
return fmt.Errorf("omemo: ProcessPreKeyBundle: %w", err)
}
// Keep the chat's negotiated version in sync with whatever session we
// just established/replaced - libomemo-c's session_cipher_encrypt
// always serializes using the session's own stored version
// (session_state_get_session_version), NOT whatever version Encrypt
// asks for, so a stale negotiated-version record here would make
// Encrypt produce a WireEnvelope whose declared Version doesn't match
// the ACTUAL bytes libsignal ends up emitting - confirmed by reading
// session_cipher.c's session_cipher_encrypt/decrypt directly, not
// assumed (this is exactly the SG_ERR_INVALID_PROTO_BUF failure the
// old, purely Decrypt-driven negotiation tracking could hit as soon as
// a chat's session got established/replaced at a version other than
// its previously-recorded negotiated one).
if err := store.SetNegotiatedVersion(byte(parsed.Version)); err != nil {
return fmt.Errorf("omemo: SetNegotiatedVersion: %w", err)
}
return nil
}
@ -333,6 +477,30 @@ func (b *Backend) Devices(owner, peer e2ee.PeerID) ([]e2ee.DeviceInfo, error) {
return infos, nil
}
func (b *Backend) Enabled(owner e2ee.PeerID) (bool, error) {
login, ownerJID, ok := e2ee.SplitOwnedPeer(owner)
if !ok {
return false, fmt.Errorf("omemo: Enabled: %q is not an OwnedPeer", owner)
}
b.mu.Lock()
defer b.mu.Unlock()
return b.db.Store(b.libctx, login, ownerJID).Enabled()
}
func (b *Backend) SetEnabled(owner e2ee.PeerID, enabled bool) error {
login, ownerJID, ok := e2ee.SplitOwnedPeer(owner)
if !ok {
return fmt.Errorf("omemo: SetEnabled: %q is not an OwnedPeer", owner)
}
b.mu.Lock()
defer b.mu.Unlock()
return b.db.Store(b.libctx, login, ownerJID).SetEnabled(enabled)
}
func parseDeviceID(device e2ee.DeviceID) (uint32, error) {
id, err := strconv.ParseUint(string(device), 10, 32)
if err != nil {

View file

@ -1,6 +1,10 @@
package omemo
import "dev.narayana.im/narayana/telegabber/e2ee/omemo/libsignal"
import (
"fmt"
"dev.narayana.im/narayana/telegabber/e2ee/omemo/libsignal"
)
// Version identifies which OMEMO wire-format generation a message, device
// list, or bundle uses - the full namespace+XML dialect, not just the
@ -46,6 +50,23 @@ func (v Version) String() string {
}
}
// ParseVersion maps an e2ee.Variant string (omemo0/omemo1/omemo2 - see
// Version.String()) back to a Version, at every point the generic
// e2ee.Backend interface hands one in (DeviceListNode, BundleNode,
// PublishedIdentity, PublishedBundle).
func ParseVersion(s string) (Version, error) {
switch s {
case "omemo0":
return Omemo0, nil
case "omemo1":
return Omemo1, nil
case "omemo2":
return Omemo2, nil
default:
return 0, fmt.Errorf("omemo: unknown version %q (want omemo0, omemo1, or omemo2)", s)
}
}
// libsignalVersion returns the underlying Signal-protocol wire version
// this OMEMO version uses. Omemo1 and Omemo2 share the same crypto-engine
// version - libomemo-c itself only branches on "message_version >= 4", not

View file

@ -29,6 +29,7 @@ var _ libsignal.Store = (*Store)(nil)
// convention):
//
// <login>/<owner>/enabled
// <login>/<owner>/negotiatedversion
// <login>/<owner>/identity/keypair
// <login>/<owner>/identity/registrationid
// <login>/<owner>/identity/signedprekey/<id>
@ -43,6 +44,10 @@ func (s *Store) enabledKey() []byte {
return []byte(fmt.Sprintf("%s/%s/enabled", s.login, s.owner))
}
func (s *Store) negotiatedVersionKey() []byte {
return []byte(fmt.Sprintf("%s/%s/negotiatedversion", s.login, s.owner))
}
func (s *Store) identityKeyPairKey() []byte {
return []byte(fmt.Sprintf("%s/%s/identity/keypair", s.login, s.owner))
}
@ -413,6 +418,28 @@ func (s *Store) SetEnabled(enabled bool) error {
return s.set(s.enabledKey(), []byte{v})
}
// NegotiatedVersion returns the wire-format version last negotiated for
// this chat's outgoing content (see e2ee/omemo's Version enum - this
// package stays version-agnostic and only stores the raw byte), and
// whether one has been recorded yet (false before the first successful
// inbound decrypt - callers should fall back to a default version).
func (s *Store) NegotiatedVersion() (byte, bool, error) {
val, err := s.get(s.negotiatedVersionKey())
if err != nil {
return 0, false, err
}
if len(val) != 1 {
return 0, false, nil
}
return val[0], true, nil
}
// SetNegotiatedVersion persists the wire-format version to use for this
// chat's outgoing content.
func (s *Store) SetNegotiatedVersion(v byte) error {
return s.set(s.negotiatedVersionKey(), []byte{v})
}
// RemoteDeviceListCache returns the last raw device-list document cached
// for remote (see e2ee.DeviceListDoc), and whether one was found - used to
// decide whether a fresh PEP fetch is needed before encrypting.

View file

@ -24,12 +24,21 @@ type Envelope struct {
Raw []byte // backend-specific serialized envelope
}
// Namespaces is what a Backend wants advertised/served for a given peer.
// Namespaces is what a Backend wants advertised/served.
type Namespaces struct {
Disco []string // disco#info features to advertise (e.g. "...+notify" hints)
EME string // urn:xmpp:eme:0 encryption-namespace hint for non-supporting clients
Disco []string // disco#info features to advertise, across every supported Variant (e.g. "...+notify" hints)
}
// Variant identifies one of a Backend's parallel wire-format generations
// (e.g. OMEMO's omemo0/omemo1/omemo2 - three simultaneously-supported
// namespaces sharing one identity/prekey set). It's an opaque string a
// Backend defines and interprets itself; other packages only ever pass a
// Variant back between Variants()/DefaultVariant()/NegotiatedVariant() and
// DeviceListNode()/BundleNode()/PublishedIdentity()/PublishedBundle() -
// never construct or compare one directly. A backend with only one
// generation can just always return/accept a single fixed value.
type Variant string
// DeviceListDoc is an opaque, backend-defined serialization of a peer's
// PEP device-list document (fetched from them, or published for a chat
// pseudo-JID we own).
@ -55,20 +64,53 @@ type Backend interface {
// Namespaces lists what this backend wants advertised/served.
Namespaces() Namespaces
// Variants lists every wire-format variant this backend can
// simultaneously serve/negotiate right now (e.g. all three OMEMO
// namespaces, or fewer if this build lacks support for some of them -
// see libsignal.ProtocolV4Supported). All variants are always
// servable/fetchable regardless of any given chat's negotiated state
// (see NegotiatedVariant) - "supported" is a deploy-time/build-time
// property, not a per-chat one.
Variants() []Variant
// DefaultVariant is what a brand new chat (no negotiation evidence
// yet) starts out using for outgoing content - e.g. OMEMO's omemo0,
// the most widely deployed variant.
DefaultVariant() Variant
// NegotiatedVariant is the wire-format variant currently in use for
// owner's (an OwnedPeer) outgoing content: DefaultVariant until an
// inbound message from owner's peer is decrypted, at which point it
// tracks whatever variant that peer's own client is actually using
// (see Decrypt). Implementations may only be able to fix this at
// session establishment time, not change it later for an
// already-established chat - see e2ee/omemo.Backend.NegotiatedVariant's
// doc comment for a concrete example of why.
NegotiatedVariant(owner PeerID) (Variant, error)
// DeviceListNode and BundleNode name the PEP node one of this backend's
// own device-list/bundle documents is published/fetched under, for the
// given variant. Note BundleNode's return value may or may not depend
// on device - some backends address every device's bundle under one
// shared node (item id = device), others use a distinct node per
// device; callers (e2ee/fetch.go, and the xmpp package's PEP-serving IQ
// handlers) must not assume either shape.
DeviceListNode(variant Variant) string
BundleNode(variant Variant, device DeviceID) string
// EnsureIdentity creates (idempotently) a local cryptographic identity
// for a PeerID this gateway owns (a bridged chat pseudo-JID - built via
// OwnedPeer, since a chat pseudo-JID alone isn't globally unique across
// telegabber's multiple bridged Telegram logins), generating and
// persisting whatever key material the backend needs.
// persisting whatever key material the backend needs. One identity
// serves every Variant simultaneously.
EnsureIdentity(peer PeerID) error
// PublishedIdentity returns the material this gateway must SERVE to
// pubsub GET requests for its own PeerID (an OwnedPeer - see
// EnsureIdentity) device list, pre-marshaled to opaque bytes the xmpp
// layer wraps in <items>/<item>.
PublishedIdentity(peer PeerID) (DeviceListDoc, error)
// EnsureIdentity) device list under the given variant, pre-marshaled to
// opaque bytes the xmpp layer wraps in <items>/<item>.
PublishedIdentity(peer PeerID, variant Variant) (DeviceListDoc, error)
// PublishedBundle is the same, for one specific device's bundle node.
PublishedBundle(peer PeerID, device DeviceID) (BundleDoc, error)
PublishedBundle(peer PeerID, device DeviceID, variant Variant) (BundleDoc, error)
// IngestRemoteDeviceList / IngestRemoteBundle feed the results of an
// outbound PEP fetch (device-list, then per-device bundle) for a real
@ -76,8 +118,10 @@ type Backend interface {
// gateway's identities is doing the fetching; peer is the remote's
// plain bare JID, no scoping needed) into the backend's store,
// establishing/refreshing sessions as needed (trust decisions happen
// inside here).
IngestRemoteDeviceList(owner PeerID, peer PeerID, doc DeviceListDoc) error
// inside here). IngestRemoteDeviceList returns the device ids it found,
// so a caller like e2ee/fetch.go knows which bundles to fetch next
// without needing to parse the backend-specific document itself.
IngestRemoteDeviceList(owner PeerID, peer PeerID, doc DeviceListDoc) ([]DeviceID, error)
IngestRemoteBundle(owner PeerID, peer PeerID, device DeviceID, doc BundleDoc) error
// Encrypt produces an opaque envelope addressed to every known device
@ -88,10 +132,26 @@ type Backend interface {
// Decrypt consumes an inbound envelope addressed to "to" (an
// OwnedPeer - see EnsureIdentity) from "from" (the real remote peer's
// plain bare JID) and returns plaintext.
// plain bare JID) and returns plaintext. A successful decrypt also
// updates "to"'s NegotiatedVariant to whichever variant this envelope
// actually used - see NegotiatedVariant's doc comment.
Decrypt(from PeerID, to PeerID, env Envelope) ([]byte, error)
// Devices lists peer's known devices, as seen from owner's (an
// OwnedPeer) point of view - for trust-listing ad-hoc commands.
Devices(owner PeerID, peer PeerID) ([]DeviceInfo, error)
// Enabled and SetEnabled track the per-chat "OMEMO is active" sticky
// flag (the auto-upgrade trigger: set the first time an inbound
// message decrypts successfully for owner, read by the outbound
// encrypt hook alongside the account-wide config toggle). owner is an
// OwnedPeer; a chat that was never marked active reports false, not an
// error.
Enabled(owner PeerID) (bool, error)
SetEnabled(owner PeerID, enabled bool) error
// Close flushes and releases whatever storage/resources this backend
// holds (e.g. the underlying Badger DB), for a clean process shutdown -
// mirrors gateway.IdsDB.Close()'s role for the id-mapping store.
Close() error
}

View file

@ -38,6 +38,9 @@ func main() {
var schemaPath = flag.String("schema", "./config_schema.json", "Schema file path")
// Folder for Badger DB of message ids
var idsPath = flag.String("ids", "ids", "Ids folder path")
// Folder for Badger DB of E2EE (OMEMO) key material, used only if
// :e2ee: :enabled: is set in the config file
var e2eeDbPath = flag.String("e2ee-db", "e2ee", "E2EE (OMEMO) folder path")
var versionFlag = flag.Bool("version", false, "Print the version and exit")
flag.Parse()
@ -68,7 +71,7 @@ func main() {
log.Infof("Starting telegabber version %v", version)
sm, component, err = xmpp.NewComponent(config.XMPP, config.Telegram, *idsPath, version)
sm, component, err = xmpp.NewComponent(config.XMPP, config.Telegram, *idsPath, version, *e2eeDbPath)
if err != nil {
log.Fatal(err)
}

View file

@ -8,10 +8,12 @@ import (
"strings"
"sync"
"dev.narayana.im/narayana/telegabber/e2ee"
"dev.narayana.im/narayana/telegabber/telegram/formatter"
"dev.narayana.im/narayana/telegabber/xmpp/gateway"
"github.com/google/uuid"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
"github.com/zelenin/go-tdlib/client"
)
@ -403,11 +405,34 @@ func (c *Client) updateMessageContent(update *client.UpdateMessageContent) {
if err == nil {
id = id+":"+uuid.String()
}
// OMEMO encrypt hook - same rules as SendMessageToGateway's (see
// its doc comment): personal-chat pseudo-JIDs only, gated on the
// per-chat active flag, once per logical edit rather than per
// jids resource copy.
var envelope *e2ee.Envelope
if !isMUC {
if backend, ok := gateway.E2EE.Backend(); ok {
owner := e2ee.OwnedPeer(c.Session.Login, gateway.CHATJID(update.ChatId, false))
if active, _ := backend.Enabled(owner); active {
peer := e2ee.PeerID(c.jid)
if err := c.ensureOMEMOSession(backend, owner, peer); err != nil {
log.Error(errors.Wrap(err, "Failed to establish OMEMO session"))
} else if env, err := backend.Encrypt(owner, []e2ee.PeerID{peer}, []byte(text.String())); err != nil {
log.Error(errors.Wrap(err, "Failed to encrypt OMEMO message"))
} else {
envelope = &env
}
}
}
}
for _, jid := range jids {
if safeToSend {
gateway.SendMessage(jid, from, c.xmpp,
gateway.SMBody(text.String()), gateway.SMId(id), gateway.SMReplaceId(replaceId),
gateway.SMIsCarbon(isCarbon), gateway.SMIsGroupchat(isMUC), gateway.SMOriginalFrom(originalFrom),
gateway.SMOMEMOEnvelope(envelope),
)
} else {
gateway.SendMUCAnnouncement(jid, from, text.String(), nickname, id, c.xmpp)

View file

@ -19,6 +19,7 @@ import (
"time"
"unicode/utf8"
"dev.narayana.im/narayana/telegabber/e2ee"
"dev.narayana.im/narayana/telegabber/telegram/cache"
"dev.narayana.im/narayana/telegabber/telegram/formatter"
"dev.narayana.im/narayana/telegabber/xmpp/gateway"
@ -2059,6 +2060,46 @@ func (c *Client) SendMessageToGateway(chatId int64, message *client.Message, id
mucJID = gateway.MUCJID(chatId)
}
// OMEMO encrypt hook - personal-chat pseudo-JIDs only (true MUC/XEP-0045
// 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
// auto-upgrade trigger (xmpp/handlers.go). The account-wide config
// toggle (persistence.Session.OMEMO) is a separate, not-yet-wired-up
// piece (M4) meant to OR into this same gate. Encryption happens once
// per logical message (not once per jids resource copy), addressed to
// 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
// intentionally left in the clear (encrypting a URL string doesn't
// protect the file itself); only auxText, and text when there's no OOB
// swap, are ever encrypted.
var textEnvelope, auxEnvelope *e2ee.Envelope
if !isGroupchat {
if backend, ok := gateway.E2EE.Backend(); ok {
owner := e2ee.OwnedPeer(c.Session.Login, gateway.CHATJID(chatId, false))
if active, _ := backend.Enabled(owner); active {
peer := e2ee.PeerID(c.jid)
if err := c.ensureOMEMOSession(backend, owner, peer); err != nil {
log.Error(errors.Wrap(err, "Failed to establish OMEMO session"))
} else {
if oob == "" && text != "" {
if env, err := backend.Encrypt(owner, []e2ee.PeerID{peer}, []byte(text)); err != nil {
log.Error(errors.Wrap(err, "Failed to encrypt OMEMO message"))
} else {
textEnvelope = &env
}
}
if auxText != "" {
if env, err := backend.Encrypt(owner, []e2ee.PeerID{peer}, []byte(auxText)); err != nil {
log.Error(errors.Wrap(err, "Failed to encrypt OMEMO auxiliary message"))
} else {
auxEnvelope = &env
}
}
}
}
}
}
for _, jid := range jids {
commonArgs := []args.V{
gateway.SMReply(reply), gateway.SMTimestamp(timestamp), gateway.SMIsCarbon(isCarbon),
@ -2067,15 +2108,33 @@ func (c *Client) SendMessageToGateway(chatId int64, message *client.Message, id
gateway.SMMucJID(mucJID), gateway.SMMucUserItem(mucUserItem),
}
gateway.SendMessage(jid, from, c.xmpp, append(commonArgs,
gateway.SMBody(text), gateway.SMId(sId), gateway.SMStanzaId(stanzaId), gateway.SMOOB(oob))...)
gateway.SMBody(text), gateway.SMId(sId), gateway.SMStanzaId(stanzaId), gateway.SMOOB(oob),
gateway.SMOMEMOEnvelope(textEnvelope))...)
if auxText != "" {
gateway.SendMessage(jid, from, c.xmpp, append(commonArgs,
gateway.SMBody(auxText), gateway.SMId(auxId), gateway.SMStanzaId(auxId))...)
gateway.SMBody(auxText), gateway.SMId(auxId), gateway.SMStanzaId(auxId),
gateway.SMOMEMOEnvelope(auxEnvelope))...)
}
}
c.UpdateLastChatMessageId(chatId, sId)
}
// ensureOMEMOSession makes sure backend has at least one established
// session with peer under owner's identity, lazily fetching and ingesting
// peer's device list/bundles over PEP if not (the first message to a peer
// with no cached session pays this synchronous round-trip cost - acceptable
// latency for a background bridge).
func (c *Client) ensureOMEMOSession(backend e2ee.Backend, owner e2ee.PeerID, peer e2ee.PeerID) error {
devices, err := backend.Devices(owner, peer)
if err != nil {
return err
}
if len(devices) > 0 {
return nil
}
return e2ee.FetchAndIngestPeer(c.xmpp, backend, owner, peer)
}
// SendDelayedMUCMessage is used to send MUC history via the legacy method or MAM
func (c *Client) SendDelayedMUCMessage(chatId int64, message *client.Message, toJid string, mamQueryId string) {
msgId, _ := gateway.IdsDB.GetByTgIds(c.Session.Login, c.jid, chatId, message.Id)

View file

@ -9,6 +9,10 @@ import (
"dev.narayana.im/narayana/telegabber/badger"
"dev.narayana.im/narayana/telegabber/config"
"dev.narayana.im/narayana/telegabber/e2ee"
"dev.narayana.im/narayana/telegabber/e2ee/omemo"
"dev.narayana.im/narayana/telegabber/e2ee/omemo/libsignal"
"dev.narayana.im/narayana/telegabber/e2ee/store/badgerstore"
"dev.narayana.im/narayana/telegabber/persistence"
"dev.narayana.im/narayana/telegabber/telegram"
"dev.narayana.im/narayana/telegabber/xmpp/gateway"
@ -39,9 +43,13 @@ var sizeRegex = regexp.MustCompile("\\A([0-9]+) ?([KMGTPE]?B?)\\z")
// NewComponent starts a new component and wraps it in
// a stream manager that you should start yourself
func NewComponent(conf config.XMPPConfig, tc config.TelegramConfig, idsPath string, version string) (*xmpp.StreamManager, *xmpp.Component, error) {
func NewComponent(conf config.XMPPConfig, tc config.TelegramConfig, idsPath string, version string, e2eeDbPath string) (*xmpp.StreamManager, *xmpp.Component, error) {
var err error
if err := setupE2EE(e2eeDbPath, conf.E2EE); err != nil {
return nil, nil, err
}
gateway.Jid, err = stanza.NewJid(conf.Jid)
gateway.Version = version
if err != nil {
@ -103,6 +111,34 @@ func NewComponent(conf config.XMPPConfig, tc config.TelegramConfig, idsPath stri
return sm, component, nil
}
// setupE2EE wires up gateway.E2EE (see its doc comment) from config. If
// E2EE is disabled (the default), gateway.E2EE still ends up non-nil, but
// its Backend() always reports ok=false - every call site is expected to
// treat that as "behave exactly as if this feature didn't exist" rather
// than nil-checking gateway.E2EE itself.
func setupE2EE(dbPath string, ec config.E2EEConfig) error {
if !ec.Enabled {
gateway.E2EE = e2ee.NewManager(nil)
return nil
}
if ec.Backend != omemo.Name {
return errors.Errorf("e2ee: unknown backend %q (only %q is supported)", ec.Backend, omemo.Name)
}
libctx, err := libsignal.NewContext()
if err != nil {
return errors.Wrap(err, "e2ee: libsignal.NewContext")
}
e2eeDB, err := badgerstore.Open(dbPath, []byte(ec.EncryptionPassphrase))
if err != nil {
return errors.Wrap(err, "e2ee: badgerstore.Open")
}
gateway.E2EE = e2ee.NewManager(omemo.New(libctx, e2eeDB))
return nil
}
func heartbeat(component *xmpp.Component) {
var err error
probeType := gateway.SPType("probe")
@ -264,6 +300,13 @@ func Close(component *xmpp.Component) {
// flush the ids database
gateway.IdsDB.Close()
// flush the e2ee database, if enabled
if backend, ok := gateway.E2EE.Backend(); ok {
if err := backend.Close(); err != nil {
log.Error(err)
}
}
// close stream
component.Disconnect()
}

129
xmpp/extensions/omemo.go Normal file
View file

@ -0,0 +1,129 @@
package extensions
import (
"encoding/xml"
"gosrc.io/xmpp/stanza"
)
// OMEMO0Encrypted is the legacy/"siacs" OMEMO <encrypted> element
// (eu.siacs.conversations.axolotl, XEP-0384 <= 0.3.0): a flat list of
// per-device <key> elements directly under <header>, plus an explicit
// <iv> - the payload's independently-random GCM nonce. Omemo1/Omemo2 (see
// OMEMOEncrypted) have no such element, since their IV is HKDF-derived
// from the key instead.
//
// <encrypted xmlns='eu.siacs.conversations.axolotl'>
// <header sid='27183'>
// <key rid='31415' prekey='true'>BASE64</key>
// <iv>BASE64</iv>
// </header>
// <payload>BASE64</payload>
// </encrypted>
type OMEMO0Encrypted struct {
XMLName xml.Name `xml:"eu.siacs.conversations.axolotl encrypted"`
Header OMEMO0Header `xml:"header"`
Payload string `xml:"payload,omitempty"`
}
// Namespace implements stanza.MsgExtension.
func (c OMEMO0Encrypted) Namespace() string {
return c.XMLName.Space
}
type OMEMO0Header struct {
SID uint32 `xml:"sid,attr"`
Keys []OMEMO0Key `xml:"key"`
IV string `xml:"iv"`
}
type OMEMO0Key struct {
RID uint32 `xml:"rid,attr"`
PreKey bool `xml:"prekey,attr,omitempty"`
Text string `xml:",chardata"`
}
// OMEMOEncrypted is the current OMEMO <encrypted> element shape, shared by
// Omemo1 (urn:xmpp:omemo:1) and Omemo2 (urn:xmpp:omemo:2) - identical
// element names, registered under both namespaces (see init()); which one
// an incoming stanza actually used is recovered from XMLName.Space after
// decoding (its tag deliberately omits a namespace, so Go's encoding/xml
// matches either and records whichever was actually present - confirmed
// against gosrc.io/xmpp's own decode path, which is plain
// d.DecodeElement() under the hood, not anything namespace-strict of its
// own). Keys are grouped per-recipient-JID under <keys jid='...'>, and the
// payload is an encrypted SCE envelope rather than the raw body, so there's
// no standalone <iv> (it's HKDF-derived from the key - see e2ee/omemo).
//
// <encrypted xmlns='urn:xmpp:omemo:2'>
// <header sid='27183'>
// <keys jid='juliet@capulet.lit'>
// <key rid='31415' kex='true'>BASE64</key>
// </keys>
// </header>
// <payload>BASE64</payload>
// </encrypted>
type OMEMOEncrypted struct {
XMLName xml.Name `xml:"encrypted"`
Header OMEMOHeader `xml:"header"`
Payload string `xml:"payload,omitempty"`
}
// Namespace implements stanza.MsgExtension.
func (c OMEMOEncrypted) Namespace() string {
return c.XMLName.Space
}
type OMEMOHeader struct {
SID uint32 `xml:"sid,attr"`
Keys []OMEMOKeys `xml:"keys"`
}
type OMEMOKeys struct {
JID string `xml:"jid,attr"`
Keys []OMEMOKey `xml:"key"`
}
type OMEMOKey struct {
RID uint32 `xml:"rid,attr"`
Kex bool `xml:"kex,attr,omitempty"`
Text string `xml:",chardata"`
}
// EME is the XEP-0380 Explicit Message Encryption hint, telling clients
// that can't decrypt <encrypted> what scheme was used instead of just
// showing nothing. The Go field is EncryptionNamespace (not Namespace) to
// avoid colliding with the stanza.MsgExtension.Namespace() method this
// type also needs - the wire attribute is still called "namespace".
type EME struct {
XMLName xml.Name `xml:"urn:xmpp:eme:0 encryption"`
EncryptionNamespace string `xml:"namespace,attr"`
Name string `xml:"name,attr,omitempty"`
}
// Namespace implements stanza.MsgExtension.
func (c EME) Namespace() string {
return c.XMLName.Space
}
func init() {
stanza.TypeRegistry.MapExtension(stanza.PKTMessage, xml.Name{
Space: "eu.siacs.conversations.axolotl",
Local: "encrypted",
}, OMEMO0Encrypted{})
stanza.TypeRegistry.MapExtension(stanza.PKTMessage, xml.Name{
Space: "urn:xmpp:omemo:1",
Local: "encrypted",
}, OMEMOEncrypted{})
stanza.TypeRegistry.MapExtension(stanza.PKTMessage, xml.Name{
Space: "urn:xmpp:omemo:2",
Local: "encrypted",
}, OMEMOEncrypted{})
stanza.TypeRegistry.MapExtension(stanza.PKTMessage, xml.Name{
Space: "urn:xmpp:eme:0",
Local: "encryption",
}, EME{})
}

View file

@ -0,0 +1,146 @@
package extensions_test
import (
"encoding/xml"
"strings"
"testing"
"gosrc.io/xmpp/stanza"
"dev.narayana.im/narayana/telegabber/xmpp/extensions"
)
// parseMessage decodes a full stanza through the exact path telegabber's
// component actually uses (stanza.NextPacket, requiring a top-level
// jabber:component:accept namespace - confirmed by reading go-xmpp's
// parser.go directly), not just a raw xml.Unmarshal of an isolated
// struct - this is what actually proves the TypeRegistry dispatch works,
// not just that my own struct tags happen to parse in isolation.
func parseMessage(t *testing.T, xmlStr string) stanza.Message {
t.Helper()
dec := xml.NewDecoder(strings.NewReader(xmlStr))
pkt, err := stanza.NextPacket(dec)
if err != nil {
t.Fatalf("NextPacket: %v", err)
}
msg, ok := pkt.(stanza.Message)
if !ok {
t.Fatalf("expected stanza.Message, got %T", pkt)
}
return msg
}
func TestOMEMO0EncryptedRoundTrip(t *testing.T) {
msg := parseMessage(t, `<message xmlns='jabber:component:accept' from='a@b' to='c@d' type='chat'>
<encrypted xmlns='eu.siacs.conversations.axolotl'>
<header sid='27183'>
<key rid='31415' prekey='true'>a2V5MQ==</key>
<key rid='12321'>a2V5Mg==</key>
<iv>aXY=</iv>
</header>
<payload>cGF5bG9hZA==</payload>
</encrypted>
</message>`)
var enc extensions.OMEMO0Encrypted
if !msg.Get(&enc) {
t.Fatalf("expected to find OMEMO0Encrypted extension")
}
if enc.Namespace() != "eu.siacs.conversations.axolotl" {
t.Fatalf("got namespace %q", enc.Namespace())
}
if enc.Header.SID != 27183 {
t.Fatalf("got sid %d, want 27183", enc.Header.SID)
}
if len(enc.Header.Keys) != 2 {
t.Fatalf("expected 2 keys, got %d", len(enc.Header.Keys))
}
if !enc.Header.Keys[0].PreKey {
t.Fatalf("expected first key to be a prekey")
}
if enc.Header.Keys[1].PreKey {
t.Fatalf("expected second key to not be a prekey")
}
if enc.Header.Keys[0].Text != "a2V5MQ==" {
t.Fatalf("got key text %q", enc.Header.Keys[0].Text)
}
if enc.Header.IV != "aXY=" {
t.Fatalf("got iv %q", enc.Header.IV)
}
if enc.Payload != "cGF5bG9hZA==" {
t.Fatalf("got payload %q", enc.Payload)
}
}
// TestOMEMOEncryptedNamespaceRecovered confirms the central design point
// for OMEMOEncrypted: it is registered under BOTH urn:xmpp:omemo:1 and
// urn:xmpp:omemo:2 (identical shape), and after decoding, XMLName.Space
// correctly reflects whichever one an incoming stanza actually used - this
// is what lets the decrypt hook later distinguish Omemo1 from Omemo2
// without needing two separate Go types.
func TestOMEMOEncryptedNamespaceRecovered(t *testing.T) {
for _, tc := range []struct {
name string
ns string
}{
{"omemo1", "urn:xmpp:omemo:1"},
{"omemo2", "urn:xmpp:omemo:2"},
} {
t.Run(tc.name, func(t *testing.T) {
msg := parseMessage(t, `<message xmlns='jabber:component:accept' from='a@b' to='c@d' type='chat'>
<encrypted xmlns='`+tc.ns+`'>
<header sid='27183'>
<keys jid='juliet@capulet.lit'>
<key rid='31415' kex='true'>a2V5</key>
</keys>
</header>
<payload>cGF5bG9hZA==</payload>
</encrypted>
</message>`)
var enc extensions.OMEMOEncrypted
if !msg.Get(&enc) {
t.Fatalf("expected to find OMEMOEncrypted extension")
}
if enc.Namespace() != tc.ns {
t.Fatalf("got namespace %q, want %q", enc.Namespace(), tc.ns)
}
if enc.Header.SID != 27183 {
t.Fatalf("got sid %d, want 27183", enc.Header.SID)
}
if len(enc.Header.Keys) != 1 || enc.Header.Keys[0].JID != "juliet@capulet.lit" {
t.Fatalf("got keys %+v", enc.Header.Keys)
}
if len(enc.Header.Keys[0].Keys) != 1 || !enc.Header.Keys[0].Keys[0].Kex {
t.Fatalf("got inner keys %+v", enc.Header.Keys[0].Keys)
}
if enc.Header.Keys[0].Keys[0].Text != "a2V5" {
t.Fatalf("got key text %q", enc.Header.Keys[0].Keys[0].Text)
}
if enc.Payload != "cGF5bG9hZA==" {
t.Fatalf("got payload %q", enc.Payload)
}
})
}
}
func TestEMERoundTrip(t *testing.T) {
msg := parseMessage(t, `<message xmlns='jabber:component:accept' from='a@b' to='c@d' type='chat'>
<body>[This message is OMEMO end-to-end encrypted]</body>
<encryption xmlns='urn:xmpp:eme:0' namespace='urn:xmpp:omemo:2' name='OMEMO'/>
</message>`)
var eme extensions.EME
if !msg.Get(&eme) {
t.Fatalf("expected to find EME extension")
}
if eme.Namespace() != "urn:xmpp:eme:0" {
t.Fatalf("got namespace %q", eme.Namespace())
}
if eme.EncryptionNamespace != "urn:xmpp:omemo:2" {
t.Fatalf("got encryption namespace %q", eme.EncryptionNamespace)
}
if eme.Name != "OMEMO" {
t.Fatalf("got name %q", eme.Name)
}
}

View file

@ -8,6 +8,8 @@ import (
"sync"
"dev.narayana.im/narayana/telegabber/badger"
"dev.narayana.im/narayana/telegabber/e2ee"
"dev.narayana.im/narayana/telegabber/e2ee/omemo"
"dev.narayana.im/narayana/telegabber/xmpp/extensions"
"github.com/google/uuid"
@ -67,6 +69,15 @@ var Version string
// IdsDB provides a disk-backed bidirectional dictionary of Telegram and XMPP ids
var IdsDB badger.IdsDB
// E2EE holds the process-wide selected end-to-end encryption backend (set
// up by xmpp.NewComponent from config), reachable from both the xmpp and
// telegram packages without either importing the other's e2ee-specific
// glue - mirrors IdsDB's role as a shared, package-level handle. Its
// Backend() reports ok=false whenever the feature is disabled or not yet
// set up; every call site must treat that as "behave exactly as if this
// feature didn't exist" rather than nil-checking E2EE itself.
var E2EE *e2ee.Manager
// DirtySessions denotes that some Telegram session configurations
// were changed and need to be re-flushed to the YamlDB
var DirtySessions = false
@ -283,6 +294,11 @@ var SMMucJID = args.NewString()
// SMMucUserItem is a XEP-0045 muc#user item (*MUCUserItem)
var SMMucUserItem = args.New()
// SMOMEMOEnvelope is an already-encrypted OMEMO envelope (*e2ee.Envelope,
// from Backend.Encrypt) to attach as an <encrypted> extension plus a
// XEP-0380 EME hint - see omemo.go's omemoStanzaExtension.
var SMOMEMOEnvelope = args.New()
func sendMessageWrapper(to, from string, component *xmpp.Component, args ...args.V) {
body := SMBody.Get(args)
subject := SMSubject.Get(args)
@ -305,6 +321,7 @@ func sendMessageWrapper(to, from string, component *xmpp.Component, args ...args
mamQueryId := SMMamQueryId.Get(args)
mucJID := SMMucJID.Get(args)
mucUserItem, _ := SMMucUserItem.Get(args).(*MUCUserItem)
envelope, _ := SMOMEMOEnvelope.Get(args).(*e2ee.Envelope)
toJid, err := stanza.NewJid(to)
if err != nil {
@ -403,6 +420,18 @@ func sendMessageWrapper(to, from string, component *xmpp.Component, args ...args
}
}
if envelope != nil {
if envelope.Backend != omemo.Name {
log.Errorf("Unsupported e2ee backend %q, dropping envelope", envelope.Backend)
} else if ext, eme, err := omemoStanzaExtension(envelope.Raw); err != nil {
log.Error(errors.Wrap(err, "Failed to encode OMEMO envelope"))
} else {
message.Extensions = append(message.Extensions, ext, eme)
if message.Body == "" {
message.Body = omemoFallbackBody
}
}
}
if oob != "" {
message.Extensions = append(message.Extensions, stanza.OOB{
URL: oob,

87
xmpp/gateway/omemo.go Normal file
View file

@ -0,0 +1,87 @@
package gateway
import (
"encoding/base64"
"encoding/xml"
"dev.narayana.im/narayana/telegabber/e2ee/omemo"
"dev.narayana.im/narayana/telegabber/xmpp/extensions"
"gosrc.io/xmpp/stanza"
)
// omemoFallbackBody is the plaintext shown by clients that don't support
// OMEMO (or don't yet trust this conversation's OMEMO session) instead of
// an empty message body - a courtesy hint, not part of the wire protocol
// (XEP-0380's EME, attached alongside this by sendMessageWrapper, is the
// actual machine-readable hint).
const omemoFallbackBody = "[This message is OMEMO end-to-end encrypted]"
// omemoStanzaExtension converts a just-produced OMEMO e2ee.Envelope's raw
// bytes (an omemo.WireEnvelope, gob-encoded - see e2ee/omemo/envelope.go)
// into the wire stanza.MsgExtension for its version, plus the XEP-0380 EME
// hint. This is the backend-specific glue e2ee.Envelope's own doc comment
// defers to the xmpp layer, since stanza XML shape isn't part of the
// backend-agnostic e2ee.Backend interface - a future second backend would
// need its own equivalent, switched on e2ee.Envelope.Backend (see its only
// caller, sendMessageWrapper's SMOMEMOEnvelope handling).
func omemoStanzaExtension(raw []byte) (extension stanza.MsgExtension, eme extensions.EME, err error) {
wireEnv, err := omemo.DecodeWireEnvelope(raw)
if err != nil {
return nil, extensions.EME{}, err
}
if wireEnv.Version == omemo.Omemo0 {
header := extensions.OMEMO0Header{
SID: wireEnv.SenderSID,
IV: base64.StdEncoding.EncodeToString(wireEnv.IV),
}
for _, k := range wireEnv.Keys {
header.Keys = append(header.Keys, extensions.OMEMO0Key{
RID: k.DeviceID,
PreKey: k.IsPreKey,
Text: base64.StdEncoding.EncodeToString(k.Ciphertext),
})
}
enc := extensions.OMEMO0Encrypted{
Header: header,
Payload: base64.StdEncoding.EncodeToString(wireEnv.Payload),
}
return enc, extensions.EME{EncryptionNamespace: "eu.siacs.conversations.axolotl", Name: "OMEMO"}, nil
}
ns := "urn:xmpp:omemo:2"
if wireEnv.Version == omemo.Omemo1 {
ns = "urn:xmpp:omemo:1"
}
// Keys are grouped per-recipient-JID on the wire (see OMEMOEncrypted's
// doc comment) - wireEnv.Keys is a flat list, so re-group here,
// preserving first-seen JID order for deterministic output.
groups := make(map[string]*extensions.OMEMOKeys)
var order []string
for _, k := range wireEnv.Keys {
group, ok := groups[k.RecipientJID]
if !ok {
group = &extensions.OMEMOKeys{JID: k.RecipientJID}
groups[k.RecipientJID] = group
order = append(order, k.RecipientJID)
}
group.Keys = append(group.Keys, extensions.OMEMOKey{
RID: k.DeviceID,
Kex: k.IsPreKey,
Text: base64.StdEncoding.EncodeToString(k.Ciphertext),
})
}
header := extensions.OMEMOHeader{SID: wireEnv.SenderSID}
for _, jid := range order {
header.Keys = append(header.Keys, *groups[jid])
}
enc := extensions.OMEMOEncrypted{
XMLName: xml.Name{Space: ns, Local: "encrypted"},
Header: header,
Payload: base64.StdEncoding.EncodeToString(wireEnv.Payload),
}
return enc, extensions.EME{EncryptionNamespace: ns, Name: "OMEMO"}, nil
}

View file

@ -10,6 +10,7 @@ import (
"sync"
"time"
"dev.narayana.im/narayana/telegabber/e2ee"
"dev.narayana.im/narayana/telegabber/persistence"
"dev.narayana.im/narayana/telegabber/telegram"
"dev.narayana.im/narayana/telegabber/xmpp/extensions"
@ -177,7 +178,7 @@ func HandleMessage(s xmpp.Sender, p stanza.Packet) {
return
}
if msg.Type != "error" && msg.Body != "" {
if msg.Type != "error" && (msg.Body != "" || hasOMEMOPayload(msg)) {
log.WithFields(log.Fields{
"from": msg.From,
"to": msg.To,
@ -245,6 +246,44 @@ func HandleMessage(s xmpp.Sender, p stanza.Packet) {
var replyId int64
text := msg.Body
// OMEMO decrypt hook - personal-chat pseudo-JIDs only (true
// MUC/XEP-0045 groupchat OMEMO is out of scope entirely, not
// deferred - see the plan doc). A successful decrypt is itself
// the auto-upgrade signal for this chat (enablement trigger 2);
// the account-wide config toggle (trigger 1) is a separate,
// not-yet-wired-up piece (M4).
if !isGroupchat {
if backend, backendOk := gateway.E2EE.Backend(); backendOk {
env, hasEnv, decErr := decodeOMEMOEnvelope(msg)
if decErr != nil {
log.Error(errors.Wrap(decErr, "Failed to decode OMEMO envelope"))
return
}
if hasEnv {
owner := e2ee.OwnedPeer(session.Session.Login, toJid.Bare())
plaintext, decErr := backend.Decrypt(e2ee.PeerID(bare), owner, env)
if decErr != nil {
log.Error(errors.Wrap(decErr, "Failed to decrypt OMEMO message"))
return
}
text = string(plaintext)
if enabled, _ := backend.Enabled(owner); !enabled {
if err := backend.SetEnabled(owner, true); err != nil {
log.Error(errors.Wrap(err, "Failed to mark chat as OMEMO-active"))
}
}
}
}
}
if text == "" {
// Either e2ee is disabled and this was an OMEMO-only
// (body-less) stanza with nothing decryptable, or some
// other body-less case the relaxed gate above now lets
// through - nothing sensible to forward to Telegram.
return
}
if len(reply.Id) > 0 {
chatId, msgId, err := gateway.IdsDB.GetByXmppId(session.Session.Login, bare, reply.Id)
if err == nil {

116
xmpp/omemo.go Normal file
View file

@ -0,0 +1,116 @@
package xmpp
import (
"encoding/base64"
"fmt"
"dev.narayana.im/narayana/telegabber/e2ee"
"dev.narayana.im/narayana/telegabber/e2ee/omemo"
"dev.narayana.im/narayana/telegabber/xmpp/extensions"
"gosrc.io/xmpp/stanza"
)
// hasOMEMOPayload reports whether msg carries any of the three OMEMO
// <encrypted> wire shapes, regardless of whether it also has a <body> -
// used to fix HandleMessage's body-gate, since OMEMO stanzas are commonly
// body-less (a plaintext fallback body is a courtesy for non-supporting
// clients, not a wire requirement - see gateway.omemoFallbackBody).
func hasOMEMOPayload(msg stanza.Message) bool {
var legacy extensions.OMEMO0Encrypted
if msg.Get(&legacy) {
return true
}
var modern extensions.OMEMOEncrypted
return msg.Get(&modern)
}
// decodeOMEMOEnvelope extracts msg's OMEMO <encrypted> payload (whichever
// of the three namespaces is present) as an opaque e2ee.Envelope, ready for
// Backend.Decrypt. ok is false if msg carries no OMEMO payload at all - not
// an error, just nothing for the decrypt hook to do.
func decodeOMEMOEnvelope(msg stanza.Message) (env e2ee.Envelope, ok bool, err error) {
var legacy extensions.OMEMO0Encrypted
if msg.Get(&legacy) {
env, err = decodeOMEMO0Envelope(legacy)
return env, true, err
}
var modern extensions.OMEMOEncrypted
if msg.Get(&modern) {
env, err = decodeOMEMOModernEnvelope(modern)
return env, true, err
}
return e2ee.Envelope{}, false, nil
}
func decodeOMEMO0Envelope(enc extensions.OMEMO0Encrypted) (e2ee.Envelope, error) {
payload, err := base64.StdEncoding.DecodeString(enc.Payload)
if err != nil {
return e2ee.Envelope{}, fmt.Errorf("omemo: decode payload: %w", err)
}
iv, err := base64.StdEncoding.DecodeString(enc.Header.IV)
if err != nil {
return e2ee.Envelope{}, fmt.Errorf("omemo: decode iv: %w", err)
}
wireEnv := &omemo.WireEnvelope{
Version: omemo.Omemo0,
SenderSID: enc.Header.SID,
IV: iv,
Payload: payload,
}
for _, k := range enc.Header.Keys {
ct, err := base64.StdEncoding.DecodeString(k.Text)
if err != nil {
return e2ee.Envelope{}, fmt.Errorf("omemo: decode key %d: %w", k.RID, err)
}
wireEnv.Keys = append(wireEnv.Keys, omemo.WireKey{
DeviceID: k.RID,
IsPreKey: k.PreKey,
Ciphertext: ct,
})
}
raw, err := wireEnv.Encode()
if err != nil {
return e2ee.Envelope{}, err
}
return e2ee.Envelope{Backend: omemo.Name, Raw: raw}, nil
}
func decodeOMEMOModernEnvelope(enc extensions.OMEMOEncrypted) (e2ee.Envelope, error) {
version := omemo.Omemo2
if enc.Namespace() == "urn:xmpp:omemo:1" {
version = omemo.Omemo1
}
payload, err := base64.StdEncoding.DecodeString(enc.Payload)
if err != nil {
return e2ee.Envelope{}, fmt.Errorf("omemo: decode payload: %w", err)
}
wireEnv := &omemo.WireEnvelope{
Version: version,
SenderSID: enc.Header.SID,
Payload: payload,
}
for _, group := range enc.Header.Keys {
for _, k := range group.Keys {
ct, err := base64.StdEncoding.DecodeString(k.Text)
if err != nil {
return e2ee.Envelope{}, fmt.Errorf("omemo: decode key %d: %w", k.RID, err)
}
wireEnv.Keys = append(wireEnv.Keys, omemo.WireKey{
RecipientJID: group.JID,
DeviceID: k.RID,
IsPreKey: k.Kex,
Ciphertext: ct,
})
}
}
raw, err := wireEnv.Encode()
if err != nil {
return e2ee.Envelope{}, err
}
return e2ee.Envelope{Backend: omemo.Name, Raw: raw}, nil
}