Applied Cryptography for Developers Module 10 – Public-key cryptography
Public-key cryptography: RSA and elliptic curves
Key pairs for key encapsulation, key agreement and signatures, why RSA needs OAEP or PSS, curves and sizes for 128 bits, and keys you can trust.
What you will learn
- Name the three jobs of public keys, key encapsulation, key agreement and signatures, and what crosses between the sides in each
- Explain why textbook RSA is unsafe and which paddings real systems use
- Choose curves and key sizes that meet 128-bit security, and say how long they stay acceptable
- Map each job to the Web Crypto algorithms that perform it
- Verify that a public key is authentic before trusting what it checks
Before you start
What you’ll learn · Before you start
What you will learn
- Name the three jobs of public keys, key encapsulation, key agreement and signatures, and what crosses between the sides in each
- Explain why textbook RSA is unsafe and which paddings real systems use
- Choose curves and key sizes that meet 128-bit security, and say how long they stay acceptable
- Map each job to the Web Crypto algorithms that perform it
- Verify that a public key is authentic before trusting what it checks
Before you start
On this page
A 32-byte X25519 public key can be printed on a web page, read out on a call or handed to an attacker without weakening it, and a 3,072-bit RSA public key lets anyone wrap a key that only one private key can unwrap. Public-key cryptography splits a key into a half for everyone and a half for its owner alone, and that split does what a shared key cannot: two parties who have never met can protect data and prove who made it.
This lesson names the three jobs a key pair does, shows on toy numbers why RSA without padding leaks what it encrypts, picks curves and sizes for 128-bit security, maps every job to the Web Crypto algorithm that ran it on Node.js 24, and ends with the check people skip: making sure a public key really belongs to its owner.
Three jobs: wrap a key, agree on one, or sign
A shared AES key works only when both sides already have it. Public-key cryptography gets around that with a key pair: A private key and the public key computed from it. The public key may be given to anyone; the private key stays with its owner, and only it can unwrap, sign or finish a key agreement for that pair.: a private key that stays with its owner, and a public key computed from it that anyone may hold. Every use of a key pair is one of three jobs:
- key encapsulation: Delivering a fresh symmetric key under a recipient's public key, so that only the matching private key recovers it. RSA-OAEP key wrapping and ML-KEM do this; an AEAD then encrypts the data under that key.. Anyone with the public key wraps a fresh symmetric key, and only the private key unwraps it. RSA-OAEP does this today; ML-KEM is its post-quantum successor.
- key agreement: Two parties each combine their own private key with the other's public key and arrive at the same shared secret without sending it, as Diffie-Hellman, ECDH and X25519 do. A KDF then turns that secret into keys.. Two sides each combine their own private key with the other’s public key and arrive at the same shared secret, as X25519 and ECDH do.
- digital signature: A value made from a message with a private key that anyone holding the matching public key can check. Any change to the message or the signature makes the check fail; Ed25519, ECDSA and RSA-PSS make them.. The private key signs a message and anyone with the public key checks it, with Ed25519, ECDSA or RSA-PSS.
None of the three encrypts a file or a stream itself. They deliver or agree on a symmetric key, and AES-GCM or ChaCha20-Poly1305 does the bulk work, a pattern the hybrid encryption lesson of this module builds. Step through the three jobs between the owner of a key pair and someone who holds only its public key:
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- One key pair. The owner generates a key pair. The private key stays where it was made; the public key is sent out and may be read by anyone, an attacker included.
- Job 1: encapsulate a key. The holder makes a fresh 32-byte AES key and wraps it under the public key, here with RSA-OAEP. Anyone could wrap a key this way; nobody else can unwrap it.
- Only the owner unwraps it. The owner unwraps the AES key with the private key. Both sides now share a key for AES-GCM, which encrypts the actual data.
- Job 2: agree on a key. For key agreement the holder needs a key pair of its own, for example X25519. It sends its public key to the owner.
- The same secret on both sides. Each side combines its own private key with the other side's public key. Both arrive at the same 32 bytes, although neither secret crossed the wire.
- Job 3: sign. The owner signs a release with the private key, here with Ed25519, and publishes the release with its 64-byte signature.
- Anyone can verify. The holder checks the signature with the public key. It verifies for this release and fails for any change, even one digit of the version.
- What never moved. Across all three jobs only public keys, a wrapped key and a signature crossed between the sides. The private key never left its owner.
Only public keys, a wrapped key and a signature crossed between the sides. That is why a public key may be published: it lets others wrap keys for its owner, agree on keys with it and check its signatures, and it never lets them do what only the private key does.
Checkpoint
Check: The three jobs of a key pair
Textbook RSA leaks equal messages and multiplies ciphertexts
RSA’s arithmetic fits in one line. Pick two primes p and q, publish n = p × q and an exponent e, keep the private exponent d, and encrypt a number m as:
In words: raise the message to the public exponent modulo n to encrypt, and the ciphertext to the private exponent to decrypt. On the program’s toy key, n = 1013 × 1019 = 1,032,247 and e = 65537, the number 250 encrypts to 148,245. Used exactly like that, as textbook RSA: RSA used as bare arithmetic, c = m^e mod n, with no padding scheme. It is deterministic, so equal messages give equal ciphertexts, and malleable, so a ciphertext times 2^e mod n opens as twice the message., it fails twice. It is deterministic, so the same message always gives the same ciphertext and anyone watching learns when two messages are equal. And it is malleable: Said of a cipher whose ciphertext can be changed so that the plaintext changes in a predictable way, as a flipped bit of a keystream cipher flips the same plaintext bit. Authentication tags prevent it. More in “Transposition, product ciphers and one-time pads”: the product of two ciphertexts is the ciphertext of the product, so multiplying a ciphertext by 2ᵉ mod n turns it into a valid ciphertext of twice the message, with no private key involved.
The program shows both on the toy key, then does the same task with RSA-OAEP from Web Crypto, and puts each through two checks: equal messages must look different, and a changed ciphertext must not open.
// RSA with no padding ("textbook RSA") on toy primes, then RSA-OAEP from Web Crypto, put through the same two checks:
// equal messages must give different ciphertexts, and a ciphertext changed without the private key must not open.
// The toy key is far too small for real use; it only makes the numbers readable.
const modPow = (base, exp, mod) => {
let result = 1n;
base %= mod;
for (; exp > 0n; exp >>= 1n) {
if (exp & 1n) result = (result * base) % mod;
base = (base * base) % mod;
}
return result;
};
const modInverse = (a, m) => {
let [oldR, r, oldS, s] = [a, m, 1n, 0n];
while (r !== 0n) {
const q = oldR / r;
[oldR, r] = [r, oldR - q * r];
[oldS, s] = [s, oldS - q * s];
}
return ((oldS % m) + m) % m;
};
// A toy key pair: n = p × q, e = 65537, d = e⁻¹ mod (p − 1)(q − 1).
const p = 1013n;
const q = 1019n;
const n = p * q;
const e = 65537n;
const d = modInverse(e, (p - 1n) * (q - 1n));
const encrypt = (m) => modPow(m, e, n);
const decrypt = (c) => modPow(c, d, n);
console.log(`textbook RSA, n = ${p} × ${q} = ${n}, e = ${e}`);
const amount = 250n;
const c1 = encrypt(amount);
const c2 = encrypt(amount);
console.log(` 250 encrypted twice: ${c1} and ${c2}, equal: ${c1 === c2}`);
const doubled = (c1 * encrypt(2n)) % n; // made from the ciphertext and the public key only
console.log(` ciphertext × 2^e mod n = ${doubled}, which decrypts to ${decrypt(doubled)}`);
// Textbook RSA has nothing to check: any number below n decrypts to some number, so a changed ciphertext is never refused.
const refusedByTextbook = (() => {
try {
decrypt(doubled);
return false;
} catch {
return true;
}
})();
console.log(` check 1, equal messages look different: ${c1 !== c2 ? 'pass' : 'FAIL'}`);
console.log(` check 2, a changed ciphertext is refused: ${refusedByTextbook ? 'pass' : `FAIL (it opened as ${decrypt(doubled)})`}`);
// The same task with RSA-OAEP: random padding inside the encryption, and a structure that decryption checks.
const { subtle } = globalThis.crypto;
const keys = await subtle.generateKey(
{ name: 'RSA-OAEP', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' },
true,
['encrypt', 'decrypt'],
);
const text = new TextEncoder().encode('amount=250');
const o1 = new Uint8Array(await subtle.encrypt({ name: 'RSA-OAEP' }, keys.publicKey, text));
const o2 = new Uint8Array(await subtle.encrypt({ name: 'RSA-OAEP' }, keys.publicKey, text));
const same = o1.every((b, i) => b === o2[i]);
const opened = new TextDecoder().decode(await subtle.decrypt({ name: 'RSA-OAEP' }, keys.privateKey, o1));
console.log('\nRSA-OAEP, 2048-bit key, SHA-256');
console.log(` "amount=250" encrypted twice: ${o1.length} bytes each, equal: ${same}, first one opens as "${opened}"`);
// The same doubling, done on the OAEP ciphertext as a number, with n and e taken from the public key.
const jwk = await subtle.exportKey('jwk', keys.publicKey);
const big = (b64u) => BigInt(`0x${Buffer.from(b64u, 'base64url').toString('hex')}`);
const [N, E] = [big(jwk.n), big(jwk.e)];
const c = BigInt(`0x${Buffer.from(o1).toString('hex')}`);
const forged = Buffer.from(((c * modPow(2n, E, N)) % N).toString(16).padStart(512, '0'), 'hex');
let refused = '';
try {
await subtle.decrypt({ name: 'RSA-OAEP' }, keys.privateKey, forged);
} catch (err) {
refused = err.name;
}
console.log(` ciphertext × 2^e mod n: decryption throws ${refused || 'nothing'}`);
console.log(` check 1, equal messages look different: ${!same ? 'pass' : 'FAIL'}`);
console.log(` check 2, a changed ciphertext is refused: ${refused ? 'pass' : 'FAIL'}`); Output
textbook RSA, n = 1013 × 1019 = 1032247, e = 65537 250 encrypted twice: 148245 and 148245, equal: true ciphertext × 2^e mod n = 487698, which decrypts to 500 check 1, equal messages look different: FAIL check 2, a changed ciphertext is refused: FAIL (it opened as 500) RSA-OAEP, 2048-bit key, SHA-256 "amount=250" encrypted twice: 256 bytes each, equal: false, first one opens as "amount=250" ciphertext × 2^e mod n: decryption throws OperationError check 1, equal messages look different: pass check 2, a changed ciphertext is refused: pass
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node textbook_rsa.mjs
Textbook RSA failed both checks: 250 became 148,245 both times, and the ciphertext times 2ᵉ opened as 500. RSA-OAEP
passed both: “amount=250” gave two different 256-byte ciphertexts, and the doubled one was refused with
OperationError. RFC 8017, the PKCS #1 standard, says its RSA primitives are not meant to give security apart from a
scheme, and it pairs each job with schemes of its own:
| Scheme in RFC 8017 | Job | Use it? |
|---|---|---|
| RSAES-OAEP | encryption and key wrapping | yes: required for new applications |
| RSAES-PKCS1-v1_5 | encryption | no: kept for old systems; ASVS 11.3.1 rules it out |
| RSASSA-PSS | signatures | yes: required for new applications |
| RSASSA-PKCS1-v1_5 | signatures | only to talk to old systems; ASVS Appendix C disallows it |
RSA-OAEP: RSAES-OAEP, RSA encryption with Optimal Asymmetric Encryption Padding (RFC 8017). Random padding makes every ciphertext different, and decryption refuses any ciphertext whose padding does not check. puts random padding inside every encryption and checks its structure on the way out, and RSA-PSS: RSASSA-PSS, RSA signatures with the Probabilistic Signature Scheme of RFC 8017, which encodes the message's hash with a random salt before the RSA operation. RFC 8017 requires it for new applications. adds a random salt to every signature; ASVS 5.0’s Appendix C approves RSA-PSS for signatures.
Random bytes in front of the message are not padding
Wrong: raw RSA (RSA_NO_PADDING in Node.js, or modular exponentiation written by hand), or a few random bytes
glued to the message before it. Right: RSA-OAEP with SHA-256 to wrap keys and RSA-PSS to sign, from the platform’s
crypto library. The OWASP Cryptographic Storage Cheat Sheet says plainly that prepending random bytes is no
substitute for OAEP, whose padding decryption checks before it returns anything.
Checkpoint
Check: Why RSA needs OAEP or PSS
Elliptic curves reach the same strength with far smaller keys
In elliptic-curve cryptography (ECC): Public-key cryptography on the points of an elliptic curve: the private key is a number and the public key that number times a base point. A 256-bit curve such as P-256 or Curve25519 gives about 128 bits of security. the private key is a number and the public key is a point: that number times a fixed base point of a curve. Working the number back out of the point is the elliptic-curve discrete logarithm problem, and on a 256-bit curve the best known attacks need about 2¹²⁸ steps, the same security strength: The work needed to break an algorithm, in bits: s bits means about 2^s operations. NIST uses the levels 112, 128, 192 and 256 to compare unlike algorithms, such as AES-128 and RSA-3072. More in “Security levels, key sizes and attack cost” as RSA-3072. Two families of curves cover nearly every system:
- The NIST P-curves, P-256, P-384 and P-521, from SP 800-186. They serve ECDSA signatures and ECDH key agreement, and they are the curves SP 800-186 lists for key establishment.
- Curve25519 and Curve448, from RFC 7748, which serve key agreement as X25519 and X448, and their Edwards forms, which serve signatures as Ed25519 and Ed448 (RFC 8032). RFC 7748 describes them as curves that lend themselves to constant-time code, and FIPS 186-5 approves EdDSA beside ECDSA and RSA.
The algorithm map later in this lesson generated one key of each kind and measured them:
| Key | Jobs | Strength (SP 800-186, SP 800-57) | Public key, as measured |
|---|---|---|---|
| P-256 | ECDSA, ECDH | 128 bits | 65 bytes raw, 91 bytes as SPKI |
| Curve25519 | X25519 key agreement | 128 bits | 32 bytes raw, 44 bytes as SPKI |
| Edwards25519 | Ed25519 signatures | 128 bits | 32 bytes raw, 44 bytes as SPKI |
| RSA-3072 | all three jobs | 128 bits | 422 bytes as SPKI |
RFC 8032 adds why Ed25519 is the usual first choice for signing: it needs no fresh random number per signature, its keys and 64-byte signatures are small, and its formulas hold for every point, so untrusted public keys need no costly checks. P-256 stays the choice where a standard or a platform asks for NIST curves.
Checkpoint
Check: Elliptic curves and what each one is for
Key sizes for 128-bit security, and how long they last
NIST SP 800-57 lines up the sizes that reach each strength: RSA needs a 3,072-bit modulus for 128 bits and 7,680 for 192, while a curve needs about twice the strength in bits, so 256 bits for 128-bit strength. ASVS 5.0 requirement 11.2.3 asks for 128 bits as a floor and gives the same pair as its example. The program holds both tables as data; change the questions at the bottom and run it again:
"""Key sizes as data: the RSA modulus for each security strength (NIST SP 800-57 Part 1 Rev. 5, Table 2), the curves
SP 800-186 places at each strength (its Table 1), and the transition dates of the draft NIST IR 8547. Change the
questions at the bottom and run it again."""
RSA_MODULUS = {112: 2048, 128: 3072, 192: 7680, 256: 15360} # strength in bits: RSA modulus in bits
CURVES = {
112: ["P-224"],
128: ["P-256", "Curve25519 (X25519)", "Edwards25519 (Ed25519)"],
192: ["P-384"],
224: ["Curve448 (X448)", "Edwards448 (Ed448)"],
256: ["P-521"],
}
def rsa_strength(modulus_bits):
"""The strength an RSA key reaches: the highest level whose modulus it meets."""
met = [s for s, k in RSA_MODULUS.items() if modulus_bits >= k]
return max(met) if met else None
def curve_strength(name):
return next(s for s, names in CURVES.items() if any(n.startswith(name) for n in names))
def status(strength, year):
"""IR 8547 (draft) for RSA and elliptic curves: 112-bit keys deprecated after 2030, all disallowed after 2035."""
if year > 2035:
return "disallowed: move to ML-KEM and ML-DSA"
if strength < 128 and year > 2030:
return "deprecated"
return "acceptable"
print("RSA modulus for each strength:")
print(" " + " ".join(f"{s} bits: {k}" for s, k in RSA_MODULUS.items()))
print("RSA size that matches P-384:", RSA_MODULUS[curve_strength("P-384")], "bits")
for bits in (2048, 3072, 4096):
print(f"RSA-{bits} reaches {rsa_strength(bits)} bits")
for name in ("P-256", "Edwards25519", "P-521"):
print(f"{name} reaches {curve_strength(name)} bits")
print(f"At 128 bits: an RSA-3072 modulus is {3072 // 8} bytes, an Ed25519 public key 32 ({3072 // 8 // 32} to 1)")
for key, year in (("RSA-2048", 2029), ("RSA-2048", 2032), ("P-256", 2032), ("P-256", 2036)):
s = rsa_strength(int(key[4:])) if key.startswith("RSA") else curve_strength(key)
print(f"{key} signing in {year}: {status(s, year)}") Output
RSA modulus for each strength: 112 bits: 2048 128 bits: 3072 192 bits: 7680 256 bits: 15360 RSA size that matches P-384: 7680 bits RSA-2048 reaches 112 bits RSA-3072 reaches 128 bits RSA-4096 reaches 128 bits P-256 reaches 128 bits Edwards25519 reaches 128 bits P-521 reaches 256 bits At 128 bits: an RSA-3072 modulus is 384 bytes, an Ed25519 public key 32 (12 to 1) RSA-2048 signing in 2029: acceptable RSA-2048 signing in 2032: deprecated P-256 signing in 2032: acceptable P-256 signing in 2036: disallowed: move to ML-KEM and ML-DSA
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 sizes.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
RSA-2048 reaches only 112 bits, and RSA-4096 still reaches only 128, because the next level up needs 7,680 bits; to match P-384 an RSA key needs 7,680 bits. At 128 bits the RSA-3072 modulus is 384 bytes against a 32-byte Ed25519 key, 12 to 1. Those strengths assume classical computers, and NIST’s draft transition plan, IR 8547, puts end dates on them. Walk through its three phases:
- Now
112 and 128 bits both acceptable
SP 800-57 rates RSA-2048 at 112 bits of strength, and RSA-3072, P-256, X25519 and Ed25519 at 128. Keys at either level may protect new data today.
- After 2030
112-bit keys deprecated RSA-2048 deprecated
The draft NIST IR 8547 deprecates RSA, ECDSA and key establishment at 112 bits: RSA-2048 may still be used where the risk is accepted, but new designs should start at 128 bits.
- After 2035
RSA and elliptic curves disallowed all disallowed
The same draft disallows RSA, ECDSA, EdDSA and Diffie-Hellman at every strength, because a large quantum computer would break them. ML-KEM and ML-DSA take their place.
The timeline has 3 events, from Now to After 2035.
So a key that must sign or agree beyond 2035 needs a post-quantum algorithm, which the post-quantum module of this track covers, and RSA-2048 is a poor choice for anything new even before then.
Key idea
For 128-bit security use a 256-bit curve, X25519 or P-256 for key agreement and Ed25519 or P-256 for signatures, and RSA-3072 only where a system needs RSA; RSA-2048 gives 112 bits, which NIST plans to deprecate after 2030.
Checkpoint
Check: Key sizes for 128-bit security
Each job has its own algorithms in Web Crypto
Web Crypto names its algorithms by job, and every key it makes is bound to one algorithm and to the operations it may perform, its usages. The program generates one key pair for each algorithm a new design is likely to need, runs the job once, and tries one thing a key was not made for:
// One key pair for each job of public-key cryptography in Web Crypto (Node.js 24): what each half of the pair may do,
// how big the public key is, and one run of the job. Every key is new on each run; only sizes and results are printed.
const { subtle } = globalThis.crypto;
const RSA = { publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256', modulusLength: 3072 };
const size = async (format, key) => new Uint8Array(await subtle.exportKey(format, key)).length;
const equal = (a, b) => a.length === b.length && a.every((x, i) => x === b[i]);
const message = new TextEncoder().encode('release 4.2.0');
const show = (job, name, pair, sizes, result) => {
console.log(`${job}: ${name}`);
console.log(` public key ${sizes}; private key may ${pair.privateKey.usages.join(', ')}`);
console.log(` ${result}`);
};
// Job 1, key encapsulation: wrap a fresh AES key under the public key; only the private key unwraps it.
const oaep = await subtle.generateKey({ name: 'RSA-OAEP', ...RSA }, true, ['wrapKey', 'unwrapKey']);
const aes = await subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']);
const wrapped = new Uint8Array(await subtle.wrapKey('raw', aes, oaep.publicKey, { name: 'RSA-OAEP' }));
const unwrapped = await subtle.unwrapKey('raw', wrapped, oaep.privateKey, { name: 'RSA-OAEP' }, 'AES-GCM', true, ['encrypt']);
const sameKey = equal(new Uint8Array(await subtle.exportKey('raw', aes)), new Uint8Array(await subtle.exportKey('raw', unwrapped)));
show('encapsulate', 'RSA-OAEP 3072, SHA-256', oaep, `${await size('spki', oaep.publicKey)} bytes (SPKI)`, `a 32-byte AES key wrapped in ${wrapped.length} bytes, unwrapped intact: ${sameKey}`);
// Job 2, key agreement: each side combines its own private key with the other's public key.
for (const alg of [{ name: 'X25519' }, { name: 'ECDH', namedCurve: 'P-256' }]) {
const a = await subtle.generateKey(alg, true, ['deriveBits']);
const b = await subtle.generateKey(alg, true, ['deriveBits']);
const ab = new Uint8Array(await subtle.deriveBits({ name: alg.name, public: b.publicKey }, a.privateKey, 256));
const ba = new Uint8Array(await subtle.deriveBits({ name: alg.name, public: a.publicKey }, b.privateKey, 256));
const label = alg.namedCurve ? `ECDH ${alg.namedCurve}` : alg.name;
show('agree', label, a, `${await size('raw', a.publicKey)} bytes raw, ${await size('spki', a.publicKey)} SPKI`, `both sides derived the same ${ab.length} bytes: ${equal(ab, ba)}`);
}
// Job 3, signatures: the private key signs, anyone with the public key verifies.
const signers = [
[{ name: 'Ed25519' }, 'Ed25519', 'Ed25519'],
[{ name: 'ECDSA', namedCurve: 'P-256' }, { name: 'ECDSA', hash: 'SHA-256' }, 'ECDSA P-256, SHA-256'],
[{ name: 'RSA-PSS', ...RSA }, { name: 'RSA-PSS', saltLength: 32 }, 'RSA-PSS 3072, SHA-256'],
];
for (const [gen, params, label] of signers) {
const pair = await subtle.generateKey(gen, true, ['sign', 'verify']);
const sig = new Uint8Array(await subtle.sign(params, pair.privateKey, message));
const ok = await subtle.verify(params, pair.publicKey, sig, message);
const changed = await subtle.verify(params, pair.publicKey, sig, new TextEncoder().encode('release 4.2.1'));
const sizes = gen.name === 'RSA-PSS' ? `${await size('spki', pair.publicKey)} bytes (SPKI)` : `${await size('raw', pair.publicKey)} bytes raw, ${await size('spki', pair.publicKey)} SPKI`;
show('sign', label, pair, sizes, `${sig.length}-byte signature verifies: ${ok}; after one changed digit: ${changed}`);
}
// A key does only the job it was made for: the RSA-OAEP key cannot sign.
try {
await subtle.sign({ name: 'RSA-PSS', saltLength: 32 }, oaep.privateKey, message);
} catch (err) {
console.log(`\nsigning with the RSA-OAEP private key: ${err.name}`);
} Output
encapsulate: RSA-OAEP 3072, SHA-256 public key 422 bytes (SPKI); private key may unwrapKey a 32-byte AES key wrapped in 384 bytes, unwrapped intact: true agree: X25519 public key 32 bytes raw, 44 SPKI; private key may deriveBits both sides derived the same 32 bytes: true agree: ECDH P-256 public key 65 bytes raw, 91 SPKI; private key may deriveBits both sides derived the same 32 bytes: true sign: Ed25519 public key 32 bytes raw, 44 SPKI; private key may sign 64-byte signature verifies: true; after one changed digit: false sign: ECDSA P-256, SHA-256 public key 65 bytes raw, 91 SPKI; private key may sign 64-byte signature verifies: true; after one changed digit: false sign: RSA-PSS 3072, SHA-256 public key 422 bytes (SPKI); private key may sign 384-byte signature verifies: true; after one changed digit: false signing with the RSA-OAEP private key: InvalidAccessError
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node algorithm_map.mjs
Every job worked, and every signature failed after one digit of the release changed. The RSA-OAEP private key refused
to sign with InvalidAccessError, although an RSA-PSS key of the same size could: a key pair is made for one
algorithm and keeps its usages. The map the program printed, as a table:
| Job | Web Crypto algorithms | The private key’s usage |
|---|---|---|
| Key encapsulation | RSA-OAEP; ML-KEM-768 in Node.js 24 as an experimental algorithm |
unwrapKey or decrypt |
| Key agreement | X25519, ECDH with P-256 or P-384 |
deriveBits or deriveKey |
| Signatures | Ed25519, ECDSA with P-256 or P-384, RSA-PSS |
sign |
Node.js 24 also has X448 and Ed448, and the post-quantum ML-KEM and ML-DSA behind its modern-algorithms
proposal, which its documentation marks as in active development.
Ask the platform before you build on an algorithm
SubtleCrypto.supports() exists in Node.js 24.21.0, but browsers differ in which algorithms they offer: feature-test
the algorithm you need, and keep RSA-OAEP, ECDSA and ECDH P-256 as the forms every engine has.
Checkpoint
Check: The Web Crypto algorithm for each job
A public key is only as good as your reason to trust it
A public key needs no secrecy, but it needs authenticity. If an attacker can replace the vendor’s key on its way to your app, they can sign their own update with their own key and send that key along, and a signature check passes, because every key verifies what it signed itself. The defence is to decide which key is the vendor’s before you check anything with it. Step through two deliveries of an update, one with a swapped key:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- The pin ships with the app. Your app carries the SHA-256 fingerprint of the vendor's public key, learned once through a separate channel, such as the app's own signed build.
- A genuine delivery. The vendor publishes update 7.1.0, its signature and its public key.
- Delivered unchanged. The bytes arrive as they were sent.
- Pin, then signature. The key's fingerprint matches the pin, and the signature verifies under that key. The app installs the update.
- A swapped delivery. In your test, the path replaces all three: a changed update, signed with a different key, delivered with that key.
- The signature still verifies. Checked only against the key that came with it, the signature verifies: any key verifies what it signed itself. A signature check alone accepts.
- The pin refuses it. The received key's fingerprint is deb2ded3…, not the pinned one, so the app refuses the update before it checks anything else.
The program runs the same two deliveries with RFC 8032’s two sample key pairs, so it prints the same bytes every time:
// Why a public key must be authentic: an app checks updates with the vendor's Ed25519 key. If a key can be swapped on
// the way, a signature check alone accepts whatever the swapped key signed; a pinned fingerprint of the vendor's key,
// shipped with the app, refuses it. The two key pairs are RFC 8032's TEST 1 and TEST 2, so every run prints the same.
const { subtle } = globalThis.crypto;
const unhex = (h) => Uint8Array.from(Buffer.from(h, 'hex'));
const hex = (b) => Buffer.from(b).toString('hex');
const b64u = (b) => Buffer.from(b).toString('base64url');
async function keyPair(secret, publicKey) {
const jwk = { kty: 'OKP', crv: 'Ed25519', d: b64u(unhex(secret)), x: b64u(unhex(publicKey)) };
const privateKey = await subtle.importKey('jwk', jwk, 'Ed25519', false, ['sign']);
const pub = await subtle.importKey('raw', unhex(publicKey), 'Ed25519', true, ['verify']);
return { privateKey, publicKey: pub };
}
// The fingerprint: SHA-256 over the key's SubjectPublicKeyInfo bytes, the form every platform can export.
const fingerprint = async (key) => hex(await subtle.digest('SHA-256', await subtle.exportKey('spki', key)));
const vendor = await keyPair('9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60', 'd75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a');
const other = await keyPair('4ccd089b28ff96da9db6c346ec114e0f5b8a319f35aba624da8cf6ed4fb8a6fb', '3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c');
const PINNED = await fingerprint(vendor.publicKey); // what the app ships with, learned through a separate channel
console.log(`pinned in the app: ${PINNED.slice(0, 16)}… (SHA-256 of the vendor key's SPKI)`);
const genuine = new TextEncoder().encode('update 7.1.0 as the vendor built it');
const altered = new TextEncoder().encode('update 7.1.0 with something added');
const deliveries = [
{ what: 'genuine key and update', key: vendor.publicKey, update: genuine, sig: await subtle.sign('Ed25519', vendor.privateKey, genuine) },
{ what: 'swapped key and update', key: other.publicKey, update: altered, sig: await subtle.sign('Ed25519', other.privateKey, altered) },
];
for (const d of deliveries) {
const signatureOk = await subtle.verify('Ed25519', d.key, d.sig, d.update);
const pinOk = (await fingerprint(d.key)) === PINNED;
console.log(`\n${d.what}: key ${(await fingerprint(d.key)).slice(0, 16)}…`);
console.log(` signature check alone: ${signatureOk ? 'accept' : 'refuse'}`);
console.log(` pin check, then signature check: ${pinOk && signatureOk ? 'accept' : 'refuse'}`);
} Output
pinned in the app: 06e3fd8fda29bb60… (SHA-256 of the vendor key's SPKI) genuine key and update: key 06e3fd8fda29bb60… signature check alone: accept pin check, then signature check: accept swapped key and update: key deb2ded39dc26fce… signature check alone: accept pin check, then signature check: refuse
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node fingerprint.mjs
The signature check alone accepted both deliveries; the fingerprint (of a key): A hash of a public key's encoded bytes, such as SHA-256 over its SubjectPublicKeyInfo, short enough to pin in an app or compare through a separate channel to confirm whose key it is. check refused the swapped key, deb2ded3… against the pinned 06e3fd8f…. A pin is one way to make a key authentic. The others carry the same idea at a larger scale:
- Certificates: a certificate authority you already trust signs a statement binding a name to a key, as TLS does; the TLS module of this track validates such chains in code.
- A pinned key or fingerprint: the expected key ships with the app or the configuration, as above; it works when the set of keys is small and you control their rotation.
- A directory you trust: keys looked up in a source with its own authenticity, such as a signed key list fetched over a verified connection.
Checkpoint
Check: Checking that a public key is authentic
Practise: choose the algorithm for each job
Turn the lesson’s choices into code with sample tests you can run here: given a job, a strength and the needs of the system, return the Web Crypto parameters the lesson recommends, or refuse a job RSA cannot do.
Exercise · Easy · Python
Pick the Web Crypto algorithm for each job
Write recommend(job, needs=frozenset(), strength=128) in recommend.py. It returns the Web Crypto algorithm parameters, as a dict, for one job of a key pair at a security strength of 128 or 192 bits, following the lesson:
jobis"encapsulate","agree"or"sign";strengthis128or192. Anything else raisesValueError.- Encapsulation uses RSA-OAEP:
{"name": "RSA-OAEP", "modulusLength": 3072, "hash": "SHA-256"}at 128 bits, and a 7,680-bit modulus with SHA-384 at 192. - Key agreement uses
{"name": "X25519"}at 128 bits, or{"name": "ECDH", "namedCurve": "P-256"}whenneedscontains"nist-curves"(a policy that allows only the NIST P-curves). At 192 bits it is ECDH on P-384. - Signing uses
{"name": "Ed25519"}at 128 bits, or{"name": "ECDSA", "namedCurve": "P-256", "hash": "SHA-256"}with"nist-curves". At 192 bits it is ECDSA on P-384 with SHA-384. - When
needscontains"rsa-only"(the other side supports only RSA), signing uses RSA-PSS with the RSA modulus and hash of that strength, and key agreement raisesValueError, because RSA cannot agree keys: encapsulate instead.
The sample tests cover five of the cases. The starter code answers Ed25519 for every job.
Starter code · recommend.py
def recommend(job: str, needs: frozenset = frozenset(), strength: int = 128) -> dict:
"""Web Crypto parameters for one job ("encapsulate", "agree" or "sign") at 128 or 192 bits."""
return {"name": "Ed25519"} # right for signing at 128 bits only The sample tests · test_recommend.py
from recommend import recommend
def raises_value_error(call):
try:
call()
except ValueError:
return True
return False
def test_sign_default():
"""signing at 128 bits with no other needs: Ed25519"""
assert recommend("sign") == {"name": "Ed25519"}
def test_agree_under_a_nist_curves_policy():
"""a policy that allows only the P-curves: ECDH on P-256"""
assert recommend("agree", frozenset({"nist-curves"})) == {"name": "ECDH", "namedCurve": "P-256"}
def test_encapsulate_at_192_bits():
"""192 bits needs a 7,680-bit modulus and SHA-384"""
assert recommend("encapsulate", strength=192) == {"name": "RSA-OAEP", "modulusLength": 7680, "hash": "SHA-384"}
def test_rsa_cannot_agree_keys():
"""RSA has no key agreement, so asking for one is an error"""
assert raises_value_error(lambda: recommend("agree", frozenset({"rsa-only"})))
def test_unknown_job_or_strength_is_an_error():
"""only the three jobs and the strengths 128 and 192 exist"""
assert raises_value_error(lambda: recommend("decrypt"))
assert raises_value_error(lambda: recommend("sign", strength=112)) A hint
Check job and strength first. Then work out the RSA modulus and the hash once from the strength (3072 and SHA-256, or 7680 and SHA-384), and the P-curve name (P-256 or P-384), before you look at the job and the needs.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Then check yourself, one question at a time, weakest idea first:
Practice that adapts to you
Practise this lesson
18 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
-
Fresher to mid · Warm-up
What is the difference between a public key and a private key, and what are the three jobs public-key cryptography does?
A key pair is a private key that never leaves its owner and a public key computed from it that anyone may hold. The public key lets others do the half of each job meant for everyone; the private key does the owner's half. The three jobs are key encapsulation (anyone wraps a fresh symmetric key under the public key, and only the private key unwraps it, as RSA-OAEP does), key agreement (two sides combine their own private key with the other's public key and both get the same secret, as X25519 and ECDH do) and signatures (the private key signs, and anyone with the public key verifies, as Ed25519, ECDSA and RSA-PSS do). None of them encrypts bulk data: that is AES-GCM's job, under the key the public-key step delivered. A public key needs no secrecy, but it does need authenticity.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
A partner sends card tokens encrypted to your RSA-2048 public key with PKCS#1 v1.5 padding, and an API of yours decrypts them. What risks does this design carry, and how do you move it to a safe scheme without breaking the partner?
-
Mid to staff
A mobile app downloads the server's RSA public key at first launch and encrypts crash reports to it. Where can a wrong key enter, what does an attacker gain if it does, and how do you make the key authentic, including when the server's key has to change?
Flashcards · 8 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
What are the three jobs of a key pair, and which algorithms do them?
Key encapsulation: anyone with the public key wraps a fresh symmetric key and only the private key unwraps it (RSA-OAEP; ML-KEM later). Key agreement: each side combines its own private key with the other's public key and gets the same secret (X25519, ECDH). Signatures: the private key signs and anyone with the public key checks (Ed25519, ECDSA, RSA-PSS). None encrypts a file itself; AES-GCM or ChaCha20-Poly1305 does the bulk work.
-
Why does textbook RSA fail?
With c = m^e mod n it is deterministic, so the same message always gives the same ciphertext and a watcher learns when two messages are equal; and malleable, since the product of two ciphertexts is the ciphertext of the product. On the toy key 250 gave 148,245 both times, and a ciphertext times 2^e mod n opened as 500 with no private key. RSA-OAEP passed both checks.
-
Which RSA schemes should new code use?
RSAES-OAEP for encryption and key wrapping, and RSASSA-PSS for signatures; RFC 8017 requires both for new applications. RSAES-PKCS1-v1_5 is kept for old systems (ASVS 11.3.1 rules it out), and RSASSA-PKCS1-v1_5 only to talk to old systems (ASVS Appendix C disallows it). A few random bytes glued to a message are not padding.
-
Which curves serve which jobs, and why is Ed25519 the usual first choice for signing?
NIST P-256, P-384 and P-521 (SP 800-186) serve ECDSA and ECDH. Curve25519 and Curve448 (RFC 7748) serve key agreement as X25519 and X448, and their Edwards forms serve signatures as Ed25519 and Ed448 (RFC 8032). Ed25519 needs no fresh random number per signature, has small keys and 64-byte signatures, and needs no costly checks of untrusted public keys. P-256 stays where a standard asks for NIST curves.
-
How large are public keys at 128-bit security?
P-256: 65 bytes raw (91 as SPKI). Curve25519 and Edwards25519: 32 bytes raw (44 as SPKI). RSA-3072: 422 bytes as SPKI, a 384-byte modulus against a 32-byte Ed25519 key, 12 to 1. RSA-2048 reaches only 112 bits, RSA-4096 still only 128, and 192 bits need 7,680.
-
What does NIST's draft transition plan do to 112-bit keys, RSA and elliptic curves?
It deprecates 112-bit security after 2030 and disallows RSA and elliptic curves after 2035, in favour of ML-KEM and ML-DSA. A key that must sign or agree beyond 2035 needs a post-quantum algorithm, and RSA-2048 is a poor choice for anything new even before then.
-
What does Web Crypto bind to every key it makes?
One algorithm and the operations it may perform, its usages: an RSA-OAEP private key unwraps or decrypts, X25519 and ECDH keys use deriveBits or deriveKey, and Ed25519, ECDSA and RSA-PSS keys sign. An RSA-OAEP private key refused to sign with InvalidAccessError, although an RSA-PSS key of the same size could.
-
A signature check passes. Why is that not enough to trust an update?
Every key verifies what it signed itself. An attacker who swaps the vendor's key can sign their own update and send their key along. Decide which key is authentic first: a pinned fingerprint (the swapped key's deb2ded3… did not match the pinned 06e3fd8f…), a certificate from an authority you trust, or a directory you trust.
Key takeaways
- A key pair does three jobs, key encapsulation, key agreement and signatures, and none of them moves the private key; the bulk data is always encrypted with a symmetric key such as AES-GCM.
- Textbook RSA is deterministic and malleable: on the toy key 250 encrypted to the same number twice, and a ciphertext times 2ᵉ opened as 500. Real systems use RSA-OAEP to wrap keys and RSA-PSS to sign.
- A 256-bit curve gives 128-bit security with a 32- or 65-byte public key, where RSA needs 3,072 bits; RSA-2048 gives only 112 bits.
- NIST’s draft plan deprecates 112-bit keys after 2030 and disallows RSA and elliptic curves after 2035, in favour of ML-KEM and ML-DSA.
- In Web Crypto each key is bound to one algorithm and its usages: RSA-OAEP to wrap, X25519 or ECDH to agree, Ed25519, ECDSA or RSA-PSS to sign.
- A signature proves only that a key made it; decide which key is authentic first, through a pin, a certificate or a trusted directory.
Terms in this lesson
- digital signature
- A value made from a message with a private key that anyone holding the matching public key can check. Any change to the message or the signature makes the check fail; Ed25519, ECDSA and RSA-PSS make them.
- elliptic-curve cryptography (ECC)
- Public-key cryptography on the points of an elliptic curve: the private key is a number and the public key that number times a base point. A 256-bit curve such as P-256 or Curve25519 gives about 128 bits of security.
- fingerprint (of a key)
- A hash of a public key's encoded bytes, such as SHA-256 over its SubjectPublicKeyInfo, short enough to pin in an app or compare through a separate channel to confirm whose key it is.
- key agreement
- Two parties each combine their own private key with the other's public key and arrive at the same shared secret without sending it, as Diffie-Hellman, ECDH and X25519 do. A KDF then turns that secret into keys.
- key encapsulation
- Delivering a fresh symmetric key under a recipient's public key, so that only the matching private key recovers it. RSA-OAEP key wrapping and ML-KEM do this; an AEAD then encrypts the data under that key.
- key pair
- A private key and the public key computed from it. The public key may be given to anyone; the private key stays with its owner, and only it can unwrap, sign or finish a key agreement for that pair.
- RSA-OAEP
- RSAES-OAEP, RSA encryption with Optimal Asymmetric Encryption Padding (RFC 8017). Random padding makes every ciphertext different, and decryption refuses any ciphertext whose padding does not check.
- RSA-PSS
- RSASSA-PSS, RSA signatures with the Probabilistic Signature Scheme of RFC 8017, which encodes the message's hash with a random salt before the RSA operation. RFC 8017 requires it for new applications.
- textbook RSA
- RSA used as bare arithmetic, c = m^e mod n, with no padding scheme. It is deterministic, so equal messages give equal ciphertexts, and malleable, so a ciphertext times 2^e mod n opens as twice the message.
References
- RFC 8017: PKCS #1, RSA Cryptography Specifications Version 2.2 (Internet Engineering Task Force (IETF))
- RFC 7748: Elliptic Curves for Security (Internet Research Task Force (IRTF))
- RFC 8032: Edwards-Curve Digital Signature Algorithm (EdDSA) (Internet Research Task Force (IRTF))
- SP 800-186: Recommendations for Discrete Logarithm-based Cryptography, Elliptic Curve Domain Parameters (Tables 1 and 2) (National Institute of Standards and Technology (NIST))
- FIPS 186-5: Digital Signature Standard (DSS) (National Institute of Standards and Technology (NIST))
- SP 800-57 Part 1 Rev. 5: Recommendation for Key Management (Table 2) (National Institute of Standards and Technology (NIST))
- NIST IR 8547 (Initial Public Draft): Transition to Post-Quantum Cryptography Standards (National Institute of Standards and Technology (NIST))
- Web Cryptography Level 2 (World Wide Web Consortium (W3C))
- Node.js 24 documentation: Web Crypto API (algorithms and key formats) (OpenJS Foundation (Node.js))
- OWASP Cryptographic Storage Cheat Sheet (algorithms, random padding) (OWASP Foundation)
- OWASP Application Security Verification Standard 5.0, V11 Cryptography (requirements 11.2.3, 11.3.1 and 11.6.1) (OWASP Foundation)
- OWASP Application Security Verification Standard 5.0, Appendix C: Cryptography Standards (OWASP Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress