omemo

package module
v0.1.3 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 8, 2026 License: GPL-3.0 Imports: 5 Imported by: 0

README

omemo-go

A Go implementation of OMEMO 2 (XEP-0384) as a transport- and storage-independent protocol layer.

omemo-go owns device state, session lifecycle, bundle management and trust decisions. It builds and parses OMEMO protocol objects and decides when X3DH/Double Ratchet operations happen, but never implements cryptography itself - that's handled by xochimilco. It is not an XMPP client: you bring your own XMPP library and storage backend.

XMPP client
     |
     v
 omemo-go
     |
     v
 xochimilco  (X3DH + Double Ratchet)

Install

go get github.com/jim-ww/omemo-go

Usage

Implement omemo.Store (persistence) and omemo.Transport (bundle/device-list publish and fetch), or use memstore.New() to get started.

ctx := context.Background()
store := memstore.New()

if err := omemo.InitIdentity(ctx, store, "alice@example.com", 1); err != nil {
    log.Fatal(err)
}

mgr, err := omemo.NewManager(ctx, store, transport,
    omemo.WithTrustResolver(func(ctx context.Context, dev omemo.Device, key ed25519.PublicKey) error {
        // Verify the fingerprint (e.g. prompt the user), or return an error
        // to refuse the device. Returning nil trusts and persists the decision.
        return nil
    }),
)
if err != nil {
    log.Fatal(err)
}

if err := mgr.PublishBundle(ctx); err != nil {
    log.Fatal(err)
}

// Encrypt for every known device of a contact. Best-effort: a failure for
// one device doesn't block the others.
msg, deviceErrs, err := mgr.EncryptMessage(ctx, "bob@example.com", []byte("hello"))
if err != nil {
    log.Fatal(err) // only set if every recipient device failed
}
// deliver msg via your own XMPP stanza-sending code

// On the receiving side, hand a parsed incoming EncryptedMessage to:
plaintext, err := mgr.DecryptMessage(ctx, msg)

Manager never sends or receives XMPP stanzas itself. EncryptMessage/DecryptMessage only build and consume omemo.EncryptedMessage values; wiring those into <message> stanzas and sending/receiving them is the application's job. Transport is only used for the bundle and device-list exchange (e.g. XMPP PEP), which OMEMO's own state machine drives on its own schedule (key rotation, prekey top-up, session bootstrap).

Status

Core protocol state machine (identity/bundle/session/trust management, multi-device encrypt, best-effort fan-out, key-transport messages) is implemented and covered by an end-to-end test in manager_test.go. Not included:

  • An XML (de)serialization adapter for a specific XMPP library - Bundle, DeviceList and EncryptedMessage are plain Go structs, independent of any wire format.
  • A persistent Store implementation for production use (see memstore for a reference/testing implementation).

Support the Project

Monero (XMR)

83YGRqP8uHed6NeegZQeX9ccCxbzoRHHEEi7pTwk4aqdJZEVXXA6NWtetnsEM2v33zFBBt3Rp6DNhU9qhJEGPspU14yN8t7

Documentation

Overview

Package omemo implements the OMEMO 2 protocol (XEP-0384) as a transport- and storage-independent orchestration layer on top of a Signal-protocol backend. It builds and parses OMEMO protocol objects, manages device, session, bundle and trust state, and decides when cryptographic operations happen - it never implements cryptography itself.

Index

Constants

View Source
const DefaultPreKeyCount = 100

DefaultPreKeyCount is how many one-time prekeys GenerateOneTimePreKeys generates when asked for the recommended pool size.

View Source
const MinPreKeyCount = 25

MinPreKeyCount is OMEMO's minimum bundle prekey count; NeedsMorePreKeys reports true once the stored pool drops to or below this.

Variables

View Source
var ErrBlockedDevice = errors.New("omemo: device is blocked")

ErrBlockedDevice is returned for a recipient device explicitly marked TrustUntrusted; such a device is always skipped regardless of a resolver.

View Source
var ErrNoRecipients = errors.New("omemo: no recipient device could be encrypted for")

ErrNoRecipients is returned by EncryptMessage when every recipient device failed, so no message could be produced at all.

View Source
var ErrOwnDeviceKeyMissing = errors.New("omemo: message has no key for this device")

ErrOwnDeviceKeyMissing is returned by DecryptMessage when an incoming message carries no RecipientKey for the local device.

View Source
var ErrPreKeyNotFound = errors.New("omemo: one-time prekey not found (already consumed or unknown)")

ErrPreKeyNotFound is returned by DecryptMessage (via Store.ConsumePreKey) when an incoming PreKeyMessage names a one-time prekey ID we don't have - almost always because the sender built the session from a stale cached bundle referencing an ID we already consumed and deleted (one-time prekeys are exactly that: usable once). The session that message would have started can never be recovered, same as ErrUnknownSession - callers should treat the two the same way (heal by pushing a fresh session).

View Source
var ErrUnknownSession = errors.New("omemo: no session for sender device")

ErrUnknownSession is returned by DecryptMessage when an incoming message carries no KeyExchange but no session for its sender device exists.

View Source
var ErrUntrustedDevice = errors.New("omemo: device is untrusted")

ErrUntrustedDevice is returned for a recipient device whose identity key has no trust decision (TrustUndecided) and no TrustResolver is configured, or whose resolver declined it.

Functions

func InitIdentity

func InitIdentity(ctx context.Context, store Store, jid string, deviceID DeviceID, protocol Protocol) error

InitIdentity generates a fresh identity key pair and device ID and stores them, along with an initial signed prekey and one-time prekey pool. It MUST be called exactly once for a new Store, before NewManager, with the same protocol NewManager will later be called with.

Types

type Bundle

type Bundle struct {
	Device Device

	// IdentityKey is the device's public identity key: 32-byte Ed25519 for
	// ProtocolV2, 32-byte Curve25519 for ProtocolV1.
	IdentityKey  []byte
	SignedPreKey SignedPreKey

	// PreKeys is the pool a bundle publisher offers; a consumer establishing
	// a session picks exactly one and the publisher MUST NOT reuse it.
	PreKeys []PreKey
}

Bundle is a device's published X3DH key material, as fetched from or published to whatever transport (e.g. XMPP PEP) the application provides.

type Device

type Device struct {
	JID string
	ID  DeviceID
}

Device identifies one specific OMEMO-capable client instance.

type DeviceError

type DeviceError struct {
	Device Device
	Err    error
}

DeviceError reports a per-recipient-device failure during a best-effort multi-device operation such as EncryptMessage.

func (*DeviceError) Error

func (e *DeviceError) Error() string

func (*DeviceError) Unwrap

func (e *DeviceError) Unwrap() error

type DeviceID

type DeviceID uint32

DeviceID identifies a single OMEMO device belonging to some JID.

type DeviceList

type DeviceList struct {
	JID     string
	Devices []DeviceID
}

DeviceList is the set of active device IDs known for a JID.

type DeviceStore

type DeviceStore interface {
	Devices(ctx context.Context, jid string) ([]DeviceID, error)
	SetDevices(ctx context.Context, jid string, devices []DeviceID) error

	RemoteIdentityKey(ctx context.Context, dev Device) (key []byte, ok bool, err error)
	PutRemoteIdentityKey(ctx context.Context, dev Device, key []byte) error
}

DeviceStore persists known device lists (own and contacts') and the identity key last seen for each remote device.

type EncryptedMessage

type EncryptedMessage struct {
	Sender  Device
	Keys    []RecipientKey
	Payload []byte

	// IV is the payload's initialization vector. It is only set for
	// ProtocolV1 messages carrying a Payload: legacy OMEMO's payload cipher
	// (unlike ProtocolV2's) uses an explicit, non-secret IV sent in the
	// clear rather than one derived from key material inside the ratchet.
	IV []byte
}

EncryptedMessage is a complete OMEMO envelope: one shared payload ciphertext (encrypted once) plus one wrapped key per recipient device.

Payload is nil for a key-transport message, which exists only to establish or refresh sessions and carries no message body.

type IdentityStore

type IdentityStore interface {
	IdentityKeyPair(ctx context.Context) ([]byte, error)
	SetIdentityKeyPair(ctx context.Context, priv []byte) error

	LocalDevice(ctx context.Context) (Device, error)
	SetLocalDevice(ctx context.Context, dev Device) error
}

IdentityStore persists this device's own long-term identity.

The private key's format depends on the protocol a given Store instance is scoped to (a Manager is scoped to exactly one Protocol - see NewManager): a 64-byte Ed25519 seed+public key (crypto/ed25519.PrivateKey's format) for ProtocolV2, or a 32-byte raw Curve25519 scalar for ProtocolV1.

type KeyExchange

type KeyExchange struct {
	// IdentityKey is the sender's public identity key: Ed25519 for
	// ProtocolV2, Curve25519 for ProtocolV1.
	IdentityKey    []byte
	EphemeralKey   []byte
	SignedPreKeyID uint32
	PreKeyID       uint32
}

KeyExchange carries the X3DH parameters needed to establish a new session. It is present on a RecipientKey only for the first message sent to a device under a given session (an OMEMOKeyExchange in XEP-0384 terms).

type Manager

type Manager struct {
	// contains filtered or unexported fields
}

Manager is the high-level OMEMO protocol state machine. It owns device, session, bundle and trust state, and is the only type applications need to call: X3DH and the Double Ratchet are never invoked directly by callers.

A Manager is safe for concurrent use. Session state is guarded per remote device, so encrypting or decrypting for different devices proceeds independently.

func NewManager

func NewManager(ctx context.Context, store Store, transport Transport, protocol Protocol, opts ...Option) (*Manager, error)

NewManager loads this device's identity from store and returns a ready Manager for protocol. Call InitIdentity first if this is a fresh Store.

protocol MUST match whatever protocol InitIdentity was called with for store - a Store is expected to be scoped to exactly one protocol (an account speaking both maintains two separate Store instances, one per protocol, each its own device identity and prekey pool).

func (*Manager) Bundle

func (m *Manager) Bundle(ctx context.Context) (Bundle, error)

Bundle builds this device's currently published bundle from the identity and prekey state held in the Store, for publishing via PublishBundle.

func (*Manager) DecryptMessage

func (m *Manager) DecryptMessage(ctx context.Context, msg *EncryptedMessage) ([]byte, error)

DecryptMessage decrypts an incoming EncryptedMessage. If msg carries a KeyExchange for this device, a new session is established as the responding party; otherwise an existing session is required.

The returned plaintext is nil for a key-transport message.

func (*Manager) EncryptKeyTransport

func (m *Manager) EncryptKeyTransport(ctx context.Context, jid string) (*EncryptedMessage, []DeviceError, error)

EncryptKeyTransport builds a key-transport message: it establishes or refreshes sessions with every known device of jid (and the local account's other devices), carrying no message body. Applications typically send this proactively to warm sessions ahead of an actual message, or to recover from a broken session.

func (*Manager) EncryptMessage

func (m *Manager) EncryptMessage(ctx context.Context, jid string, plaintext []byte) (*EncryptedMessage, []DeviceError, error)

EncryptMessage encrypts plaintext for every known device of jid, plus every other known device of the local account (so the sender's own other clients, e.g. a phone, can decrypt messages sent from this device), creating sessions as needed. It is best-effort: a failure for one recipient device does not prevent encrypting for the others. The returned error is non-nil only if no recipient could be encrypted for at all; per-device failures are always reported in the returned slice.

func (*Manager) GenerateOneTimePreKeys

func (m *Manager) GenerateOneTimePreKeys(ctx context.Context, n int) error

GenerateOneTimePreKeys generates n new one-time prekeys and adds them to the pool.

func (*Manager) LocalDevice

func (m *Manager) LocalDevice() Device

LocalDevice returns this Manager's own device identity.

func (*Manager) NeedsMorePreKeys

func (m *Manager) NeedsMorePreKeys(ctx context.Context) (bool, error)

NeedsMorePreKeys reports whether the one-time prekey pool has dropped to or below OMEMO's minimum bundle size and should be topped up (followed by PublishBundle - note the bundle itself only ever advertises the pool the Store holds; the caller's Transport.PublishBundle is expected to publish the full current pool, not just what Bundle returns).

func (*Manager) PublishBundle

func (m *Manager) PublishBundle(ctx context.Context) error

PublishBundle publishes this device's current bundle via Transport.

func (*Manager) ResetSession added in v0.1.2

func (m *Manager) ResetSession(ctx context.Context, dev Device) error

ResetSession discards any session (good or broken) held with dev, so the next message to or from it forces a brand-new session via a fresh X3DH handshake against dev's currently-published bundle, instead of continuing to use a session that decrypt/encrypt calls have started failing against. A session going bad on our end (e.g. our own session storage was wiped) isn't something the far side can detect on its own - it keeps sending ordinary ratchet messages against a session we no longer recognize, which DecryptMessage can only ever fail with ErrUnknownSession, forever, until something forces a rebuild. Callers should follow this with EncryptKeyTransport to dev's JID, so the far side also picks up the new session instead of continuing to encrypt against the one just discarded.

func (*Manager) RotateSignedPreKey

func (m *Manager) RotateSignedPreKey(ctx context.Context) error

RotateSignedPreKey generates a new signed prekey, demoting the current one to stale for its rotation grace period. OMEMO recommends doing this every one to four weeks; the caller decides the schedule.

func (*Manager) SyncDevices

func (m *Manager) SyncDevices(ctx context.Context, jid string) error

SyncDevices refreshes the known device list for jid via Transport and stores it.

type Option

type Option func(*Manager)

Option configures optional Manager behavior.

func WithTrustResolver

func WithTrustResolver(r TrustResolver) Option

WithTrustResolver installs a callback consulted for recipient devices whose identity key has no trust decision yet. Without one, encrypting to such a device fails with ErrUntrustedDevice.

type PreKey

type PreKey struct {
	ID     uint32
	Public []byte
}

PreKey is the public half of a single one-time prekey offered in a bundle. OMEMO demands a bundle carry at least 25, recommended around 100.

type PreKeyRecord

type PreKeyRecord struct {
	ID      uint32
	Public  []byte
	Private []byte
}

PreKeyRecord is a one-time prekey together with its private half, as held by the device that published it, before it is consumed by a peer.

type PreKeyStore

type PreKeyStore interface {
	CurrentSignedPreKey(ctx context.Context) (SignedPreKeyRecord, error)
	// StaleSignedPreKey returns the previous signed prekey, if one is still
	// being kept around during its rotation grace period, and ok=false
	// otherwise.
	StaleSignedPreKey(ctx context.Context) (rec SignedPreKeyRecord, ok bool, err error)
	// RotateSignedPreKey stores next as the current signed prekey, demoting
	// the previous current one to stale.
	RotateSignedPreKey(ctx context.Context, next SignedPreKeyRecord) error

	PreKeyCount(ctx context.Context) (int, error)
	// PreKeys lists the currently unconsumed one-time prekey pool, for
	// publishing a bundle. It does not consume anything.
	PreKeys(ctx context.Context) ([]PreKeyRecord, error)
	// NextPreKeyID atomically allocates and returns the next unused one-time
	// prekey ID. It MUST never repeat an ID, including ones already consumed
	// and no longer present in the pool - the live count alone cannot be
	// used to derive this, since consumed prekeys leave no trace behind.
	NextPreKeyID(ctx context.Context) (uint32, error)
	PutPreKeys(ctx context.Context, recs []PreKeyRecord) error
	// ConsumePreKey atomically fetches and deletes a one-time prekey by ID.
	// It MUST fail if the ID is unknown or was already consumed, since OMEMO
	// forbids reusing a one-time prekey.
	ConsumePreKey(ctx context.Context, id uint32) (PreKeyRecord, error)
}

PreKeyStore persists this device's own signed prekey (plus a stale one kept during its rotation grace period) and one-time prekey pool.

type Protocol added in v0.1.0

type Protocol int

Protocol distinguishes the two OMEMO wire protocols this library can speak. They share the Double Ratchet and X3DH DH-chain machinery but differ in identity key type, payload cipher, and wire format - see internal/signal for how each is dispatched.

const (
	// ProtocolV2 is XEP-0384 (OMEMO 2, urn:xmpp:omemo:2). Identity keys are
	// Ed25519.
	ProtocolV2 Protocol = iota

	// ProtocolV1 is legacy, pre-standardization OMEMO
	// (eu.siacs.conversations.axolotl). Identity keys are native Curve25519,
	// signed via XEdDSA.
	ProtocolV1
)

func (Protocol) String added in v0.1.0

func (p Protocol) String() string

type RecipientKey

type RecipientKey struct {
	Device DeviceID
	Data   []byte

	// KeyExchange is set when this key was produced while establishing a new
	// session, and must accompany Data so the recipient can complete X3DH.
	KeyExchange *KeyExchange
}

RecipientKey is one recipient device's wrapped copy of a message's payload key, encrypted through that device's own Double Ratchet session.

type SessionStore

type SessionStore interface {
	Session(ctx context.Context, dev Device) (data []byte, ok bool, err error)
	PutSession(ctx context.Context, dev Device, data []byte) error
	DeleteSession(ctx context.Context, dev Device) error
}

SessionStore persists per-device Double Ratchet session state as an opaque blob (see internal/signal.Session.Marshal).

type SignedPreKey

type SignedPreKey struct {
	ID        uint32
	Public    []byte
	Signature []byte
}

SignedPreKey is the public half of a bundle's signed prekey, rotated periodically (OMEMO recommends every one to four weeks).

type SignedPreKeyRecord

type SignedPreKeyRecord struct {
	ID        uint32
	Public    []byte
	Private   []byte
	Signature []byte
}

SignedPreKeyRecord is a signed prekey together with its private half, as held by the device that published it.

type Store

Store aggregates all persistence this library needs. Implementations may back it with SQLite, Postgres, Badger, BoltDB, memory, or anything else.

type Transport

type Transport interface {
	FetchDeviceList(ctx context.Context, jid string) (DeviceList, error)
	PublishDeviceList(ctx context.Context, list DeviceList) error

	FetchBundle(ctx context.Context, dev Device) (Bundle, error)
	PublishBundle(ctx context.Context, bundle Bundle) error
}

Transport is everything this library needs a concrete XMPP (or other) backend to provide network access for. It never sends or receives actual chat messages: an EncryptedMessage produced by Manager.EncryptMessage is the application's own responsibility to deliver via its normal message-sending path, and an incoming one is handed to Manager.DecryptMessage the same way. Transport exists only for the out-of-band bundle and device-list exchange (e.g. XMPP PEP) that OMEMO's own state machine initiates on its own schedule.

type TrustResolver

type TrustResolver func(ctx context.Context, dev Device, identityKey []byte) error

TrustResolver is consulted for a recipient device in TrustUndecided state. Returning nil trusts the device for this call and persists that decision; returning an error skips the device for this call without persisting anything, leaving it TrustUndecided for future calls.

type TrustState

type TrustState int

TrustState describes the trust decision associated with a remote device's identity key.

const (
	// TrustUndecided means no trust decision has been made yet. Encrypting to
	// such a device is refused unless a TrustResolver is configured.
	TrustUndecided TrustState = iota

	// TrustTrusted means the identity key has been verified or accepted.
	TrustTrusted

	// TrustUntrusted means the identity key has been explicitly rejected
	// (e.g. it changed unexpectedly). Such a device is always skipped.
	TrustUntrusted
)

func (TrustState) String

func (s TrustState) String() string

type TrustStore

type TrustStore interface {
	Trust(ctx context.Context, identityKey []byte) (TrustState, error)
	SetTrust(ctx context.Context, identityKey []byte, state TrustState) error
}

TrustStore persists trust decisions. Trust is bound to an identity key (its fingerprint) rather than a Device, since that key - not the device ID - is OMEMO's actual security anchor. identityKey bytes differ by protocol (see IdentityStore) but never collide across protocols since Ed25519 and Curve25519 keys occupy the same 32-byte space with effectively disjoint values, and callers additionally scope Store instances per protocol.

Directories

Path Synopsis
internal
signal
Package signal isolates every call into the xochimilco Signal-protocol backend (X3DH + Double Ratchet) behind a small, OMEMO-shaped seam.
Package signal isolates every call into the xochimilco Signal-protocol backend (X3DH + Double Ratchet) behind a small, OMEMO-shaped seam.
Package memstore is a plain in-memory omemo.Store, useful for tests and as a reference for implementing the interface against a real database.
Package memstore is a plain in-memory omemo.Store, useful for tests and as a reference for implementing the interface against a real database.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL