@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 buildUsage
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— usesnode: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 afterclose().no_keypair—completeRegistration()called without a precedinggenerateKeyPair().registration_incomplete—decryptWithPrivateKey()called beforecompleteRegistration().record_already_exists— secondcompleteRegistration()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 viaUserKeyStore.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(), anddecryptWithPrivateKey()exports are removed from the browser build. UseUserKeyStoreinstead. (The Node.js build is unchanged — those functions still exist there.) - The v1 IndexedDB database
agent-chatham-keysis 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. KeyPairResultis still exported but now also describesUserKeyStore.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_DIRenv var, the~/.agent_chatham/default directory, and the internalloadPrivateKeyPEMpath-resolution helper. Also removed:NODE_KEY_DIRandNODE_KEY_FILEfromCONSTANTS. - Removed: the
hasKeyPair()Node export. The file-existence probe no longer belongs in a crypto library; callers (@agentchatham/sdk'sIdentityStore) 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 returnednullwhen 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 testThe 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_closederror 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.
