mirror of
https://dev.narayana.im/narayana/telegabber.git
synced 2026-08-05 12:17:06 +00:00
221 lines
7.1 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|