@eternl/hub-core
v0.31.3
Published
Shared infrastructure for Eternl Hub SDKs.
Keywords
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-coreRequirements
- A runtime with the Web Crypto API (
globalThis.crypto.subtle): browsers, or Node.js ≥ 18. The crypto module callsassertCryptoCapabilities()and throws on environments without it. - ESM or CJS consumers are both supported (dual
import/requirebuilds 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.
- Session —
createPairSession, the symmetric protocol layer that owns the wire envelope, encrypt/decrypt, heartbeats, presence ping/pong, and replay validation for one pairing. - Transport —
NatsTransport(NATS WebSocket) behind a smallTransportinterface for dependency injection. AMockTransporttest double is available from the@eternl/hub-core/testingsubpath. - 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.
- Lifecycle —
LifecycleDispatcher, 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
EternlConnectErrorclass, 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
MockTransportlive under the dedicated@eternl/hub-core/testingsubpath 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)—pinmay benullonly when the instance is rebuilt from persisted state (see below); a fresh handshake requires a non-null PIN.initialize()returnsPromise<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 theseqthat was sent with the ciphertext.getSAS6()throws untilcompleteHandshakehas 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:
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).
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.SAS6 (Short Authentication String). A 6-digit code is derived from the shared secret + the PIN-bound salt under a distinct HKDF
infolabel. 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.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, andcreatePairSessionrejects frames whose protocol version does not matchPROTOCOL_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 layer — hub-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/
