Password Protecting Blog Posts WebCrypto!

I have some sections of this site (work in progress posts, etc), that I want to lock with a password until they are made available to the public section of this site. I took some of my crypto knowledge wrote a process to encrypt the post contents during the build process using a custom Astro component, and then decrypt it on the client side with a password.

For this, I’m using the NodeJS crypto module to do the encryption within the Astro site, and the Web Crypto API to do the decryption on the client side. This works pretty seamlessly with Astro, and I’m pretty happy with the results.

How it Works

Disclaimer: The cryptography code in this post is theoretically secure, but has not been audited. I make no guarantees about the security of the code contained within. This source code is available AS-IS, with no warranties of any kind.

Before we start, this post assumes you know a bit about cryptography, and have some experience with NodeJS, Astro, and React. Some resources to get started:

With that out of the way, let’s dive into how it works.

Server Side (Astro)

We do all of the encryption of the contents within the server side (or static build) of the Astro site. We then send the AES encrypted content, pbkdf (“password based key derivation function”) salts & parameters, and IV (“initialization vector”) to the client.

Encryption process:

  • Take the passphrase for the post in question, and derive a key from it using PBKDF2 with a random salt.
  • Generate a random Initialization Vector (IV) for encrypting with AES.
  • Encrypt the post content with AES-256-GCM using the derived content key and IV, and a 128 bit auth tag.
  • Store the encrypted content, salt, and IV in a JSON object, and write to a randomized filename in the /public/encrypted directory.
  • Encrypt the URL of the encrypted content file with the passphrase
  • Send the encrypted link to the json file in /public/encrypted, salt and IV to the client by embedding it in the webpage.

Client-Side (React Component)

On the client side, we use the Web Crypto API to decrypt and verify the contents.

  • Take the user provided password, and derive the key using PBKDF2 with the salt and correct number of iterations.
  • Decrypt the encrypted content using the derived key and the IV.
  • Take the provided URL, and downlod / decrypt the contents of the encrypted file.
  • Take the results from the decryption the HTML into the page.

Some Notes:

  • We use PBKDF2 to generate strong keys derived from the password
  • We chose AES-GCM since it provides authenticated encryption so we don’t need to implement our own message authentication scheme.
  • The IV is randomly generated for each encryption to avoid attacks, and is sent to the client along with the encrypted data.
  • I’m using a slight overkill (at today’s compute power) of 1,000,000 iterations for the PBKDF2
  • The passwords are also randomly generated
  • Actual encrypted post contents are stored at a secondary hard-to-guess location, derived using the passphrase and the URL of the post. This way, we’re not providing the encrypted contents unless the user enters the correct passphrase.

Server-Side Implementation

This is the Astro component that does the encryption. It takes the post content, and the password, and encrypts the content. It then outputs the encrypted content, the HMAC, the IV, and the salts, using an Astro “island” to allow the frontend to use it.

It can be used anywhere in your Astro project, but I’m using it to wrap the contents of posts I want to Lock. Since it’s just wrapping arbitrary HTML, you could use it to encrypt portions of a page.

In my implementation the built page output’s encrypted data files only contain a pointer to the encrypted content. The actual encrypted content is stored elsewhere in a file in the /encrypted directory, which is a random filename based on the hash of the page url and some other secret info.

EncryptedPostContainer.astro:

---
const postHTMLContents = await Astro.slots.render('default');
import { EncryptedPost } from './EncryptedPost';
import crypto from 'node:crypto'
import buffer from 'node:buffer'
import fs from 'node:fs'
import path from 'node:path'

export interface Props {
  passphrase?: string,
}

export interface EncryptedPost {
  iterations: number,
  salt: string,
  iv: string,
  encryptedData: string,
  tagSizeBytes: number
}

/**
 * Encrypt the post contents using AES-256-CBC with a passphrase
 * @param plainText the plaintext to encrypt (the post contents)
 * @param passphrase the passphrase to encrypt the post contents with
 */
export async function encryptPostContents (plainText: string, passphrase: string): Promise<EncryptedPost> {
  const pbkdf2_iterations = 1_000_000
  const gcm_tag_size_bytes = 16
  const salt = crypto.randomBytes(64); // randomly salt the password with 64 bytes

  // derive key from passphrase using pbkdf2
  const derivedKey : buffer.Buffer = await new Promise((resolve, reject) => {
    // deriving key with 100,000 iterations, 256bit length and sha256 digest
    crypto.pbkdf2(passphrase, salt, pbkdf2_iterations, 256/8, 'sha256', (err, derivedKey) => {
      if (err) reject(err)
      resolve(derivedKey)
    })
  })

  // NOTE: GCM requires (iv, key) pair to be unique for every encryption
  const iv = crypto.randomBytes(12) // 12 bytes is the recommended size for GCM (96 bits)

  // create cipher object
  const cipher = crypto.createCipheriv('aes-256-gcm', derivedKey, iv, {
    authTagLength: gcm_tag_size_bytes
  })

  // encrypt the given text
  let encrypted = cipher.update(plainText)

  // concat the final encrypted data with the auth tag
  encrypted = buffer.Buffer.concat([encrypted, cipher.final(), cipher.getAuthTag()])

  const out = {
    iterations: pbkdf2_iterations,
    salt: salt.toString('base64'),
    iv: iv.toString('base64'),
    encryptedData: encrypted.toString('base64'),
    tagSizeBytes: gcm_tag_size_bytes
  }

  return out
}

const { passphrase: passphrase_override } = Astro.props

// get the password for the post
// this is set in your .env file
let passphrase = import.meta.env.ADMIN_PASSWORD

if (passphrase_override) {
  if (passphrase_override.length < 8) {
    throw new Error('Post passphrase must be at least 8 characters long')
  }

  passphrase = passphrase_override
}

/*
Derive a filename to store the actual encrypted post contents from the passphrase, using pbkdf to prevent brute force attacks.

HMAC would NOT be secure here, since we could guess a passphrase for hmac-sha256(url, password) and, check the post encrypted directory for a match, allowing us to brute force the password without running PBKDF2. With this, it is as difficult to determine where the actual contents are stored as it is to bruteforce the password itself.

We're not publishing the actual ciphertext of the post within the public page, only a pointer to it. A user must enter a valid passphrase to get the link to download ciphertext of the post. As a result, we don't provide any information about the length of the post unless the user enters the correct passphrase.
*/
const storage_filename : buffer.Buffer = await new Promise((resolve, reject) => {
  // deriving key with 100,000 iterations, 256bit length and sha256 digest
  crypto.pbkdf2(passphrase, Astro.url.pathname, 1_000_000, 256/8, 'sha256', (err, derivedKey) => {
    if (err) reject(err)
    resolve(derivedKey)
  })
})

// convert the buffer to a hex string
const output_filename = storage_filename.toString('hex')

// encrypt the post contents
const cipherText = await encryptPostContents(postHTMLContents, passphrase);

// find a place in to store the encrypted post contents
const encryptedFilePath = path.join('/encrypted', output_filename + '.json')

// write the encrypted post contents to a file in /public/encrypted/{{hash}}.json
// NOTE: this works well for Astro in dev mode, but prod mode requires some an extra step to copy the files to the dist directory
console.log(`Encrypted post contents to ${encryptedFilePath}`)
fs.writeFileSync(path.join('./public', encryptedFilePath), JSON.stringify(cipherText, null, 2))

// encrypt a JSON object with the URL of the encrypted post contents
// send the encrypted post contents to the client
const encryptedPostPointer = await encryptPostContents(JSON.stringify({ url: encryptedFilePath }), passphrase)
---

{/* Frontend React component to do decrypt */}
<EncryptedPost cipherText={encryptedPostPointer} client:load />

The Client Side

This is the React component that runs on the client side to decrypt the post contents. It uses the Web Crypto API to do the decryption.

Note: I’ve got some Typescript, Tailwind CSS / Icons, and React in here, but you can adapt this to whatever tools you’re using, it just needs to be a component that executes within the browser.

EncryptedPost.tsx

import * as React from 'react';
import { type EncryptedPost } from '../lib/embedded_crypto';
import { LockClosedIcon } from '@heroicons/react/24/outline';

/**
 * Client side component for rendering encrypted posts
 */
export function EncryptedPost({ cipherText }: { cipherText: EncryptedPost }) {
  const [key, setKey] = React.useState('')
  const [decryptedHTML, setDecryptedHTML] = React.useState<string | null>(null)

  React.useEffect(() => {
    // if the passphrase is stored in sessionStorage, decrypt the post
    const passphrase = sessionStorage.getItem('passphrase')
    if (passphrase) {
      runDecrypt(passphrase)
    }
  }, [])

  // for large strings, use this from https://stackoverflow.com/a/49124600
  const base64_to_buf = (b64: string) =>
    Uint8Array.from(atob(b64), (c) => c.charCodeAt(0))

  /**
   * Derive a decryption key from the given password
   */
  async function deriveEncryptionKey (cipherText: EncryptedPost, password: string): Promise<CryptoKey> {
    const enc = new TextEncoder();
    const passwordKey = window.crypto.subtle.importKey("raw", enc.encode(password), "PBKDF2", false, [
      "deriveKey",
    ])
    return window.crypto.subtle.deriveKey(
      {
        name: "PBKDF2",
        salt: base64_to_buf(cipherText.salt),
        iterations: cipherText.iterations,
        hash: "SHA-256",
      },
      await passwordKey,
      { name: "AES-GCM", length: 256 },
      false,
      ["decrypt"]
    )
  }

  /**
   * Decrypts the encrypted data using the given password
   */
  async function decrypt (cipherText: EncryptedPost, password: string): Promise<string> {
    const key = await deriveEncryptionKey(cipherText, password);
    const tag_size_bits = cipherText.tagSizeBytes * 8;
    const decrypted = await window.crypto.subtle.decrypt(
      {
        name: "AES-GCM",
        iv: base64_to_buf(cipherText.iv),
        tagLength: tag_size_bits,
      },
      key,
      base64_to_buf(cipherText.encryptedData)
    )
    return new TextDecoder().decode(decrypted)
  }

  /**
   * Wrapper function to decrypt the post contents
   * and set the decryptedHTML state
   * Alerts the user if the password is incorrect
   */
  function runDecrypt(key: string) {
    decrypt(cipherText, key)
      .then((decrypted) => {
        console.log('success: decrypted pointer to payload')
        sessionStorage.setItem('passphrase', key)
        return JSON.parse(decrypted)
      })
      .then(pointer => {
        console.log('loading encrypted payload', pointer.url)
        return fetch(pointer.url)
      })
      .then(res => res.json())
      .then(data => {
        console.log('loaded encrypted payload')
        return decrypt(data, key)
      })
      .then(data => {
        console.log('success: decrypted post contents!')
        setDecryptedHTML(data)
      })
      .catch(err => {
        setKey('')
        sessionStorage.removeItem('passphrase')
        console.error('decryption error', err);
        alert('Incorrect password');
        throw err
      })
  }

  return (
    <>
      {/* render password prompt if not decrypted yet */}
      {
        !decryptedHTML
          ? <>
            <div className="flex flex-col items-center gap-4 my-2 card">
              <LockClosedIcon className="w-10 h-10 text-red-500 min-w-10" />
              <div className='flex flex-col items-center gap-1'>
                <div className='text-lg font-bold'>Password Protected</div>
                <div>This post is locked. Enter the password below to view it.</div>
              </div>
              <input
                type="password"
                value={key}
                onChange={e => setKey(e.target.value)}
                onKeyDown={e => {if (e.key === 'Enter') runDecrypt(key)}}
                placeholder='Password'
                className='max-w-full p-2 bg-transparent border border-gray-400 rounded dark:border-gray-700'
              />
              <button className="btn" onClick={() => {
                runDecrypt(key)
              }}>Submit</button>
            </div>
          </>
        : null
      }

      {/* render decrypted post contents */}
      {
        decryptedHTML ? <div className="markdown-container" dangerouslySetInnerHTML={{ __html: decryptedHTML }} />
        : null
      }
    </>
  )
}

Building the site

The above components work for dev mode. For production builds, there’s a little extra work to do to ensure the files are copied into dist/

rm ./public/encrypted/*.json && astro check && astro build && cp -r ./public/encrypted ./dist/encrypted

Aside: I wish Astro had a good way of creating adhoc asset files that could be loaded on the client side. My solution here is a bit of a hack, but it works for now. I see a previous RFC about an API to allow creation of files like this but doesn’t look like it landed anywhere.

A final note

This should be pretty secure, I’ve done quite a bunch of research in the crypto space (I find this stuff fun) and have done graduate-level crypto classes in the past. That said, I’m not a professional cryptographer, so I can’t guarantee that this is 100% secure. If you’re going to use this in production, I’d recommend getting a professional audit of its security done.

For my purposes of protecting blog posts that will eventually be public, this is way more than enough.

If there are any professional cryptographers reading this, I’d love to hear your thoughts on this implementation, and if there’s any vulnerabilities I’ve missed!

Future Enhancements

This could be extended to support multiple passphrases or users (Username / password) pairs by encrypting the actual key with a user specific key per user/passphrase combo.

References

I used the following resources while writing this:

Change Log

  • 5/12/2024 - Initial Revision

Found a typo or technical problem? file an issue!