OpenE2EE

Node

Set up the Node filesystem store for services, integration tests, CLIs, and relay development — with direct answers on key storage, retention, prekeys, recovery, and what a server-side identity does to your threat model.

Status
pre-1.0
Applies to
0.1.0
Platforms
Node 18+
Prereqs
Quickstart
Reading time
12 min

The SDK is easiest to run on Node, but you must carefully consider why you use it. Node is suitable for integration tests, CLIs, and disclosed bots or bridges. It is not suitable for the "decrypt on the server so we can do X" pattern. That design can work, but the result is not end-to-end encrypted.

It is also, in practice, the runtime you develop your relay on, which is a different job from running a client. That distinction runs through this page, and the last section is about it specifically.

What the Node store covers

NodeSignalProtocolStore implements the complete store contract. It covers identity keys, EC and Kyber prekeys, sessions, trust decisions, Sesame devices, sender keys, and message records. It declares implements ISignalProtocolLocalStore, so the compiler checks that coverage on every SDK build.

Multi-device linking and both group APIs are therefore available on this adapter. Use inMemoryStore() for tests where data must not survive a restart.

Install

npm install @open-e2ee/signal-protocol-sdk@0.1.0

No peer dependencies. The adapter uses node:crypto and node:fs, both built in. The package is ESM-only and requires Node 18 or later.

Setup

import { createSignalProtocolClient } from '@open-e2ee/signal-protocol-sdk';
import { nodeStore } from '@open-e2ee/signal-protocol-sdk/local/store/node';

// Async, and takes an optional data directory.
const storage = await nodeStore({ dataDir: './.signal-protocol' });

const signal = await createSignalProtocolClient({
  identity: { userId },
  adapters: { storage, relay },
});

await signal.syncToServer();

nodeStore(config?) returns a promise and accepts one option: dataDir. Set it explicitly. The default path is ~/.config/open-e2ee/signal-protocol, which is reasonable for a CLI but not for a service. A service home directory depends on the account that starts the process. A different account can create a second identity and cause identity-change warnings for every contact.

Two more calls you will want in a long-lived process:

signal.registerHook('onMessageDecrypted', (envelope) => {
  handle(envelope.conversationId, envelope.content);
});

signal.startRelaySubscription();

// On shutdown.
await signal.stop();

startRelaySubscription() logs a warning and returns without doing anything if no relay adapter or onMessageDecrypted hook exists. It does not throw. Register the hook first. If messages silently fail to arrive, check that order first.

Before the seven questions: what a server-side identity means

A Node client is a full participant with a real identity key. If it can read a conversation, its machine is inside the encryption boundary. The boundary also includes anyone who can access the machine, disk, snapshots, process memory, or logs.

This design is suitable for a user-run CLI, test harness, or disclosed bot. Do not use it to bypass end-to-end encryption while you continue to describe the product as end-to-end encrypted. If your server can decrypt conversations, users receive a different guarantee. Your documentation must state the actual guarantee.


The seven questions

1. Where are the keys stored?

On the filesystem, under dataDir, as JSON collection files with per-record encryption. Identity keys, prekeys, sessions, and trust decisions all live there. Session records go in a sessions/ subdirectory. Protocol security state lives in protocol_security_state_v1.json.

The store encrypts records with AES-256-GCM. Each record uses a fresh 12-byte IV and a 16-byte authentication tag. The store saves { encrypted_data, iv, auth_tag } in base64.

The database key is 32 bytes from randomBytes, written to <dataDir>/db.key with mode 0o600, inside directories created with mode 0o700. There is no OS keychain involved and no key derivation from a passphrase: nodeStore() takes no password parameter. The key file sits next to the data it encrypts.

This design creates a narrow boundary that resembles the browser adapter, not the Expo adapter. It protects only a data-directory copy that omits db.key. A backup of the complete directory includes the key. Anyone with access as the service account can read all stored data. getDatabaseKey() is a public store method.

If you want a stronger boundary, it has to come from outside the SDK

Use full-disk encryption, a dedicated service account, or a tmpfs data directory for ephemeral workers. You can also place dataDir on a volume that your backup system excludes. The adapter has no passphrase-wrapped key or keychain integration, so you must apply these controls during deployment.

2. How long are they retained?

Protocol lifetimes are identical on every runtime. Signed prekeys rotate after keyRefreshIntervalMs, which defaults to two days. maxPreKeyAgeMs is 14 days, maxMessageKeysStored is 1000, and keyExpirationMs is 7 days. Unacknowledged sessions expire after 30 days.

Storage lifetime differs from the browser runtime. Nothing evicts a Node data directory. It persists until you delete it. The primary failure mode is accumulation in unintended locations. Examples include container build contexts, machine snapshots, incorrect log-agent globs, and paths that a deployment tool synchronizes.

Decide where the directory lives before you deploy, not after.

3. How do I keep keys away from my own backend?

The protocol answer is the same everywhere: no code path in the SDK transmits a private key, and syncToServer() uploads public material only.

The Node-specific leak paths are the ones worth listing, because on a server they are all defaults somewhere:

  • Structured logging. Logging error context, or an envelope "for debugging," sends protocol material to the log aggregator. Configure logger redaction.
  • APM and error-reporting agents. They capture local variables in stack frames. Decrypted content in a frame becomes a payload to a third party.
  • Core dumps and heap snapshots. On by default in some container runtimes. Both contain key material.
  • Snapshots and backups of the volume holding dataDir. See question 1: the key is in there too.

If your backend is the relay, note that the relay never needs anything private. The ISignalProtocolRelayServer interface contains no private fields. If you add a private-key column, stop.

4. How many prekeys, and when do they refill?

Unchanged from other runtimes: batch of 100 one-time prekeys, preKeyLowThreshold 50, internal replenishment floor 10, preKeyCheckThrottleMs 12 hours.

const status = await signal.checkPreKeyStatus();
// { oneTimePreKeysRemaining, needsReplenishment }

oneTimePreKeysRemaining is -1 when the relay cannot determine the count. It is not 0, so do not treat it as exhaustion.

A long-lived Node process can schedule this check, but nobody will notice if you omit it. Node has no app foreground event. Run the check on a timer and alert on needsReplenishment. Other people consume your prekeys at a rate that you do not control.

For rotation without a full client, the usual shape for a scheduled job, the SDK exports a headless path:

import { rotateKeysHeadless } from '@open-e2ee/signal-protocol-sdk/client/headless';

await rotateKeysHeadless(relay, userId, deviceId, { storage });

It requires a local store, and it throws if you do not pass one. signal.rotateEcSignedPreKey() and signal.rotateKyberPreKey() are the equivalents on a live client.

5. What happens when the data directory is lost?

Losing the data directory is equivalent to reinstalling an app. Common causes include an ephemeral container, a filesystem-replacing deployment, or a cleanup job. A different service account can also change the default dataDir.

The consequences are the same as everywhere: identity key gone, sessions gone, and every contact sees an identity change on next contact. There is no recovery path in the SDK, because no server holds a copy.

This loss can be difficult to notice on Node. A phone reinstall is a user action, but a container restart can be routine. A bot or verified bridge usually needs a durable identity. Mount its dataDir on persistent storage and manage it as stateful infrastructure.

6. What does the relay store?

Opacity ledger

ArtifactStays on deviceSent to relayIn object storeVisible as metadata
identityKeyPair (private)yes, in dataDirneverneverno
Identity public keyyesyesnoyes — the pinned identity
Signed prekey + signatureprivate half localpublic halfnoyes
Kyber prekeysprivate half localpublic halfnoyes
One-time prekeysprivate half localpublic halfnocount and consumption observable
Database key (db.key)yes, in dataDirneverneverno
Session / ratchet stateyesneverneverno
Message plaintextyesneverneverno
Message ciphertextyesyesnosize and timing
Sender / recipient IDs, device IDsyesyesnoyes — the social graph
timestamp, messageType, clientMessageIdyesyesnoyes
Attachment bytesencrypted firstnoyes, opaquesize, count, timing

If you write both the relay and client, use this table for schema review. You choose to retain everything in the "sent to relay" column. You also choose not to retain the other data, so you cannot produce it under subpoena.

7. What should I do about backups?

The SDK does not include a backup mechanism. Node exposes a filesystem that you can copy, which creates additional risk.

Copying dataDir copies db.key with it, so a snapshot is a full, usable identity in plaintext-equivalent terms. There is no passphrase step to make that copy safe.

Realistic positions:

  • Ephemeral by design. The service has no durable identity. It re-registers on deploy. Simplest, and correct for short-lived workers and test harnesses. Contacts see identity changes, so it is wrong for anything a human verifies.
  • Durable volume, no backups. dataDir on a persistent volume, explicitly excluded from snapshotting. The identity survives restarts. Losing the volume means starting over. A defensible default for most bots.
  • Secret backup. The backup contains a conversation decryption key. Encrypt it with a separately stored key, log access, and include it in security reviews. Do not store it with application-log backups.

See recovery, backup, and migration for three named threat-model profiles with explicit tradeoffs.


The other Node job: building the relay

Most Node code in an end-to-end encrypted system is not a client at all. It is the relay: the service that stores prekey bundles, hands them out, accepts opaque envelopes, and delivers them. That service holds no keys and decrypts nothing, which is why it is the part you can operate normally.

The SDK ships two relay implementations: InMemorySignalProtocolRelayServer (via inMemoryRelay(), in-memory, for tests and demos) and ConvexSignalProtocolRelayServer for Convex backends. There is no generic HTTP or database-backed relay. If you use neither implementation, implement ISignalProtocolRelayServer yourself:

import type { ISignalProtocolRelayServer } from '@open-e2ee/signal-protocol-sdk/remote/relay/types';

The type signature does not express all correctness requirements. In particular, the relay must consume one-time prekeys atomically under concurrent requests. Incorrect consumption causes intermittent session failures under load, not an immediate error. Read relay and prekey infrastructure before you start.

You can also test this behavior by running a Node client against the in-memory relay. Two clients and inMemoryRelay() provide an end-to-end assertion in CI without infrastructure. See testing encrypted flows.

Operational notes

  • Do not assume that you can scrub process memory. JavaScript engines copy and move buffers, so zeroing is best-effort. Heap dumps, core dumps, and swap can expose server data.
  • Test the published artifact on every Node line you support. Do not infer from one version.
  • Set the data directory mode. The store creates directories at 0o700. You must secure pre-created directories and volumes with permissive modes.

Good uses

  • Integration tests. Two Node clients and inMemoryRelay() in CI, with no infrastructure.
  • CLIs the user runs themselves. The user's machine is already inside their trust boundary.
  • Disclosed bots and bridges. A bot can read its conversations. This design is honest when all participants know about the bot.
  • Relay development. Exercising an ISignalProtocolRelayServer implementation against a real client.

Next

On this page