@push.rocks/smartcrypto

Cross-runtime cryptographic primitives for Node.js and browsers, including strict X25519 envelopes, AES-256-GCM authenticated encryption, DEK wrapping, SHA-256 hashing, RSA keys, and self-signed X.509 certificates, all on the platform's WebCrypto without third-party runtime dependencies.

Issue Reporting and Security

For reporting bugs, issues, or security vulnerabilities, please visit community.foss.global/. This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a code.foss.global/ account to submit Pull Requests directly.

Install

pnpm add @push.rocks/smartcrypto

X25519 Envelope Profile

The fixed x25519-hkdf-sha256-aes-256-gcm-v1 profile seals a Uint8Array to one recipient. It uses a fresh ephemeral X25519 key and random 12-byte AES-GCM nonce for every seal. The exact context bytes are mandatory AAD and are also bound into HKDF through their SHA-256 digest.

import {
  generateX25519KeyPair,
  openX25519Envelope,
  sealX25519Envelope,
} from '@push.rocks/smartcrypto';

const encoder = new TextEncoder();
const recipient = await generateX25519KeyPair();
const context = encoder.encode('tenant:example/secret:database-password');

const envelope = await sealX25519Envelope({
  plaintext: encoder.encode('secret value'),
  recipientPublicKey: recipient.publicKey,
  recipientKeyId: 'recipient-key-2026-01',
  context,
});

const plaintext = await openX25519Envelope({
  envelope,
  recipientPrivateKey: recipient.privateKey,
  expectedRecipientKeyId: 'recipient-key-2026-01',
  context,
});

generateX25519KeyPair() returns strict raw 32-byte public and private material. importX25519PublicKey(), exportX25519PublicKey(), importX25519PrivateKey(), and exportX25519PrivateKey() provide checked conversion to and from standard CryptoKey objects.

The envelope has exactly these fields:

interface IX25519EnvelopeV1 {
  schemaVersion: 1;
  profile: 'x25519-hkdf-sha256-aes-256-gcm-v1';
  recipientKeyId: string;
  ephemeralPublicKey: string;
  nonce: string;
  ciphertext: string;
  tag: string;
  contextDigest: string;
}

All binary envelope fields use canonical unpadded base64url. parseX25519Envelope() rejects extra fields, unknown versions and profiles, noncanonical encodings, incorrect fixed lengths, malformed key IDs, and ciphertexts larger than SMARTCRYPTO_MAX_CIPHERTEXT_BYTES.

AES-GCM And DEK Wrapping

aesGcmEncrypt() and aesGcmDecrypt() accept only Uint8Array data and exact 32-byte AES keys. AAD is always required, although an explicitly supplied empty Uint8Array is valid. Encryption returns separate nonce, ciphertext, and 16-byte tag fields.

import {
  aesGcmDecrypt,
  aesGcmEncrypt,
  generateDek,
  generateAes256GcmKey,
  rewrapDek,
  unwrapDek,
  wrapDek,
} from '@push.rocks/smartcrypto';

const payloadAad = new TextEncoder().encode('payload binding');
const dek = generateDek();
const encryptedPayload = await aesGcmEncrypt({
  key: dek,
  plaintext: new TextEncoder().encode('payload'),
  aad: payloadAad,
});

const oldKek = generateAes256GcmKey();
const newKek = generateAes256GcmKey();
const oldWrapAad = new TextEncoder().encode('old KEK binding');
const newWrapAad = new TextEncoder().encode('new KEK binding');
const wrappedDek = await wrapDek({ dek, kek: oldKek, wrapAad: oldWrapAad });

const rewrappedDek = await rewrapDek({
  wrappedDek,
  currentKek: oldKek,
  currentWrapAad: oldWrapAad,
  newKek,
  newWrapAad,
});

const unwrappedDek = await unwrapDek({
  wrappedDek: rewrappedDek,
  kek: newKek,
  wrapAad: newWrapAad,
});
const plaintext = await aesGcmDecrypt({
  key: unwrappedDek,
  encryptedData: encryptedPayload,
  aad: payloadAad,
});

rewrapDek() only receives the wrapped DEK, so payload ciphertext, nonce, and tag remain unchanged during KEK rotation.

Hashing

All digests are lowercase hexadecimal strings. Strings are hashed as their UTF-8 bytes.

The package entry works in every runtime. sha256FromString() and sha256FromBuffer() use WebCrypto; sha256FromStringSync(), createSha256Hasher() and sha256FromStream() use a built-in incremental SHA-256 and therefore also work without WebCrypto, for example on insecure browser origins.

import {
  createSha256Hasher,
  sha256FromBuffer,
  sha256FromStream,
  sha256FromString,
  sha256FromStringSync,
} from '@push.rocks/smartcrypto';

const digest = await sha256FromString('Hello, world!');
const digestSync = sha256FromStringSync('Hello, world!');
const bufferDigest = await sha256FromBuffer(new Uint8Array([0xca, 0xfe]));

const hasher = createSha256Hasher();
hasher.update(new Uint8Array([0xca, 0xfe])).update(new Uint8Array([0xba, 0xbe]));
const incrementalDigest = hasher.digest();

const response = await fetch('/artifact');
const streamDigest = await sha256FromStream(response.body!);

createSha256Hasher() returns an ISha256Hasher. update() accepts ArrayBuffer or Uint8Array values and returns the hasher for chaining; digest() finalizes it, and any later update() or digest() call fails with INVALID_STATE. sha256FromStream() reads a ReadableStream<Uint8Array> chunk by chunk with bounded memory and always releases the reader lock; a non-byte chunk cancels the stream and fails with INVALID_ARGUMENT, and a source error is rethrown unchanged.

Hashing Entry

@push.rocks/smartcrypto/hash exports exactly the package-entry hashing functions and ISha256Hasher, and its module graph contains no third-party code. Use it in browser code that reaches the hashing functions through a re-exported namespace, such as a plugins.ts import hub:

// plugins.ts
import * as smartcrypto from '@push.rocks/smartcrypto/hash';
export { smartcrypto };

// elsewhere
import * as plugins from './plugins.js';
const digest = plugins.smartcrypto.createSha256Hasher().update(bytes).digest();

esbuild does not tree-shake a namespace that is re-exported and then read through two property accesses (plugins.smartcrypto.createSha256Hasher); it keeps every export of the imported module. Through the package entry that includes the RSA, X25519 and AES-GCM code; through /hash the same pattern stays a few kilobytes. Named imports from the package entry are tree-shaken either way.

Node.js Entry

@push.rocks/smartcrypto/node exports the same hashing functions backed by node:crypto, plus file and MD5 hashing. It runs in Node.js and Deno.

import {
  createSha256Hasher,
  md5FromString,
  sha256FromFile,
  sha256FromStream,
  sha256FromStringSync,
} from '@push.rocks/smartcrypto/node';
import { createReadStream } from 'node:fs';

const fileDigest = await sha256FromFile('./artifact.tgz');
const streamDigest = await sha256FromStream(createReadStream('./artifact.tgz'));
const checksum = await md5FromString('legacy payload');

sha256FromStream() here accepts a Node.js Readable or a WHATWG ReadableStream<Uint8Array>. Node.js readables may emit bytes or strings (hashed as UTF-8); any other chunk fails with INVALID_ARGUMENT and destroys the stream. sha256FromFile() streams the file, so file-system errors such as ENOENT are rethrown unchanged. MD5 is cryptographically broken; use md5FromString() only for checksums that an external protocol requires.

Function @push.rocks/smartcrypto and /hash @push.rocks/smartcrypto/node
sha256FromString(string) WebCrypto node:crypto
sha256FromStringSync(string) built-in SHA-256 node:crypto
sha256FromBuffer(ArrayBuffer | Uint8Array) WebCrypto node:crypto
createSha256Hasher() built-in SHA-256 node:crypto
sha256FromStream(stream) ReadableStream<Uint8Array> Node.js Readable or ReadableStream<Uint8Array>
sha256FromFile(path) - yes
md5FromString(string) - yes

Errors

Cryptographic and validation failures use SmartCryptoError with a stable TSmartCryptoErrorCode. Messages, JSON output, Node inspection, stack metadata, and cause do not retain plaintext, keys, shared secrets, or ciphertext details.

RSA APIs

Smartcrypto, KeyPair, PrivateKey, and PublicKey implement RSA on the platform: WebCrypto (globalThis.crypto.subtle) for key generation, signing and verification, and a strict built-in DER, PEM and OpenSSH encoder for the key formats. They run in Node.js and browsers without third-party code.

import { Smartcrypto, PublicKey } from '@push.rocks/smartcrypto';

const smartCrypto = new Smartcrypto();
const keyPair = await smartCrypto.createKeyPair();
const publicKeyPem = keyPair.publicKey.toPemString();
const privateKeyPem = keyPair.privateKey.toPemString();

const data = new TextEncoder().encode('signing input');
const signature = await keyPair.privateKey.createRs256Signature(data);
const publicKey = PublicKey.fromCanonicalRsaPublicPemString(publicKeyPem);
const isValid = await publicKey.verifyRs256Signature(data, signature);

createKeyPair({ modulusLength }) accepts 2048 (default), 3072, or 4096 and uses the public exponent 65537. modulusLength on either key returns the modulus size in bits.

Operation Format
PublicKey.toPemString() SPKI -----BEGIN PUBLIC KEY-----
PrivateKey.toPemString() unencrypted PKCS#1 -----BEGIN RSA PRIVATE KEY-----
PublicKey.fromPemString(), PublicKey.createFromPrivateKey() SPKI or PKCS#1 public key
PrivateKey.fromPemString(), PrivateKey.createFromPrivateKey() PKCS#1 RSA PRIVATE KEY or PKCS#8 PRIVATE KEY
PublicKey.toOpenSSH() ssh-rsa <base64> user@host, as ssh-keygen -y writes it
PrivateKey.toOpenSSH() unencrypted -----BEGIN OPENSSH PRIVATE KEY----- (openssh-key-v1) with the comment user@host, as ssh-keygen writes it

PEM output uses 64-column lines and CRLF line endings. PEM input is read from the first PEM block in the string; its label must match its content, its base64 must be canonical (no non-zero unused bits), and blocks with RFC 1421 headers (legacy Proc-Type: 4,ENCRYPTED keys) are rejected. Parse failures throw SmartCryptoError with INVALID_KEY. new PublicKey(components) and new PrivateKey(components) take the RSA integers as unsigned big-endian bytes (IRsaPublicKeyComponents, IRsaPrivateKeyComponents).

PublicKey.fromCanonicalRsaPublicPemString() parses exactly one canonical SPKI or PKCS#1 RSA public key with a modulus of at least 2048 bits. PublicKey.verifyRs256Signature() verifies RSASSA-PKCS1-v1_5/SHA-256 signatures with WebCrypto, which compares the complete encoded message: a signature whose DigestInfo names another digest algorithm or carries extra elements (as in GHSA-86w9-cpqp-85rv) resolves to false, as do malformed input and a signature of the wrong length. PrivateKey.createRs256Signature() signs with the same scheme (JWS RS256).

Passphrase-Encrypted Private Keys

PrivateKey.toEncryptedPemString(passphrase) writes a PKCS#8 -----BEGIN ENCRYPTED PRIVATE KEY----- PEM using PBES2 with PBKDF2-HMAC-SHA256 (600,000 iterations, 16-byte salt) and AES-256-CBC. PrivateKey.fromEncryptedPemString(pem, passphrase) reads PBES2 keys with PBKDF2 (HMAC-SHA1/256/384/512) and AES-128/192/256-CBC, which covers keys written by Node.js and OpenSSL. Both run on WebCrypto and are asynchronous.

import { Smartcrypto, PrivateKey } from '@push.rocks/smartcrypto';

const keyPair = await new Smartcrypto().createKeyPair({ modulusLength: 4096 });
const encryptedPem = await keyPair.privateKey.toEncryptedPemString('my passphrase');
const privateKey = await PrivateKey.fromEncryptedPemString(encryptedPem, 'my passphrase');

Passphrases are asymmetric between export and import: toEncryptedPemString('') fails with INVALID_ARGUMENT, while fromEncryptedPemString(pem, '') accepts an empty passphrase so that keys stored by @push.rocks/smartkey (encrypted under an empty passphrase by default) stay readable. A non-string passphrase fails with INVALID_ARGUMENT, a wrong passphrase with AUTHENTICATION_FAILED, a malformed or non-RSA key with INVALID_KEY, and other encryption schemes with UNSUPPORTED_PROFILE.

Self-Signed X.509 Certificates

createSelfSignedCertificate() creates a self-signed X.509 v3 end-entity certificate and its key with WebCrypto and the built-in DER encoder, for TLS servers that need a certificate without a CA: test servers, development servers and fallback certificates.

import * as https from 'node:https';
import { createSelfSignedCertificate } from '@push.rocks/smartcrypto';

const { certificatePem, privateKeyPem } = await createSelfSignedCertificate({
  commonName: 'localhost',
  dnsNames: ['localhost'],
  ipAddresses: ['127.0.0.1', '::1'],
});

const server = https.createServer({ cert: certificatePem, key: privateKeyPem });
// A client trusts it explicitly: https.get({ ..., ca: certificatePem })
Option Default Meaning
commonName required subject and issuer CN, 1 to 64 characters without control characters
dnsNames none dNSName subject alternative names (printable ASCII, so IDNs go in A-label form; wildcards such as *.example.com are written as given)
ipAddresses none iPAddress subject alternative names: IPv4 dotted quads, or IPv6 groups with at most one :: (no zone index, no embedded IPv4)
notBefore now start of validity, truncated to whole seconds
notAfter notBefore + 365 days end of validity, truncated to whole seconds; must be later than notBefore
keyAlgorithm 'RSA-2048' 'RSA-2048' (sha256WithRSAEncryption) or 'ECDSA-P256' (ecdsa-with-SHA256)

The certificate has a random positive 16-byte serial number, CN=<commonName> as subject and issuer, and the extensions basicConstraints CA:FALSE and keyUsage (both critical; digitalSignature, plus keyEncipherment for RSA), extendedKeyUsage serverAuth, and subjectAltName when names are given. Validity dates through 2049 are UTCTime, later ones GeneralizedTime (RFC 5280). It passes openssl verify -x509_strict -purpose sslserver with itself as the trust anchor. certificatePem is a CERTIFICATE PEM and privateKeyPem an unencrypted PKCS#8 PRIVATE KEY PEM. Invalid options fail with INVALID_ARGUMENT, WebCrypto failures with CRYPTO_OPERATION_FAILED. Certificate parsing, chains and CA-issued certificates are out of scope.

Runtime Support

The WebCrypto APIs, including the RSA APIs and encrypted private-key PEMs, and the package-entry hashing functions are tested with Node.js 22 and current Chromium. Browsers require a secure context for WebCrypto. The @push.rocks/smartcrypto/node entry is tested with Node.js 22 and Deno.

The package has no runtime dependencies. It declares "sideEffects": false, and no module does work at import time. Browser bundlers such as esbuild, Rollup, Rolldown and webpack therefore keep only what a bundle imports: a bundle that uses only the hashing functions stays a few kilobytes.

Migration to 3.0.0

3.0.0 removes node-forge (GHSA-86w9-cpqp-85rv has no patched release). The RSA classes keep their PEM, encrypted-PEM and OpenSSH methods; these changes need action:

2.x 3.0.0
publicKey.verifyRs256(data, signature) (synchronous boolean) await publicKey.verifyRs256Signature(data, signature) (Promise<boolean>). The method was renamed so that an un-awaited call fails to compile instead of testing an always-truthy promise
publicKey.forgePublicKey, privateKey.forgePrivateKey removed; use the PEM methods, modulusLength, createRs256Signature() and verifyRs256Signature()
privateKey.forgePrivateKey.sign(md) await privateKey.createRs256Signature(data) for RS256
key.forge*Key.n.bitLength() key.modulusLength
new PublicKey(forgeKey), new PrivateKey(forgeKey) PublicKey.fromPemString(pem), PrivateKey.fromPemString(pem), or the constructors with IRsaPublicKeyComponents / IRsaPrivateKeyComponents
import { nodeForge } from '@push.rocks/smartcrypto' removed; for self-signed certificates see the X.509 note below
privateKey.toOpenSSH() returned a legacy PEM encrypted with the passphrase user@host returns an unencrypted openssh-key-v1 key with the comment user@host; PublicKey.toOpenSSH() is unchanged
fromPemString() / createFromPrivateKey() threw a plain Error throw SmartCryptoError with INVALID_KEY
PEM label and content could disagree (forge read either key format under PRIVATE KEY or RSA PRIVATE KEY) the label must match the content: RSA PRIVATE KEY is PKCS#1, PRIVATE KEY is PKCS#8, PUBLIC KEY is SPKI, RSA PUBLIC KEY is PKCS#1; a mismatch fails with INVALID_KEY
PEM blocks with RFC 1421 headers were read when unencrypted any header block (Proc-Type, DEK-Info, ...) fails with INVALID_KEY; convert legacy encrypted keys to PKCS#8 (openssl pkcs8 -topk8) and use PrivateKey.fromEncryptedPemString()
base64 with non-zero unused bits in the final character was accepted rejected with INVALID_KEY, so a key has exactly one PEM encoding

PEM output is byte-identical to 2.x for the same key.

X.509. 2.x had no X.509 API beyond re-exporting nodeForge. Code that generated self-signed certificates through smartcrypto.nodeForge, such as @git.zone/tstest, @push.rocks/smartproxy and test fixtures in social.io, moves to createSelfSignedCertificate() (since 3.1.0; see Self-Signed X.509 Certificates). The private key PEM is PKCS#8 (PRIVATE KEY) instead of forge's PKCS#1 (RSA PRIVATE KEY); Node.js TLS accepts both. Certificate parsing still needs Node.js X509Certificate or a dedicated library.

Migration from @push.rocks/smartkey

@push.rocks/smartkey is deprecated; RSA key pairs and passphrase-encrypted private keys live in this package.

@push.rocks/smartkey @push.rocks/smartcrypto
new SmartKey().getKeypair(passphrase) await new Smartcrypto().createKeyPair({ modulusLength: 4096 })
keyPair.publicKey (SPKI PEM string) keyPair.publicKey.toPemString()
keyPair.privateKey (encrypted PKCS#8 PEM string) await keyPair.privateKey.toEncryptedPemString(passphrase)
keyPair.privateKey from getKeypair() without a passphrase (an encrypted PKCS#8 PEM with an empty passphrase) read it with await PrivateKey.fromEncryptedPemString(pem, ''), then re-export with await privateKey.toEncryptedPemString(passphrase) using a real passphrase, or privateKey.toPemString() (unencrypted PKCS#1)
reading the encrypted PEM with crypto.createPrivateKey({ key, passphrase }) await PrivateKey.fromEncryptedPemString(pem, passphrase) (Node.js can still read the PEM directly)

Migration from @push.rocks/smarthash

@push.rocks/smarthash is deprecated; its hashing functions live in this package with the same names and byte-identical digests. Node.js code imports from @push.rocks/smartcrypto/node, browser code (formerly @push.rocks/smarthash/web) from @push.rocks/smartcrypto, or from @push.rocks/smartcrypto/hash when it re-exports the import as a namespace (see Hashing Entry).

@push.rocks/smarthash @push.rocks/smartcrypto
sha256FromString (. or ./web) sha256FromString from /node (Node.js) or the package entry (browser)
sha256FromStringSync (. or ./web) sha256FromStringSync from /node or the package entry
sha256FromBuffer (. or ./web) sha256FromBuffer from /node or the package entry
createSha256Hasher, ISha256Hasher (. or ./web) createSha256Hasher, ISha256Hasher from /node or the package entry
sha256FromStream(stream) (.) sha256FromStream from /node; it takes a Node.js Readable or a WHATWG ReadableStream<Uint8Array>. A legacy non-Readable Stream must be wrapped first with new Readable().wrap(stream)
sha256FromStream(readableStream) (./web) sha256FromStream from the package entry
sha256FromFile (.) sha256FromFile from /node
md5FromString (.) md5FromString from /node
sha256FromFile, md5FromString (./web, which always threw) not available in browsers; call them from Node.js through /node
sha265FromObject(object) removed (misspelled and unused); sha256FromString(smartjson.stringify(value)) with @push.rocks/smartjson reproduces its key-order-independent digest

Behavior changes: invalid input and use of a finalized hasher fail with SmartCryptoError (INVALID_ARGUMENT, INVALID_STATE) instead of a plain Error. In the package entry, sha256FromString() and sha256FromBuffer() require WebCrypto and fail with CRYPTO_OPERATION_FAILED where it is missing (smarthash fell back to JavaScript); use sha256FromStringSync() or createSha256Hasher() there.

This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the license file.

Please note: The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.

Trademarks

This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.

Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.

Company Information

Task Venture Capital GmbH
Registered at District Court Bremen HRB 35230 HB, Germany

For any legal inquiries or further information, please contact us via email at hello@task.vc.

By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.

S
Description
A library providing easy methods for cryptographic operations, including key pair generation.
Readme
1.1 MiB
Languages
TypeScript 100%