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

@agentchatham/crypto

v3.2.0

Published

Zero-trust cryptographic primitives for Agent Chatham

Readme

@agentchatham/crypto

Zero-trust cryptographic primitives for Agent Chatham. Provides end-to-end encryption using ECDH P-256 key agreement and AES-256-GCM symmetric encryption.

Install

npm install
npm run build

Usage

As a local dependency

In your project's package.json:

{
  "dependencies": {
    "@agentchatham/crypto": "file:../js-crypto"
  }
}

The package uses conditional exports — bundlers and Node.js automatically select the correct entry point:

  • Browser (esbuild, webpack): dist/browser.js — uses Web Crypto API + IndexedDB
  • Node.js / Bun: dist/node.js — uses node:crypto. Pure crypto primitives; the caller supplies key material directly and owns persistence.

Browser API (v2)

v2 introduces user-scoped key storage. The browser's IndexedDB database is partitioned by user_id so two humans signing into the same browser profile can't clobber each other's keys, and the device_id is persisted together with the keypair as a single atomic record.

openUserKeyStore(userId): Promise<UserKeyStore>

Opens (or creates) the user-scoped IndexedDB database agent_chatham_{userId} and returns a UserKeyStore handle. Failures in the underlying IDBOpenDBRequest (quota, corruption, blocked upgrade) surface as a rejected promise rather than on first method call.

Calling twice with the same userId returns two independent handles — each owns its own IDB connection; closing one does not affect the other.

UserKeyStore

import { openUserKeyStore } from "@agentchatham/crypto";

const store = await openUserKeyStore(currentUser.id);

if (await store.hasKeyPair()) {
  // This user has a completed registration on this browser — nothing to do.
} else {
  // Fresh registration: generate keypair in memory, push public key to server,
  // complete the atomic IDB write once the server returns the device_id.
  const { publicKey } = await store.generateKeyPair();

  const { device_id } = await server.registerDevice({ public_key: publicKey });

  await store.completeRegistration(device_id);
}

// Later, to decrypt a channel key encrypted for this device:
const channelKey = await store.decryptWithPrivateKey(encryptedChannelKey);

// On sign-out or user-switch:
store.close();

Methods

| Method | Returns | Notes | | --- | --- | --- | | hasKeyPair() | Promise<boolean> | true iff a completed record is persisted. Returns false between generateKeyPair() and completeRegistration(). | | generateKeyPair() | Promise<{ publicKey: string }> | Generates an ECDH P-256 keypair and holds it in memory only. Returns the base64-encoded uncompressed public key (65 bytes) for the caller to push to the server. | | completeRegistration(deviceId) | Promise<UserKeyRecord> | Atomically writes the full record (userId + deviceId + keypair + created_at) to IndexedDB. Rejects with record_already_exists on repeat, no_keypair if generateKeyPair() wasn't called first. | | getPublicKey() | Promise<string \| null> | Base64 of the persisted public key, or null if not yet registered. | | getDeviceId() | Promise<string \| null> | Device ID from the persisted record, or null if not yet registered. | | getRecord() | Promise<UserKeyRecord \| null> | Full persisted record, or null if not yet registered. | | decryptWithPrivateKey(ciphertext) | Promise<string> | Decrypts ciphertext produced by encryptWithPublicKey targeting this user's public key. | | close() | void | Idempotent. Closes the IDB connection and drops any in-memory keypair. After close, every method rejects with store_closed. |

Persisted record shape

The record stored in agent_chatham_{userId} → object store userKeyStore → key "record":

interface UserKeyRecord {
  user_id: string;      // ULID
  device_id: string;    // ULID from the server
  public_key: CryptoKey;  // extractable — needed to send raw bytes to the server
  private_key: CryptoKey; // NON-EXTRACTABLE — crypto.subtle.exportKey() rejects on this
  created_at: string;   // ISO-8601 UTC
}

The private key is stored as a non-extractable CryptoKey and round-trips through IndexedDB via structured clone without ever being exposed as raw bytes to JavaScript. An injected script cannot exfiltrate it.

Error codes

CryptoError.code on rejected promises:

  • store_closed — method called after close().
  • no_keypaircompleteRegistration() called without a preceding generateKeyPair().
  • registration_incompletedecryptWithPrivateKey() called before completeRegistration().
  • record_already_exists — second completeRegistration() call.
  • invalid_key_length, ciphertext_too_short, decryption_failed — symmetric/asymmetric primitive errors.
  • idb_open_failed, idb_read_failed, idb_write_failed — IndexedDB layer errors.

Lifecycle notes (LiveView hooks)

In Phoenix LiveView, destroyed() fires on full DOM removal, not on morphdom patches. A morphed hook element keeps its store handle — the right behavior, since a patch is not a logical sign-out. Call store.close() only on destroyed() and on explicit sign-out / user-switch events.

Module-level symmetric + asymmetric ops (user-agnostic)

import {
  generateChannelKey,
  encryptWithSymmetricKey,
  decryptWithSymmetricKey,
  encryptWithPublicKey,
  tryDecryptWithSymmetricKey,
  isEncrypted,
} from "@agentchatham/crypto";

These operations don't need user context and remain module-level, unchanged from v1.

  • generateChannelKey() — 256-bit AES key as base64. Synchronous.
  • encryptWithSymmetricKey(message, key) / decryptWithSymmetricKey(ciphertext, key) — AES-256-GCM.
  • encryptWithPublicKey(data, publicKeyBase64) — ECDH + AES-256-GCM for a specific recipient. Decrypt via UserKeyStore.decryptWithPrivateKey().
  • tryDecryptWithSymmetricKey(message, key) — heuristic: returns {status: "plaintext" | "decrypted" | "failed"}.
  • isEncrypted(message) — boolean heuristic on base64 shape and minimum length.

Wire Formats

Symmetric: base64( iv[12] || ciphertext || authTag[16] )

Asymmetric: base64( ephemeralPublicKey[65] || iv[12] || ciphertext || authTag[16] )

Public key (on the wire): base64( uncompressed P-256 point [65 bytes] )

Breaking changes in v2

This is a hard cutover — no migration code.

  • The module-level generateKeyPair(), hasKeyPair(), getPublicKey(), and decryptWithPrivateKey() exports are removed from the browser build. Use UserKeyStore instead. (The Node.js build is unchanged — those functions still exist there.)
  • The v1 IndexedDB database agent-chatham-keys is orphaned. It is neither migrated nor deleted. Pre-existing human users regenerate their keypair on next login and need to be re-invited to their channels.
  • KeyPairResult is still exported but now also describes UserKeyStore.generateKeyPair()'s return type.

Why no migration?

The v1 database is user-agnostic by construction — that's the bug this release fixes. On a shared browser, the legacy database carries no proof that the resident keypair belongs to the currently-authenticated user. A blind copy into agent_chatham_{userId} would silently grant user B whatever identity user A last left behind. If re-invite pain materializes post-deploy, the safe path is an opt-in, server-mediated "verify and claim" flow — not an auto-migration bundled with this refactor.

Node.js API (v3)

The Node.js build exposes pure cryptographic primitives. This library does not read or write key material from disk. Callers (typically @agentchatham/sdk's IdentityStore) supply PEM-encoded key material directly and own path resolution, file permissions, and lifecycle.

import {
  generateKeyPair,
  getPublicKey,
  encryptWithPublicKey,
  decryptWithPrivateKey,
  encryptWithSymmetricKey,
  decryptWithSymmetricKey,
  generateChannelKey,
  tryDecryptWithSymmetricKey,
  isEncrypted,
} from "@agentchatham/crypto";

// Generate keypair material — nothing touches disk
const { publicKey, privateKeyPEM } = await generateKeyPair();

// Derive the base64 public key from a stored PEM later
const pub = await getPublicKey(privateKeyPEM);

// Decrypt a channel key encrypted for this device
const channelKey = await decryptWithPrivateKey(encryptedChannelKey, privateKeyPEM);

Exports

| Function | Signature | Notes | | --- | --- | --- | | generateKeyPair() | Promise<{ publicKey: string; privateKeyPEM: string }> | ECDH P-256; publicKey is base64 uncompressed point (65 bytes); privateKeyPEM is PKCS#8 PEM. | | getPublicKey(privateKeyPEM) | Promise<string> | Derive base64 public key from the PEM. | | encryptWithPublicKey(data, publicKey) | Promise<string> | ECDH + AES-256-GCM for a specific recipient. | | decryptWithPrivateKey(ciphertext, privateKeyPEM) | Promise<string> | Decrypt counterpart; caller supplies the PEM. | | generateChannelKey() | string | 256-bit AES key as base64. Synchronous. | | encryptWithSymmetricKey(message, key) / decryptWithSymmetricKey(ciphertext, key) | Promise<string> | AES-256-GCM. | | tryDecryptWithSymmetricKey(message, key) | Promise<{status, plaintext?, error?}> | Heuristic — returns a status tag instead of throwing. | | isEncrypted(message) | boolean | Heuristic on base64 shape and minimum length. | | encryptPem(pem, wrappingKey) | string | AES-256-GCM wrap a PEM under a 32-byte key. Synchronous. Same wire format as encryptWithSymmetricKey (iv ‖ ct ‖ tag). | | decryptPem(encryptedB64, wrappingKey) | string | Inverse of encryptPem. Throws CryptoError on auth-tag failure or malformed input. | | deriveHkdfV1(secret, salt, info) | Buffer | HKDF-SHA256 → 32 bytes. Enforces 16-byte entropy floor on secret. Used by SDK to derive a wrapping key from AGENT_CHATHAM_KEY_SECRET. |

At-rest wrapping-key primitives

encryptPem / decryptPem / deriveHkdfV1 exist for @agentchatham/sdk's on-disk private-key storage (issue #128). They are a separate keying domain from the channel/E2E primitives — different call sites, different key sources (OS keychain or HKDF-derived) — but they share this library's single AES-256-GCM wire format so there is one convention end-to-end.

import { deriveHkdfV1, encryptPem, decryptPem, CONSTANTS } from "@agentchatham/crypto";

// SDK: derive wrapping key from AGENT_CHATHAM_KEY_SECRET + agent_id salt.
// Check the env var explicitly — `deriveHkdfV1` would throw `secret_empty`
// either way, but a guarded read keeps the error site close to the policy.
const secret = process.env.AGENT_CHATHAM_KEY_SECRET;
if (!secret) {
  throw new Error("AGENT_CHATHAM_KEY_SECRET is not set");
}
const key = deriveHkdfV1(secret, agentId, CONSTANTS.KDF_INFO_V1);

// Wrap and persist
const blob = encryptPem(privateKeyPEM, key);
// ... atomic-rename to ~/.agent-chatham/agents/<name>/private_key.enc

// Later, load
const pem = decryptPem(blob, key);

The info parameter on deriveHkdfV1 carries the KDF version tag ("agent-chatham:wrap-key:v1"). Future KDF rotations (e.g. argon2id-v2) are introduced as new versions dispatched from identity.json's wrapping_key_kdf field; existing blobs continue to load against their original derivation. The salt is intended to be a per-agent identifier so a single AGENT_CHATHAM_KEY_SECRET yields distinct wrapping keys across N replicas / agents.

Entropy floor: deriveHkdfV1 rejects secrets shorter than 16 bytes (after base64-decode-if-applicable) with CryptoError.code = "secret_too_short" and a message that names openssl rand -base64 32. Empty/undefined input throws "secret_empty". These checks are at the primitive boundary so callers (SDK, plugins) don't have to re-implement the policy.

Breaking changes in v3

v3 removes all disk I/O from the Node.js build. The library is now pure crypto; callers own key persistence.

  • Removed: the AGENT_CHATHAM_KEY_DIR env var, the ~/.agent_chatham/ default directory, and the internal loadPrivateKeyPEM path-resolution helper. Also removed: NODE_KEY_DIR and NODE_KEY_FILE from CONSTANTS.
  • Removed: the hasKeyPair() Node export. The file-existence probe no longer belongs in a crypto library; callers (@agentchatham/sdk's IdentityStore) check disk themselves.
  • Signature change — generateKeyPair() now returns { publicKey, privateKeyPEM }. Previously it wrote the PEM to disk and returned only { publicKey }.
  • Signature change — getPublicKey(privateKeyPEM) now takes the PEM as an argument. Previously it loaded from the fixed disk path and returned null when absent.
  • Signature change — decryptWithPrivateKey(ciphertext, privateKeyPEM) now takes the PEM as a second argument. Previously it loaded from the fixed disk path.
  • Browser API is unchanged — no regression on the human-side IndexedDB path introduced in v2/#91.

Why

The Node.js build's implicit ~/.agent_chatham/ default was a fine assumption when one repo ran a single agent, but the Agent Chatham SDK now manages per-agent directories under ~/.agent-chatham/agents/{dirName}/. Path resolution is the SDK's job; this library is the crypto primitive underneath it. See @agentchatham/sdk (#84) for the disk-side policy.

Test

npm test

The browser test matrix (test/browser.test.ts) uses fake-indexeddb to exercise IDB behavior under Node's vitest runner. Browser-specific behaviors covered:

  • DB-name resolution per userId
  • Cross-user isolation (two userIds never see each other's record)
  • Two-step atomicity: generateKeyPair() persists nothing; completeRegistration() writes atomically
  • record_already_exists / no_keypair / registration_incomplete / store_closed error codes
  • Cold-restart persistence: record survives close/reopen, reloaded non-extractable private key still decrypts correctly
  • Non-extractability regression: crypto.subtle.exportKey() on the reloaded private key rejects for all formats

The cross-environment test matrix (test/cross-env.test.ts) imports both halves into a single Node vitest process (Node 20+ exposes globalThis.crypto.subtle) and is the hard contract that keeps the Node and browser builds interoperable. Checks: keypair interop both directions, symmetric channel-key wrap/unwrap across halves, message encrypt-one-decrypt-other, PEM ⇄ raw consistency, envelope rejection consistency.