@agentchatham/crypto
v3.2.1
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)— classifies content structurally, then decrypts. Content that parses as a JSON message envelope (a JSON object with a stringkind) returns{status: "plaintext", plaintext}unchanged: it was not encrypted with the key, so treat it as untrusted. Anything else is ciphertext:{status: "decrypted", plaintext}on success,{status: "failed", error}otherwise — including content that is not valid base64. Failed content is never handed back as readable text. Node and browser run the same check (since 3.2.1).isEncrypted(message)— boolean check on strict standard base64 and minimum length; a pure string check, so Node and browsers agree.
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?}> | Returns a status tag instead of throwing: plaintext for a JSON message envelope (untrusted), else decrypted or failed — also for content that is not ciphertext. |
| isEncrypted(message) | boolean | Strict standard base64 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.
Versioning
This repo has no develop branch: work branches off master and its PR targets
master. So unlike agent_chatham — where the version identifies a release and
feature PRs leave it alone — the bump belongs in the PR here.
package.json version, chosen by what the PR ships:
| The PR ships | Bump | |---|---| | a new feature, a new export, a new option | minor | | a fix, a refactor, a dependency bump, tests | patch | | a breaking change to the public API | major — propose it and get the author to confirm; never decide it alone |
A forgotten bump reddens master after the merge. Every push to master
runs npm publish, npm rejects a version it already has with E403, and by then
the merge has happened — the only fix is another commit. Check
npm view @agentchatham/crypto version against package.json before asking for a merge.
Docs-only PRs need no bump: CI and the publish workflow both ignore markdown, so nothing tries to republish.
