ASVS Level 1 Cryptography in Node.js and Go

Series OWASP ASVS 5.0 Level 1 Part 13/13 All parts

Chapter V11 Cryptography of the OWASP Application Security Verification Standard has three Level 1 requirements, and none of them ask you to design anything. They ask you to stop using four specific things: the ECB block mode, PKCS#1 v1.5 padding, unauthenticated encryption, and MD5. Every one of them is already in a library you have installed, one argument away from being chosen by accident.

This part covers all three with runnable code in Node.js and in Go. Pick your stack once and the whole article follows it. Every control is a broken version next to a fixed version, one small change apart, and three of the four failures are visible in the output rather than merely explained.

There is no web framework here. These are short command line programs, because the mistakes live in the cryptography calls and not in the request handling.

Conceptual Overview

A block cipher encrypts exactly one block at a time. AES handles 16 bytes. Everything longer needs a mode of operation, which is the rule for turning one 16-byte operation into a message of any length. The mode is where the security lives, and picking the wrong one undoes the cipher completely.

ECB is the mode that does nothing. Electronic Code Book splits the message into blocks and encrypts each one on its own. The same 16 bytes of input always produce the same 16 bytes of output, so the shape of your data survives encryption. This is the reason the famous encrypted penguin image is still recognisably a penguin.

Encryption is not integrity. A classic mode such as CBC hides the content but does nothing to prove it was not edited. An attacker who cannot read your ciphertext can still change it in ways that produce a predictable change in the plaintext. Authenticated encryption, of which AES-GCM is the common example, produces a short authentication tag alongside the ciphertext, and decryption fails outright if either has been touched.

Padding schemes are part of the algorithm choice. RSA cannot encrypt a raw message, so it pads first. PKCS#1 v1.5 padding has been broken since 1998 (the Bleichenbacher attack), because a server that behaves differently for a badly padded message leaks one bit at a time until the whole message falls out. OAEP replaced it.

A hash function is only useful while collisions are hard. A collision is two different inputs with the same digest. MD5 collisions can be produced in seconds on a laptop, which means an MD5 digest no longer identifies a file. Step 5 shows two different files with the same MD5 sum, using a pair published by researchers over twenty years ago.

Prerequisites

Step 1: Set Up a Scratch Project

Nothing in this article needs a dependency. Both standard libraries ship everything used here.

mkdir -p ~/asvs-v11/node
cd ~/asvs-v11/node
npm init -y
npm pkg set type=module

Each example is one file, run with node ecb-bad.mjs.

mkdir -p ~/asvs-v11/go
cd ~/asvs-v11/go
go mod init asvs-v11

Each example is its own cmd/ directory. Build them all at once and run them from bin/:

go build -o bin/ ./...
./bin/ecb-bad

The examples use a fixed key of thirty-two identical bytes so that your output matches the output printed here. Real keys come from a key management service or from crypto.randomBytes and crypto/rand, and never from a constant in the source.

Step 2: Stop Using ECB

V11.3.1 Verify that insecure block modes (e.g., ECB) and weak padding schemes (e.g., PKCS#1 v1.5) are not used.

The plaintext below is three 16-byte records from a payroll export. Two of them are identical.

Create ecb-bad.mjs:

import { createCipheriv } from 'node:crypto'

const key = Buffer.alloc(32, 7)
const plaintext = Buffer.from(
  'SALARY 0000120  ' + 'SALARY 0000120  ' + 'SALARY 9800000  ',
)

const cipher = createCipheriv('aes-256-ecb', key, null)
const out = Buffer.concat([cipher.update(plaintext), cipher.final()])

for (let i = 0; i < 48; i += 16) {
  console.log(out.subarray(i, i + 16).toString('hex'))
}

Copy the file to ecb-good.mjs. Two changes: the mode becomes aes-256-gcm, and GCM needs a nonce, a number used once per message:

import { createCipheriv, randomBytes } from 'node:crypto'

const nonce = randomBytes(12)
const cipher = createCipheriv('aes-256-gcm', key, nonce)
const out = Buffer.concat([cipher.update(plaintext), cipher.final()])

Create cmd/ecb-bad/main.go. Go’s standard library does not provide an ECB mode at all, so the loop has to be written by hand. Treat that as the standard library telling you something:

package main

import (
	"crypto/aes"
	"encoding/hex"
	"fmt"
	"log"
)

func main() {
	key := make([]byte, 32)
	for i := range key {
		key[i] = 7
	}
	plaintext := []byte("SALARY 0000120  " + "SALARY 0000120  " + "SALARY 9800000  ")

	block, err := aes.NewCipher(key)
	if err != nil {
		log.Fatal(err)
	}

	out := make([]byte, len(plaintext))
	for i := 0; i < len(plaintext); i += 16 {
		block.Encrypt(out[i:i+16], plaintext[i:i+16])
	}

	for i := 0; i < len(out); i += 16 {
		fmt.Println(hex.EncodeToString(out[i : i+16]))
	}
}

Copy the directory to cmd/ecb-good. The hand-written loop is replaced by a mode that needs a nonce, a number used once per message:

	aead, err := cipher.NewGCM(block)
	if err != nil {
		log.Fatal(err)
	}

	nonce := make([]byte, aead.NonceSize())
	rand.Read(nonce)
	out := aead.Seal(nil, nonce, plaintext, nil)

The broken version prints the same ciphertext twice:

115c38c12b430856b66792a0f2a25fd8
115c38c12b430856b66792a0f2a25fd8
8101e702a50e37c8bef390d7815ee4c9

Anybody holding that file learns two things without a key: which records are identical, and that the third one is different. Applied to a database column of salaries, statuses, or country codes, that is most of the information the column carries. The fixed version repeats nothing:

eb74a541119a83f2b6a8ad430339c96b
ce4a40b87023ee64d7efdeb1343c6064
f88ffd3cce995857bf6e7f5ce12ac985

Search your code for the string ecb in any casing. It appears in cipher names such as AES/ECB/PKCS5Padding and aes-128-ecb, and it is never the right answer.

Step 3: Replace PKCS#1 v1.5 With OAEP

The second half of V11.3.1 names a padding scheme rather than a mode. RSA encryption pads the message before the mathematics happens, and the padding scheme decides whether an attacker can learn anything from how you react to a malformed ciphertext.

This is the one control in this article with no output to look at. A Bleichenbacher attack needs thousands of requests against a live decryption service, and the difference between the two versions is a single argument. Check it by reading the code, not by running it.

Create rsa-bad.mjs:

import { generateKeyPairSync, publicEncrypt, privateDecrypt, constants } from 'node:crypto'

const { publicKey, privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 })
const message = Buffer.from('card 4111111111111111')

const box = publicEncrypt(
  { key: publicKey, padding: constants.RSA_PKCS1_PADDING },
  message,
)
const back = privateDecrypt(
  { key: privateKey, padding: constants.RSA_PKCS1_PADDING },
  box,
)

console.log('padding    : RSA_PKCS1_PADDING')
console.log('ciphertext :', box.length, 'bytes')
console.log('decrypted  :', back.toString())

Copy the file to rsa-good.mjs and change the padding on both calls:

const box = publicEncrypt(
  { key: publicKey, padding: constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha256' },
  message,
)
const back = privateDecrypt(
  { key: privateKey, padding: constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha256' },
  box,
)

Create cmd/rsa-bad/main.go:

package main

import (
	"crypto/rand"
	"crypto/rsa"
	"fmt"
	"log"
)

func main() {
	key, err := rsa.GenerateKey(rand.Reader, 2048)
	if err != nil {
		log.Fatal(err)
	}
	message := []byte("card 4111111111111111")

	box, err := rsa.EncryptPKCS1v15(rand.Reader, &key.PublicKey, message)
	if err != nil {
		log.Fatal(err)
	}
	back, err := rsa.DecryptPKCS1v15(rand.Reader, key, box)
	if err != nil {
		log.Fatal(err)
	}

	fmt.Println("padding    : PKCS#1 v1.5")
	fmt.Println("ciphertext :", len(box), "bytes")
	fmt.Println("decrypted  :", string(back))
}

Copy the directory to cmd/rsa-good and change the two calls. OAEP takes a hash function, which is why crypto/sha256 joins the imports:

	box, err := rsa.EncryptOAEP(sha256.New(), rand.Reader, &key.PublicKey, message, nil)
	if err != nil {
		log.Fatal(err)
	}
	back, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, key, box, nil)
	if err != nil {
		log.Fatal(err)
	}

Both versions print the same thing, which is exactly the problem:

ciphertext : 256 bytes
decrypted  : card 4111111111111111

A test suite cannot tell them apart. Grep for the names instead: RSA_PKCS1_PADDING, PKCS1v15, and PKCS1Padding. Note that SignPKCS1v15 is a different function and is not banned by this requirement; the attack is on decryption. If you are choosing today, use RSA-PSS for signatures anyway, or use an Ed25519 key and stop thinking about padding.

Step 4: Authenticate What You Encrypt

V11.3.2 Verify that only approved ciphers and modes such as AES with GCM are used.

CBC is not ECB, and it does not leak repeated blocks. What it does not do is stop an attacker from editing the message. This example encrypts a session record, then changes the role inside it without the key.

The trick is how CBC decrypts: each block of plaintext is exclusive-ored with the previous block of ciphertext. Flip a bit in ciphertext block 1 and you flip the matching bit in plaintext block 2. Block 1 itself turns into random noise, which the attacker does not care about.

Create cbc-bad.mjs:

import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'

const key = Buffer.alloc(32, 7)
const iv = randomBytes(16)
const plaintext = Buffer.from('session=demo    user=alice      role=member     ')

const cipher = createCipheriv('aes-256-cbc', key, iv)
const box = Buffer.concat([cipher.update(plaintext), cipher.final()])

// The attacker knows the format, so it knows block 2 and what it wants instead.
const known = Buffer.from('role=member     ')
const wanted = Buffer.from('role=admin      ')
for (let i = 0; i < 16; i++) {
  box[16 + i] ^= known[i] ^ wanted[i]
}

const decipher = createDecipheriv('aes-256-cbc', key, iv)
const out = Buffer.concat([decipher.update(box), decipher.final()])
console.log('block 0:', out.subarray(0, 16).toString())
console.log('block 1:', out.subarray(16, 32).toString('hex'))
console.log('block 2:', out.subarray(32, 48).toString())

Copy the file to gcm-good.mjs. The cipher becomes GCM, the initialisation vector becomes a 12-byte nonce, and decryption now needs the authentication tag:

const cipher = createCipheriv('aes-256-gcm', key, nonce)
const box = Buffer.concat([cipher.update(plaintext), cipher.final()])
const tag = cipher.getAuthTag()

for (let i = 0; i < 16; i++) {
  box[32 + i] ^= known[i] ^ wanted[i]
}

const decipher = createDecipheriv('aes-256-gcm', key, nonce)
decipher.setAuthTag(tag)
try {
  const out = Buffer.concat([decipher.update(box), decipher.final()])
  console.log(JSON.stringify(out.toString()))
} catch (err) {
  console.log('rejected:', err.message)
}

Create cmd/cbc-bad/main.go:

package main

import (
	"crypto/aes"
	"crypto/cipher"
	"crypto/rand"
	"encoding/hex"
	"fmt"
	"log"
)

func main() {
	key := make([]byte, 32)
	for i := range key {
		key[i] = 7
	}
	plaintext := []byte("session=demo    user=alice      role=member     ")

	block, err := aes.NewCipher(key)
	if err != nil {
		log.Fatal(err)
	}
	iv := make([]byte, 16)
	rand.Read(iv)

	box := make([]byte, len(plaintext))
	cipher.NewCBCEncrypter(block, iv).CryptBlocks(box, plaintext)

	// The attacker knows the format, so it knows block 2 and what it wants instead.
	known := []byte("role=member     ")
	wanted := []byte("role=admin      ")
	for i := 0; i < 16; i++ {
		box[16+i] ^= known[i] ^ wanted[i]
	}

	out := make([]byte, len(box))
	cipher.NewCBCDecrypter(block, iv).CryptBlocks(out, box)
	fmt.Println("block 0:", string(out[0:16]))
	fmt.Println("block 1:", hex.EncodeToString(out[16:32]))
	fmt.Println("block 2:", string(out[32:48]))
}

Copy the directory to cmd/gcm-good. Seal replaces the encrypter and Open replaces the decrypter, and Open returns an error instead of a plaintext when the message has been edited:

	aead, err := cipher.NewGCM(block)
	if err != nil {
		log.Fatal(err)
	}
	nonce := make([]byte, aead.NonceSize())
	rand.Read(nonce)

	box := aead.Seal(nil, nonce, plaintext, nil)

	for i := 0; i < 16; i++ {
		box[32+i] ^= known[i] ^ wanted[i]
	}

	out, err := aead.Open(nil, nonce, box, nil)
	if err != nil {
		fmt.Println("rejected:", err)
		return
	}
	fmt.Println(string(out))

The CBC version decrypts without complaint, and the record now says admin:

block 0: session=demo    
block 1: 2a6e69eba67bc6877ddb2293e5fd80df
block 2: role=admin      

Block 1 is different on every run, because it is the wreckage of the block the attacker sacrificed. An application that reads role= out of that string never looks at it.

The GCM version refuses to decrypt at all:

rejected: Unsupported state or unable to authenticate data
rejected: cipher: message authentication failed

One rule comes with GCM and it matters more than the mode itself: never use the same nonce twice with the same key. Two messages sharing a nonce leak the difference between their plaintexts and, worse, let an attacker forge the tag. Generate a random 12-byte nonce per message, store it next to the ciphertext, and never derive it from something that repeats such as a user identifier or a row number.

Step 5: Retire MD5

V11.4.1 Verify that only approved hash functions are used for general cryptographic use cases, including digital signatures, HMAC, KDF, and random bit generation. Disallowed hash functions, such as MD5, must not be used for any cryptographic purpose.

Here is a pair of 128-byte files published by Xiaoyun Wang’s team in 2004. They are not the same file. Write them out:

base64 -d > wang1.bin <<'END'
0THdAsXm7sRpPZoGmK/5XC/KtYcSRn6rQARYPrj7f4lVrTQGCfSzAoPkiIMlcUFaCFEl6PfNyZ/Z
Hb3ygDc8W9iCPjFWNI9brm2s1DbJGcbdU+K0h9oD/QI5YwbSSM2g6Z8zQg9XfujOVLZwgKgNHsaY
Iby2qIOTlvllK2/3KnA=
END
base64 -d > wang2.bin <<'END'
0THdAsXm7sRpPZoGmK/5XC/KtQcSRn6rQARYPrj7f4lVrTQGCfSzAoPkiIMl8UFaCFEl6PfNyZ/Z
Hb1ygDc8W9iCPjFWNI9brm2s1DbJGcbdU+I0h9oD/QI5YwbSSM2g6Z8zQg9XfujOVLZwgCgNHsaY
Iby2qIOTlvllq2/3KnA=
END
cmp wang1.bin wang2.bin
md5sum wang1.bin wang2.bin
wang1.bin wang2.bin differ: char 20, line 1
79054025255fb1a26e4bc422aef54eb4  wang1.bin
79054025255fb1a26e4bc422aef54eb4  wang2.bin

Two different files, one MD5 sum. Now build the check that trusts it: a program that approves a file if its digest matches the one recorded when the file was reviewed.

Create hash-bad.mjs:

import { createHash } from 'node:crypto'
import { readFileSync } from 'node:fs'

const approved = '79054025255fb1a26e4bc422aef54eb4'

const file = process.argv[2]
const digest = createHash('md5').update(readFileSync(file)).digest('hex')

console.log(file, digest, digest === approved ? 'ACCEPTED' : 'REJECTED')

Copy the file to hash-good.mjs. Change the algorithm, and record the digest that algorithm produces:

const approved = '8d12236e5c4ed9f4e790db4d868fd5c399df267e18ff65c1107c328228cffc98'

const digest = createHash('sha256').update(readFileSync(file)).digest('hex')

Create cmd/hash-bad/main.go:

package main

import (
	"crypto/md5"
	"encoding/hex"
	"fmt"
	"log"
	"os"
)

const approved = "79054025255fb1a26e4bc422aef54eb4"

func main() {
	data, err := os.ReadFile(os.Args[1])
	if err != nil {
		log.Fatal(err)
	}

	sum := md5.Sum(data)
	digest := hex.EncodeToString(sum[:])

	verdict := "REJECTED"
	if digest == approved {
		verdict = "ACCEPTED"
	}
	fmt.Println(os.Args[1], digest, verdict)
}

Copy the directory to cmd/hash-good. Change the import, the call, and the recorded digest:

	"crypto/sha256"

const approved = "8d12236e5c4ed9f4e790db4d868fd5c399df267e18ff65c1107c328228cffc98"

	sum := sha256.Sum256(data)

Run both programs against both files:

wang1.bin 79054025255fb1a26e4bc422aef54eb4 ACCEPTED
wang2.bin 79054025255fb1a26e4bc422aef54eb4 ACCEPTED
wang1.bin 8d12236e5c4ed9f4e790db4d868fd5c399df267e18ff65c1107c328228cffc98 ACCEPTED
wang2.bin b9fef2a8fc93b05e7701e97196fda6c4fbeea25ff8e64fdfee7015eca8fa617d REJECTED

The MD5 check approved a file nobody reviewed. Replace md5 with sha256 anywhere a digest is used to decide something: signature verification, HMAC, key derivation, artifact allowlists, cache keys that cross a trust boundary, deduplication of files uploaded by users.

SHA-1 belongs in the same bin. Its first collision was published in 2017 and it has been forbidden for signatures since. The approved general purpose choices today are SHA-256, SHA-384, SHA-512, and the SHA-3 family.

Common Mistakes and Troubleshooting

Reusing a GCM nonce. This is the one way to make GCM worse than CBC. If you cannot guarantee a unique nonce, use a scheme designed to survive repeats, such as XChaCha20-Poly1305 with a 24-byte random nonce.

Encrypting a password instead of hashing it. Nothing in this chapter applies to password storage. Encryption is reversible by design, which is precisely what you do not want. Use Argon2id, covered in Part 7.

Assuming MD5 is fine “because it is only a checksum”. It is fine for detecting accidental corruption, such as a truncated download. It is not fine the moment an attacker gets to choose the file, which includes every artifact allowlist, every uploaded file deduplication, and every integrity check on something fetched over a network.

Storing the ciphertext without the nonce or tag. Decryption needs all three. Store them together in one field, for example nonce, then ciphertext, then tag, and write the layout down.

Using the default padding argument by accident. Node’s publicEncrypt uses OAEP with SHA-1 when you pass no options at all, and Go’s EncryptOAEP makes you name the hash. Name it explicitly in both, and name SHA-256.

Confusing the initialisation vector with a key. An IV or a nonce is not secret and is stored next to the ciphertext. It just has to be unpredictable (CBC) or unique (GCM).

Best Practices

Use one encryption helper for the whole application. One encrypt(plaintext) and one decrypt(box) with the mode, the nonce handling, and the key source decided once. Every place that calls createCipheriv directly is a place that can choose wrongly.

Prefer a library that gives you no choices. libsodium through sodium-native, or Go’s crypto/cipher with GCM and nothing else exposed, removes the whole category of “which mode” bugs.

Grep for the banned names in continuous integration. A pipeline step that fails on ecb, md5, sha1, and PKCS1_PADDING in application source costs nothing and catches the copied snippet before review does.

Record which algorithm encrypted each stored value. A one-byte version prefix on the ciphertext makes it possible to migrate later without guessing.

Rotate keys on a schedule and keep them out of the repository. A key in the source is not an encryption problem, it is a configuration problem, and it undoes everything above.

Do not write your own mode. The Go ECB loop in Step 2 is six lines and it is completely broken. That is the shape of most home-made cryptography.

Conclusion

Chapter V11 at Level 1 is three requirements and four things to stop doing. You saw ECB print the same ciphertext for the same plaintext, swapped PKCS#1 v1.5 for OAEP with SHA-256, watched an attacker rewrite a CBC-encrypted session record without the key while GCM refused the same edit, and watched two different files pass one MD5 check.

None of these fixes were hard. They were all one argument, one function name, or one algorithm name, which is what makes them easy to get wrong in the first place and easy to close in an afternoon. Mark V11.3.1, V11.3.2, and V11.4.1 as passed in your own record.

Part 14 covers chapter V12 Secure Communication, which is the other half of this story: the same choices, made for data in transit rather than data at rest.

Requirement text quoted from the OWASP Application Security Verification Standard 5.0.0, used under CC BY-SA 4.0. The colliding file pair is from the 2004 MD5 collision published by Xiaoyun Wang, Dengguo Feng, Xuejia Lai, and Hongbo Yu.

All tutorials →

Latest Tutorials

Support this site