expo-libsignal
The Signal Protocol as an Expo Module for React Native apps. Identity, 1:1
messaging (X3DH, Double Ratchet, Kyber), groups (Sender Keys), and Sealed
Sender. Pluggable stores with a SQLCipher-backed implementation included.
| Expo | 55+ |
| React Native | 0.83+ |
| Android | 7+ (SDK 36) |
| iOS | 15.1+ |
New architecture is required.
Roadmap
Identity (IdentityKeyPair, ProtocolAddress) | shipped |
1:1 messaging (SessionBuilder, SessionCipher), X3DH, Double Ratchet, Kyber | shipped |
Default SQLCipher-backed store (expo-libsignal/stores) | shipped |
Groups (GroupSessionBuilder, GroupCipher, Sender Keys) | shipped |
Sealed Sender (SealedSender, SenderCertificate, ServerCertificate) | shipped |
Example SignalClient facade (in example/src/client/) | shipped |
| Provisioning | deferred (no standalone primitive in libsignal 0.94.4) |
Quickstart
Two personas exchange a message. Each holds a SignalClient, the
facade in
example/src/client/SignalClient.ts.
It is not exported from the package — copy the file into your app and
adapt. The pattern will likely move into the library after some
real-world feedback.
import { SignalClient } from './client/SignalClient'
const alice = await SignalClient.open({
databaseName: 'alice.db',
keyAlias: 'alice.dbkey',
self: { name: 'alice-uuid', deviceId: 1 },
})
await alice.initializeIfNeeded({ registrationId: 12345 })
const bob = await SignalClient.open({
databaseName: 'bob.db',
keyAlias: 'bob.dbkey',
self: { name: 'bob-uuid', deviceId: 1 },
})
await bob.initializeIfNeeded({ registrationId: 67890 })
const bobsBundle = await bob.publishOneTimePreKey({
preKeyId: 100, signedPreKeyId: 200, kyberPreKeyId: 300,
})
await alice.startSession({ name: 'bob-uuid', deviceId: 1 }, bobsBundle)
const envelope = await alice.send({ name: 'bob-uuid', deviceId: 1 }, 'hello')
const received = await bob.receive(envelope)
console.log(received.plaintext)
A working three-persona chat demo lives on the Client tab of the
example app. It exercises 1:1, sealed sender, and groups end to end on
both platforms.
Install
bun add expo-libsignal
If you'll use the bundled SQLCipher store, also install expo-sqlite
and expo-secure-store — see Persistence for the full
setup. Other peers (expo, react, react-native) are already
present in any Expo + React Native app.
Add the config plugin to app.json or app.config.ts:
{
"expo": {
"plugins": ["expo-libsignal"]
}
}
Then prebuild:
bunx expo prebuild --clean
The plugin handles the platform plumbing:
- iOS injects the
LibSignalClient pod (Signal hosts the podspec at
their own URL, not the CocoaPods trunk), sets the FFI download
checksum, and propagates the FFI linker flags from the
LibSignalClient pod scope to your app target via
user_target_xcconfig.
- Android injects Signal's Maven repository
(
https://build-artifacts.signal.org/libraries/maven/) into your root
build.gradle and enables core library desugaring in your
app/build.gradle so libsignal's Java 8+ APIs work on minSdk 24.
If you want the default SQLCipher-backed store, see
Persistence for the one extra step.
Usage
The library ships two layers. The facade in
example/src/client/SignalClient.ts wraps everything (identity, 1:1,
sealed sender, groups, persistence) behind one class. Most apps copy
and adapt it. The primitives underneath (SessionBuilder,
SessionCipher, GroupCipher, SealedSender, store interfaces) are
exported from the package root for cases where you want finer control.
Facade
See the Quickstart above and
example/src/screens/SignalClientScreen.tsx for the full three-persona
chat demo. The facade source is short, has no hidden state, and is
meant to be read top to bottom before you copy it.
Primitives
If you want the lower-level building blocks the facade wraps:
import { IdentityKeyPair } from 'expo-libsignal'
const kp = await IdentityKeyPair.generate()
const serialized = kp.serialize()
const publicKey = kp.publicKey().serialize()
const restored = await IdentityKeyPair.deserialize(serialized)
Build a session and exchange messages:
import {
SessionBuilder,
SessionCipher,
ProtocolAddress,
PreKeyBundle,
} from 'expo-libsignal'
const bobAddress = await ProtocolAddress.create('bob-user-id', 1)
const aliceAddress = await ProtocolAddress.create('alice-user-id', 1)
const builder = new SessionBuilder(
{ sessionStore: alice.sessionStore, identityStore: alice.identityStore },
bobAddress,
aliceAddress,
)
await builder.processPreKeyBundle(bundle)
const cipher = new SessionCipher(
{
sessionStore: alice.sessionStore,
identityStore: alice.identityStore,
preKeyStore: alice.preKeyStore,
signedPreKeyStore: alice.signedPreKeyStore,
kyberPreKeyStore: alice.kyberPreKeyStore,
},
bobAddress,
aliceAddress,
)
const ciphertext = await cipher.encrypt(new TextEncoder().encode('hello'))
if (ciphertext.type === 'preKeySignal') {
} else {
}
Group messaging exposes GroupSessionBuilder and GroupCipher; sealed
sender exposes SealedSender.encrypt / SealedSender.decryptMessage.
The shapes mirror libsignal upstream so existing protocol knowledge
transfers.
Persistence
Store implementations are pluggable. Implement the SessionStore,
IdentityKeyStore, PreKeyStore, SignedPreKeyStore,
KyberPreKeyStore, and SenderKeyStore interfaces yourself, or use
the default SQLCipher-backed store that ships with the library:
import { SQLCipherProtocolStore } from 'expo-libsignal/stores'
const store = await SQLCipherProtocolStore.open()
if (!(await store.hasLocalIdentity())) {
await store.initializeLocalIdentity(await IdentityKeyPair.generate(), registrationId)
}
const cipher = new SessionCipher(
{
sessionStore: store,
identityStore: store,
preKeyStore: store,
signedPreKeyStore: store,
kyberPreKeyStore: store,
},
remoteAddress,
localAddress,
)
const ciphertext = await store.runExclusive(() => cipher.encrypt(plaintext))
Enabling SQLCipher
The store needs two peer dependencies:
bunx expo install expo-secure-store expo-sqlite
Add the expo-sqlite plugin with useSQLCipher to your app.json:
{
"expo": {
"plugins": [
"expo-libsignal",
"expo-secure-store",
["expo-sqlite", { "useSQLCipher": true }]
]
}
}
Prebuild:
bunx expo prebuild --clean
That swaps expo-sqlite's vendored SQLite amalgamation for SQLCipher's
(CommonCrypto backend on iOS, OpenSSL on Android) and applies the
SQLite flags SQLCipher needs (SQLITE_HAS_CODEC, NDEBUG, etc.). The
store verifies SQLCipher is active via PRAGMA cipher_version after
applying the key, and refuses to open if the encryption layer is
missing.
Key handling
The SQLCipher key is 32 random bytes from the OS CSPRNG, hex-encoded,
kept in the iOS Keychain or Android Keystore via expo-secure-store
(WHEN_UNLOCKED_THIS_DEVICE_ONLY by default). Override
keychainAccessible in the open options, or supply your own
keyProvider to skip secure-store entirely.
Schema migrations
Forward-only. During 0.x a release may change the schema without a
data migration path; release notes call this out when it happens. The
library refuses to open a database whose schema is newer than what it
understands, which keeps a downgrade from corrupting your data.
Writing your own transport
The library does not move bytes; the app does. The Transport interface is
the seam where your shipping mechanism (websocket, REST, push, message queue)
plugs in:
import type { Address, Envelope, Transport } from 'expo-libsignal'
class MyWebsocketTransport implements Transport {
async send(to: Address, envelope: Envelope): Promise<void> {
this.socket.emit('envelope', { to, envelope: serialize(envelope) })
}
subscribe(self: Address, onEnvelope: (e: Envelope) => void): () => void {
const handler = (raw: unknown) => onEnvelope(deserialize(raw))
this.socket.on(`inbox:${self.name}.${self.deviceId}`, handler)
return () => this.socket.off(`inbox:${self.name}.${self.deviceId}`, handler)
}
}
On receive, use dispatchReceived to route by kind without writing the
if/else if chain yourself:
import { dispatchReceived, type Received } from 'expo-libsignal'
async function onInbound(received: Received) {
await dispatchReceived(received, {
message: async (m) => { },
'group-message': async (m) => { },
'group-welcome': async (m) => { },
})
}
Implementing your own store
See src/core/stores.ts for the interface contract. One non-obvious
detail: PreKeyStore, SignedPreKeyStore, and KyberPreKeyStore
each need a bulk-load method (loadPreKeys, loadSignedPreKeys,
loadKyberPreKeys). Sealed sender decrypt and kyber prekey
resolution seed every candidate up front because the in-envelope ids
only surface after decryption begins.
Errors
Every error from the library extends LibsignalError. Specific
subclasses let callers act on the cause without parsing strings:
UntrustedIdentityError | Remote identity changed and the store rejected it. |
SessionNotFoundError | Tried to encrypt or fetch a session that does not exist. |
SenderKeyNotFoundError | Group encrypt or decrypt with no SKDM exchanged. |
InvalidMessageError | Bytes do not deserialize, signature is bad, or the format is wrong. |
DuplicateMessageError | Replay of a message already processed. |
InvalidKeyError | PreKey, signed prekey, or kyber prekey lookup failed. |
StoreError | SQLCipher layer failure (bad key, locked file, etc.). |
SchemaTooNewError | Database schema is from a newer library version than the installed one. |
import {
IdentityKeyPair,
LibsignalError,
InvalidMessageError,
UntrustedIdentityError,
} from 'expo-libsignal'
try {
await IdentityKeyPair.deserialize(corruptedBytes)
} catch (e) {
if (e instanceof InvalidMessageError) {
} else if (e instanceof UntrustedIdentityError) {
} else if (e instanceof LibsignalError) {
}
}
Threat model
Protocol-level guarantees (forward secrecy, post-compromise security,
PQ hybrid in X3DH, sealed sender, group sender authentication) are
libsignal's. See signal.org/docs and the
libsignal repository.
Specific to this wrapping:
- Native binding correctness is verified by on-device smoke
(
example/SMOKE_TEST_LOG.md). Not third-party audited.
- The config plugin pins
LIBSIGNAL_FFI_PREBUILD_CHECKSUM and verifies
it at pod install. Bumping libsignal requires updating version and
checksum.
- SQLCipher key: 32 OS-CSPRNG bytes in
expo-secure-store
(WHEN_UNLOCKED_THIS_DEVICE_ONLY by default). Override keyProvider
to own key custody.
- Schema migrations are forward-only; downgrades throw
SchemaTooNewError. During 0.x a release may change schema without
migration. Release notes flag this when it happens.
- Provisioning is not bound. libsignal 0.94.4 exposes it only through
the chat-connection layer, which we do not wrap.
- Group membership is the app's responsibility. Shipping a sender key
to a non-member adds them to the group.
- Identity-change UX is the app's responsibility. The library raises
UntrustedIdentityError; surfacing the safety-number banner is up
to you.
How it works
No Rust FFI is bundled. The build pulls Signal's prebuilt artifacts:
- iOS:
LibSignalClient CocoaPod 0.94.4.
- Android:
org.signal:libsignal-android:0.94.4 from Signal's
Maven repository.
Pinned by exact version. To bump, update LIBSIGNAL_VERSION and
LIBSIGNAL_IOS_FFI_SHA256 in plugin/src/index.ts, and the version
pin in ios/ExpoLibsignal.podspec and android/build.gradle.
License
See LICENSE.
Contributing
See CONTRIBUTING.md.
Security
See SECURITY.md.