Implement OMEMO package

This commit is contained in:
Bohdan Horbeshko 2026-07-28 20:32:56 -04:00
parent 5e71ba954c
commit 6a7cd095e9
23 changed files with 1291 additions and 162 deletions

126
e2ee/omemo/backend_test.go Normal file
View file

@ -0,0 +1,126 @@
package omemo_test
import (
"bytes"
"testing"
"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"
)
func newTestBackend(t *testing.T, version omemo.Version) *omemo.Backend {
t.Helper()
ctx, err := libsignal.NewContext()
if err != nil {
t.Fatalf("NewContext: %v", err)
}
t.Cleanup(ctx.Close)
db, err := badgerstore.Open(t.TempDir(), nil)
if err != nil {
t.Fatalf("badgerstore.Open: %v", err)
}
t.Cleanup(func() { db.Close() })
return omemo.New(ctx, db, version)
}
// TestBackendRoundTrip exercises the full e2ee.Backend interface, not just
// the underlying libsignal primitives (already covered by libsignal's own
// round-trip test): EnsureIdentity, publishing a device-list/bundle,
// ingesting them on the other side, and Encrypt/Decrypt in both
// directions. This simulates two independent parties - "gateway" (the
// bridged chat pseudo-JID's owned identity) and "client" (the real XMPP
// user's own device) - each with their own Backend/store, exchanging
// PublishedIdentity/PublishedBundle results directly in place of the real
// PEP fetch (e2ee/fetch.go's actual IQ round trip isn't built yet).
func TestBackendRoundTrip(t *testing.T) {
const gatewayLogin = "telegram-login-1"
const gatewayBareJID = "12345@transport.example"
const realUserBareJID = "alice@real.example"
for _, tc := range []struct {
name string
version omemo.Version
}{
{"omemo0", omemo.Omemo0},
{"omemo1", omemo.Omemo1},
{"omemo2", omemo.Omemo2},
} {
t.Run(tc.name, func(t *testing.T) {
if tc.version != omemo.Omemo0 && !libsignal.ProtocolV4Supported {
t.Skip("protocol v4 (omemo1/omemo2) not supported by this build (signal_legacy tag)")
}
gateway := newTestBackend(t, tc.version)
client := newTestBackend(t, tc.version)
gatewayOwned := e2ee.OwnedPeer(gatewayLogin, gatewayBareJID)
clientOwned := e2ee.OwnedPeer("n/a", realUserBareJID)
realUser := e2ee.PeerID(realUserBareJID) // how the gateway refers to the real user
gatewayAsPeer := e2ee.PeerID(gatewayBareJID) // how the client refers to the gateway
if err := gateway.EnsureIdentity(gatewayOwned); err != nil {
t.Fatalf("gateway.EnsureIdentity: %v", err)
}
if err := client.EnsureIdentity(clientOwned); err != nil {
t.Fatalf("client.EnsureIdentity: %v", err)
}
// Gateway "fetches" the client's device list + bundle.
clientDeviceList, err := client.PublishedIdentity(clientOwned)
if err != nil {
t.Fatalf("client.PublishedIdentity: %v", err)
}
if err := gateway.IngestRemoteDeviceList(gatewayOwned, realUser, clientDeviceList); err != nil {
t.Fatalf("gateway.IngestRemoteDeviceList: %v", err)
}
clientBundle, err := client.PublishedBundle(clientOwned, omemo.OwnDeviceID)
if err != nil {
t.Fatalf("client.PublishedBundle: %v", err)
}
if err := gateway.IngestRemoteBundle(gatewayOwned, realUser, omemo.OwnDeviceID, clientBundle); err != nil {
t.Fatalf("gateway.IngestRemoteBundle: %v", err)
}
// Gateway encrypts a message originating from Telegram.
plaintext1 := []byte("hello from telegram")
env1, err := gateway.Encrypt(gatewayOwned, []e2ee.PeerID{realUser}, plaintext1)
if err != nil {
t.Fatalf("gateway.Encrypt: %v", err)
}
// Client decrypts it - this establishes its side of the
// session as a side effect, with no prior bundle fetch needed
// on its end (matching the prekey-message responder flow
// already validated at the libsignal level).
got1, err := client.Decrypt(gatewayAsPeer, clientOwned, env1)
if err != nil {
t.Fatalf("client.Decrypt: %v", err)
}
if !bytes.Equal(got1, plaintext1) {
t.Fatalf("round trip mismatch: got %q, want %q", got1, plaintext1)
}
// 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)
}
})
}
}

198
e2ee/omemo/bundle.go Normal file
View file

@ -0,0 +1,198 @@
package omemo
import (
"encoding/base64"
"encoding/xml"
"fmt"
"dev.narayana.im/narayana/telegabber/e2ee/omemo/libsignal"
)
// Bundle document parsing/encoding for all three OMEMO versions. As with
// devicelist.go, Omemo1 and Omemo2 share an identical element shape (spk/
// spks/ik/prekeys/pk) - only the namespace differs, per the same
// changelog-silence reasoning - so one xepBundle struct serves both,
// namespace-agnostic on Unmarshal, explicit on Marshal.
//
// Omemo0's element names (signedPreKeyPublic/signedPreKeySignature/
// identityKey/prekeys/preKeyPublic) do NOT carry over to Omemo1/Omemo2 -
// confirmed against the XEP-0384 XML schema directly, not assumed.
//
// None of the three bundle formats carry a registration id (unlike
// vanilla Signal protocol's own prekey-bundle API, which does) - OMEMO
// simply doesn't use it (XEP-0384's OMEMOKeyExchange has no such field
// either). parseBundle leaves RegistrationID as 0; callers pass that
// straight through to libsignal.RemoteBundle, which is the standard,
// harmless practice for this field in OMEMO implementations - device_id
// already does what registration_id would otherwise disambiguate.
type omemo0Bundle struct {
XMLName xml.Name `xml:"eu.siacs.conversations.axolotl bundle"`
SignedPreKeyPublic omemo0IDText `xml:"signedPreKeyPublic"`
SignedPreKeySignature string `xml:"signedPreKeySignature"`
IdentityKey string `xml:"identityKey"`
PreKeys omemo0PreKeysWrap `xml:"prekeys"`
}
type omemo0IDText struct {
ID uint32 `xml:"signedPreKeyId,attr"`
Text string `xml:",chardata"`
}
type omemo0PreKeysWrap struct {
List []omemo0PreKey `xml:"preKeyPublic"`
}
type omemo0PreKey struct {
ID uint32 `xml:"preKeyId,attr"`
Text string `xml:",chardata"`
}
type xepBundle struct {
XMLName xml.Name `xml:"bundle"`
SPK xepIDText `xml:"spk"`
SPKS string `xml:"spks"`
IK string `xml:"ik"`
PreKeys xepPreKeysWrap `xml:"prekeys"`
}
type xepIDText struct {
ID uint32 `xml:"id,attr"`
Text string `xml:",chardata"`
}
type xepPreKeysWrap struct {
List []xepIDText `xml:"pk"`
}
// parsedBundle is the common, namespace-agnostic result of parsing any of
// the three bundle formats, in the raw (still base64-decoded) bytes
// libsignal expects.
type parsedBundle struct {
SignedPreKeyID uint32
SignedPreKeyPublic []byte
SignedPreKeySignature []byte
IdentityKeyPublic []byte
PreKeys []parsedPreKey
}
type parsedPreKey struct {
ID uint32
Public []byte
}
func parseBundle(raw []byte) (*parsedBundle, error) {
var omemo0 omemo0Bundle
if err := xml.Unmarshal(raw, &omemo0); err == nil {
return decodeOmemo0Bundle(omemo0)
}
var xep xepBundle
if err := xml.Unmarshal(raw, &xep); err != nil {
return nil, err
}
return decodeXepBundle(xep)
}
func decodeOmemo0Bundle(b omemo0Bundle) (*parsedBundle, error) {
spk, err := b64Decode(b.SignedPreKeyPublic.Text)
if err != nil {
return nil, fmt.Errorf("omemo: omemo0 bundle signedPreKeyPublic: %w", err)
}
sig, err := b64Decode(b.SignedPreKeySignature)
if err != nil {
return nil, fmt.Errorf("omemo: omemo0 bundle signedPreKeySignature: %w", err)
}
ik, err := b64Decode(b.IdentityKey)
if err != nil {
return nil, fmt.Errorf("omemo: omemo0 bundle identityKey: %w", err)
}
result := &parsedBundle{
SignedPreKeyID: b.SignedPreKeyPublic.ID,
SignedPreKeyPublic: spk,
SignedPreKeySignature: sig,
IdentityKeyPublic: ik,
}
for _, pk := range b.PreKeys.List {
data, err := b64Decode(pk.Text)
if err != nil {
return nil, fmt.Errorf("omemo: omemo0 bundle preKeyPublic %d: %w", pk.ID, err)
}
result.PreKeys = append(result.PreKeys, parsedPreKey{ID: pk.ID, Public: data})
}
return result, nil
}
func decodeXepBundle(b xepBundle) (*parsedBundle, error) {
spk, err := b64Decode(b.SPK.Text)
if err != nil {
return nil, fmt.Errorf("omemo: bundle spk: %w", err)
}
sig, err := b64Decode(b.SPKS)
if err != nil {
return nil, fmt.Errorf("omemo: bundle spks: %w", err)
}
ik, err := b64Decode(b.IK)
if err != nil {
return nil, fmt.Errorf("omemo: bundle ik: %w", err)
}
result := &parsedBundle{
SignedPreKeyID: b.SPK.ID,
SignedPreKeyPublic: spk,
SignedPreKeySignature: sig,
IdentityKeyPublic: ik,
}
for _, pk := range b.PreKeys.List {
data, err := b64Decode(pk.Text)
if err != nil {
return nil, fmt.Errorf("omemo: bundle pk %d: %w", pk.ID, err)
}
result.PreKeys = append(result.PreKeys, parsedPreKey{ID: pk.ID, Public: data})
}
return result, nil
}
// encodeBundle produces a bundle document for one of this gateway's own
// published devices, in the namespace matching version. Public keys are
// expected in the form DecodeSignedPreKey/DecodePreKey/
// DecodeIdentityPublicKey return - i.e. already picking the signature form
// (Omemo0 vs Omemo1/Omemo2) matching version, per the rule documented on
// libsignal.SignedPreKeyInfo.
func encodeBundle(version Version, identityKeyPublic []byte, spkID uint32, spkPublic, spkSignature []byte, preKeys []libsignal.PreKeyInfo) ([]byte, error) {
if version == Omemo0 {
b := omemo0Bundle{
SignedPreKeyPublic: omemo0IDText{ID: spkID, Text: b64Encode(spkPublic)},
SignedPreKeySignature: b64Encode(spkSignature),
IdentityKey: b64Encode(identityKeyPublic),
}
for _, pk := range preKeys {
b.PreKeys.List = append(b.PreKeys.List, omemo0PreKey{ID: pk.ID, Text: b64Encode(pk.PublicKey)})
}
return xml.Marshal(b)
}
ns := omemo1NS
if version == Omemo2 {
ns = omemo2NS
}
b := xepBundle{
XMLName: xml.Name{Space: ns, Local: "bundle"},
SPK: xepIDText{ID: spkID, Text: b64Encode(spkPublic)},
SPKS: b64Encode(spkSignature),
IK: b64Encode(identityKeyPublic),
}
for _, pk := range preKeys {
b.PreKeys.List = append(b.PreKeys.List, xepIDText{ID: pk.ID, Text: b64Encode(pk.PublicKey)})
}
return xml.Marshal(b)
}
func b64Encode(b []byte) string {
return base64.StdEncoding.EncodeToString(b)
}
func b64Decode(s string) ([]byte, error) {
return base64.StdEncoding.DecodeString(s)
}

83
e2ee/omemo/devicelist.go Normal file
View file

@ -0,0 +1,83 @@
package omemo
import "encoding/xml"
// Device-list document parsing/encoding for all three OMEMO versions.
// Omemo1 and Omemo2 share an identical element shape (only the namespace
// differs - the XEP-0384 0.8.0 changelog only calls out SCE-related and
// namespace renames, nothing about the device-list/bundle schema itself,
// and 0.4.0/Omemo1's own changelog entry that introduced this schema
// doesn't mention it changing again later), so one xepDeviceList struct
// serves both: its XMLName tag omits the namespace, which makes Go's
// encoding/xml match on local name only during Unmarshal (so it accepts
// either namespace), while Marshal gets the right namespace by having the
// caller set XMLName explicitly before encoding.
// Namespace strings shared by both the device-list and bundle documents
// (see bundle.go) - both document kinds are just different local element
// names within the same OMEMO namespace.
const (
omemo0NS = "eu.siacs.conversations.axolotl"
omemo1NS = "urn:xmpp:omemo:1"
omemo2NS = "urn:xmpp:omemo:2"
)
type omemo0DeviceList struct {
XMLName xml.Name `xml:"eu.siacs.conversations.axolotl list"`
Devices []omemo0Device `xml:"device"`
}
type omemo0Device struct {
ID uint32 `xml:"id,attr"`
}
type xepDeviceList struct {
XMLName xml.Name `xml:"devices"`
Devices []xepDevice `xml:"device"`
}
type xepDevice struct {
ID uint32 `xml:"id,attr"`
Label string `xml:"label,attr,omitempty"`
LabelSig string `xml:"labelsig,attr,omitempty"`
}
// parseDeviceList extracts device ids from a device-list document, in
// whichever of the three known namespaces it's in.
func parseDeviceList(raw []byte) ([]uint32, error) {
var omemo0 omemo0DeviceList
if err := xml.Unmarshal(raw, &omemo0); err == nil {
ids := make([]uint32, len(omemo0.Devices))
for i, d := range omemo0.Devices {
ids[i] = d.ID
}
return ids, nil
}
var xep xepDeviceList
if err := xml.Unmarshal(raw, &xep); err != nil {
return nil, err
}
ids := make([]uint32, len(xep.Devices))
for i, d := range xep.Devices {
ids[i] = d.ID
}
return ids, nil
}
// encodeDeviceList produces a device-list document for this gateway's own
// published identity, in the namespace matching version.
func encodeDeviceList(version Version, deviceID uint32) ([]byte, error) {
if version == Omemo0 {
return xml.Marshal(omemo0DeviceList{Devices: []omemo0Device{{ID: deviceID}}})
}
ns := omemo1NS
if version == Omemo2 {
ns = omemo2NS
}
list := xepDeviceList{
XMLName: xml.Name{Space: ns, Local: "devices"},
Devices: []xepDevice{{ID: deviceID}},
}
return xml.Marshal(list)
}

149
e2ee/omemo/encrypt.go Normal file
View file

@ -0,0 +1,149 @@
package omemo
import (
"errors"
"fmt"
"dev.narayana.im/narayana/telegabber/e2ee"
"dev.narayana.im/narayana/telegabber/e2ee/omemo/libsignal"
)
func (b *Backend) Encrypt(from e2ee.PeerID, to []e2ee.PeerID, plaintext []byte) (e2ee.Envelope, error) {
login, owner, ok := e2ee.SplitOwnedPeer(from)
if !ok {
return e2ee.Envelope{}, fmt.Errorf("omemo: Encrypt: %q is not an OwnedPeer", from)
}
b.mu.Lock()
defer b.mu.Unlock()
store := b.db.Store(b.libctx, login, owner)
// Outer content encryption happens once per message, regardless of how
// many recipient devices it ends up going to - see envelope.go.
payload, err := EncryptOuter(b.version, plaintext, owner)
if err != nil {
return e2ee.Envelope{}, fmt.Errorf("omemo: EncryptOuter: %w", err)
}
storeCtx, err := libsignal.NewStoreContext(b.libctx, store)
if err != nil {
return e2ee.Envelope{}, fmt.Errorf("omemo: NewStoreContext: %w", err)
}
defer storeCtx.Close()
env := &WireEnvelope{
Version: b.version,
SenderSID: ownDeviceIDNum,
IV: payload.IV,
Payload: payload.Ciphertext,
}
for _, peer := range to {
deviceIDs, err := store.GetSubDeviceSessions(string(peer))
if err != nil {
return e2ee.Envelope{}, fmt.Errorf("omemo: GetSubDeviceSessions(%s): %w", peer, err)
}
if len(deviceIDs) == 0 {
return e2ee.Envelope{}, fmt.Errorf("omemo: Encrypt: no known devices/sessions for %s (IngestRemoteBundle was not called)", peer)
}
for _, deviceID := range deviceIDs {
cipher, err := libsignal.NewSessionCipher(b.libctx, storeCtx, libsignal.Address{Name: string(peer), DeviceID: deviceID}, b.version.libsignalVersion())
if err != nil {
return e2ee.Envelope{}, fmt.Errorf("omemo: NewSessionCipher(%s:%d): %w", peer, deviceID, err)
}
ct, err := cipher.Encrypt(payload.ContentKey)
cipher.Close()
if err != nil {
return e2ee.Envelope{}, fmt.Errorf("omemo: Encrypt(%s:%d): %w", peer, deviceID, err)
}
env.Keys = append(env.Keys, WireKey{
RecipientJID: string(peer),
DeviceID: deviceID,
IsPreKey: ct.Type == libsignal.CiphertextPreKeyType,
Ciphertext: ct.Serialized,
})
}
}
raw, err := env.Encode()
if err != nil {
return e2ee.Envelope{}, err
}
return e2ee.Envelope{Backend: Name, Raw: raw}, nil
}
func (b *Backend) Decrypt(from, to e2ee.PeerID, env e2ee.Envelope) ([]byte, error) {
if env.Backend != Name {
return nil, fmt.Errorf("omemo: Decrypt: envelope backend %q, want %q", env.Backend, Name)
}
login, owner, ok := e2ee.SplitOwnedPeer(to)
if !ok {
return nil, fmt.Errorf("omemo: Decrypt: %q is not an OwnedPeer", to)
}
wireEnv, err := DecodeWireEnvelope(env.Raw)
if err != nil {
return nil, fmt.Errorf("omemo: DecodeWireEnvelope: %w", err)
}
var keyForUs *WireKey
for i := range wireEnv.Keys {
if wireEnv.Keys[i].DeviceID == ownDeviceIDNum {
keyForUs = &wireEnv.Keys[i]
break
}
}
if keyForUs == nil {
return nil, errors.New("omemo: Decrypt: no <key> addressed to our own device in this envelope")
}
b.mu.Lock()
defer b.mu.Unlock()
store := b.db.Store(b.libctx, login, owner)
regID, err := store.GetLocalRegistrationID()
if err != nil {
return nil, fmt.Errorf("omemo: GetLocalRegistrationID: %w", err)
}
storeCtx, err := libsignal.NewStoreContext(b.libctx, store)
if err != nil {
return nil, fmt.Errorf("omemo: NewStoreContext: %w", err)
}
defer storeCtx.Close()
// The cipher's configured version must match what the SENDER actually
// used (wireEnv.Version, derived from which XML namespace their
// stanza used), not this backend's own outgoing preference (b.version)
// - a remote peer's message version isn't guaranteed to match ours,
// and this is the one case telegabber's own M1 round-trip test never
// exercised (both sides there always shared the same configured
// version), so this is deliberately conservative rather than assumed.
cipher, err := libsignal.NewSessionCipher(b.libctx, storeCtx, libsignal.Address{Name: string(from), DeviceID: wireEnv.SenderSID}, wireEnv.Version.libsignalVersion())
if err != nil {
return nil, fmt.Errorf("omemo: NewSessionCipher: %w", err)
}
defer cipher.Close()
msgType := libsignal.CiphertextSignalType
if keyForUs.IsPreKey {
msgType = libsignal.CiphertextPreKeyType
}
// Omemo1 and Omemo2 both use the OMEMOKeyExchange/OMEMOAuthenticatedMessage
// wire framing (libsignal.ProtocolVersionV4); only Omemo0 uses the
// original WhisperTextProtocol framing.
useOmemoFraming := wireEnv.Version != Omemo0
contentKey, err := cipher.Decrypt(b.libctx, libsignal.CiphertextMessage{Type: msgType, Serialized: keyForUs.Ciphertext}, useOmemoFraming, regID)
if err != nil {
return nil, fmt.Errorf("omemo: SessionCipher.Decrypt: %w", err)
}
plaintext, err := DecryptOuter(wireEnv.Version, &EncryptedPayload{Ciphertext: wireEnv.Payload, IV: wireEnv.IV, ContentKey: contentKey})
if err != nil {
return nil, fmt.Errorf("omemo: DecryptOuter: %w", err)
}
return plaintext, nil
}

View file

@ -14,8 +14,6 @@ import (
"io" "io"
"golang.org/x/crypto/hkdf" "golang.org/x/crypto/hkdf"
"dev.narayana.im/narayana/telegabber/e2ee/omemo/libsignal"
) )
// This file is OMEMO's outer, non-Signal content encryption: the layer // This file is OMEMO's outer, non-Signal content encryption: the layer
@ -24,94 +22,115 @@ import (
// blob (not the body itself) is what gets Signal/Double-Ratchet-encrypted // blob (not the body itself) is what gets Signal/Double-Ratchet-encrypted
// once per recipient device (see libsignal.SessionCipher.Encrypt) - that // once per recipient device (see libsignal.SessionCipher.Encrypt) - that
// per-device wrapping, and the device/session bookkeeping around it, is // per-device wrapping, and the device/session bookkeeping around it, is
// omemo.go's job, built on top of EncryptOuter/DecryptOuter here. // identity.go/encrypt.go's job, built on top of EncryptOuter/DecryptOuter
// here.
// //
// Two schemes, matching the two wire versions (confirmed against the // Three versions (see version.go), confirmed against the XEP-0384 spec
// XEP-0384 spec text directly, not memory - see the M2 kickoff discussion): // text and its version history directly, not memory:
// //
// - Legacy (ProtocolVersionLegacy, eu.siacs.conversations.axolotl): // - Omemo0 (eu.siacs.conversations.axolotl): AES-128-GCM directly on the
// AES-128-GCM directly on the plaintext body. The GCM tag is stripped // plaintext body. The GCM tag is stripped from the ciphertext and
// from the ciphertext and instead appended to the key (key||tag, 32 // instead appended to the key (key||tag, 32 bytes) before that blob is
// bytes) before that blob is Signal-encrypted per recipient; <payload> // Signal-encrypted per recipient; <payload> carries ciphertext only,
// carries ciphertext only, <iv> carries the GCM nonce. // <iv> carries the GCM nonce.
// //
// - Modern (ProtocolVersionModern, OMEMO 1/2): the plaintext is first // - Omemo1 and Omemo2 share the same payload crypto (introduced at
// wrapped in a minimal XEP-0420 SCE envelope (sce.go). That envelope is // Omemo1/XEP-0384 0.4.0, unchanged since): the plaintext is first
// encrypted with AES-256-CBC using a key/authKey/iv derived via // wrapped in an XEP-0420 SCE envelope - Omemo1 and Omemo2 use two
// different SCE dialects, see sce.go - then that envelope is encrypted
// with AES-256-CBC using a key/authKey/iv derived via
// HKDF-SHA256(random 32-byte key, salt=32 zero bytes, info="OMEMO // HKDF-SHA256(random 32-byte key, salt=32 zero bytes, info="OMEMO
// Payload") -> 80 bytes, split 32/32/16. The ciphertext is // Payload") -> 80 bytes, split 32/32/16. The ciphertext is
// HMAC-SHA256'd with authKey and truncated to 16 bytes. key||tag (48 // HMAC-SHA256'd with authKey and truncated to 16 bytes (confirmed
// bytes) is the per-recipient blob; <payload> carries ciphertext only; // against the current spec text directly - an earlier spec version
// there is no <iv> element - the receiver re-derives it from the key // had a 32-vs-16-byte inconsistency here, fixed in 0.8.2). key||tag
// via the same HKDF. // (48 bytes) is the per-recipient blob; <payload> carries ciphertext
// only; there is no <iv> element - the receiver re-derives it from the
// key via the same HKDF.
const ( const (
legacyKeyLen = 16 // AES-128 omemo0KeyLen = 16 // AES-128
legacyIVLen = 12 // GCM nonce, standard size (crypto/cipher.NewGCM's default) omemo0IVLen = 12 // GCM nonce, standard size (crypto/cipher.NewGCM's default)
legacyTagLen = 16 omemo0TagLen = 16
modernKeyLen = 32 // AES-256 sceKeyLen = 32 // AES-256 (Omemo1/Omemo2 shared payload crypto)
modernAuthKeyLen = 32 sceAuthKeyLen = 32
modernIVLen = 16 // AES block size sceIVLen = 16 // AES block size
modernTagLen = 16 sceTagLen = 16
modernHKDFInfo = "OMEMO Payload" sceHKDFInfo = "OMEMO Payload"
) )
// EncryptedPayload is the result of OMEMO's outer content-encryption step. // EncryptedPayload is the result of OMEMO's outer content-encryption step.
type EncryptedPayload struct { type EncryptedPayload struct {
Ciphertext []byte Ciphertext []byte
IV []byte // legacy only; nil for modern IV []byte // Omemo0 only; nil for Omemo1/Omemo2
ContentKey []byte // the blob to Signal-encrypt once per recipient device ContentKey []byte // the blob to Signal-encrypt once per recipient device
} }
// EncryptOuter performs OMEMO's outer content encryption for plaintext, // EncryptOuter performs OMEMO's outer content encryption for plaintext,
// per version (ProtocolVersionLegacy or ProtocolVersionModern). fromJID is // per version. fromJID is used as the SCE <from> binding for Omemo1/
// used as the SCE <from> binding for modern; ignored for legacy. // Omemo2; ignored for Omemo0 (which has no SCE wrapping at all).
func EncryptOuter(version int, plaintext []byte, fromJID string) (*EncryptedPayload, error) { func EncryptOuter(version Version, plaintext []byte, fromJID string) (*EncryptedPayload, error) {
switch version { switch version {
case libsignal.ProtocolVersionLegacy: case Omemo0:
ciphertext, iv, contentKey, err := encryptPayloadLegacy(plaintext) ciphertext, iv, contentKey, err := encryptPayloadOmemo0(plaintext)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &EncryptedPayload{Ciphertext: ciphertext, IV: iv, ContentKey: contentKey}, nil return &EncryptedPayload{Ciphertext: ciphertext, IV: iv, ContentKey: contentKey}, nil
case libsignal.ProtocolVersionModern: case Omemo1:
sceXML, err := sceEncode(plaintext, fromJID) sceXML, err := sceEncodeV0(plaintext, fromJID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
ciphertext, contentKey, err := encryptPayloadModern(sceXML) ciphertext, contentKey, err := encryptPayloadSCE(sceXML)
if err != nil {
return nil, err
}
return &EncryptedPayload{Ciphertext: ciphertext, ContentKey: contentKey}, nil
case Omemo2:
sceXML, err := sceEncodeV1(plaintext, fromJID)
if err != nil {
return nil, err
}
ciphertext, contentKey, err := encryptPayloadSCE(sceXML)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &EncryptedPayload{Ciphertext: ciphertext, ContentKey: contentKey}, nil return &EncryptedPayload{Ciphertext: ciphertext, ContentKey: contentKey}, nil
default: default:
return nil, fmt.Errorf("omemo: unsupported protocol version %d", version) return nil, fmt.Errorf("omemo: EncryptOuter: unsupported version %v", version)
} }
} }
// DecryptOuter reverses EncryptOuter. // DecryptOuter reverses EncryptOuter.
func DecryptOuter(version int, payload *EncryptedPayload) ([]byte, error) { func DecryptOuter(version Version, payload *EncryptedPayload) ([]byte, error) {
switch version { switch version {
case libsignal.ProtocolVersionLegacy: case Omemo0:
return decryptPayloadLegacy(payload.Ciphertext, payload.IV, payload.ContentKey) return decryptPayloadOmemo0(payload.Ciphertext, payload.IV, payload.ContentKey)
case libsignal.ProtocolVersionModern: case Omemo1:
sceXML, err := decryptPayloadModern(payload.Ciphertext, payload.ContentKey) sceXML, err := decryptPayloadSCE(payload.Ciphertext, payload.ContentKey)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return sceDecode(sceXML) return sceDecodeV0(sceXML)
case Omemo2:
sceXML, err := decryptPayloadSCE(payload.Ciphertext, payload.ContentKey)
if err != nil {
return nil, err
}
return sceDecodeV1(sceXML)
default: default:
return nil, fmt.Errorf("omemo: unsupported protocol version %d", version) return nil, fmt.Errorf("omemo: DecryptOuter: unsupported version %v", version)
} }
} }
func encryptPayloadLegacy(plaintext []byte) (ciphertext, iv, contentKey []byte, err error) { func encryptPayloadOmemo0(plaintext []byte) (ciphertext, iv, contentKey []byte, err error) {
key := make([]byte, legacyKeyLen) key := make([]byte, omemo0KeyLen)
if _, err := rand.Read(key); err != nil { if _, err := rand.Read(key); err != nil {
return nil, nil, nil, err return nil, nil, nil, err
} }
iv = make([]byte, legacyIVLen) iv = make([]byte, omemo0IVLen)
if _, err := rand.Read(iv); err != nil { if _, err := rand.Read(iv); err != nil {
return nil, nil, nil, err return nil, nil, nil, err
} }
@ -124,21 +143,21 @@ func encryptPayloadLegacy(plaintext []byte) (ciphertext, iv, contentKey []byte,
return nil, nil, nil, err return nil, nil, nil, err
} }
sealed := gcm.Seal(nil, iv, plaintext, nil) sealed := gcm.Seal(nil, iv, plaintext, nil)
if len(sealed) < legacyTagLen { if len(sealed) < omemo0TagLen {
return nil, nil, nil, errors.New("omemo: GCM output shorter than its own tag") return nil, nil, nil, errors.New("omemo: GCM output shorter than its own tag")
} }
ciphertext = sealed[:len(sealed)-legacyTagLen] ciphertext = sealed[:len(sealed)-omemo0TagLen]
tag := sealed[len(sealed)-legacyTagLen:] tag := sealed[len(sealed)-omemo0TagLen:]
contentKey = append(append([]byte{}, key...), tag...) contentKey = append(append([]byte{}, key...), tag...)
return ciphertext, iv, contentKey, nil return ciphertext, iv, contentKey, nil
} }
func decryptPayloadLegacy(ciphertext, iv, contentKey []byte) ([]byte, error) { func decryptPayloadOmemo0(ciphertext, iv, contentKey []byte) ([]byte, error) {
if len(contentKey) != legacyKeyLen+legacyTagLen { if len(contentKey) != omemo0KeyLen+omemo0TagLen {
return nil, fmt.Errorf("omemo: legacy content key must be %d bytes, got %d", legacyKeyLen+legacyTagLen, len(contentKey)) return nil, fmt.Errorf("omemo: omemo0 content key must be %d bytes, got %d", omemo0KeyLen+omemo0TagLen, len(contentKey))
} }
key := contentKey[:legacyKeyLen] key := contentKey[:omemo0KeyLen]
tag := contentKey[legacyKeyLen:] tag := contentKey[omemo0KeyLen:]
block, err := aes.NewCipher(key) block, err := aes.NewCipher(key)
if err != nil { if err != nil {
return nil, err return nil, err
@ -152,16 +171,19 @@ func decryptPayloadLegacy(ciphertext, iv, contentKey []byte) ([]byte, error) {
} }
func hkdfSplit(key []byte) (encKey, authKey, iv []byte, err error) { func hkdfSplit(key []byte) (encKey, authKey, iv []byte, err error) {
h := hkdf.New(sha256.New, key, make([]byte, sha256.Size), []byte(modernHKDFInfo)) h := hkdf.New(sha256.New, key, make([]byte, sha256.Size), []byte(sceHKDFInfo))
out := make([]byte, modernKeyLen+modernAuthKeyLen+modernIVLen) out := make([]byte, sceKeyLen+sceAuthKeyLen+sceIVLen)
if _, err := io.ReadFull(h, out); err != nil { if _, err := io.ReadFull(h, out); err != nil {
return nil, nil, nil, err return nil, nil, nil, err
} }
return out[:modernKeyLen], out[modernKeyLen : modernKeyLen+modernAuthKeyLen], out[modernKeyLen+modernAuthKeyLen:], nil return out[:sceKeyLen], out[sceKeyLen : sceKeyLen+sceAuthKeyLen], out[sceKeyLen+sceAuthKeyLen:], nil
} }
func encryptPayloadModern(plaintext []byte) (ciphertext, contentKey []byte, err error) { // encryptPayloadSCE/decryptPayloadSCE are the payload crypto shared by
key := make([]byte, modernKeyLen) // Omemo1 and Omemo2 - only the SCE dialect wrapping plaintext before this
// runs differs between them (see sce.go).
func encryptPayloadSCE(plaintext []byte) (ciphertext, contentKey []byte, err error) {
key := make([]byte, sceKeyLen)
if _, err := rand.Read(key); err != nil { if _, err := rand.Read(key); err != nil {
return nil, nil, err return nil, nil, err
} }
@ -179,18 +201,18 @@ func encryptPayloadModern(plaintext []byte) (ciphertext, contentKey []byte, err
mac := hmac.New(sha256.New, authKey) mac := hmac.New(sha256.New, authKey)
mac.Write(ciphertext) mac.Write(ciphertext)
tag := mac.Sum(nil)[:modernTagLen] tag := mac.Sum(nil)[:sceTagLen]
contentKey = append(append([]byte{}, key...), tag...) contentKey = append(append([]byte{}, key...), tag...)
return ciphertext, contentKey, nil return ciphertext, contentKey, nil
} }
func decryptPayloadModern(ciphertext, contentKey []byte) ([]byte, error) { func decryptPayloadSCE(ciphertext, contentKey []byte) ([]byte, error) {
if len(contentKey) != modernKeyLen+modernTagLen { if len(contentKey) != sceKeyLen+sceTagLen {
return nil, fmt.Errorf("omemo: modern content key must be %d bytes, got %d", modernKeyLen+modernTagLen, len(contentKey)) return nil, fmt.Errorf("omemo: SCE content key must be %d bytes, got %d", sceKeyLen+sceTagLen, len(contentKey))
} }
key := contentKey[:modernKeyLen] key := contentKey[:sceKeyLen]
tag := contentKey[modernKeyLen:] tag := contentKey[sceKeyLen:]
encKey, authKey, iv, err := hkdfSplit(key) encKey, authKey, iv, err := hkdfSplit(key)
if err != nil { if err != nil {
@ -199,7 +221,7 @@ func decryptPayloadModern(ciphertext, contentKey []byte) ([]byte, error) {
mac := hmac.New(sha256.New, authKey) mac := hmac.New(sha256.New, authKey)
mac.Write(ciphertext) mac.Write(ciphertext)
expectedTag := mac.Sum(nil)[:modernTagLen] expectedTag := mac.Sum(nil)[:sceTagLen]
if !hmac.Equal(expectedTag, tag) { if !hmac.Equal(expectedTag, tag) {
return nil, errors.New("omemo: payload authentication failed") return nil, errors.New("omemo: payload authentication failed")
} }
@ -245,31 +267,31 @@ func pkcs7Unpad(data []byte) ([]byte, error) {
// WireKey is one recipient device's Signal-encrypted content-key blob, // WireKey is one recipient device's Signal-encrypted content-key blob,
// version-and-namespace-agnostic - the xmpp-layer glue turns this into a // version-and-namespace-agnostic - the xmpp-layer glue turns this into a
// <key rid=".." prekey=".."/> (legacy) or <key rid=".." kex=".."/> // <key rid=".." prekey=".."/> (Omemo0) or <key rid=".." kex=".."/>
// (modern) element; both attributes mean the same thing (IsPreKey). // (Omemo1/Omemo2) element; both attributes mean the same thing (IsPreKey).
type WireKey struct { type WireKey struct {
RecipientJID string RecipientJID string
DeviceID uint32 DeviceID uint32
IsPreKey bool IsPreKey bool
Ciphertext []byte // libsignal.CiphertextMessage.Serialized - already in the correct per-version wire format (see the M2 architecture note: libomemo-c itself produces OMEMOKeyExchange/OMEMOAuthenticatedMessage protobuf bytes for protocol v4, no extra wrapping needed here) Ciphertext []byte // libsignal.CiphertextMessage.Serialized - already in the correct per-version wire format (libomemo-c itself produces the OMEMOKeyExchange/OMEMOAuthenticatedMessage protobuf bytes for protocol v4, no extra wrapping needed here)
} }
// WireEnvelope is the decoded structure of an OMEMO <encrypted> element, // WireEnvelope is the decoded structure of an OMEMO <encrypted> element,
// independent of which XML shape (legacy vs OMEMO 2/SCE) it came from or // independent of which XML shape it came from or will be marshaled to -
// will be marshaled to - that XML-specific glue lives in the xmpp package, // that XML-specific glue lives in the xmpp package, which imports this
// which imports this package explicitly when the omemo backend is active. // package explicitly when the omemo backend is active.
type WireEnvelope struct { type WireEnvelope struct {
Version int Version Version
SenderSID uint32 SenderSID uint32
IV []byte // legacy only IV []byte // Omemo0 only
Payload []byte // outer ciphertext Payload []byte // outer ciphertext
Keys []WireKey Keys []WireKey
} }
// Encode serializes a WireEnvelope for e2ee.Envelope.Raw. This is // Encode serializes a WireEnvelope for e2ee.Envelope.Raw. This is
// Go-to-Go-only plumbing (produced by omemo.go's Encrypt, consumed by the // Go-to-Go-only plumbing (produced by identity.go/encrypt.go's Encrypt,
// xmpp-layer glue in the same process), not a wire format in its own // consumed by the xmpp-layer glue in the same process), not a wire format
// right, so gob is a fine fit - no schema stability concerns. // in its own right, so gob is a fine fit - no schema stability concerns.
func (e *WireEnvelope) Encode() ([]byte, error) { func (e *WireEnvelope) Encode() ([]byte, error) {
var buf bytes.Buffer var buf bytes.Buffer
if err := gob.NewEncoder(&buf).Encode(e); err != nil { if err := gob.NewEncoder(&buf).Encode(e); err != nil {

View file

@ -4,17 +4,16 @@ import (
"bytes" "bytes"
"reflect" "reflect"
"testing" "testing"
"dev.narayana.im/narayana/telegabber/e2ee/omemo/libsignal"
) )
func TestEncryptOuterRoundTrip(t *testing.T) { func TestEncryptOuterRoundTrip(t *testing.T) {
for _, tc := range []struct { for _, tc := range []struct {
name string name string
version int version Version
}{ }{
{"legacy", libsignal.ProtocolVersionLegacy}, {"omemo0", Omemo0},
{"modern", libsignal.ProtocolVersionModern}, {"omemo1", Omemo1},
{"omemo2", Omemo2},
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
plaintext := []byte("hello from telegram") plaintext := []byte("hello from telegram")
@ -26,11 +25,11 @@ func TestEncryptOuterRoundTrip(t *testing.T) {
if len(payload.Ciphertext) == 0 { if len(payload.Ciphertext) == 0 {
t.Fatalf("expected non-empty ciphertext") t.Fatalf("expected non-empty ciphertext")
} }
if tc.version == libsignal.ProtocolVersionLegacy && len(payload.IV) != legacyIVLen { if tc.version == Omemo0 && len(payload.IV) != omemo0IVLen {
t.Fatalf("expected a %d-byte IV for legacy, got %d", legacyIVLen, len(payload.IV)) t.Fatalf("expected a %d-byte IV for omemo0, got %d", omemo0IVLen, len(payload.IV))
} }
if tc.version == libsignal.ProtocolVersionModern && payload.IV != nil { if tc.version != Omemo0 && payload.IV != nil {
t.Fatalf("expected no IV for modern (receiver re-derives it), got %d bytes", len(payload.IV)) t.Fatalf("expected no IV for %s (receiver re-derives it), got %d bytes", tc.name, len(payload.IV))
} }
got, err := DecryptOuter(tc.version, payload) got, err := DecryptOuter(tc.version, payload)
@ -47,10 +46,11 @@ func TestEncryptOuterRoundTrip(t *testing.T) {
func TestEncryptOuterTamperDetection(t *testing.T) { func TestEncryptOuterTamperDetection(t *testing.T) {
for _, tc := range []struct { for _, tc := range []struct {
name string name string
version int version Version
}{ }{
{"legacy", libsignal.ProtocolVersionLegacy}, {"omemo0", Omemo0},
{"modern", libsignal.ProtocolVersionModern}, {"omemo1", Omemo1},
{"omemo2", Omemo2},
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
payload, err := EncryptOuter(tc.version, []byte("secret"), "gateway@example.com") payload, err := EncryptOuter(tc.version, []byte("secret"), "gateway@example.com")
@ -71,22 +71,39 @@ func TestEncryptOuterTamperDetection(t *testing.T) {
func TestSCERoundTrip(t *testing.T) { func TestSCERoundTrip(t *testing.T) {
plaintext := []byte("hello sce") plaintext := []byte("hello sce")
xmlBytes, err := sceEncode(plaintext, "alice@example.com")
if err != nil { t.Run("v0", func(t *testing.T) {
t.Fatalf("sceEncode: %v", err) xmlBytes, err := sceEncodeV0(plaintext, "alice@example.com")
} if err != nil {
got, err := sceDecode(xmlBytes) t.Fatalf("sceEncodeV0: %v", err)
if err != nil { }
t.Fatalf("sceDecode: %v", err) got, err := sceDecodeV0(xmlBytes)
} if err != nil {
if !bytes.Equal(got, plaintext) { t.Fatalf("sceDecodeV0: %v", err)
t.Fatalf("got %q, want %q", got, plaintext) }
} if !bytes.Equal(got, plaintext) {
t.Fatalf("got %q, want %q", got, plaintext)
}
})
t.Run("v1", func(t *testing.T) {
xmlBytes, err := sceEncodeV1(plaintext, "alice@example.com")
if err != nil {
t.Fatalf("sceEncodeV1: %v", err)
}
got, err := sceDecodeV1(xmlBytes)
if err != nil {
t.Fatalf("sceDecodeV1: %v", err)
}
if !bytes.Equal(got, plaintext) {
t.Fatalf("got %q, want %q", got, plaintext)
}
})
} }
func TestWireEnvelopeEncodeDecode(t *testing.T) { func TestWireEnvelopeEncodeDecode(t *testing.T) {
env := &WireEnvelope{ env := &WireEnvelope{
Version: libsignal.ProtocolVersionModern, Version: Omemo2,
SenderSID: 12345, SenderSID: 12345,
Payload: []byte("ciphertext-bytes"), Payload: []byte("ciphertext-bytes"),
Keys: []WireKey{ Keys: []WireKey{

350
e2ee/omemo/identity.go Normal file
View file

@ -0,0 +1,350 @@
package omemo
import (
"crypto/rand"
"errors"
"fmt"
"math/big"
"strconv"
"sync"
"dev.narayana.im/narayana/telegabber/e2ee"
"dev.narayana.im/narayana/telegabber/e2ee/omemo/libsignal"
"dev.narayana.im/narayana/telegabber/e2ee/store/badgerstore"
)
// Name identifies this backend in the e2ee registry and config.
const Name = "omemo"
// initialPreKeyCount is how many one-time prekeys EnsureIdentity generates
// up front - a conventional pool size (matches common OMEMO implementation
// practice) balancing bundle-refill frequency against bundle size.
// Replenishing a depleted pool (prekeys are removed automatically as
// they're consumed - see ListPreKeyIDs's doc comment) is a future
// enhancement, not handled yet.
const initialPreKeyCount = 100
// ownDeviceIDNum is the sole device id this gateway ever publishes for an
// owned identity - there is exactly one telegabber process encrypting on
// behalf of any given bridged chat pseudo-JID, so unlike a real multi-device
// XMPP account, no more than one device id is ever needed.
const ownDeviceIDNum uint32 = 1
// OwnDeviceID is ownDeviceIDNum in e2ee.DeviceID form, for callers that
// need to reference "the" device of an owned identity (e.g. PublishedBundle).
var OwnDeviceID = e2ee.DeviceID(strconv.FormatUint(uint64(ownDeviceIDNum), 10))
// Backend is the OMEMO (XEP-0384) implementation of e2ee.Backend.
type Backend struct {
libctx *libsignal.Context
db *badgerstore.DB
version Version // which version this backend produces for new outgoing content; incoming messages of any of the three versions are always accepted, decoded per-message
// libsignal is not internally thread-safe (see its package doc) - one
// mutex serializes every call into it, across every identity this
// backend manages. Simple and correct; per-identity striping would
// allow more concurrency but isn't needed at telegabber's message
// volume.
mu sync.Mutex
}
// New creates an OMEMO backend backed by db, producing new outgoing
// content in the given version (Omemo0, Omemo1, or Omemo2 - see version.go).
func New(libctx *libsignal.Context, db *badgerstore.DB, version Version) *Backend {
return &Backend{libctx: libctx, db: db, version: version}
}
var _ e2ee.Backend = (*Backend)(nil)
func (b *Backend) Name() string { return Name }
func (b *Backend) Namespaces() e2ee.Namespaces {
switch b.version {
case Omemo0:
return e2ee.Namespaces{
Disco: []string{omemo0NS + ".devicelist+notify"},
EME: omemo0NS,
}
case Omemo1:
return e2ee.Namespaces{
Disco: []string{omemo1NS + ":devices+notify"},
EME: omemo1NS,
}
default:
return e2ee.Namespaces{
Disco: []string{omemo2NS + ":devices+notify"},
EME: omemo2NS,
}
}
}
func (b *Backend) EnsureIdentity(peer e2ee.PeerID) error {
login, owner, ok := e2ee.SplitOwnedPeer(peer)
if !ok {
return fmt.Errorf("omemo: EnsureIdentity: %q is not an OwnedPeer", peer)
}
b.mu.Lock()
defer b.mu.Unlock()
store := b.db.Store(b.libctx, login, owner)
has, err := store.HasIdentityKeyPair()
if err != nil {
return fmt.Errorf("omemo: HasIdentityKeyPair: %w", err)
}
if has {
return nil // idempotent - already set up
}
idKeyPair, err := libsignal.GenerateIdentityKeyPair(b.libctx)
if err != nil {
return fmt.Errorf("omemo: GenerateIdentityKeyPair: %w", err)
}
if err := store.SaveIdentityKeyPair(idKeyPair.Record); err != nil {
return fmt.Errorf("omemo: SaveIdentityKeyPair: %w", err)
}
regID, err := libsignal.GenerateRegistrationID(b.libctx)
if err != nil {
return fmt.Errorf("omemo: GenerateRegistrationID: %w", err)
}
if err := store.SaveRegistrationID(regID); err != nil {
return fmt.Errorf("omemo: SaveRegistrationID: %w", err)
}
signedPreKey, err := libsignal.GenerateSignedPreKey(b.libctx, idKeyPair, 1)
if err != nil {
return fmt.Errorf("omemo: GenerateSignedPreKey: %w", err)
}
if err := store.StoreSignedPreKey(signedPreKey.ID, signedPreKey.Record); err != nil {
return fmt.Errorf("omemo: StoreSignedPreKey: %w", err)
}
start, err := store.NextPreKeyIDs(initialPreKeyCount)
if err != nil {
return fmt.Errorf("omemo: NextPreKeyIDs: %w", err)
}
preKeys, err := libsignal.GeneratePreKeys(b.libctx, start, initialPreKeyCount)
if err != nil {
return fmt.Errorf("omemo: GeneratePreKeys: %w", err)
}
for _, pk := range preKeys {
if err := store.StorePreKey(pk.ID, pk.Record); err != nil {
return fmt.Errorf("omemo: StorePreKey %d: %w", pk.ID, err)
}
}
return nil
}
func (b *Backend) PublishedIdentity(peer e2ee.PeerID) (e2ee.DeviceListDoc, error) {
login, owner, ok := e2ee.SplitOwnedPeer(peer)
if !ok {
return e2ee.DeviceListDoc{}, fmt.Errorf("omemo: PublishedIdentity: %q is not an OwnedPeer", peer)
}
b.mu.Lock()
defer b.mu.Unlock()
store := b.db.Store(b.libctx, login, owner)
if has, err := store.HasIdentityKeyPair(); err != nil {
return e2ee.DeviceListDoc{}, err
} else if !has {
return e2ee.DeviceListDoc{}, errors.New("omemo: PublishedIdentity: EnsureIdentity was not called")
}
raw, err := encodeDeviceList(b.version, ownDeviceIDNum)
if err != nil {
return e2ee.DeviceListDoc{}, err
}
return e2ee.DeviceListDoc{Raw: raw}, nil
}
func (b *Backend) PublishedBundle(peer e2ee.PeerID, device e2ee.DeviceID) (e2ee.BundleDoc, error) {
login, owner, ok := e2ee.SplitOwnedPeer(peer)
if !ok {
return e2ee.BundleDoc{}, fmt.Errorf("omemo: PublishedBundle: %q is not an OwnedPeer", peer)
}
if device != OwnDeviceID {
return e2ee.BundleDoc{}, fmt.Errorf("omemo: PublishedBundle: unknown device %q", device)
}
b.mu.Lock()
defer b.mu.Unlock()
store := b.db.Store(b.libctx, login, owner)
identityPublic, _, err := store.GetIdentityKeyPair()
if err != nil {
return e2ee.BundleDoc{}, fmt.Errorf("omemo: GetIdentityKeyPair: %w", err)
}
// EnsureIdentity always creates signed prekey id 1 and never rotates it
// yet (a future enhancement), so id 1 is the only one that will exist.
spkRecord, found, err := store.LoadSignedPreKey(1)
if err != nil {
return e2ee.BundleDoc{}, fmt.Errorf("omemo: LoadSignedPreKey: %w", err)
}
if !found {
return e2ee.BundleDoc{}, errors.New("omemo: PublishedBundle: EnsureIdentity was not called")
}
spkInfo, err := libsignal.DecodeSignedPreKey(b.libctx, spkRecord)
if err != nil {
return e2ee.BundleDoc{}, fmt.Errorf("omemo: DecodeSignedPreKey: %w", err)
}
// See the rule documented on libsignal.SignedPreKeyInfo: the signature
// must match the serialization form the verifier will re-derive, which
// depends on the recipient's protocol version (Omemo0 -> legacy form,
// Omemo1/Omemo2 -> OMEMO form, since both share libsignal.ProtocolVersionV4)
// - but since we only publish one bundle version at a time (this
// backend's own configured version), that's the one to use here too.
signature := spkInfo.Signature
if b.version != Omemo0 {
signature = spkInfo.SignatureOMEMO
}
preKeyIDs, err := store.ListPreKeyIDs()
if err != nil {
return e2ee.BundleDoc{}, fmt.Errorf("omemo: ListPreKeyIDs: %w", err)
}
preKeys := make([]libsignal.PreKeyInfo, 0, len(preKeyIDs))
for _, id := range preKeyIDs {
record, found, err := store.LoadPreKey(id)
if err != nil {
return e2ee.BundleDoc{}, fmt.Errorf("omemo: LoadPreKey %d: %w", id, err)
}
if !found {
continue // consumed between ListPreKeyIDs and here - fine, just skip it
}
info, err := libsignal.DecodePreKey(b.libctx, record)
if err != nil {
return e2ee.BundleDoc{}, fmt.Errorf("omemo: DecodePreKey %d: %w", id, err)
}
preKeys = append(preKeys, *info)
}
raw, err := encodeBundle(b.version, identityPublic, spkInfo.ID, spkInfo.PublicKey, signature, preKeys)
if err != nil {
return e2ee.BundleDoc{}, err
}
return e2ee.BundleDoc{Raw: raw}, nil
}
func (b *Backend) IngestRemoteDeviceList(owner, peer e2ee.PeerID, doc e2ee.DeviceListDoc) error {
login, ownerJID, ok := e2ee.SplitOwnedPeer(owner)
if !ok {
return fmt.Errorf("omemo: IngestRemoteDeviceList: %q is not an OwnedPeer", owner)
}
if _, err := parseDeviceList(doc.Raw); err != nil {
return fmt.Errorf("omemo: parseDeviceList: %w", err)
}
b.mu.Lock()
defer b.mu.Unlock()
store := b.db.Store(b.libctx, login, ownerJID)
return store.SaveRemoteDeviceListCache(string(peer), doc.Raw)
}
func (b *Backend) IngestRemoteBundle(owner, peer e2ee.PeerID, device e2ee.DeviceID, doc e2ee.BundleDoc) error {
login, ownerJID, ok := e2ee.SplitOwnedPeer(owner)
if !ok {
return fmt.Errorf("omemo: IngestRemoteBundle: %q is not an OwnedPeer", owner)
}
deviceID, err := parseDeviceID(device)
if err != nil {
return err
}
parsed, err := parseBundle(doc.Raw)
if err != nil {
return fmt.Errorf("omemo: parseBundle: %w", err)
}
var preKeyID uint32
var preKeyPublic []byte
if len(parsed.PreKeys) > 0 {
idx, err := cryptoRandIndex(len(parsed.PreKeys))
if err != nil {
return err
}
preKeyID = parsed.PreKeys[idx].ID
preKeyPublic = parsed.PreKeys[idx].Public
}
b.mu.Lock()
defer b.mu.Unlock()
store := b.db.Store(b.libctx, login, ownerJID)
storeCtx, err := libsignal.NewStoreContext(b.libctx, store)
if err != nil {
return fmt.Errorf("omemo: NewStoreContext: %w", err)
}
defer storeCtx.Close()
builder, err := libsignal.NewSessionBuilder(b.libctx, storeCtx, libsignal.Address{Name: string(peer), DeviceID: deviceID}, b.version.libsignalVersion())
if err != nil {
return fmt.Errorf("omemo: NewSessionBuilder: %w", err)
}
defer builder.Close()
err = builder.ProcessPreKeyBundle(b.libctx, libsignal.RemoteBundle{
// OMEMO's wire format (neither legacy nor OMEMOKeyExchange) ever
// transmits a registration id - device id already does what it
// would otherwise disambiguate. 0 is standard, harmless practice.
RegistrationID: 0,
DeviceID: deviceID,
PreKeyID: preKeyID,
PreKeyPublic: preKeyPublic,
SignedPreKeyID: parsed.SignedPreKeyID,
SignedPreKeyPublic: parsed.SignedPreKeyPublic,
SignedPreKeySignature: parsed.SignedPreKeySignature,
IdentityKeyPublic: parsed.IdentityKeyPublic,
})
if err != nil {
return fmt.Errorf("omemo: ProcessPreKeyBundle: %w", err)
}
return nil
}
func (b *Backend) Devices(owner, peer e2ee.PeerID) ([]e2ee.DeviceInfo, error) {
login, ownerJID, ok := e2ee.SplitOwnedPeer(owner)
if !ok {
return nil, fmt.Errorf("omemo: Devices: %q is not an OwnedPeer", owner)
}
b.mu.Lock()
defer b.mu.Unlock()
store := b.db.Store(b.libctx, login, ownerJID)
deviceIDs, err := store.GetSubDeviceSessions(string(peer))
if err != nil {
return nil, err
}
infos := make([]e2ee.DeviceInfo, 0, len(deviceIDs))
for _, id := range deviceIDs {
// A stored session implies IsTrustedIdentity already accepted this
// device's key (TOFU) - finer-grained trust states (manually
// verified/revoked) are a documented future enhancement, not
// needed for this phase.
infos = append(infos, e2ee.DeviceInfo{ID: e2ee.DeviceID(strconv.FormatUint(uint64(id), 10)), Trusted: true})
}
return infos, nil
}
func parseDeviceID(device e2ee.DeviceID) (uint32, error) {
id, err := strconv.ParseUint(string(device), 10, 32)
if err != nil {
return 0, fmt.Errorf("omemo: invalid device id %q: %w", device, err)
}
return uint32(id), nil
}
func cryptoRandIndex(n int) (int, error) {
i, err := rand.Int(rand.Reader, big.NewInt(int64(n)))
if err != nil {
return 0, err
}
return int(i.Int64()), nil
}

View file

@ -2,7 +2,7 @@
package libsignal package libsignal
// ModernOMEMOSupported is false under the "signal_legacy" build tag: this // ProtocolV4Supported is false under the "signal_legacy" build tag: this
// build links vanilla libsignal-protocol-c, which has no modern OMEMO // build links vanilla libsignal-protocol-c, which has no modern OMEMO
// (protocol v4) support at all - only legacy/siacs OMEMO (protocol v3). // (protocol v4) support at all - only legacy/siacs OMEMO (protocol v3).
const ModernOMEMOSupported = false const ProtocolV4Supported = false

View file

@ -2,9 +2,9 @@
package libsignal package libsignal
// ModernOMEMOSupported reports whether this build links a library capable // ProtocolV4Supported reports whether this build links a library capable
// of modern OMEMO (protocol v4, shared by OMEMO 1/urn:xmpp:omemo:1 and // of modern OMEMO (protocol v4, shared by OMEMO 1/urn:xmpp:omemo:1 and
// OMEMO 2/urn:xmpp:omemo:2), as opposed to only legacy/siacs OMEMO // OMEMO 2/urn:xmpp:omemo:2), as opposed to only legacy/siacs OMEMO
// (protocol v3). Callers should check this before advertising or // (protocol v3). Callers should check this before advertising or
// negotiating anything beyond the legacy namespace. // negotiating anything beyond the legacy namespace.
const ModernOMEMOSupported = true const ProtocolV4Supported = true

View file

@ -40,7 +40,7 @@ type SessionCipher struct {
// NewSessionCipher creates a session cipher for storeCtx's local identity // NewSessionCipher creates a session cipher for storeCtx's local identity
// talking to remote. version selects the wire format for messages this // talking to remote. version selects the wire format for messages this
// cipher *produces* (ProtocolVersionLegacy or ProtocolVersionModern); // cipher *produces* (ProtocolVersionV3 or ProtocolVersionV4);
// decrypting adapts to whatever version the incoming message declares. // decrypting adapts to whatever version the incoming message declares.
func NewSessionCipher(ctx *Context, storeCtx *StoreContext, remote Address, version int) (*SessionCipher, error) { func NewSessionCipher(ctx *Context, storeCtx *StoreContext, remote Address, version int) (*SessionCipher, error) {
addr := newCAddress(remote) addr := newCAddress(remote)

View file

@ -11,7 +11,7 @@ import "C"
// Vanilla libsignal-protocol-c has no OMEMO (protocol v4) wire format // Vanilla libsignal-protocol-c has no OMEMO (protocol v4) wire format
// support at all, so these always fail with ErrInvalidVersion - callers // support at all, so these always fail with ErrInvalidVersion - callers
// (SessionCipher.Decrypt) should never reach here with useOmemoFraming set // (SessionCipher.Decrypt) should never reach here with useOmemoFraming set
// under this build, since ModernOMEMOSupported is false and the caller is // under this build, since ProtocolV4Supported is false and the caller is
// expected to check it first. // expected to check it first.
func deserializeSignalMessageOmemo(ctx *Context, data *C.uint8_t, length C.size_t) (*C.signal_message, C.int) { func deserializeSignalMessageOmemo(ctx *Context, data *C.uint8_t, length C.size_t) (*C.signal_message, C.int) {

View file

@ -17,7 +17,7 @@
// useful on distros that only package that one (e.g. Debian bullseye ships // useful on distros that only package that one (e.g. Debian bullseye ships
// libsignal-protocol-c-dev but not libomemo-c-dev, which arrived in // libsignal-protocol-c-dev but not libomemo-c-dev, which arrived in
// bookworm). That build only supports legacy/siacs OMEMO; check // bookworm). That build only supports legacy/siacs OMEMO; check
// ModernOMEMOSupported before relying on anything else. See cgo_modern.go/ // ProtocolV4Supported before relying on anything else. See cgo_modern.go/
// cgo_legacy.go and the version_*/deserialize_*/signedprekey_* files for // cgo_legacy.go and the version_*/deserialize_*/signedprekey_* files for
// the resulting API-surface differences between the two libraries. // the resulting API-surface differences between the two libraries.
// //

View file

@ -245,7 +245,7 @@ func deserializeSignedPreKey(ctx *Context, record []byte) (*C.session_signed_pre
// 33-byte DJB_TYPE-prefixed legacy form) or ec_public_key_serialize_omemo // 33-byte DJB_TYPE-prefixed legacy form) or ec_public_key_serialize_omemo
// (version >= 4, the raw 32-byte Montgomery form) and checks it against // (version >= 4, the raw 32-byte Montgomery form) and checks it against
// whichever signature is supplied - so use Signature for // whichever signature is supplied - so use Signature for
// ProtocolVersionLegacy and SignatureOMEMO for ProtocolVersionModern. // ProtocolVersionV3 and SignatureOMEMO for ProtocolVersionV4.
type SignedPreKeyInfo struct { type SignedPreKeyInfo struct {
ID uint32 ID uint32
PublicKey []byte PublicKey []byte

View file

@ -19,8 +19,8 @@ import (
// (urn:xmpp:omemo:2) - those two differ only in outer SCE stanza framing, // (urn:xmpp:omemo:2) - those two differ only in outer SCE stanza framing,
// not in this underlying Double Ratchet/X3DH wire format. // not in this underlying Double Ratchet/X3DH wire format.
const ( const (
ProtocolVersionLegacy = 3 ProtocolVersionV3 = 3
ProtocolVersionModern = 4 ProtocolVersionV4 = 4
) )
// RemoteBundle is the decoded material from a peer's XEP-0384 device // RemoteBundle is the decoded material from a peer's XEP-0384 device
@ -47,7 +47,7 @@ type SessionBuilder struct {
// NewSessionBuilder creates a session builder for storeCtx's local identity // NewSessionBuilder creates a session builder for storeCtx's local identity
// talking to remote. version controls the protocol version new sessions // talking to remote. version controls the protocol version new sessions
// built by ProcessPreKeyBundle will use for encryption going forward // built by ProcessPreKeyBundle will use for encryption going forward
// (ProtocolVersionLegacy or ProtocolVersionModern). // (ProtocolVersionV3 or ProtocolVersionV4).
func NewSessionBuilder(ctx *Context, storeCtx *StoreContext, remote Address, version int) (*SessionBuilder, error) { func NewSessionBuilder(ctx *Context, storeCtx *StoreContext, remote Address, version int) (*SessionBuilder, error) {
addr := newCAddress(remote) addr := newCAddress(remote)

View file

@ -118,7 +118,7 @@ func (id *identity) bundle(t *testing.T, deviceID uint32, version int) libsignal
} }
signature := spkInfo.Signature signature := spkInfo.Signature
if version >= libsignal.ProtocolVersionModern { if version >= libsignal.ProtocolVersionV4 {
signature = spkInfo.SignatureOMEMO signature = spkInfo.SignatureOMEMO
} }
@ -140,12 +140,12 @@ func TestSessionRoundTrip(t *testing.T) {
version int version int
omemo bool omemo bool
}{ }{
{"legacy", libsignal.ProtocolVersionLegacy, false}, {"legacy", libsignal.ProtocolVersionV3, false},
{"modern", libsignal.ProtocolVersionModern, true}, {"modern", libsignal.ProtocolVersionV4, true},
} { } {
tc := tc tc := tc
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
if tc.version == libsignal.ProtocolVersionModern && !libsignal.ModernOMEMOSupported { if tc.version == libsignal.ProtocolVersionV4 && !libsignal.ProtocolV4Supported {
t.Skip("modern OMEMO not supported by this build (signal_legacy tag)") t.Skip("modern OMEMO not supported by this build (signal_legacy tag)")
} }

View file

@ -18,19 +18,19 @@ var errModernOMEMOUnavailable = errors.New("libsignal: modern OMEMO (protocol v4
// Vanilla libsignal-protocol-c has no session_builder_set_version / // Vanilla libsignal-protocol-c has no session_builder_set_version /
// session_cipher_set_version - it only ever produces/consumes // session_cipher_set_version - it only ever produces/consumes
// CIPHERTEXT_CURRENT_VERSION (protocol v3, legacy/siacs OMEMO) messages. // CIPHERTEXT_CURRENT_VERSION (protocol v3, legacy/siacs OMEMO) messages.
// Requesting ProtocolVersionLegacy is a no-op (that's the library's only // Requesting ProtocolVersionV3 is a no-op (that's the library's only
// behavior); requesting anything else is rejected rather than silently // behavior); requesting anything else is rejected rather than silently
// ignored. // ignored.
func setBuilderVersion(b *C.session_builder, version int) error { func setBuilderVersion(b *C.session_builder, version int) error {
if version != ProtocolVersionLegacy { if version != ProtocolVersionV3 {
return errModernOMEMOUnavailable return errModernOMEMOUnavailable
} }
return nil return nil
} }
func setCipherVersion(c *C.session_cipher, version int) error { func setCipherVersion(c *C.session_cipher, version int) error {
if version != ProtocolVersionLegacy { if version != ProtocolVersionV3 {
return errModernOMEMOUnavailable return errModernOMEMOUnavailable
} }
return nil return nil

View file

@ -10,7 +10,7 @@ package libsignal
import "C" import "C"
// setBuilderVersion and setCipherVersion select the wire protocol version // setBuilderVersion and setCipherVersion select the wire protocol version
// (ProtocolVersionLegacy or ProtocolVersionModern) libomemo-c's // (ProtocolVersionV3 or ProtocolVersionV4) libomemo-c's
// session_builder_set_version/session_cipher_set_version support. See // session_builder_set_version/session_cipher_set_version support. See
// version_legacy.go for the vanilla-libsignal-protocol-c build, which has // version_legacy.go for the vanilla-libsignal-protocol-c build, which has
// no such functions and only ever produces protocol v3. // no such functions and only ever produces protocol v3.

View file

@ -7,22 +7,34 @@ import (
"math/big" "math/big"
) )
// Minimal XEP-0420 Stanza Content Encryption envelope, used only as the // Two dialects of XEP-0420 Stanza Content Encryption, matching what each
// plaintext OMEMO 2 (modern) content encryption operates on - just enough // OMEMO version actually wraps its plaintext in - just enough of each to
// to carry a plain message body, per the spec's requirement that the // carry a plain message body, per each dialect's requirement that the
// envelope MUST contain <rpad/> and SHOULD contain <from/> (MUST contain // envelope MUST contain <rpad/> and SHOULD contain <from/> (MUST contain
// <to/> for MUC, which this gateway doesn't do OMEMO for - see the plan's // <to/> for MUC, which this gateway doesn't do OMEMO for - see the plan's
// scope note on MUC). // scope note on MUC).
type sceEnvelope struct { //
XMLName xml.Name `xml:"urn:xmpp:sce:1 envelope"` // V0 (Omemo1, XEP-0384 0.4.0-0.7.x) is the OLD XEP-0420 shape, confirmed
Content sceContent `xml:"content"` // by reading the archived 0.3.2 spec directly:
RPad string `xml:"rpad"` //
From *sceJIDAttr `xml:"from"` // <content xmlns='urn:xmpp:sce:0'>
} // <payload>
// <body xmlns='jabber:client'>...</body>
type sceContent struct { // </payload>
Body sceBody `xml:"body"` // <from jid='...'/>
} // <rpad>...</rpad>
// </content>
//
// V1 (Omemo2, current) renamed content->envelope and payload->content
// (XEP-0420 0.4.0+, urn:xmpp:sce:1):
//
// <envelope xmlns='urn:xmpp:sce:1'>
// <content>
// <body xmlns='jabber:client'>...</body>
// </content>
// <from jid='...'/>
// <rpad>...</rpad>
// </envelope>
type sceBody struct { type sceBody struct {
XMLName xml.Name `xml:"jabber:client body"` XMLName xml.Name `xml:"jabber:client body"`
@ -33,16 +45,26 @@ type sceJIDAttr struct {
JID string `xml:"jid,attr"` JID string `xml:"jid,attr"`
} }
// sceEncode wraps plaintext in an SCE envelope and serializes it - the // --- V0 (Omemo1) ---
// "plaintext" that modern OMEMO's outer content encryption actually
// operates on. fromJID becomes the <from/> binding (empty to omit it). type sceContentV0 struct {
func sceEncode(plaintext []byte, fromJID string) ([]byte, error) { XMLName xml.Name `xml:"urn:xmpp:sce:0 content"`
Payload scePayloadV0 `xml:"payload"`
From *sceJIDAttr `xml:"from"`
RPad string `xml:"rpad"`
}
type scePayloadV0 struct {
Body sceBody `xml:"body"`
}
func sceEncodeV0(plaintext []byte, fromJID string) ([]byte, error) {
rpad, err := randomPadding() rpad, err := randomPadding()
if err != nil { if err != nil {
return nil, err return nil, err
} }
env := sceEnvelope{ env := sceContentV0{
Content: sceContent{Body: sceBody{Text: string(plaintext)}}, Payload: scePayloadV0{Body: sceBody{Text: string(plaintext)}},
RPad: rpad, RPad: rpad,
} }
if fromJID != "" { if fromJID != "" {
@ -51,9 +73,44 @@ func sceEncode(plaintext []byte, fromJID string) ([]byte, error) {
return xml.Marshal(env) return xml.Marshal(env)
} }
// sceDecode reverses sceEncode, extracting the plain body text. func sceDecodeV0(data []byte) ([]byte, error) {
func sceDecode(data []byte) ([]byte, error) { var env sceContentV0
var env sceEnvelope if err := xml.Unmarshal(data, &env); err != nil {
return nil, err
}
return []byte(env.Payload.Body.Text), nil
}
// --- V1 (Omemo2) ---
type sceEnvelopeV1 struct {
XMLName xml.Name `xml:"urn:xmpp:sce:1 envelope"`
Content sceContentV1 `xml:"content"`
From *sceJIDAttr `xml:"from"`
RPad string `xml:"rpad"`
}
type sceContentV1 struct {
Body sceBody `xml:"body"`
}
func sceEncodeV1(plaintext []byte, fromJID string) ([]byte, error) {
rpad, err := randomPadding()
if err != nil {
return nil, err
}
env := sceEnvelopeV1{
Content: sceContentV1{Body: sceBody{Text: string(plaintext)}},
RPad: rpad,
}
if fromJID != "" {
env.From = &sceJIDAttr{JID: fromJID}
}
return xml.Marshal(env)
}
func sceDecodeV1(data []byte) ([]byte, error) {
var env sceEnvelopeV1
if err := xml.Unmarshal(data, &env); err != nil { if err := xml.Unmarshal(data, &env); err != nil {
return nil, err return nil, err
} }
@ -62,7 +119,7 @@ func sceDecode(data []byte) ([]byte, error) {
// randomPadding returns base64-encoded random padding of a random length // randomPadding returns base64-encoded random padding of a random length
// (1-63 bytes before encoding) to mask plaintext length, per XEP-0420's // (1-63 bytes before encoding) to mask plaintext length, per XEP-0420's
// MUST-contain-<rpad/> requirement. // MUST-contain-<rpad/> requirement (both dialects).
func randomPadding() (string, error) { func randomPadding() (string, error) {
n, err := rand.Int(rand.Reader, big.NewInt(63)) n, err := rand.Int(rand.Reader, big.NewInt(63))
if err != nil { if err != nil {

59
e2ee/omemo/version.go Normal file
View file

@ -0,0 +1,59 @@
package omemo
import "dev.narayana.im/narayana/telegabber/e2ee/omemo/libsignal"
// Version identifies which OMEMO wire-format generation a message, device
// list, or bundle uses - the full namespace+XML dialect, not just the
// underlying crypto-engine version (see libsignal.ProtocolVersionV3/V4,
// which is a strictly coarser, two-way distinction shared by two of the
// three versions below).
type Version int
const (
// Omemo0 is the original "siacs" OMEMO (eu.siacs.conversations.axolotl,
// XEP-0384 <= 0.3.0): libsignal.ProtocolVersionV3, AES-128-GCM directly
// on the plaintext body, key||tag distributed per-device, <iv> element
// present. Still the most widely deployed version as of this writing,
// and the current implementation priority.
Omemo0 Version = iota
// Omemo1 is urn:xmpp:omemo:1 (XEP-0384 0.4.0-0.7.x, introduced
// 2020-03-08): libsignal.ProtocolVersionV4, AES-256-CBC+HMAC-SHA256
// (via HKDF) over an SCE envelope using the OLD (pre-0.4.0) XEP-0420
// dialect: root element <content xmlns='urn:xmpp:sce:0'>, inner
// wrapper <payload/>. Confirmed by reading the archived XEP-0420 0.3.2
// spec directly (https://xmpp.org/extensions/attic/xep-0420-0.3.2.html),
// not assumed - see sceEncodeV0/sceDecodeV0.
Omemo1
// Omemo2 is urn:xmpp:omemo:2 (XEP-0384 0.8.0+, current spec):
// libsignal.ProtocolVersionV4, the same AES-256-CBC+HMAC-SHA256 scheme
// as Omemo1, but over the NEW (XEP-0420 0.4.0+) dialect: root element
// <envelope xmlns='urn:xmpp:sce:1'>, inner wrapper <content/>.
Omemo2
)
func (v Version) String() string {
switch v {
case Omemo0:
return "omemo0"
case Omemo1:
return "omemo1"
case Omemo2:
return "omemo2"
default:
return "omemo?"
}
}
// libsignalVersion returns the underlying Signal-protocol wire version
// this OMEMO version uses. Omemo1 and Omemo2 share the same crypto-engine
// version - libomemo-c itself only branches on "message_version >= 4", not
// on which OMEMO namespace is in play (confirmed by reading protocol.c) -
// they differ only in the outer XML/SCE dialect (see envelope.go).
func (v Version) libsignalVersion() int {
if v == Omemo0 {
return libsignal.ProtocolVersionV3
}
return libsignal.ProtocolVersionV4
}

25
e2ee/peer.go Normal file
View file

@ -0,0 +1,25 @@
package e2ee
import "strings"
// OwnedPeer constructs the PeerID for an identity this gateway owns and
// generates keys for - a bridged chat pseudo-JID.
//
// Telegram chat ids (and therefore their pseudo-JIDs) are only unique
// within one Telegram login, not globally - telegabber bridges multiple
// Telegram accounts under the same XMPP domain, so two different logins
// could each have a chat whose pseudo-JID is the same string. An owned
// PeerID must therefore be scoped by login too. A real remote peer's
// PeerID (the other end of a conversation) needs no such scoping - it's
// just their bare JID - since their sessions/trust are already nested
// under the owning identity's own scope (see badgerstore's key layout).
func OwnedPeer(login, bareJID string) PeerID {
return PeerID(login + "\x00" + bareJID)
}
// SplitOwnedPeer reverses OwnedPeer. ok is false if peer wasn't built by
// OwnedPeer (e.g. a plain remote-peer PeerID was passed by mistake).
func SplitOwnedPeer(peer PeerID) (login, bareJID string, ok bool) {
login, bareJID, ok = strings.Cut(string(peer), "\x00")
return
}

View file

@ -59,6 +59,10 @@ func (s *Store) preKeyKey(id uint32) []byte {
return []byte(fmt.Sprintf("%s/%s/identity/prekey/%d", s.login, s.owner, id)) return []byte(fmt.Sprintf("%s/%s/identity/prekey/%d", s.login, s.owner, id))
} }
func (s *Store) preKeyPrefix() []byte {
return []byte(fmt.Sprintf("%s/%s/identity/prekey/", s.login, s.owner))
}
func (s *Store) preKeyCounterKey() []byte { func (s *Store) preKeyCounterKey() []byte {
return []byte(fmt.Sprintf("%s/%s/identity/prekey/counter", s.login, s.owner)) return []byte(fmt.Sprintf("%s/%s/identity/prekey/counter", s.login, s.owner))
} }
@ -212,6 +216,35 @@ func (s *Store) ContainsPreKey(id uint32) bool {
return found return found
} }
// ListPreKeyIDs returns the ids of every one-time prekey currently stored
// (i.e. not yet consumed - libomemo-c's session_cipher automatically
// removes a prekey via RemovePreKey the moment it's used to decrypt an
// inbound prekey message, so the surviving ids are neither contiguous nor
// derivable from the counter NextPreKeyIDs advances). Used to publish an
// accurate bundle - see e2ee/omemo's PublishedBundle.
func (s *Store) ListPreKeyIDs() ([]uint32, error) {
prefix := s.preKeyPrefix()
var ids []uint32
err := s.db.View(func(txn *badger.Txn) error {
opts := badger.DefaultIteratorOptions
opts.PrefetchValues = false
it := txn.NewIterator(opts)
defer it.Close()
for it.Seek(prefix); it.ValidForPrefix(prefix); it.Next() {
suffix := it.Item().Key()[len(prefix):]
// Skips the "counter" key, which shares this prefix - its
// suffix doesn't parse as a uint32, so it's silently excluded.
id, err := strconv.ParseUint(string(suffix), 10, 32)
if err != nil {
continue
}
ids = append(ids, uint32(id))
}
return nil
})
return ids, err
}
func (s *Store) RemovePreKey(id uint32) error { func (s *Store) RemovePreKey(id uint32) error {
return s.delete(s.preKeyKey(id)) return s.delete(s.preKeyKey(id))
} }
@ -339,7 +372,10 @@ func (s *Store) SaveRegistrationID(id uint32) error {
// NextPreKeyIDs atomically reserves a contiguous block of count prekey ids // NextPreKeyIDs atomically reserves a contiguous block of count prekey ids
// for libsignal.GeneratePreKeys, returning the first id in the block. // for libsignal.GeneratePreKeys, returning the first id in the block.
func (s *Store) NextPreKeyIDs(count uint32) (uint32, error) { func (s *Store) NextPreKeyIDs(count uint32) (uint32, error) {
var start uint32 // Starts at 1, not 0: XEP-0384 0.9.1 explicitly requires positive
// (non-zero) prekey ids ("Fix using id=0 in examples. Spec requires
// positive numbers.").
start := uint32(1)
err := s.db.Update(func(txn *badger.Txn) error { err := s.db.Update(func(txn *badger.Txn) error {
item, err := txn.Get(s.preKeyCounterKey()) item, err := txn.Get(s.preKeyCounterKey())
if err != nil && err != badger.ErrKeyNotFound { if err != nil && err != badger.ErrKeyNotFound {

View file

@ -135,15 +135,15 @@ func TestNextPreKeyIDs(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("NextPreKeyIDs: %v", err) t.Fatalf("NextPreKeyIDs: %v", err)
} }
if start1 != 0 { if start1 != 1 {
t.Fatalf("expected first block to start at 0, got %d", start1) t.Fatalf("expected first block to start at 1 (XEP-0384 requires positive ids), got %d", start1)
} }
start2, err := store.NextPreKeyIDs(5) start2, err := store.NextPreKeyIDs(5)
if err != nil { if err != nil {
t.Fatalf("NextPreKeyIDs: %v", err) t.Fatalf("NextPreKeyIDs: %v", err)
} }
if start2 != 10 { if start2 != 11 {
t.Fatalf("expected second block to start at 10, got %d", start2) t.Fatalf("expected second block to start at 11, got %d", start2)
} }
} }

View file

@ -56,35 +56,42 @@ type Backend interface {
Namespaces() Namespaces Namespaces() Namespaces
// 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), // for a PeerID this gateway owns (a bridged chat pseudo-JID - built via
// generating and persisting whatever key material the backend needs. // OwnedPeer, since a chat pseudo-JID alone isn't globally unique across
// telegabber's multiple bridged Telegram logins), generating and
// persisting whatever key material the backend needs.
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 (device list), pre-marshaled // pubsub GET requests for its own PeerID (an OwnedPeer - see
// to opaque bytes the xmpp layer wraps in <items>/<item>. // EnsureIdentity) device list, pre-marshaled to opaque bytes the xmpp
// layer wraps in <items>/<item>.
PublishedIdentity(peer PeerID) (DeviceListDoc, error) PublishedIdentity(peer PeerID) (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) (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
// remote peer into the backend's store, establishing/refreshing // remote peer (owner is an OwnedPeer identifying which of this
// sessions as needed (trust decisions happen inside here). // gateway's identities is doing the fetching; peer is the remote's
IngestRemoteDeviceList(peer PeerID, doc DeviceListDoc) error // plain bare JID, no scoping needed) into the backend's store,
IngestRemoteBundle(peer PeerID, device DeviceID, doc BundleDoc) error // establishing/refreshing sessions as needed (trust decisions happen
// inside here).
IngestRemoteDeviceList(owner PeerID, peer PeerID, doc DeviceListDoc) 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
// of every given recipient, ready to be embedded in a stanza by the // of every given recipient, ready to be embedded in a stanza by the
// xmpp layer. from is the identity doing the encrypting (a chat // xmpp layer. from is the identity doing the encrypting (an OwnedPeer -
// pseudo-JID this gateway owns). // see EnsureIdentity); to are the real remote peers' plain bare JIDs.
Encrypt(from PeerID, to []PeerID, plaintext []byte) (Envelope, error) Encrypt(from PeerID, to []PeerID, plaintext []byte) (Envelope, error)
// Decrypt consumes an inbound envelope addressed to "to" (a chat // Decrypt consumes an inbound envelope addressed to "to" (an
// pseudo-JID this gateway owns) from "from" (the real remote peer) // OwnedPeer - see EnsureIdentity) from "from" (the real remote peer's
// and returns plaintext. // plain bare JID) and returns plaintext.
Decrypt(from PeerID, to PeerID, env Envelope) ([]byte, error) Decrypt(from PeerID, to PeerID, env Envelope) ([]byte, error)
// Devices lists known devices for a peer (trust-listing ad-hoc commands). // Devices lists peer's known devices, as seen from owner's (an
Devices(peer PeerID) ([]DeviceInfo, error) // OwnedPeer) point of view - for trust-listing ad-hoc commands.
Devices(owner PeerID, peer PeerID) ([]DeviceInfo, error)
} }