Skip to content

Crypt Sign

GitHub Actions edited this page Sep 18, 2026 · 1 revision

Crypt-Sign

HMAC, RSA (PSS / PKCS#1 v1.5) and ECDSA digital signatures using the Web Crypto API.

Deno Bun Node.js Cloudflare Workers Browser

Overview

Digital signature functions — HMAC, RSA (PSS / PKCS#1 v1.5), ECDSA and Ed25519 — for message authentication and integrity verification.

Features

Feature Bun Deno Node.js Workers Browser
HMAC-SHA-256 ✅ ✅ ✅ ✅ ✅
HMAC-SHA-512 ✅ ✅ ✅ ✅ ✅
RSA-PSS ✅ ✅ ✅ ✅ ✅
RSA-PKCS#1 v1.5 ✅ ✅ ✅ ✅ ✅
ECDSA (P-256/384/521) ✅ ✅ ✅ ✅ ✅
Ed25519 (EdDSA) ✅ ✅ ✅ ✅ ✅
Binary data ✅ ✅ ✅ ✅ ✅
CryptoKey / JWK in ✅ ✅ ✅ ✅ ✅

Key input

Every function accepts a SigningKey — the same three forms everywhere:

Form Use
string PEM-armoured asymmetric key, or a raw secret for HMAC
CryptoKey An already-imported Web Crypto key, used as-is
JsonWebKey A JWK, e.g. an entry straight out of a provider's JWKS document

A supplied CryptoKey or JWK is validated against the operation, not trusted: family, curve, hash, public/private type and usages must all permit what is being asked, and a JWK's own alg, use and key_ops must not contradict it. See Security notes.

Installation

Deno:

deno add @tundralibs/crypt

Bun:

bunx jsr add @tundralibs/crypt

Node.js:

npx jsr add @tundralibs/crypt

API Reference

signHMAC()

Creates an HMAC signature.

async function signHMAC(
  data: string | Uint8Array,
  secret: SigningKey,
  options?: HMACOptions,
): Promise<string>;

Example:

import { signHMAC } from '@tundralibs/crypt/sign';

const signature = await signHMAC('my data', 'secret-key');
console.log(signature); // hex string

verifyHMAC()

Verifies an HMAC signature.

async function verifyHMAC(
  data: string | Uint8Array,
  signature: string,
  secret: SigningKey,
  options?: HMACOptions,
): Promise<boolean>;

Example:

import { verifyHMAC } from '@tundralibs/crypt/sign';

declare const signature: string;

const isValid = await verifyHMAC('my data', signature, 'secret-key');
console.log(isValid); // true or false

signRSA()

Creates an RSA-PSS signature.

async function signRSA(
  data: string | Uint8Array,
  privateKey: SigningKey,
  options?: RSAOptions,
): Promise<string>;

Example:

import { signRSA } from '@tundralibs/crypt/sign';

const privateKey = `-----BEGIN PRIVATE KEY-----...`;
const signature = await signRSA('my data', privateKey);

verifyRSA()

Verifies an RSA-PSS signature.

async function verifyRSA(
  data: string | Uint8Array,
  signature: string,
  publicKey: SigningKey,
  options?: RSAOptions,
): Promise<boolean>;

Example:

import { verifyRSA } from '@tundralibs/crypt/sign';

declare const signature: string;

const publicKey = `-----BEGIN PUBLIC KEY-----...`;
const isValid = await verifyRSA('my data', signature, publicKey);

signEC()

Creates an ECDSA signature on a NIST P-curve.

async function signEC(
  data: string | Uint8Array,
  privateKey: SigningKey,
  options?: ECOptions,
): Promise<string>;

The returned base64 decodes to the raw R‖S concatenation required by RFC 7515 §3.4 — never ASN.1/DER. See Signature encoding.

Curve and hash both default to whatever the key commits to: the curve is read from the key material, and the hash follows the RFC 7518 pairing for that curve. options.curve pins the expectation rather than selecting one — a key on a different curve is rejected, not coerced.

Example:

import { signEC } from '@tundralibs/crypt/sign';

const privateKey = `-----BEGIN PRIVATE KEY-----...`;
const signature = await signEC('my data', privateKey);

// Pin the curve — a key on any other curve is refused
const pinned = await signEC('my data', privateKey, { curve: 'P-256' });

verifyEC()

Verifies an ECDSA signature.

async function verifyEC(
  data: string | Uint8Array,
  signature: string,
  publicKey: SigningKey,
  options?: ECOptions,
): Promise<boolean>;

Example:

import { verifyEC } from '@tundralibs/crypt/sign';

declare const signature: string;

const publicKey = `-----BEGIN PUBLIC KEY-----...`;
const isValid = await verifyEC('my data', signature, publicKey);

signEd25519()

Signs data using Ed25519 (EdDSA, RFC 8032). Nothing to configure — the curve, the digest and the 64-byte signature width are fixed by the algorithm, and signing is deterministic (no per-signature nonce to mismanage). Accepts a PKCS#8 PEM, an Ed25519 CryptoKey, or a private OKP JWK.

async function signEd25519(
  data: string | Uint8Array,
  privateKey: SigningKey,
): Promise<string>; // base64 of the 64-byte signature

verifyEd25519()

Verifies a signEd25519 signature. A key of the wrong family is refused (thrown) rather than reported as false; a malformed or wrong-width signature returns false.

async function verifyEd25519(
  data: string | Uint8Array,
  signature: string,
  publicKey: SigningKey,
): Promise<boolean>;

Example:

import { signEd25519, verifyEd25519 } from '@tundralibs/crypt/sign';
import { generateEd25519Keys } from '@tundralibs/crypt/generators';

const { privateKey, publicKey } = await generateEd25519Keys();
const signature = await signEd25519('important document', privateKey);
const valid = await verifyEd25519('important document', signature, publicKey);
console.log(valid); // true

ecdsaDerToRaw()

Converts an ASN.1/DER ECDSA signature — SEQUENCE { INTEGER r, INTEGER s }, as emitted by OpenSSL and by every WebAuthn/FIDO2 authenticator's ES256 assertion — into the base64 R‖S form verifyEC accepts. It is a pure re-encoding of the same (r, s) pair: it runs no cryptography, so a signature that converts cleanly is not thereby valid — verifyEC remains the check.

function ecdsaDerToRaw(der: Uint8Array, curve: ECCurve): string;

Example — verifying a WebAuthn assertion:

import { ecdsaDerToRaw, verifyEC } from '@tundralibs/crypt/sign';

// `signature` is the authenticator's DER assertion; `signedData` is
// authenticatorData ‖ SHA-256(clientDataJSON); `publicKey` is the stored
// credential key.
declare const signature: Uint8Array;
declare const signedData: Uint8Array;
declare const publicKey: JsonWebKey;

const ok = await verifyEC(
  signedData,
  ecdsaDerToRaw(signature, 'P-256'),
  publicKey,
);

Signature encoding: R‖S, not DER

ECDSA signatures have two incompatible encodings in common use:

Encoding Shape Used by
Raw R‖S Fixed width, bare JOSE / JWS (RFC 7515 §3.4), Web Crypto
ASN.1 / DER SEQUENCE { INTEGER r, INTEGER s } OpenSSL, most non-web tooling

signEC emits only R‖S and verifyEC accepts only R‖S; a DER signature returns false rather than verifying. Convert at the boundary with ecdsaDerToRaw() when bridging DER-based tooling such as OpenSSL or a WebAuthn authenticator.

Widths are fixed by the curve:

Curve JOSE alg Hash R‖S bytes
P-256 ES256 SHA-256 64
P-384 ES384 SHA-384 96
P-521 ES512 SHA-512 132

Note the last row: ES512 uses P-521, not a nonexistent "P-512". The algorithm is named for its hash, the curve for its field size — and 521 bits rounds up to 66 bytes per half, hence 132 rather than 128.

Supported key formats

Format Sign Verify Notes
PEM PRIVATE KEY (PKCS#8) ✅ — Includes Apple's .p8
PEM PUBLIC KEY (SPKI) — ✅
PEM EC PRIVATE KEY (SEC1) ✅ — Rewrapped as PKCS#8 automatically
CryptoKey ✅ ✅ Used as-is; may be non-extractable
JWK (JsonWebKey) ✅ ✅ EC, RSA, OKP and oct
Raw secret string ✅ ✅ HMAC only
PEM ENCRYPTED PRIVATE KEY ❌ ❌ Decrypt first — see below
PEM RSA PRIVATE KEY (PKCS#1) ❌ ❌ Not importable by Web Crypto
PEM RSA PUBLIC KEY (PKCS#1) ❌ ❌ Not importable by Web Crypto
X.509 CERTIFICATE ❌ ❌ Extract the public key first

Web Crypto imports only PKCS#8, SPKI, JWK and raw, so the unsupported rows are platform limits rather than choices. Convert with OpenSSL:

# Encrypted → plaintext PKCS#8
openssl pkcs8 -topk8 -nocrypt -in encrypted.pem -out key.pem

# PKCS#1 RSA → PKCS#8
openssl pkcs8 -topk8 -nocrypt -in pkcs1.pem -out pkcs8.pem

# Certificate → public key
openssl x509 -in cert.pem -pubkey -noout -out pub.pem

Examples

HMAC Message Authentication

import { signHMAC, verifyHMAC } from '@tundralibs/crypt/sign';

const secret = 'shared-secret-key';
const message = 'Important message';

// Sign
const signature = await signHMAC(message, secret);

// Verify
const isAuthentic = await verifyHMAC(message, signature, secret);
console.log(isAuthentic); // true

RSA Digital Signatures

import { signRSA, verifyRSA } from '@tundralibs/crypt/sign';
import { generateRSAKeyPair } from '@tundralibs/crypt/generators';

// Generate key pair (PEM-exported). The key size lives in the key itself —
// signRSA/verifyRSA take no size option.
const keys = await generateRSAKeyPair({
  algorithm: 'RSA-PSS',
  keySize: 2048,
  hashAlgorithm: 'SHA-256',
  format: 'PEM',
});
const publicKey = keys.publicKeyExported as string;
const privateKey = keys.privateKeyExported as string;

// Sign with private key (RSA-PSS by default; pass { scheme: 'PKCS1' } for
// RSASSA-PKCS1-v1_5)
const signature = await signRSA('document', privateKey);

// Verify with public key
const isValid = await verifyRSA('document', signature, publicKey);

ECDSA Digital Signatures

import { signEC, verifyEC } from '@tundralibs/crypt/sign';
import { generateECKeyPair } from '@tundralibs/crypt/generators';

const keys = await generateECKeyPair({
  algorithm: 'ECDSA',
  curve: 'P-256',
  format: 'PEM',
});

// Curve and hash come from the key (P-256 → SHA-256)
const signature = await signEC('document', keys.privateKeyExported as string);
const isValid = await verifyEC(
  'document',
  signature,
  keys.publicKeyExported as string,
);

Verifying with a JWK from a JWKS endpoint

import { verifyEC } from '@tundralibs/crypt/sign';

declare const jwksUri: string;
declare const kid: string;
declare const signature: string;

const { keys } = await (await fetch(jwksUri)).json() as {
  keys: (JsonWebKey & { kid?: string })[];
};
const jwk = keys.find((k) => k.kid === kid)!;

// No PEM conversion: the JWK is used directly, and its own alg/use/key_ops
// are checked against the operation before any signature is examined.
const isValid = await verifyEC('payload', signature, jwk, { curve: 'P-256' });

Security Notes

  • HMAC requires a shared secret
  • RSA requires minimum 2048-bit keys
  • Use SHA-256 or higher hash algorithms
  • Never expose private keys

Key validation

Accepting a CryptoKey or JWK means the key's metadata is no longer implied by the code path, so it is checked rather than trusted. A key is refused when:

  • its family does not match the operation (an EC key offered to verifyRSA, a public key offered as an HMAC secret);
  • its curve is not the pinned one — verifying an ES256 signature with a P-384 key fails as a key error, distinguishable from a bad signature;
  • its hash disagrees, for the RSA and HMAC keys that bind one at import;
  • it is the wrong type for the job — a private key handed to a verifier, or a public key asked to sign;
  • its usages do not include the operation;
  • a JWK's declared alg, use, key_ops, kty or crv contradicts the operation (RFC 7517 §4.1–4.4).

ECDSA specifics

  • Each ES* algorithm binds exactly one curve. Never verify a signature with a key on a different curve, and prefer pinning options.curve when the expected curve is known.
  • Only R‖S is accepted. A DER signature returns false — this is deliberate, so one signature cannot have two valid spellings.
  • ECDSA requires a unique random nonce per signature; the Web Crypto implementation handles this. Two signatures over the same data will differ, which is expected, not a bug.

← Back to Crypt

Clone this wiki locally