mirror of
https://dev.narayana.im/narayana/telegabber.git
synced 2026-08-05 04:07:07 +00:00
Abstract E2EE manager/registry
This commit is contained in:
parent
3751113015
commit
5e71ba954c
8 changed files with 630 additions and 14 deletions
35
e2ee/manager.go
Normal file
35
e2ee/manager.go
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
package e2ee
|
||||
|
||||
import "fmt"
|
||||
|
||||
// Manager holds the process-wide selected E2EE backend (or none, if E2EE
|
||||
// is disabled), reachable from both the xmpp and telegram packages without
|
||||
// either importing the other's e2ee-specific glue.
|
||||
type Manager struct {
|
||||
backend Backend
|
||||
}
|
||||
|
||||
// NewManager selects a backend by name from the registry. An empty name
|
||||
// means E2EE is disabled: the returned Manager's Backend() always reports
|
||||
// ok=false, and every call site is expected to treat that as "behave
|
||||
// exactly as if this feature didn't exist".
|
||||
func NewManager(name string) (*Manager, error) {
|
||||
if name == "" {
|
||||
return &Manager{}, nil
|
||||
}
|
||||
b, ok := Get(name)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("e2ee: unknown backend %q (forgot to import its package for its init() side effect?)", name)
|
||||
}
|
||||
return &Manager{backend: b}, nil
|
||||
}
|
||||
|
||||
// Backend returns the selected backend. ok is false if m is nil or no
|
||||
// backend was selected (E2EE disabled) - callers should treat a nil
|
||||
// Manager the same as one built via NewManager("").
|
||||
func (m *Manager) Backend() (backend Backend, ok bool) {
|
||||
if m == nil || m.backend == nil {
|
||||
return nil, false
|
||||
}
|
||||
return m.backend, true
|
||||
}
|
||||
288
e2ee/omemo/envelope.go
Normal file
288
e2ee/omemo/envelope.go
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
// Package omemo is the OMEMO (XEP-0384) implementation of e2ee.Backend.
|
||||
package omemo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/gob"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"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
|
||||
// that encrypts the actual message body once per message, independent of
|
||||
// how many recipient devices it goes to. The resulting small "content key"
|
||||
// blob (not the body itself) is what gets Signal/Double-Ratchet-encrypted
|
||||
// once per recipient device (see libsignal.SessionCipher.Encrypt) - that
|
||||
// per-device wrapping, and the device/session bookkeeping around it, is
|
||||
// omemo.go's job, built on top of EncryptOuter/DecryptOuter here.
|
||||
//
|
||||
// Two schemes, matching the two wire versions (confirmed against the
|
||||
// XEP-0384 spec text directly, not memory - see the M2 kickoff discussion):
|
||||
//
|
||||
// - Legacy (ProtocolVersionLegacy, eu.siacs.conversations.axolotl):
|
||||
// AES-128-GCM directly on the plaintext body. The GCM tag is stripped
|
||||
// from the ciphertext and instead appended to the key (key||tag, 32
|
||||
// bytes) before that blob is Signal-encrypted per recipient; <payload>
|
||||
// carries ciphertext only, <iv> carries the GCM nonce.
|
||||
//
|
||||
// - Modern (ProtocolVersionModern, OMEMO 1/2): the plaintext is first
|
||||
// wrapped in a minimal XEP-0420 SCE envelope (sce.go). 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
|
||||
// Payload") -> 80 bytes, split 32/32/16. The ciphertext is
|
||||
// HMAC-SHA256'd with authKey and truncated to 16 bytes. key||tag (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 (
|
||||
legacyKeyLen = 16 // AES-128
|
||||
legacyIVLen = 12 // GCM nonce, standard size (crypto/cipher.NewGCM's default)
|
||||
legacyTagLen = 16
|
||||
|
||||
modernKeyLen = 32 // AES-256
|
||||
modernAuthKeyLen = 32
|
||||
modernIVLen = 16 // AES block size
|
||||
modernTagLen = 16
|
||||
modernHKDFInfo = "OMEMO Payload"
|
||||
)
|
||||
|
||||
// EncryptedPayload is the result of OMEMO's outer content-encryption step.
|
||||
type EncryptedPayload struct {
|
||||
Ciphertext []byte
|
||||
IV []byte // legacy only; nil for modern
|
||||
ContentKey []byte // the blob to Signal-encrypt once per recipient device
|
||||
}
|
||||
|
||||
// EncryptOuter performs OMEMO's outer content encryption for plaintext,
|
||||
// per version (ProtocolVersionLegacy or ProtocolVersionModern). fromJID is
|
||||
// used as the SCE <from> binding for modern; ignored for legacy.
|
||||
func EncryptOuter(version int, plaintext []byte, fromJID string) (*EncryptedPayload, error) {
|
||||
switch version {
|
||||
case libsignal.ProtocolVersionLegacy:
|
||||
ciphertext, iv, contentKey, err := encryptPayloadLegacy(plaintext)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &EncryptedPayload{Ciphertext: ciphertext, IV: iv, ContentKey: contentKey}, nil
|
||||
case libsignal.ProtocolVersionModern:
|
||||
sceXML, err := sceEncode(plaintext, fromJID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ciphertext, contentKey, err := encryptPayloadModern(sceXML)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &EncryptedPayload{Ciphertext: ciphertext, ContentKey: contentKey}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("omemo: unsupported protocol version %d", version)
|
||||
}
|
||||
}
|
||||
|
||||
// DecryptOuter reverses EncryptOuter.
|
||||
func DecryptOuter(version int, payload *EncryptedPayload) ([]byte, error) {
|
||||
switch version {
|
||||
case libsignal.ProtocolVersionLegacy:
|
||||
return decryptPayloadLegacy(payload.Ciphertext, payload.IV, payload.ContentKey)
|
||||
case libsignal.ProtocolVersionModern:
|
||||
sceXML, err := decryptPayloadModern(payload.Ciphertext, payload.ContentKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return sceDecode(sceXML)
|
||||
default:
|
||||
return nil, fmt.Errorf("omemo: unsupported protocol version %d", version)
|
||||
}
|
||||
}
|
||||
|
||||
func encryptPayloadLegacy(plaintext []byte) (ciphertext, iv, contentKey []byte, err error) {
|
||||
key := make([]byte, legacyKeyLen)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
iv = make([]byte, legacyIVLen)
|
||||
if _, err := rand.Read(iv); err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
sealed := gcm.Seal(nil, iv, plaintext, nil)
|
||||
if len(sealed) < legacyTagLen {
|
||||
return nil, nil, nil, errors.New("omemo: GCM output shorter than its own tag")
|
||||
}
|
||||
ciphertext = sealed[:len(sealed)-legacyTagLen]
|
||||
tag := sealed[len(sealed)-legacyTagLen:]
|
||||
contentKey = append(append([]byte{}, key...), tag...)
|
||||
return ciphertext, iv, contentKey, nil
|
||||
}
|
||||
|
||||
func decryptPayloadLegacy(ciphertext, iv, contentKey []byte) ([]byte, error) {
|
||||
if len(contentKey) != legacyKeyLen+legacyTagLen {
|
||||
return nil, fmt.Errorf("omemo: legacy content key must be %d bytes, got %d", legacyKeyLen+legacyTagLen, len(contentKey))
|
||||
}
|
||||
key := contentKey[:legacyKeyLen]
|
||||
tag := contentKey[legacyKeyLen:]
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sealed := append(append([]byte{}, ciphertext...), tag...)
|
||||
return gcm.Open(nil, iv, sealed, nil)
|
||||
}
|
||||
|
||||
func hkdfSplit(key []byte) (encKey, authKey, iv []byte, err error) {
|
||||
h := hkdf.New(sha256.New, key, make([]byte, sha256.Size), []byte(modernHKDFInfo))
|
||||
out := make([]byte, modernKeyLen+modernAuthKeyLen+modernIVLen)
|
||||
if _, err := io.ReadFull(h, out); err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
return out[:modernKeyLen], out[modernKeyLen : modernKeyLen+modernAuthKeyLen], out[modernKeyLen+modernAuthKeyLen:], nil
|
||||
}
|
||||
|
||||
func encryptPayloadModern(plaintext []byte) (ciphertext, contentKey []byte, err error) {
|
||||
key := make([]byte, modernKeyLen)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
encKey, authKey, iv, err := hkdfSplit(key)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
block, err := aes.NewCipher(encKey)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
padded := pkcs7Pad(plaintext, aes.BlockSize)
|
||||
ciphertext = make([]byte, len(padded))
|
||||
cipher.NewCBCEncrypter(block, iv).CryptBlocks(ciphertext, padded)
|
||||
|
||||
mac := hmac.New(sha256.New, authKey)
|
||||
mac.Write(ciphertext)
|
||||
tag := mac.Sum(nil)[:modernTagLen]
|
||||
|
||||
contentKey = append(append([]byte{}, key...), tag...)
|
||||
return ciphertext, contentKey, nil
|
||||
}
|
||||
|
||||
func decryptPayloadModern(ciphertext, contentKey []byte) ([]byte, error) {
|
||||
if len(contentKey) != modernKeyLen+modernTagLen {
|
||||
return nil, fmt.Errorf("omemo: modern content key must be %d bytes, got %d", modernKeyLen+modernTagLen, len(contentKey))
|
||||
}
|
||||
key := contentKey[:modernKeyLen]
|
||||
tag := contentKey[modernKeyLen:]
|
||||
|
||||
encKey, authKey, iv, err := hkdfSplit(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
mac := hmac.New(sha256.New, authKey)
|
||||
mac.Write(ciphertext)
|
||||
expectedTag := mac.Sum(nil)[:modernTagLen]
|
||||
if !hmac.Equal(expectedTag, tag) {
|
||||
return nil, errors.New("omemo: payload authentication failed")
|
||||
}
|
||||
|
||||
if len(ciphertext) == 0 || len(ciphertext)%aes.BlockSize != 0 {
|
||||
return nil, errors.New("omemo: invalid ciphertext length")
|
||||
}
|
||||
block, err := aes.NewCipher(encKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
padded := make([]byte, len(ciphertext))
|
||||
cipher.NewCBCDecrypter(block, iv).CryptBlocks(padded, ciphertext)
|
||||
return pkcs7Unpad(padded)
|
||||
}
|
||||
|
||||
func pkcs7Pad(data []byte, blockSize int) []byte {
|
||||
padLen := blockSize - (len(data) % blockSize)
|
||||
padded := make([]byte, len(data)+padLen)
|
||||
copy(padded, data)
|
||||
for i := len(data); i < len(padded); i++ {
|
||||
padded[i] = byte(padLen)
|
||||
}
|
||||
return padded
|
||||
}
|
||||
|
||||
func pkcs7Unpad(data []byte) ([]byte, error) {
|
||||
n := len(data)
|
||||
if n == 0 {
|
||||
return nil, errors.New("omemo: cannot unpad empty data")
|
||||
}
|
||||
padLen := int(data[n-1])
|
||||
if padLen == 0 || padLen > n || padLen > aes.BlockSize {
|
||||
return nil, errors.New("omemo: invalid PKCS7 padding")
|
||||
}
|
||||
for _, b := range data[n-padLen:] {
|
||||
if int(b) != padLen {
|
||||
return nil, errors.New("omemo: invalid PKCS7 padding")
|
||||
}
|
||||
}
|
||||
return data[:n-padLen], nil
|
||||
}
|
||||
|
||||
// WireKey is one recipient device's Signal-encrypted content-key blob,
|
||||
// version-and-namespace-agnostic - the xmpp-layer glue turns this into a
|
||||
// <key rid=".." prekey=".."/> (legacy) or <key rid=".." kex=".."/>
|
||||
// (modern) element; both attributes mean the same thing (IsPreKey).
|
||||
type WireKey struct {
|
||||
RecipientJID string
|
||||
DeviceID uint32
|
||||
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)
|
||||
}
|
||||
|
||||
// WireEnvelope is the decoded structure of an OMEMO <encrypted> element,
|
||||
// independent of which XML shape (legacy vs OMEMO 2/SCE) it came from or
|
||||
// will be marshaled to - that XML-specific glue lives in the xmpp package,
|
||||
// which imports this package explicitly when the omemo backend is active.
|
||||
type WireEnvelope struct {
|
||||
Version int
|
||||
SenderSID uint32
|
||||
IV []byte // legacy only
|
||||
Payload []byte // outer ciphertext
|
||||
Keys []WireKey
|
||||
}
|
||||
|
||||
// Encode serializes a WireEnvelope for e2ee.Envelope.Raw. This is
|
||||
// Go-to-Go-only plumbing (produced by omemo.go's Encrypt, consumed by the
|
||||
// xmpp-layer glue in the same process), not a wire format in its own
|
||||
// right, so gob is a fine fit - no schema stability concerns.
|
||||
func (e *WireEnvelope) Encode() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
if err := gob.NewEncoder(&buf).Encode(e); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// DecodeWireEnvelope reverses WireEnvelope.Encode.
|
||||
func DecodeWireEnvelope(raw []byte) (*WireEnvelope, error) {
|
||||
var e WireEnvelope
|
||||
if err := gob.NewDecoder(bytes.NewReader(raw)).Decode(&e); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &e, nil
|
||||
}
|
||||
119
e2ee/omemo/envelope_test.go
Normal file
119
e2ee/omemo/envelope_test.go
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
package omemo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"dev.narayana.im/narayana/telegabber/e2ee/omemo/libsignal"
|
||||
)
|
||||
|
||||
func TestEncryptOuterRoundTrip(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
version int
|
||||
}{
|
||||
{"legacy", libsignal.ProtocolVersionLegacy},
|
||||
{"modern", libsignal.ProtocolVersionModern},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
plaintext := []byte("hello from telegram")
|
||||
|
||||
payload, err := EncryptOuter(tc.version, plaintext, "gateway@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptOuter: %v", err)
|
||||
}
|
||||
if len(payload.Ciphertext) == 0 {
|
||||
t.Fatalf("expected non-empty ciphertext")
|
||||
}
|
||||
if tc.version == libsignal.ProtocolVersionLegacy && len(payload.IV) != legacyIVLen {
|
||||
t.Fatalf("expected a %d-byte IV for legacy, got %d", legacyIVLen, len(payload.IV))
|
||||
}
|
||||
if tc.version == libsignal.ProtocolVersionModern && payload.IV != nil {
|
||||
t.Fatalf("expected no IV for modern (receiver re-derives it), got %d bytes", len(payload.IV))
|
||||
}
|
||||
|
||||
got, err := DecryptOuter(tc.version, payload)
|
||||
if err != nil {
|
||||
t.Fatalf("DecryptOuter: %v", err)
|
||||
}
|
||||
if !bytes.Equal(got, plaintext) {
|
||||
t.Fatalf("round trip mismatch: got %q, want %q", got, plaintext)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptOuterTamperDetection(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
version int
|
||||
}{
|
||||
{"legacy", libsignal.ProtocolVersionLegacy},
|
||||
{"modern", libsignal.ProtocolVersionModern},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
payload, err := EncryptOuter(tc.version, []byte("secret"), "gateway@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptOuter: %v", err)
|
||||
}
|
||||
|
||||
tampered := *payload
|
||||
tampered.Ciphertext = append([]byte{}, payload.Ciphertext...)
|
||||
tampered.Ciphertext[0] ^= 0xFF
|
||||
|
||||
if _, err := DecryptOuter(tc.version, &tampered); err == nil {
|
||||
t.Fatalf("expected DecryptOuter to reject tampered ciphertext")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSCERoundTrip(t *testing.T) {
|
||||
plaintext := []byte("hello sce")
|
||||
xmlBytes, err := sceEncode(plaintext, "alice@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("sceEncode: %v", err)
|
||||
}
|
||||
got, err := sceDecode(xmlBytes)
|
||||
if err != nil {
|
||||
t.Fatalf("sceDecode: %v", err)
|
||||
}
|
||||
if !bytes.Equal(got, plaintext) {
|
||||
t.Fatalf("got %q, want %q", got, plaintext)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWireEnvelopeEncodeDecode(t *testing.T) {
|
||||
env := &WireEnvelope{
|
||||
Version: libsignal.ProtocolVersionModern,
|
||||
SenderSID: 12345,
|
||||
Payload: []byte("ciphertext-bytes"),
|
||||
Keys: []WireKey{
|
||||
{RecipientJID: "bob@example.com", DeviceID: 1, IsPreKey: true, Ciphertext: []byte("key-ciphertext-1")},
|
||||
{RecipientJID: "bob@example.com", DeviceID: 2, IsPreKey: false, Ciphertext: []byte("key-ciphertext-2")},
|
||||
},
|
||||
}
|
||||
|
||||
raw, err := env.Encode()
|
||||
if err != nil {
|
||||
t.Fatalf("Encode: %v", err)
|
||||
}
|
||||
|
||||
got, err := DecodeWireEnvelope(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("DecodeWireEnvelope: %v", err)
|
||||
}
|
||||
|
||||
if got.Version != env.Version || got.SenderSID != env.SenderSID || !bytes.Equal(got.Payload, env.Payload) {
|
||||
t.Fatalf("round trip mismatch: got %+v", got)
|
||||
}
|
||||
if len(got.Keys) != len(env.Keys) {
|
||||
t.Fatalf("expected %d keys, got %d", len(env.Keys), len(got.Keys))
|
||||
}
|
||||
for i := range env.Keys {
|
||||
if !reflect.DeepEqual(got.Keys[i], env.Keys[i]) {
|
||||
t.Fatalf("key %d mismatch: got %+v, want %+v", i, got.Keys[i], env.Keys[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
76
e2ee/omemo/sce.go
Normal file
76
e2ee/omemo/sce.go
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
package omemo
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/xml"
|
||||
"math/big"
|
||||
)
|
||||
|
||||
// Minimal XEP-0420 Stanza Content Encryption envelope, used only as the
|
||||
// plaintext OMEMO 2 (modern) content encryption operates on - just enough
|
||||
// to carry a plain message body, per the spec's requirement that the
|
||||
// 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
|
||||
// scope note on MUC).
|
||||
type sceEnvelope struct {
|
||||
XMLName xml.Name `xml:"urn:xmpp:sce:1 envelope"`
|
||||
Content sceContent `xml:"content"`
|
||||
RPad string `xml:"rpad"`
|
||||
From *sceJIDAttr `xml:"from"`
|
||||
}
|
||||
|
||||
type sceContent struct {
|
||||
Body sceBody `xml:"body"`
|
||||
}
|
||||
|
||||
type sceBody struct {
|
||||
XMLName xml.Name `xml:"jabber:client body"`
|
||||
Text string `xml:",chardata"`
|
||||
}
|
||||
|
||||
type sceJIDAttr struct {
|
||||
JID string `xml:"jid,attr"`
|
||||
}
|
||||
|
||||
// sceEncode wraps plaintext in an SCE envelope and serializes it - the
|
||||
// "plaintext" that modern OMEMO's outer content encryption actually
|
||||
// operates on. fromJID becomes the <from/> binding (empty to omit it).
|
||||
func sceEncode(plaintext []byte, fromJID string) ([]byte, error) {
|
||||
rpad, err := randomPadding()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
env := sceEnvelope{
|
||||
Content: sceContent{Body: sceBody{Text: string(plaintext)}},
|
||||
RPad: rpad,
|
||||
}
|
||||
if fromJID != "" {
|
||||
env.From = &sceJIDAttr{JID: fromJID}
|
||||
}
|
||||
return xml.Marshal(env)
|
||||
}
|
||||
|
||||
// sceDecode reverses sceEncode, extracting the plain body text.
|
||||
func sceDecode(data []byte) ([]byte, error) {
|
||||
var env sceEnvelope
|
||||
if err := xml.Unmarshal(data, &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []byte(env.Content.Body.Text), nil
|
||||
}
|
||||
|
||||
// randomPadding returns base64-encoded random padding of a random length
|
||||
// (1-63 bytes before encoding) to mask plaintext length, per XEP-0420's
|
||||
// MUST-contain-<rpad/> requirement.
|
||||
func randomPadding() (string, error) {
|
||||
n, err := rand.Int(rand.Reader, big.NewInt(63))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
buf := make([]byte, n.Int64()+1)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(buf), nil
|
||||
}
|
||||
20
e2ee/registry.go
Normal file
20
e2ee/registry.go
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
package e2ee
|
||||
|
||||
// backends is a process-wide registry of available Backend implementations,
|
||||
// keyed by Backend.Name(). Backends self-register from an init() function
|
||||
// in their own package (mirroring the self-registration idiom already used
|
||||
// by xmpp/extensions for stanza payload types), so importing a backend
|
||||
// package for its side effect is what makes it available here.
|
||||
var backends = map[string]Backend{}
|
||||
|
||||
// Register adds b to the registry, keyed by b.Name(). Call from the
|
||||
// backend package's own init().
|
||||
func Register(b Backend) {
|
||||
backends[b.Name()] = b
|
||||
}
|
||||
|
||||
// Get looks up a registered backend by name.
|
||||
func Get(name string) (Backend, bool) {
|
||||
b, ok := backends[name]
|
||||
return b, ok
|
||||
}
|
||||
90
e2ee/types.go
Normal file
90
e2ee/types.go
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
// Package e2ee is a backend-agnostic end-to-end encryption abstraction for
|
||||
// telegabber's XMPP side. It knows nothing about stanza XML shape - a
|
||||
// Backend deals only in opaque byte envelopes; wrapping/unwrapping those
|
||||
// into <encrypted>/PEP XML is the caller's job (see e2ee/omemo for the
|
||||
// first, and so far only, implementation, and its envelope.go for the
|
||||
// XEP-0384 wire format specifically).
|
||||
package e2ee
|
||||
|
||||
// PeerID identifies one bridging endpoint's cryptographic identity scope.
|
||||
// For telegabber this is always a bare XMPP JID: either a bridged chat's
|
||||
// pseudo-JID (whose keys the gateway owns and generates) or a real remote
|
||||
// user's bare JID (whose keys the gateway only ever fetches).
|
||||
type PeerID string
|
||||
|
||||
// DeviceID is an opaque per-backend device identifier, serialized as its
|
||||
// natural string form (OMEMO: decimal uint32; a hypothetical single-key
|
||||
// backend could always use "0" or the empty string).
|
||||
type DeviceID string
|
||||
|
||||
// Envelope is an opaque, backend-defined encrypted payload, plus enough
|
||||
// metadata for the xmpp layer to build the wire XML generically.
|
||||
type Envelope struct {
|
||||
Backend string // e.g. "omemo" - which backend produced Raw, so the xmpp layer picks the right XML shape
|
||||
Raw []byte // backend-specific serialized envelope
|
||||
}
|
||||
|
||||
// Namespaces is what a Backend wants advertised/served for a given peer.
|
||||
type Namespaces struct {
|
||||
Disco []string // disco#info features to advertise (e.g. "...+notify" hints)
|
||||
EME string // urn:xmpp:eme:0 encryption-namespace hint for non-supporting clients
|
||||
}
|
||||
|
||||
// DeviceListDoc is an opaque, backend-defined serialization of a peer's
|
||||
// PEP device-list document (fetched from them, or published for a chat
|
||||
// pseudo-JID we own).
|
||||
type DeviceListDoc struct{ Raw []byte }
|
||||
|
||||
// BundleDoc is an opaque, backend-defined serialization of one device's
|
||||
// PEP bundle document.
|
||||
type BundleDoc struct{ Raw []byte }
|
||||
|
||||
// DeviceInfo describes one known device of a peer, for trust-listing
|
||||
// ad-hoc commands.
|
||||
type DeviceInfo struct {
|
||||
ID DeviceID
|
||||
Trusted bool
|
||||
Fingerprint string
|
||||
}
|
||||
|
||||
// Backend is the minimal contract a pluggable E2EE scheme must implement.
|
||||
type Backend interface {
|
||||
// Name identifies the backend for config/logging (e.g. "omemo").
|
||||
Name() string
|
||||
|
||||
// Namespaces lists what this backend wants advertised/served.
|
||||
Namespaces() Namespaces
|
||||
|
||||
// EnsureIdentity creates (idempotently) a local cryptographic identity
|
||||
// for a PeerID this gateway owns (a bridged chat pseudo-JID),
|
||||
// generating and persisting whatever key material the backend needs.
|
||||
EnsureIdentity(peer PeerID) error
|
||||
|
||||
// PublishedIdentity returns the material this gateway must SERVE to
|
||||
// pubsub GET requests for its own PeerID (device list), pre-marshaled
|
||||
// to opaque bytes the xmpp layer wraps in <items>/<item>.
|
||||
PublishedIdentity(peer PeerID) (DeviceListDoc, error)
|
||||
// PublishedBundle is the same, for one specific device's bundle node.
|
||||
PublishedBundle(peer PeerID, device DeviceID) (BundleDoc, error)
|
||||
|
||||
// IngestRemoteDeviceList / IngestRemoteBundle feed the results of an
|
||||
// outbound PEP fetch (device-list, then per-device bundle) for a real
|
||||
// remote peer into the backend's store, establishing/refreshing
|
||||
// sessions as needed (trust decisions happen inside here).
|
||||
IngestRemoteDeviceList(peer PeerID, doc DeviceListDoc) error
|
||||
IngestRemoteBundle(peer PeerID, device DeviceID, doc BundleDoc) error
|
||||
|
||||
// Encrypt produces an opaque envelope addressed to every known device
|
||||
// of every given recipient, ready to be embedded in a stanza by the
|
||||
// xmpp layer. from is the identity doing the encrypting (a chat
|
||||
// pseudo-JID this gateway owns).
|
||||
Encrypt(from PeerID, to []PeerID, plaintext []byte) (Envelope, error)
|
||||
|
||||
// Decrypt consumes an inbound envelope addressed to "to" (a chat
|
||||
// pseudo-JID this gateway owns) from "from" (the real remote peer)
|
||||
// and returns plaintext.
|
||||
Decrypt(from PeerID, to PeerID, env Envelope) ([]byte, error)
|
||||
|
||||
// Devices lists known devices for a peer (trust-listing ad-hoc commands).
|
||||
Devices(peer PeerID) ([]DeviceInfo, error)
|
||||
}
|
||||
1
go.mod
1
go.mod
|
|
@ -11,6 +11,7 @@ require (
|
|||
github.com/soheilhy/args v0.0.0-20150720134047-6bcf4c78e87e
|
||||
github.com/xdg-go/stringprep v1.0.4
|
||||
github.com/zelenin/go-tdlib v0.5.2
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519
|
||||
gopkg.in/yaml.v2 v2.2.4
|
||||
gosrc.io/xmpp v0.5.2-0.20211214110136-5f99e1cd06e1
|
||||
)
|
||||
|
|
|
|||
15
go.sum
15
go.sum
|
|
@ -1,18 +1,6 @@
|
|||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
dev.narayana.im/narayana/go-tdlib v0.0.0-20230730021136-47da33180615 h1:RRUZJSro+k8FkazNx7QEYLVoO4wZtchvsd0Y2RBWjeU=
|
||||
dev.narayana.im/narayana/go-tdlib v0.0.0-20230730021136-47da33180615/go.mod h1:Xs8fXbk5n7VaPyrSs9DP7QYoBScWYsjX+lUcWmx1DIU=
|
||||
dev.narayana.im/narayana/go-tdlib v0.0.0-20231111182840-bc2f985e6268 h1:NCbc2bYuUGQsb/3z5SCIia3N34Ktwq3FwaUAfgF/WEU=
|
||||
dev.narayana.im/narayana/go-tdlib v0.0.0-20231111182840-bc2f985e6268/go.mod h1:Xs8fXbk5n7VaPyrSs9DP7QYoBScWYsjX+lUcWmx1DIU=
|
||||
dev.narayana.im/narayana/go-tdlib v0.0.0-20240124222245-b4c12addb061 h1:CWAQT74LwQne/3Po5KXDvudu3N0FBWm3XZZZhtl5j2w=
|
||||
dev.narayana.im/narayana/go-tdlib v0.0.0-20240124222245-b4c12addb061/go.mod h1:Xs8fXbk5n7VaPyrSs9DP7QYoBScWYsjX+lUcWmx1DIU=
|
||||
dev.narayana.im/narayana/go-xmpp v0.0.0-20220524203317-306b4ff58e8f h1:6249ajbMjgYz53Oq0IjTvjHXbxTfu29Mj1J/6swRHs4=
|
||||
dev.narayana.im/narayana/go-xmpp v0.0.0-20220524203317-306b4ff58e8f/go.mod h1:L3NFMqYOxyLz3JGmgFyWf7r9htE91zVGiK40oW4RwdY=
|
||||
dev.narayana.im/narayana/go-xmpp v0.0.0-20240131013505-18c46e6c59fd h1:+UW+E7JjI88aH4beDn1cw6D8rs1I061hN91HU4Y4pT8=
|
||||
dev.narayana.im/narayana/go-xmpp v0.0.0-20240131013505-18c46e6c59fd/go.mod h1:L3NFMqYOxyLz3JGmgFyWf7r9htE91zVGiK40oW4RwdY=
|
||||
dev.narayana.im/narayana/go-xmpp v0.0.0-20240512132113-6725c3862314 h1:29/NjOGOUDceO73Hk4Nj4uVa1je8MULJlsDSvKxSN/k=
|
||||
dev.narayana.im/narayana/go-xmpp v0.0.0-20240512132113-6725c3862314/go.mod h1:L3NFMqYOxyLz3JGmgFyWf7r9htE91zVGiK40oW4RwdY=
|
||||
dev.narayana.im/narayana/go-xmpp v0.0.0-20250818040038-376b5d77528a h1:9PPqmhy6HbhhCS5EZzw+sdi4EpWW+LOwnz+/JXTcHjQ=
|
||||
dev.narayana.im/narayana/go-xmpp v0.0.0-20250818040038-376b5d77528a/go.mod h1:L3NFMqYOxyLz3JGmgFyWf7r9htE91zVGiK40oW4RwdY=
|
||||
dev.narayana.im/narayana/go-xmpp v0.0.0-20250823114312-ed4011fc17e4 h1:HQT33Zp3iRkbCiijWDo943K//wQgzoMccIP7Vb2uEfY=
|
||||
dev.narayana.im/narayana/go-xmpp v0.0.0-20250823114312-ed4011fc17e4/go.mod h1:L3NFMqYOxyLz3JGmgFyWf7r9htE91zVGiK40oW4RwdY=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
|
|
@ -125,8 +113,6 @@ github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gi
|
|||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/zelenin/go-tdlib v0.5.2 h1:inEATEM0Pz6/HBI3wTlhd+brDHpmoXGgwdSb8/V6GiA=
|
||||
github.com/zelenin/go-tdlib v0.5.2/go.mod h1:Xs8fXbk5n7VaPyrSs9DP7QYoBScWYsjX+lUcWmx1DIU=
|
||||
go.coder.com/go-tools v0.0.0-20190317003359-0c6a35b74a16/go.mod h1:iKV5yK9t+J5nG9O3uF6KYdPEz3dyfMyB15MN1rbQ8Qw=
|
||||
go.opencensus.io v0.22.5 h1:dntmOdLpSpHlVqbW5Eay97DelsZHe+55D+xC6i0dDS0=
|
||||
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
|
||||
|
|
@ -138,6 +124,7 @@ golang.org/x/crypto v0.0.0-20180426230345-b49d69b5da94/go.mod h1:6SG95UA2DQfeDnf
|
|||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 h1:7I4JAnoQBe7ZtJcBaYHi5UtiO8tQHbUSXxL+pnGRANg=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
|
|
|
|||
Loading…
Reference in a new issue