telegabber/e2ee/omemo/encrypt.go
2026-07-28 23:14:32 -04:00

169 lines
5.8 KiB
Go

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)
// Which variant to actually encrypt as is negotiated per chat, not
// fixed on the backend - see negotiatedVersion's doc comment.
version, err := b.negotiatedVersion(store)
if err != nil {
return e2ee.Envelope{}, fmt.Errorf("omemo: negotiatedVersion: %w", err)
}
// Outer content encryption happens once per message, regardless of how
// many recipient devices it ends up going to - see envelope.go.
payload, err := EncryptOuter(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: 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}, 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) - a remote peer's message version isn't guaranteed to match
// whatever we've last negotiated for outgoing content (see
// negotiatedVersion), so this is deliberately read straight from the
// envelope rather than assumed.
cipher, err := libsignal.NewSessionCipher(b.libctx, storeCtx, libsignal.Address{Name: string(from), DeviceID: wireEnv.SenderSID}, wireEnv.Version.libsignalVersion())
if err != nil {
return nil, fmt.Errorf("omemo: NewSessionCipher: %w", err)
}
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)
}
// A successful decrypt is the negotiation signal: "to"'s outgoing
// content should now use whatever variant this message actually used.
// This only actually changes anything the first time a session is
// established for "from" (a regular message on an existing session can
// only ever arrive at wireEnv.Version already matching that session's
// own stored version - libomemo-c rejects anything else outright - so
// this is a harmless no-op then); see the KNOWN LIMITATION on
// e2ee.Backend.NegotiatedVariant's doc comment for why an
// already-established chat can't actually be moved to a different
// variant later.
if err := store.SetNegotiatedVersion(byte(wireEnv.Version)); err != nil {
return nil, fmt.Errorf("omemo: SetNegotiatedVersion: %w", err)
}
return plaintext, nil
}