Digital Signatures
A digital signature proves a message came from the holder of a private key and was not modified. It is one-way: anyone with the public key verifies; only the key holder signs. Use it for JWTs, software updates, webhook authenticity — anywhere you need authenticity over an untrusted channel.
Ed25519, RSA-PSS, ECDSA, verify path
EXAMPLE
// SCENARIO — webhook signing and verification, defensive perspective.
// ─── 1) Ed25519 — modern default ───────────────────────────────
// Fast, small keys (32 bytes), deterministic, side-channel resistant.
import crypto from 'node:crypto';
const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
const msg = Buffer.from('order:12345:paid');
const sig = crypto.sign(null, msg, privateKey); // 64 bytes
const ok = crypto.verify(null, msg, publicKey, sig);
console.log(ok); // true
// Export / import
const pem = privateKey.export({ type: 'pkcs8', format: 'pem' });
const pub = publicKey.export({ type: 'spki', format: 'pem' });
// ─── 2) RSA-PSS — when you need RSA (interop, HSMs) ────────────
const { publicKey: rsaPub, privateKey: rsaPriv } = crypto.generateKeyPairSync('rsa', {
modulusLength: 3072, // 2048 minimum; 3072+ preferred
});
const sig2 = crypto.sign('sha256', msg, {
key: rsaPriv,
padding: crypto.constants.RSA_PKCS1_PSS_PADDING, // PSS, not v1.5
saltLength: crypto.constants.RSA_PSS_SALTLEN_DIGEST,
});
const ok2 = crypto.verify('sha256', msg, {
key: rsaPub,
padding: crypto.constants.RSA_PKCS1_PSS_PADDING,
saltLength: crypto.constants.RSA_PSS_SALTLEN_DIGEST,
}, sig2);
// Avoid RSA-PKCS#1 v1.5 for new code (still legal for some interop; PSS is safer).
// ─── 3) ECDSA (P-256) — short signatures, broad support ────────
const { publicKey: ecPub, privateKey: ecPriv } = crypto.generateKeyPairSync('ec', {
namedCurve: 'P-256',
});
const sig3 = crypto.sign('sha256', msg, ecPriv);
const ok3 = crypto.verify('sha256', msg, ecPub, sig3);
// Avoid: secp192r1 (too short), curves with weak parameters, ECDSA without
// guaranteed RNG quality (deterministic ECDSA / Ed25519 sidestep RNG failure).
// ─── 4) Signing a webhook payload ──────────────────────────────
function signPayload(body, privateKey, keyId) {
const ts = Math.floor(Date.now() / 1000);
const data = Buffer.from(`${ts}.${body}`);
const sig = crypto.sign(null, data, privateKey).toString('base64');
return { ts, sig, keyId };
}
// Send headers like:
// X-Signature-Timestamp: 1717000000
// X-Signature-KeyId: 2024-q3
// X-Signature: base64sig…
// ─── 5) Verifying — the critical path ──────────────────────────
const keyring = new Map([
['2024-q3', publicKey],
['2024-q4', publicKey], // rotation overlap
]);
function verifyWebhook(req) {
const ts = parseInt(req.get('X-Signature-Timestamp'), 10);
const keyId = req.get('X-Signature-KeyId');
const sigB64 = req.get('X-Signature');
if (!ts || !keyId || !sigB64) return false;
// 1. Reject stale timestamps (replay protection)
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - ts) > 300) return false; // 5 min window
// 2. Look up the public key for this key id
const pk = keyring.get(keyId);
if (!pk) return false;
// 3. Reconstruct the signed bytes
const data = Buffer.from(`${ts}.${req.rawBody.toString('utf8')}`);
// 4. Verify
try {
return crypto.verify(null, data, pk, Buffer.from(sigB64, 'base64'));
} catch {
return false;
}
}
// IMPORTANT: webhook bodies must be verified BEFORE JSON.parse runs.
// JSON re-serialization changes whitespace → signature mismatch.
// In Express:
// app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } }));
// ─── 6) JWT signatures — use a library ─────────────────────────
import * as jose from 'jose';
const jwt = await new jose.SignJWT({ sub: 'user_42', role: 'admin' })
.setProtectedHeader({ alg: 'EdDSA' })
.setIssuer('https://app.example.com')
.setAudience('api')
.setExpirationTime('15m')
.sign(privateKey);
const { payload } = await jose.jwtVerify(jwt, publicKey, {
issuer: 'https://app.example.com',
audience: 'api',
});
// Pitfalls:
// • alg=none in the header → reject
// • RSA public key being verified against an HS256 token → key confusion
// • Forgetting audience/issuer checks → token replay across services
// ─── 7) Key rotation ───────────────────────────────────────────
// Always sign with the newest key, verify against the keyring.
// Publish a /.well-known/jwks.json or equivalent so consumers can fetch keys
// by key id and cache them.
// ─── 8) What signatures are NOT ────────────────────────────────
// • Encryption — signatures don't hide the data, only authenticate it
// • Authorisation — signature == authentic, not == permitted
// • Hash — a hash has no key; anyone can compute it
// • MAC — MAC uses a shared secret; signature uses asymmetric keys
// ─── CHECKLIST ─────────────────────────────────────────────────
// 1. Default to Ed25519 for new systems
// 2. RSA: 3072-bit or larger, PSS padding only
// 3. Always include + verify a timestamp; reject stale requests
// 4. Verify before parsing the body
// 5. Key ID in the request → rotate keys safely
// 6. Use a vetted library for JWTs; reject alg=none and pin acceptable algs
// 7. Use timingSafeEqual / library verify (constant-time)
Why it matters
Use Ed25519 by default; reach for RSA-PSS or ECDSA only when interop demands it. Whatever you pick, sign a timestamp alongside the payload, verify against a key id, and parse the body only after the signature checks out — otherwise replay and key-confusion bugs creep back in.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
import { sign, verify, generateKeyPairSync } from 'crypto';
const { privateKey, publicKey } = generateKeyPairSync('ed25519');
const sig = sign(null, Buffer.from('hi'), privateKey);
const ok = verify(null, Buffer.from('hi'), publicKey, sig);
Try it Yourself »
Discussion
Loading…