npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

expo-libsignal

v0.2.0

Published

Expo Module wrapping signalapp/libsignal — Signal Protocol cryptography for React Native.

Readme

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.

| Runtime | Supported Versions | | ------------ | ------------------ | | Expo | 55+ | | React Native | 0.83+ | | Android | 7+ (SDK 36) | | iOS | 15.1+ |

New architecture is required.

Roadmap

| Surface | State | | ------------------------------------------------------------------------------ | ------------------------------------------------------ | | 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.

// Assumes you copied example/src/client/SignalClient.ts to ./client/SignalClient.ts.
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 })

// Bob publishes a one-time prekey bundle; alice consumes it to start a session.
const bobsBundle = await bob.publishOneTimePreKey({
  preKeyId: 100, signedPreKeyId: 200, kyberPreKeyId: 300,
})
await alice.startSession({ name: 'bob-uuid', deviceId: 1 }, bobsBundle)

// Alice encrypts. The returned envelope is what your app ships to bob (over
// your own transport: push, websocket, REST, whatever).
const envelope = await alice.send({ name: 'bob-uuid', deviceId: 1 }, 'hello')

// Bob decrypts whatever envelope landed.
const received = await bob.receive(envelope)
console.log(received.plaintext)  // 'hello'

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'

// First run: generate a fresh identity (X25519 keypair).
const kp = await IdentityKeyPair.generate()

// Serialize to bytes for storage.
const serialized = kp.serialize()
// Uint8Array, 64 bytes (32-byte public key + 32-byte private key)

// Public identity key (33 bytes: 1 type byte + 32 raw key).
const publicKey = kp.publicKey().serialize()

// Restore.
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') {
  // First message after a session is established. Bob calls
  // SessionCipher.decryptPreKeySignal on receipt.
} else {
  // Ongoing ratcheted message. Bob calls SessionCipher.decryptSignal.
}

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)
}

// One object satisfies every interface.
const cipher = new SessionCipher(
  {
    sessionStore: store,
    identityStore: store,
    preKeyStore: store,
    signedPreKeyStore: store,
    kyberPreKeyStore: store,
  },
  remoteAddress,
  localAddress,
)

// Wrap each protocol operation so its store reads and writes stay atomic.
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> {
    // Whatever your server expects. The envelope is a tagged union:
    // { type: 'preKeySignal' | 'signal', from: Address, bytes: Uint8Array } |
    // { type: 'sealed', bytes: Uint8Array } |
    // { type: 'sender-key-distribution', from, bytes, distributionId } |
    // { type: 'group', from, distributionId, bytes }
    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) => { /* store m.plaintext, sealed flag in your chat UI */ },
    'group-message': async (m) => { /* same, but routed by m.distributionId */ },
    'group-welcome': async (m) => { /* mark m.distributionId as joined */ },
  })
}

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:

| Class | Cause | | ------------------------ | ----------------------------------------------------------------------- | | 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) {
    // Bytes were not a valid serialized keypair.
  } else if (e instanceof UntrustedIdentityError) {
    // Identity rotation; surface a safety-number-changed banner.
  } else if (e instanceof LibsignalError) {
    // Some other libsignal-side issue.
  }
}

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.