npm.io
1.0.1 • Published yesterday

@notifycode/hash-it

Licence
MIT
Version
1.0.1
Deps
0
Size
471 kB
Vulns
0
Weekly
0
Stars
4

@notifycode/hash-it

Password hashing and asymmetric token signing for Node.js and Bun — payment-network-style private/public key separation, real native cryptography, zero runtime dependencies.

Tests Coverage Node TypeScript Zero deps


Why this exists

hash-it is built around one specific, real idea: payment-network-style key separation. Systems like Mastercard's token service don't verify anything with a shared secret — a private key signs, and any number of services holding only the public key can verify a token without ever being able to forge one. No shared secret means no single point of compromise across every service that needs to check a token.

hash-it implements that architecture pattern faithfully:

┌─────────────────┐          ┌──────────────────────┐
│  Token Issuer   │          │  Token Verifier(s)   │
│  (private key   │  token   │  (public key only —  │
│   only)         │ ───────► │   can verify but     │
│                 │          │   cannot forge)       │
└─────────────────┘          └──────────────────────┘

The private key never leaves the issuer. Verification also checks that the actual key type (EC vs. RSA) matches the algorithm the token claims — it doesn't just trust the header.

What this genuinely is NOT (yet): that architecture pattern is necessary for payment-grade security, but it is not sufficient on its own. Mastercard's actual security also rests on tamper-resistant hardware that never lets a private key exist outside it, a formal PKI with certificate revocation, independent adversarial security audits, and compliance certification (PCI-DSS, FIPS 140). hash-it does not have those.

hash-it tokens are also not JWT. They're structurally similar — header.payload.signature, base64url-encoded JSON — because that's a sensible, well-understood shape. But the header's typ is "hash-it", not "JWT", and verification is not spec-compliant with JOSE/JWT. This is deliberate: a hash-it token will never be silently accepted by a generic JWT library, and a JWT will never be silently accepted by hash-it. If you need interoperability with jsonwebtoken, jose, Auth0, or any other JWT-consuming system, use a JWT library instead.


Features

Password hashing scrypt (default, real, memory-hard, native crypto), PBKDF2 (fallback), Argon2id (Bun-native only, opt-in)
Non-blocking hashit.password.hash/verify use the libuv threadpool (or Bun's worker thread for Argon2id) — safe to call from a live request handler
Asymmetric tokens ECDSA P-256/P-384/P-521, RSA-4096 (RS256/RS512/PS256) — hash-it's own format, not JWT
Key rotation Multi-key PublicKeySet — rotate without invalidating tokens issued under the previous key
Async key generation generateKeyPairAsync — RSA-4096 generation off the event loop
Encryption AES-256-GCM (default, AEAD), AES-256-CBC (Encrypt-then-MAC with independently derived keys), ChaCha20-Poly1305
Session management Access + refresh token pairs, with rotation
API tokens Opaque, prefixed tokens (myapp_.eyJ...) with embedded verifiable claims
Zero dependencies Node.js crypto only. Bun implements it compatibly — nothing extra to install there either
Timing-safe Constant-time comparison throughout password/token verification

Installation

npm install @notifycode/hash-it
# or
bun add @notifycode/hash-it
# or
pnpm add @notifycode/hash-it

Requires Node.js ≥ 18, or Bun (any recent version).


Quick Start

import { hashit } from '@notifycode/hash-it';

// 1. Hash a password — scrypt by default, non-blocking
const { hash } = await hashit.password.hash('user-password');
const { valid, needsRehash } = await hashit.password.verify('user-password', hash);

// 2. Generate a key pair
const keyPair = hashit.keys.generate();              // sync — fine for ES256, slow-ish for RSA
const rsaKeyPair = await hashit.keys.generateAsync({ algorithm: 'RS256' }); // non-blocking

// 3. Sign and verify a token (hash-it's own format — see note above on JWT)
const token = hashit.token.sign(
  { sub: 'user_123', role: 'admin' },
  { privateKey: keyPair.privateKey, expiresIn: '15m' }
);

const result = hashit.token.verify(token, { publicKey: keyPair.publicKey });

// 4. Session management
const session = hashit.session.create(keyPair, { sub: 'user_123' });

// 5. API tokens
const apiToken = hashit.apiToken.generate(keyPair, { prefix: 'myapp_', expiresIn: '90d' });

API Reference

All functionality is accessible via the hashit object:

import { hashit } from '@notifycode/hash-it';

hashit.password
.hash(password, options?)

Hash a password. Default algorithm is scrypt — real, native, memory-hard, zero dependencies on both Node.js and Bun. Non-blocking.

const result = await hashit.password.hash('my-password');
// result.hash      — e.g. '$hashit-scrypt$v1$N=65536,r=8,p=1$...'
// result.algorithm — 'scrypt'
// result.salt      — base64url-encoded salt
// result.timingMs  — milliseconds taken

Options:

Option Applies to Default Description
algorithm 'scrypt' 'scrypt' | 'pbkdf2' | 'argon2id' (Bun only)
costFactor scrypt 65536 CPU/memory cost (N) — must be a power of two
blockSize scrypt 8 Block size (r)
parallelization scrypt 1 Parallelization (p)
iterations pbkdf2 600000 PBKDF2 iteration count
digest pbkdf2 'sha512' PBKDF2 HMAC digest
memoryCost argon2id 19456 Memory cost in KiB (Bun only)
timeCost argon2id 2 Time cost / iterations (Bun only)
hashLength scrypt/pbkdf2 32 Output length in bytes
saltLength scrypt/pbkdf2 16 Salt length in bytes (min 12)
// Explicit fallback algorithm
const result = await hashit.password.hash('my-password', { algorithm: 'pbkdf2' });

// Real Argon2id — only works on Bun, throws a clear error otherwise
const result = await hashit.password.hash('my-password', { algorithm: 'argon2id' });
.verify(password, hash)

Verify a password. Non-blocking, constant-time. Handles scrypt, pbkdf2, and Bun-native argon2id hashes based on the hash's own prefix — you don't need to specify which algorithm was used.

const { valid, needsRehash, timingMs } = await hashit.password.verify('my-password', storedHash);

if (valid && needsRehash) {
  const { hash: newHash } = await hashit.password.hash('my-password');
  await db.updateUserHash(userId, newHash);
}

This never throws — including for a hash that requires Bun's native Argon2id when you're not running on Bun. In that case it fails closed (valid: false, needsRehash: true) rather than crashing whatever called it.

.needsRehash(hash, options?)

Check whether a stored hash was created with weaker-than-current parameters, or a deprecated algorithm.

if (hashit.password.needsRehash(storedHash)) {
  // rehash on next successful login
}

hashit.keys
.generate(options?)

Generate an asymmetric key pair. Synchronous — blocks the event loop, which matters for RSA-4096. Fine for CLIs, startup code, or offline key rotation; use .generateAsync() in a live server.

const keyPair = hashit.keys.generate();                          // ECDSA P-256 (default)
const keyPair2 = hashit.keys.generate({ algorithm: 'ES384' });
const rsaKeyPair = hashit.keys.generate({ algorithm: 'RS256' });  // RSA-4096
const namedKey = hashit.keys.generate({ algorithm: 'ES256', kid: 'key-2024-01' });

Supported algorithms: ES256, ES384, ES512, RS256, RS512, PS256

.generateAsync(options?)

Same output shape, but non-blocking — offloads to libuv's threadpool. Use this in a request handler or during live key rotation.

const keyPair = await hashit.keys.generateAsync({ algorithm: 'RS256' });
.exportPublic(keyPair)
const publicEntry = hashit.keys.exportPublic(keyPair);
// { kid, algorithm, publicKey, createdAt } — no private key included
.buildKeySet(entries[])
const keySet = hashit.keys.buildKeySet([
  hashit.keys.exportPublic(currentKey),
  hashit.keys.exportPublic(previousKey), // kept during rotation window
]);

hashit.token

hash-it's own signed-token format — not JWT, see the note at the top of this document.

.sign(payload, options)
const token = hashit.token.sign(
  { sub: 'user_123', role: 'admin', org: 'acme' },
  {
    privateKey: keyPair.privateKey,
    kid: keyPair.kid,           // key ID in header, needed for rotation
    algorithm: 'ES256',         // default
    expiresIn: '15m',           // '30s', '15m', '1h', '7d', '2w', or seconds
    issuer: 'auth-service',
    audience: 'api-service',
  }
);
.verify(token, options)

Verifies the signature, confirms the actual key type matches the declared algorithm, and validates claims. Never throws.

const result = hashit.token.verify(token, {
  publicKey: keyPair.publicKey,    // string, or a PublicKeySet for rotation
  issuer: 'auth-service',          // optional — checked if provided
  audience: 'api-service',         // optional — checked if provided
  algorithms: ['ES256', 'ES384'],  // optional — restrict allowed algorithms
  clockSkew: 30,                   // seconds of tolerance (default: 30)
});

if (result.valid) {
  console.log(result.payload?.sub, result.kid, result.algorithm);
} else {
  console.log(result.error);
}
.decode(token)

Decodes without verifying the signature. Never use this for authentication — debugging/metadata only.

const payload = hashit.token.decode(token); // UNSAFE — no signature check

hashit.session
.create(keyPair, options)
const session = hashit.session.create(keyPair, {
  sub: 'user_123',
  issuer: 'auth-service',
  accessExpiresIn: '15m',   // default
  refreshExpiresIn: '7d',   // default
  claims: { role: 'admin', org: 'acme' },
});
// session.accessToken / refreshToken / accessExpiresAt / refreshExpiresAt / tokenType ('Bearer')
.verify(token, publicKey, options?)
const { valid, payload } = hashit.session.verify(session.accessToken, keyPair.publicKey);
.rotate(refreshToken, keyPair, options)
const newSession = hashit.session.rotate(session.refreshToken, keyPair, { sub: 'user_123' });

hashit.apiToken

Opaque tokens with embedded, verifiable claims — similar in spirit to GitHub's ghp_ or Stripe's sk_ prefixes, but the payload is a real signed hash-it token underneath.

.generate(keyPair, options?)
const apiToken = hashit.apiToken.generate(keyPair, {
  prefix: 'myapp_',          // default: 'hsh_'. Any length — no 8-character limit.
  sub: 'org_123',
  expiresIn: '90d',          // or null for non-expiring
  claims: { scopes: ['read', 'write'] },
});

// apiToken.token   — 'myapp_.eyJhbGciOiJFUzI1NiJ9...'
// apiToken.prefix  — 'myapp_'
// apiToken.masked  — 'myapp_.****3a1b' — safe for logs
// apiToken.expiresAt — Unix timestamp or null

The . right after the prefix is a real delimiter, not a guess — any prefix length works.

.verify(token, publicKey)
const { valid, payload } = hashit.apiToken.verify(apiToken.token, keyPair.publicKey);
.mask(token)
hashit.apiToken.mask('hsh_.eyJhbGciOiJFUzI1NiJ9.abc123');
// → 'hsh_.****c123'

hashit.encrypt
.seal(plaintext, key, options?)
const sealed = hashit.encrypt.seal('sensitive-data', 'my-encryption-key');
// { ciphertext, iv, tag, algorithm: 'aes-256-gcm' }
Option Default Choices
algorithm aes-256-gcm aes-256-gcm, aes-256-cbc, chacha20-poly1305

CBC mode derives its encryption key and its HMAC integrity key independently from your input key — they are not the same secret reused twice.

.open(encrypted, key)
const plaintext = hashit.encrypt.open(sealed, 'my-encryption-key');

hashit.utils
hashit.utils.randomBytes(32);                 // CSPRNG random bytes, base64url
hashit.utils.safeEqual(a, b);                 // constant-time string comparison
hashit.utils.parseDuration('15m');            // → 900
hashit.utils.fingerprint('ua:chrome,ip:1.2.3.4'); // HMAC-based fingerprint for device/session binding

Key Rotation

// 1. Generate a new key pair
const newKey = hashit.keys.generate({ kid: 'key-2025-01' });

// 2. Build a key set with both old and new keys
const keySet = hashit.keys.buildKeySet([
  hashit.keys.exportPublic(newKey),
  hashit.keys.exportPublic(oldKey), // keeps validating tokens signed before rotation
]);

// 3. Sign new tokens with the new key
const token = hashit.token.sign(payload, { privateKey: newKey.privateKey, kid: newKey.kid });

// 4. Verify against the key set — works for tokens from either key
const result = hashit.token.verify(token, { publicKey: keySet });

// 5. Once the refresh window has passed, drop the old key from the set

Tree-Shakeable Named Exports

import {
  hashPassword, verifyPassword, hashPasswordAsync, verifyPasswordAsync,
  signToken, verifyToken,
  generateKeyPair, generateKeyPairAsync, buildKeySet,
  seal, open,
  createSession, rotateSession,
} from '@notifycode/hash-it';

Error Handling

Errors extend HashItError with a structured code:

import { HashItError, HashItErrorCode } from '@notifycode/hash-it';

try {
  const keyPair = await hashit.keys.generateAsync({ algorithm: 'RS256' });
} catch (err) {
  if (err instanceof HashItError) {
    console.log(err.code); // e.g. HashItErrorCode.INVALID_KEY
  }
}

Error codes: INVALID_KEY, INVALID_TOKEN, TOKEN_EXPIRED, TOKEN_NOT_YET_VALID, SIGNATURE_INVALID, ALGORITHM_MISMATCH, KEY_NOT_FOUND, AUDIENCE_MISMATCH, ISSUER_MISMATCH, ENCRYPT_FAILED, DECRYPT_FAILED, HASH_FAILED, INVALID_PARAMS, UNSUPPORTED_RUNTIME

UNSUPPORTED_RUNTIME is thrown only by hashit.password.hash(pw, { algorithm: 'argon2id' }) when called outside Bun — an explicit request for something unavailable gets a clear error, rather than a silent downgrade.


Testing

npm test              # run all 199 tests
npm run test:watch    # watch mode

Coverage: 97%+ statements/lines, 88%+ branches, 100% functions.


Security

See SECURITY.md for the responsible disclosure process, threat model, and cryptographic primitives in use. See CHANGELOG.md for what changed and why, release to release — including anywhere a past claim in this README turned out to be wrong.

Keywords