secure-channel-sdk
v3.0.0
Published
Secure End-to-End Encryption SDK using AES-GCM and HKDF
Maintainers
Readme
secure-channel-sdk
A production-ready SDK for client-side end-to-end authenticated encryption built entirely on the Web Crypto API. Works in browsers and Node.js without native dependencies.
Features
- AES-GCM 256-bit — authenticated encryption with tamper detection
- PBKDF2-SHA256 (600 000 iterations) — password hardening per OWASP guidance
- HKDF per-message key derivation — unique encryption key for every message
- Context binding (AAD) — ciphertext is cryptographically bound to
method,path,userId; swapping context fails decryption - Replay protection — sequence number + timestamp sliding window blocks replayed packets
- Deterministic mode (AES-GCM-DET) — reproducible ciphertext for auditable channels
- Rate-limiting & brute-force lockout — exponential backoff + 30 s lockout after 5 failed decryption attempts
- Non-extractable keys — raw key material never leaves the WebCrypto heap
- Universal — browsers (Web Crypto API) and Node.js 18+
Installation
npm install secure-channel-sdkQuick Start
Password-based encryption (PasswordChannel)
Best for user-facing scenarios: encrypting data with a passphrase before sending it to a server.
import { PasswordChannel } from 'secure-channel-sdk';
const context = { method: 'POST', path: '/api/notes', userId: 'user_42' };
// Encrypt
const pkg = await PasswordChannel.encrypt('correct-horse-battery-staple', 'top secret payload', context);
// pkg is a plain object — store it or POST it as JSON
// Decrypt (same password + same context required)
const plaintext = await PasswordChannel.decrypt('correct-horse-battery-staple', pkg, context);
console.log(plaintext); // 'top secret payload'If the password is wrong, the context differs, or any byte of pkg is tampered with, decrypt throws.
Session-based encryption (SecureChannelSession)
Best for high-throughput channels: both sides share a raw key, messages carry sequence numbers and timestamps.
import { SecureChannelSession } from 'secure-channel-sdk';
const sharedKey = crypto.getRandomValues(new Uint8Array(32)); // 256-bit master key
const ctx = { method: 'POST', path: '/api/data', userId: 'user_42' };
// Sender
const sender = await SecureChannelSession.init(sharedKey);
const pkg = await sender.encrypt('hello world', ctx);
// Receiver — reconstruct from the same key and the session salt carried in pkg
const receiver = await SecureChannelSession.fromExisting(sharedKey, base64ToBytes(pkg.saltB64));
const bytes = await receiver.decrypt(pkg, ctx);
console.log(new TextDecoder().decode(bytes)); // 'hello world'Replaying the same pkg a second time throws Replay detected.
API Reference
PasswordChannel
All methods are static.
encrypt(password, data, context?, options?): Promise<PasswordEncryptResult>
| Parameter | Type | Description |
| ---------- | ---------------------- | ------------------------------------- |
| password | string | Non-empty passphrase |
| data | string | Plaintext to encrypt |
| context | Partial<HttpContext> | Optional — method, path, userId |
| options | SecureChannelOptions | Optional — algorithm, key length |
Returns a PasswordEncryptResult object (JSON-serialisable). All fields are base64 strings.
decrypt(password, pkg, context?, options?): Promise<string>
Validates input, enforces rate limiting, derives the key, and decrypts. Throws on any failure.
SecureChannelSession
SecureChannelSession.init(masterKey, options?): Promise<SecureChannelSession>
Creates a new session with a fresh random salt. masterKey may be Uint8Array (raw bytes) or a non-extractable CryptoKey with deriveKey usage.
SecureChannelSession.fromExisting(masterKey, salt, options?): Promise<SecureChannelSession>
Reconstructs a session from an existing salt (e.g. received from the sender).
session.encrypt(plaintext, httpCtx): Promise<EncryptResult>
| Parameter | Type | Notes |
| ----------- | ---------------------- | -------------------------------------------------------- |
| plaintext | string \| Uint8Array | Data to encrypt |
| httpCtx | HttpContext | method, path, userId (optional ts, apiVersion) |
session.decrypt(pkg, httpCtx): Promise<Uint8Array>
Validates fields, checks timestamps, checks replay window, then decrypts. Returns raw bytes.
SecureChannelOptions
interface SecureChannelOptions {
algo?: 'AES-GCM' | 'AES-GCM-DET'; // default: 'AES-GCM'
keyLengthBits?: 128 | 192 | 256; // default: 256
initialSeq?: number; // default: 1
replayWindowSize?: number; // default: 1024
maxAgeSeconds?: number; // default: 60
useAdaptiveConfig?: boolean; // run benchmark and pick optimal config
}EncryptResult / PasswordEncryptResult
interface EncryptResult {
version: number; // protocol version (currently 1)
ctB64: string; // ciphertext (base64)
tagB64: string; // AES-GCM auth tag, 16 bytes (base64)
ivB64: string; // initialisation vector, 12 bytes (base64)
saltB64: string; // HKDF session salt (base64)
seq: number; // sequence number
algo: 'AES-GCM' | 'AES-GCM-DET';
ts: number; // Unix timestamp ms at encrypt time
keyLengthBits: 128 | 192 | 256;
}
interface PasswordEncryptResult extends EncryptResult {
passwordSaltB64: string; // PBKDF2 salt (base64)
}Security Architecture
Key derivation chain
Password
│
▼ PBKDF2-SHA256 (600 000 iterations, 16-byte random salt)
Master Key (HKDF, non-extractable)
│
├─▶ HKDF(salt=sessionSalt, info="enc|{seq}") ──▶ AES-GCM message key
└─▶ HKDF(salt=sessionSalt, info="iv_gen") ──▶ HMAC-SHA256 IV key (DET mode only)Each message gets a unique AES-GCM key derived from the master key, the session salt, and the sequence number. Compromising one message key exposes only that message.
Context binding (AAD)
Every ciphertext is bound to:
["<apiVersion>", "<METHOD>", "<path>", "<userId>", "<ts>", "<seq>"]Serialised as JSON and passed to AES-GCM as Additional Authenticated Data. Changing any field makes AES-GCM authentication fail — the ciphertext cannot be transplanted to a different endpoint or user.
Replay protection
SecureChannelSession maintains a sliding window of replayWindowSize sequence numbers (default 1 024). Any seq seen before, or outside the window, is rejected. Messages older than maxAgeSeconds (default 60 s) or more than 60 s in the future are also rejected.
Client-side rate limiting (PasswordChannel)
This is a usability defence, not a cryptographic guarantee. A motivated attacker with direct WebCrypto access can bypass it. Pair it with server-side rate limiting in production.
- Exponential backoff: 500 ms → 1 s → 2 s → … after each failed attempt
- Hard lockout for 30 s after 5 failures
- Lockout timestamp persisted in
sessionStorageand synced to in-memory state - All
decryptcalls serialised through an async mutex — parallel brute-force within one JS context is blocked
Adaptive Configuration
import { CryptoBenchmark, SecureChannelSession } from 'secure-channel-sdk';
// Measure AES-GCM throughput and pick the optimal key length
const session = await SecureChannelSession.init(key, { useAdaptiveConfig: true });CryptoBenchmark.run() encrypts 64 KB × 5 with AES-GCM-256 and falls back to 128-bit only if throughput is below 50 MB/s and 128-bit is measurably faster.
Browser & Node.js Compatibility
| Environment | Minimum version |
| ------------- | ---------------------------------- |
| Chrome / Edge | 37+ |
| Firefox | 34+ |
| Safari | 11+ |
| Node.js | 18+ (globalThis.crypto built-in) |
Development
npm install
npm test # vitest run
npm run build # tsc → dist/See CONTRIBUTING.md for contribution guidelines and SECURITY.md for the vulnerability disclosure policy.
License
ISC © 2024 Mykola Dzoban
