Nathan Lamont

Notes to Self

JavaScript crypto

You were looking for a way to incorporate libsodium, including using it in a web worker. The purpose is to be able to have concealed (secret) content on a static site (this site, Notes to Self), where the content needs to be served to anonymous visitors, but only readable by visitors with a given password.

However, you believe that the last time you incorporated libsodium (~4 years ago?) it may have been more complex. For some reason you were not using/able to use an npm package.

Your initial naïve impulse was to use a password to generate a symmetric key — but somehow having that decoded symmetric key sitting in the client's possession seemed reckless. Eventually you realized that libsodium can do exactly what you want — indeed is designed to — and calls this Authenticated Encryption.

The example code (in C, copied below) does not quite match the description they provide.

#define MESSAGE (const unsigned char *) "test"
#define MESSAGE_LEN 4
#define CIPHERTEXT_LEN (crypto_box_MACBYTES + MESSAGE_LEN)

unsigned char alice_publickey[crypto_box_PUBLICKEYBYTES];
unsigned char alice_secretkey[crypto_box_SECRETKEYBYTES];
crypto_box_keypair(alice_publickey, alice_secretkey);

unsigned char bob_publickey[crypto_box_PUBLICKEYBYTES];
unsigned char bob_secretkey[crypto_box_SECRETKEYBYTES];
crypto_box_keypair(bob_publickey, bob_secretkey);

unsigned char nonce[crypto_box_NONCEBYTES];
unsigned char ciphertext[CIPHERTEXT_LEN];
randombytes_buf(nonce, sizeof nonce);
if (crypto_box_easy(ciphertext, MESSAGE, MESSAGE_LEN, nonce,
                    bob_publickey, alice_secretkey) != 0) {
    /* error */
}

unsigned char decrypted[MESSAGE_LEN];
if (crypto_box_open_easy(decrypted, ciphertext, CIPHERTEXT_LEN, nonce,
                         alice_publickey, bob_secretkey) != 0) {
    /* message for Bob pretending to be from Alice has been forged! */
}

What that code shows is Alice encoding a message for Bob. Alice uses her private key and Bob's public to encrypt it. Bob uses Alice's public key and his private to decrypt it.

The key pairs can be generated from a seed value, so a password can be hashed to create a seed pair. That's in the client. The client will necessarily possess a private and public key, as well as the generator's public key. But the client keys don't exist until the user types in a password — any password. Only the correct pair, the pair with the public key that matches the public key used to encrypt the content, will allow the client to decrypt the content.

Your JavaScript version looked like this:

// create password hashes (to create deterministic key pairs)
const clientPasswordHash = sodium.crypto_pwhash(
  sodium.crypto_box_SEEDBYTES,
  'client-passw0rd',
  sodium.from_base64(this.salt),
  sodium.crypto_pwhash_OPSLIMIT_INTERACTIVE,
  sodium.crypto_pwhash_MEMLIMIT_INTERACTIVE,
  sodium.crypto_pwhash_ALG_DEFAULT
)
const generatorPasswordHash = sodium.crypto_pwhash(
  sodium.crypto_box_SEEDBYTES,
  'generator-passw0rd',
  sodium.from_base64(this.salt),
  sodium.crypto_pwhash_OPSLIMIT_INTERACTIVE,
  sodium.crypto_pwhash_MEMLIMIT_INTERACTIVE,
  sodium.crypto_pwhash_ALG_DEFAULT
)
const client = sodium.crypto_box_seed_keypair(clientPasswordHash)
const generator = sodium.crypto_box_seed_keypair(generatorPasswordHash)
const nonce = sodium.randombytes_buf(sodium.crypto_box_NONCEBYTES)
const box = sodium.crypto_box_detached(
  'the text to encrypt and keep secret,
  nonce,
  client.publicKey,
  generator.privateKey
)
const opened = sodium.crypto_box_open_detached(
  box.ciphertext,
  box.mac,
  nonce,
  generator.publicKey,
  client.privateKey,
  'text'
)

Why are you using the "detached" flavor of the function instead of "easy?" You want to pack the nonce, mac, and encrypted text yourself. The final version looked like this:

generator.js

function concatBuffers(b1, b2) {
  const tmp = new Uint8Array(b1.byteLength + b2.byteLength)
  tmp.set(new Uint8Array(b1), 0)
  tmp.set(new Uint8Array(b2), b1.byteLength)
  return tmp
}

const salt = sodium.from_base64(config.salt)
const generatorPasswordHash = sodium.crypto_pwhash(
  sodium.crypto_box_SEEDBYTES,
  config.generatorPassword,
  salt,
  sodium.crypto_pwhash_OPSLIMIT_INTERACTIVE,
  sodium.crypto_pwhash_MEMLIMIT_INTERACTIVE,
  sodium.crypto_pwhash_ALG_DEFAULT
)
const generator = sodium.crypto_box_seed_keypair(generatorPasswordHash)
const clientKey = sodium.from_base64(config.clientPublicKey)
// ... later: for each post, p
const nonce = sodium.randombytes_buf(sodium.crypto_box_NONCEBYTES)

const box = sodium.crypto_box_detached(
  p.html,
  nonce,
  clientKey,
  generator.privateKey
)
const header = concatBuffers(nonce, box.mac)
p.encryptedBody = sodium.to_base64(concatBuffers(header, box.ciphertext))

decrypt.js

// for some post p
let value = sodium.from_base64(p.encryptedBody)
const nonce = value.slice(0, sodium.crypto_box_NONCEBYTES)
const mac = value.slice(
  sodium.crypto_box_NONCEBYTES,
  sodium.crypto_box_NONCEBYTES + sodium.crypto_box_MACBYTES
)
value = value.slice(
  sodium.crypto_box_NONCEBYTES + sodium.crypto_box_MACBYTES
)
try {
  const opened = sodium.crypto_box_open_detached(
    value,
    mac,
    nonce,
    // generator public key
    generator.publicKey,
    this.privateKey,
    'text'
  )
  // opened is now the decrypted password
} catch (e) {
  // something went wrong; bad params or wrong key
}

With the buffer concat function, and subsequent slicing, we are just putting all the pieces together in a single blob to make it easier to pass around.

Not Related to Above Solution, But Still Useful

Symmetric, "Secret-key" cryptography: encrypting and decrypting with a single secret key:

symmetricEncrypt(value, key) {
  const nonce = sodium.randombytes_buf(sodium.crypto_box_NONCEBYTES)
  const encrypted = sodium.crypto_box_easy_afternm(
    value,
    nonce,
    sodium.from_base64(key)
  )
  return sodium.to_base64(this.concatBuffers(nonce, encrypted))
},
symmetricDecrypt(value, key) {
  value = sodium.from_base64(value)
  const nonce = value.slice(0, sodium.crypto_box_NONCEBYTES)
  value = value.slice(sodium.crypto_box_NONCEBYTES)
  return sodium.to_string(
    sodium.crypto_box_open_easy_afternm(
      value,
      nonce,
      sodium.from_base64(key)
    )
  )
},