How E2EE Encryption Works in LINEJS Using Curve25519 and TweetNaCl

LINEJS implements end-to-end encryption by combining X25519 Diffie-Hellman key exchange via curve25519-js with Curve25519 key-pair generation via tweetnacl, deriving AES-256-GCM keys through SHA-256 stretching to secure message payloads.

The evex-dev/linejs repository provides a full-stack TypeScript client for the LINE messaging protocol, including a complete E2EE implementation that interoperates with the official LINE mobile apps. The cryptography stack relies on two lightweight, audited libraries to handle the elliptic-curve operations and symmetric encryption, orchestrated by the E2EE class in packages/linejs/base/e2ee/mod.ts.

Cryptographic Primitives: Curve25519-JS and TweetNaCl

LINEJS delegates low-level cryptographic operations to two distinct libraries, each serving a specific purpose in the E2EE lifecycle:

Library Purpose Key Function
curve25519-js X25519 Diffie-Hellman scalar multiplication sharedKey(privateKey, publicKey)
tweetnacl Curve25519 key-pair generation nacl.box.keyPair()

The curve25519-js library performs the X25519 ECDH operation that transforms a local private key and a remote public key into a shared secret. TweetNaCl is used primarily for generating the initial identity key pairs during client initialization and for the temporary key pairs required during QR-code login flows.

Key Management and Storage

The E2EE system maintains persistent key material using the client's storage interface, prefixed with e2eeKeys:.

Self Key Retrieval

Before encrypting or decrypting, the client loads its own key pair via getE2EESelfKeyData in packages/linejs/base/e2ee/mod.ts:

// Load own key pair or fetch the newest one from the server
const selfKeyData = await this.getE2EESelfKeyData(_from);

If no local key exists, the client negotiates a new key pair with the LINE servers and stores it under the e2eeKeys: prefix.

Peer Key Discovery

For one-to-one chats, the client calls talk.negotiateE2EEPublicKey to fetch the recipient's public key from LINE's key distribution servers. For group chats, the client uses the pre-fetched group public key stored under the e2eePublicKeys: prefix.

The E2EE Encryption Flow

The encryption process follows a standard Encrypt-then-MAC pattern using AES-256-GCM, with keys derived from an X25519 shared secret.

1. Shared Secret Derivation

The client generates the shared secret using generateSharedSecret, which wraps curve25519-js:

const keyData = this.generateSharedSecret(privateKey, remotePublicKey);

Internally, this calls sharedKey(privateKey, publicKey) from curve25519-js to perform the X25519 scalar multiplication.

2. Key Stretching

For legacy E2EE v1, the shared secret is stretched using SHA-256 with domain separation constants:

const aesKey = this.getSHA256Sum(Buffer.from(sharedSecret), "Key");
const aesIv  = this.xor(this.getSHA256Sum(Buffer.from(sharedSecret), "IV"));

The "Key" and "IV" strings act as domain separators to produce distinct key material for encryption and initialization vectors.

3. AES-256-GCM Encryption (v2)

Modern LINE clients use E2EE v2, implemented in encryptE2EEMessageV2 (lines 637-649 in mod.ts):

  1. Generate random salt (16 bytes) and sign (12 bytes, used as GCM nonce).
  2. Derive GCM key: SHA256(keyData || salt || "Key").
  3. Construct AAD (Additional Authenticated Data) encoding to, from, sender key ID, receiver key ID, protocol version, and content type.
  4. Encrypt payload using crypto.createCipheriv("aes-256-gcm", gcmKey, sign).
  5. Package chunks: [salt, ciphertext+authTag, sign, senderKeyId, receiverKeyId].

The encryptE2EETextMessage helper (lines 438-459) wraps this flow for text content, automatically serializing the text into a JSON payload.

Decrypting E2EE Messages

Decryption mirrors the encryption process. When a message arrives with the e2ee flag, the client invokes decryptE2EEMessage (lines 511-525), which dispatches to decryptE2EEMessageV2 (lines 929-949).

The decryption steps:

  1. Load keys: Retrieve the local private key and the sender's public key using getE2EELocalPublicKey.
  2. Re-derive shared secret: Call generateSharedSecret with the local private key and sender's public key to produce the identical keyData used during encryption.
  3. Reconstruct GCM key: Calculate SHA256(keyData || salt || "Key") using the salt from the first chunk.
  4. Verify and decrypt: Use crypto.createDecipheriv("aes-256-gcm", gcmKey, sign) with the sign (nonce) from the third chunk, verifying the authentication tag appended to the ciphertext.
  5. Parse payload: Extract the JSON content containing the text or location data.

If the authentication tag verification fails, the decryption throws an error, preventing tampered or corrupted messages from being processed.

Integration with the Talk API

The E2EE system is transparently integrated into the high-level messaging API. In packages/linejs/base/service/talk/mod.ts, the sendMessage method (lines 4-26) checks for the e2ee flag:

if (e2ee && !chunks) {
  const encryptedChunks = await this.client.e2ee.encryptE2EEMessage(
    to,
    text,
    contentType
  );
  // ... attach encryptedChunks to the message payload
}

When e2ee: true is passed without pre-encrypted chunks, the client automatically:

  1. Resolves the recipient's public key
  2. Generates the shared secret
  3. Encrypts the payload
  4. Packages the chunks for transmission

This allows developers to send encrypted messages with a single flag while maintaining full compatibility with the LINE protocol.

QR Login Secret Generation

For QR-code based authentication, LINEJS uses TweetNaCl to generate ephemeral key pairs. The createSqrSecret method (lines 1020-1036 in mod.ts) implements this:

const { secretKey, publicKey } = nacl.box.keyPair();

The method returns:

  • secretKey: A Uint8Array kept client-side to decrypt the QR login response
  • sqrSecret: A URL-encoded string representation of the public key appended to the QR code URL

This temporary key pair is used only during the authentication handshake and is discarded afterward, ensuring that long-term identity keys are not exposed during the QR scanning process.

Summary

LINEJS implements a robust end-to-end encryption system that interoperates with the official LINE mobile applications:

  • Curve25519-JS handles the X25519 Diffie-Hellman scalar multiplication to derive shared secrets between peers.
  • TweetNaCl provides secure Curve25519 key-pair generation for identity keys and QR-login ephemeral secrets.
  • The E2EE class in packages/linejs/base/e2ee/mod.ts orchestrates key management, shared secret derivation via SHA-256 stretching, and AES-256-GCM encryption/decryption.
  • E2EE v2 uses a chunks array format containing salt, ciphertext with authentication tag, nonce, and key identifiers.
  • The Talk API seamlessly integrates encryption via the e2ee: true flag, automatically handling key negotiation and payload encryption.

Frequently Asked Questions

What is the difference between curve25519-js and tweetnacl in LINEJS?

Curve25519-js is used exclusively for the X25519 Diffie-Hellman operation (sharedKey) to compute a shared secret from a private key and a remote public key. TweetNaCl is used for generating Curve25519 key pairs (nacl.box.keyPair) during client initialization and for creating temporary key pairs during QR-code login flows. While both libraries use Curve25519, curve25519-js focuses on scalar multiplication, whereas TweetNaCl provides higher-level box primitives and secure random key generation.

How does LINEJS derive the AES key for E2EE messages?

LINEJS derives the AES key through a multi-step process defined in packages/linejs/base/e2ee/mod.ts. First, it generates a shared secret using generateSharedSecret, which calls sharedKey from curve25519-js to perform X25519 Diffie-Hellman. For legacy v1, it stretches this secret using getSHA256Sum(sharedSecret, "Key") for the AES key and an XOR of getSHA256Sum(sharedSecret, "IV") for the initialization vector. For modern v2 encryption, it computes SHA256(keyData || salt || "Key") to produce the 256-bit AES-GCM key.

What is the 'chunks' array structure in LINEJS E2EE messages?

The chunks array is the binary payload format used to transmit encrypted messages in LINEJS, constructed in encryptE2EEMessageV2. The array contains exactly five elements in order:

  1. Salt (16 bytes) - random entropy used in key derivation;
  2. Ciphertext + Auth Tag - the AES-256-GCM encrypted payload concatenated with its 16-byte authentication tag;
  3. Sign (12 bytes) - the nonce (IV) used for the GCM cipher;
  4. Sender Key ID - identifier for the sender's public key;
  5. Receiver Key ID - identifier for the recipient's public key. This structure allows the recipient to reconstruct the exact parameters needed for decryption and authentication verification.

Is LINEJS E2EE compatible with the official LINE mobile app?

Yes, the LINEJS E2EE implementation is fully compatible with the official LINE mobile applications because it follows the same protocol specifications used by LINE Corporation's official clients. The implementation uses standard X25519 for key exchange and AES-256-GCM for message encryption, matching the cryptographic primitives used in the mobile apps. The chunks array format, key identifiers, and AAD (Additional Authenticated Data) structure conform to the LINE E2EE v2 specification, ensuring that messages encrypted by LINEJS can be decrypted by official clients and vice versa.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →