Modern Software Tools, Explained Back to Hub
Security · Cryptography · Trust

The Two-Key Trick

Public-key cryptography solved one of online security’s hardest bootstrap problems: how do two strangers on an open network establish trust and secrecy without already sharing a secret key? The answer is not “encrypt everything with giant math all the time.” The answer is to use public-key crypto sparingly for identity, signatures, and key establishment, then hand the session off to fast symmetric encryption.

Problem strangers on an untrusted network Idea publish one key, protect the other Role authentication and session setup for the web
§01

Why two keys matter

Symmetric cryptography is fast and powerful, but it has a setup problem: both sides must already know the same secret key. That works inside a system you control. It does not solve the broader Internet problem of a browser talking to an unfamiliar server over a hostile network.

Public-key cryptography changes the shape of the problem. You generate a keypair: one public key that anyone can know, and one private key that only you keep. Now strangers can encrypt something only you can open, or verify that a message really came from you if it carries your signature. On the web, that public key is usually trusted only after the browser validates a certificate chain and hostname binding.

Symmetric-only vs public-key setup
Public-key cryptography does not replace symmetric encryption. It makes symmetric encryption deployable between parties who did not already share a secret.
§02

RSA: a toy trapdoor built from multiplication

RSA is the classic teaching example. Choose two primes p and q, multiply them to get n = p · q, then choose exponents e and d so that raising to e and then to d reverses the operation modulo n. The public key is (n, e). The private key is d.

For a toy example, take p = 61 and q = 53. Then n = 3233, and φ(n) = (61 - 1)(53 - 1) = 3120, where φ(n) is the count of integers below n that share no factor with n. Choose e = 17, which is coprime to 3120, and then choose d = 2753 because 17 × 2753 ≡ 1 mod 3120. That gives the public key (3233, 17) and the private key 2753. If the message is 42, encryption computes 42^17 mod 3233 = 2557, and decryption computes 2557^2753 mod 3233 = 42.

The core asymmetry is this: multiplying primes is easy, but recovering the original factors from a large composite number is believed to be hard. Real systems use much larger keys than the toy numbers below, plus secure encodings and padding. This demo shows the shape of RSA, not a deployable scheme.

Toy RSA in one number
42
Fixed toy keypair: p=61, q=53, n=3233, e=17, d=2753.

Plaintext

42

Encrypted

2557

Decrypted

42

Operations

rsa_toy.js — textbook RSA for intuition only
function gcd(a, b) {
  while (b !== 0n) [a, b] = [b, a % b];
  return a;
}

function egcd(a, b) {
  if (b === 0n) return [a, 1n, 0n];
  const [g, x1, y1] = egcd(b, a % b);
  return [g, y1, x1 - (a / b) * y1];
}

function modInv(a, m) {
  const [g, x] = egcd(a, m);
  if (g !== 1n) throw new Error("no inverse");
  return (x % m + m) % m;
}

function modPow(base, exp, mod) {
  let out = 1n;
  base %= mod;
  while (exp > 0n) {
    if (exp & 1n) out = (out * base) % mod;
    base = (base * base) % mod;
    exp >>= 1n;
  }
  return out;
}

function rsaKeypair(p, q, e) {
  const n = p * q;
  const phi = (p - 1n) * (q - 1n);
  if (gcd(e, phi) !== 1n) throw new Error("bad exponent");
  const d = modInv(e, phi);
  return { publicKey: { n, e }, privateKey: { n, d } };
}

const { publicKey, privateKey } = rsaKeypair(61n, 53n, 17n);
const message = 42n;
const cipher = modPow(message, publicKey.e, publicKey.n);
const plain = modPow(cipher, privateKey.d, privateKey.n);

console.log(`public: n=${publicKey.n} e=${publicKey.e}`);
console.log(`private: d=${privateKey.d}`);
console.log("cipher:", cipher.toString());
console.log("round-trip:", plain.toString());
§03

Digital signatures: authenticity, not secrecy

Encryption answers “who can read this?” Signatures answer “who wrote this, and was it changed?” The signer computes a digest of the message, uses the private key to sign that digest, and everyone else can verify the signature with the public key. In practice, real RSA signatures use secure signature encodings such as RSA-PSS rather than bare modular exponentiation.

This distinction matters on the web. When your browser connects to a bank, the first job is not bulk secrecy. The first job is proving that the server is really the bank and not a machine in the middle impersonating it.

Signature verification under tampering

Message

TRANSFER=100

Digest

0

Signature

0

Interpretation

signatures.js — sign and verify a toy message digest
function modPow(base, exp, mod) {
  let out = 1n;
  base %= mod;
  while (exp > 0n) {
    if (exp & 1n) out = (out * base) % mod;
    base = (base * base) % mod;
    exp >>= 1n;
  }
  return out;
}

function tinyHash(str) {
  let h = 0n;
  for (const ch of str) h = (h * 131n + BigInt(ch.charCodeAt(0))) % 3233n;
  return h;
}

const publicKey = { n: 3233n, e: 17n };
const privateKey = { n: 3233n, d: 2753n };

function sign(message) {
  return modPow(tinyHash(message), privateKey.d, privateKey.n);
}

function verify(message, signature) {
  return modPow(signature, publicKey.e, publicKey.n) === tinyHash(message);
}

const msg = "TRANSFER=100";
const sig = sign(msg);
console.log("signature:", sig.toString());
console.log("verify original:", verify(msg, sig));
console.log("verify tampered:", verify("TRANSFER=900", sig));
§04

TLS and the web: authenticate first, then switch to symmetric crypto

The browser does not use public-key cryptography because it wants to encrypt every page byte with slow heavyweight math. It uses public-key cryptography to authenticate the server and agree on a shared session secret. After that, the connection switches to fast symmetric encryption for the real traffic.

Modern TLS usually uses certificate-based signatures plus ephemeral Diffie-Hellman style key exchange rather than textbook RSA encryption of the session key. The server authenticates the handshake transcript with CertificateVerify, and both sides confirm the derived keys with Finished. The important architectural point is the same: public-key crypto bootstraps trust and key establishment; symmetric crypto carries the bulk data.

Browser handshake with and without a trusted signature

Browser

Trusted issuer keys Checks signatures on the server's identity and ephemeral key material.

Network

Open and hostile Anyone may observe, relay, or modify traffic.

Server

Private signing key Proves it owns the identity bound to the certificate.

Handshake steps

§05

What public-key cryptography is actually for

Public-key cryptography is foundational to online security, but it is easy to misunderstand its role. It is not a magical replacement for every other security primitive. In practice, it is most valuable for a narrow set of bootstrapping tasks.

Identity

Signatures let a server prove possession of a private key linked to its certificate and domain identity.

Key establishment

Two parties can create or protect a shared session key even if they never met before.

Delegation and audit

Signed artifacts, tokens, commits, and updates can be verified later by anyone with the public key.

For bulk encryption of web traffic, disks, and VPN tunnels, symmetric cryptography still does most of the heavy lifting because it is far faster.
§06

Implementation: a toy authenticated key exchange

The final snippet combines the ideas above. It uses a toy Diffie-Hellman exchange to derive a shared secret and a toy RSA signature to authenticate a simplified handshake transcript that includes the ephemeral values. It also computes a toy Finished-style tag over that transcript and shared secret. This is still vastly simpler than real TLS, but it captures the real division of labor much better than “encrypt the whole website with RSA.”

authenticated_key_exchange.js — signed transcript and shared session secret
function modPow(base, exp, mod) {
  let out = 1n;
  base %= mod;
  while (exp > 0n) {
    if (exp & 1n) out = (out * base) % mod;
    base = (base * base) % mod;
    exp >>= 1n;
  }
  return out;
}

const rsaPub = { n: 3233n, e: 17n };
const rsaPriv = { n: 3233n, d: 2753n };

function tinyHash(str, mod) {
  let h = 0n;
  for (const ch of str) h = (h * 131n + BigInt(ch.charCodeAt(0))) % mod;
  return h;
}

function signDigest(x) {
  return modPow(x % rsaPriv.n, rsaPriv.d, rsaPriv.n);
}

function verifyDigest(x, sig) {
  return modPow(sig, rsaPub.e, rsaPub.n) === (x % rsaPub.n);
}

const p = 23n;
const g = 5n;
const serverSecret = 6n;
const clientSecret = 15n;

const serverPublic = modPow(g, serverSecret, p);
const clientPublic = modPow(g, clientSecret, p);
const transcript = `ClientHello:${clientPublic}|ServerHello:${serverPublic}`;
const transcriptHash = tinyHash(transcript, rsaPub.n);
const serverSignature = signDigest(transcriptHash);

console.log("server public:", serverPublic.toString());
console.log("transcript signature valid:", verifyDigest(transcriptHash, serverSignature));

const clientShared = modPow(serverPublic, clientSecret, p);
const serverShared = modPow(clientPublic, serverSecret, p);
const finishedClient = (clientShared + tinyHash(transcript, p)) % p;
const finishedServer = (serverShared + tinyHash(transcript, p)) % p;

console.log("shared secret at client:", clientShared.toString());
console.log("shared secret at server:", serverShared.toString());
console.log("match:", clientShared === serverShared);
console.log("finished tags match:", finishedClient === finishedServer);