import { readFileSync, writeFileSync } from "node:fs";import { createPrivateKey, sign } from "node:crypto"; const privateKey = createPrivateKey(readFileSync("key.pem")); // PKCS#8 "BEGIN PRIVATE KEY"const message = readFileSync("message.txt"); // Ed25519 hashes the message itself (SHA-512 inside the algorithm): the digest argument is nullconst signature = sign(null, message, privateKey); writeFileSync("sig.bin", signature);console.log("signature:", signature.toString("hex")); // 64 bytes, the same on every run import { readFileSync } from "node:fs";import { createPublicKey, verify } from "node:crypto"; const publicKey = createPublicKey(readFileSync("pub.pem")); // SPKI "BEGIN PUBLIC KEY"const message = readFileSync("message.txt");const signature = readFileSync("sig.bin"); console.log("valid:", verify(null, message, publicKey, signature));console.log("tampered message valid:", verify(null, Buffer.from("hello ed25520"), publicKey, signature)); How it works
createPrivateKeyreads theBEGIN PRIVATE KEY(PKCS#8) block andcreatePublicKeytheBEGIN PUBLIC KEY(SPKI) block. The key type comes from the OID inside the PEM, soprivateKey.asymmetricKeyTypeis"ed25519"without any option.sign(null, message, privateKey)returns a 64-byteBuffer. Pass the message bytes themselves: SHA-512 runs inside the Ed25519 algorithm.verify(null, message, publicKey, signature)returnstrueorfalse. It throws only for unusable keys or arguments, never for a wrong signature.- The run prints the same signature as every other recipe of this task for the RFC 8032 test key: Ed25519 has no random nonce, so the same key and message always give the same bytes.
Gotchas
- Any hash name fails:
sign("sha256", …)and evensign("sha512", …)throwERR_OSSL_INVALID_DIGEST(error:1C80007A:Provider routines::invalid digest). The algorithm argument must benullorundefined. - The streaming
createSign/createVerifyclasses do not support Ed25519 and throwERR_CRYPTO_UNSUPPORTED_OPERATION. Ed25519 is one-shot: read the whole message, then callsign. - A key that arrives as 32 raw bytes (libsodium, Solana, a JWK
x) is not PEM. Import it as a JWK —createPublicKey({ key: { kty: "OKP", crv: "Ed25519", x }, format: "jwk" })— instead of assembling DER by hand. - Comparing signature bytes in tests works here because Node.js signs deterministically, but not every implementation does: Apple CryptoKit randomizes Ed25519 signatures. Cross-platform tests should verify, not compare.
Related recipes
- How to sign and verify Ed25519 signatures in WebCrypto
- How to sign and verify Ed25519 signatures in Python
- How to sign and verify Ed25519 signatures in Go
- How to sign and verify Ed25519 signatures with OpenSSL
- How to sign a message with ECDSA in Node.js
- How to derive a shared key with ECDH and HKDF in Node.js