mirror of
https://dev.narayana.im/narayana/telegabber.git
synced 2026-08-05 04:07:07 +00:00
Wire OMEMO message handling
This commit is contained in:
parent
6a7cd095e9
commit
09e0c793bf
23 changed files with 1368 additions and 175 deletions
6
Makefile
6
Makefile
|
|
@ -7,7 +7,9 @@ MAKEOPTS := "-j4"
|
||||||
|
|
||||||
# GOTAGS=signal_legacy builds the OMEMO cgo bindings against the pre-fork
|
# 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
|
# 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 :=
|
GOTAGS :=
|
||||||
|
|
||||||
all:
|
all:
|
||||||
|
|
@ -15,7 +17,7 @@ all:
|
||||||
go build -ldflags "-X main.commit=${COMMIT}" -tags "${GOTAGS}" -o release/telegabber
|
go build -ldflags "-X main.commit=${COMMIT}" -tags "${GOTAGS}" -o release/telegabber
|
||||||
|
|
||||||
test:
|
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:
|
lint:
|
||||||
$(GOPATH)/bin/golint ./...
|
$(GOPATH)/bin/golint ./...
|
||||||
|
|
|
||||||
|
|
@ -26,3 +26,19 @@
|
||||||
:port: 5347
|
:port: 5347
|
||||||
:password: 'password'
|
:password: 'password'
|
||||||
:db: 'session.dat'
|
: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
|
||||||
|
|
|
||||||
|
|
@ -17,12 +17,29 @@ type Config struct {
|
||||||
|
|
||||||
// XMPPConfig is for :xmpp: subtree
|
// XMPPConfig is for :xmpp: subtree
|
||||||
type XMPPConfig struct {
|
type XMPPConfig struct {
|
||||||
Loglevel string `yaml:":loglevel"`
|
Loglevel string `yaml:":loglevel"`
|
||||||
Jid string `yaml:":jid"`
|
Jid string `yaml:":jid"`
|
||||||
Host string `yaml:":host"`
|
Host string `yaml:":host"`
|
||||||
Port string `yaml:":port"`
|
Port string `yaml:":port"`
|
||||||
Password string `yaml:":password"`
|
Password string `yaml:":password"`
|
||||||
Db string `yaml:":db"`
|
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
|
// TelegramConfig is for :telegram: subtree
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,23 @@
|
||||||
},
|
},
|
||||||
":db": {
|
":db": {
|
||||||
"$ref": "#/definitions/non-empty-string"
|
"$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
129
e2ee/fetch.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,32 +1,24 @@
|
||||||
package e2ee
|
package e2ee
|
||||||
|
|
||||||
import "fmt"
|
|
||||||
|
|
||||||
// Manager holds the process-wide selected E2EE backend (or none, if E2EE
|
// Manager holds the process-wide selected E2EE backend (or none, if E2EE
|
||||||
// is disabled), reachable from both the xmpp and telegram packages without
|
// 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 {
|
type Manager struct {
|
||||||
backend Backend
|
backend Backend
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewManager selects a backend by name from the registry. An empty name
|
// NewManager wraps backend for process-wide access. Pass nil to represent
|
||||||
// means E2EE is disabled: the returned Manager's Backend() always reports
|
// E2EE being disabled - every call site is expected to treat that the same
|
||||||
// ok=false, and every call site is expected to treat that as "behave
|
// as "behave exactly as if this feature didn't exist" (see Backend's ok
|
||||||
// exactly as if this feature didn't exist".
|
// return).
|
||||||
func NewManager(name string) (*Manager, error) {
|
func NewManager(backend Backend) *Manager {
|
||||||
if name == "" {
|
return &Manager{backend: backend}
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Backend returns the selected backend. ok is false if m is nil or no
|
// Backend returns the selected backend. ok is false if m is nil or backend
|
||||||
// backend was selected (E2EE disabled) - callers should treat a nil
|
// was nil (E2EE disabled) - callers should treat a nil Manager the same as
|
||||||
// Manager the same as one built via NewManager("").
|
// one built via NewManager(nil).
|
||||||
func (m *Manager) Backend() (backend Backend, ok bool) {
|
func (m *Manager) Backend() (backend Backend, ok bool) {
|
||||||
if m == nil || m.backend == nil {
|
if m == nil || m.backend == nil {
|
||||||
return nil, false
|
return nil, false
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import (
|
||||||
"dev.narayana.im/narayana/telegabber/e2ee/store/badgerstore"
|
"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()
|
t.Helper()
|
||||||
|
|
||||||
ctx, err := libsignal.NewContext()
|
ctx, err := libsignal.NewContext()
|
||||||
|
|
@ -25,102 +25,143 @@ func newTestBackend(t *testing.T, version omemo.Version) *omemo.Backend {
|
||||||
}
|
}
|
||||||
t.Cleanup(func() { db.Close() })
|
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
|
// TestBackendRoundTrip exercises the full e2ee.Backend interface, not just
|
||||||
// the underlying libsignal primitives (already covered by libsignal's own
|
// the underlying libsignal primitives (already covered by libsignal's own
|
||||||
// round-trip test): EnsureIdentity, publishing a device-list/bundle,
|
// round-trip test): EnsureIdentity, publishing a device-list/bundle,
|
||||||
// ingesting them on the other side, and Encrypt/Decrypt in both
|
// ingesting them on the other side, Encrypt/Decrypt in both directions, and
|
||||||
// directions. This simulates two independent parties - "gateway" (the
|
// per-chat version negotiation (defaults to Omemo0, then tracks whatever
|
||||||
// bridged chat pseudo-JID's owned identity) and "client" (the real XMPP
|
// variant the chat's very first session actually gets established at - see
|
||||||
// user's own device) - each with their own Backend/store, exchanging
|
// e2ee.Backend.NegotiatedVariant's doc comment). Each variant is tested with
|
||||||
// PublishedIdentity/PublishedBundle results directly in place of the real
|
// its own fresh pair of identities/sessions rather than trying to "upgrade"
|
||||||
// PEP fetch (e2ee/fetch.go's actual IQ round trip isn't built yet).
|
// 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) {
|
func TestBackendRoundTrip(t *testing.T) {
|
||||||
const gatewayLogin = "telegram-login-1"
|
t.Run("omemo0 (default)", func(t *testing.T) {
|
||||||
const gatewayBareJID = "12345@transport.example"
|
testBackendRoundTripAtVariant(t, "telegram-login-1", "12345@transport.example", "alice@real.example", e2ee.Variant(omemo.Omemo0.String()))
|
||||||
const realUserBareJID = "alice@real.example"
|
})
|
||||||
|
|
||||||
for _, tc := range []struct {
|
t.Run("omemo2 (fresh chat established directly at a non-default variant)", func(t *testing.T) {
|
||||||
name string
|
if !libsignal.ProtocolV4Supported {
|
||||||
version omemo.Version
|
t.Skip("protocol v4 (omemo1/omemo2) not supported by this build (signal_legacy tag)")
|
||||||
}{
|
}
|
||||||
{"omemo0", omemo.Omemo0},
|
testBackendRoundTripAtVariant(t, "telegram-login-2", "67890@transport.example", "bob@real.example", e2ee.Variant(omemo.Omemo2.String()))
|
||||||
{"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)")
|
|
||||||
}
|
|
||||||
|
|
||||||
gateway := newTestBackend(t, tc.version)
|
// testBackendRoundTripAtVariant simulates two independent parties -
|
||||||
client := newTestBackend(t, tc.version)
|
// "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)
|
gateway := newTestBackend(t)
|
||||||
clientOwned := e2ee.OwnedPeer("n/a", realUserBareJID)
|
client := newTestBackend(t)
|
||||||
realUser := e2ee.PeerID(realUserBareJID) // how the gateway refers to the real user
|
|
||||||
gatewayAsPeer := e2ee.PeerID(gatewayBareJID) // how the client refers to the gateway
|
|
||||||
|
|
||||||
if err := gateway.EnsureIdentity(gatewayOwned); err != nil {
|
gatewayOwned := e2ee.OwnedPeer(gatewayLogin, gatewayBareJID)
|
||||||
t.Fatalf("gateway.EnsureIdentity: %v", err)
|
clientOwned := e2ee.OwnedPeer("n/a", realUserBareJID)
|
||||||
}
|
realUser := e2ee.PeerID(realUserBareJID) // how the gateway refers to the real user
|
||||||
if err := client.EnsureIdentity(clientOwned); err != nil {
|
gatewayAsPeer := e2ee.PeerID(gatewayBareJID) // how the client refers to the gateway
|
||||||
t.Fatalf("client.EnsureIdentity: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gateway "fetches" the client's device list + bundle.
|
if err := gateway.EnsureIdentity(gatewayOwned); err != nil {
|
||||||
clientDeviceList, err := client.PublishedIdentity(clientOwned)
|
t.Fatalf("gateway.EnsureIdentity: %v", err)
|
||||||
if err != nil {
|
}
|
||||||
t.Fatalf("client.PublishedIdentity: %v", err)
|
if err := client.EnsureIdentity(clientOwned); err != nil {
|
||||||
}
|
t.Fatalf("client.EnsureIdentity: %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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gateway encrypts a message originating from Telegram.
|
if v, err := gateway.NegotiatedVariant(gatewayOwned); err != nil || v != gateway.DefaultVariant() {
|
||||||
plaintext1 := []byte("hello from telegram")
|
t.Fatalf("expected a fresh chat to default to %v, got %v (err %v)", gateway.DefaultVariant(), v, err)
|
||||||
env1, err := gateway.Encrypt(gatewayOwned, []e2ee.PeerID{realUser}, plaintext1)
|
}
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("gateway.Encrypt: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Client decrypts it - this establishes its side of the
|
// Gateway "fetches" the client's device list + bundle, under variant.
|
||||||
// session as a side effect, with no prior bundle fetch needed
|
clientDeviceList, err := client.PublishedIdentity(clientOwned, variant)
|
||||||
// on its end (matching the prekey-message responder flow
|
if err != nil {
|
||||||
// already validated at the libsignal level).
|
t.Fatalf("client.PublishedIdentity: %v", err)
|
||||||
got1, err := client.Decrypt(gatewayAsPeer, clientOwned, env1)
|
}
|
||||||
if err != nil {
|
gotDeviceIDs, err := gateway.IngestRemoteDeviceList(gatewayOwned, realUser, clientDeviceList)
|
||||||
t.Fatalf("client.Decrypt: %v", err)
|
if err != nil {
|
||||||
}
|
t.Fatalf("gateway.IngestRemoteDeviceList: %v", err)
|
||||||
if !bytes.Equal(got1, plaintext1) {
|
}
|
||||||
t.Fatalf("round trip mismatch: got %q, want %q", got1, plaintext1)
|
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
|
// Gateway encrypts a message originating from Telegram.
|
||||||
// for the gateway's device - the session established during
|
plaintext1 := []byte("hello from telegram")
|
||||||
// the decrypt above already covers it.
|
env1, err := gateway.Encrypt(gatewayOwned, []e2ee.PeerID{realUser}, plaintext1)
|
||||||
plaintext2 := []byte("hi telegram")
|
if err != nil {
|
||||||
env2, err := client.Encrypt(clientOwned, []e2ee.PeerID{gatewayAsPeer}, plaintext2)
|
t.Fatalf("gateway.Encrypt: %v", err)
|
||||||
if err != nil {
|
}
|
||||||
t.Fatalf("client.Encrypt: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
got2, err := gateway.Decrypt(realUser, gatewayOwned, env2)
|
// Client decrypts it - this establishes its side of the session as a
|
||||||
if err != nil {
|
// side effect, with no prior bundle fetch needed on its end (matching
|
||||||
t.Fatalf("gateway.Decrypt: %v", err)
|
// the prekey-message responder flow already validated at the libsignal
|
||||||
}
|
// level), and records variant as negotiated too.
|
||||||
if !bytes.Equal(got2, plaintext2) {
|
got1, err := client.Decrypt(gatewayAsPeer, clientOwned, env1)
|
||||||
t.Fatalf("round trip mismatch: got %q, want %q", got2, plaintext2)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,7 @@ type xepPreKeysWrap struct {
|
||||||
// the three bundle formats, in the raw (still base64-decoded) bytes
|
// the three bundle formats, in the raw (still base64-decoded) bytes
|
||||||
// libsignal expects.
|
// libsignal expects.
|
||||||
type parsedBundle struct {
|
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
|
SignedPreKeyID uint32
|
||||||
SignedPreKeyPublic []byte
|
SignedPreKeyPublic []byte
|
||||||
SignedPreKeySignature []byte
|
SignedPreKeySignature []byte
|
||||||
|
|
@ -109,6 +110,7 @@ func decodeOmemo0Bundle(b omemo0Bundle) (*parsedBundle, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
result := &parsedBundle{
|
result := &parsedBundle{
|
||||||
|
Version: Omemo0,
|
||||||
SignedPreKeyID: b.SignedPreKeyPublic.ID,
|
SignedPreKeyID: b.SignedPreKeyPublic.ID,
|
||||||
SignedPreKeyPublic: spk,
|
SignedPreKeyPublic: spk,
|
||||||
SignedPreKeySignature: sig,
|
SignedPreKeySignature: sig,
|
||||||
|
|
@ -138,7 +140,12 @@ func decodeXepBundle(b xepBundle) (*parsedBundle, error) {
|
||||||
return nil, fmt.Errorf("omemo: bundle ik: %w", err)
|
return nil, fmt.Errorf("omemo: bundle ik: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
version := Omemo2
|
||||||
|
if b.XMLName.Space == omemo1NS {
|
||||||
|
version = Omemo1
|
||||||
|
}
|
||||||
result := &parsedBundle{
|
result := &parsedBundle{
|
||||||
|
Version: version,
|
||||||
SignedPreKeyID: b.SPK.ID,
|
SignedPreKeyID: b.SPK.ID,
|
||||||
SignedPreKeyPublic: spk,
|
SignedPreKeyPublic: spk,
|
||||||
SignedPreKeySignature: sig,
|
SignedPreKeySignature: sig,
|
||||||
|
|
|
||||||
|
|
@ -19,9 +19,16 @@ func (b *Backend) Encrypt(from e2ee.PeerID, to []e2ee.PeerID, plaintext []byte)
|
||||||
|
|
||||||
store := b.db.Store(b.libctx, login, owner)
|
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
|
// Outer content encryption happens once per message, regardless of how
|
||||||
// many recipient devices it ends up going to - see envelope.go.
|
// 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 {
|
if err != nil {
|
||||||
return e2ee.Envelope{}, fmt.Errorf("omemo: EncryptOuter: %w", err)
|
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()
|
defer storeCtx.Close()
|
||||||
|
|
||||||
env := &WireEnvelope{
|
env := &WireEnvelope{
|
||||||
Version: b.version,
|
Version: version,
|
||||||
SenderSID: ownDeviceIDNum,
|
SenderSID: ownDeviceIDNum,
|
||||||
IV: payload.IV,
|
IV: payload.IV,
|
||||||
Payload: payload.Ciphertext,
|
Payload: payload.Ciphertext,
|
||||||
|
|
@ -49,7 +56,7 @@ func (b *Backend) Encrypt(from e2ee.PeerID, to []e2ee.PeerID, plaintext []byte)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, deviceID := range deviceIDs {
|
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 {
|
if err != nil {
|
||||||
return e2ee.Envelope{}, fmt.Errorf("omemo: NewSessionCipher(%s:%d): %w", peer, deviceID, err)
|
return e2ee.Envelope{}, fmt.Errorf("omemo: NewSessionCipher(%s:%d): %w", peer, deviceID, err)
|
||||||
}
|
}
|
||||||
|
|
@ -115,12 +122,11 @@ func (b *Backend) Decrypt(from, to e2ee.PeerID, env e2ee.Envelope) ([]byte, erro
|
||||||
defer storeCtx.Close()
|
defer storeCtx.Close()
|
||||||
|
|
||||||
// The cipher's configured version must match what the SENDER actually
|
// The cipher's configured version must match what the SENDER actually
|
||||||
// used (wireEnv.Version, derived from which XML namespace their
|
// used (wireEnv.Version, derived from which XML namespace their stanza
|
||||||
// stanza used), not this backend's own outgoing preference (b.version)
|
// used) - a remote peer's message version isn't guaranteed to match
|
||||||
// - a remote peer's message version isn't guaranteed to match ours,
|
// whatever we've last negotiated for outgoing content (see
|
||||||
// and this is the one case telegabber's own M1 round-trip test never
|
// negotiatedVersion), so this is deliberately read straight from the
|
||||||
// exercised (both sides there always shared the same configured
|
// envelope rather than assumed.
|
||||||
// version), so this is deliberately conservative rather than assumed.
|
|
||||||
cipher, err := libsignal.NewSessionCipher(b.libctx, storeCtx, libsignal.Address{Name: string(from), DeviceID: wireEnv.SenderSID}, wireEnv.Version.libsignalVersion())
|
cipher, err := libsignal.NewSessionCipher(b.libctx, storeCtx, libsignal.Address{Name: string(from), DeviceID: wireEnv.SenderSID}, wireEnv.Version.libsignalVersion())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("omemo: NewSessionCipher: %w", err)
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("omemo: DecryptOuter: %w", err)
|
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
|
return plaintext, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,11 +34,17 @@ const ownDeviceIDNum uint32 = 1
|
||||||
// need to reference "the" device of an owned identity (e.g. PublishedBundle).
|
// need to reference "the" device of an owned identity (e.g. PublishedBundle).
|
||||||
var OwnDeviceID = e2ee.DeviceID(strconv.FormatUint(uint64(ownDeviceIDNum), 10))
|
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 {
|
type Backend struct {
|
||||||
libctx *libsignal.Context
|
libctx *libsignal.Context
|
||||||
db *badgerstore.DB
|
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
|
|
||||||
|
|
||||||
// libsignal is not internally thread-safe (see its package doc) - one
|
// libsignal is not internally thread-safe (see its package doc) - one
|
||||||
// mutex serializes every call into it, across every identity this
|
// mutex serializes every call into it, across every identity this
|
||||||
|
|
@ -48,34 +54,134 @@ type Backend struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates an OMEMO backend backed by db, producing new outgoing
|
// New creates an OMEMO backend backed by db.
|
||||||
// content in the given version (Omemo0, Omemo1, or Omemo2 - see version.go).
|
func New(libctx *libsignal.Context, db *badgerstore.DB) *Backend {
|
||||||
func New(libctx *libsignal.Context, db *badgerstore.DB, version Version) *Backend {
|
return &Backend{libctx: libctx, db: db}
|
||||||
return &Backend{libctx: libctx, db: db, version: version}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ e2ee.Backend = (*Backend)(nil)
|
var _ e2ee.Backend = (*Backend)(nil)
|
||||||
|
|
||||||
func (b *Backend) Name() string { return Name }
|
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 {
|
func (b *Backend) Namespaces() e2ee.Namespaces {
|
||||||
switch b.version {
|
disco := []string{omemo0NS + ".devicelist+notify"}
|
||||||
case Omemo0:
|
if libsignal.ProtocolV4Supported {
|
||||||
return e2ee.Namespaces{
|
disco = append(disco, omemo1NS+":devices+notify", omemo2NS+":devices+notify")
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
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 {
|
func (b *Backend) EnsureIdentity(peer e2ee.PeerID) error {
|
||||||
|
|
@ -138,11 +244,15 @@ func (b *Backend) EnsureIdentity(peer e2ee.PeerID) error {
|
||||||
return nil
|
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)
|
login, owner, ok := e2ee.SplitOwnedPeer(peer)
|
||||||
if !ok {
|
if !ok {
|
||||||
return e2ee.DeviceListDoc{}, fmt.Errorf("omemo: PublishedIdentity: %q is not an OwnedPeer", peer)
|
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()
|
b.mu.Lock()
|
||||||
defer b.mu.Unlock()
|
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")
|
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 {
|
if err != nil {
|
||||||
return e2ee.DeviceListDoc{}, err
|
return e2ee.DeviceListDoc{}, err
|
||||||
}
|
}
|
||||||
return e2ee.DeviceListDoc{Raw: raw}, nil
|
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)
|
login, owner, ok := e2ee.SplitOwnedPeer(peer)
|
||||||
if !ok {
|
if !ok {
|
||||||
return e2ee.BundleDoc{}, fmt.Errorf("omemo: PublishedBundle: %q is not an OwnedPeer", peer)
|
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 {
|
if device != OwnDeviceID {
|
||||||
return e2ee.BundleDoc{}, fmt.Errorf("omemo: PublishedBundle: unknown device %q", device)
|
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()
|
b.mu.Lock()
|
||||||
defer b.mu.Unlock()
|
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
|
// See the rule documented on libsignal.SignedPreKeyInfo: the signature
|
||||||
// must match the serialization form the verifier will re-derive, which
|
// must match the serialization form the verifier will re-derive, which
|
||||||
// depends on the recipient's protocol version (Omemo0 -> legacy form,
|
// depends on the requested variant (Omemo0 -> legacy form, Omemo1/Omemo2
|
||||||
// Omemo1/Omemo2 -> OMEMO form, since both share libsignal.ProtocolVersionV4)
|
// -> OMEMO form, since both share libsignal.ProtocolVersionV4) - the
|
||||||
// - but since we only publish one bundle version at a time (this
|
// same signed prekey serves every variant, just re-signed per request.
|
||||||
// backend's own configured version), that's the one to use here too.
|
|
||||||
signature := spkInfo.Signature
|
signature := spkInfo.Signature
|
||||||
if b.version != Omemo0 {
|
if version != Omemo0 {
|
||||||
signature = spkInfo.SignatureOMEMO
|
signature = spkInfo.SignatureOMEMO
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -224,27 +337,36 @@ func (b *Backend) PublishedBundle(peer e2ee.PeerID, device e2ee.DeviceID) (e2ee.
|
||||||
preKeys = append(preKeys, *info)
|
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 {
|
if err != nil {
|
||||||
return e2ee.BundleDoc{}, err
|
return e2ee.BundleDoc{}, err
|
||||||
}
|
}
|
||||||
return e2ee.BundleDoc{Raw: raw}, nil
|
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)
|
login, ownerJID, ok := e2ee.SplitOwnedPeer(owner)
|
||||||
if !ok {
|
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 {
|
ids, err := parseDeviceList(doc.Raw)
|
||||||
return fmt.Errorf("omemo: parseDeviceList: %w", err)
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("omemo: parseDeviceList: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
b.mu.Lock()
|
b.mu.Lock()
|
||||||
defer b.mu.Unlock()
|
defer b.mu.Unlock()
|
||||||
|
|
||||||
store := b.db.Store(b.libctx, login, ownerJID)
|
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 {
|
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()
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("omemo: NewSessionBuilder: %w", err)
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("omemo: ProcessPreKeyBundle: %w", err)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -333,6 +477,30 @@ func (b *Backend) Devices(owner, peer e2ee.PeerID) ([]e2ee.DeviceInfo, error) {
|
||||||
return infos, nil
|
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) {
|
func parseDeviceID(device e2ee.DeviceID) (uint32, error) {
|
||||||
id, err := strconv.ParseUint(string(device), 10, 32)
|
id, err := strconv.ParseUint(string(device), 10, 32)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
package omemo
|
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
|
// Version identifies which OMEMO wire-format generation a message, device
|
||||||
// list, or bundle uses - the full namespace+XML dialect, not just the
|
// 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
|
// libsignalVersion returns the underlying Signal-protocol wire version
|
||||||
// this OMEMO version uses. Omemo1 and Omemo2 share the same crypto-engine
|
// this OMEMO version uses. Omemo1 and Omemo2 share the same crypto-engine
|
||||||
// version - libomemo-c itself only branches on "message_version >= 4", not
|
// version - libomemo-c itself only branches on "message_version >= 4", not
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ var _ libsignal.Store = (*Store)(nil)
|
||||||
// convention):
|
// convention):
|
||||||
//
|
//
|
||||||
// <login>/<owner>/enabled
|
// <login>/<owner>/enabled
|
||||||
|
// <login>/<owner>/negotiatedversion
|
||||||
// <login>/<owner>/identity/keypair
|
// <login>/<owner>/identity/keypair
|
||||||
// <login>/<owner>/identity/registrationid
|
// <login>/<owner>/identity/registrationid
|
||||||
// <login>/<owner>/identity/signedprekey/<id>
|
// <login>/<owner>/identity/signedprekey/<id>
|
||||||
|
|
@ -43,6 +44,10 @@ func (s *Store) enabledKey() []byte {
|
||||||
return []byte(fmt.Sprintf("%s/%s/enabled", s.login, s.owner))
|
return []byte(fmt.Sprintf("%s/%s/enabled", s.login, s.owner))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) negotiatedVersionKey() []byte {
|
||||||
|
return []byte(fmt.Sprintf("%s/%s/negotiatedversion", s.login, s.owner))
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) identityKeyPairKey() []byte {
|
func (s *Store) identityKeyPairKey() []byte {
|
||||||
return []byte(fmt.Sprintf("%s/%s/identity/keypair", s.login, s.owner))
|
return []byte(fmt.Sprintf("%s/%s/identity/keypair", s.login, s.owner))
|
||||||
}
|
}
|
||||||
|
|
@ -413,6 +418,28 @@ func (s *Store) SetEnabled(enabled bool) error {
|
||||||
return s.set(s.enabledKey(), []byte{v})
|
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
|
// RemoteDeviceListCache returns the last raw device-list document cached
|
||||||
// for remote (see e2ee.DeviceListDoc), and whether one was found - used to
|
// for remote (see e2ee.DeviceListDoc), and whether one was found - used to
|
||||||
// decide whether a fresh PEP fetch is needed before encrypting.
|
// decide whether a fresh PEP fetch is needed before encrypting.
|
||||||
|
|
|
||||||
|
|
@ -24,12 +24,21 @@ type Envelope struct {
|
||||||
Raw []byte // backend-specific serialized envelope
|
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 {
|
type Namespaces struct {
|
||||||
Disco []string // disco#info features to advertise (e.g. "...+notify" hints)
|
Disco []string // disco#info features to advertise, across every supported Variant (e.g. "...+notify" hints)
|
||||||
EME string // urn:xmpp:eme:0 encryption-namespace hint for non-supporting clients
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// DeviceListDoc is an opaque, backend-defined serialization of a peer's
|
||||||
// PEP device-list document (fetched from them, or published for a chat
|
// PEP device-list document (fetched from them, or published for a chat
|
||||||
// pseudo-JID we own).
|
// pseudo-JID we own).
|
||||||
|
|
@ -55,20 +64,53 @@ type Backend interface {
|
||||||
// Namespaces lists what this backend wants advertised/served.
|
// Namespaces lists what this backend wants advertised/served.
|
||||||
Namespaces() Namespaces
|
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
|
// EnsureIdentity creates (idempotently) a local cryptographic identity
|
||||||
// for a PeerID this gateway owns (a bridged chat pseudo-JID - built via
|
// 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
|
// OwnedPeer, since a chat pseudo-JID alone isn't globally unique across
|
||||||
// telegabber's multiple bridged Telegram logins), generating and
|
// 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
|
EnsureIdentity(peer PeerID) error
|
||||||
|
|
||||||
// PublishedIdentity returns the material this gateway must SERVE to
|
// PublishedIdentity returns the material this gateway must SERVE to
|
||||||
// pubsub GET requests for its own PeerID (an OwnedPeer - see
|
// pubsub GET requests for its own PeerID (an OwnedPeer - see
|
||||||
// EnsureIdentity) device list, pre-marshaled to opaque bytes the xmpp
|
// EnsureIdentity) device list under the given variant, pre-marshaled to
|
||||||
// layer wraps in <items>/<item>.
|
// opaque bytes the xmpp layer wraps in <items>/<item>.
|
||||||
PublishedIdentity(peer PeerID) (DeviceListDoc, error)
|
PublishedIdentity(peer PeerID, variant Variant) (DeviceListDoc, error)
|
||||||
// PublishedBundle is the same, for one specific device's bundle node.
|
// 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
|
// IngestRemoteDeviceList / IngestRemoteBundle feed the results of an
|
||||||
// outbound PEP fetch (device-list, then per-device bundle) for a real
|
// 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
|
// gateway's identities is doing the fetching; peer is the remote's
|
||||||
// plain bare JID, no scoping needed) into the backend's store,
|
// plain bare JID, no scoping needed) into the backend's store,
|
||||||
// establishing/refreshing sessions as needed (trust decisions happen
|
// establishing/refreshing sessions as needed (trust decisions happen
|
||||||
// inside here).
|
// inside here). IngestRemoteDeviceList returns the device ids it found,
|
||||||
IngestRemoteDeviceList(owner PeerID, peer PeerID, doc DeviceListDoc) error
|
// 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
|
IngestRemoteBundle(owner PeerID, peer PeerID, device DeviceID, doc BundleDoc) error
|
||||||
|
|
||||||
// Encrypt produces an opaque envelope addressed to every known device
|
// 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
|
// Decrypt consumes an inbound envelope addressed to "to" (an
|
||||||
// OwnedPeer - see EnsureIdentity) from "from" (the real remote peer's
|
// 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)
|
Decrypt(from PeerID, to PeerID, env Envelope) ([]byte, error)
|
||||||
|
|
||||||
// Devices lists peer's known devices, as seen from owner's (an
|
// Devices lists peer's known devices, as seen from owner's (an
|
||||||
// OwnedPeer) point of view - for trust-listing ad-hoc commands.
|
// OwnedPeer) point of view - for trust-listing ad-hoc commands.
|
||||||
Devices(owner PeerID, peer PeerID) ([]DeviceInfo, error)
|
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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,9 @@ func main() {
|
||||||
var schemaPath = flag.String("schema", "./config_schema.json", "Schema file path")
|
var schemaPath = flag.String("schema", "./config_schema.json", "Schema file path")
|
||||||
// Folder for Badger DB of message ids
|
// Folder for Badger DB of message ids
|
||||||
var idsPath = flag.String("ids", "ids", "Ids folder path")
|
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")
|
var versionFlag = flag.Bool("version", false, "Print the version and exit")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
|
|
@ -68,7 +71,7 @@ func main() {
|
||||||
|
|
||||||
log.Infof("Starting telegabber version %v", version)
|
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 {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,12 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"dev.narayana.im/narayana/telegabber/e2ee"
|
||||||
"dev.narayana.im/narayana/telegabber/telegram/formatter"
|
"dev.narayana.im/narayana/telegabber/telegram/formatter"
|
||||||
"dev.narayana.im/narayana/telegabber/xmpp/gateway"
|
"dev.narayana.im/narayana/telegabber/xmpp/gateway"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/pkg/errors"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"github.com/zelenin/go-tdlib/client"
|
"github.com/zelenin/go-tdlib/client"
|
||||||
)
|
)
|
||||||
|
|
@ -403,11 +405,34 @@ func (c *Client) updateMessageContent(update *client.UpdateMessageContent) {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
id = id+":"+uuid.String()
|
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 {
|
for _, jid := range jids {
|
||||||
if safeToSend {
|
if safeToSend {
|
||||||
gateway.SendMessage(jid, from, c.xmpp,
|
gateway.SendMessage(jid, from, c.xmpp,
|
||||||
gateway.SMBody(text.String()), gateway.SMId(id), gateway.SMReplaceId(replaceId),
|
gateway.SMBody(text.String()), gateway.SMId(id), gateway.SMReplaceId(replaceId),
|
||||||
gateway.SMIsCarbon(isCarbon), gateway.SMIsGroupchat(isMUC), gateway.SMOriginalFrom(originalFrom),
|
gateway.SMIsCarbon(isCarbon), gateway.SMIsGroupchat(isMUC), gateway.SMOriginalFrom(originalFrom),
|
||||||
|
gateway.SMOMEMOEnvelope(envelope),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
gateway.SendMUCAnnouncement(jid, from, text.String(), nickname, id, c.xmpp)
|
gateway.SendMUCAnnouncement(jid, from, text.String(), nickname, id, c.xmpp)
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"dev.narayana.im/narayana/telegabber/e2ee"
|
||||||
"dev.narayana.im/narayana/telegabber/telegram/cache"
|
"dev.narayana.im/narayana/telegabber/telegram/cache"
|
||||||
"dev.narayana.im/narayana/telegabber/telegram/formatter"
|
"dev.narayana.im/narayana/telegabber/telegram/formatter"
|
||||||
"dev.narayana.im/narayana/telegabber/xmpp/gateway"
|
"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)
|
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 {
|
for _, jid := range jids {
|
||||||
commonArgs := []args.V{
|
commonArgs := []args.V{
|
||||||
gateway.SMReply(reply), gateway.SMTimestamp(timestamp), gateway.SMIsCarbon(isCarbon),
|
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.SMMucJID(mucJID), gateway.SMMucUserItem(mucUserItem),
|
||||||
}
|
}
|
||||||
gateway.SendMessage(jid, from, c.xmpp, append(commonArgs,
|
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 != "" {
|
if auxText != "" {
|
||||||
gateway.SendMessage(jid, from, c.xmpp, append(commonArgs,
|
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)
|
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
|
// 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) {
|
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)
|
msgId, _ := gateway.IdsDB.GetByTgIds(c.Session.Login, c.jid, chatId, message.Id)
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,10 @@ import (
|
||||||
|
|
||||||
"dev.narayana.im/narayana/telegabber/badger"
|
"dev.narayana.im/narayana/telegabber/badger"
|
||||||
"dev.narayana.im/narayana/telegabber/config"
|
"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/persistence"
|
||||||
"dev.narayana.im/narayana/telegabber/telegram"
|
"dev.narayana.im/narayana/telegabber/telegram"
|
||||||
"dev.narayana.im/narayana/telegabber/xmpp/gateway"
|
"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
|
// NewComponent starts a new component and wraps it in
|
||||||
// a stream manager that you should start yourself
|
// 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
|
var err error
|
||||||
|
|
||||||
|
if err := setupE2EE(e2eeDbPath, conf.E2EE); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
gateway.Jid, err = stanza.NewJid(conf.Jid)
|
gateway.Jid, err = stanza.NewJid(conf.Jid)
|
||||||
gateway.Version = version
|
gateway.Version = version
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -103,6 +111,34 @@ func NewComponent(conf config.XMPPConfig, tc config.TelegramConfig, idsPath stri
|
||||||
return sm, component, nil
|
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) {
|
func heartbeat(component *xmpp.Component) {
|
||||||
var err error
|
var err error
|
||||||
probeType := gateway.SPType("probe")
|
probeType := gateway.SPType("probe")
|
||||||
|
|
@ -264,6 +300,13 @@ func Close(component *xmpp.Component) {
|
||||||
// flush the ids database
|
// flush the ids database
|
||||||
gateway.IdsDB.Close()
|
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
|
// close stream
|
||||||
component.Disconnect()
|
component.Disconnect()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
129
xmpp/extensions/omemo.go
Normal file
129
xmpp/extensions/omemo.go
Normal 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{})
|
||||||
|
}
|
||||||
146
xmpp/extensions/omemo_test.go
Normal file
146
xmpp/extensions/omemo_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,8 @@ import (
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"dev.narayana.im/narayana/telegabber/badger"
|
"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"
|
"dev.narayana.im/narayana/telegabber/xmpp/extensions"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
@ -67,6 +69,15 @@ var Version string
|
||||||
// IdsDB provides a disk-backed bidirectional dictionary of Telegram and XMPP ids
|
// IdsDB provides a disk-backed bidirectional dictionary of Telegram and XMPP ids
|
||||||
var IdsDB badger.IdsDB
|
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
|
// DirtySessions denotes that some Telegram session configurations
|
||||||
// were changed and need to be re-flushed to the YamlDB
|
// were changed and need to be re-flushed to the YamlDB
|
||||||
var DirtySessions = false
|
var DirtySessions = false
|
||||||
|
|
@ -283,6 +294,11 @@ var SMMucJID = args.NewString()
|
||||||
// SMMucUserItem is a XEP-0045 muc#user item (*MUCUserItem)
|
// SMMucUserItem is a XEP-0045 muc#user item (*MUCUserItem)
|
||||||
var SMMucUserItem = args.New()
|
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) {
|
func sendMessageWrapper(to, from string, component *xmpp.Component, args ...args.V) {
|
||||||
body := SMBody.Get(args)
|
body := SMBody.Get(args)
|
||||||
subject := SMSubject.Get(args)
|
subject := SMSubject.Get(args)
|
||||||
|
|
@ -305,6 +321,7 @@ func sendMessageWrapper(to, from string, component *xmpp.Component, args ...args
|
||||||
mamQueryId := SMMamQueryId.Get(args)
|
mamQueryId := SMMamQueryId.Get(args)
|
||||||
mucJID := SMMucJID.Get(args)
|
mucJID := SMMucJID.Get(args)
|
||||||
mucUserItem, _ := SMMucUserItem.Get(args).(*MUCUserItem)
|
mucUserItem, _ := SMMucUserItem.Get(args).(*MUCUserItem)
|
||||||
|
envelope, _ := SMOMEMOEnvelope.Get(args).(*e2ee.Envelope)
|
||||||
|
|
||||||
toJid, err := stanza.NewJid(to)
|
toJid, err := stanza.NewJid(to)
|
||||||
if err != nil {
|
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 != "" {
|
if oob != "" {
|
||||||
message.Extensions = append(message.Extensions, stanza.OOB{
|
message.Extensions = append(message.Extensions, stanza.OOB{
|
||||||
URL: oob,
|
URL: oob,
|
||||||
|
|
|
||||||
87
xmpp/gateway/omemo.go
Normal file
87
xmpp/gateway/omemo.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,7 @@ import (
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"dev.narayana.im/narayana/telegabber/e2ee"
|
||||||
"dev.narayana.im/narayana/telegabber/persistence"
|
"dev.narayana.im/narayana/telegabber/persistence"
|
||||||
"dev.narayana.im/narayana/telegabber/telegram"
|
"dev.narayana.im/narayana/telegabber/telegram"
|
||||||
"dev.narayana.im/narayana/telegabber/xmpp/extensions"
|
"dev.narayana.im/narayana/telegabber/xmpp/extensions"
|
||||||
|
|
@ -177,7 +178,7 @@ func HandleMessage(s xmpp.Sender, p stanza.Packet) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if msg.Type != "error" && msg.Body != "" {
|
if msg.Type != "error" && (msg.Body != "" || hasOMEMOPayload(msg)) {
|
||||||
log.WithFields(log.Fields{
|
log.WithFields(log.Fields{
|
||||||
"from": msg.From,
|
"from": msg.From,
|
||||||
"to": msg.To,
|
"to": msg.To,
|
||||||
|
|
@ -245,6 +246,44 @@ func HandleMessage(s xmpp.Sender, p stanza.Packet) {
|
||||||
|
|
||||||
var replyId int64
|
var replyId int64
|
||||||
text := msg.Body
|
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 {
|
if len(reply.Id) > 0 {
|
||||||
chatId, msgId, err := gateway.IdsDB.GetByXmppId(session.Session.Login, bare, reply.Id)
|
chatId, msgId, err := gateway.IdsDB.GetByXmppId(session.Session.Login, bare, reply.Id)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|
|
||||||
116
xmpp/omemo.go
Normal file
116
xmpp/omemo.go
Normal 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
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue