TechLead
Lesson 9 of 22
6 min read
Cybersecurity

Encryption Fundamentals

Learn symmetric and asymmetric encryption, hashing, digital signatures, and how to implement encryption correctly in your applications

Why Encryption Matters

Encryption is the process of converting readable data (plaintext) into an unreadable format (ciphertext) using a mathematical algorithm and a key. Only someone with the correct key can decrypt the ciphertext back into plaintext. Encryption is the foundation of data confidentiality — it protects sensitive information both in transit (data moving over networks) and at rest (data stored on disk).

As developers, we deal with encryption every day whether we realize it or not. HTTPS uses encryption to protect web traffic. Password hashing is a form of one-way encryption. Database encryption protects stored data. Understanding how encryption works helps you make informed decisions about which algorithms to use, how to manage keys, and how to avoid common pitfalls that render encryption useless.

Symmetric Encryption

Symmetric encryption uses the same key for both encryption and decryption. It is fast and efficient, making it ideal for encrypting large amounts of data. The most widely used symmetric algorithm is AES (Advanced Encryption Standard) with 256-bit keys. AES-256-GCM is the recommended mode as it provides both encryption and authentication (AEAD — Authenticated Encryption with Associated Data).

import crypto from "crypto";

// AES-256-GCM encryption (recommended for most use cases)
class SymmetricEncryption {
  private algorithm = "aes-256-gcm" as const;

  // Encrypt data with AES-256-GCM
  encrypt(plaintext: string, key: Buffer): {
    ciphertext: string;
    iv: string;
    authTag: string;
  } {
    // Generate a random IV (initialization vector) for each encryption
    // CRITICAL: Never reuse an IV with the same key
    const iv = crypto.randomBytes(12); // 96-bit IV for GCM

    const cipher = crypto.createCipheriv(this.algorithm, key, iv);

    let ciphertext = cipher.update(plaintext, "utf8", "base64");
    ciphertext += cipher.final("base64");

    // GCM produces an authentication tag that verifies integrity
    const authTag = cipher.getAuthTag();

    return {
      ciphertext,
      iv: iv.toString("base64"),
      authTag: authTag.toString("base64"),
    };
  }

  // Decrypt data with AES-256-GCM
  decrypt(
    ciphertext: string,
    key: Buffer,
    iv: string,
    authTag: string
  ): string {
    const decipher = crypto.createDecipheriv(
      this.algorithm,
      key,
      Buffer.from(iv, "base64")
    );

    // Set the authentication tag for verification
    decipher.setAuthTag(Buffer.from(authTag, "base64"));

    let plaintext = decipher.update(ciphertext, "base64", "utf8");
    plaintext += decipher.final("utf8"); // Throws if auth tag is invalid

    return plaintext;
  }
}

// Key derivation from a password
function deriveKeyFromPassword(
  password: string,
  salt?: Buffer
): { key: Buffer; salt: Buffer } {
  // Use a random salt if not provided
  const actualSalt = salt || crypto.randomBytes(32);

  // PBKDF2: derive a 256-bit key from a password
  // 600,000 iterations is OWASP's recommendation for PBKDF2-SHA256
  const key = crypto.pbkdf2Sync(
    password,
    actualSalt,
    600000,
    32,
    "sha256"
  );

  return { key, salt: actualSalt };
}

// Usage example: encrypting sensitive data before storage
const encryption = new SymmetricEncryption();
const key = crypto.randomBytes(32); // 256-bit key

const encrypted = encryption.encrypt("SSN: 123-45-6789", key);
console.log("Encrypted:", encrypted.ciphertext);

const decrypted = encryption.decrypt(
  encrypted.ciphertext,
  key,
  encrypted.iv,
  encrypted.authTag
);
console.log("Decrypted:", decrypted); // "SSN: 123-45-6789"

Asymmetric Encryption

Asymmetric encryption uses a key pair: a public key for encryption and a private key for decryption. Anyone can encrypt data with the public key, but only the holder of the private key can decrypt it. This solves the key distribution problem of symmetric encryption — you can share your public key openly. RSA and Elliptic Curve Cryptography (ECC) are the most common asymmetric algorithms.

// RSA key pair generation and encryption
async function generateRSAKeyPair(): Promise<{
  publicKey: string;
  privateKey: string;
}> {
  return new Promise((resolve, reject) => {
    crypto.generateKeyPair(
      "rsa",
      {
        modulusLength: 4096, // Key size in bits (minimum 2048)
        publicKeyEncoding: { type: "spki", format: "pem" },
        privateKeyEncoding: {
          type: "pkcs8",
          format: "pem",
          cipher: "aes-256-cbc",
          passphrase: process.env.KEY_PASSPHRASE,
        },
      },
      (err, publicKey, privateKey) => {
        if (err) reject(err);
        resolve({ publicKey, privateKey });
      }
    );
  });
}

// Encrypt with public key
function rsaEncrypt(data: string, publicKey: string): string {
  const encrypted = crypto.publicEncrypt(
    {
      key: publicKey,
      padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
      oaepHash: "sha256",
    },
    Buffer.from(data)
  );
  return encrypted.toString("base64");
}

// Decrypt with private key
function rsaDecrypt(ciphertext: string, privateKey: string): string {
  const decrypted = crypto.privateDecrypt(
    {
      key: privateKey,
      padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
      oaepHash: "sha256",
      passphrase: process.env.KEY_PASSPHRASE,
    },
    Buffer.from(ciphertext, "base64")
  );
  return decrypted.toString("utf8");
}

// Hybrid encryption: RSA + AES for large data
function hybridEncrypt(data: string, recipientPublicKey: string) {
  // Generate a random AES key for this message
  const aesKey = crypto.randomBytes(32);

  // Encrypt the data with AES (fast for large data)
  const enc = new SymmetricEncryption();
  const encrypted = enc.encrypt(data, aesKey);

  // Encrypt the AES key with RSA (only small key is RSA-encrypted)
  const encryptedKey = rsaEncrypt(aesKey.toString("base64"), recipientPublicKey);

  return { encryptedKey, ...encrypted };
}

Hashing and Digital Signatures

A hash function takes input of any size and produces a fixed-size output (the hash or digest). Cryptographic hash functions have special properties: they are one-way (you cannot derive the input from the hash), collision-resistant (it is computationally infeasible to find two inputs with the same hash), and deterministic (the same input always produces the same hash). SHA-256 is the most widely used cryptographic hash function.

// Hashing for integrity verification
function hashData(data: string): string {
  return crypto.createHash("sha256").update(data).digest("hex");
}

// HMAC: Hash-based Message Authentication Code
// Uses a secret key + hash to verify both integrity AND authenticity
function createHMAC(data: string, secretKey: string): string {
  return crypto.createHmac("sha256", secretKey).update(data).digest("hex");
}

function verifyHMAC(data: string, secretKey: string, expectedMAC: string): boolean {
  const computedMAC = createHMAC(data, secretKey);
  // Timing-safe comparison prevents timing attacks
  return crypto.timingSafeEqual(
    Buffer.from(computedMAC, "hex"),
    Buffer.from(expectedMAC, "hex")
  );
}

// Digital signatures: sign with private key, verify with public key
function signData(data: string, privateKey: string): string {
  const sign = crypto.createSign("SHA256");
  sign.update(data);
  return sign.sign(
    { key: privateKey, passphrase: process.env.KEY_PASSPHRASE },
    "base64"
  );
}

function verifySignature(
  data: string,
  signature: string,
  publicKey: string
): boolean {
  const verify = crypto.createVerify("SHA256");
  verify.update(data);
  return verify.verify(publicKey, signature, "base64");
}

// Practical example: Webhook signature verification
function verifyWebhookSignature(
  payload: string,
  signature: string,
  secret: string
): boolean {
  const expectedSignature = createHMAC(payload, secret);
  try {
    return crypto.timingSafeEqual(
      Buffer.from(signature),
      Buffer.from(`sha256=${expectedSignature}`)
    );
  } catch {
    return false; // Different lengths = not equal
  }
}

Security Warning: Encryption Mistakes

  • Never reuse IVs/nonces: With AES-GCM, reusing an IV with the same key completely breaks security. Always generate a random IV for each encryption.
  • Never use ECB mode: ECB encrypts identical blocks to identical ciphertext, leaking patterns in the data.
  • Always use authenticated encryption: Use GCM or ChaCha20-Poly1305 to ensure both confidentiality and integrity.
  • Protect your keys: Encryption is only as strong as your key management. Use a KMS or HSM for production keys.
  • Use constant-time comparison: For HMAC verification, use timingSafeEqual to prevent timing attacks.

When to Use What

Use Case Algorithm Notes
Encrypting data at restAES-256-GCMFast, authenticated encryption
Password hashingArgon2id / bcryptDeliberately slow, salted
Data integritySHA-256 / HMAC-SHA256HMAC when you need authenticity
Key exchangeECDH / X25519For establishing shared secrets
Digital signaturesEd25519 / RSA-PSSNon-repudiation, code signing

Continue Learning