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

@eternl/hub-core

v0.31.3

Published

Shared infrastructure for Eternl Hub SDKs.

Readme

@eternl/hub-core

Shared infrastructure for Eternl Hub SDKs. This package provides the common foundation used by both @eternl/hub-dapp-sdk and @eternl/hub-wallet-sdk: the encrypted transport, the cryptographic handshake, the wire protocol, and the symmetric session layer that ties them together.

It is a low-level package. Most application code should use the role-specific SDKs (dApp / wallet) rather than wiring these primitives by hand. The primitives are documented here for SDK authors and for advanced integrations.

Installation

npm install @eternl/hub-core
# or
pnpm add @eternl/hub-core

Requirements

  • A runtime with the Web Crypto API (globalThis.crypto.subtle): browsers, or Node.js ≥ 18. The crypto module calls assertCryptoCapabilities() and throws on environments without it.
  • ESM or CJS consumers are both supported (dual import/require builds with types).

Overview

The package is organised into a few areas:

  • Crypto — ECDH P-256 key exchange + AES-256-GCM AEAD, with a PIN-bound key derivation and a 6-digit Short Authentication String (SAS6) for MITM protection.
  • SessioncreatePairSession, the symmetric protocol layer that owns the wire envelope, encrypt/decrypt, heartbeats, presence ping/pong, and replay validation for one pairing.
  • TransportNatsTransport (NATS WebSocket) behind a small Transport interface for dependency injection. A MockTransport test double is available from the @eternl/hub-core/testing subpath.
  • Messages — typed message definitions, type guards, and create… factories for the DApp ↔ Wallet protocol.
  • Presence — subject builders, header keys, and cadence constants for the JetStream-stream-based presence protocol.
  • LifecycleLifecycleDispatcher, a singleton that fans out DOM/app lifecycle events (tab hidden/visible, page hide/show, app background/foreground) to presence beacons.
  • Types / Utils / Errors / Constants — shared TypeScript types, encoding/event helpers, the EternlConnectError class, and protocol configuration.

Imports and subpath exports

The production primitives are re-exported from the package root:

import { createSessionEncryption, createPairSession, NatsTransport } from '@eternl/hub-core';

Each area is also available as a subpath export for narrower imports:

import { createSessionEncryption } from '@eternl/hub-core/crypto';
import { EternlConnectError }      from '@eternl/hub-core/errors';
import { createApiCall }           from '@eternl/hub-core/messages';
import { createPairSession }       from '@eternl/hub-core/session';
import { NatsTransport }           from '@eternl/hub-core/transport';
import { LifecycleDispatcher }     from '@eternl/hub-core/lifecycle';
import { presencePublishSubject }  from '@eternl/hub-core/presence';
import type { AccountInfo }        from '@eternl/hub-core/types';
import { generateNonce }           from '@eternl/hub-core/utils';

The valid subpaths are ./crypto, ./errors, ./messages, ./session, ./presence, ./lifecycle, ./transport, ./types, and ./utils.

Test doubles such as MockTransport live under the dedicated @eternl/hub-core/testing subpath and are intentionally not part of the production root export:

import { MockTransport } from '@eternl/hub-core/testing';

Crypto

The crypto module exposes both low-level primitives (generateKeyPair, deriveSharedSecret, deriveSessionKey, encrypt, decrypt, deriveSAS6, …) and a stateful session wrapper created via a factory.

SessionEncryption is not a constructor. It is a small object exposing { fromRaw } (an alias for createSessionEncryptionFromRaw). The handshake-driven instance is created with the createSessionEncryption(pairingId, pin) factory:

import { createSessionEncryption } from '@eternl/hub-core';
import { bytesToHex } from '@eternl/hub-core'; // also on '@eternl/hub-core/utils'

// Each side creates an encryption context bound to the shared pairingId + PIN.
const enc = createSessionEncryption(pairingId, pin);

// 1. Generate this side's ephemeral key pair; returns the raw public key bytes.
const myPublicKey = await enc.initialize();
const myPublicKeyHex = bytesToHex(myPublicKey); // send this to the peer
// (equivalently: enc.getPublicKeyHex())

// 2. After receiving the peer's public key (hex), complete the handshake.
//    completeHandshake takes a SINGLE argument: the peer public key as hex.
await enc.completeHandshake(peerPublicKeyHex);

// 3. Both sides display the 6-digit SAS for out-of-band confirmation.
const sas6 = enc.getSAS6(); // throws if called before completeHandshake

// 4. Encrypt. encrypt() returns { data, seq } — `seq` is a per-direction
//    monotonic counter that must travel alongside the ciphertext on the wire.
const { data, seq } = await enc.encrypt(new TextEncoder().encode('hello'));

// 5. The receiver decrypts using the same seq it read off the wire.
const plaintext = await enc.decrypt(data, seq);

// When finished, zero out the in-memory key material.
enc.dispose();

Key points (verified against src/crypto/index.ts):

  • createSessionEncryption(pairingId, pin)pin may be null only when the instance is rebuilt from persisted state (see below); a fresh handshake requires a non-null PIN.
  • initialize() returns Promise<Uint8Array> (the raw public key); getPublicKeyHex() returns the same key as hex.
  • completeHandshake(peerPublicKeyHex) takes exactly one argument.
  • encrypt(plaintext) resolves to { data: Uint8Array; seq: bigint } and requires the handshake to have completed; calling it earlier throws.
  • decrypt(data, seq) requires the seq that was sent with the ciphertext.
  • getSAS6() throws until completeHandshake has run.

Rebuilding from persisted state

To restore an encryption context without re-running the handshake (e.g. after reload), use SessionEncryption.fromRaw / createSessionEncryptionFromRaw:

import { SessionEncryption } from '@eternl/hub-core';

const enc = await SessionEncryption.fromRaw({
  pairingId,
  sessionKeyRaw, // 32-byte AES key
  pinAadBytes,   // PIN-hash AAD bytes captured during the original handshake
});
// `enc` can encrypt/decrypt immediately, but cannot run completeHandshake again.

Session

createPairSession is the symmetric protocol layer. It composes a SessionEncryptionApi plus a Transport, owns the wire envelope (an 8-byte big-endian seq prefix followed by the AES-GCM blob), publishes/subscribes on hub.pair.v2.<pairId>, runs the heartbeat, auto-replies to presence pings, and validates replay/protocol-version on every inbound frame.

import {
  createSessionEncryption,
  createPairSession,
  NatsTransport,
} from '@eternl/hub-core';

const transport = new NatsTransport();
await transport.connect('wss://ws.hub.de-5.eternl.art'); // or DEFAULT_HUB_URL

const encryption = createSessionEncryption(pairId, pin);
await encryption.initialize();
// ... exchange public keys and call encryption.completeHandshake(peerPublicKeyHex) ...

const session = createPairSession({
  pairId,
  encryption,
  transport,
  enableHeartbeat: true, // default
});

// Listen for decrypted, validated inbound messages.
const off = session.on('message', ({ message, type }) => {
  // handle role-specific protocol messages (api-call/api-response, etc.)
});

// Send any protocol message object; it is stamped, encoded, encrypted, and published.
await session.send(someProtocolMessage);

// Active presence probe — resolves true if the peer pongs within the timeout.
const online = await session.requestPresenceCheck();

// Cleanup (also available via `using` / Symbol.dispose).
off();
session.dispose();

createPairSession also emits named events for observability and lifecycle reactions: presencePing, presencePong, heartbeatReceived, peerPresence, peerDisconnect, peerPairingRemoved, and error. Role layers (the dApp and wallet SDKs) build on top of the 'message' event.

Transport

import { NatsTransport } from '@eternl/hub-core';

const transport = new NatsTransport();
await transport.connect('wss://ws.hub.de-5.eternl.art'); // or DEFAULT_HUB_URL

const sub = transport.subscribe('hub.pair.v2.<pairId>', (data) => {
  // data is a Uint8Array
});

await transport.publish('hub.pair.v2.<pairId>', encodedBytes);

sub.unsubscribe();
await transport.disconnect();

connect(url, opts?) accepts a per-connection authenticator. Against the production supercluster (deny-by-default ACLs) you must supply one — either a static authenticator or, for long-lived sessions, a getAuthenticator provider that re-mints credentials on each reconnect. MockTransport (imported from @eternl/hub-core/testing) implements the same Transport interface for tests and records published messages on publishedMessages.

Messages

import {
  MessageType,
  createApiCall,
  isApiResponse,
  encodeMessage,
  decodeMessage,
  generateNonce,
} from '@eternl/hub-core';

const decoded = decodeMessage(bytes);
if (isApiResponse(decoded)) {
  // handle the response
}

The module exports typed message interfaces, the MessageType value map, is… type guards, and create… factories (createConnectRequest, createApiCall, createHeartbeat, createPresencePing, etc.). The create… factories take a nonce generator; in this package nonces are produced by generateNonce(sessionId).

| Message type | Description | |--------------|-------------| | CONNECT_REQUEST / CONNECT_RESPONSE | DApp requests connection; wallet approves/rejects | | API_CALL / API_RESPONSE | DApp calls a CIP-30/CIP-95 method; wallet returns the result | | ACCESS_GRANTED / ACCESS_DENIED | Per-request access decisions | | ACCOUNT_SWITCH / ACCOUNT_CHANGED | Account change request / notification | | DISCONNECT / PAIRING_REMOVED | Temporary disconnect / permanent unpair | | HEARTBEAT | Keep-alive | | PRESENCE / PRESENCE_PING / PRESENCE_PONG | Presence status and round-trip probe |

Types

import type { AccountInfo, NetworkType } from '@eternl/hub-core';
import { Permission } from '@eternl/hub-core';

const network: NetworkType = 'mainnet';

const permissions = [
  Permission.ADDRESS_READ, // 'address_read'
  Permission.SIGN_TX,      // 'sign_tx'
];

Permission is an enum covering CIP-30 core methods (NETWORK_ID, ADDRESS_READ, SIGN_TX, SUBMIT_TX, …) and CIP-95 governance (GOVERNANCE, DREP_KEY, STAKE_KEYS).

Errors

import { EternlConnectError, ErrorCode } from '@eternl/hub-core';

try {
  // ... operation
} catch (error) {
  if (error instanceof EternlConnectError) {
    console.log(error.code);        // ErrorCode enum value
    console.log(error.recoverable); // boolean — whether a retry may succeed
    console.log(error.cause);       // original error, if wrapping one
  }
}

EternlConnectError is the single error type used throughout the package. It carries an ErrorCode (e.g. TIMEOUT, DECRYPTION_FAILED, REPLAY_DETECTED, HUB_UNAVAILABLE), a recoverable flag, and an optional cause. Static helpers (EternlConnectError.timeout, .decryptionFailed, .replayDetected, …) build the common cases.

Constants

import {
  PROTOCOL_VERSION,
  DEFAULT_HUB_URL,
  SESSION_EXPIRY_MS,
  RECONNECTION,
  NONCE_WINDOW_MS,
  CHANNELS, // CHANNELS.PAIR_V2(pairId) === `hub.pair.v2.${pairId}`
} from '@eternl/hub-core';

DEFAULT_HUB_URL is the Eternl reference NATS WebSocket endpoint, currently wss://ws.hub.de-5.eternl.art. Point it at your own hub by passing a different URL to transport.connect(url).

Security model

The package's central security property is MITM-resistant pairing between two devices that share a short PIN. The design (see src/crypto/index.ts) combines four mechanisms:

  1. ECDH P-256 + AES-256-GCM. Each side generates an ephemeral key pair, exchanges public keys, and derives a shared secret via ECDH. Frames are sealed with AES-256-GCM (authenticated encryption).

  2. PIN-bound key derivation. The session key is derived as HKDF-SHA256(sharedSecret, salt = SHA-256(utf8(pin) || pairingId), info = "eternl-hub-v2"). Binding the salt to the PIN means an attacker who substitutes the public keys but does not know the PIN cannot derive the same session key, even with full knowledge of the shared secret they negotiated.

  3. SAS6 (Short Authentication String). A 6-digit code is derived from the shared secret + the PIN-bound salt under a distinct HKDF info label. Both peers display it and the user confirms a match out-of-band. If a MITM substituted one or both public keys, each side derives a different shared secret, so the two SAS6 codes disagree and the user aborts before any signing traffic flows.

  4. Per-frame AAD + replay/nonce protection. Every AEAD frame's additional authenticated data is utf8(pairingId) || SHA-256(pin)[:8] || be64(seq). A peer using a different PIN or pairingId fails AES-GCM authentication on every frame. A sliding-window nonce validator (createNonceValidator, window = NONCE_WINDOW_MS) rejects replayed or stale messages, and createPairSession rejects frames whose protocol version does not match PROTOCOL_VERSION.

seq is a per-direction monotonic counter carried in both the AAD and the wire envelope. Note that strict ordering enforcement is deferred to the SDK layerhub-core validates the nonce window and that the AAD seq matches the ciphertext, but does not itself reject out-of-order delivery beyond replay detection.

License

Business Source License 1.1 (BUSL-1.1). See LICENSE.

The Licensor is Tastenkunst GmbH. Additional Use Grant: None — you may copy, modify, and make non-production use of this package. Production/commercial use requires a separate license from Tastenkunst. On the Change Date (the fourth anniversary of each version's first publication) that version converts to GPL-2.0-or-later.

Contact: https://tastenkunst.com/