telegabber/e2ee/omemo/libsignal/session_test.go
2026-07-28 20:32:56 -04:00

221 lines
7.1 KiB
Go

package libsignal_test
// This is a self-consistency round trip, not a transcription of published
// X3DH/Double Ratchet test vectors: two simulated parties generate real
// keys through the cgo-linked library, establish a session via X3DH, and
// exchange messages, verifying the plaintext survives encrypt-on-one-side
// / decrypt-on-the-other for both protocol versions. That's the property
// that actually matters for this binding (it interoperates with the real
// library correctly), and avoids the risk of mistranscribing external
// vectors for a library/wire-format combination with no independently
// published vectors readily available.
import (
"bytes"
"testing"
"dev.narayana.im/narayana/telegabber/e2ee/omemo/libsignal"
)
// identity bundles up one simulated party's generated keys and store.
type identity struct {
ctx *libsignal.Context
storeCtx *libsignal.StoreContext
registrationID uint32
identityPublicKey []byte
signedPreKeyID uint32
signedPreKeyRecord []byte
preKeyID uint32
preKeyRecord []byte
}
func newIdentity(t *testing.T) *identity {
t.Helper()
ctx, err := libsignal.NewContext()
if err != nil {
t.Fatalf("NewContext: %v", err)
}
t.Cleanup(ctx.Close)
idKeyPair, err := libsignal.GenerateIdentityKeyPair(ctx)
if err != nil {
t.Fatalf("GenerateIdentityKeyPair: %v", err)
}
pub, priv, err := libsignal.SplitIdentityKeyPair(ctx, idKeyPair.Record)
if err != nil {
t.Fatalf("SplitIdentityKeyPair: %v", err)
}
regID, err := libsignal.GenerateRegistrationID(ctx)
if err != nil {
t.Fatalf("GenerateRegistrationID: %v", err)
}
store := newMemStore(pub, priv, regID)
signedPreKey, err := libsignal.GenerateSignedPreKey(ctx, idKeyPair, 1)
if err != nil {
t.Fatalf("GenerateSignedPreKey: %v", err)
}
if err := store.StoreSignedPreKey(signedPreKey.ID, signedPreKey.Record); err != nil {
t.Fatalf("StoreSignedPreKey: %v", err)
}
preKeys, err := libsignal.GeneratePreKeys(ctx, 1, 1)
if err != nil {
t.Fatalf("GeneratePreKeys: %v", err)
}
if len(preKeys) != 1 {
t.Fatalf("expected 1 prekey, got %d", len(preKeys))
}
if err := store.StorePreKey(preKeys[0].ID, preKeys[0].Record); err != nil {
t.Fatalf("StorePreKey: %v", err)
}
storeCtx, err := libsignal.NewStoreContext(ctx, store)
if err != nil {
t.Fatalf("NewStoreContext: %v", err)
}
t.Cleanup(storeCtx.Close)
return &identity{
ctx: ctx,
storeCtx: storeCtx,
registrationID: regID,
identityPublicKey: pub,
signedPreKeyID: signedPreKey.ID,
signedPreKeyRecord: signedPreKey.Record,
preKeyID: preKeys[0].ID,
preKeyRecord: preKeys[0].Record,
}
}
// bundle returns id's own device bundle, as it would be published in an
// XEP-0384 bundle - i.e. what a remote peer's SessionBuilder needs to
// start a session with id via X3DH.
//
// Which signature to include depends on the recipient's protocol version:
// session_builder_process_pre_key_bundle re-serializes the signed prekey's
// public key via ec_public_key_serialize (version < 4, the 33-byte
// DJB_TYPE-prefixed legacy form) or ec_public_key_serialize_omemo
// (version >= 4, the raw 32-byte Montgomery form) before verifying it
// against whichever signature was supplied - confirmed by reading
// session_builder.c. Supplying the wrong one of the two signatures
// libomemo-c's signed-prekey generation always computes fails with
// SG_ERR_INVALID_KEY, not a version-related error, which is what actually
// surfaced this while writing this test.
func (id *identity) bundle(t *testing.T, deviceID uint32, version int) libsignal.RemoteBundle {
t.Helper()
spkInfo, err := libsignal.DecodeSignedPreKey(id.ctx, id.signedPreKeyRecord)
if err != nil {
t.Fatalf("DecodeSignedPreKey: %v", err)
}
pkInfo, err := libsignal.DecodePreKey(id.ctx, id.preKeyRecord)
if err != nil {
t.Fatalf("DecodePreKey: %v", err)
}
signature := spkInfo.Signature
if version >= libsignal.ProtocolVersionV4 {
signature = spkInfo.SignatureOMEMO
}
return libsignal.RemoteBundle{
RegistrationID: id.registrationID,
DeviceID: deviceID,
PreKeyID: pkInfo.ID,
PreKeyPublic: pkInfo.PublicKey,
SignedPreKeyID: spkInfo.ID,
SignedPreKeyPublic: spkInfo.PublicKey,
SignedPreKeySignature: signature,
IdentityKeyPublic: id.identityPublicKey,
}
}
func TestSessionRoundTrip(t *testing.T) {
for _, tc := range []struct {
name string
version int
omemo bool
}{
{"legacy", libsignal.ProtocolVersionV3, false},
{"modern", libsignal.ProtocolVersionV4, true},
} {
tc := tc
t.Run(tc.name, func(t *testing.T) {
if tc.version == libsignal.ProtocolVersionV4 && !libsignal.ProtocolV4Supported {
t.Skip("modern OMEMO not supported by this build (signal_legacy tag)")
}
alice := newIdentity(t)
bob := newIdentity(t)
aliceAddr := libsignal.Address{Name: "alice@example.com", DeviceID: 1}
bobAddr := libsignal.Address{Name: "bob@example.com", DeviceID: 1}
// Alice fetches Bob's bundle and establishes a session (X3DH).
builder, err := libsignal.NewSessionBuilder(alice.ctx, alice.storeCtx, bobAddr, tc.version)
if err != nil {
t.Fatalf("NewSessionBuilder: %v", err)
}
defer builder.Close()
if err := builder.ProcessPreKeyBundle(alice.ctx, bob.bundle(t, bobAddr.DeviceID, tc.version)); err != nil {
t.Fatalf("ProcessPreKeyBundle: %v", err)
}
aliceCipher, err := libsignal.NewSessionCipher(alice.ctx, alice.storeCtx, bobAddr, tc.version)
if err != nil {
t.Fatalf("NewSessionCipher (alice): %v", err)
}
defer aliceCipher.Close()
plaintext1 := []byte("hello bob")
ct1, err := aliceCipher.Encrypt(plaintext1)
if err != nil {
t.Fatalf("Encrypt (alice->bob): %v", err)
}
if ct1.Type != libsignal.CiphertextPreKeyType {
t.Fatalf("expected first message to be a prekey message, got type %d", ct1.Type)
}
// Bob decrypts Alice's first message - this establishes Bob's
// side of the session as a side effect; no ProcessPreKeyBundle
// needed on his end, only a bare SessionCipher.
bobCipher, err := libsignal.NewSessionCipher(bob.ctx, bob.storeCtx, aliceAddr, tc.version)
if err != nil {
t.Fatalf("NewSessionCipher (bob): %v", err)
}
defer bobCipher.Close()
got1, err := bobCipher.Decrypt(bob.ctx, *ct1, tc.omemo, bob.registrationID)
if err != nil {
t.Fatalf("Decrypt (bob<-alice): %v", err)
}
if !bytes.Equal(got1, plaintext1) {
t.Fatalf("round trip mismatch: got %q, want %q", got1, plaintext1)
}
// Bob replies; Alice decrypts using her already-established
// session (an ordinary ratchet message this time, not a
// prekey message).
plaintext2 := []byte("hi alice")
ct2, err := bobCipher.Encrypt(plaintext2)
if err != nil {
t.Fatalf("Encrypt (bob->alice): %v", err)
}
if ct2.Type != libsignal.CiphertextSignalType {
t.Fatalf("expected reply to be an ordinary ratchet message, got type %d", ct2.Type)
}
got2, err := aliceCipher.Decrypt(alice.ctx, *ct2, tc.omemo, alice.registrationID)
if err != nil {
t.Fatalf("Decrypt (alice<-bob): %v", err)
}
if !bytes.Equal(got2, plaintext2) {
t.Fatalf("round trip mismatch: got %q, want %q", got2, plaintext2)
}
})
}
}