telegabber/xmpp/e2ee.go
2026-07-29 01:32:16 -04:00

117 lines
3.5 KiB
Go

package xmpp
import (
"encoding/xml"
"github.com/pkg/errors"
"dev.narayana.im/narayana/telegabber/e2ee"
"dev.narayana.im/narayana/telegabber/xmpp/gateway"
log "github.com/sirupsen/logrus"
"gosrc.io/xmpp"
"gosrc.io/xmpp/stanza"
)
// handleGetE2EEPubSubIq answers a PEP GET request for one of the e2ee
// backend's own device-list/bundle nodes, in whichever of its Variants the
// request actually names - available whenever the E2EE feature is
// deploy-enabled at all (config's :xmpp: :e2ee: :enabled), regardless of
// whether this specific chat has actually gone OMEMO-active yet. This is
// deliberate, not an oversight: a real client can only ever decide to
// start speaking OMEMO to us once it's already able to fetch our bundle,
// so serving must not be gated behind the same per-chat/account flag that
// gates outbound encryption (see the plan's enablement model).
func handleGetE2EEPubSubIq(s xmpp.Sender, iq *stanza.IQ, pubsub *stanza.PubSubGeneric) {
component, answer, ok := iqResultStub(s, iq)
if !ok {
return
}
defer gateway.ResumableSend(component, answer)
backend, ok := gateway.E2EE.Backend()
if !ok {
iqAnswerSetError(answer, 404)
return
}
_, toOk, toIsGroup := toToID(iq.To)
if !toOk || toIsGroup {
// e2ee identities only ever exist for personal-chat pseudo-JIDs -
// true MUC/XEP-0045 groupchat OMEMO is out of scope entirely (see
// the plan doc).
iqAnswerSetError(answer, 404)
return
}
toJid, err := stanza.NewJid(iq.To)
if err != nil {
iqAnswerSetError(answer, 400)
return
}
bare, _, fromOk := gateway.SplitJID(iq.From)
if !fromOk {
iqAnswerSetError(answer, 400)
return
}
session, sessionOk := sessions[bare]
if !sessionOk {
log.Errorf("E2EE PEP IQ from stranger %v", iq.From)
iqAnswerSetError(answer, 403)
return
}
owner := e2ee.OwnedPeer(session.Session.Login, toJid.Bare())
if err := backend.EnsureIdentity(owner); err != nil {
log.Error(errors.Wrap(err, "Failed to ensure e2ee identity"))
iqAnswerSetError(answer, 500)
return
}
for _, variant := range backend.Variants() {
if pubsub.Items.Node == backend.DeviceListNode(variant) {
doc, err := backend.PublishedIdentity(owner, variant)
if err != nil {
log.Error(errors.Wrap(err, "Failed to publish e2ee device list"))
iqAnswerSetError(answer, 500)
return
}
setE2EEItemsAnswer(answer, pubsub.Items.Node, "current", doc.Raw)
return
}
if pubsub.Items.Node == backend.BundleNode(variant, backend.OwnDevice()) {
doc, err := backend.PublishedBundle(owner, backend.OwnDevice(), variant)
if err != nil {
log.Error(errors.Wrap(err, "Failed to publish e2ee bundle"))
iqAnswerSetError(answer, 500)
return
}
setE2EEItemsAnswer(answer, pubsub.Items.Node, string(backend.OwnDevice()), doc.Raw)
return
}
}
// Node didn't match any variant this backend currently supports.
iqAnswerSetError(answer, 404)
}
// setE2EEItemsAnswer embeds raw (an e2ee.DeviceListDoc/BundleDoc's already-
// marshaled XML) as the single <item> of a pubsub#items result - raw is
// re-parsed into a generic stanza.Node rather than injected as a string,
// since stanza.Item.Any expects a structured node, not raw bytes.
func setE2EEItemsAnswer(answer *stanza.IQ, node, itemID string, raw []byte) {
var content stanza.Node
if err := xml.Unmarshal(raw, &content); err != nil {
log.Error(errors.Wrap(err, "Failed to unmarshal e2ee document"))
iqAnswerSetError(answer, 500)
return
}
answer.Payload = &stanza.PubSubGeneric{
Items: &stanza.Items{
Node: node,
List: []stanza.Item{
{Id: itemID, Any: &content},
},
},
}
}