mirror of
https://dev.narayana.im/narayana/telegabber.git
synced 2026-08-05 12:17:06 +00:00
Implement OMEMO package
This commit is contained in:
parent
5e71ba954c
commit
6a7cd095e9
23 changed files with 1291 additions and 162 deletions
126
e2ee/omemo/backend_test.go
Normal file
126
e2ee/omemo/backend_test.go
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
package omemo_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"dev.narayana.im/narayana/telegabber/e2ee"
|
||||||
|
"dev.narayana.im/narayana/telegabber/e2ee/omemo"
|
||||||
|
"dev.narayana.im/narayana/telegabber/e2ee/omemo/libsignal"
|
||||||
|
"dev.narayana.im/narayana/telegabber/e2ee/store/badgerstore"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestBackend(t *testing.T, version omemo.Version) *omemo.Backend {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
ctx, err := libsignal.NewContext()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewContext: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(ctx.Close)
|
||||||
|
|
||||||
|
db, err := badgerstore.Open(t.TempDir(), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("badgerstore.Open: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { db.Close() })
|
||||||
|
|
||||||
|
return omemo.New(ctx, db, version)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBackendRoundTrip exercises the full e2ee.Backend interface, not just
|
||||||
|
// the underlying libsignal primitives (already covered by libsignal's own
|
||||||
|
// round-trip test): EnsureIdentity, publishing a device-list/bundle,
|
||||||
|
// ingesting them on the other side, and Encrypt/Decrypt in both
|
||||||
|
// directions. This simulates two independent parties - "gateway" (the
|
||||||
|
// bridged chat pseudo-JID's owned identity) and "client" (the real XMPP
|
||||||
|
// user's own device) - each with their own Backend/store, exchanging
|
||||||
|
// PublishedIdentity/PublishedBundle results directly in place of the real
|
||||||
|
// PEP fetch (e2ee/fetch.go's actual IQ round trip isn't built yet).
|
||||||
|
func TestBackendRoundTrip(t *testing.T) {
|
||||||
|
const gatewayLogin = "telegram-login-1"
|
||||||
|
const gatewayBareJID = "12345@transport.example"
|
||||||
|
const realUserBareJID = "alice@real.example"
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
version omemo.Version
|
||||||
|
}{
|
||||||
|
{"omemo0", omemo.Omemo0},
|
||||||
|
{"omemo1", omemo.Omemo1},
|
||||||
|
{"omemo2", omemo.Omemo2},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if tc.version != omemo.Omemo0 && !libsignal.ProtocolV4Supported {
|
||||||
|
t.Skip("protocol v4 (omemo1/omemo2) not supported by this build (signal_legacy tag)")
|
||||||
|
}
|
||||||
|
|
||||||
|
gateway := newTestBackend(t, tc.version)
|
||||||
|
client := newTestBackend(t, tc.version)
|
||||||
|
|
||||||
|
gatewayOwned := e2ee.OwnedPeer(gatewayLogin, gatewayBareJID)
|
||||||
|
clientOwned := e2ee.OwnedPeer("n/a", realUserBareJID)
|
||||||
|
realUser := e2ee.PeerID(realUserBareJID) // how the gateway refers to the real user
|
||||||
|
gatewayAsPeer := e2ee.PeerID(gatewayBareJID) // how the client refers to the gateway
|
||||||
|
|
||||||
|
if err := gateway.EnsureIdentity(gatewayOwned); err != nil {
|
||||||
|
t.Fatalf("gateway.EnsureIdentity: %v", err)
|
||||||
|
}
|
||||||
|
if err := client.EnsureIdentity(clientOwned); err != nil {
|
||||||
|
t.Fatalf("client.EnsureIdentity: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gateway "fetches" the client's device list + bundle.
|
||||||
|
clientDeviceList, err := client.PublishedIdentity(clientOwned)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("client.PublishedIdentity: %v", err)
|
||||||
|
}
|
||||||
|
if err := gateway.IngestRemoteDeviceList(gatewayOwned, realUser, clientDeviceList); err != nil {
|
||||||
|
t.Fatalf("gateway.IngestRemoteDeviceList: %v", err)
|
||||||
|
}
|
||||||
|
clientBundle, err := client.PublishedBundle(clientOwned, omemo.OwnDeviceID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("client.PublishedBundle: %v", err)
|
||||||
|
}
|
||||||
|
if err := gateway.IngestRemoteBundle(gatewayOwned, realUser, omemo.OwnDeviceID, clientBundle); err != nil {
|
||||||
|
t.Fatalf("gateway.IngestRemoteBundle: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gateway encrypts a message originating from Telegram.
|
||||||
|
plaintext1 := []byte("hello from telegram")
|
||||||
|
env1, err := gateway.Encrypt(gatewayOwned, []e2ee.PeerID{realUser}, plaintext1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("gateway.Encrypt: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client decrypts it - this establishes its side of the
|
||||||
|
// session as a side effect, with no prior bundle fetch needed
|
||||||
|
// on its end (matching the prekey-message responder flow
|
||||||
|
// already validated at the libsignal level).
|
||||||
|
got1, err := client.Decrypt(gatewayAsPeer, clientOwned, env1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("client.Decrypt: %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(got1, plaintext1) {
|
||||||
|
t.Fatalf("round trip mismatch: got %q, want %q", got1, plaintext1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client replies. It needs no explicit IngestRemoteBundle call
|
||||||
|
// for the gateway's device - the session established during
|
||||||
|
// the decrypt above already covers it.
|
||||||
|
plaintext2 := []byte("hi telegram")
|
||||||
|
env2, err := client.Encrypt(clientOwned, []e2ee.PeerID{gatewayAsPeer}, plaintext2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("client.Encrypt: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got2, err := gateway.Decrypt(realUser, gatewayOwned, env2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("gateway.Decrypt: %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(got2, plaintext2) {
|
||||||
|
t.Fatalf("round trip mismatch: got %q, want %q", got2, plaintext2)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
198
e2ee/omemo/bundle.go
Normal file
198
e2ee/omemo/bundle.go
Normal file
|
|
@ -0,0 +1,198 @@
|
||||||
|
package omemo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/xml"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"dev.narayana.im/narayana/telegabber/e2ee/omemo/libsignal"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Bundle document parsing/encoding for all three OMEMO versions. As with
|
||||||
|
// devicelist.go, Omemo1 and Omemo2 share an identical element shape (spk/
|
||||||
|
// spks/ik/prekeys/pk) - only the namespace differs, per the same
|
||||||
|
// changelog-silence reasoning - so one xepBundle struct serves both,
|
||||||
|
// namespace-agnostic on Unmarshal, explicit on Marshal.
|
||||||
|
//
|
||||||
|
// Omemo0's element names (signedPreKeyPublic/signedPreKeySignature/
|
||||||
|
// identityKey/prekeys/preKeyPublic) do NOT carry over to Omemo1/Omemo2 -
|
||||||
|
// confirmed against the XEP-0384 XML schema directly, not assumed.
|
||||||
|
//
|
||||||
|
// None of the three bundle formats carry a registration id (unlike
|
||||||
|
// vanilla Signal protocol's own prekey-bundle API, which does) - OMEMO
|
||||||
|
// simply doesn't use it (XEP-0384's OMEMOKeyExchange has no such field
|
||||||
|
// either). parseBundle leaves RegistrationID as 0; callers pass that
|
||||||
|
// straight through to libsignal.RemoteBundle, which is the standard,
|
||||||
|
// harmless practice for this field in OMEMO implementations - device_id
|
||||||
|
// already does what registration_id would otherwise disambiguate.
|
||||||
|
|
||||||
|
type omemo0Bundle struct {
|
||||||
|
XMLName xml.Name `xml:"eu.siacs.conversations.axolotl bundle"`
|
||||||
|
SignedPreKeyPublic omemo0IDText `xml:"signedPreKeyPublic"`
|
||||||
|
SignedPreKeySignature string `xml:"signedPreKeySignature"`
|
||||||
|
IdentityKey string `xml:"identityKey"`
|
||||||
|
PreKeys omemo0PreKeysWrap `xml:"prekeys"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type omemo0IDText struct {
|
||||||
|
ID uint32 `xml:"signedPreKeyId,attr"`
|
||||||
|
Text string `xml:",chardata"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type omemo0PreKeysWrap struct {
|
||||||
|
List []omemo0PreKey `xml:"preKeyPublic"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type omemo0PreKey struct {
|
||||||
|
ID uint32 `xml:"preKeyId,attr"`
|
||||||
|
Text string `xml:",chardata"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type xepBundle struct {
|
||||||
|
XMLName xml.Name `xml:"bundle"`
|
||||||
|
SPK xepIDText `xml:"spk"`
|
||||||
|
SPKS string `xml:"spks"`
|
||||||
|
IK string `xml:"ik"`
|
||||||
|
PreKeys xepPreKeysWrap `xml:"prekeys"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type xepIDText struct {
|
||||||
|
ID uint32 `xml:"id,attr"`
|
||||||
|
Text string `xml:",chardata"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type xepPreKeysWrap struct {
|
||||||
|
List []xepIDText `xml:"pk"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsedBundle is the common, namespace-agnostic result of parsing any of
|
||||||
|
// the three bundle formats, in the raw (still base64-decoded) bytes
|
||||||
|
// libsignal expects.
|
||||||
|
type parsedBundle struct {
|
||||||
|
SignedPreKeyID uint32
|
||||||
|
SignedPreKeyPublic []byte
|
||||||
|
SignedPreKeySignature []byte
|
||||||
|
IdentityKeyPublic []byte
|
||||||
|
PreKeys []parsedPreKey
|
||||||
|
}
|
||||||
|
|
||||||
|
type parsedPreKey struct {
|
||||||
|
ID uint32
|
||||||
|
Public []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseBundle(raw []byte) (*parsedBundle, error) {
|
||||||
|
var omemo0 omemo0Bundle
|
||||||
|
if err := xml.Unmarshal(raw, &omemo0); err == nil {
|
||||||
|
return decodeOmemo0Bundle(omemo0)
|
||||||
|
}
|
||||||
|
|
||||||
|
var xep xepBundle
|
||||||
|
if err := xml.Unmarshal(raw, &xep); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return decodeXepBundle(xep)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeOmemo0Bundle(b omemo0Bundle) (*parsedBundle, error) {
|
||||||
|
spk, err := b64Decode(b.SignedPreKeyPublic.Text)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("omemo: omemo0 bundle signedPreKeyPublic: %w", err)
|
||||||
|
}
|
||||||
|
sig, err := b64Decode(b.SignedPreKeySignature)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("omemo: omemo0 bundle signedPreKeySignature: %w", err)
|
||||||
|
}
|
||||||
|
ik, err := b64Decode(b.IdentityKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("omemo: omemo0 bundle identityKey: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &parsedBundle{
|
||||||
|
SignedPreKeyID: b.SignedPreKeyPublic.ID,
|
||||||
|
SignedPreKeyPublic: spk,
|
||||||
|
SignedPreKeySignature: sig,
|
||||||
|
IdentityKeyPublic: ik,
|
||||||
|
}
|
||||||
|
for _, pk := range b.PreKeys.List {
|
||||||
|
data, err := b64Decode(pk.Text)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("omemo: omemo0 bundle preKeyPublic %d: %w", pk.ID, err)
|
||||||
|
}
|
||||||
|
result.PreKeys = append(result.PreKeys, parsedPreKey{ID: pk.ID, Public: data})
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeXepBundle(b xepBundle) (*parsedBundle, error) {
|
||||||
|
spk, err := b64Decode(b.SPK.Text)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("omemo: bundle spk: %w", err)
|
||||||
|
}
|
||||||
|
sig, err := b64Decode(b.SPKS)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("omemo: bundle spks: %w", err)
|
||||||
|
}
|
||||||
|
ik, err := b64Decode(b.IK)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("omemo: bundle ik: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &parsedBundle{
|
||||||
|
SignedPreKeyID: b.SPK.ID,
|
||||||
|
SignedPreKeyPublic: spk,
|
||||||
|
SignedPreKeySignature: sig,
|
||||||
|
IdentityKeyPublic: ik,
|
||||||
|
}
|
||||||
|
for _, pk := range b.PreKeys.List {
|
||||||
|
data, err := b64Decode(pk.Text)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("omemo: bundle pk %d: %w", pk.ID, err)
|
||||||
|
}
|
||||||
|
result.PreKeys = append(result.PreKeys, parsedPreKey{ID: pk.ID, Public: data})
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// encodeBundle produces a bundle document for one of this gateway's own
|
||||||
|
// published devices, in the namespace matching version. Public keys are
|
||||||
|
// expected in the form DecodeSignedPreKey/DecodePreKey/
|
||||||
|
// DecodeIdentityPublicKey return - i.e. already picking the signature form
|
||||||
|
// (Omemo0 vs Omemo1/Omemo2) matching version, per the rule documented on
|
||||||
|
// libsignal.SignedPreKeyInfo.
|
||||||
|
func encodeBundle(version Version, identityKeyPublic []byte, spkID uint32, spkPublic, spkSignature []byte, preKeys []libsignal.PreKeyInfo) ([]byte, error) {
|
||||||
|
if version == Omemo0 {
|
||||||
|
b := omemo0Bundle{
|
||||||
|
SignedPreKeyPublic: omemo0IDText{ID: spkID, Text: b64Encode(spkPublic)},
|
||||||
|
SignedPreKeySignature: b64Encode(spkSignature),
|
||||||
|
IdentityKey: b64Encode(identityKeyPublic),
|
||||||
|
}
|
||||||
|
for _, pk := range preKeys {
|
||||||
|
b.PreKeys.List = append(b.PreKeys.List, omemo0PreKey{ID: pk.ID, Text: b64Encode(pk.PublicKey)})
|
||||||
|
}
|
||||||
|
return xml.Marshal(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
ns := omemo1NS
|
||||||
|
if version == Omemo2 {
|
||||||
|
ns = omemo2NS
|
||||||
|
}
|
||||||
|
b := xepBundle{
|
||||||
|
XMLName: xml.Name{Space: ns, Local: "bundle"},
|
||||||
|
SPK: xepIDText{ID: spkID, Text: b64Encode(spkPublic)},
|
||||||
|
SPKS: b64Encode(spkSignature),
|
||||||
|
IK: b64Encode(identityKeyPublic),
|
||||||
|
}
|
||||||
|
for _, pk := range preKeys {
|
||||||
|
b.PreKeys.List = append(b.PreKeys.List, xepIDText{ID: pk.ID, Text: b64Encode(pk.PublicKey)})
|
||||||
|
}
|
||||||
|
return xml.Marshal(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func b64Encode(b []byte) string {
|
||||||
|
return base64.StdEncoding.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func b64Decode(s string) ([]byte, error) {
|
||||||
|
return base64.StdEncoding.DecodeString(s)
|
||||||
|
}
|
||||||
83
e2ee/omemo/devicelist.go
Normal file
83
e2ee/omemo/devicelist.go
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
package omemo
|
||||||
|
|
||||||
|
import "encoding/xml"
|
||||||
|
|
||||||
|
// Device-list document parsing/encoding for all three OMEMO versions.
|
||||||
|
// Omemo1 and Omemo2 share an identical element shape (only the namespace
|
||||||
|
// differs - the XEP-0384 0.8.0 changelog only calls out SCE-related and
|
||||||
|
// namespace renames, nothing about the device-list/bundle schema itself,
|
||||||
|
// and 0.4.0/Omemo1's own changelog entry that introduced this schema
|
||||||
|
// doesn't mention it changing again later), so one xepDeviceList struct
|
||||||
|
// serves both: its XMLName tag omits the namespace, which makes Go's
|
||||||
|
// encoding/xml match on local name only during Unmarshal (so it accepts
|
||||||
|
// either namespace), while Marshal gets the right namespace by having the
|
||||||
|
// caller set XMLName explicitly before encoding.
|
||||||
|
|
||||||
|
// Namespace strings shared by both the device-list and bundle documents
|
||||||
|
// (see bundle.go) - both document kinds are just different local element
|
||||||
|
// names within the same OMEMO namespace.
|
||||||
|
const (
|
||||||
|
omemo0NS = "eu.siacs.conversations.axolotl"
|
||||||
|
omemo1NS = "urn:xmpp:omemo:1"
|
||||||
|
omemo2NS = "urn:xmpp:omemo:2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type omemo0DeviceList struct {
|
||||||
|
XMLName xml.Name `xml:"eu.siacs.conversations.axolotl list"`
|
||||||
|
Devices []omemo0Device `xml:"device"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type omemo0Device struct {
|
||||||
|
ID uint32 `xml:"id,attr"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type xepDeviceList struct {
|
||||||
|
XMLName xml.Name `xml:"devices"`
|
||||||
|
Devices []xepDevice `xml:"device"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type xepDevice struct {
|
||||||
|
ID uint32 `xml:"id,attr"`
|
||||||
|
Label string `xml:"label,attr,omitempty"`
|
||||||
|
LabelSig string `xml:"labelsig,attr,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseDeviceList extracts device ids from a device-list document, in
|
||||||
|
// whichever of the three known namespaces it's in.
|
||||||
|
func parseDeviceList(raw []byte) ([]uint32, error) {
|
||||||
|
var omemo0 omemo0DeviceList
|
||||||
|
if err := xml.Unmarshal(raw, &omemo0); err == nil {
|
||||||
|
ids := make([]uint32, len(omemo0.Devices))
|
||||||
|
for i, d := range omemo0.Devices {
|
||||||
|
ids[i] = d.ID
|
||||||
|
}
|
||||||
|
return ids, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var xep xepDeviceList
|
||||||
|
if err := xml.Unmarshal(raw, &xep); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ids := make([]uint32, len(xep.Devices))
|
||||||
|
for i, d := range xep.Devices {
|
||||||
|
ids[i] = d.ID
|
||||||
|
}
|
||||||
|
return ids, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// encodeDeviceList produces a device-list document for this gateway's own
|
||||||
|
// published identity, in the namespace matching version.
|
||||||
|
func encodeDeviceList(version Version, deviceID uint32) ([]byte, error) {
|
||||||
|
if version == Omemo0 {
|
||||||
|
return xml.Marshal(omemo0DeviceList{Devices: []omemo0Device{{ID: deviceID}}})
|
||||||
|
}
|
||||||
|
ns := omemo1NS
|
||||||
|
if version == Omemo2 {
|
||||||
|
ns = omemo2NS
|
||||||
|
}
|
||||||
|
list := xepDeviceList{
|
||||||
|
XMLName: xml.Name{Space: ns, Local: "devices"},
|
||||||
|
Devices: []xepDevice{{ID: deviceID}},
|
||||||
|
}
|
||||||
|
return xml.Marshal(list)
|
||||||
|
}
|
||||||
149
e2ee/omemo/encrypt.go
Normal file
149
e2ee/omemo/encrypt.go
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
package omemo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"dev.narayana.im/narayana/telegabber/e2ee"
|
||||||
|
"dev.narayana.im/narayana/telegabber/e2ee/omemo/libsignal"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (b *Backend) Encrypt(from e2ee.PeerID, to []e2ee.PeerID, plaintext []byte) (e2ee.Envelope, error) {
|
||||||
|
login, owner, ok := e2ee.SplitOwnedPeer(from)
|
||||||
|
if !ok {
|
||||||
|
return e2ee.Envelope{}, fmt.Errorf("omemo: Encrypt: %q is not an OwnedPeer", from)
|
||||||
|
}
|
||||||
|
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
|
||||||
|
store := b.db.Store(b.libctx, login, owner)
|
||||||
|
|
||||||
|
// Outer content encryption happens once per message, regardless of how
|
||||||
|
// many recipient devices it ends up going to - see envelope.go.
|
||||||
|
payload, err := EncryptOuter(b.version, plaintext, owner)
|
||||||
|
if err != nil {
|
||||||
|
return e2ee.Envelope{}, fmt.Errorf("omemo: EncryptOuter: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
storeCtx, err := libsignal.NewStoreContext(b.libctx, store)
|
||||||
|
if err != nil {
|
||||||
|
return e2ee.Envelope{}, fmt.Errorf("omemo: NewStoreContext: %w", err)
|
||||||
|
}
|
||||||
|
defer storeCtx.Close()
|
||||||
|
|
||||||
|
env := &WireEnvelope{
|
||||||
|
Version: b.version,
|
||||||
|
SenderSID: ownDeviceIDNum,
|
||||||
|
IV: payload.IV,
|
||||||
|
Payload: payload.Ciphertext,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, peer := range to {
|
||||||
|
deviceIDs, err := store.GetSubDeviceSessions(string(peer))
|
||||||
|
if err != nil {
|
||||||
|
return e2ee.Envelope{}, fmt.Errorf("omemo: GetSubDeviceSessions(%s): %w", peer, err)
|
||||||
|
}
|
||||||
|
if len(deviceIDs) == 0 {
|
||||||
|
return e2ee.Envelope{}, fmt.Errorf("omemo: Encrypt: no known devices/sessions for %s (IngestRemoteBundle was not called)", peer)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, deviceID := range deviceIDs {
|
||||||
|
cipher, err := libsignal.NewSessionCipher(b.libctx, storeCtx, libsignal.Address{Name: string(peer), DeviceID: deviceID}, b.version.libsignalVersion())
|
||||||
|
if err != nil {
|
||||||
|
return e2ee.Envelope{}, fmt.Errorf("omemo: NewSessionCipher(%s:%d): %w", peer, deviceID, err)
|
||||||
|
}
|
||||||
|
ct, err := cipher.Encrypt(payload.ContentKey)
|
||||||
|
cipher.Close()
|
||||||
|
if err != nil {
|
||||||
|
return e2ee.Envelope{}, fmt.Errorf("omemo: Encrypt(%s:%d): %w", peer, deviceID, err)
|
||||||
|
}
|
||||||
|
env.Keys = append(env.Keys, WireKey{
|
||||||
|
RecipientJID: string(peer),
|
||||||
|
DeviceID: deviceID,
|
||||||
|
IsPreKey: ct.Type == libsignal.CiphertextPreKeyType,
|
||||||
|
Ciphertext: ct.Serialized,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := env.Encode()
|
||||||
|
if err != nil {
|
||||||
|
return e2ee.Envelope{}, err
|
||||||
|
}
|
||||||
|
return e2ee.Envelope{Backend: Name, Raw: raw}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) Decrypt(from, to e2ee.PeerID, env e2ee.Envelope) ([]byte, error) {
|
||||||
|
if env.Backend != Name {
|
||||||
|
return nil, fmt.Errorf("omemo: Decrypt: envelope backend %q, want %q", env.Backend, Name)
|
||||||
|
}
|
||||||
|
login, owner, ok := e2ee.SplitOwnedPeer(to)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("omemo: Decrypt: %q is not an OwnedPeer", to)
|
||||||
|
}
|
||||||
|
|
||||||
|
wireEnv, err := DecodeWireEnvelope(env.Raw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("omemo: DecodeWireEnvelope: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var keyForUs *WireKey
|
||||||
|
for i := range wireEnv.Keys {
|
||||||
|
if wireEnv.Keys[i].DeviceID == ownDeviceIDNum {
|
||||||
|
keyForUs = &wireEnv.Keys[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if keyForUs == nil {
|
||||||
|
return nil, errors.New("omemo: Decrypt: no <key> addressed to our own device in this envelope")
|
||||||
|
}
|
||||||
|
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
|
||||||
|
store := b.db.Store(b.libctx, login, owner)
|
||||||
|
regID, err := store.GetLocalRegistrationID()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("omemo: GetLocalRegistrationID: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
storeCtx, err := libsignal.NewStoreContext(b.libctx, store)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("omemo: NewStoreContext: %w", err)
|
||||||
|
}
|
||||||
|
defer storeCtx.Close()
|
||||||
|
|
||||||
|
// The cipher's configured version must match what the SENDER actually
|
||||||
|
// used (wireEnv.Version, derived from which XML namespace their
|
||||||
|
// stanza used), not this backend's own outgoing preference (b.version)
|
||||||
|
// - a remote peer's message version isn't guaranteed to match ours,
|
||||||
|
// and this is the one case telegabber's own M1 round-trip test never
|
||||||
|
// exercised (both sides there always shared the same configured
|
||||||
|
// version), so this is deliberately conservative rather than assumed.
|
||||||
|
cipher, err := libsignal.NewSessionCipher(b.libctx, storeCtx, libsignal.Address{Name: string(from), DeviceID: wireEnv.SenderSID}, wireEnv.Version.libsignalVersion())
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("omemo: NewSessionCipher: %w", err)
|
||||||
|
}
|
||||||
|
defer cipher.Close()
|
||||||
|
|
||||||
|
msgType := libsignal.CiphertextSignalType
|
||||||
|
if keyForUs.IsPreKey {
|
||||||
|
msgType = libsignal.CiphertextPreKeyType
|
||||||
|
}
|
||||||
|
// Omemo1 and Omemo2 both use the OMEMOKeyExchange/OMEMOAuthenticatedMessage
|
||||||
|
// wire framing (libsignal.ProtocolVersionV4); only Omemo0 uses the
|
||||||
|
// original WhisperTextProtocol framing.
|
||||||
|
useOmemoFraming := wireEnv.Version != Omemo0
|
||||||
|
|
||||||
|
contentKey, err := cipher.Decrypt(b.libctx, libsignal.CiphertextMessage{Type: msgType, Serialized: keyForUs.Ciphertext}, useOmemoFraming, regID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("omemo: SessionCipher.Decrypt: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
plaintext, err := DecryptOuter(wireEnv.Version, &EncryptedPayload{Ciphertext: wireEnv.Payload, IV: wireEnv.IV, ContentKey: contentKey})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("omemo: DecryptOuter: %w", err)
|
||||||
|
}
|
||||||
|
return plaintext, nil
|
||||||
|
}
|
||||||
|
|
@ -14,8 +14,6 @@ import (
|
||||||
"io"
|
"io"
|
||||||
|
|
||||||
"golang.org/x/crypto/hkdf"
|
"golang.org/x/crypto/hkdf"
|
||||||
|
|
||||||
"dev.narayana.im/narayana/telegabber/e2ee/omemo/libsignal"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// This file is OMEMO's outer, non-Signal content encryption: the layer
|
// This file is OMEMO's outer, non-Signal content encryption: the layer
|
||||||
|
|
@ -24,94 +22,115 @@ import (
|
||||||
// blob (not the body itself) is what gets Signal/Double-Ratchet-encrypted
|
// blob (not the body itself) is what gets Signal/Double-Ratchet-encrypted
|
||||||
// once per recipient device (see libsignal.SessionCipher.Encrypt) - that
|
// once per recipient device (see libsignal.SessionCipher.Encrypt) - that
|
||||||
// per-device wrapping, and the device/session bookkeeping around it, is
|
// per-device wrapping, and the device/session bookkeeping around it, is
|
||||||
// omemo.go's job, built on top of EncryptOuter/DecryptOuter here.
|
// identity.go/encrypt.go's job, built on top of EncryptOuter/DecryptOuter
|
||||||
|
// here.
|
||||||
//
|
//
|
||||||
// Two schemes, matching the two wire versions (confirmed against the
|
// Three versions (see version.go), confirmed against the XEP-0384 spec
|
||||||
// XEP-0384 spec text directly, not memory - see the M2 kickoff discussion):
|
// text and its version history directly, not memory:
|
||||||
//
|
//
|
||||||
// - Legacy (ProtocolVersionLegacy, eu.siacs.conversations.axolotl):
|
// - Omemo0 (eu.siacs.conversations.axolotl): AES-128-GCM directly on the
|
||||||
// AES-128-GCM directly on the plaintext body. The GCM tag is stripped
|
// plaintext body. The GCM tag is stripped from the ciphertext and
|
||||||
// from the ciphertext and instead appended to the key (key||tag, 32
|
// instead appended to the key (key||tag, 32 bytes) before that blob is
|
||||||
// bytes) before that blob is Signal-encrypted per recipient; <payload>
|
// Signal-encrypted per recipient; <payload> carries ciphertext only,
|
||||||
// carries ciphertext only, <iv> carries the GCM nonce.
|
// <iv> carries the GCM nonce.
|
||||||
//
|
//
|
||||||
// - Modern (ProtocolVersionModern, OMEMO 1/2): the plaintext is first
|
// - Omemo1 and Omemo2 share the same payload crypto (introduced at
|
||||||
// wrapped in a minimal XEP-0420 SCE envelope (sce.go). That envelope is
|
// Omemo1/XEP-0384 0.4.0, unchanged since): the plaintext is first
|
||||||
// encrypted with AES-256-CBC using a key/authKey/iv derived via
|
// wrapped in an XEP-0420 SCE envelope - Omemo1 and Omemo2 use two
|
||||||
|
// different SCE dialects, see sce.go - then that envelope is encrypted
|
||||||
|
// with AES-256-CBC using a key/authKey/iv derived via
|
||||||
// HKDF-SHA256(random 32-byte key, salt=32 zero bytes, info="OMEMO
|
// HKDF-SHA256(random 32-byte key, salt=32 zero bytes, info="OMEMO
|
||||||
// Payload") -> 80 bytes, split 32/32/16. The ciphertext is
|
// Payload") -> 80 bytes, split 32/32/16. The ciphertext is
|
||||||
// HMAC-SHA256'd with authKey and truncated to 16 bytes. key||tag (48
|
// HMAC-SHA256'd with authKey and truncated to 16 bytes (confirmed
|
||||||
// bytes) is the per-recipient blob; <payload> carries ciphertext only;
|
// against the current spec text directly - an earlier spec version
|
||||||
// there is no <iv> element - the receiver re-derives it from the key
|
// had a 32-vs-16-byte inconsistency here, fixed in 0.8.2). key||tag
|
||||||
// via the same HKDF.
|
// (48 bytes) is the per-recipient blob; <payload> carries ciphertext
|
||||||
|
// only; there is no <iv> element - the receiver re-derives it from the
|
||||||
|
// key via the same HKDF.
|
||||||
|
|
||||||
const (
|
const (
|
||||||
legacyKeyLen = 16 // AES-128
|
omemo0KeyLen = 16 // AES-128
|
||||||
legacyIVLen = 12 // GCM nonce, standard size (crypto/cipher.NewGCM's default)
|
omemo0IVLen = 12 // GCM nonce, standard size (crypto/cipher.NewGCM's default)
|
||||||
legacyTagLen = 16
|
omemo0TagLen = 16
|
||||||
|
|
||||||
modernKeyLen = 32 // AES-256
|
sceKeyLen = 32 // AES-256 (Omemo1/Omemo2 shared payload crypto)
|
||||||
modernAuthKeyLen = 32
|
sceAuthKeyLen = 32
|
||||||
modernIVLen = 16 // AES block size
|
sceIVLen = 16 // AES block size
|
||||||
modernTagLen = 16
|
sceTagLen = 16
|
||||||
modernHKDFInfo = "OMEMO Payload"
|
sceHKDFInfo = "OMEMO Payload"
|
||||||
)
|
)
|
||||||
|
|
||||||
// EncryptedPayload is the result of OMEMO's outer content-encryption step.
|
// EncryptedPayload is the result of OMEMO's outer content-encryption step.
|
||||||
type EncryptedPayload struct {
|
type EncryptedPayload struct {
|
||||||
Ciphertext []byte
|
Ciphertext []byte
|
||||||
IV []byte // legacy only; nil for modern
|
IV []byte // Omemo0 only; nil for Omemo1/Omemo2
|
||||||
ContentKey []byte // the blob to Signal-encrypt once per recipient device
|
ContentKey []byte // the blob to Signal-encrypt once per recipient device
|
||||||
}
|
}
|
||||||
|
|
||||||
// EncryptOuter performs OMEMO's outer content encryption for plaintext,
|
// EncryptOuter performs OMEMO's outer content encryption for plaintext,
|
||||||
// per version (ProtocolVersionLegacy or ProtocolVersionModern). fromJID is
|
// per version. fromJID is used as the SCE <from> binding for Omemo1/
|
||||||
// used as the SCE <from> binding for modern; ignored for legacy.
|
// Omemo2; ignored for Omemo0 (which has no SCE wrapping at all).
|
||||||
func EncryptOuter(version int, plaintext []byte, fromJID string) (*EncryptedPayload, error) {
|
func EncryptOuter(version Version, plaintext []byte, fromJID string) (*EncryptedPayload, error) {
|
||||||
switch version {
|
switch version {
|
||||||
case libsignal.ProtocolVersionLegacy:
|
case Omemo0:
|
||||||
ciphertext, iv, contentKey, err := encryptPayloadLegacy(plaintext)
|
ciphertext, iv, contentKey, err := encryptPayloadOmemo0(plaintext)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &EncryptedPayload{Ciphertext: ciphertext, IV: iv, ContentKey: contentKey}, nil
|
return &EncryptedPayload{Ciphertext: ciphertext, IV: iv, ContentKey: contentKey}, nil
|
||||||
case libsignal.ProtocolVersionModern:
|
case Omemo1:
|
||||||
sceXML, err := sceEncode(plaintext, fromJID)
|
sceXML, err := sceEncodeV0(plaintext, fromJID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
ciphertext, contentKey, err := encryptPayloadModern(sceXML)
|
ciphertext, contentKey, err := encryptPayloadSCE(sceXML)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &EncryptedPayload{Ciphertext: ciphertext, ContentKey: contentKey}, nil
|
||||||
|
case Omemo2:
|
||||||
|
sceXML, err := sceEncodeV1(plaintext, fromJID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ciphertext, contentKey, err := encryptPayloadSCE(sceXML)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &EncryptedPayload{Ciphertext: ciphertext, ContentKey: contentKey}, nil
|
return &EncryptedPayload{Ciphertext: ciphertext, ContentKey: contentKey}, nil
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("omemo: unsupported protocol version %d", version)
|
return nil, fmt.Errorf("omemo: EncryptOuter: unsupported version %v", version)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DecryptOuter reverses EncryptOuter.
|
// DecryptOuter reverses EncryptOuter.
|
||||||
func DecryptOuter(version int, payload *EncryptedPayload) ([]byte, error) {
|
func DecryptOuter(version Version, payload *EncryptedPayload) ([]byte, error) {
|
||||||
switch version {
|
switch version {
|
||||||
case libsignal.ProtocolVersionLegacy:
|
case Omemo0:
|
||||||
return decryptPayloadLegacy(payload.Ciphertext, payload.IV, payload.ContentKey)
|
return decryptPayloadOmemo0(payload.Ciphertext, payload.IV, payload.ContentKey)
|
||||||
case libsignal.ProtocolVersionModern:
|
case Omemo1:
|
||||||
sceXML, err := decryptPayloadModern(payload.Ciphertext, payload.ContentKey)
|
sceXML, err := decryptPayloadSCE(payload.Ciphertext, payload.ContentKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return sceDecode(sceXML)
|
return sceDecodeV0(sceXML)
|
||||||
|
case Omemo2:
|
||||||
|
sceXML, err := decryptPayloadSCE(payload.Ciphertext, payload.ContentKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return sceDecodeV1(sceXML)
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("omemo: unsupported protocol version %d", version)
|
return nil, fmt.Errorf("omemo: DecryptOuter: unsupported version %v", version)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func encryptPayloadLegacy(plaintext []byte) (ciphertext, iv, contentKey []byte, err error) {
|
func encryptPayloadOmemo0(plaintext []byte) (ciphertext, iv, contentKey []byte, err error) {
|
||||||
key := make([]byte, legacyKeyLen)
|
key := make([]byte, omemo0KeyLen)
|
||||||
if _, err := rand.Read(key); err != nil {
|
if _, err := rand.Read(key); err != nil {
|
||||||
return nil, nil, nil, err
|
return nil, nil, nil, err
|
||||||
}
|
}
|
||||||
iv = make([]byte, legacyIVLen)
|
iv = make([]byte, omemo0IVLen)
|
||||||
if _, err := rand.Read(iv); err != nil {
|
if _, err := rand.Read(iv); err != nil {
|
||||||
return nil, nil, nil, err
|
return nil, nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -124,21 +143,21 @@ func encryptPayloadLegacy(plaintext []byte) (ciphertext, iv, contentKey []byte,
|
||||||
return nil, nil, nil, err
|
return nil, nil, nil, err
|
||||||
}
|
}
|
||||||
sealed := gcm.Seal(nil, iv, plaintext, nil)
|
sealed := gcm.Seal(nil, iv, plaintext, nil)
|
||||||
if len(sealed) < legacyTagLen {
|
if len(sealed) < omemo0TagLen {
|
||||||
return nil, nil, nil, errors.New("omemo: GCM output shorter than its own tag")
|
return nil, nil, nil, errors.New("omemo: GCM output shorter than its own tag")
|
||||||
}
|
}
|
||||||
ciphertext = sealed[:len(sealed)-legacyTagLen]
|
ciphertext = sealed[:len(sealed)-omemo0TagLen]
|
||||||
tag := sealed[len(sealed)-legacyTagLen:]
|
tag := sealed[len(sealed)-omemo0TagLen:]
|
||||||
contentKey = append(append([]byte{}, key...), tag...)
|
contentKey = append(append([]byte{}, key...), tag...)
|
||||||
return ciphertext, iv, contentKey, nil
|
return ciphertext, iv, contentKey, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func decryptPayloadLegacy(ciphertext, iv, contentKey []byte) ([]byte, error) {
|
func decryptPayloadOmemo0(ciphertext, iv, contentKey []byte) ([]byte, error) {
|
||||||
if len(contentKey) != legacyKeyLen+legacyTagLen {
|
if len(contentKey) != omemo0KeyLen+omemo0TagLen {
|
||||||
return nil, fmt.Errorf("omemo: legacy content key must be %d bytes, got %d", legacyKeyLen+legacyTagLen, len(contentKey))
|
return nil, fmt.Errorf("omemo: omemo0 content key must be %d bytes, got %d", omemo0KeyLen+omemo0TagLen, len(contentKey))
|
||||||
}
|
}
|
||||||
key := contentKey[:legacyKeyLen]
|
key := contentKey[:omemo0KeyLen]
|
||||||
tag := contentKey[legacyKeyLen:]
|
tag := contentKey[omemo0KeyLen:]
|
||||||
block, err := aes.NewCipher(key)
|
block, err := aes.NewCipher(key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -152,16 +171,19 @@ func decryptPayloadLegacy(ciphertext, iv, contentKey []byte) ([]byte, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func hkdfSplit(key []byte) (encKey, authKey, iv []byte, err error) {
|
func hkdfSplit(key []byte) (encKey, authKey, iv []byte, err error) {
|
||||||
h := hkdf.New(sha256.New, key, make([]byte, sha256.Size), []byte(modernHKDFInfo))
|
h := hkdf.New(sha256.New, key, make([]byte, sha256.Size), []byte(sceHKDFInfo))
|
||||||
out := make([]byte, modernKeyLen+modernAuthKeyLen+modernIVLen)
|
out := make([]byte, sceKeyLen+sceAuthKeyLen+sceIVLen)
|
||||||
if _, err := io.ReadFull(h, out); err != nil {
|
if _, err := io.ReadFull(h, out); err != nil {
|
||||||
return nil, nil, nil, err
|
return nil, nil, nil, err
|
||||||
}
|
}
|
||||||
return out[:modernKeyLen], out[modernKeyLen : modernKeyLen+modernAuthKeyLen], out[modernKeyLen+modernAuthKeyLen:], nil
|
return out[:sceKeyLen], out[sceKeyLen : sceKeyLen+sceAuthKeyLen], out[sceKeyLen+sceAuthKeyLen:], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func encryptPayloadModern(plaintext []byte) (ciphertext, contentKey []byte, err error) {
|
// encryptPayloadSCE/decryptPayloadSCE are the payload crypto shared by
|
||||||
key := make([]byte, modernKeyLen)
|
// Omemo1 and Omemo2 - only the SCE dialect wrapping plaintext before this
|
||||||
|
// runs differs between them (see sce.go).
|
||||||
|
func encryptPayloadSCE(plaintext []byte) (ciphertext, contentKey []byte, err error) {
|
||||||
|
key := make([]byte, sceKeyLen)
|
||||||
if _, err := rand.Read(key); err != nil {
|
if _, err := rand.Read(key); err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -179,18 +201,18 @@ func encryptPayloadModern(plaintext []byte) (ciphertext, contentKey []byte, err
|
||||||
|
|
||||||
mac := hmac.New(sha256.New, authKey)
|
mac := hmac.New(sha256.New, authKey)
|
||||||
mac.Write(ciphertext)
|
mac.Write(ciphertext)
|
||||||
tag := mac.Sum(nil)[:modernTagLen]
|
tag := mac.Sum(nil)[:sceTagLen]
|
||||||
|
|
||||||
contentKey = append(append([]byte{}, key...), tag...)
|
contentKey = append(append([]byte{}, key...), tag...)
|
||||||
return ciphertext, contentKey, nil
|
return ciphertext, contentKey, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func decryptPayloadModern(ciphertext, contentKey []byte) ([]byte, error) {
|
func decryptPayloadSCE(ciphertext, contentKey []byte) ([]byte, error) {
|
||||||
if len(contentKey) != modernKeyLen+modernTagLen {
|
if len(contentKey) != sceKeyLen+sceTagLen {
|
||||||
return nil, fmt.Errorf("omemo: modern content key must be %d bytes, got %d", modernKeyLen+modernTagLen, len(contentKey))
|
return nil, fmt.Errorf("omemo: SCE content key must be %d bytes, got %d", sceKeyLen+sceTagLen, len(contentKey))
|
||||||
}
|
}
|
||||||
key := contentKey[:modernKeyLen]
|
key := contentKey[:sceKeyLen]
|
||||||
tag := contentKey[modernKeyLen:]
|
tag := contentKey[sceKeyLen:]
|
||||||
|
|
||||||
encKey, authKey, iv, err := hkdfSplit(key)
|
encKey, authKey, iv, err := hkdfSplit(key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -199,7 +221,7 @@ func decryptPayloadModern(ciphertext, contentKey []byte) ([]byte, error) {
|
||||||
|
|
||||||
mac := hmac.New(sha256.New, authKey)
|
mac := hmac.New(sha256.New, authKey)
|
||||||
mac.Write(ciphertext)
|
mac.Write(ciphertext)
|
||||||
expectedTag := mac.Sum(nil)[:modernTagLen]
|
expectedTag := mac.Sum(nil)[:sceTagLen]
|
||||||
if !hmac.Equal(expectedTag, tag) {
|
if !hmac.Equal(expectedTag, tag) {
|
||||||
return nil, errors.New("omemo: payload authentication failed")
|
return nil, errors.New("omemo: payload authentication failed")
|
||||||
}
|
}
|
||||||
|
|
@ -245,31 +267,31 @@ func pkcs7Unpad(data []byte) ([]byte, error) {
|
||||||
|
|
||||||
// WireKey is one recipient device's Signal-encrypted content-key blob,
|
// WireKey is one recipient device's Signal-encrypted content-key blob,
|
||||||
// version-and-namespace-agnostic - the xmpp-layer glue turns this into a
|
// version-and-namespace-agnostic - the xmpp-layer glue turns this into a
|
||||||
// <key rid=".." prekey=".."/> (legacy) or <key rid=".." kex=".."/>
|
// <key rid=".." prekey=".."/> (Omemo0) or <key rid=".." kex=".."/>
|
||||||
// (modern) element; both attributes mean the same thing (IsPreKey).
|
// (Omemo1/Omemo2) element; both attributes mean the same thing (IsPreKey).
|
||||||
type WireKey struct {
|
type WireKey struct {
|
||||||
RecipientJID string
|
RecipientJID string
|
||||||
DeviceID uint32
|
DeviceID uint32
|
||||||
IsPreKey bool
|
IsPreKey bool
|
||||||
Ciphertext []byte // libsignal.CiphertextMessage.Serialized - already in the correct per-version wire format (see the M2 architecture note: libomemo-c itself produces OMEMOKeyExchange/OMEMOAuthenticatedMessage protobuf bytes for protocol v4, no extra wrapping needed here)
|
Ciphertext []byte // libsignal.CiphertextMessage.Serialized - already in the correct per-version wire format (libomemo-c itself produces the OMEMOKeyExchange/OMEMOAuthenticatedMessage protobuf bytes for protocol v4, no extra wrapping needed here)
|
||||||
}
|
}
|
||||||
|
|
||||||
// WireEnvelope is the decoded structure of an OMEMO <encrypted> element,
|
// WireEnvelope is the decoded structure of an OMEMO <encrypted> element,
|
||||||
// independent of which XML shape (legacy vs OMEMO 2/SCE) it came from or
|
// independent of which XML shape it came from or will be marshaled to -
|
||||||
// will be marshaled to - that XML-specific glue lives in the xmpp package,
|
// that XML-specific glue lives in the xmpp package, which imports this
|
||||||
// which imports this package explicitly when the omemo backend is active.
|
// package explicitly when the omemo backend is active.
|
||||||
type WireEnvelope struct {
|
type WireEnvelope struct {
|
||||||
Version int
|
Version Version
|
||||||
SenderSID uint32
|
SenderSID uint32
|
||||||
IV []byte // legacy only
|
IV []byte // Omemo0 only
|
||||||
Payload []byte // outer ciphertext
|
Payload []byte // outer ciphertext
|
||||||
Keys []WireKey
|
Keys []WireKey
|
||||||
}
|
}
|
||||||
|
|
||||||
// Encode serializes a WireEnvelope for e2ee.Envelope.Raw. This is
|
// Encode serializes a WireEnvelope for e2ee.Envelope.Raw. This is
|
||||||
// Go-to-Go-only plumbing (produced by omemo.go's Encrypt, consumed by the
|
// Go-to-Go-only plumbing (produced by identity.go/encrypt.go's Encrypt,
|
||||||
// xmpp-layer glue in the same process), not a wire format in its own
|
// consumed by the xmpp-layer glue in the same process), not a wire format
|
||||||
// right, so gob is a fine fit - no schema stability concerns.
|
// in its own right, so gob is a fine fit - no schema stability concerns.
|
||||||
func (e *WireEnvelope) Encode() ([]byte, error) {
|
func (e *WireEnvelope) Encode() ([]byte, error) {
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if err := gob.NewEncoder(&buf).Encode(e); err != nil {
|
if err := gob.NewEncoder(&buf).Encode(e); err != nil {
|
||||||
|
|
|
||||||
|
|
@ -4,17 +4,16 @@ import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"reflect"
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"dev.narayana.im/narayana/telegabber/e2ee/omemo/libsignal"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestEncryptOuterRoundTrip(t *testing.T) {
|
func TestEncryptOuterRoundTrip(t *testing.T) {
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
name string
|
name string
|
||||||
version int
|
version Version
|
||||||
}{
|
}{
|
||||||
{"legacy", libsignal.ProtocolVersionLegacy},
|
{"omemo0", Omemo0},
|
||||||
{"modern", libsignal.ProtocolVersionModern},
|
{"omemo1", Omemo1},
|
||||||
|
{"omemo2", Omemo2},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
plaintext := []byte("hello from telegram")
|
plaintext := []byte("hello from telegram")
|
||||||
|
|
@ -26,11 +25,11 @@ func TestEncryptOuterRoundTrip(t *testing.T) {
|
||||||
if len(payload.Ciphertext) == 0 {
|
if len(payload.Ciphertext) == 0 {
|
||||||
t.Fatalf("expected non-empty ciphertext")
|
t.Fatalf("expected non-empty ciphertext")
|
||||||
}
|
}
|
||||||
if tc.version == libsignal.ProtocolVersionLegacy && len(payload.IV) != legacyIVLen {
|
if tc.version == Omemo0 && len(payload.IV) != omemo0IVLen {
|
||||||
t.Fatalf("expected a %d-byte IV for legacy, got %d", legacyIVLen, len(payload.IV))
|
t.Fatalf("expected a %d-byte IV for omemo0, got %d", omemo0IVLen, len(payload.IV))
|
||||||
}
|
}
|
||||||
if tc.version == libsignal.ProtocolVersionModern && payload.IV != nil {
|
if tc.version != Omemo0 && payload.IV != nil {
|
||||||
t.Fatalf("expected no IV for modern (receiver re-derives it), got %d bytes", len(payload.IV))
|
t.Fatalf("expected no IV for %s (receiver re-derives it), got %d bytes", tc.name, len(payload.IV))
|
||||||
}
|
}
|
||||||
|
|
||||||
got, err := DecryptOuter(tc.version, payload)
|
got, err := DecryptOuter(tc.version, payload)
|
||||||
|
|
@ -47,10 +46,11 @@ func TestEncryptOuterRoundTrip(t *testing.T) {
|
||||||
func TestEncryptOuterTamperDetection(t *testing.T) {
|
func TestEncryptOuterTamperDetection(t *testing.T) {
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
name string
|
name string
|
||||||
version int
|
version Version
|
||||||
}{
|
}{
|
||||||
{"legacy", libsignal.ProtocolVersionLegacy},
|
{"omemo0", Omemo0},
|
||||||
{"modern", libsignal.ProtocolVersionModern},
|
{"omemo1", Omemo1},
|
||||||
|
{"omemo2", Omemo2},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
payload, err := EncryptOuter(tc.version, []byte("secret"), "gateway@example.com")
|
payload, err := EncryptOuter(tc.version, []byte("secret"), "gateway@example.com")
|
||||||
|
|
@ -71,22 +71,39 @@ func TestEncryptOuterTamperDetection(t *testing.T) {
|
||||||
|
|
||||||
func TestSCERoundTrip(t *testing.T) {
|
func TestSCERoundTrip(t *testing.T) {
|
||||||
plaintext := []byte("hello sce")
|
plaintext := []byte("hello sce")
|
||||||
xmlBytes, err := sceEncode(plaintext, "alice@example.com")
|
|
||||||
|
t.Run("v0", func(t *testing.T) {
|
||||||
|
xmlBytes, err := sceEncodeV0(plaintext, "alice@example.com")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("sceEncode: %v", err)
|
t.Fatalf("sceEncodeV0: %v", err)
|
||||||
}
|
}
|
||||||
got, err := sceDecode(xmlBytes)
|
got, err := sceDecodeV0(xmlBytes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("sceDecode: %v", err)
|
t.Fatalf("sceDecodeV0: %v", err)
|
||||||
}
|
}
|
||||||
if !bytes.Equal(got, plaintext) {
|
if !bytes.Equal(got, plaintext) {
|
||||||
t.Fatalf("got %q, want %q", got, plaintext)
|
t.Fatalf("got %q, want %q", got, plaintext)
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("v1", func(t *testing.T) {
|
||||||
|
xmlBytes, err := sceEncodeV1(plaintext, "alice@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("sceEncodeV1: %v", err)
|
||||||
|
}
|
||||||
|
got, err := sceDecodeV1(xmlBytes)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("sceDecodeV1: %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(got, plaintext) {
|
||||||
|
t.Fatalf("got %q, want %q", got, plaintext)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWireEnvelopeEncodeDecode(t *testing.T) {
|
func TestWireEnvelopeEncodeDecode(t *testing.T) {
|
||||||
env := &WireEnvelope{
|
env := &WireEnvelope{
|
||||||
Version: libsignal.ProtocolVersionModern,
|
Version: Omemo2,
|
||||||
SenderSID: 12345,
|
SenderSID: 12345,
|
||||||
Payload: []byte("ciphertext-bytes"),
|
Payload: []byte("ciphertext-bytes"),
|
||||||
Keys: []WireKey{
|
Keys: []WireKey{
|
||||||
|
|
|
||||||
350
e2ee/omemo/identity.go
Normal file
350
e2ee/omemo/identity.go
Normal file
|
|
@ -0,0 +1,350 @@
|
||||||
|
package omemo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"dev.narayana.im/narayana/telegabber/e2ee"
|
||||||
|
"dev.narayana.im/narayana/telegabber/e2ee/omemo/libsignal"
|
||||||
|
"dev.narayana.im/narayana/telegabber/e2ee/store/badgerstore"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Name identifies this backend in the e2ee registry and config.
|
||||||
|
const Name = "omemo"
|
||||||
|
|
||||||
|
// initialPreKeyCount is how many one-time prekeys EnsureIdentity generates
|
||||||
|
// up front - a conventional pool size (matches common OMEMO implementation
|
||||||
|
// practice) balancing bundle-refill frequency against bundle size.
|
||||||
|
// Replenishing a depleted pool (prekeys are removed automatically as
|
||||||
|
// they're consumed - see ListPreKeyIDs's doc comment) is a future
|
||||||
|
// enhancement, not handled yet.
|
||||||
|
const initialPreKeyCount = 100
|
||||||
|
|
||||||
|
// ownDeviceIDNum is the sole device id this gateway ever publishes for an
|
||||||
|
// owned identity - there is exactly one telegabber process encrypting on
|
||||||
|
// behalf of any given bridged chat pseudo-JID, so unlike a real multi-device
|
||||||
|
// XMPP account, no more than one device id is ever needed.
|
||||||
|
const ownDeviceIDNum uint32 = 1
|
||||||
|
|
||||||
|
// OwnDeviceID is ownDeviceIDNum in e2ee.DeviceID form, for callers that
|
||||||
|
// need to reference "the" device of an owned identity (e.g. PublishedBundle).
|
||||||
|
var OwnDeviceID = e2ee.DeviceID(strconv.FormatUint(uint64(ownDeviceIDNum), 10))
|
||||||
|
|
||||||
|
// Backend is the OMEMO (XEP-0384) implementation of e2ee.Backend.
|
||||||
|
type Backend struct {
|
||||||
|
libctx *libsignal.Context
|
||||||
|
db *badgerstore.DB
|
||||||
|
version Version // which version this backend produces for new outgoing content; incoming messages of any of the three versions are always accepted, decoded per-message
|
||||||
|
|
||||||
|
// libsignal is not internally thread-safe (see its package doc) - one
|
||||||
|
// mutex serializes every call into it, across every identity this
|
||||||
|
// backend manages. Simple and correct; per-identity striping would
|
||||||
|
// allow more concurrency but isn't needed at telegabber's message
|
||||||
|
// volume.
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates an OMEMO backend backed by db, producing new outgoing
|
||||||
|
// content in the given version (Omemo0, Omemo1, or Omemo2 - see version.go).
|
||||||
|
func New(libctx *libsignal.Context, db *badgerstore.DB, version Version) *Backend {
|
||||||
|
return &Backend{libctx: libctx, db: db, version: version}
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ e2ee.Backend = (*Backend)(nil)
|
||||||
|
|
||||||
|
func (b *Backend) Name() string { return Name }
|
||||||
|
|
||||||
|
func (b *Backend) Namespaces() e2ee.Namespaces {
|
||||||
|
switch b.version {
|
||||||
|
case Omemo0:
|
||||||
|
return e2ee.Namespaces{
|
||||||
|
Disco: []string{omemo0NS + ".devicelist+notify"},
|
||||||
|
EME: omemo0NS,
|
||||||
|
}
|
||||||
|
case Omemo1:
|
||||||
|
return e2ee.Namespaces{
|
||||||
|
Disco: []string{omemo1NS + ":devices+notify"},
|
||||||
|
EME: omemo1NS,
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return e2ee.Namespaces{
|
||||||
|
Disco: []string{omemo2NS + ":devices+notify"},
|
||||||
|
EME: omemo2NS,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) EnsureIdentity(peer e2ee.PeerID) error {
|
||||||
|
login, owner, ok := e2ee.SplitOwnedPeer(peer)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("omemo: EnsureIdentity: %q is not an OwnedPeer", peer)
|
||||||
|
}
|
||||||
|
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
|
||||||
|
store := b.db.Store(b.libctx, login, owner)
|
||||||
|
|
||||||
|
has, err := store.HasIdentityKeyPair()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("omemo: HasIdentityKeyPair: %w", err)
|
||||||
|
}
|
||||||
|
if has {
|
||||||
|
return nil // idempotent - already set up
|
||||||
|
}
|
||||||
|
|
||||||
|
idKeyPair, err := libsignal.GenerateIdentityKeyPair(b.libctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("omemo: GenerateIdentityKeyPair: %w", err)
|
||||||
|
}
|
||||||
|
if err := store.SaveIdentityKeyPair(idKeyPair.Record); err != nil {
|
||||||
|
return fmt.Errorf("omemo: SaveIdentityKeyPair: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
regID, err := libsignal.GenerateRegistrationID(b.libctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("omemo: GenerateRegistrationID: %w", err)
|
||||||
|
}
|
||||||
|
if err := store.SaveRegistrationID(regID); err != nil {
|
||||||
|
return fmt.Errorf("omemo: SaveRegistrationID: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
signedPreKey, err := libsignal.GenerateSignedPreKey(b.libctx, idKeyPair, 1)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("omemo: GenerateSignedPreKey: %w", err)
|
||||||
|
}
|
||||||
|
if err := store.StoreSignedPreKey(signedPreKey.ID, signedPreKey.Record); err != nil {
|
||||||
|
return fmt.Errorf("omemo: StoreSignedPreKey: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
start, err := store.NextPreKeyIDs(initialPreKeyCount)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("omemo: NextPreKeyIDs: %w", err)
|
||||||
|
}
|
||||||
|
preKeys, err := libsignal.GeneratePreKeys(b.libctx, start, initialPreKeyCount)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("omemo: GeneratePreKeys: %w", err)
|
||||||
|
}
|
||||||
|
for _, pk := range preKeys {
|
||||||
|
if err := store.StorePreKey(pk.ID, pk.Record); err != nil {
|
||||||
|
return fmt.Errorf("omemo: StorePreKey %d: %w", pk.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) PublishedIdentity(peer e2ee.PeerID) (e2ee.DeviceListDoc, error) {
|
||||||
|
login, owner, ok := e2ee.SplitOwnedPeer(peer)
|
||||||
|
if !ok {
|
||||||
|
return e2ee.DeviceListDoc{}, fmt.Errorf("omemo: PublishedIdentity: %q is not an OwnedPeer", peer)
|
||||||
|
}
|
||||||
|
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
|
||||||
|
store := b.db.Store(b.libctx, login, owner)
|
||||||
|
if has, err := store.HasIdentityKeyPair(); err != nil {
|
||||||
|
return e2ee.DeviceListDoc{}, err
|
||||||
|
} else if !has {
|
||||||
|
return e2ee.DeviceListDoc{}, errors.New("omemo: PublishedIdentity: EnsureIdentity was not called")
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := encodeDeviceList(b.version, ownDeviceIDNum)
|
||||||
|
if err != nil {
|
||||||
|
return e2ee.DeviceListDoc{}, err
|
||||||
|
}
|
||||||
|
return e2ee.DeviceListDoc{Raw: raw}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) PublishedBundle(peer e2ee.PeerID, device e2ee.DeviceID) (e2ee.BundleDoc, error) {
|
||||||
|
login, owner, ok := e2ee.SplitOwnedPeer(peer)
|
||||||
|
if !ok {
|
||||||
|
return e2ee.BundleDoc{}, fmt.Errorf("omemo: PublishedBundle: %q is not an OwnedPeer", peer)
|
||||||
|
}
|
||||||
|
if device != OwnDeviceID {
|
||||||
|
return e2ee.BundleDoc{}, fmt.Errorf("omemo: PublishedBundle: unknown device %q", device)
|
||||||
|
}
|
||||||
|
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
|
||||||
|
store := b.db.Store(b.libctx, login, owner)
|
||||||
|
|
||||||
|
identityPublic, _, err := store.GetIdentityKeyPair()
|
||||||
|
if err != nil {
|
||||||
|
return e2ee.BundleDoc{}, fmt.Errorf("omemo: GetIdentityKeyPair: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureIdentity always creates signed prekey id 1 and never rotates it
|
||||||
|
// yet (a future enhancement), so id 1 is the only one that will exist.
|
||||||
|
spkRecord, found, err := store.LoadSignedPreKey(1)
|
||||||
|
if err != nil {
|
||||||
|
return e2ee.BundleDoc{}, fmt.Errorf("omemo: LoadSignedPreKey: %w", err)
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return e2ee.BundleDoc{}, errors.New("omemo: PublishedBundle: EnsureIdentity was not called")
|
||||||
|
}
|
||||||
|
spkInfo, err := libsignal.DecodeSignedPreKey(b.libctx, spkRecord)
|
||||||
|
if err != nil {
|
||||||
|
return e2ee.BundleDoc{}, fmt.Errorf("omemo: DecodeSignedPreKey: %w", err)
|
||||||
|
}
|
||||||
|
// See the rule documented on libsignal.SignedPreKeyInfo: the signature
|
||||||
|
// must match the serialization form the verifier will re-derive, which
|
||||||
|
// depends on the recipient's protocol version (Omemo0 -> legacy form,
|
||||||
|
// Omemo1/Omemo2 -> OMEMO form, since both share libsignal.ProtocolVersionV4)
|
||||||
|
// - but since we only publish one bundle version at a time (this
|
||||||
|
// backend's own configured version), that's the one to use here too.
|
||||||
|
signature := spkInfo.Signature
|
||||||
|
if b.version != Omemo0 {
|
||||||
|
signature = spkInfo.SignatureOMEMO
|
||||||
|
}
|
||||||
|
|
||||||
|
preKeyIDs, err := store.ListPreKeyIDs()
|
||||||
|
if err != nil {
|
||||||
|
return e2ee.BundleDoc{}, fmt.Errorf("omemo: ListPreKeyIDs: %w", err)
|
||||||
|
}
|
||||||
|
preKeys := make([]libsignal.PreKeyInfo, 0, len(preKeyIDs))
|
||||||
|
for _, id := range preKeyIDs {
|
||||||
|
record, found, err := store.LoadPreKey(id)
|
||||||
|
if err != nil {
|
||||||
|
return e2ee.BundleDoc{}, fmt.Errorf("omemo: LoadPreKey %d: %w", id, err)
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
continue // consumed between ListPreKeyIDs and here - fine, just skip it
|
||||||
|
}
|
||||||
|
info, err := libsignal.DecodePreKey(b.libctx, record)
|
||||||
|
if err != nil {
|
||||||
|
return e2ee.BundleDoc{}, fmt.Errorf("omemo: DecodePreKey %d: %w", id, err)
|
||||||
|
}
|
||||||
|
preKeys = append(preKeys, *info)
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := encodeBundle(b.version, identityPublic, spkInfo.ID, spkInfo.PublicKey, signature, preKeys)
|
||||||
|
if err != nil {
|
||||||
|
return e2ee.BundleDoc{}, err
|
||||||
|
}
|
||||||
|
return e2ee.BundleDoc{Raw: raw}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) IngestRemoteDeviceList(owner, peer e2ee.PeerID, doc e2ee.DeviceListDoc) error {
|
||||||
|
login, ownerJID, ok := e2ee.SplitOwnedPeer(owner)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("omemo: IngestRemoteDeviceList: %q is not an OwnedPeer", owner)
|
||||||
|
}
|
||||||
|
if _, err := parseDeviceList(doc.Raw); err != nil {
|
||||||
|
return fmt.Errorf("omemo: parseDeviceList: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
|
||||||
|
store := b.db.Store(b.libctx, login, ownerJID)
|
||||||
|
return store.SaveRemoteDeviceListCache(string(peer), doc.Raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) IngestRemoteBundle(owner, peer e2ee.PeerID, device e2ee.DeviceID, doc e2ee.BundleDoc) error {
|
||||||
|
login, ownerJID, ok := e2ee.SplitOwnedPeer(owner)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("omemo: IngestRemoteBundle: %q is not an OwnedPeer", owner)
|
||||||
|
}
|
||||||
|
deviceID, err := parseDeviceID(device)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
parsed, err := parseBundle(doc.Raw)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("omemo: parseBundle: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var preKeyID uint32
|
||||||
|
var preKeyPublic []byte
|
||||||
|
if len(parsed.PreKeys) > 0 {
|
||||||
|
idx, err := cryptoRandIndex(len(parsed.PreKeys))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
preKeyID = parsed.PreKeys[idx].ID
|
||||||
|
preKeyPublic = parsed.PreKeys[idx].Public
|
||||||
|
}
|
||||||
|
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
|
||||||
|
store := b.db.Store(b.libctx, login, ownerJID)
|
||||||
|
storeCtx, err := libsignal.NewStoreContext(b.libctx, store)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("omemo: NewStoreContext: %w", err)
|
||||||
|
}
|
||||||
|
defer storeCtx.Close()
|
||||||
|
|
||||||
|
builder, err := libsignal.NewSessionBuilder(b.libctx, storeCtx, libsignal.Address{Name: string(peer), DeviceID: deviceID}, b.version.libsignalVersion())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("omemo: NewSessionBuilder: %w", err)
|
||||||
|
}
|
||||||
|
defer builder.Close()
|
||||||
|
|
||||||
|
err = builder.ProcessPreKeyBundle(b.libctx, libsignal.RemoteBundle{
|
||||||
|
// OMEMO's wire format (neither legacy nor OMEMOKeyExchange) ever
|
||||||
|
// transmits a registration id - device id already does what it
|
||||||
|
// would otherwise disambiguate. 0 is standard, harmless practice.
|
||||||
|
RegistrationID: 0,
|
||||||
|
DeviceID: deviceID,
|
||||||
|
PreKeyID: preKeyID,
|
||||||
|
PreKeyPublic: preKeyPublic,
|
||||||
|
SignedPreKeyID: parsed.SignedPreKeyID,
|
||||||
|
SignedPreKeyPublic: parsed.SignedPreKeyPublic,
|
||||||
|
SignedPreKeySignature: parsed.SignedPreKeySignature,
|
||||||
|
IdentityKeyPublic: parsed.IdentityKeyPublic,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("omemo: ProcessPreKeyBundle: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) Devices(owner, peer e2ee.PeerID) ([]e2ee.DeviceInfo, error) {
|
||||||
|
login, ownerJID, ok := e2ee.SplitOwnedPeer(owner)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("omemo: Devices: %q is not an OwnedPeer", owner)
|
||||||
|
}
|
||||||
|
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
|
||||||
|
store := b.db.Store(b.libctx, login, ownerJID)
|
||||||
|
deviceIDs, err := store.GetSubDeviceSessions(string(peer))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
infos := make([]e2ee.DeviceInfo, 0, len(deviceIDs))
|
||||||
|
for _, id := range deviceIDs {
|
||||||
|
// A stored session implies IsTrustedIdentity already accepted this
|
||||||
|
// device's key (TOFU) - finer-grained trust states (manually
|
||||||
|
// verified/revoked) are a documented future enhancement, not
|
||||||
|
// needed for this phase.
|
||||||
|
infos = append(infos, e2ee.DeviceInfo{ID: e2ee.DeviceID(strconv.FormatUint(uint64(id), 10)), Trusted: true})
|
||||||
|
}
|
||||||
|
return infos, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseDeviceID(device e2ee.DeviceID) (uint32, error) {
|
||||||
|
id, err := strconv.ParseUint(string(device), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("omemo: invalid device id %q: %w", device, err)
|
||||||
|
}
|
||||||
|
return uint32(id), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cryptoRandIndex(n int) (int, error) {
|
||||||
|
i, err := rand.Int(rand.Reader, big.NewInt(int64(n)))
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return int(i.Int64()), nil
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
package libsignal
|
package libsignal
|
||||||
|
|
||||||
// ModernOMEMOSupported is false under the "signal_legacy" build tag: this
|
// ProtocolV4Supported is false under the "signal_legacy" build tag: this
|
||||||
// build links vanilla libsignal-protocol-c, which has no modern OMEMO
|
// build links vanilla libsignal-protocol-c, which has no modern OMEMO
|
||||||
// (protocol v4) support at all - only legacy/siacs OMEMO (protocol v3).
|
// (protocol v4) support at all - only legacy/siacs OMEMO (protocol v3).
|
||||||
const ModernOMEMOSupported = false
|
const ProtocolV4Supported = false
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,9 @@
|
||||||
|
|
||||||
package libsignal
|
package libsignal
|
||||||
|
|
||||||
// ModernOMEMOSupported reports whether this build links a library capable
|
// ProtocolV4Supported reports whether this build links a library capable
|
||||||
// of modern OMEMO (protocol v4, shared by OMEMO 1/urn:xmpp:omemo:1 and
|
// of modern OMEMO (protocol v4, shared by OMEMO 1/urn:xmpp:omemo:1 and
|
||||||
// OMEMO 2/urn:xmpp:omemo:2), as opposed to only legacy/siacs OMEMO
|
// OMEMO 2/urn:xmpp:omemo:2), as opposed to only legacy/siacs OMEMO
|
||||||
// (protocol v3). Callers should check this before advertising or
|
// (protocol v3). Callers should check this before advertising or
|
||||||
// negotiating anything beyond the legacy namespace.
|
// negotiating anything beyond the legacy namespace.
|
||||||
const ModernOMEMOSupported = true
|
const ProtocolV4Supported = true
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ type SessionCipher struct {
|
||||||
|
|
||||||
// NewSessionCipher creates a session cipher for storeCtx's local identity
|
// NewSessionCipher creates a session cipher for storeCtx's local identity
|
||||||
// talking to remote. version selects the wire format for messages this
|
// talking to remote. version selects the wire format for messages this
|
||||||
// cipher *produces* (ProtocolVersionLegacy or ProtocolVersionModern);
|
// cipher *produces* (ProtocolVersionV3 or ProtocolVersionV4);
|
||||||
// decrypting adapts to whatever version the incoming message declares.
|
// decrypting adapts to whatever version the incoming message declares.
|
||||||
func NewSessionCipher(ctx *Context, storeCtx *StoreContext, remote Address, version int) (*SessionCipher, error) {
|
func NewSessionCipher(ctx *Context, storeCtx *StoreContext, remote Address, version int) (*SessionCipher, error) {
|
||||||
addr := newCAddress(remote)
|
addr := newCAddress(remote)
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import "C"
|
||||||
// Vanilla libsignal-protocol-c has no OMEMO (protocol v4) wire format
|
// Vanilla libsignal-protocol-c has no OMEMO (protocol v4) wire format
|
||||||
// support at all, so these always fail with ErrInvalidVersion - callers
|
// support at all, so these always fail with ErrInvalidVersion - callers
|
||||||
// (SessionCipher.Decrypt) should never reach here with useOmemoFraming set
|
// (SessionCipher.Decrypt) should never reach here with useOmemoFraming set
|
||||||
// under this build, since ModernOMEMOSupported is false and the caller is
|
// under this build, since ProtocolV4Supported is false and the caller is
|
||||||
// expected to check it first.
|
// expected to check it first.
|
||||||
|
|
||||||
func deserializeSignalMessageOmemo(ctx *Context, data *C.uint8_t, length C.size_t) (*C.signal_message, C.int) {
|
func deserializeSignalMessageOmemo(ctx *Context, data *C.uint8_t, length C.size_t) (*C.signal_message, C.int) {
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
// useful on distros that only package that one (e.g. Debian bullseye ships
|
// useful on distros that only package that one (e.g. Debian bullseye ships
|
||||||
// libsignal-protocol-c-dev but not libomemo-c-dev, which arrived in
|
// libsignal-protocol-c-dev but not libomemo-c-dev, which arrived in
|
||||||
// bookworm). That build only supports legacy/siacs OMEMO; check
|
// bookworm). That build only supports legacy/siacs OMEMO; check
|
||||||
// ModernOMEMOSupported before relying on anything else. See cgo_modern.go/
|
// ProtocolV4Supported before relying on anything else. See cgo_modern.go/
|
||||||
// cgo_legacy.go and the version_*/deserialize_*/signedprekey_* files for
|
// cgo_legacy.go and the version_*/deserialize_*/signedprekey_* files for
|
||||||
// the resulting API-surface differences between the two libraries.
|
// the resulting API-surface differences between the two libraries.
|
||||||
//
|
//
|
||||||
|
|
|
||||||
|
|
@ -245,7 +245,7 @@ func deserializeSignedPreKey(ctx *Context, record []byte) (*C.session_signed_pre
|
||||||
// 33-byte DJB_TYPE-prefixed legacy form) or ec_public_key_serialize_omemo
|
// 33-byte DJB_TYPE-prefixed legacy form) or ec_public_key_serialize_omemo
|
||||||
// (version >= 4, the raw 32-byte Montgomery form) and checks it against
|
// (version >= 4, the raw 32-byte Montgomery form) and checks it against
|
||||||
// whichever signature is supplied - so use Signature for
|
// whichever signature is supplied - so use Signature for
|
||||||
// ProtocolVersionLegacy and SignatureOMEMO for ProtocolVersionModern.
|
// ProtocolVersionV3 and SignatureOMEMO for ProtocolVersionV4.
|
||||||
type SignedPreKeyInfo struct {
|
type SignedPreKeyInfo struct {
|
||||||
ID uint32
|
ID uint32
|
||||||
PublicKey []byte
|
PublicKey []byte
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,8 @@ import (
|
||||||
// (urn:xmpp:omemo:2) - those two differ only in outer SCE stanza framing,
|
// (urn:xmpp:omemo:2) - those two differ only in outer SCE stanza framing,
|
||||||
// not in this underlying Double Ratchet/X3DH wire format.
|
// not in this underlying Double Ratchet/X3DH wire format.
|
||||||
const (
|
const (
|
||||||
ProtocolVersionLegacy = 3
|
ProtocolVersionV3 = 3
|
||||||
ProtocolVersionModern = 4
|
ProtocolVersionV4 = 4
|
||||||
)
|
)
|
||||||
|
|
||||||
// RemoteBundle is the decoded material from a peer's XEP-0384 device
|
// RemoteBundle is the decoded material from a peer's XEP-0384 device
|
||||||
|
|
@ -47,7 +47,7 @@ type SessionBuilder struct {
|
||||||
// NewSessionBuilder creates a session builder for storeCtx's local identity
|
// NewSessionBuilder creates a session builder for storeCtx's local identity
|
||||||
// talking to remote. version controls the protocol version new sessions
|
// talking to remote. version controls the protocol version new sessions
|
||||||
// built by ProcessPreKeyBundle will use for encryption going forward
|
// built by ProcessPreKeyBundle will use for encryption going forward
|
||||||
// (ProtocolVersionLegacy or ProtocolVersionModern).
|
// (ProtocolVersionV3 or ProtocolVersionV4).
|
||||||
func NewSessionBuilder(ctx *Context, storeCtx *StoreContext, remote Address, version int) (*SessionBuilder, error) {
|
func NewSessionBuilder(ctx *Context, storeCtx *StoreContext, remote Address, version int) (*SessionBuilder, error) {
|
||||||
addr := newCAddress(remote)
|
addr := newCAddress(remote)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -118,7 +118,7 @@ func (id *identity) bundle(t *testing.T, deviceID uint32, version int) libsignal
|
||||||
}
|
}
|
||||||
|
|
||||||
signature := spkInfo.Signature
|
signature := spkInfo.Signature
|
||||||
if version >= libsignal.ProtocolVersionModern {
|
if version >= libsignal.ProtocolVersionV4 {
|
||||||
signature = spkInfo.SignatureOMEMO
|
signature = spkInfo.SignatureOMEMO
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -140,12 +140,12 @@ func TestSessionRoundTrip(t *testing.T) {
|
||||||
version int
|
version int
|
||||||
omemo bool
|
omemo bool
|
||||||
}{
|
}{
|
||||||
{"legacy", libsignal.ProtocolVersionLegacy, false},
|
{"legacy", libsignal.ProtocolVersionV3, false},
|
||||||
{"modern", libsignal.ProtocolVersionModern, true},
|
{"modern", libsignal.ProtocolVersionV4, true},
|
||||||
} {
|
} {
|
||||||
tc := tc
|
tc := tc
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
if tc.version == libsignal.ProtocolVersionModern && !libsignal.ModernOMEMOSupported {
|
if tc.version == libsignal.ProtocolVersionV4 && !libsignal.ProtocolV4Supported {
|
||||||
t.Skip("modern OMEMO not supported by this build (signal_legacy tag)")
|
t.Skip("modern OMEMO not supported by this build (signal_legacy tag)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,19 +18,19 @@ var errModernOMEMOUnavailable = errors.New("libsignal: modern OMEMO (protocol v4
|
||||||
// Vanilla libsignal-protocol-c has no session_builder_set_version /
|
// Vanilla libsignal-protocol-c has no session_builder_set_version /
|
||||||
// session_cipher_set_version - it only ever produces/consumes
|
// session_cipher_set_version - it only ever produces/consumes
|
||||||
// CIPHERTEXT_CURRENT_VERSION (protocol v3, legacy/siacs OMEMO) messages.
|
// CIPHERTEXT_CURRENT_VERSION (protocol v3, legacy/siacs OMEMO) messages.
|
||||||
// Requesting ProtocolVersionLegacy is a no-op (that's the library's only
|
// Requesting ProtocolVersionV3 is a no-op (that's the library's only
|
||||||
// behavior); requesting anything else is rejected rather than silently
|
// behavior); requesting anything else is rejected rather than silently
|
||||||
// ignored.
|
// ignored.
|
||||||
|
|
||||||
func setBuilderVersion(b *C.session_builder, version int) error {
|
func setBuilderVersion(b *C.session_builder, version int) error {
|
||||||
if version != ProtocolVersionLegacy {
|
if version != ProtocolVersionV3 {
|
||||||
return errModernOMEMOUnavailable
|
return errModernOMEMOUnavailable
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func setCipherVersion(c *C.session_cipher, version int) error {
|
func setCipherVersion(c *C.session_cipher, version int) error {
|
||||||
if version != ProtocolVersionLegacy {
|
if version != ProtocolVersionV3 {
|
||||||
return errModernOMEMOUnavailable
|
return errModernOMEMOUnavailable
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ package libsignal
|
||||||
import "C"
|
import "C"
|
||||||
|
|
||||||
// setBuilderVersion and setCipherVersion select the wire protocol version
|
// setBuilderVersion and setCipherVersion select the wire protocol version
|
||||||
// (ProtocolVersionLegacy or ProtocolVersionModern) libomemo-c's
|
// (ProtocolVersionV3 or ProtocolVersionV4) libomemo-c's
|
||||||
// session_builder_set_version/session_cipher_set_version support. See
|
// session_builder_set_version/session_cipher_set_version support. See
|
||||||
// version_legacy.go for the vanilla-libsignal-protocol-c build, which has
|
// version_legacy.go for the vanilla-libsignal-protocol-c build, which has
|
||||||
// no such functions and only ever produces protocol v3.
|
// no such functions and only ever produces protocol v3.
|
||||||
|
|
|
||||||
|
|
@ -7,22 +7,34 @@ import (
|
||||||
"math/big"
|
"math/big"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Minimal XEP-0420 Stanza Content Encryption envelope, used only as the
|
// Two dialects of XEP-0420 Stanza Content Encryption, matching what each
|
||||||
// plaintext OMEMO 2 (modern) content encryption operates on - just enough
|
// OMEMO version actually wraps its plaintext in - just enough of each to
|
||||||
// to carry a plain message body, per the spec's requirement that the
|
// carry a plain message body, per each dialect's requirement that the
|
||||||
// envelope MUST contain <rpad/> and SHOULD contain <from/> (MUST contain
|
// envelope MUST contain <rpad/> and SHOULD contain <from/> (MUST contain
|
||||||
// <to/> for MUC, which this gateway doesn't do OMEMO for - see the plan's
|
// <to/> for MUC, which this gateway doesn't do OMEMO for - see the plan's
|
||||||
// scope note on MUC).
|
// scope note on MUC).
|
||||||
type sceEnvelope struct {
|
//
|
||||||
XMLName xml.Name `xml:"urn:xmpp:sce:1 envelope"`
|
// V0 (Omemo1, XEP-0384 0.4.0-0.7.x) is the OLD XEP-0420 shape, confirmed
|
||||||
Content sceContent `xml:"content"`
|
// by reading the archived 0.3.2 spec directly:
|
||||||
RPad string `xml:"rpad"`
|
//
|
||||||
From *sceJIDAttr `xml:"from"`
|
// <content xmlns='urn:xmpp:sce:0'>
|
||||||
}
|
// <payload>
|
||||||
|
// <body xmlns='jabber:client'>...</body>
|
||||||
type sceContent struct {
|
// </payload>
|
||||||
Body sceBody `xml:"body"`
|
// <from jid='...'/>
|
||||||
}
|
// <rpad>...</rpad>
|
||||||
|
// </content>
|
||||||
|
//
|
||||||
|
// V1 (Omemo2, current) renamed content->envelope and payload->content
|
||||||
|
// (XEP-0420 0.4.0+, urn:xmpp:sce:1):
|
||||||
|
//
|
||||||
|
// <envelope xmlns='urn:xmpp:sce:1'>
|
||||||
|
// <content>
|
||||||
|
// <body xmlns='jabber:client'>...</body>
|
||||||
|
// </content>
|
||||||
|
// <from jid='...'/>
|
||||||
|
// <rpad>...</rpad>
|
||||||
|
// </envelope>
|
||||||
|
|
||||||
type sceBody struct {
|
type sceBody struct {
|
||||||
XMLName xml.Name `xml:"jabber:client body"`
|
XMLName xml.Name `xml:"jabber:client body"`
|
||||||
|
|
@ -33,16 +45,26 @@ type sceJIDAttr struct {
|
||||||
JID string `xml:"jid,attr"`
|
JID string `xml:"jid,attr"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// sceEncode wraps plaintext in an SCE envelope and serializes it - the
|
// --- V0 (Omemo1) ---
|
||||||
// "plaintext" that modern OMEMO's outer content encryption actually
|
|
||||||
// operates on. fromJID becomes the <from/> binding (empty to omit it).
|
type sceContentV0 struct {
|
||||||
func sceEncode(plaintext []byte, fromJID string) ([]byte, error) {
|
XMLName xml.Name `xml:"urn:xmpp:sce:0 content"`
|
||||||
|
Payload scePayloadV0 `xml:"payload"`
|
||||||
|
From *sceJIDAttr `xml:"from"`
|
||||||
|
RPad string `xml:"rpad"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type scePayloadV0 struct {
|
||||||
|
Body sceBody `xml:"body"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func sceEncodeV0(plaintext []byte, fromJID string) ([]byte, error) {
|
||||||
rpad, err := randomPadding()
|
rpad, err := randomPadding()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
env := sceEnvelope{
|
env := sceContentV0{
|
||||||
Content: sceContent{Body: sceBody{Text: string(plaintext)}},
|
Payload: scePayloadV0{Body: sceBody{Text: string(plaintext)}},
|
||||||
RPad: rpad,
|
RPad: rpad,
|
||||||
}
|
}
|
||||||
if fromJID != "" {
|
if fromJID != "" {
|
||||||
|
|
@ -51,9 +73,44 @@ func sceEncode(plaintext []byte, fromJID string) ([]byte, error) {
|
||||||
return xml.Marshal(env)
|
return xml.Marshal(env)
|
||||||
}
|
}
|
||||||
|
|
||||||
// sceDecode reverses sceEncode, extracting the plain body text.
|
func sceDecodeV0(data []byte) ([]byte, error) {
|
||||||
func sceDecode(data []byte) ([]byte, error) {
|
var env sceContentV0
|
||||||
var env sceEnvelope
|
if err := xml.Unmarshal(data, &env); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return []byte(env.Payload.Body.Text), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- V1 (Omemo2) ---
|
||||||
|
|
||||||
|
type sceEnvelopeV1 struct {
|
||||||
|
XMLName xml.Name `xml:"urn:xmpp:sce:1 envelope"`
|
||||||
|
Content sceContentV1 `xml:"content"`
|
||||||
|
From *sceJIDAttr `xml:"from"`
|
||||||
|
RPad string `xml:"rpad"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type sceContentV1 struct {
|
||||||
|
Body sceBody `xml:"body"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func sceEncodeV1(plaintext []byte, fromJID string) ([]byte, error) {
|
||||||
|
rpad, err := randomPadding()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
env := sceEnvelopeV1{
|
||||||
|
Content: sceContentV1{Body: sceBody{Text: string(plaintext)}},
|
||||||
|
RPad: rpad,
|
||||||
|
}
|
||||||
|
if fromJID != "" {
|
||||||
|
env.From = &sceJIDAttr{JID: fromJID}
|
||||||
|
}
|
||||||
|
return xml.Marshal(env)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sceDecodeV1(data []byte) ([]byte, error) {
|
||||||
|
var env sceEnvelopeV1
|
||||||
if err := xml.Unmarshal(data, &env); err != nil {
|
if err := xml.Unmarshal(data, &env); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -62,7 +119,7 @@ func sceDecode(data []byte) ([]byte, error) {
|
||||||
|
|
||||||
// randomPadding returns base64-encoded random padding of a random length
|
// randomPadding returns base64-encoded random padding of a random length
|
||||||
// (1-63 bytes before encoding) to mask plaintext length, per XEP-0420's
|
// (1-63 bytes before encoding) to mask plaintext length, per XEP-0420's
|
||||||
// MUST-contain-<rpad/> requirement.
|
// MUST-contain-<rpad/> requirement (both dialects).
|
||||||
func randomPadding() (string, error) {
|
func randomPadding() (string, error) {
|
||||||
n, err := rand.Int(rand.Reader, big.NewInt(63))
|
n, err := rand.Int(rand.Reader, big.NewInt(63))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
59
e2ee/omemo/version.go
Normal file
59
e2ee/omemo/version.go
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
package omemo
|
||||||
|
|
||||||
|
import "dev.narayana.im/narayana/telegabber/e2ee/omemo/libsignal"
|
||||||
|
|
||||||
|
// Version identifies which OMEMO wire-format generation a message, device
|
||||||
|
// list, or bundle uses - the full namespace+XML dialect, not just the
|
||||||
|
// underlying crypto-engine version (see libsignal.ProtocolVersionV3/V4,
|
||||||
|
// which is a strictly coarser, two-way distinction shared by two of the
|
||||||
|
// three versions below).
|
||||||
|
type Version int
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Omemo0 is the original "siacs" OMEMO (eu.siacs.conversations.axolotl,
|
||||||
|
// XEP-0384 <= 0.3.0): libsignal.ProtocolVersionV3, AES-128-GCM directly
|
||||||
|
// on the plaintext body, key||tag distributed per-device, <iv> element
|
||||||
|
// present. Still the most widely deployed version as of this writing,
|
||||||
|
// and the current implementation priority.
|
||||||
|
Omemo0 Version = iota
|
||||||
|
|
||||||
|
// Omemo1 is urn:xmpp:omemo:1 (XEP-0384 0.4.0-0.7.x, introduced
|
||||||
|
// 2020-03-08): libsignal.ProtocolVersionV4, AES-256-CBC+HMAC-SHA256
|
||||||
|
// (via HKDF) over an SCE envelope using the OLD (pre-0.4.0) XEP-0420
|
||||||
|
// dialect: root element <content xmlns='urn:xmpp:sce:0'>, inner
|
||||||
|
// wrapper <payload/>. Confirmed by reading the archived XEP-0420 0.3.2
|
||||||
|
// spec directly (https://xmpp.org/extensions/attic/xep-0420-0.3.2.html),
|
||||||
|
// not assumed - see sceEncodeV0/sceDecodeV0.
|
||||||
|
Omemo1
|
||||||
|
|
||||||
|
// Omemo2 is urn:xmpp:omemo:2 (XEP-0384 0.8.0+, current spec):
|
||||||
|
// libsignal.ProtocolVersionV4, the same AES-256-CBC+HMAC-SHA256 scheme
|
||||||
|
// as Omemo1, but over the NEW (XEP-0420 0.4.0+) dialect: root element
|
||||||
|
// <envelope xmlns='urn:xmpp:sce:1'>, inner wrapper <content/>.
|
||||||
|
Omemo2
|
||||||
|
)
|
||||||
|
|
||||||
|
func (v Version) String() string {
|
||||||
|
switch v {
|
||||||
|
case Omemo0:
|
||||||
|
return "omemo0"
|
||||||
|
case Omemo1:
|
||||||
|
return "omemo1"
|
||||||
|
case Omemo2:
|
||||||
|
return "omemo2"
|
||||||
|
default:
|
||||||
|
return "omemo?"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// libsignalVersion returns the underlying Signal-protocol wire version
|
||||||
|
// this OMEMO version uses. Omemo1 and Omemo2 share the same crypto-engine
|
||||||
|
// version - libomemo-c itself only branches on "message_version >= 4", not
|
||||||
|
// on which OMEMO namespace is in play (confirmed by reading protocol.c) -
|
||||||
|
// they differ only in the outer XML/SCE dialect (see envelope.go).
|
||||||
|
func (v Version) libsignalVersion() int {
|
||||||
|
if v == Omemo0 {
|
||||||
|
return libsignal.ProtocolVersionV3
|
||||||
|
}
|
||||||
|
return libsignal.ProtocolVersionV4
|
||||||
|
}
|
||||||
25
e2ee/peer.go
Normal file
25
e2ee/peer.go
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
package e2ee
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// OwnedPeer constructs the PeerID for an identity this gateway owns and
|
||||||
|
// generates keys for - a bridged chat pseudo-JID.
|
||||||
|
//
|
||||||
|
// Telegram chat ids (and therefore their pseudo-JIDs) are only unique
|
||||||
|
// within one Telegram login, not globally - telegabber bridges multiple
|
||||||
|
// Telegram accounts under the same XMPP domain, so two different logins
|
||||||
|
// could each have a chat whose pseudo-JID is the same string. An owned
|
||||||
|
// PeerID must therefore be scoped by login too. A real remote peer's
|
||||||
|
// PeerID (the other end of a conversation) needs no such scoping - it's
|
||||||
|
// just their bare JID - since their sessions/trust are already nested
|
||||||
|
// under the owning identity's own scope (see badgerstore's key layout).
|
||||||
|
func OwnedPeer(login, bareJID string) PeerID {
|
||||||
|
return PeerID(login + "\x00" + bareJID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SplitOwnedPeer reverses OwnedPeer. ok is false if peer wasn't built by
|
||||||
|
// OwnedPeer (e.g. a plain remote-peer PeerID was passed by mistake).
|
||||||
|
func SplitOwnedPeer(peer PeerID) (login, bareJID string, ok bool) {
|
||||||
|
login, bareJID, ok = strings.Cut(string(peer), "\x00")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
@ -59,6 +59,10 @@ func (s *Store) preKeyKey(id uint32) []byte {
|
||||||
return []byte(fmt.Sprintf("%s/%s/identity/prekey/%d", s.login, s.owner, id))
|
return []byte(fmt.Sprintf("%s/%s/identity/prekey/%d", s.login, s.owner, id))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) preKeyPrefix() []byte {
|
||||||
|
return []byte(fmt.Sprintf("%s/%s/identity/prekey/", s.login, s.owner))
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) preKeyCounterKey() []byte {
|
func (s *Store) preKeyCounterKey() []byte {
|
||||||
return []byte(fmt.Sprintf("%s/%s/identity/prekey/counter", s.login, s.owner))
|
return []byte(fmt.Sprintf("%s/%s/identity/prekey/counter", s.login, s.owner))
|
||||||
}
|
}
|
||||||
|
|
@ -212,6 +216,35 @@ func (s *Store) ContainsPreKey(id uint32) bool {
|
||||||
return found
|
return found
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListPreKeyIDs returns the ids of every one-time prekey currently stored
|
||||||
|
// (i.e. not yet consumed - libomemo-c's session_cipher automatically
|
||||||
|
// removes a prekey via RemovePreKey the moment it's used to decrypt an
|
||||||
|
// inbound prekey message, so the surviving ids are neither contiguous nor
|
||||||
|
// derivable from the counter NextPreKeyIDs advances). Used to publish an
|
||||||
|
// accurate bundle - see e2ee/omemo's PublishedBundle.
|
||||||
|
func (s *Store) ListPreKeyIDs() ([]uint32, error) {
|
||||||
|
prefix := s.preKeyPrefix()
|
||||||
|
var ids []uint32
|
||||||
|
err := s.db.View(func(txn *badger.Txn) error {
|
||||||
|
opts := badger.DefaultIteratorOptions
|
||||||
|
opts.PrefetchValues = false
|
||||||
|
it := txn.NewIterator(opts)
|
||||||
|
defer it.Close()
|
||||||
|
for it.Seek(prefix); it.ValidForPrefix(prefix); it.Next() {
|
||||||
|
suffix := it.Item().Key()[len(prefix):]
|
||||||
|
// Skips the "counter" key, which shares this prefix - its
|
||||||
|
// suffix doesn't parse as a uint32, so it's silently excluded.
|
||||||
|
id, err := strconv.ParseUint(string(suffix), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ids = append(ids, uint32(id))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return ids, err
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) RemovePreKey(id uint32) error {
|
func (s *Store) RemovePreKey(id uint32) error {
|
||||||
return s.delete(s.preKeyKey(id))
|
return s.delete(s.preKeyKey(id))
|
||||||
}
|
}
|
||||||
|
|
@ -339,7 +372,10 @@ func (s *Store) SaveRegistrationID(id uint32) error {
|
||||||
// NextPreKeyIDs atomically reserves a contiguous block of count prekey ids
|
// NextPreKeyIDs atomically reserves a contiguous block of count prekey ids
|
||||||
// for libsignal.GeneratePreKeys, returning the first id in the block.
|
// for libsignal.GeneratePreKeys, returning the first id in the block.
|
||||||
func (s *Store) NextPreKeyIDs(count uint32) (uint32, error) {
|
func (s *Store) NextPreKeyIDs(count uint32) (uint32, error) {
|
||||||
var start uint32
|
// Starts at 1, not 0: XEP-0384 0.9.1 explicitly requires positive
|
||||||
|
// (non-zero) prekey ids ("Fix using id=0 in examples. Spec requires
|
||||||
|
// positive numbers.").
|
||||||
|
start := uint32(1)
|
||||||
err := s.db.Update(func(txn *badger.Txn) error {
|
err := s.db.Update(func(txn *badger.Txn) error {
|
||||||
item, err := txn.Get(s.preKeyCounterKey())
|
item, err := txn.Get(s.preKeyCounterKey())
|
||||||
if err != nil && err != badger.ErrKeyNotFound {
|
if err != nil && err != badger.ErrKeyNotFound {
|
||||||
|
|
|
||||||
|
|
@ -135,15 +135,15 @@ func TestNextPreKeyIDs(t *testing.T) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NextPreKeyIDs: %v", err)
|
t.Fatalf("NextPreKeyIDs: %v", err)
|
||||||
}
|
}
|
||||||
if start1 != 0 {
|
if start1 != 1 {
|
||||||
t.Fatalf("expected first block to start at 0, got %d", start1)
|
t.Fatalf("expected first block to start at 1 (XEP-0384 requires positive ids), got %d", start1)
|
||||||
}
|
}
|
||||||
start2, err := store.NextPreKeyIDs(5)
|
start2, err := store.NextPreKeyIDs(5)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NextPreKeyIDs: %v", err)
|
t.Fatalf("NextPreKeyIDs: %v", err)
|
||||||
}
|
}
|
||||||
if start2 != 10 {
|
if start2 != 11 {
|
||||||
t.Fatalf("expected second block to start at 10, got %d", start2)
|
t.Fatalf("expected second block to start at 11, got %d", start2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -56,35 +56,42 @@ type Backend interface {
|
||||||
Namespaces() Namespaces
|
Namespaces() Namespaces
|
||||||
|
|
||||||
// EnsureIdentity creates (idempotently) a local cryptographic identity
|
// EnsureIdentity creates (idempotently) a local cryptographic identity
|
||||||
// for a PeerID this gateway owns (a bridged chat pseudo-JID),
|
// for a PeerID this gateway owns (a bridged chat pseudo-JID - built via
|
||||||
// generating and persisting whatever key material the backend needs.
|
// OwnedPeer, since a chat pseudo-JID alone isn't globally unique across
|
||||||
|
// telegabber's multiple bridged Telegram logins), generating and
|
||||||
|
// persisting whatever key material the backend needs.
|
||||||
EnsureIdentity(peer PeerID) error
|
EnsureIdentity(peer PeerID) error
|
||||||
|
|
||||||
// PublishedIdentity returns the material this gateway must SERVE to
|
// PublishedIdentity returns the material this gateway must SERVE to
|
||||||
// pubsub GET requests for its own PeerID (device list), pre-marshaled
|
// pubsub GET requests for its own PeerID (an OwnedPeer - see
|
||||||
// to opaque bytes the xmpp layer wraps in <items>/<item>.
|
// EnsureIdentity) device list, pre-marshaled to opaque bytes the xmpp
|
||||||
|
// layer wraps in <items>/<item>.
|
||||||
PublishedIdentity(peer PeerID) (DeviceListDoc, error)
|
PublishedIdentity(peer PeerID) (DeviceListDoc, error)
|
||||||
// PublishedBundle is the same, for one specific device's bundle node.
|
// PublishedBundle is the same, for one specific device's bundle node.
|
||||||
PublishedBundle(peer PeerID, device DeviceID) (BundleDoc, error)
|
PublishedBundle(peer PeerID, device DeviceID) (BundleDoc, error)
|
||||||
|
|
||||||
// IngestRemoteDeviceList / IngestRemoteBundle feed the results of an
|
// IngestRemoteDeviceList / IngestRemoteBundle feed the results of an
|
||||||
// outbound PEP fetch (device-list, then per-device bundle) for a real
|
// outbound PEP fetch (device-list, then per-device bundle) for a real
|
||||||
// remote peer into the backend's store, establishing/refreshing
|
// remote peer (owner is an OwnedPeer identifying which of this
|
||||||
// sessions as needed (trust decisions happen inside here).
|
// gateway's identities is doing the fetching; peer is the remote's
|
||||||
IngestRemoteDeviceList(peer PeerID, doc DeviceListDoc) error
|
// plain bare JID, no scoping needed) into the backend's store,
|
||||||
IngestRemoteBundle(peer PeerID, device DeviceID, doc BundleDoc) error
|
// establishing/refreshing sessions as needed (trust decisions happen
|
||||||
|
// inside here).
|
||||||
|
IngestRemoteDeviceList(owner PeerID, peer PeerID, doc DeviceListDoc) error
|
||||||
|
IngestRemoteBundle(owner PeerID, peer PeerID, device DeviceID, doc BundleDoc) error
|
||||||
|
|
||||||
// Encrypt produces an opaque envelope addressed to every known device
|
// Encrypt produces an opaque envelope addressed to every known device
|
||||||
// of every given recipient, ready to be embedded in a stanza by the
|
// of every given recipient, ready to be embedded in a stanza by the
|
||||||
// xmpp layer. from is the identity doing the encrypting (a chat
|
// xmpp layer. from is the identity doing the encrypting (an OwnedPeer -
|
||||||
// pseudo-JID this gateway owns).
|
// see EnsureIdentity); to are the real remote peers' plain bare JIDs.
|
||||||
Encrypt(from PeerID, to []PeerID, plaintext []byte) (Envelope, error)
|
Encrypt(from PeerID, to []PeerID, plaintext []byte) (Envelope, error)
|
||||||
|
|
||||||
// Decrypt consumes an inbound envelope addressed to "to" (a chat
|
// Decrypt consumes an inbound envelope addressed to "to" (an
|
||||||
// pseudo-JID this gateway owns) from "from" (the real remote peer)
|
// OwnedPeer - see EnsureIdentity) from "from" (the real remote peer's
|
||||||
// and returns plaintext.
|
// plain bare JID) and returns plaintext.
|
||||||
Decrypt(from PeerID, to PeerID, env Envelope) ([]byte, error)
|
Decrypt(from PeerID, to PeerID, env Envelope) ([]byte, error)
|
||||||
|
|
||||||
// Devices lists known devices for a peer (trust-listing ad-hoc commands).
|
// Devices lists peer's known devices, as seen from owner's (an
|
||||||
Devices(peer PeerID) ([]DeviceInfo, error)
|
// OwnedPeer) point of view - for trust-listing ad-hoc commands.
|
||||||
|
Devices(owner PeerID, peer PeerID) ([]DeviceInfo, error)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue