diff --git a/e2ee/omemo/backend_test.go b/e2ee/omemo/backend_test.go new file mode 100644 index 0000000..9164b38 --- /dev/null +++ b/e2ee/omemo/backend_test.go @@ -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) + } + }) + } +} diff --git a/e2ee/omemo/bundle.go b/e2ee/omemo/bundle.go new file mode 100644 index 0000000..e9c336c --- /dev/null +++ b/e2ee/omemo/bundle.go @@ -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) +} diff --git a/e2ee/omemo/devicelist.go b/e2ee/omemo/devicelist.go new file mode 100644 index 0000000..48600d3 --- /dev/null +++ b/e2ee/omemo/devicelist.go @@ -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) +} diff --git a/e2ee/omemo/encrypt.go b/e2ee/omemo/encrypt.go new file mode 100644 index 0000000..2b860ac --- /dev/null +++ b/e2ee/omemo/encrypt.go @@ -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 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 +} diff --git a/e2ee/omemo/envelope.go b/e2ee/omemo/envelope.go index fefe233..b0eb325 100644 --- a/e2ee/omemo/envelope.go +++ b/e2ee/omemo/envelope.go @@ -14,8 +14,6 @@ import ( "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 @@ -24,94 +22,115 @@ import ( // 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. +// identity.go/encrypt.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): +// Three versions (see version.go), confirmed against the XEP-0384 spec +// text and its version history directly, not memory: // -// - 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; -// carries ciphertext only, carries the GCM nonce. +// - Omemo0 (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; carries ciphertext only, +// 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 +// - Omemo1 and Omemo2 share the same payload crypto (introduced at +// Omemo1/XEP-0384 0.4.0, unchanged since): the plaintext is first +// 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 // 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; carries ciphertext only; -// there is no element - the receiver re-derives it from the key -// via the same HKDF. +// HMAC-SHA256'd with authKey and truncated to 16 bytes (confirmed +// against the current spec text directly - an earlier spec version +// had a 32-vs-16-byte inconsistency here, fixed in 0.8.2). key||tag +// (48 bytes) is the per-recipient blob; carries ciphertext +// only; there is no 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 + omemo0KeyLen = 16 // AES-128 + omemo0IVLen = 12 // GCM nonce, standard size (crypto/cipher.NewGCM's default) + omemo0TagLen = 16 - modernKeyLen = 32 // AES-256 - modernAuthKeyLen = 32 - modernIVLen = 16 // AES block size - modernTagLen = 16 - modernHKDFInfo = "OMEMO Payload" + sceKeyLen = 32 // AES-256 (Omemo1/Omemo2 shared payload crypto) + sceAuthKeyLen = 32 + sceIVLen = 16 // AES block size + sceTagLen = 16 + sceHKDFInfo = "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 + IV []byte // Omemo0 only; nil for Omemo1/Omemo2 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 binding for modern; ignored for legacy. -func EncryptOuter(version int, plaintext []byte, fromJID string) (*EncryptedPayload, error) { +// per version. fromJID is used as the SCE binding for Omemo1/ +// Omemo2; ignored for Omemo0 (which has no SCE wrapping at all). +func EncryptOuter(version Version, plaintext []byte, fromJID string) (*EncryptedPayload, error) { switch version { - case libsignal.ProtocolVersionLegacy: - ciphertext, iv, contentKey, err := encryptPayloadLegacy(plaintext) + case Omemo0: + ciphertext, iv, contentKey, err := encryptPayloadOmemo0(plaintext) if err != nil { return nil, err } return &EncryptedPayload{Ciphertext: ciphertext, IV: iv, ContentKey: contentKey}, nil - case libsignal.ProtocolVersionModern: - sceXML, err := sceEncode(plaintext, fromJID) + case Omemo1: + sceXML, err := sceEncodeV0(plaintext, fromJID) if err != nil { 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 { return nil, err } return &EncryptedPayload{Ciphertext: ciphertext, ContentKey: contentKey}, nil default: - return nil, fmt.Errorf("omemo: unsupported protocol version %d", version) + return nil, fmt.Errorf("omemo: EncryptOuter: unsupported version %v", version) } } // DecryptOuter reverses EncryptOuter. -func DecryptOuter(version int, payload *EncryptedPayload) ([]byte, error) { +func DecryptOuter(version Version, 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) + case Omemo0: + return decryptPayloadOmemo0(payload.Ciphertext, payload.IV, payload.ContentKey) + case Omemo1: + sceXML, err := decryptPayloadSCE(payload.Ciphertext, payload.ContentKey) if err != nil { 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: - 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) { - key := make([]byte, legacyKeyLen) +func encryptPayloadOmemo0(plaintext []byte) (ciphertext, iv, contentKey []byte, err error) { + key := make([]byte, omemo0KeyLen) if _, err := rand.Read(key); err != nil { return nil, nil, nil, err } - iv = make([]byte, legacyIVLen) + iv = make([]byte, omemo0IVLen) if _, err := rand.Read(iv); err != nil { return nil, nil, nil, err } @@ -124,21 +143,21 @@ func encryptPayloadLegacy(plaintext []byte) (ciphertext, iv, contentKey []byte, return nil, nil, nil, err } 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") } - ciphertext = sealed[:len(sealed)-legacyTagLen] - tag := sealed[len(sealed)-legacyTagLen:] + ciphertext = sealed[:len(sealed)-omemo0TagLen] + tag := sealed[len(sealed)-omemo0TagLen:] 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)) +func decryptPayloadOmemo0(ciphertext, iv, contentKey []byte) ([]byte, error) { + if len(contentKey) != omemo0KeyLen+omemo0TagLen { + return nil, fmt.Errorf("omemo: omemo0 content key must be %d bytes, got %d", omemo0KeyLen+omemo0TagLen, len(contentKey)) } - key := contentKey[:legacyKeyLen] - tag := contentKey[legacyKeyLen:] + key := contentKey[:omemo0KeyLen] + tag := contentKey[omemo0KeyLen:] block, err := aes.NewCipher(key) if err != nil { 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) { - h := hkdf.New(sha256.New, key, make([]byte, sha256.Size), []byte(modernHKDFInfo)) - out := make([]byte, modernKeyLen+modernAuthKeyLen+modernIVLen) + h := hkdf.New(sha256.New, key, make([]byte, sha256.Size), []byte(sceHKDFInfo)) + out := make([]byte, sceKeyLen+sceAuthKeyLen+sceIVLen) if _, err := io.ReadFull(h, out); err != nil { 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) { - key := make([]byte, modernKeyLen) +// encryptPayloadSCE/decryptPayloadSCE are the payload crypto shared by +// 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 { return nil, nil, err } @@ -179,18 +201,18 @@ func encryptPayloadModern(plaintext []byte) (ciphertext, contentKey []byte, err mac := hmac.New(sha256.New, authKey) mac.Write(ciphertext) - tag := mac.Sum(nil)[:modernTagLen] + tag := mac.Sum(nil)[:sceTagLen] 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)) +func decryptPayloadSCE(ciphertext, contentKey []byte) ([]byte, error) { + if len(contentKey) != sceKeyLen+sceTagLen { + return nil, fmt.Errorf("omemo: SCE content key must be %d bytes, got %d", sceKeyLen+sceTagLen, len(contentKey)) } - key := contentKey[:modernKeyLen] - tag := contentKey[modernKeyLen:] + key := contentKey[:sceKeyLen] + tag := contentKey[sceKeyLen:] encKey, authKey, iv, err := hkdfSplit(key) if err != nil { @@ -199,7 +221,7 @@ func decryptPayloadModern(ciphertext, contentKey []byte) ([]byte, error) { mac := hmac.New(sha256.New, authKey) mac.Write(ciphertext) - expectedTag := mac.Sum(nil)[:modernTagLen] + expectedTag := mac.Sum(nil)[:sceTagLen] if !hmac.Equal(expectedTag, tag) { 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, // version-and-namespace-agnostic - the xmpp-layer glue turns this into a -// (legacy) or -// (modern) element; both attributes mean the same thing (IsPreKey). +// (Omemo0) or +// (Omemo1/Omemo2) 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) + 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 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. +// independent of which XML shape 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 + Version Version SenderSID uint32 - IV []byte // legacy only + IV []byte // Omemo0 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. +// Go-to-Go-only plumbing (produced by identity.go/encrypt.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 { diff --git a/e2ee/omemo/envelope_test.go b/e2ee/omemo/envelope_test.go index 5f9c22c..9995a40 100644 --- a/e2ee/omemo/envelope_test.go +++ b/e2ee/omemo/envelope_test.go @@ -4,17 +4,16 @@ 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 + version Version }{ - {"legacy", libsignal.ProtocolVersionLegacy}, - {"modern", libsignal.ProtocolVersionModern}, + {"omemo0", Omemo0}, + {"omemo1", Omemo1}, + {"omemo2", Omemo2}, } { t.Run(tc.name, func(t *testing.T) { plaintext := []byte("hello from telegram") @@ -26,11 +25,11 @@ func TestEncryptOuterRoundTrip(t *testing.T) { 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 == Omemo0 && len(payload.IV) != omemo0IVLen { + t.Fatalf("expected a %d-byte IV for omemo0, got %d", omemo0IVLen, 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)) + if tc.version != Omemo0 && payload.IV != nil { + 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) @@ -47,10 +46,11 @@ func TestEncryptOuterRoundTrip(t *testing.T) { func TestEncryptOuterTamperDetection(t *testing.T) { for _, tc := range []struct { name string - version int + version Version }{ - {"legacy", libsignal.ProtocolVersionLegacy}, - {"modern", libsignal.ProtocolVersionModern}, + {"omemo0", Omemo0}, + {"omemo1", Omemo1}, + {"omemo2", Omemo2}, } { t.Run(tc.name, func(t *testing.T) { payload, err := EncryptOuter(tc.version, []byte("secret"), "gateway@example.com") @@ -71,22 +71,39 @@ func TestEncryptOuterTamperDetection(t *testing.T) { 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) - } + + t.Run("v0", func(t *testing.T) { + xmlBytes, err := sceEncodeV0(plaintext, "alice@example.com") + if err != nil { + t.Fatalf("sceEncodeV0: %v", err) + } + got, err := sceDecodeV0(xmlBytes) + if err != nil { + t.Fatalf("sceDecodeV0: %v", err) + } + 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) { env := &WireEnvelope{ - Version: libsignal.ProtocolVersionModern, + Version: Omemo2, SenderSID: 12345, Payload: []byte("ciphertext-bytes"), Keys: []WireKey{ diff --git a/e2ee/omemo/identity.go b/e2ee/omemo/identity.go new file mode 100644 index 0000000..85d7f3e --- /dev/null +++ b/e2ee/omemo/identity.go @@ -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 +} diff --git a/e2ee/omemo/libsignal/capability_legacy.go b/e2ee/omemo/libsignal/capability_legacy.go index f7fac72..25be604 100644 --- a/e2ee/omemo/libsignal/capability_legacy.go +++ b/e2ee/omemo/libsignal/capability_legacy.go @@ -2,7 +2,7 @@ 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 // (protocol v4) support at all - only legacy/siacs OMEMO (protocol v3). -const ModernOMEMOSupported = false +const ProtocolV4Supported = false diff --git a/e2ee/omemo/libsignal/capability_modern.go b/e2ee/omemo/libsignal/capability_modern.go index cd068f5..8d26b63 100644 --- a/e2ee/omemo/libsignal/capability_modern.go +++ b/e2ee/omemo/libsignal/capability_modern.go @@ -2,9 +2,9 @@ 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 // OMEMO 2/urn:xmpp:omemo:2), as opposed to only legacy/siacs OMEMO // (protocol v3). Callers should check this before advertising or // negotiating anything beyond the legacy namespace. -const ModernOMEMOSupported = true +const ProtocolV4Supported = true diff --git a/e2ee/omemo/libsignal/cipher.go b/e2ee/omemo/libsignal/cipher.go index 5f5e697..2431062 100644 --- a/e2ee/omemo/libsignal/cipher.go +++ b/e2ee/omemo/libsignal/cipher.go @@ -40,7 +40,7 @@ type SessionCipher struct { // NewSessionCipher creates a session cipher for storeCtx's local identity // 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. func NewSessionCipher(ctx *Context, storeCtx *StoreContext, remote Address, version int) (*SessionCipher, error) { addr := newCAddress(remote) diff --git a/e2ee/omemo/libsignal/deserialize_legacy.go b/e2ee/omemo/libsignal/deserialize_legacy.go index e9ad47e..a929924 100644 --- a/e2ee/omemo/libsignal/deserialize_legacy.go +++ b/e2ee/omemo/libsignal/deserialize_legacy.go @@ -11,7 +11,7 @@ import "C" // Vanilla libsignal-protocol-c has no OMEMO (protocol v4) wire format // support at all, so these always fail with ErrInvalidVersion - callers // (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. func deserializeSignalMessageOmemo(ctx *Context, data *C.uint8_t, length C.size_t) (*C.signal_message, C.int) { diff --git a/e2ee/omemo/libsignal/doc.go b/e2ee/omemo/libsignal/doc.go index 7041837..6dd9d86 100644 --- a/e2ee/omemo/libsignal/doc.go +++ b/e2ee/omemo/libsignal/doc.go @@ -17,7 +17,7 @@ // 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 // 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 // the resulting API-surface differences between the two libraries. // diff --git a/e2ee/omemo/libsignal/keys.go b/e2ee/omemo/libsignal/keys.go index ea7b451..4e9307a 100644 --- a/e2ee/omemo/libsignal/keys.go +++ b/e2ee/omemo/libsignal/keys.go @@ -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 // (version >= 4, the raw 32-byte Montgomery form) and checks it against // whichever signature is supplied - so use Signature for -// ProtocolVersionLegacy and SignatureOMEMO for ProtocolVersionModern. +// ProtocolVersionV3 and SignatureOMEMO for ProtocolVersionV4. type SignedPreKeyInfo struct { ID uint32 PublicKey []byte diff --git a/e2ee/omemo/libsignal/session.go b/e2ee/omemo/libsignal/session.go index a9e7420..592e784 100644 --- a/e2ee/omemo/libsignal/session.go +++ b/e2ee/omemo/libsignal/session.go @@ -19,8 +19,8 @@ import ( // (urn:xmpp:omemo:2) - those two differ only in outer SCE stanza framing, // not in this underlying Double Ratchet/X3DH wire format. const ( - ProtocolVersionLegacy = 3 - ProtocolVersionModern = 4 + ProtocolVersionV3 = 3 + ProtocolVersionV4 = 4 ) // 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 // talking to remote. version controls the protocol version new sessions // 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) { addr := newCAddress(remote) diff --git a/e2ee/omemo/libsignal/session_test.go b/e2ee/omemo/libsignal/session_test.go index bfb20c7..3cb0a62 100644 --- a/e2ee/omemo/libsignal/session_test.go +++ b/e2ee/omemo/libsignal/session_test.go @@ -118,7 +118,7 @@ func (id *identity) bundle(t *testing.T, deviceID uint32, version int) libsignal } signature := spkInfo.Signature - if version >= libsignal.ProtocolVersionModern { + if version >= libsignal.ProtocolVersionV4 { signature = spkInfo.SignatureOMEMO } @@ -140,12 +140,12 @@ func TestSessionRoundTrip(t *testing.T) { version int omemo bool }{ - {"legacy", libsignal.ProtocolVersionLegacy, false}, - {"modern", libsignal.ProtocolVersionModern, true}, + {"legacy", libsignal.ProtocolVersionV3, false}, + {"modern", libsignal.ProtocolVersionV4, true}, } { tc := tc 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)") } diff --git a/e2ee/omemo/libsignal/version_legacy.go b/e2ee/omemo/libsignal/version_legacy.go index 5185d45..9711f09 100644 --- a/e2ee/omemo/libsignal/version_legacy.go +++ b/e2ee/omemo/libsignal/version_legacy.go @@ -18,19 +18,19 @@ var errModernOMEMOUnavailable = errors.New("libsignal: modern OMEMO (protocol v4 // Vanilla libsignal-protocol-c has no session_builder_set_version / // session_cipher_set_version - it only ever produces/consumes // 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 // ignored. func setBuilderVersion(b *C.session_builder, version int) error { - if version != ProtocolVersionLegacy { + if version != ProtocolVersionV3 { return errModernOMEMOUnavailable } return nil } func setCipherVersion(c *C.session_cipher, version int) error { - if version != ProtocolVersionLegacy { + if version != ProtocolVersionV3 { return errModernOMEMOUnavailable } return nil diff --git a/e2ee/omemo/libsignal/version_modern.go b/e2ee/omemo/libsignal/version_modern.go index 54ace5f..fe64460 100644 --- a/e2ee/omemo/libsignal/version_modern.go +++ b/e2ee/omemo/libsignal/version_modern.go @@ -10,7 +10,7 @@ package libsignal import "C" // 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 // version_legacy.go for the vanilla-libsignal-protocol-c build, which has // no such functions and only ever produces protocol v3. diff --git a/e2ee/omemo/sce.go b/e2ee/omemo/sce.go index 0417ca8..4ca35d0 100644 --- a/e2ee/omemo/sce.go +++ b/e2ee/omemo/sce.go @@ -7,22 +7,34 @@ import ( "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 +// Two dialects of XEP-0420 Stanza Content Encryption, matching what each +// OMEMO version actually wraps its plaintext in - just enough of each to +// carry a plain message body, per each dialect's requirement that the // envelope MUST contain and SHOULD contain (MUST contain // 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"` -} +// +// V0 (Omemo1, XEP-0384 0.4.0-0.7.x) is the OLD XEP-0420 shape, confirmed +// by reading the archived 0.3.2 spec directly: +// +// +// +// ... +// +// +// ... +// +// +// V1 (Omemo2, current) renamed content->envelope and payload->content +// (XEP-0420 0.4.0+, urn:xmpp:sce:1): +// +// +// +// ... +// +// +// ... +// type sceBody struct { XMLName xml.Name `xml:"jabber:client body"` @@ -33,16 +45,26 @@ 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 binding (empty to omit it). -func sceEncode(plaintext []byte, fromJID string) ([]byte, error) { +// --- V0 (Omemo1) --- + +type sceContentV0 struct { + 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() if err != nil { return nil, err } - env := sceEnvelope{ - Content: sceContent{Body: sceBody{Text: string(plaintext)}}, + env := sceContentV0{ + Payload: scePayloadV0{Body: sceBody{Text: string(plaintext)}}, RPad: rpad, } if fromJID != "" { @@ -51,9 +73,44 @@ func sceEncode(plaintext []byte, fromJID string) ([]byte, error) { return xml.Marshal(env) } -// sceDecode reverses sceEncode, extracting the plain body text. -func sceDecode(data []byte) ([]byte, error) { - var env sceEnvelope +func sceDecodeV0(data []byte) ([]byte, error) { + var env sceContentV0 + 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 { return nil, err } @@ -62,7 +119,7 @@ func sceDecode(data []byte) ([]byte, error) { // 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- requirement. +// MUST-contain- requirement (both dialects). func randomPadding() (string, error) { n, err := rand.Int(rand.Reader, big.NewInt(63)) if err != nil { diff --git a/e2ee/omemo/version.go b/e2ee/omemo/version.go new file mode 100644 index 0000000..67dd996 --- /dev/null +++ b/e2ee/omemo/version.go @@ -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, 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 , inner + // wrapper . 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 + // , inner wrapper . + 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 +} diff --git a/e2ee/peer.go b/e2ee/peer.go new file mode 100644 index 0000000..75c12ae --- /dev/null +++ b/e2ee/peer.go @@ -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 +} diff --git a/e2ee/store/badgerstore/store.go b/e2ee/store/badgerstore/store.go index edea7cf..b2b384f 100644 --- a/e2ee/store/badgerstore/store.go +++ b/e2ee/store/badgerstore/store.go @@ -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)) } +func (s *Store) preKeyPrefix() []byte { + return []byte(fmt.Sprintf("%s/%s/identity/prekey/", s.login, s.owner)) +} + func (s *Store) preKeyCounterKey() []byte { 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 } +// 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 { 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 // for libsignal.GeneratePreKeys, returning the first id in the block. 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 { item, err := txn.Get(s.preKeyCounterKey()) if err != nil && err != badger.ErrKeyNotFound { diff --git a/e2ee/store/badgerstore/store_test.go b/e2ee/store/badgerstore/store_test.go index 89ce91b..70ae4ab 100644 --- a/e2ee/store/badgerstore/store_test.go +++ b/e2ee/store/badgerstore/store_test.go @@ -135,15 +135,15 @@ func TestNextPreKeyIDs(t *testing.T) { if err != nil { t.Fatalf("NextPreKeyIDs: %v", err) } - if start1 != 0 { - t.Fatalf("expected first block to start at 0, got %d", start1) + if start1 != 1 { + t.Fatalf("expected first block to start at 1 (XEP-0384 requires positive ids), got %d", start1) } start2, err := store.NextPreKeyIDs(5) if err != nil { t.Fatalf("NextPreKeyIDs: %v", err) } - if start2 != 10 { - t.Fatalf("expected second block to start at 10, got %d", start2) + if start2 != 11 { + t.Fatalf("expected second block to start at 11, got %d", start2) } } diff --git a/e2ee/types.go b/e2ee/types.go index fa28961..1d7f3a1 100644 --- a/e2ee/types.go +++ b/e2ee/types.go @@ -56,35 +56,42 @@ type Backend interface { 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. + // for a PeerID this gateway owns (a bridged chat pseudo-JID - built via + // 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 // 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 /. + // pubsub GET requests for its own PeerID (an OwnedPeer - see + // EnsureIdentity) device list, pre-marshaled to opaque bytes the xmpp + // layer wraps in /. 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 + // remote peer (owner is an OwnedPeer identifying which of this + // gateway's identities is doing the fetching; peer is the remote's + // plain bare JID, no scoping needed) into the backend's store, + // 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 // 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). + // xmpp layer. from is the identity doing the encrypting (an OwnedPeer - + // see EnsureIdentity); to are the real remote peers' plain bare JIDs. 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 consumes an inbound envelope addressed to "to" (an + // OwnedPeer - see EnsureIdentity) from "from" (the real remote peer's + // plain bare JID) 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) + // Devices lists peer's known devices, as seen from owner's (an + // OwnedPeer) point of view - for trust-listing ad-hoc commands. + Devices(owner PeerID, peer PeerID) ([]DeviceInfo, error) }