From 09e0c793bf55b2ced9c534c900184dc097068892 Mon Sep 17 00:00:00 2001 From: Bohdan Horbeshko Date: Tue, 28 Jul 2026 23:14:32 -0400 Subject: [PATCH] Wire OMEMO message handling --- Makefile | 6 +- config.yml.example | 16 +++ config/config.go | 29 +++- config_schema.json | 17 +++ e2ee/fetch.go | 129 +++++++++++++++++ e2ee/manager.go | 30 ++-- e2ee/omemo/backend_test.go | 207 ++++++++++++++++----------- e2ee/omemo/bundle.go | 7 + e2ee/omemo/encrypt.go | 38 +++-- e2ee/omemo/identity.go | 246 +++++++++++++++++++++++++++----- e2ee/omemo/version.go | 23 ++- e2ee/store/badgerstore/store.go | 27 ++++ e2ee/types.go | 82 +++++++++-- telegabber.go | 5 +- telegram/handlers.go | 25 ++++ telegram/utils.go | 63 +++++++- xmpp/component.go | 45 +++++- xmpp/extensions/omemo.go | 129 +++++++++++++++++ xmpp/extensions/omemo_test.go | 146 +++++++++++++++++++ xmpp/gateway/gateway.go | 29 ++++ xmpp/gateway/omemo.go | 87 +++++++++++ xmpp/handlers.go | 41 +++++- xmpp/omemo.go | 116 +++++++++++++++ 23 files changed, 1368 insertions(+), 175 deletions(-) create mode 100644 e2ee/fetch.go create mode 100644 xmpp/extensions/omemo.go create mode 100644 xmpp/extensions/omemo_test.go create mode 100644 xmpp/gateway/omemo.go create mode 100644 xmpp/omemo.go diff --git a/Makefile b/Makefile index f441332..9771c93 100644 --- a/Makefile +++ b/Makefile @@ -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 ./... diff --git a/config.yml.example b/config.yml.example index 260dc46..9c49b50 100644 --- a/config.yml.example +++ b/config.yml.example @@ -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 diff --git a/config/config.go b/config/config.go index b214495..a397757 100644 --- a/config/config.go +++ b/config/config.go @@ -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 diff --git a/config_schema.json b/config_schema.json index a58da77..7e72cfa 100644 --- a/config_schema.json +++ b/config_schema.json @@ -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" + } + } } } } diff --git a/e2ee/fetch.go b/e2ee/fetch.go new file mode 100644 index 0000000..9f1728b --- /dev/null +++ b/e2ee/fetch.go @@ -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) + } +} diff --git a/e2ee/manager.go b/e2ee/manager.go index 2d8bf5b..17b85db 100644 --- a/e2ee/manager.go +++ b/e2ee/manager.go @@ -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 diff --git a/e2ee/omemo/backend_test.go b/e2ee/omemo/backend_test.go index 9164b38..dfaa31b 100644 --- a/e2ee/omemo/backend_test.go +++ b/e2ee/omemo/backend_test.go @@ -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) } } diff --git a/e2ee/omemo/bundle.go b/e2ee/omemo/bundle.go index e9c336c..4c5797a 100644 --- a/e2ee/omemo/bundle.go +++ b/e2ee/omemo/bundle.go @@ -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, diff --git a/e2ee/omemo/encrypt.go b/e2ee/omemo/encrypt.go index 2b860ac..8b28421 100644 --- a/e2ee/omemo/encrypt.go +++ b/e2ee/omemo/encrypt.go @@ -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 } diff --git a/e2ee/omemo/identity.go b/e2ee/omemo/identity.go index 85d7f3e..b8cac61 100644 --- a/e2ee/omemo/identity.go +++ b/e2ee/omemo/identity.go @@ -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 +// ".devicelist"; Omemo1/Omemo2 use XEP-0384's ":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 (".bundles:"); 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 { diff --git a/e2ee/omemo/version.go b/e2ee/omemo/version.go index 67dd996..7e89d6c 100644 --- a/e2ee/omemo/version.go +++ b/e2ee/omemo/version.go @@ -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 diff --git a/e2ee/store/badgerstore/store.go b/e2ee/store/badgerstore/store.go index b2b384f..eaa1bdb 100644 --- a/e2ee/store/badgerstore/store.go +++ b/e2ee/store/badgerstore/store.go @@ -29,6 +29,7 @@ var _ libsignal.Store = (*Store)(nil) // convention): // // //enabled +// //negotiatedversion // //identity/keypair // //identity/registrationid // //identity/signedprekey/ @@ -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. diff --git a/e2ee/types.go b/e2ee/types.go index 1d7f3a1..c5be9c7 100644 --- a/e2ee/types.go +++ b/e2ee/types.go @@ -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 /. - PublishedIdentity(peer PeerID) (DeviceListDoc, error) + // EnsureIdentity) device list under the given variant, pre-marshaled to + // opaque bytes the xmpp layer wraps in /. + 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 } diff --git a/telegabber.go b/telegabber.go index 5994d6c..62b08f9 100644 --- a/telegabber.go +++ b/telegabber.go @@ -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) } diff --git a/telegram/handlers.go b/telegram/handlers.go index b08148a..8c1781c 100644 --- a/telegram/handlers.go +++ b/telegram/handlers.go @@ -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) diff --git a/telegram/utils.go b/telegram/utils.go index 4f68bd6..4a176ec 100644 --- a/telegram/utils.go +++ b/telegram/utils.go @@ -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) diff --git a/xmpp/component.go b/xmpp/component.go index 1f0ce06..b3f5e76 100644 --- a/xmpp/component.go +++ b/xmpp/component.go @@ -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() } diff --git a/xmpp/extensions/omemo.go b/xmpp/extensions/omemo.go new file mode 100644 index 0000000..e8229df --- /dev/null +++ b/xmpp/extensions/omemo.go @@ -0,0 +1,129 @@ +package extensions + +import ( + "encoding/xml" + + "gosrc.io/xmpp/stanza" +) + +// OMEMO0Encrypted is the legacy/"siacs" OMEMO element +// (eu.siacs.conversations.axolotl, XEP-0384 <= 0.3.0): a flat list of +// per-device elements directly under
, plus an explicit +// - 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. +// +// +//
+// BASE64 +// BASE64 +//
+// BASE64 +//
+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 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 , and the +// payload is an encrypted SCE envelope rather than the raw body, so there's +// no standalone (it's HKDF-derived from the key - see e2ee/omemo). +// +// +//
+// +// BASE64 +// +//
+// BASE64 +//
+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 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{}) +} diff --git a/xmpp/extensions/omemo_test.go b/xmpp/extensions/omemo_test.go new file mode 100644 index 0000000..c574aed --- /dev/null +++ b/xmpp/extensions/omemo_test.go @@ -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, ` + +
+ a2V5MQ== + a2V5Mg== + aXY= +
+ cGF5bG9hZA== +
+
`) + + 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, ` + +
+ + a2V5 + +
+ cGF5bG9hZA== +
+
`) + + 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, ` + [This message is OMEMO end-to-end encrypted] + + `) + + 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) + } +} diff --git a/xmpp/gateway/gateway.go b/xmpp/gateway/gateway.go index 73b7df0..7e65c4a 100644 --- a/xmpp/gateway/gateway.go +++ b/xmpp/gateway/gateway.go @@ -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 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, diff --git a/xmpp/gateway/omemo.go b/xmpp/gateway/omemo.go new file mode 100644 index 0000000..517b82a --- /dev/null +++ b/xmpp/gateway/omemo.go @@ -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 +} diff --git a/xmpp/handlers.go b/xmpp/handlers.go index 0c05c6f..f1f5f78 100644 --- a/xmpp/handlers.go +++ b/xmpp/handlers.go @@ -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 { diff --git a/xmpp/omemo.go b/xmpp/omemo.go new file mode 100644 index 0000000..f0af9cf --- /dev/null +++ b/xmpp/omemo.go @@ -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 +// wire shapes, regardless of whether it also has a - +// 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 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 +}