Tutorial·  

How to use Libsodium in Cloudflare Workers

Learn how to leverage NaCL to crypto box seal values, useful when working with GitHub repository secrets.

While working on the NuxtHub's GitHub app and action, we decided to synchronise the environment variables and secrets to the repository so both the runtime and build environments would be similar.

The problem

When creating repository secrets with the GitHub REST API, you need to encrypt them using the Libsodium library.

import libsodium from 'libsodium-wrappers'

// Check if libsodium is ready and then proceed.
await sodium.ready

const publicKey = 'repositoryProductionPublicKeyAsBase64'
// Convert base64 key to Uint8Array
const binaryPublicKey = Uint8Array.from(Buffer.from(publicKey.data.key, 'base64'))
// Convert string value to Uint8Array
const binarySecretValue = new TextEncoder().encode('my-secret-value')
// Encrypt with libsodium
const encryptedBytes = libsodium.crypto_box_seal(binaryValue, binaryKey)
// Convert to base64
const encryptedBase64 = Buffer.from(encryptedBytes).toString('base64')

// Call GitHub API with the encryptedBase64 value

By trying the libsodium-wrappers NPM library, we quickly hit the TypeError: Cannot read properties of undefined (reading 'href') error when awaiting .ready .

We decided to not hack around it like suggested here.

Inside Libsodium: NaCl

I decided to checkout the Libsodium documentation which mentions that the library is a portable, cross-compilable, installable, and package-able fork of NaCl, with a compatible but extended API to improve usability even further.

As we only use the crypto_box_seal function from Libsodium, I believed that we could directly use a compatible NaCl library that would work on edge runtimes.

This is how I found the TweetNaCl library that works perfectly in Cloudflare Workers.

One problem: tweetnacl does not have a crypto_box_seal method, so what to do?

Learning about Sealed Boxes

Libsodium did a great job explaining the Sealed boxes concept.

Sealed boxes are designed to anonymously send messages to a recipient given their public key.

Only the recipient can decrypt these messages using their private key. While the recipient can verify the integrity of the message, they cannot verify the identity of the sender.

A message is encrypted using an ephemeral key pair, with the secret key being erased right after the encryption process.:br
Without knowing the secret key used for a given message, the sender cannot decrypt the message later. Furthermore, without additional data, a message cannot be correlated with the identity of its sender.

The documentation also gives us the algorithm implementation details:

ephemeral_pk ‖ box(m, recipient_pk, ephemeral_sk, nonce=blake2b(ephemeral_pk ‖ recipient_pk))

We can re-create the crypto box seal algorithm with NaCL:

  1. Generate an ephemeral key pair using nacl.box.keyPair()
  2. Derive the nonce using Blake2b cryptographic hash function
  3. Encrypt the message with nacl.box() by combining the ephemeral secret key and the repository public key
  4. Combine the ephemeral public key and our encrypted message

The Solution

To create our edge-compatible crypto_box_seal function using tweetnacl and blakejs.

First, we need to install the dependencies:

Terminal
npm i tweetnacl blakejs

Then create the cryptoBoxSeal and deviceNonce methods in a Nuxt server util:

server/utils/crypto.ts
import nacl from 'tweetnacl'
import { blake2b } from 'blakejs'

// Function to derive the nonce using Blake2b
function deriveNonce(ephemeralPublicKey: Uint8Array, recipientPublicKey: Uint8Array) {
  // Concatenate ephemeralPublicKey ‖ recipientPublicKey
  const input = new Uint8Array(
    ephemeralPublicKey.length + recipientPublicKey.length
  )
  input.set(ephemeralPublicKey, 0)
  input.set(recipientPublicKey, ephemeralPublicKey.length)

  // Derive the nonce using Blake2b
  return blake2b(input, undefined, nacl.box.nonceLength)
}

export function cryptoBoxSeal(message: Uint8Array, recipientPublicKey: Uint8Array) {
  // Generate ephemeral key pair
  const ephemeralKeyPair = nacl.box.keyPair()

  // Derive the nonce using Blake2b
  const nonce = deriveNonce(ephemeralKeyPair.publicKey, recipientPublicKey)
  // Encrypt the message using the ephemeral secret key and recipient's public key
  const encryptedMessage = nacl.box(
    message,
    nonce,
    recipientPublicKey,
    ephemeralKeyPair.secretKey
  )

  // Combine the ephemeral public key and encrypted message
  const sealedBox = new Uint8Array(
    ephemeralKeyPair.publicKey.length + encryptedMessage.length
  )

  // Similar signature from libsodium
  // ephemeral_pk ‖ box(m, recipient_pk, ephemeral_sk, nonce=blake2b(ephemeral_pk ‖ recipient_pk))
  sealedBox.set(ephemeralKeyPair.publicKey, 0)
  sealedBox.set(encryptedMessage, ephemeralKeyPair.publicKey.length)

  return sealedBox
}

Finally, we can replace our example above to use our new helper:

example.ts
- // Encrypt with libsodium
- const encryptedBytes = libsodium.crypto_box_seal(binaryValue, binaryKey)
+ // Encrypt using cryptoBoxSeal
+ const encryptedBytes = cryptoBoxSeal(binaryValue, binaryPublicKey)

That's it! This was a good opportunity to learn more on how encrypting secrets work for GitHub and not be afraid on diving into how librairies work under the hood.

Start with NuxtHub today