@eternl/hub-core

中文文档
0.55.0发布于 2026/11/10BUSL-1.1TypeScriptESM + CJS零依赖近 30 天下载 2619

Shared infrastructure for Eternl Hub SDKs.

原始使用文档
文档目录14 节
  1. Installation
  2. Requirements
  3. Overview
  4. Imports and subpath exports
  5. Crypto
  6. Rebuilding from persisted state
  7. Session
  8. Transport
  9. Messages
  10. Types
  11. Errors
  12. Constants
  13. Security model
  14. License

@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.
  • 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 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.
  • 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 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 typeDescription
CONNECT_REQUEST / CONNECT_RESPONSEDApp requests connection; wallet approves/rejects
API_CALL / API_RESPONSEDApp calls a CIP-30/CIP-95 method; wallet returns the result
ACCESS_GRANTED / ACCESS_DENIEDPer-request access decisions
ACCOUNT_SWITCH / ACCOUNT_CHANGEDAccount change request / notification
DISCONNECT / PAIRING_REMOVEDTemporary disconnect / permanent unpair
HEARTBEATKeep-alive
PRESENCE / PRESENCE_PING / PRESENCE_PONGPresence 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 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/

版本与发布记录

@eternl/hub-core 共发布 66 个版本,首次发布于 2026/05/08;过去 12 个月发布了 66 个版本。

版本发布日期距今
0.55.0latest2026/11/103 天前
0.54.02026/11/103 天前
0.53.02026/11/103 天前
0.52.02026/11/103 天前
0.51.02026/11/104 天前
0.50.02026/11/094 天前

关于 @eternl/hub-core 的常见问题

@eternl/hub-core 是什么?

Shared infrastructure for Eternl Hub SDKs.

如何安装 @eternl/hub-core?

在项目目录运行 npm install @eternl/hub-core,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:@nats-io/nats-core(3.4.0)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@eternl/hub-core 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@eternl/hub-core 支持 ESM 还是 CommonJS?

同时提供 ES Module 与 CommonJS 入口,import 和 require() 都可以使用,打包工具会自动选择合适的版本。

@eternl/hub-core 可以商用吗?

@eternl/hub-core 使用 BUSL-1.1 许可。声明的许可为 BUSL-1.1,请阅读许可原文确认使用条件。以上为概括,不构成法律意见,请以许可原文为准。

@eternl/hub-core 的下载量有多少?

根据 npm 官方下载统计,@eternl/hub-core 在 2026/10/14 – 2026/11/12 这 30 天内被下载 2,619 次,日均约 87 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@eternl/hub-core 还在维护吗?

最新版本 0.55.0 发布于 2026/11/10(3 天前);至今共发布 66 个版本,其中过去 12 个月发布了 66 个;首次发布于 2026/05/08。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。